1. 为什么分库分表场景下要单独聊 group-instance.xml
分库分表这件事,只要业务量稍微往上走一走,基本都绕不开。订单表按用户 ID 拆成 8 个库、每个库再拆 16 张表,这种配置在很多中型系统里已经是标配。数据拆开之后,最直接的问题就来了:数据同步怎么做?缓存怎么刷新?异构数据源怎么保持一致?
Canal 就是在这个环节里被大量使用的工具。它伪装成 MySQL 的从库,读取 binlog,把变更事件投递出来,下游可以接消息队列、可以写 ES、可以刷 Redis。单库单表的时候,一个 instance 配一个库,简单直接。但一旦进入分库分表,一个逻辑表散落在几十个物理库表里,如果还按单实例的思路去配,运维成本会直接爆炸。
group-instance.xml就是 Canal 为这种多库多表场景准备的一把钥匙。它的核心作用是:把多个物理实例(instance)组合成一个逻辑分组,让下游消费者感知到的是一个统一的数据源,而不是几十个零散的 instance。这篇文章我会把 group-instance.xml 的定位、配置结构、实操步骤、踩坑经验完整拆一遍,适合已经在用 Canal 做单库同步、准备往分库分表场景迁移的开发和运维同学参考。如果你还没接触过 Canal,建议先把单实例跑通再来看这篇,不然容易一头雾水。
2. group-instance.xml 到底解决了什么问题
2.1 分库分表下 Canal 的原始困境
先还原一下没有 group-instance 之前的场景。假设订单表 order 被拆成了 4 个库:db0、db1、db2、db3,每个库里有 order_0 到 order_3 四张表。Canal 要同步这些数据,最朴素的做法是配 4 个 instance,每个 instance 对应一个库,各自订阅自己的 binlog。
这样做的直接后果有三个。第一,下游消费者要同时连 4 个 Canal 客户端,自己去做合并和路由,业务代码里到处是"这个库连哪个 instance"的判断逻辑。第二,每个 instance 都要独立维护位点(position),一旦某个 instance 挂了重启,位点管理、重复消费、顺序性问题全都要单独处理。第三,运维层面 4 个 instance 就是 4 份配置、4 份日志、4 个监控项,库再拆多一点,比如 16 个库,运维直接崩溃。
更麻烦的是顺序性。同一个订单的变更可能因为分片规则落在不同库,如果下游要保证同一业务主键的顺序,多 instance 各自投递根本没法保证。这不是 Canal 的锅,是架构本身的问题,但 group-instance 提供了另一种解法。
2.2 group-instance 的核心思路
group-instance.xml 的思路其实很朴素:既然多个物理库本质上是同一份逻辑数据,那就把它们在 Canal 内部先"合起来",对外只暴露一个 instance 名字。下游消费者连的还是那个熟悉的 instance,但背后 Canal 帮你把多个物理库的 binlog 都读进来,统一投递。
这里有个关键点要理解清楚:group-instance 并不是把数据真正物理合并,它做的是逻辑分组。每个物理库依然有自己独立的 binlog 读取、独立的位点管理,只是在投递层做了一个聚合。所以它解决的是"消费端感知"的问题,不是"数据存储"的问题。
注意:group-instance 解决的是多库订阅的统一入口问题,它不会帮你做分片路由,也不会帮你做数据合并。下游拿到的还是原始的库表变更事件,只是这些事件来自同一个逻辑 instance。
2.3 和普通 instance 的本质区别
普通 instance 的配置里,canal.instance.master.address指向一个具体的 MySQL 地址,一个 instance 对应一个数据源。group-instance 则是在 instance 之上再包一层,通过canal.instance.detecting之类的机制去管理多个子 instance。
从配置文件的角度看,普通 instance 用的是instance.properties,group-instance 用的是group-instance.xml。前者是 properties 格式,后者是 Spring XML 格式,这个差异很多人第一次接触会懵。XML 里可以定义多个property节点,也可以引用外部的 properties 文件,灵活性更高,但也更容易配错。
从消费端看,普通 instance 你连example,group-instance 你连的是 group 的名字,比如order-group。对客户端来说,连接方式、订阅方式基本没变,变的只是背后的数据来源从一个库变成了多个库。
3. group-instance.xml 的配置结构拆解
3.1 文件位置与加载机制
group-instance.xml 放在 Canal 的conf目录下,具体路径是conf/{group-instance-name}/group-instance.xml。比如你定义一个叫order-group的分组,那目录结构就是:
conf/ order-group/ group-instance.xml instance.properties # 可选,用于覆盖默认配置Canal Server 启动时会扫描 conf 目录下的所有子目录,每个子目录如果包含instance.properties或group-instance.xml,就会被识别为一个 instance 或 group instance。这里有个容易踩的坑:如果同一个目录下同时存在instance.properties和group-instance.xml,Canal 会优先按 group-instance 处理,但配置项会互相覆盖,行为很难预测。所以建议一个目录只放一种配置文件。
加载顺序上,Canal 先读group-instance.xml里的 Spring bean 定义,再读同目录下的instance.properties做属性覆盖。这个机制意味着你可以把公共配置放在 XML 里,把环境相关的配置(比如数据库地址、账号密码)放在 properties 里,方便不同环境切换。
3.2 核心配置项逐条说明
group-instance.xml 里最关键的几个配置项,我按重要性排一下:
| 配置项 | 作用 | 典型值 |
|---|---|---|
| canal.instance.master.address | 主库地址,group 模式下通常指向第一个库 | 127.0.0.1:3306 |
| canal.instance.dbUsername | 数据库账号 | canal |
| canal.instance.dbPassword | 数据库密码 | canal |
| canal.instance.filter.regex | 过滤规则,决定订阅哪些库表 | .\.. |
| canal.instance.connectionCharset | 连接字符集 | UTF-8 |
| canal.instance.detecting.enable | 是否开启多库探测 | true |
| canal.instance.detecting.sql | 探测 SQL,用于发现分库 | show databases |
这里重点说detecting相关的配置。group-instance 之所以能管理多个库,靠的就是 detecting 机制。开启后,Canal 会定期执行detecting.sql,根据返回结果动态发现需要订阅的库。比如你配了show databases,返回里所有匹配filter.regex的库都会被纳入订阅范围。
提示:detecting 机制适合库数量动态变化的场景,比如按业务线分库、按租户分库。如果库是固定的,直接写死地址列表反而更稳定,避免探测带来的额外开销。
3.3 filter.regex 的写法与陷阱
filter.regex是 group-instance 里最容易配错的一项。它的格式是库名正则.表名正则,中间用点号分隔。比如order_db\\..*表示订阅 order_db 库下的所有表,.*\\..*表示订阅所有库所有表。
分库分表场景下,库名通常有规律,比如order_db_0到order_db_7,那正则可以写成order_db_\\d+\\..*。表名如果是order_0到order_15,可以写成order_db_\\d+\\.order_\\d+。
这里有个坑:Java 正则里反斜杠要转义,所以在 properties 文件里写\\d,在 XML 里写\\d或者用 CDATA 包裹。很多人第一次配的时候只写了一个反斜杠,结果正则匹配不上,排查半天。
另一个坑是 filter.regex 的匹配是全匹配,不是包含匹配。也就是说order_db不会匹配order_db_0,必须写成order_db.*或者order_db_\\d+。这个和很多人的直觉相反,务必注意。
4. 分库分表场景下的完整实操步骤
4.1 环境准备与前置检查
动手之前,先把几个前置条件确认清楚。第一,MySQL 必须开启 binlog,且格式为 ROW。show variables like 'binlog_format'返回必须是 ROW,MIXED 和 STATEMENT 都不行,因为 Canal 解析 ROW 格式的 binlog 才能拿到完整的行变更数据。第二,binlog 的binlog_row_image建议设为 FULL,这样 update 事件里能拿到变更前后的完整字段,下游处理更方便。
第三,Canal 账号要有 REPLICATION SLAVE 和 REPLICATION CLIENT 权限。授权语句大概是:
CREATE USER 'canal'@'%' IDENTIFIED BY 'canal'; GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%'; FLUSH PRIVILEGES;第四,确认所有分库的 server_id 不冲突。Canal 伪装成从库,每个 instance 会占用一个 server_id,如果多个 instance 的 server_id 重复,MySQL 会拒绝连接。group-instance 模式下,每个子 instance 的 server_id 由 Canal 自动分配,但建议在配置里显式指定,避免冲突。
4.2 编写 group-instance.xml
下面是一个分库分表场景的完整配置示例,假设订单表拆成 4 个库,每个库 4 张表:
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd"> <bean class="com.alibaba.otter.canal.instance.spring.support.PropertyPlaceholderConfigurer" lazy-init="false"> <property name="ignoreResourceNotFound" value="true" /> <property name="systemPropertiesModeName" value="SYSTEM_PROPERTIES_MODE_OVERRIDE" /> <property name="locationNames"> <list> <value>classpath:canal.properties</value> <value>classpath:instance.properties</value> </list> </property> </bean> <bean id="instance" class="com.alibaba.otter.canal.instance.spring.CanalInstanceWithSpring"> <property name="destination" value="order-group" /> <property name="eventParser"> <bean class="com.alibaba.otter.canal.parse.inbound.mysql.MysqlEventParser"> <property name="destination" value="order-group" /> <property name="slaveId" value="${canal.instance.mysql.slaveId}" /> <property name="filterQueryDcl" value="false" /> <property name="filterQueryDml" value="false" /> <property name="filterQueryDdl" value="false" /> <property name="filterRows" value="false" /> <property name="connectionCharset" value="UTF-8" /> <property name="detectingEnable" value="true" /> <property name="detectingSQL" value="show databases" /> <property name="masterAddress" value="${canal.instance.master.address}" /> <property name="dbUsername" value="${canal.instance.dbUsername}" /> <property name="dbPassword" value="${canal.instance.dbPassword}" /> <property name="filter" value="${canal.instance.filter.regex}" /> </bean> </property> </bean> </beans>这个配置里,detectingEnable设为 true,detectingSQL用show databases,配合filter.regex就能自动发现所有匹配的库。slaveId从 properties 里读,方便不同环境用不同的值。
4.3 配套 instance.properties 的写法
同目录下的instance.properties负责提供具体参数:
canal.instance.master.address=127.0.0.1:3306 canal.instance.dbUsername=canal canal.instance.dbPassword=canal canal.instance.mysql.slaveId=1234 canal.instance.filter.regex=order_db_\\d+\\..* canal.instance.connectionCharset=UTF-8 canal.instance.tsdb.enable=true canal.instance.tsdb.dir=${canal.home}/conf/order-group/tsdbfilter.regex这里写的是order_db_\\d+\\..*,匹配 order_db_0 到 order_db_9 的所有表。如果你只想订阅 order 相关的表,可以改成order_db_\\d+\\.order_\\d+。
tsdb.enable建议开启,它会把表结构变更记录到本地,下游解析 binlog 时能拿到正确的字段类型。分库分表场景下,多个库的表结构可能不完全一致,tsdb 能帮你兜底。
4.4 启动与验证
配置写完后,启动 Canal Server。启动日志里会看到类似这样的输出:
start CannalInstance for order-group detecting databases: [order_db_0, order_db_1, order_db_2, order_db_3] subscribe binlog from order_db_0 subscribe binlog from order_db_1 subscribe binlog from order_db_2 subscribe binlog from order_db_3看到 detecting 列出了所有分库,说明配置生效了。接下来用客户端连上去验证:
CanalConnector connector = CanalConnectors.newSingleConnector( new InetSocketAddress("127.0.0.1", 11111), "order-group", "", "" ); connector.connect(); connector.subscribe("order_db_\\d+\\..*"); Message message = connector.get(100);如果能在 message 里看到来自不同库的变更事件,说明 group-instance 已经正常工作。这里注意,客户端订阅的 filter 要和服务端的 filter 配合,客户端可以进一步缩小范围,但不能超出服务端的范围。
5. 常见问题与排查技巧实录
5.1 位点管理混乱导致重复消费
group-instance 模式下,每个子 instance 有独立的位点,但对外暴露的是一个逻辑 instance。如果某个子 instance 重启,它的位点可能回退,导致重复投递。这个问题在多库场景下特别隐蔽,因为下游看到的是同一个 instance,很难判断是哪个库重复了。
排查思路:先看 Canal 日志里每个子 instance 的位点变化,对比 MySQL 的show master status,确认位点是否正常推进。如果发现某个库的位点卡住,检查该库的 binlog 是否被清理、网络是否稳定、账号权限是否正常。
解决方式:开启 tsdb 记录位点,定期备份位点文件。Canal 的位点默认存在conf/{instance}/meta.dat,建议把这个文件纳入监控,位点长时间不更新就告警。
5.2 filter.regex 匹配不上
这是最高频的问题。表现是 Canal 启动正常,但收不到任何数据。排查步骤:
- 确认
filter.regex的转义是否正确,Java 正则里\d要写成\\d。 - 确认库名表名是否真的匹配,可以用
show databases和show tables手动核对。 - 确认客户端订阅的 filter 是否和服务端一致,客户端 filter 不能超出服务端范围。
- 确认 binlog 格式是 ROW,
show variables like 'binlog_format'检查。
提示:filter.regex 是全匹配,
order_db不会匹配order_db_0。这个坑我见过太多次,建议配置完后用在线正则工具先验证一遍。
5.3 多库顺序性问题
分库分表下,同一个业务主键的变更可能落在不同库,group-instance 虽然统一了入口,但投递顺序不保证。比如订单创建在 db0,订单支付在 db1,下游可能先收到支付事件再收到创建事件。
这个问题没有银弹。如果业务强依赖顺序,有两个方向:一是下游按业务主键做缓冲和排序,二是把同一业务主键的变更路由到同一个库(这需要改分片规则)。Canal 层面能做的有限,主要是保证单个库内的顺序,跨库顺序需要业务侧配合。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 启动后无数据 | filter.regex 不匹配 | 检查正则转义和库表名 |
| 部分库无数据 | detecting 未发现该库 | 检查 detecting.sql 和权限 |
| 重复消费 | 位点回退 | 检查 meta.dat 和 binlog 保留时间 |
| 连接被拒 | slaveId 冲突 | 检查各 instance 的 slaveId |
| 字段类型错误 | tsdb 未开启 | 开启 tsdb 并重建 |
| 内存溢出 | 单批数据量过大 | 调整 canal.instance.transaction.size |
6. 性能调优与生产环境经验
6.1 批量拉取与内存控制
group-instance 模式下,多个库的数据会汇聚到一个投递通道,单批数据量容易变大。canal.instance.transaction.size控制单次事务的最大条数,默认 1024,分库分表场景建议调到 2048 或 4096,但不要太大,否则内存压力大。
canal.instance.memory.buffer.size控制内存缓冲大小,默认 16384,单位是字节。如果下游消费慢,缓冲会堆积,建议根据下游吞吐调整。我一般会把它设成 32768,配合监控观察堆积情况。
6.2 并行度与线程模型
Canal 的解析线程数由canal.instance.parser.parallel控制,默认是 CPU 核数。分库分表下,多个库的 binlog 可以并行解析,但投递是串行的。如果发现解析是瓶颈,可以适当调大这个值;如果投递是瓶颈,调大没用,得从下游消费能力入手。
还有个参数canal.instance.parser.parallelBufferSize,控制并行解析的缓冲队列大小,默认 256。库多的时候建议调到 512 或 1024,避免队列满导致解析阻塞。
6.3 监控指标与告警配置
生产环境必须监控的几个指标:
- 位点延迟:当前位点和 MySQL 最新位点的差值,超过阈值告警。
- 投递速率:每秒投递的事件数,突降说明有问题。
- 内存使用:缓冲队列的使用率,超过 80% 告警。
- 子 instance 状态:每个子 instance 是否存活,挂了要立即知道。
Canal 自带 JMX 指标,可以接 Prometheus 或者自己写脚本采集。我一般会在 Canal 外面包一层健康检查,定期调connector.getWithoutAck(1)看是否有数据返回,没返回就告警。
注意:group-instance 的子 instance 状态在原生监控里不够直观,建议自己写脚本解析日志,把每个子 instance 的位点单独打点,这样出问题能快速定位到具体是哪个库。
7. 几个我踩过的坑和实操心得
第一个坑是 detecting 机制的延迟。detecting.sql默认是定时执行的,间隔由canal.instance.detecting.interval.time控制,默认 5 秒。如果新加了一个分库,最多要等 5 秒才能被发现。这个延迟在扩容场景下会有影响,建议把间隔调小到 1 秒,或者干脆用固定地址列表。
第二个坑是 filter.regex 的优先级。服务端的 filter 和客户端的 filter 是取交集的,如果服务端配了order_db_\\d+\\..*,客户端配了.*\\..*,实际生效的是服务端的范围。很多人以为客户端能扩大范围,其实不能。
第三个坑是 tsdb 的清理。tsdb 会记录所有表结构变更,时间长了文件会很大。建议定期清理,或者设置canal.instance.tsdb.spring.xml里的过期时间。我见过一个案例,tsdb 文件涨到几十 G,把磁盘撑爆了。
第四个坑是 slaveId 的分配。group-instance 下每个子 instance 需要一个唯一的 slaveId,如果配置里写死了一个值,多个子 instance 会冲突。正确做法是用${canal.instance.mysql.slaveId}配合自动递增,或者手动给每个库分配不同的值。
最后一个心得:分库分表场景下,Canal 的配置复杂度会指数级上升,建议先用一两个库跑通,再逐步扩展。不要一上来就配几十个库,出了问题根本没法排查。另外,group-instance 虽然好用,但它不是万能的,如果业务对顺序性要求极高,或者分片规则经常变,可能需要考虑其他方案,比如在应用层做双写,或者用专门的数据同步中间件。
这个内容后续还可以这样扩展:一是结合具体消息队列(比如 Kafka、RocketMQ)讲下游消费的最佳实践,二是讲 group-instance 和 Canal Admin 的集成,三是讲多机房场景下的位点同步。这些方向我后续会单独写,感兴趣可以先关注。