文章目录
- 前言
- [一、Canal 是个什么东西](#一、Canal 是个什么东西)
- 二、它最常被用在哪些场景
-
- [2.1 缓存与数据库的一致性](#2.1 缓存与数据库的一致性)
- [2.2 搜索索引同步(DB → ES)](#2.2 搜索索引同步(DB → ES))
- [2.3 数据异构:按查询维度把拆开的表合起来](#2.3 数据异构:按查询维度把拆开的表合起来)
- [2.4 给下游"喂"变更:实时入仓与业务事件](#2.4 给下游"喂"变更:实时入仓与业务事件)
- [2.5 汇总宽表的增量更新](#2.5 汇总宽表的增量更新)
- 三、在项目里接进来
- 四、三个容易踩的坑
- 五、它保证不了什么
- 写在最后
前言
之前做一个客户管理模块的统计分析页,要展示的字段有几十个,散落在客户表、合同表、跟进记录表、商家表等 10 多张表里。客户主表几十万,个别从表上百万,直接实时 JOIN 出来,接口经常超时;退一步改成定时任务哪怕是5分钟跑一次汇总,数据也总是慢一拍------领导做了相关操作后,总是想立马就看到数据效果。
这类问题的解法其实很成熟:把多表关联的结果预先打宽,落到一张独立的表里,业务表一有变更就增量更新它。但是有一点绕不开:数据库里某张表刚刚改动了,用什么办法能第一时间知道,而且不用在业务代码里到处埋点呢?
答案就是我们今天要介绍的 Canal。它借助 MySQL 的 binlog,把指定表的所有变动,生成谁都能消费的事件流。消费者端就可以根据通知,自由对宽表进行处理了。
汇总宽表处理,只是 Canal 功用的冰山一角,它还有哪些典型的使用场景? Canal 又是怎么"读到" MySQL 每一次改动的?又如何能把它接入到项目中呢?接下来就让我们逐个解答。
一、Canal 是个什么东西
一句话:它是 MySQL binlog 的订阅器。
MySQL 会把所有数据变更写进 binlog(二进制日志),从库靠读它来同步数据。Canal 做的事就是把自己伪装成一个从库 ------向主库发送 dump 协议请求,主库于是把 binlog 推给它,和真实从库同步走的是同一套机制。Canal 拿到字节流后解析成结构化的 CanalEntry,再交给下游消费。

这个定位有两个直接后果,后面每一类场景都建立在它们之上:
-
对业务代码零侵入。 不用在 Service 里埋点、不用发消息、不用改任何一行写库的代码------Canal 是在数据库外面"旁听"。
-
只能拿到增量。 它从当前位点往后推,历史数据不管。
它和"在业务代码里顺手发个消息"的根本差别在可靠性 :业务代码发消息是"写完库再发消息",两步之间任何一步失败都会不一致;而 Canal 读的是 binlog 本身,只要库写成功,变更就一定在 binlog 里。
二、它最常被用在哪些场景
Canal 的用法可以归成五类。前四类是社区的成熟实践,第五类就是开头那个场景。
2.1 缓存与数据库的一致性
这是 Canal 最常被提到的用途。
痛点:数据库更新后,缓存里的旧值要删掉或刷新。常规做法是"先更新库、再删缓存",但删缓存这一步可能失败、也可能被漏写------缓存和数据库就悄悄不一致了,往往等用户投诉才发现。
Canal 的做法:业务代码只管写库,缓存这一侧交给一个独立消费者。它订阅业务表,收到 UPDATE / DELETE 时把对应的缓存 key 删掉或回写。
边界:Canal 的延迟在毫秒到秒级,不是强一致。所以缓存仍然要设过期时间兜底------Canal 只是把不一致的窗口从"不确定"压到了"很短"。
2.2 搜索索引同步(DB → ES)
痛点:MySQL 的全文检索能力弱,复杂检索、模糊匹配、多条件组合基本都得靠 Elasticsearch。但往 ES 写数据这件事如果和写库绑在一起(双写),又是一处新的不一致来源。
Canal 的做法 :监听业务表 → 把变更写到 ES。这条路径有开箱方案,canal.adapter 里就带现成的 ES 配置(conf/es/*.yml),不必自己写消费代码。
2.3 数据异构:按查询维度把拆开的表合起来
痛点:分库分表之后,按非分片键查询、多表 JOIN、聚合统计统统变难------因为数据被拆散了。
Canal 的做法 :把需要 JOIN 的几张表的变更,按某个查询维度聚合到另一个存储里(另一个库、ES、HBase 都行)。本质上是写的时候多干一点,读的时候快很多。开头那个"字段散在四张表里"的问题,就属于这一类。
2.4 给下游"喂"变更:实时入仓与业务事件
实时入仓 :MySQL → Canal → Kafka → Flink/Spark/Doris。Canal 负责把增量投到消息队列,后面接什么由你决定,把 T+1 的离线报表推进到秒级。
业务事件:变更发生后通知其它系统------订单超时未支付自动取消、支付成功后发积分、客户信息变更后同步 CRM,都不必再靠定时任务扫表。
Tip
这一类建议走 MQ 模式 (
canal.serverMode = kafka),而不是客户端直连。为的是解耦:下游处理慢、或目标端临时不可用,消息在 MQ 里堆着,恢复后继续消费,不至于反过来把 Canal 拖死。
2.5 汇总宽表的增量更新
例如开头那个客管场景。
痛点 :几十个字段散在多张表,实时 JOIN 要三秒以上;定时跑批又慢一拍。核心矛盾是------数据要新鲜,计算还不能贵。
Canal 的做法 :把多表关联的结果预计算进一张汇总宽表(即预打宽的汇总表,下同),业务表一变更就增量更新它。因为每次只处理变化的那几行,而不是全量重算,所以既比实时 JOIN 便宜,又比定时任务新鲜。
Warning
三条要先想清楚的边界:
- 只给增量,不给全量。 汇总宽表得先有历史基线------上线前自己跑一次全量初始化,再让 Canal 接管增量。跳过这步,汇总宽表就是个残缺的数字。
- Canal 不做计算。 累加、去重、分组全部在消费端自己写。所以"不上大数据框架"这个前提,只在计算逻辑简单、关联表不多、消费端追得上时成立。
- 多表 JOIN 复杂时要谨慎。 需要跨多表做复杂关联才能打宽、或者要做窗口聚合和状态管理时,Canal 会很吃力------这些正是流处理框架存在的理由。
三、在项目里接进来
用一个最小例子走通,整体就三步。
第一步,MySQL 侧开 binlog。 在 my.cnf 里加:
properties
[mysqld]
log-bin=mysql-bin
binlog-format=ROW # 必须是 ROW,其它格式 Canal 解析不了
server-id=1 # 全局唯一,且不能与 Canal 的 slaveId 相同
再建一个"当从库"用的账号:
sql
CREATE USER 'canal'@'%' IDENTIFIED BY 'canal';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
Note
MySQL 8 默认用
caching_sha2_password。Canal 1.1.4 起陆续适配这套认证、1.1.8 已明确支持,直连 MySQL 8 默认认证未必会失败;但若你用的是较旧版本、或连接报caching_sha2_password Auth failed,最省事的办法还是建账号时写成IDENTIFIED WITH mysql_native_password BY 'canal'。订阅从库的话还要再开log_slave_updates=1。
第二步,起 canal-server。 从 GitHub Release 下载 canal.deployer-1.1.8.tar.gz 解压,改 conf/example/instance.properties:
properties
canal.instance.master.address=127.0.0.1:3306
canal.instance.dbUsername=canal
canal.instance.dbPassword=canal
canal.instance.filter.regex=crm_db\\.customer
Warning
注意
filter.regex的写法 :库名和表名之间的点是正则元字符,必须写成\\.(两个反斜杠加一个点)。写成crm_db.customer不会报错 ,但.在正则里能匹配任意字符,过滤会失效 ------它不光放行目标表,还会把crm_dbXcustomer这类无关的库表一并推上来,表面看就是"明明配了白名单,怎么什么都在推"。
执行 sh bin/startup.sh,看 logs/example/example.log 出现 start successful 即成功。
第三步,写消费者。 加依赖 com.alibaba.otter:canal.client:1.1.8,核心循环就三步------拉、处理、确认:
java
CanalConnector connector = CanalConnectors.newSingleConnector(
new InetSocketAddress("127.0.0.1", 11111),
"example", "canal", "canal");
connector.connect();
connector.subscribe("crm_db\\.customer");
while (true) {
Message message = connector.getWithoutAck(100); // ① 拉一批,不自动确认
long batchId = message.getId();
if (batchId != -1 && !message.getEntries().isEmpty()) {
try {
handle(message.getEntries()); // ② 处理
connector.ack(batchId); // ③ 成功:确认
} catch (Exception e) {
connector.rollback(batchId); // 失败:回滚,下次重来
}
}
}
在 MySQL 里改一条 crm_db.customer 的数据,消费者这边就能收到事件:
java
CanalEntry.RowChange rowChange =
CanalEntry.RowChange.parseFrom(entry.getStoreValue());
System.out.println(entry.getHeader().getTableName()
+ " -> " + rowChange.getEventType()); // INSERT / UPDATE / DELETE
四、三个容易踩的坑
Warning
第一,binlog 保留期是颗定时炸弹。 Canal 停机时间一旦超过 MySQL 的
expire_logs_days(或binlog_expire_logs_seconds),位点对应的 binlog 已被清理,增量补不回来,只能重新做全量初始化。要么把保留期设得足够长,要么对此加告警。
Warning第二,毒数据会把整条链路卡死。 Canal 是顺序消费 模型:某一批一直失败、一直
rollback,后面的变更就全部堵着。重试必须有上限------超过阈值就落进异常表人工介入,而不是无限重试。
Warning第三,客户端 subscribe 会覆盖服务端配置。 如果你在代码里又调了一次
connector.subscribe(".*\\..*"),服务端filter.regex的白名单和黑名单就全部失效了。过滤规则只放在一处。
五、它保证不了什么
有一件事必须说清楚:Canal 只能保证"至少一次",不保证"恰好一次"。
看第三节那段代码的顺序------handle() 处理完,再 ack(batchId)。如果 handle() 已经把数据写进了目标表,但在 ack 之前进程崩了,Canal 并不知道你处理过,下次会把同一批再推一遍。
这不是"可能发生",是必然会发生:宕机、重启、网络抖动、处理超时,任何一个都会触发重推。Canal 与你的目标库之间没有分布式事务,位点提交和业务落库这两件事绑不成一个原子操作,所以「至少一次」就是它能给的极限。

Tip
所以消费端必须自己实现幂等,方向有三条:
主键 UPSERT 而不是累加。 存明细、查询时再聚合,重复写同一主键就是覆盖,天然幂等。
去重表。 用
库名 + 表名 + 主键 + binlog 位点(entry.getHeader().getLogfileName()拼上getLogfileOffset())作唯一标识,处理前先插入去重表,冲突就说明处理过,直接跳过。Redis 原子去重。 用
SETNX做"检查并标记",比"先查再写"安全。
这几条在缓存同步那类场景里无所谓(删缓存本来就是幂等的),但在 2.5 节那种汇总宽表累加的场景里是生死线------同一笔订单加两遍,"累计金额"就凭空多出来一块,而且不报错,只能靠对账发现。
写在最后
Canal 的价值不在"能同步数据",而在它把 MySQL 的 binlog 变成了一条任何 Java 服务都能消费的事件流------不用上大数据栈,不用业务代码配合。
它的边界同样清楚:只做搬运,不做计算;只给增量,不保证恰好一次。这几条记住了,它就是一个很省心的工具;记不住,它就会用"缓存对不上、金额多一笔"的方式让你慢慢记住它。
本文的引用仅限自我学习如有侵权,请联系作者删除。
参考知识
汇总宽表要实时更新,又不想上大数据框架?阿里开源的 Canal 把 MySQL 改动变成事件流