1. 为什么动这个念头:Canal到底解决什么问题
先说一个很常见的场景:业务库用MySQL 5.7,线上数据每天几百GB写入量级,后台有几个系统需要实时感知前端业务的变化——比如订单状态变了要同步到另一套库,或者用户改了资料要刷新缓存。最原始的做法是业务代码里双写,订单表update完再加一个逻辑去同步、去清缓存。这玩意儿短期能用,但一旦出现漏写、重试、事务不一致,排查起来非常酸爽。而且很多同步场景根本不是你能控制的:别人部门的业务表、日志表,你总不能逼人家改代码?
这时候就需要一种“旁观者清”的方案:不碰业务代码,直接从MySQL的binlog里把数据变化捞出来,再推给下游。这个思路就是增量数据捕获(CDC, Change Data Capture),而Canal就是这一领域在MySQL侧最成熟的开源实现。
Canal是阿里开源的中间件,核心逻辑是伪装成MySQL的从库,向Master发送dump协议请求,主库把binlog推给Canal,Canal把binlog解析成结构化的数据变更事件,再交给客户端或者消息队列。在MySQL 5.7环境下,这套链路非常稳定,因为5.7的binlog能力和复制协议已经相当成熟。这篇文章我会把从“开启binlog”到“客户端成功消费一行数据”的完整链路走一遍,适合正在做数据同步、缓存刷新、异构数据库迁移的开发者参考,也顺便帮你避开我当年踩过的几个坑。
2. MySQL 5.7的binlog机制:先搞懂Canal在解析什么东西
2.1 binlog三种格式的区别,以及为什么Canal只推荐ROW
MySQL 5.7的binlog有三种格式:STATEMENT、ROW、MIXED。STATEMENT记录的是SQL语句本身,假设一条UPDATE影响1万行,binlog里就存一条语句,备库重放执行一遍。优点是日志量小,缺点是非确定性函数、主键顺序变化等问题会导致主从数据不一致。
ROW格式则记录每一行数据的实际变化,UPDATE一万行就有一万条变更记录,每条记录包含变更前和变更后的完整行镜像。日志量大,但最精确、最可靠。MIXED是MySQL自己判断:默认用STATEMENT,遇到不确定操作再切到ROW。
Canal为什么不支持STATEMENT?因为Canal的定位是“逻辑解析”,它要把变更还原成结构化的字段级数据,比如告诉你“id=123这行的name字段从A改成B了”。STATEMENT格式下Canal无法可靠还原每一行的前后值——它拿到的只是一句SQL,得自己再模拟执行一遍才知道结果,这既不安全也不现实。所以Canal强制要求使用ROW格式,如果你的MySQL还在用默认的STATEMENT或MIXED,Canal要么启动报错,要么解析出来的数据是空的。
binlog_row_image这个参数也得注意。MySQL 5.7支持FULL、MINIMAL、NOBLOB三种行镜像模式。Canal要拿到完整字段的前后值,必须设置binlog_row_image=FULL(默认就是FULL)。如果你为了省日志空间改成MINIMAL,那binlog里只记录被修改的列,Canal解析出来的字段信息会缺失,下游同步就会出问题。
2.2 GTID与位点:Canal靠什么定位到“读到哪里了”
Canal要像从库一样拉binlog,就必须告诉主库“从哪个位置开始拉”。MySQL的binlog位点由两部分组成:binlog文件名和偏移量,比如mysql-bin.000001:1245。Canal启动时会先去实例配置文件里找位点,找不到就请求主库拿当前最新的位点,或者从你指定的位点开始。
如果你开了GTID(全局事务标识符),Canal也可以基于GTID来定位,这样更精确,主从切换后也不容易乱。我个人的建议是:如果主要是为了让Canal工作,GTID不是必须的,但开了之后确实省心一些。MySQL 5.7里GTID默认是关闭的,开启需要修改gtid_mode=ON、enforce_gtid_consistency=ON,然后重启实例。注意5.7改gtid_mode不能一次性从OFF直接改ON,需要按照官方文档经过ON_PERMISSIVE、ON_IMPLICIT_PERMISSIVE逐步切换,否则会报错。如果你不想折腾GTID,直接用binlog位点配置,只要主库不做过大数据目录清理,Canal照样稳定。
2.3 server-id冲突:这是最隐蔽的坑
Canal伪装成从库之后,主库眼里它就只是一个普通的复制客户端。MySQL的主从复制要求每个复制客户端的server-id不能一样,否则主库会报错并把后连的那个踢掉。如果你的MySQL实例已经有真实从库,且server-id=1,然后Canal的配置里也写成1,那么Canal一连接,真实从库或者Canal就会被主库断掉。
一个稳妥的做法:给Canal分配一个独立的server-id,比如102、103这样的高位数字,避开已有的从库。这个id配置在Canal的canal.properties里(canal.instance.mysql.slaveId),不是改MySQL的参数,别搞混。我见过有人把这个配置理解成MySQL的server_id,然后去改my.cnf,结果主库重启,真实从库全断了,事故级别还蛮高。
3. MySQL 5.7侧的准备:从my.cnf参数到账号权限
3.1 核心参数配置清单
准备好一台MySQL 5.7实例,先检查当前binlog配置情况:
SHOW VARIABLES LIKE 'log_bin%'; SHOW VARIABLES LIKE 'binlog_format%'; SHOW VARIABLES LIKE 'binlog_row_image%'; SHOW VARIABLES LIKE 'server_id%';如果log_bin是OFF,需要在my.cnf的[mysqld]段里添加以下配置后重启MySQL:
[mysqld] server_id = 20 log_bin = /data/mysql/logs/mysql-bin binlog_format = ROW binlog_row_image = FULL expire_logs_days = 7 max_binlog_size = 256M binlog_cache_size = 32K sync_binlog = 1这里我解释一下几个容易被忽略的参数:
- expire_logs_days = 7:binlog文件保留7天。如果设为0,binlog永不自动清理,磁盘迟早打满;如果设得太短,Canal一旦停服超过保留期,再来消费时位点对应的binlog已经没了,Canal只能报错或者从当前位点继续,中间的数据就丢了。到底保留几天要根据你的同步链路稳定性来决定,保守一点15天也好。
- max_binlog_size = 256M:单个binlog文件大小上限。这个参数不敏感,只要不是太小就好,因为Canal在消费大文件时,如果连续重启,每次都要从开头解析也是浪费性能。
- sync_binlog = 1:每次事务提交都同步刷盘binlog。对Canal来说最大的好处是崩溃恢复后binlog不丢,解析出来的变更才能保证完整,代价是写入性能稍有下降。
- binlog_cache_size = 32K:这是会话级binlog缓存大小,多数场景默认值够用,但如果单事务很大,可能需要调大。对于Canal解析没有直接影响,属于顺手配置。
3.2 创建Canal专用账号
Canal需要以MySQL账号身份连接主库,这个账号至少要有REPLICATION SLAVE、REPLICATION CLIENT权限。前者用于请求binlog dump,后者用于执行SHOW MASTER STATUS、SHOW SLAVE STATUS之类的查询。如果你还需要通过Canal去反查数据或者做某些过滤场景,可能还要SELECT权限,但最小化权限原则下,先给两个复制权限就够了。
CREATE USER 'canal'@'%' IDENTIFIED BY 'Canal@2024'; GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%'; FLUSH PRIVILEGES;注意几点:
- 账号的host不要只给localhost,因为Canal服务端可能跑在另一台机器上。如果Canal和MySQL在同一台,那给localhost也行,但出于一般部署习惯,建议给一个安全的网段。
- 密码别图省事用弱口令,这个账号能拉全库的binlog,等于是能拿到全库的数据变化。虽然不能直接读表,但binlog里包含了所有行数据的前后镜像,敏感程度等同于数据库本身。
- 如果你做了账号IP白名单,记得把Canal服务端所在IP加进去。我遇到过Canal连接MySQL要用SSL或者要经过跳板机的情况,账号权限没问题,但网络策略挡着连不上,这类问题最容易被忽略。
3.3 验证配置是否生效
MySQL重启后,重新执行一遍参数检查,然后插入一条测试数据,手动刷新一个binlog段,再查看文件:
FLUSH LOGS; SHOW MASTER STATUS;正常情况下你会看到类似这样的输出:
+------------------+----------+--------------+------------------+-------------------+ | File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set | +------------------+----------+--------------+------------------+-------------------+ | mysql-bin.000030 | 154 | | | | +------------------+----------+--------------+------------------+-------------------+这条信息在Canal排查位点问题时非常关键:如果Canal报找不到position,你就拿这里的File和Position手动填到Canal配置里,让它从当前最新位点开始消费。注意这样会跳过已经产生的历史数据,只消费配置之后的新增变更。
4. Canal服务端部署与配置:每个参数都别想当然
4.1 版本选择与安装
Canal目前维护的分支是1.1.x系列,针对MySQL 5.7建议直接用最新稳定版,比如1.1.7或后续更新的版本。解压之后目录结构大致如下:
canal.deployer-1.1.7/ ├── bin/ │ ├── startup.sh │ └── stop.sh ├── conf/ │ ├── canal.properties │ ├── example/ │ │ └── instance.properties │ └── logback.xml ├── lib/ └── logs/部署前确认Java环境为JDK 1.8以上,因为Canal老版本在JDK 11上出现过一些反射相关的兼容性问题,如果条件允许用JDK 8最省心。解压后不要急着启动,先去改两个核心配置文件。
4.2 canal.properties:全局配置怎么填
canal.properties是服务端全局配置,决定整台Canal以什么模式运行。最核心的几个参数:
# 当前节点ID,集群部署时每个节点的ID不能重复 canal.id = 1 # 服务端口,客户端用这个端口连接 canal.port = 11111 # 目标zk地址,单机部署不需要 canal.zkQuorum = # 数据抓取到内存队列后最晚多久可以投递 canal.default.canal.flow.manager.name = default # 服务端运行模式 canal.serverMode = tcp这里重点说canal.serverMode。它可以取值tcp、kafka、rocketMQ、rabbitMQ。tcp模式是最轻量的:Canal服务端把解析出来的数据通过自研协议推给直连的客户端。kafka模式则是把数据直接投递到Kafka Topic里,下游通过Kafka消费。如果公司已有Kafka集群,推荐直接用kafka模式,因为Canal帮你省掉了构建客户端消费程序这一步,数据先进Kafka,再由业务自行消费,链路更松耦合、也更好做水平扩展。
单机部署时zkQuorum留空即可。如果你要部署Canal集群保证高可用,则需要先准备一套ZooKeeper,再把canal.zkQuorum填上,多个Canal节点通过zk做选主和位点共享。这一步不建议第一版就上,先跑通单机,后面再平滑升级。
4.3 instance.properties:决定这份Canal在盯哪张库表
每个Canal服务可以有多个instance,一个instance对应一个“数据源+消费通道”,默认的instance名字叫example。修改conf/example/instance.properties:
# 主库地址和端口 canal.instance.master.address = 192.168.1.20:3306 # 伪装成从库时的slaveId canal.instance.mysql.slaveId = 1234 # 数据源账号 canal.instance.dbUsername = canal canal.instance.dbPassword = Canal@2024 # 连接编码,5.7一般用UTF-8 canal.instance.connectionCharset = UTF-8 # binlog位点信息 canal.instance.master.journal.name = mysql-bin.000030 canal.instance.master.position = 154 # 如果启用GTID,则使用下面这行并注释上面的位点 # canal.instance.gtidSn = 3a6c34e1-2bb6-11ec-8e2b-00163e123456:1-32 # 订阅过滤规则,只消费指定库表 canal.instance.filter.regex = test_db\\..* # 黑名单过滤,也可用filter.blackRegex canal.instance.filter.blackRegex =filter.regex规则是Canal里最常用的正则,它决定了你要抓哪些库哪些表。格式是“库名\..*”或者“库名\.表名”,多个规则用逗号分隔。类似:
canal.instance.filter.regex = test_db\\.\\w+, order_db\\.t_order这里要特别强调一个容易踩坑的细节:在properties文件里,点号在正则里匹配任意字符,必须转义成\.,而且Java properties文件里的反斜杠本身也需要再转义一次,所以你要写成\.,写成一个\是没用的。我在这个细节上卡过整整一下午,开头几次都以为正则没生效,查了半天才发现是转义问题。另外,黑名单优先级高于白名单,如果一个表同时命中的规则有冲突,黑名单生效。
filter.regex支持用逗号分隔多个匹配串,支持正则的|逻辑。有人在配置里写了test_db..*然后日志里一条数据都看不到,大概率就是反斜杠没写够——原参数里那两个反斜杠,一个都不能少。
4.4 启动与状态验证
启动Canal:
bin/startup.sh然后盯日志:
tail -f logs/example/example.log正常启动后,日志里应该能看到类似“success to load binlog”或“start successful”的标记。如果一直卡着没有任何输出,多半是网络层面的问题:Canal到MySQL 3306端口不通、账号权限不对、或者MySQL的binlog地址在Canal所在机器上访问不到。
接着可以做一次端到端验证:
-- 在MySQL里执行 INSERT INTO test_db.t_user (id, name) VALUES (1, 'hello');正常情况下,Canal日志里会吐出这条INSERT对应的解析记录。如果没有输出,看两个地方:一是filter.regex有没有匹配到test_db.t_user,二是MySQL的binlog_format到底是不是ROW。很多人第一步就把binlog_format改了ROW,但MySQL实例可能有多套配置文件或者被全局参数覆盖,实际运行时仍是MIXED,这个要重点排查。
5. 客户端接入:从拿到Entry到真正落库
5.1 最小可用工程搭建
Canal提供的canal-client是官方Java客户端,也是最主流的使用方式。在pom.xml里引入:
<dependency> <groupId>com.alibaba.otter</groupId> <artifactId>canal.client</artifactId> <version>1.1.7</version> </dependency>注意canal-client的1.1.x版本和canal.deployer 1.1.x版本最好保持一致,避免协议不兼容。一个最简的消费程序骨架如下:
public class CanalClientDemo { public static void main(String[] args) throws Exception { CanalConnector connector = CanalConnectors.newSingleConnector( new InetSocketAddress("127.0.0.1", 11111), "example", "", ""); connector.connect(); connector.subscribe("test_db\\..*"); connector.rollback(); while (true) { Message message = connector.getWithoutAck(100); long batchId = message.getId(); if (batchId == -1 || message.getEntries().isEmpty()) { Thread.sleep(1000); continue; } System.out.printf("batchId=%d, entries=%d%n", batchId, message.getEntries().size()); for (CanalEntry.Entry entry : message.getEntries()) { // 只处理行变更事件,忽略事务开始结束等事件 if (entry.getEntryType() != CanalEntry.EntryType.ROWDATA) { continue; } CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue()); CanalEntry.EventType eventType = rowChange.getEventType(); String table = entry.getHeader().getTableName(); System.out.println("table=" + table + ", eventType=" + eventType); for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) { if (eventType == CanalEntry.EventType.DELETE) { printColumns(rowData.getBeforeColumnsList()); } else if (eventType == CanalEntry.EventType.INSERT) { printColumns(rowData.getAfterColumnsList()); } else { // UPDATE:before是旧值,after是新值 printColumns(rowData.getBeforeColumnsList()); printColumns(rowData.getAfterColumnsList()); } } } connector.ack(batchId); } } private static void printColumns(List<CanalEntry.Column> columns) { for (CanalEntry.Column column : columns) { System.out.println(column.getName() + "=" + column.getValue() + ", updated=" + column.getUpdated()); } } }这个程序虽然只有几十行,但已经把消费闭环跑通了:连接Canal服务端、订阅test_db下的所有表、轮询拉取变更、解析行数据、业务处理、最后ack确认消费完成。把它跑起来,你就能在控制台看到每次数据库变更对应的结构化字段数据。
5.2 Entry的结构和几个关键字段
CanalEntry.Entry是Canal对binlog中一条变更事件进行二次封装后的统一结构,内部有几层信息,从外到内依次是Header和StoreValue。Header里包含日志位点信息:logfileName、logfileOffset、数据库名、表名、事件类型、变更时间等。StoreValue则是序列化后的RowChange对象,里面是实际的行数据列表。
字段层面,每行数据由多个Column组成,每个Column有name、value、isNull、updated等属性。其中updated字段很有意思:Canal会标记这个字段是否在本次UPDATE中被修改。注意这个标记并不总是可靠,取决于binlog_row_image的设置和MySQL解析细节,不能把它当成唯一判断依据。
还有一个容易忽略的点:CanalEntry.EventType不止INSERT、UPDATE、DELETE,还有QUERY、TRUNCATE、CREATE、ALTER、ERASE等DDL事件类型。如果你的下游同步程序只处理前三种,那么其他事件类型要显式跳过或单独处理,否则可能会被忽略掉关键的DDL变更,导致下游表结构和上游不一致。
5.3 ack机制与幂等设计:这决定了你线上敢不敢重启
Canal的数据消费和消息队列类似,客户端拉到一批消息后,必须调用ack告诉Canal“这批处理完了,可以往后挪位点了”。如果业务处理抛异常,你可以不调用ack然后调用rollback,Canal会重新推送这批数据。这里有一个非常关键的设计选择:是先处理业务再ack,还是先ack再处理业务?
先业务后ack的安全性高,但性能差、失败会重复消费;先ack后业务性能好,但一旦业务处理中途宕机,这条数据就丢了。没有一个绝对正确的答案,取决于你对“丢数据”还是“重复数据”的容忍度。
我的经验是:如果下游是缓存刷新这类允许少量重复的场景,可以先ack再刷缓存,配合幂等操作收益很大;如果下游是订单记账、余额变动这种绝对不能丢的场景,则必须业务处理成功后再ack,同时处理逻辑本身要保证幂等——即在INSERT时使用INSERT ON DUPLICATE KEY UPDATE,UPDATE前先查后改,DELETE没有副作用,这样即使同一行数据被重复投递,最终结果也一致。
getWithoutAck和get的区别也要注意。getWithoutAck是拉取但不确认,拉完后你有时间处理,处理完再ack;get则直接确认,一般不推荐使用。另一个细节是每次拉取后,即使entries为空,也需要ack或者rollback,否则Canal会认为消费客户端卡住了,batchId一直不前进。
5.4 和Kafka等消息队列对接的简化路径
如果不想写Java客户端,或者公司里的消费方是各种语言都有,建议把Canal的serverMode改成kafka,让Canal直接把数据写入Kafka。配置canal.properties里的模式后,再配置Kafka的bootstrap.servers、topic名、分区数和副本数。这样下游就能直接用Kafka的SDK消费binlog变更事件,格式仍然是CanalEntry.Entry的protobuf序列化。
这种模式最大的好处是把“Canal客户端开发”这件事完全消灭掉,你只需要关心Kafka的消费和反序列化。缺点是Canal的one-topic模式下默认会按照主库的分库分表逻辑混合投递,需要在消费时按表名再过滤;不过Canal也支持动态topic,配置里可以写成按库或表维度自动生成多topic,灵活性更高。
6. 踩坑实录:我在这套链路上栽过的跟头和排查方法
6.1 常见问题速查
| 问题现象 | 可能原因 | 排查思路 | 解决办法 |
|---|---|---|---|
| Canal启动后日志一直报“connect error” | MySQL账号权限不足、网络不通、密码错误 | telnet测试3306端口、用canal账号手动登录MySQL | 重新GRANT权限、检查网络策略、确认密码无误 |
| 连接成功后日志里没有任何数据事件 | filter.regex正则没写对、binlog_format不是ROW | 检查filter配置转义、确认binlog_format和row_image | 修正为正则并注意双反斜杠、调整为ROW+FULL |
| 客户端能连上但收到的表名/库名不对 | 订阅规则和filter.regex不一致,多个instance混淆 | 确认客户端subscribe参数和instance配置一致 | 统一订阅表达式,必要时重启instance |
| 数据偶尔丢一条 | ack时机太早、binlog被清理导致位点跳过 | 查看位点与binlog保留时间、梳理ack流程 | 改为业务成功后ack、延长binlog保留期 |
| Canal一台机器上只能解析一个MySQL吗 | 默认instance只对应一个数据源 | 添加多个instance,各配不同的dataSourceKey | 创建conf/新instance目录并注册到canal.properties |
| DDL变更时客户端报错或数据错乱 | 客户端未处理DDL事件类型 | 检查EntryType是否为DDL类型 | 增加DDL处理逻辑,或显式过滤跳过 |
| 消费延迟持续堆积 | 消费端处理慢、批量太小、服务端队列满 | 查看消费耗时、调大batchSize、增加异步线程 | 增大getWithoutAck的batchSize、多线程消费加幂等保护 |
| 从库或真实同步中断 | server-id冲突 | 查看主库错误日志、对比各实例server-id | 修改Canal的slaveId为独立高位值 |
6.2 位点丢失:最不能忽略的恢复手段
Canal消费中段后再次重启,服务端会从上次保存的位点继续。这个位点默认存内存,如果是集群模式会同步到ZooKeeper。但如果你改了instance配置或手动调整了位点信息,一定要先在MySQL侧用SHOW MASTER STATUS确认当前位点的binlog文件还在。如果对应的binlog已被清理,Canal会从配置的位点读取失败。
我遇到过一种情况:因为磁盘满了清理过binlog,Canal启动时日志里报错说找不到对应的binlog文件,当时比较紧张。处理方式也比较直接:把instance.properties里的位点改成MySQL当前的SHOW MASTER STATUS值,让它从最新位点继续。代价是清理之前的变更全部不可追溯,但如果业务流程允许,这样恢复了服务再慢慢补数据是最快的缓解方案。真正治本的办法还是把binlog保留时间调长,并给Canal做一个独立的位点监控告警,发现连续停滞或位点前进缓慢时提前介入。
6.3 重复数据与乱序问题:从消费端兜底
Canal自身会尽量保证同一行变更的顺序,但在并发消费或者使用消息队列的情况下,下游可能出现乱序。一个常见场景:同一个主键的UPDATE事件先后进入Kafka,两个消费者分别消费,先消费到的反而后落库,导致最终结果错误。解决思路是:
- 在Topic分区维度按主键做哈希,让同一主键的数据固定进同一分区,消费端从该分区顺序读,这个约束在Kafka模式下一开始就要设计好。
- 业务更新要尽量做到“用订单号+更新时间做乐观锁条件”,比直接全覆盖更稳。
- 如果追求极端精确,可以在消费端攒批后按主键分组再排序,牺牲一点实时性换取顺序确定性。
6.4 我的一些经验体会
这套链路跑通之后,你回头看会觉得它其实不复杂,核心就是三件事:MySQL把binlog打开、Canal把binlog解析成事件、客户端把事件变成业务动作。但坑几乎都藏在配置细节里。
如果让我给刚接手的人一个最实用的建议,就是先把MySQL的binlog手动解析一遍——哪怕是借助mysqlbinlog命令看一条UPDATE记录长什么样,再去配Canal。你理解了源数据长什么样,后面所有解析异常、字段缺失、事件类型不对的问题都能迎刃而解。一开始就一头扎进Canal配置文件里,踩坑概率会高很多。”我看很多团队把文档丢给新人就去忙别的了,建议是别省这一步,值得花两个小时演示一遍。
另外,Canal的日志和监控一定要尽早接入。它是个常驻进程,不是跑完即退的脚本,一旦出现问题,日志不会像Web接口那样直接告诉你哪里错了。建议在启动脚本里加上健康检查,定期验证Canal是否能正常消费一条测试表的变更。我曾见过Canal服务挂了一星期,业务侧毫无感知,直到缓存数据明显陈旧才发现问题,这种体验很不好。
最后分享一个小扩展方向:Canal解析出来的数据不只是拿来同步,还能做很多事情,比如把日常变更实时写入数仓做增量分析、把Delete事件作为逻辑删除标记物、或者在下游构建一份和线上结构不同的宽表。本质上binlog里蕴含的是整个系统的数据流轨迹,Canal只是把这条轨迹变成你随时可以编程访问的东西。等这套基础跑稳了,你会发现数据可观测性会提升一个层次。