How to Use Canal to Monitor Table Schema Changes
Canal — a CDC framework built on MySQL binlog — offers native support for detecting not only DML (INSERT/UPDATE/DELETE) but also DDL (CREATE/ALTER/DROP) events. Capturing schema changes in real time is essential for any system that streams data downstream, because unnoticed column additions, type modifications, or index adjustments can cause parsing failures, corrupt downstream stores, or break hard‑coded business logic. This article explains how Canal listens to table‑structure changes, walks through the necessary configuration, shows concrete client implementations (Java and Go), and demonstrates a full pipeline that pushes DDL events to Kafka.
Canal’s architecture consists of three core components:
- Canal Server – connects to MySQL, reads the binary log, and translates raw events into a unified
Entryprotocol. - Canal Client SDKs – Java, Go, Python, etc., pull
Entryobjects from the server and apply custom logic. - Instance – a logical unit inside the server that isolates a specific database or set of tables. Each instance can be tuned with filter rules (
filter.regex) and mode settings.
When MySQL emits a DDL statement, it appears in the binlog as a QUERY event. Canal converts this event into an Entry whose EntryType is ROWDATA and whose RowChange payload has isDdl = true. By checking RowChange.isDdl() on the client side, you can instantly recognise a schema‑change event.
Enabling DDL Capture
Server‑side Settings
Add the following lines to the instance’s instance.properties file:
# Monitor all tables in the `mydb` database
canal.instance.filter.regex = mydb\\..*
# Turn on DDL parsing – the server will emit RowChange entries for DDL
canal.instance.filter.query.dml = true
# Optional: push only DDL events (set to false to receive DML as well)
canal.instance.filter.query.only = false
filter.regexlimits the scope of monitoring, preventing noise from unrelated databases.filter.query.dml = truetells Canal to treat DDL as regular entries rather than discarding them.filter.query.onlyis handy for a dedicated metadata‑sync service that cares solely about schema changes.
Client‑side Detection (Java)
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.CanalConnectors;
import com.alibaba.otter.canal.protocol.CanalEntry.*;
import com.alibaba.otter.canal.protocol.Message;
import java.net.InetSocketAddress;
import java.util.List;
public class DdlListener {
public static void main(String[] args) {
// 1️⃣ Create a connector to the Canal server
CanalConnector connector = CanalConnectors.newSingleConnector(
new InetSocketAddress("127.0.0.1", 11111),
"example", // instance name defined in instance.properties
"", // username (empty for default)
"" // password
);
int batchSize = 1000;
connector.connect();
connector.subscribe(".*\\..*"); // subscribe to all schemas/tables
connector.rollback();
while (true) {
// 2️⃣ Pull a batch of entries
Message message = connector.getWithoutAck(batchSize);
long batchId = message.getId();
int size = message.getEntries().size();
if (batchId == -1 || size == 0) {
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
continue;
}
// 3️⃣ Iterate over entries
for (Entry entry : message.getEntries()) {
if (entry.getEntryType() != EntryType.ROWDATA) {
continue; // skip transaction markers, heartbeat, etc.
}
RowChange rowChange;
try {
rowChange = RowChange.parseFrom(entry.getStoreValue());
} catch (Exception e) {
throw new RuntimeException("Failed to parse RowChange", e);
}
// 4️⃣ Detect DDL
if (rowChange.getIsDdl()) {
String sql = rowChange.getSql(); // original DDL statement
String schema = entry.getHeader().getSchemaName(); // affected database
String table = entry.getHeader().getTableName(); // affected table
System.out.printf("[DDL] %s.%s : %s%n", schema, table, sql);
// 5️⃣ Optional: further parsing (e.g., extract column list) or
// persist the DDL to a metadata store.
}
}
// 6️⃣ Acknowledge the batch
connector.ack(batchId);
}
}
}
Key points
RowChange.getIsDdl()is the flag that distinguishes DDL from ordinary row changes.RowChange.getSql()returns the raw DDL string, which can be logged, audited, or re‑executed on a downstream system.- Header fields (
schemaName,tableName) give you the exact target of the change without parsing the SQL yourself.
Client‑side Detection (Go)
package main
import (
"fmt"
canal "github.com/alibaba/canal-go/client"
"github.com/alibaba/canal-go/protocol"
)
func main() {
conn := canal.NewSimpleCanalConnector("127.0.0.1:11111", "example", "", "")
conn.Connect()
defer conn.Disconnect()
conn.Subscribe(".*\\..*")
for {
msg, err := conn.Get(1000)
if err != nil || msg.Id == -1 {
continue
}
for _, entry := range msg.Entries {
if entry.EntryType != protocol.EntryType_ROWDATA {
continue
}
rowChange, _ := protocol.RowChangeParse(entry.StoreValue)
if rowChange.IsDdl {
fmt.Printf("[DDL] %s.%s : %s\n",
entry.Header.SchemaName,
entry.Header.TableName,
rowChange.Sql)
}
}
conn.Ack(msg.Id)
}
}
Both snippets illustrate the same workflow: connect → subscribe → fetch → filter ROWDATA → check isDdl → act.
End‑to‑End Example: Broadcasting DDL to Kafka
Below is a practical pipeline that captures DDL events with Canal and publishes them to a Kafka topic named ddl-changes. Downstream services can then replay the DDL on other databases, update schema registries, or trigger alerting workflows.
1️⃣ Infrastructure Overview
| Component | Version | Remarks |
|---|---|---|
| MySQL | 5.7+ / 8.0+ | log_bin enabled, binlog_format=ROW |
| Canal Server | 1.1.5 | Official Docker image canal/canal-server |
| Kafka | 2.8+ | Used as the transport layer for DDL |
| Java SDK | 1.1.5 | canal.client Maven dependency |
2️⃣ Docker‑Compose Skeleton
version: '3'
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
command: >
--default-authentication-plugin=mysql_native_password
--log-bin=mysql-bin
--binlog-format=ROW
ports:
- "3306:3306"
canal:
image: canal/canal-server
depends_on:
- mysql
environment:
- canal.instance.master.address=mysql:3306
- canal.instance.dbUsername=root
- canal.instance.dbPassword=root
- canal.instance.filter.regex=mydb\\..*
- canal.instance.filter.query.dml=true
ports:
- "11111:11111"
Running docker-compose up -d brings up a MySQL instance with binlog enabled and a Canal server already listening for changes in the mydb schema.
3️⃣ Kafka Producer (Java)
import org.apache.kafka.clients.producer.*;
import java.util.Properties;
public class DdlKafkaProducer {
private final Producer<String, String> producer;
private final String topic = "ddl-changes";
public DdlKafkaProducer() {
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
// Optional tuning
props.put("linger.ms", "5");
props.put("batch.size", "16384");
producer = new KafkaProducer<>(props);
}
public void send(String schema, String table, String sql) {
String key = schema + "." + table;
ProducerRecord<String, String> record = new ProducerRecord<>(topic, key, sql);
producer.send(record, (metadata, exception) -> {
if (exception != null) {
exception.printStackTrace();
} else {
System.out.printf("DDL sent → %s-%d offset %d%n",
metadata.topic(), metadata.partition(), metadata.offset());
}
});
}
public void close() {
producer.close();
}
}
Integrate the producer into the earlier DdlListener:
DdlKafkaProducer kafkaProducer = new DdlKafkaProducer();
...
if (rowChange.getIsDdl()) {
String sql = rowChange.getSql();
String schema = entry.getHeader().getSchemaName();
String table = entry.getHeader().getTableName();
kafkaProducer.send(schema, table, sql);
}
...
kafkaProducer.close();
4️⃣ Consuming the DDL Stream (Python)
from kafka import KafkaConsumer
consumer = KafkaConsumer(
'ddl-changes',
bootstrap_servers='localhost:9092',
group_id='ddl-sync',
value_deserializer=lambda m: m.decode('utf-8')
)
for msg in consumer:
key = msg.key.decode('utf-8') # schema.table
sql = msg.value
print(f"[{key}] DDL: {sql}")
# Example: execute the DDL on a replica database
# replica_cursor.execute(sql)
With this setup, any ALTER TABLE, CREATE INDEX, or DROP COLUMN executed on the source MySQL instance instantly appears on the ddl-changes topic, ready for downstream consumption.
Common Pitfalls & Tuning Tips
| Symptom | Likely Cause | Remedy |
|---|---|---|
| No DDL events arrive | canal.instance.filter.query.dml left at default false or the regex does not match the target schema |
Verify the property is set to true and test the regex with a simple SHOW TABLES pattern |
RowChange.parseFrom throws |
Binlog format mismatch (e.g., MySQL using STATEMENT instead of ROW) or version incompatibility between MySQL and Canal |
Switch MySQL to binlog_format=ROW and keep Canal version aligned with the MySQL major release |
| High latency when publishing to Kafka | Producer batch size too small or linger.ms set excessively high |
Tune batch.size and linger.ms to balance throughput and latency; enable async callbacks as shown |
| Duplicate DDL messages | Multiple Canal instances subscribe to the same database without deduplication | Use the Kafka key (schema.table) for idempotent processing, or enable Kafka’s Exactly‑once semantics |
| Memory pressure on Canal Server | Unacknowledged batches accumulate, causing the internal buffer to grow | Ensure connector.ack(batchId) is always called, even on exception paths; consider increasing canal.instance.memory.buffer.size if needed |
Performance Nuggets
- Granular Filtering – The more specific the
filter.regex, the fewer irrelevant events Canal has to parse and forward. - Batch Size – A
batchSizeof 500‑2000 offers a good trade‑off between latency and network overhead. - Retry Logic – Wrap Kafka sends and MySQL replays in retry loops with exponential back‑off to survive transient failures.
- Metadata Persistence – Persist captured DDL into a dedicated metadata store (e.g., a separate MySQL schema or a version‑controlled file) for audit trails and offline schema reconstruction.
Recap
- Activate DDL parsing on the Canal server via
filter.query.dml. - Consume entries on the client side and use
RowChange.getIsDdl()to separate schema changes from row changes. - Extract the raw SQL (
RowChange.getSql()) and context (schemaName,tableName). - Push the DDL to downstream systems such as Kafka, where it can be replayed, logged, or used to keep multiple databases in sync.
- Address operational concerns—filtering, batch sizing, error handling, and idempotency—to keep the pipeline robust in production.
By mastering these steps, you can build a CDC pipeline that not only streams data changes but also gracefully handles the evolution of the underlying schema, ensuring downstream services stay in lockstep with the source database’s structure.