一、先把场景说清楚:多源数据集成到底在解决什么问题
很多团队发展到一定阶段,都会遇到一个共同的烦恼:数据散落在好几个地方。订单在MySQL里,用户行为日志在MongoDB里,商品信息在PostgreSQL里,还有些数据在Oracle老系统里躺着。业务部门天天喊着要“全局视角”,老板要看实时报表,风控要跨库关联,搜索要近实时索引。这时候,把分散的数据统一同步到一个集中的存储(比如数据仓库、Elasticsearch、另一个MySQL实例)就成了刚需。
Canal是阿里巴巴开源的一个组件,核心能力是模拟MySQL主从复制的协议,把自己伪装成MySQL的从库,然后接收主库的binlog变更,从而拿到增删改的数据。它天生就是干这个活的。但你可能会问:一个Canal只能连一个MySQL,多源怎么办?
答案不是只靠Canal,而是靠一套架构设计。这篇文章就聊聊,当数据源头不止一个时,怎么用Canal做多源集成,以及多源同步里最让人头疼的“冲突”问题怎么解。
二、多源集成的架构设计:从“点对点”到“枢纽式”
2.1 最朴素的方案:一个源配一个Canal
刚开始接触Canal的人,最容易想到的架构是这样:MySQL1 -> Canal1 -> 目标库,MySQL2 -> Canal2 -> 目标库。每个Canal各管各的,互不干扰。这种“点对点”模式胜在简单,配置起来也快,适合数据源只有两三个、业务不复杂的场景。
但是一旦数据源多了,比如五个、八个,这种模式就会暴露出问题:你需要部署很多Canal实例,每台都要维护;目标端的连接数会暴涨;如果多个源里都有同一张业务表(比如不同分库里的订单表),你要在目标端搞多张表,业务查询时还得做union,这就很麻烦。
2.2 成熟的架构:源端多Canal,汇聚端统一处理
稍微正规一点的架构应该是这样的:每个MySQL源独立部署一个Canal实例,但Canal不会直接把数据写到最终目标,而是先写到一个中间层——消息队列(比如Kafka或RocketMQ)。然后再由一个统一的消费程序(或者直接用Canal的adapter)从消息队列里读数据,经过过滤、清洗、转换、合并之后,再写入目标存储。
这种架构的好处很明显:
- 解耦:Canal只负责“搬运”,不关心目标端长什么样。
- 削峰填谷:突发流量不怕,消息队列能缓冲。
- 统一收口:所有源的数据都汇到同一个队列或者同一个consumer里,方便做全局处理。
- 容错:某个Canal挂了,消息不会丢,拉起来继续消费就行。
下图就是这种架构的简化描述,你可以想象成:多个水龙头(数据源)都往一个蓄水池(消息队列)里放水,然后由一个水泵(消费程序)统一抽水到农田(目标库)。水龙头数量再多,水泵和农田那边只看到一股稳定的水流。
2.3 目标端表设计:多源合并的两种思路
多源数据的汇聚,最常见的是两种场景:
- 场景A:不同业务的表,各归各。比如MySQL1有订单表,MySQL2有用户表,MySQL3有商品表。那目标端直接建三张表就行,互不影响。
- 场景B:同一张业务表,拆在多库。比如订单按用户ID取模分库,order_db_0、order_db_1、order_db_2。这种情况下,我们希望在目标端只有一张“大订单表”,所有分库的数据都往里灌。
场景B是真正考验设计能力的地方。最简单的做法是,让每个Canal实例都把数据写到同一个Kafka topic,然后消费时根据表名和主键,用“upsert”的方式写入目标表。这时候就会遇到一个没法回避的问题:冲突。
三、多源同步冲突的本质和常见类型
说到冲突,先得明白冲突是怎么来的。多源同步里的冲突,本质上就是“两个源对同一条数据产生了不同的看法,但目标端只能保留一个结果”。
具体来说,常见的有这么几种情况:
3.1 主键冲突
最简单也最直白。两个不同的MySQL库,业务上都是订单表,订单ID都是自增的。结果库1里有一个订单ID=100,库2里也有一个订单ID=100,但它们分别代表不同的业务订单。如果直接照搬主键写入目标表,第二个肯定会撞车。
3.2 字段更新顺序冲突
同一业务主键的数据在两个库里都会更新。比如用户表,库1负责更新用户的昵称,库2负责更新用户的积分。当两个库几乎同时更新同一条记录时,目标端会收到两条消息:一条把昵称改为“小明”,另一条把积分改为“500”。如果消费程序处理这两条消息的顺序颠倒,就可能导致“旧数据覆盖新数据”的问题。
3.3 删除与更新冲突
库1里一条记录被删除了,库2里同一主键的记录还在更新。消费程序先处理删除,再处理更新,那目标端就会“死而复生”。或者反过来,先更新再删除,那倒是没问题,但你不能保证消息顺序永远正确。
3.4 结构性冲突
两个源里同一张表,字段A在库1里叫name,在库2里叫user_name;或者字段类型不一致,一个int一个string。这种冲突在集成阶段就得处理,否则消费端写数据时会报错。
四、冲突解决策略:实践中的几板斧
4.1 策略一:业务字段上的“全局唯一ID”
解决主键冲突最直接的思路是——别让两个源自己生成ID。我们在源端设计表时就预留一个全局唯一的业务ID,比如订单号用“全局发号器”(比如雪花算法)生成,而不是依赖数据库自增。这样即使两个分库的数据,其主键也不会重复。
如果源表已经用了自增ID,没法改呢?那就在目标端换一个“复合主键”:比如“source_id + original_id”。source_id用来标识数据来自哪个源,original_id是源库里的原始主键。这样至少不会因为主键撞车而写不进去。
我见过很多团队在实际中就是这么做的:目标表里加一个src_flag字段,值为“db1”或“db2”,然后主键设为(src_flag, id)。虽然看似多了一个字段,但解决了一大堆麻烦。
4.2 策略二:版本号(乐观锁)控制更新顺序
处理字段更新顺序冲突的标准做法,是引入“版本号机制”。源表增加一个version字段,每次更新时版本号+1。消费端拿到binlog里的数据后,比较消息里的版本号和目标端当前记录的版本号。只有消息版本大于目标端版本,才允许覆盖。
举个实际例子:库1更新了昵称,对应版本号是5;库2更新了积分,对应版本号是4。如果两条消息几乎同时到达,消费端先处理积分更新(版本4),发现目标端当前版本是3,那就更新;然后处理昵称更新(版本5),发现目标端版本已经是4了,5>4,继续更新。这样最终结果就是昵称为新,积分也为新。可如果反过来了,先处理的昵称(版本5),目标端从3跳到5;再处理积分(版本4),发现4<5,就丢弃。这样最终结果就是昵称新,积分旧——虽然丢了积分更新,但至少不会被旧昵称污染。比完全乱序好得多。
如果业务表里没有version字段,还可以用更新时间戳作为比较依据。但要注意,如果两个数据库主机的时钟不同步,时间戳比较就不可靠了。所以最稳妥的还是显式加版本号,由应用层或binlog里的事务ID参与决策。
4.3 策略三:按主键分桶路由,强制串行化
如果你觉得“版本号比较”逻辑很绕,还有一种更笨但更可靠的方法——让同一个主键的消息永远只被同一个消费者线程处理。在Kafka里,可以通过设置“按主键哈希分区”来实现。比如把消息的key设为目标主键(或复合主键),这样同一个key的消息会进入同一个分区,Kafka保证同一个分区内的消息是严格有序的。
这样一来,消费端逐个处理同一个主键的更新,就永远不会出现乱序。你只需要保证源端binlog发送到Kafka时,每个主键的变更消息确实是先后顺序产生的。Canal发送到Kafka时,如果你把table名加主键作为分区key,那这个顺序就能得到保证。
这种策略有个限制:消息吞吐量会被压制在单个分区的处理能力之内。但如果业务上同一个主键的更新频率不是极高,这完全是划算的。因为换来了“零冲突,无脑写”的省心体验。
4.4 策略四:删除延迟合并(墓碑机制)
删除与更新的冲突,通常用“墓碑”思路解决。做法是:当源端删除了某条数据时,Canal收到的binlog是一条DELETE语句,消费端不直接删除目标端记录,而是把目标端记录标记为“已删除”,比如增加一个deleted字段置为1,同时保留数据。
这样如果有后续的更新消息来了,消费端先看这个key对应的是不是“墓碑”,如果是,就不执行更新(因为源已经删了),或者根据业务需求把deleted字段重置回0并更新数据。但大多数情况下,删除就意味着“这条记录不该存在”,所以后续更新就直接丢弃。
等到一个确认窗口期(比如24小时)过后,如果这个主键没有再收到任何更新,消费端再真正把它从目标表里物理删除。这样可以避免“删除”和“更新”交叉带来的逻辑混乱。
4.5 策略五:结构冲突统一在ETL层转换
结构性冲突不在同步时的随机冲突,而是在规划阶段就要解决的。在消费端,可以做一个“字段映射配置”,把不同源的字段名统一到目标表的字段名。比如:
- db1的
name-> 目标表的user_name - db2的
user_name-> 目标表的user_name
类型不一致的,比如db1的age是int,db2的age是varchar,在消费端做一次cast转换。这事儿不用搞得很复杂,写一个轻量级的转换脚本,在插入目标表前执行就好。
五、实战示例:一个完整的多源同步流程(技术栈:Java + Spring Boot + Canal + Kafka + MyBatis-Plus)
为了让你更直观地感受整个设计,下面给一个能落地的示例。场景是:两个MySQL库(order_db_1和order_db_2)都有一张t_order表,主键都是自增id,但业务上同一订单在两个库不会重复(实际上不会,但为了演示冲突解决,我们假设订单id可能撞车)。目标库是一个MongoDB的orders集合,但为了照顾大多数读者,我们用MySQL目标库,表结构加一个src_flag作为复合主键的一部分。
5.1 目标表结构
-- 目标库中的订单表,用于存储来自两个源的数据
CREATE TABLE t_order_center (
src_flag VARCHAR(10) NOT NULL COMMENT '来源标识:db1或db2',
order_id BIGINT NOT NULL COMMENT '源库中的原始订单ID',
user_id BIGINT NOT NULL COMMENT '下单用户ID',
amount DECIMAL(10,2) NOT NULL COMMENT '订单金额',
status VARCHAR(20) NOT NULL COMMENT '订单状态',
update_time DATETIME NOT NULL COMMENT '更新时间,用于冲突比较',
PRIMARY KEY (src_flag, order_id)
) COMMENT='多源订单汇总表';
5.2 Canal配置(以db1为例,db2同理)
{
"canal.instance.mysql.slaveId": 1001,
"canal.instance.master.address": "192.168.1.101:3306",
"canal.instance.dbUsername": "canal_reader",
"canal.instance.dbPassword": "Canal@123",
"canal.instance.connectionCharset": "UTF-8",
"canal.instance.filter.regex": "order_db_1.t_order",
"canal.mq.topic": "order_sync_topic",
"canal.mq.partition.hash": "order_id",
"canal.mq.partition.hash.algorithm": "murmur"
}
db2的配置,把IP换成192.168.1.102,库名换成order_db_2,slaveId改成1002,其余相同。
5.3 Kafka消费者统一处理
下面是一个Consumer的核心代码,包含主键冲突规避、时间戳冲突处理和字段映射,全部注释完整。
// 技术栈:Java + Spring Boot + Kafka + MyBatis-Plus
import com.alibaba.fastjson.JSONObject;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@Component
public class OrderSyncConsumer {
private final OrderCenterMapper orderCenterMapper;
public OrderSyncConsumer(OrderCenterMapper orderCenterMapper) {
this.orderCenterMapper = orderCenterMapper;
}
/**
* 监听Kafka中的binlog消息,所有源Canal发送的数据都进入同一个topic
* @param record Kafka消息,value是Canal转发的JSON格式binlog数据
*/
@KafkaListener(topics = "order_sync_topic", groupId = "order-sync-group")
public void onMessage(ConsumerRecord<String, String> record) {
JSONObject msg = JSONObject.parseObject(record.value());
// 提取binlog中携带的原始数据
String database = msg.getString("database"); // 源库名,如 order_db_1
String table = msg.getString("table"); // 表名,这里固定为 t_order
String eventType = msg.getString("type"); // INSERT / UPDATE / DELETE
JSONObject data = msg.getJSONObject("data"); // 变更后的整行数据
// 通过源库名生成来源标记
String srcFlag = database.replace("order_db_", "db");
// 原始订单ID
Long orderId = data.getLong("id");
// 用“srcFlag + orderId”作为目标端的业务主键
OrderCenter order = new OrderCenter();
order.setSrcFlag(srcFlag);
order.setOrderId(orderId);
if ("DELETE".equalsIgnoreCase(eventType)) {
// 删除策略:先标记后物理删,这里用“墓碑法”简化,直接执行删除
// 真实场景可改为标记字段延迟删除
orderCenterMapper.deleteById(order);
return;
}
// 以下是INSERT和UPDATE的处理,统一按“存在则比较时间,否则插入”来执行
OrderCenter exist = orderCenterMapper.selectById(order);
if (exist == null) {
// 目标端无记录,直接插入
OrderCenter fresh = buildNewRecord(srcFlag, data);
orderCenterMapper.insert(fresh);
} else {
// 目标端已有记录,比较更新时间,只有新数据才覆盖
LocalDateTime incomingTime = parseTime(data.getString("update_time"));
LocalDateTime existingTime = exist.getUpdateTime();
// 如果消息里的更新时间晚于或等于库里已有时间,才执行更新
if (incomingTime.isAfter(existingTime) || incomingTime.equals(existingTime)) {
OrderCenter fresh = buildNewRecord(srcFlag, data);
fresh.setId(exist.getId()); // 保留主键ID做条件更新
orderCenterMapper.updateById(fresh);
}
// 否则丢弃这条旧消息,不做任何操作
}
}
/**
* 根据Canal解析出的数据行,构造成目标表实体
*/
private OrderCenter buildNewRecord(String srcFlag, JSONObject data) {
OrderCenter order = new OrderCenter();
order.setSrcFlag(srcFlag);
order.setOrderId(data.getLong("id"));
order.setUserId(data.getLong("user_id"));
order.setAmount(data.getBigDecimal("amount"));
order.setStatus(data.getString("status"));
order.setUpdateTime(parseTime(data.getString("update_time")));
return order;
}
/**
* 将字符串时间解析为LocalDateTime,兼容各种格式
*/
private LocalDateTime parseTime(String time) {
if (time == null || time.isEmpty()) {
return LocalDateTime.now();
}
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
return LocalDateTime.parse(time, formatter);
}
}
上面的代码里,最核心的就是“先查询是否存在,再比较更新时间”这段逻辑。这是一种典型的“在目标端做裁决”的方案,不需要源端配合,随时随地都能用。
但必须提醒你,这段代码在极端高并发下可能有问题:两个线程同时查到不存在,然后都执行insert,会导致目标端复合主键冲突。解决的办法有两个:
- 把消费并发度调为1(同一个分区单线程消费),这样就天然串行。
- 用数据库的“INSERT ... ON DUPLICATE KEY UPDATE”来原子化处理。
我更推荐你结合数据库特性做原子化,比如用MyBatis-Plus的自定义SQL:
-- 技术栈:MySQL原生SQL
INSERT INTO t_order_center
(src_flag, order_id, user_id, amount, status, update_time)
VALUES
(#{srcFlag}, #{orderId}, #{userId}, #{amount}, #{status}, #{updateTime})
ON DUPLICATE KEY UPDATE
user_id = IF(VALUES(update_time) >= update_time, VALUES(user_id), user_id),
amount = IF(VALUES(update_time) >= update_time, VALUES(amount), amount),
status = IF(VALUES(update_time) >= update_time, VALUES(status), status),
update_time = IF(VALUES(update_time) >= update_time, VALUES(update_time), update_time);
这种写法既处理了主键冲突,又通过update_time字段做了时间比较,一个语句解决两个问题,性能还高。
六、更多关联技术:你以为只有Canal?还有这些好搭档
6.1 Canal的另一个形态:Canal Adapter
如果你不想自己写消费者,Canal官方提供了Adapter组件,可以把他当成一个“现成的同步管道”。它的好处是内置了RDB(关系数据库)、ES、HBase等适配器,支持配置化同步,不需要写代码。
但是它的缺点是灵活性有限。如果你需要实现复杂的冲突解决逻辑(比如上面说的按源库优先级或版本号决策),还是自己写消费端更靠谱。所以我的建议是:场景简单且只需要单向同步,可以试试Adapter;场景复杂,还是自己做ETL吧。
6.2 Debezium:另一个多源同步选手
不少朋友会在选型时拿Debezium和Canal对比。Debezium也是干binlog同步这件事的,但它支持更多数据库(MySQL、PostgreSQL、SQL Server、MongoDB等),而且与Kafka Connect深度集成。如果你需要同时同步MySQL和PostgreSQL,那Canal就不太行了(Canal只支持MySQL),这时候可以选择Debezium。
但从国内团队使用习惯和运维成本来看,Canal依然很流行,因为不少团队的MySQL生态多,而且Canal的部署和管理相对轻量。
七、应用场景总结与注意事项
7.1 典型的应用场景
- 从库路由:多个业务库共享给一个运营后台查询,需要做实时汇总。比如订单中心、用户中心、商品中心的数据合并。
- 搜索索引构建:业务数据分散在多个MySQL,需要实时同步到Elasticsearch,供搜索和聚合分析使用。
- 数据仓库近实时入仓:把多源MySQL数据同步到ClickHouse或Doris,替代T+1的传统同步。
- 缓存重建:多源数据变化后同步到Redis或本地缓存,用于热点数据展示。
7.2 技术优缺点
先说优点:
- 实时性好,毫秒级延迟,基本能做到秒级可见。
- 对源库侵入性低,Canal模拟从库去拉binlog,不影响源库业务。
- 扩展性强,多源并行部署,架构天然支持水平扩展。
- 生态成熟,Kafka做缓冲,消费端可任意对接。
再说缺点:
- Canal只支持MySQL和MariaDB,不能覆盖其他数据库源。
- 部署运维有一定成本,尤其是Canal集群和Kafka集群需要监控。
- binlog带来的数据量大,可能占带宽,如果业务更新频繁,需要评估带宽和存储。
- 冲突解决方案需要业务方积极配合,很多情况下源表没有version字段,需要改建表或接受一定的数据不一致窗口。
7.3 实践中的注意事项
- 不要直接依赖业务时间:如果系统时钟不同步,时间比较冲突策略会失效。尽量在binlog消息里带上Canal的
executeTime,那是从MySQL事务提交时拿到的真实时间,比业务字段更可靠。 - 监控Canal同步位点:Canal的消费位点如果长时间停滞,会导致目标端数据严重滞后。建议配置一个监控脚本,定期检查位点延迟,超阈值就告警。
- 幂等性设计要贯穿始终:消费端宕机重启后可能重复消费Kafka消息。所有写入操作都要做到幂等,否则会导致数据重复或错乱。
- 目标端要留审计字段:比如
sync_time、src_flag、last_update_by。这样排查问题时有据可查。 - 测试阶段务必模拟多源并发:不要光测单条同步,一定要构造两个库同时更新同一条记录、删除后更新、网络延迟导致乱序等场景,验证你的冲突策略真的有效。
八、文章总结
多源数据集成不是一个能“一把梭”的简单任务,它需要在架构层面先理清数据流,再在细节层面设计好冲突对策。
架构上,建议走“一源一Canal + 消息队列 + 统一消费端”的成熟路线,让各个环节各司其职,避免点对点的大量耦合。冲突处理上,没有银弹,通常要做组合拳:用复合主键或者全局ID解决主键冲突,用版本号或时间戳解决更新顺序冲突,用墓碑机制解决删除和更新交叉问题,再配合ETL层做字段映射和类型转换搞定结构冲突。
再提醒一遍,如果你的系统对数据一致性要求极其严格,建议从源头就设计好版本号字段和全局唯一ID,这比事后在同步层“擦屁股”要省心一百倍。多源同步的尽头不是“把数据搬过去”,而是“搬到对、不丢、不乱”。希望这篇文章能让你在搭建自己的多源同步体系时,少踩几个坑,思路更清晰。
评论
围绕“Canal在多源数据集成场景下的架构设计与多源同步冲突的解决策略”参与讨论