开头
搞数据同步这件事,做了这么多年,Canal绝对是我用得最顺手的一个方案。简单说,Canal就是一套开源的binlog订阅解析组件,它把自己伪装成一个MySQL从库,通过原生的复制协议向主库请求binlog,然后解析成结构化的消息扔给下游。很多团队拿它做缓存同步、搜索引擎索引更新、异构数据复制、事件驱动的数据管道,核心场景就一句话:让MySQL的数据变更实时流转到其他系统里。
这篇文章适合谁看?如果你正在做数据同步、微服务间的数据一致性、或者想把MySQL的数据实时推到消息队列里,那这篇就是给你的。我会基于MySQL 5.7这个经典版本,把Canal从配置到消费的全链路拆开讲,包括binlog格式的底层原理、Canal的核心配置逻辑、消息解析的实战细节,以及我在生产环境里踩过的那些坑。读完后,你至少能自己搭一套能用的增量同步链路出来。
1. 方案选型:为什么偏偏是Canal
1.1 自己读binlog为什么不可行
先说说为什么不用最原始的办法——自己解析binlog。理论上,MySQL的binlog就是一个文件序列,按固定格式记录了所有数据变更,只要照着官方文档解析就行了。但真正做起来,你会发现这是一条特别陡峭的曲线。
binlog里面有事件头、事件体,还要处理format description event、rotate event、table map event、row event等各种类型。更要命的是,不同小版本之间,event结构还可能略有差异。MySQL 5.7有一套规则,8.0又改了部分细节。再加上需要自己维护位点信息,要处理MySQL重启后文件轮转,要应对主从切换,这些工程问题加起来,没一两个月的开发和调试,很难拿出一套稳定可用的东西。我的经验是,如果只是做内部工具,自己写写也行;但要是线上核心链路,除非团队特别缺人又特别闲,否则不建议自研。
1.2 Canal、Maxwell、Debezium怎么选
市面上除了Canal,还有Maxwell和Debezium。它们底层都是模拟从库拉binlog,但对下游的输出方式差别很大。我做了一个简单的对比表格,这样看最直观。
| 对比维度 | Canal | Maxwell | Debezium |
|---|---|---|---|
| 输出格式 | 自定义protobuf结构,也可输出JSON | 直接输出JSON | 输出JSON或Avro |
| 消息队列支持 | 内置MQ模块,支持Kafka、RocketMQ、RabbitMQ等 | 主要面向Kafka,也支持Kinesis等 | 依托Kafka Connect生态 |
| 部署复杂度 | 中等,依赖Java环境 | 较低,依赖Java环境,单进程即可 | 较高,依赖Kafka Connect框架 |
| 增量快照能力 | 原生不支持,需要配合工具 | 支持基础快照,能力一般 | 内置快照机制,比较完善 |
| 更新事件的旧值 | 默认只给变更后值,需要开启配置才能获取旧值 | 默认带旧值 | 默认带旧值,可配置 |
| 中文社区资料 | 非常丰富 | 较少 | 中等 |
对于国内团队来说,Canal的社区资料和熟悉度是最大的优势。很多公司的数据同步中间件都是基于Canal做的二次封装,遇到问题基本都能搜到解决方案。而且Canal的MQ模块做得比较完善,可以直接把解析后的数据投递到Kafka,这对于下游是消息队列的架构来说非常省事。所以我最终选Canal,不是因为Debezium不好,而是因为在MySQL 5.7 + Kafka这条链路上,Canal的工程成熟度最高。
1.3 Canal干了哪些活
Canal的核心链路分三块:binlog抓取、事件解析、消息投递。抓取环节就是模拟一个MySQL从库,向主库发送dump请求,主库就会把binlog实时推过来。解析环节把binlog里的字节流还原成可读的变更事件,比如哪张表哪一行被插入了,字段值是什么。投递环节则把事件封装成统一格式,通过TCP长连接直接发给客户端,或者写入消息队列。
这里有个容易忽略的点:Canal并不是简单地把binlog字节原样转发,而是完整解析了RBR格式下的行变更。也就是说,下游拿到的不是一串看不懂的二进制,而是一组字段名和字段值的映射。这是Canal最有价值的地方,它把一个底层的、只有DBA才看得懂的东西,变成了应用工程师也能直接消费的标准化消息。
2. MySQL 5.7的binlog基础与配置
2.1 binlog_format三个选项,Canal为什么只认ROW
MySQL的binlog有三种格式:STATEMENT、ROW、MIXED。Canal之所以强烈要求使用ROW格式,是因为只有ROW格式记录了“哪一行变成了什么”,而STATEMENT格式只记录“执行了哪条SQL”。
举个例子。执行UPDATE users SET age = age + 1 WHERE status = 1,STATEMENT格式只记录这条SQL文本,如果下游想知道具体哪些行被改了,就必须自己重放一次SQL,这在数据同步里不可接受。ROW格式则会把命中的每一行的前后镜像都写进binlog,下游直接拿到age字段的旧值和新值。MIXED格式表面上混合了两者的优势,但遇到某些语句时还是会退化为STATEMENT记录,导致变更事件不完整。所以生产环境做增量同步,binlog格式必须固定为ROW。
2.2 my.cnf里的关键参数
MySQL 5.7开启binlog并配套Canal,通常需要这样配置:
[mysqld] server_id = 100 log_bin = mysql-bin binlog_format = ROW binlog_row_image = FULL expire_logs_days = 7 max_binlog_size = 256M gtid_mode = on enforce_gtid_consistency = onserver_id必须设置且全局唯一,这是主从复制协议的标识,不设置binlog不会正常工作。log_bin指定binlog文件前缀,建议放在独立的数据目录里。binlog_row_image=FULL非常关键,它要求binlog同时记录变更前和变更后的完整行镜像。如果设置成MINIMAL,UPDATE事件的前镜像只包含主键列,Canal倒是也能解析,但是下游就拿不到非主键字段的旧值了。
gtid_mode=on是强烈建议开启的。GTID(全局事务标识)相当于给每个事务分配了一个全局唯一ID,Canal在解析时可以用GTID来做位点管理。特别是在后续做主从切换时,GTID能大幅降低位点断点续传的复杂度。不过要注意,开启GTID需要MySQL 5.6及以上版本,5.7完全没问题,而且开启后对常规业务几乎无感。
2.3 改完配置后必须做的验证
配置改完不是重启就完事了,必须做一次验证,确认binlog在按预期工作。登录MySQL执行:
SHOW VARIABLES LIKE 'log_bin'; SHOW VARIABLES LIKE 'binlog_format'; SHOW VARIABLES LIKE 'binlog_row_image'; SHOW VARIABLES LIKE 'gtid_mode';这三个值分别应该显示为ON、ROW、FULL、ON。然后执行一条更新数据,再用SHOW BINLOG EVENTS查看最新的事件,确认里面出现的是Write_rows或Update_rows事件,而不是Query事件。如果这里只出现Query事件,说明binlog_format还是STATEMENT,Canal接上去肯定解析不出正确的行数据。
我见过不少同事在配置阶段就栽了跟头。明明改了my.cnf,但没确认配置文件是否被mysqld加载,或者MySQL里用的是SET GLOBAL binlog_format='ROW'这种运行时修改,重启后就恢复原样了。检查配置是否真正生效,永远不要只看改没改文件,要看数据库里的实际变量值。
3. Canal部署与核心配置
3.1 环境准备与版本选型
Canal是基于Java的,部署机必须装JDK。我的建议是JDK 8或JDK 11,这两个版本是Canal官方长期测试过的。Canal 1.1.5及以上版本对MySQL 5.7的支持比较完善,如果项目还在用MySQL 5.6或更老的版本,建议选1.0.x分支的稳定版,因为1.1.x在某些操作上对老版本有过调整。
另外要说清楚,Canal本身分为deployer和adapter两种形态。deployer是核心服务端,负责抓取和解析binlog,对外提供TCP端口或MQ接入能力。adapter是配套的同步适配器,可以直接对接ES、HBase、RDBMS等目标存储。绝大多数场景下,我们只需要部署deployer,然后把解析后的消息拿到自己的消费程序里处理,或者丢给Kafka。这一篇我主要讲deployer。
3.2 MySQL侧账号权限配置
Canal要模拟从库,所以它连接MySQL的账号必须有复制权限。通常这样创建:
CREATE USER 'canal'@'%' IDENTIFIED BY 'canal_password'; GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%'; FLUSH PRIVILEGES;REPLICATION SLAVE是核心权限,没有它Canal无法发送dump请求。REPLICATION CLIENT是为了能查询master status,用于获取最新的binlog位置。SELECT权限是Canal在初始化时用来读取表结构信息的,没有它解析时拿不到字段元数据,就算binlog抓下来了也还原不出字段名。
这里给一个额外建议:如果你担心权限过大的问题,可以先只授权给特定库,比如ON mydb.*。但要注意,REPLICATION SLAVE必须建在*.*上,因为复制权限是全局的,没法限定到某个库。
3.3 canal.properties和instance.properties的深挖
Canal的配置分两层。第一层是全局配置canal.properties,管理整个Canal进程的行为,比如是否开启MQ投递、服务端口、内存缓冲等。第二层是实例配置instance.properties,每个instance对应一个需要订阅的MySQL数据源。
全局配置里,最核心的是canal.serverMode,它决定Canal以什么模式对外提供数据。默认是tcp,也就是客户端直连Canal的TCP端口拉消息。如果要接入Kafka,要改成kafka,然后在kafka.bootstrap.servers里配置集群地址。
实例配置里,这些参数是必调的:
## 主库地址 canal.instance.master.address = 127.0.0.1:3306 ## 位点管理方式 canal.instance.master.journal.name = canal.instance.master.position = canal.instance.master.gtid = ## 消费账号 canal.instance.dbUsername = canal canal.instance.dbPassword = canal_password ## 订阅规则 canal.instance.filter.regex = mydb\\..* ## 表结构缓存 canal.instance.parser.parallelThreadSize = 2canal.instance.master.address下面那三个参数是用来控制起始位点的。第一次启动时,我们通常希望Canal从“当前最新位置”开始解析,那就把journal.name和position留空,让Canal自动获取MySQL当前的binlog文件名和位置。如果想从某个历史位点开始恢复数据,就要手动指定这两个值。这块很容易搞混,后面我会专门展开说。
canal.instance.filter.regex是订阅规则,支持正则表达式,格式是库名\.表名。mydb\\..*表示订阅mydb库下所有表。如果要订阅多个库,用竖线分隔,比如db1\\..*|db2\\..*。
3.4 启动Canal并确认工作状态
配置完成后,启动命令很简单。Linux下进入deployer目录执行bin/startup.sh,然后看日志logs/canal/canal.log和logs/example/example.log。看到类似这样的日志,基本说明已经成功连接MySQL并开始解析了:
begin to find start position ========== find start position successfully, GTID:123:1-200 generate table filter: mydb\\..* load table metadata: mydb.users dump data for batch success先确认canal.log里没有异常堆栈,再确认example.log里有没有出现dump data for batch success。如果出现no table meta found之类的日志,多半是订阅规则写错了,或者MySQL账号的SELECT权限没给到位,导致Canal拿不到表结构。启动后,还可以在服务器上执行netstat -anp | grep 11111确认默认的TCP端口11111在监听。
4. 消息解析与消费实战
4.1 Canal消息的数据结构
Canal用protobuf定义了一套消息格式,核心概念是Entry和RowChange。一个Entry对应binlog中的一个变更事件,里面有一个header,包含日志文件名、日志偏移量、GTID、事件时间、表名、库名等元信息。Entry的storeValue字段里,存放的是序列化后的RowChange结构。
RowChange包含事件类型和具体的数据行。事件类型就是SQL执行的真实类型,比如INSERT、UPDATE、DELETE,还有DDL的CREATE、ALTER等。每一行数据在RowChange里体现为RowData结构,包含beforeColumns和afterColumns两个字段列表。INSERT事件的beforeColumns为空,DELETE事件的afterColumns为空,UPDATE事件则两边都有。
4.2 一个用户表变更的完整拆解
假设users表结构是这样的:
CREATE TABLE `users` ( `id` int(11) NOT NULL, `name` varchar(50) DEFAULT NULL, `age` int(11) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB当客户端执行UPDATE users SET age = 30 WHERE id = 1时,Canal解析出来的消息大概长这样:
{ "header": { "logfileName": "mysql-bin.000008", "logfileOffset": 1024, "gtid": "123:1-200", "schemaName": "mydb", "tableName": "users", "eventType": "UPDATE", "executeTime": 1690000000000 }, "storeValue": { "rowData": [ { "beforeColumns": [ {"name": "id", "value": "1"}, {"name": "name", "value": "alice"}, {"name": "age", "value": "29"} ], "afterColumns": [ {"name": "id", "value": "1"}, {"name": "name", "value": "alice"}, {"name": "age", "value": "30"} ] } ] } }这串消息里的价值在哪里?注意beforeColumns里的age是29,afterColumns里是30。下游消费者拿到这个变更事件后,可以直接根据before和after的差异决定先删旧缓存再写新缓存,或者直接把新数据写入ES替换旧文档。如果是自己解析binlog的原始字节,这一步的还原工作量和出错的概率都会成倍上升。
这里有个细节值得多说一句:默认情况下Canal输出的字段类型是字符串,所有int、bigint、date都以文本形式呈现。这在大部分场景下够用,但如果你需要精确的数值类型语义,比如要区分1和'1',就要注意在消费端做类型还原。Canal也提供了一些参数控制字段类型的输出格式,比如把日期时间格式化成时间戳,这个在conf配置里可以单独调。
4.3 TCP模式还是MQ模式
TCP模式是Canal的原生玩法。客户端引入canal.client依赖,建立一个CanalConnector,连上Canal的11111端口,然后循环调用getWithoutAck拿到一批消息,处理完再调用ack确认。这是典型的生产者消费者模型,Canal在服务端维护了消息的投递和确认状态,客户端如果挂了没确认,Canal会重新投递。
MQ模式则把解析后的消息直接写入消息队列,比较推荐的做法是接入Kafka。每条binlog事件对应Kafka里的一个消息,topic可以按库名或表名规划,Canal支持通过配置把不同表分到不同topic。这样下游各个应用只要各取所需的topic,就能实现数据的解耦分发。
我自己的经验是:如果你只有一个下游消费者,并且希望保持最直接的控制力,比如要做到精确的按批确认和位点管理,可以用TCP模式。如果你有多个下游团队都要消费同一份变更数据,或者你的技术栈里已经用了Kafka,那MQ模式省心得多,毕竟Canal内置了生产者的能力,不需要自己写发送逻辑。
4.4 位点恢复与高可用
Canal的位点管理是一个关键点。在TCP模式下,Canal自己会记录每个客户端的消费位点,客户端每批消息处理完后确认ack,Canal就记录下这批消息对应的binlog位点。默认情况下,这些位点信息写在logs/meta目录的meta.log文件里。Canal重启时会读取这些位点,从上次确认的位置继续拉取,不会重复也不会遗漏。
但这里有个容易踩的坑:如果你用Docker部署Canal,logs/meta目录默认在容器内,容器重建后位点就丢了。生产环境必须把logs/meta目录通过volume映射到宿主机,否则一次容器重建,Canal就会从头开始解析,下游可能短时间内收到海量重复数据。用K8s部署的话,要挂PVC,道理一样。
高可用方面,Canal官方也提供了Canal Admin和Canal集群方案,支持多节点同时连接同一个MySQL实例,通过zookeeper协调位点。具体做法是部署多个Canal实例,其中一个作为running节点,其他作为standby节点,zookeeper负责故障时的切换。如果你的MySQL本身就是主从架构,我还建议把Canal同时订阅主库和备库,做好双链路冗余。一旦主库切了,备库上的Canal立刻顶上,数据同步链路不会断。
5. 常见问题与排查技巧实录
5.1 问题速查表
下面这张表是我在实际运维过程中整理的高频问题,每条都对应着一种更隐蔽的故障类型。
| 问题现象 | 可能原因 | 排查思路 |
|---|---|---|
| Canal连接主库失败 | MySQL账号权限不足或密码错误 | 检查REPLICATION SLAVE权限,检查密码 |
| 启动正常但没有数据输出 | binlog_format不是ROW或订阅正则写错 | 确认MySQL端binlog_format=ROW,确认filter.regex范围 |
| 解析出字段值全是null | 表结构缓存失效或binlog_row_image=MINIMAL | 清掉Canal的table meta缓存,确认binlog_row_image=FULL |
出现could not find table meta | SELECT权限不足或表不存在 | 确认账号的SELECT权限,确认表在订阅库内 |
| 消费端收到重复数据 | 客户端未正确发送ack / 位点未持久化 | 确认ack机制,确认meta.log目录持久化 |
| 消息顺序错乱 | 多线程消费,下游并行处理 | 按主键或唯一键做分区,保证同一行数据串行消费 |
| 大事务导致延迟 | 单次变更超过Canal的buffer容量 | 调大canal.instance.parser.bufferSize,或拆分批处理 |
| 主从切换后Canal失联 | Canal位点记录与切换后的GTID不匹配 | 开启GTID并使用GTID位点,重新指定master地址 |
5.2 性能调优的三个方向
第一是连接数。Canal默认以单连接的方式拉取binlog,网络的往返延迟会影响吞吐。在Canal 1.1.x里,可以通过canal.instance.parser.parallelThreadSize开启并行解析,比如配置成4,让多个线程并行解析不同批次的binlog事件,能显著提高单实例的吞吐量。
第二是内存缓冲。canal.instance.memory.buffer.size默认是16384个事件,如果业务瞬时涌入大量变更,缓冲区满了之后Canal只能暂时停止拉取binlog,这会造成消费端延迟。这时要根据业务峰值调整缓冲区大小,同时也要留意Canal进程的堆内存,建议把JVM的-Xms和-Xmx调一致,避免动态扩容造成的性能抖动。
第三是消费端并发。TCP模式下,我们可以在消费程序里用多线程并行处理消息,但要注意保证同一行数据的变更顺序。通常做法是根据消息里的主键或唯一键做哈希,把相同键的变更投递到同一个工作线程里,这样既提升了吞吐,又不会乱序。
5.3 我在生产环境踩过的坑
第一个坑是DDL导致的表结构缓存失效。Canal在解析binlog时会缓存表结构,如果业务执行了ALTER TABLE,Canal内部会自动刷新缓存,但偶尔会因为DDL语句的解析异常没有刷新成功。我遇到的情况是,执行ALTER TABLE后下游收到的消息里字段数量对不上。最简单的处理办法是在Canal的客户端里监听DDL类型的Entry,一旦发现DDL就主动清掉本地相关的表结构缓存,或者让Canal通过canal.instance.parser.filter.query.dcl参数过滤掉DDL,直接忽略这类事件。
第二个坑是自增主键导致的insert重复。如果下游消费程序直接把主键当作唯一标识写入新的存储,要特别注意Canal输出的是字符串类型的主键,在主键是bigint时,超过JavaScript安全整数范围会丢失精度。当时我们的下游是Node.js服务,接收JSON消息后主键变成了不精确的浮点数,导致数据关联错乱。解决办法是消费端用字符串类型保存主键,或者让Canal在输出JSON时把主键字段保持为字符串格式。
第三个坑是Canal对多表join出来的视图无能为力。注意,Canal订阅的是binlog事件,它只关心表级别的行变更,不关心业务语义。如果下游想知道“某个用户在哪个订单里消费过”这类跨表信息,必须自己在消费端关联,不能指望Canal帮你做聚合。这条坑其实说到底是架构预期的问题,Canal是数据管道,不是业务处理引擎,把这两件事分开,整个链路才能各司其职。
结尾
最后说点实在的。Canal这套方案,我用下来最大的体会是:它确实把binlog解析这件难事做成了开箱即用的组件,但底层原理依然值得每个人去理解。我建议所有打算在生产环境用Canal的朋友,先找一个MySQL测试实例,手动执行几条INSERT和UPDATE,然后去Canal的log里看解析结果,亲手对比一下消息里的before和after字段。这个过程会让你对binlog的ROW格式有非常直观的理解。之后再上生产,遇到问题就不会慌。还有一个值得花时间的小技巧:给Canal单独配一个日志级别的监控项,观察它解析延迟的变化曲线,数据同步链路的健康状况,基本就能提前预判了。