如何用 Canal + Outbox + Kafka 构建高可靠的事件驱动架构
一个"关注"操作背后,到底该写几次数据库、更新几层缓存?本文聊聊我在项目中基于 Outbox 模式的事件驱动实践。
一、从"双写不一致"说起
社交产品里,用户点击"关注"是一个看似简单、实则暗藏玄机的操作。一个关注行为发生后,系统至少需要做这些事:
- 写入
following表(记录关注关系) - 写入
follower表(更新粉丝列表) - 更新 Redis 关注数 / 粉丝数计数
- 更新 Redis ZSet 缓存(关注列表 / 粉丝列表)
如果未来还有推荐系统、搜索索引、数据仓库......那写入点只会更多。
最直接的做法:在业务代码里依次写 DB、写 Redis、发 MQ。这样做的问题是什么呢?只要任意一步失败,数据就永远不一致了。而且 DB 事务只能管住 DB 自己,管不了 Redis,更管不了 Kafka。
这就是经典的"双写不一致"问题:两个或多个数据源之间没有原子性保证,一步成功、一步失败,后续就是无穷无尽的数据对账和人工修复。
二、核心思路:把"发消息"变成"写数据库"
我们采用的方案是 Canal + Outbox 表 + Kafka 的事件驱动架构。它只有一个核心思想:
把"发消息"变成"写数据库",利用 MySQL 本地事务保证原子性,再通过 Canal 监听 binlog 异步投递。
整个数据流是这样的:
业务层 中间层 消费层
+----------------+ binlog +----------------+ Kafka +--------------------+
| 写 following | ----------> | CanalKafka | --------> | 粉丝表更新 |
| + outbox 表 | | Bridge | | ZSet 缓存更新 |
| (同一事务) | | (CDC 桥接) | | 计数更新 |
+----------------+ +----------------+ | ES 搜索索引更新 |
+--------------------+
关键步骤只有三步:
-
业务层 :在
@Transactional事务中,同时写入业务表(如following)和outbox事件表。DB 事务提交成功,事件一定生成;DB 回滚,事件一定不会产生。 -
中间层 :Canal 伪装成 MySQL 从库,监听
outbox表的 binlog 变更,通过CanalKafkaBridge将事件推送到 Kafka。 -
消费层:多个独立的消费者组订阅 Kafka 消息,各自异步处理------更新粉丝表、刷新 ZSet 缓存、修正计数、构建 ES 索引......
三、表结构设计
3.1 关注表(following)------ 唯一权威主表
sql
CREATE TABLE following (
id BIGINT UNSIGNED NOT NULL,
from_user_id BIGINT UNSIGNED NOT NULL, -- 关注者
to_user_id BIGINT UNSIGNED NOT NULL, -- 被关注者
rel_status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_from_to (from_user_id, to_user_id),
KEY idx_from_created (from_user_id, created_at, to_user_id, rel_status),
KEY idx_to (to_user_id, from_user_id, rel_status)
);
3.2 粉丝表(follower)------ 投影表,可重建
sql
CREATE TABLE follower (
id BIGINT UNSIGNED NOT NULL,
to_user_id BIGINT UNSIGNED NOT NULL, -- 被关注者(视角转换)
from_user_id BIGINT UNSIGNED NOT NULL, -- 粉丝
rel_status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_to_from (to_user_id, from_user_id),
KEY idx_to_created (to_user_id, created_at, from_user_id, rel_status),
KEY idx_from (from_user_id, to_user_id, rel_status)
);
设计哲学:
following以关注者为核心视角,优化关注操作和"我关注了谁"的查询;follower以被关注者为核心视角,优化粉丝列表和"谁关注了我"的查询;- 两张表数据完全同步,但索引设计不同,各自服务不同的查询场景;
- 核心原则 :只有
following是真理(source of truth),follower只是它的投影(projection)。
3.3 Outbox 事件表
sql
CREATE TABLE outbox (
id BIGINT UNSIGNED NOT NULL,
aggregate_type VARCHAR(64) NOT NULL, -- "following" / "know_post"
aggregate_id BIGINT UNSIGNED NULL, -- 关联的业务ID
type VARCHAR(64) NOT NULL, -- "FollowCreated" / "KnowPostPublished"
payload JSON NOT NULL, -- 事件详情
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY ix_outbox_agg (aggregate_type, aggregate_id),
KEY ix_outbox_ct (created_at)
);
payload 采用 JSON 格式的设计非常关键------不同事件类型有不同字段,JSON 不需要频繁修改表结构,下游消费者按需解析即可,灵活度极高。
四、代码实现
4.1 业务层:事务内双写
java
@Override
@Transactional
public boolean follow(long fromUserId, long toUserId) {
// 限流:Lua 脚本令牌桶
Long ok = redis.execute(tokenScript,
List.of("rl:follow:" + fromUserId), "100", "1");
if (ok == null || ok == 0L) {
return false;
}
long id = ThreadLocalRandom.current().nextLong(Long.MAX_VALUE);
int inserted = mapper.insertFollowing(id, fromUserId, toUserId, 1);
if (inserted > 0) {
// 同事务内写入 outbox 表
Long outId = ThreadLocalRandom.current().nextLong(Long.MAX_VALUE);
String payload = objectMapper.writeValueAsString(
new RelationEvent("FollowCreated", fromUserId, toUserId, id));
outboxMapper.insert(outId, "following", id, "FollowCreated", payload);
return true;
}
return false;
}
要点:
following写入和outbox写入在同一个@Transactional中,MySQL 单机事务保证二者原子性;- 事件生成完全不需要关心 Kafka 是否在线------这就是"把发消息变成写数据库"的威力;
- 用户请求只经历一次 DB 事务,不掺杂 Redis、MQ、ES 等外部依赖,P99 延迟极低。
4.2 Canal 桥接层
Canal 伪装成 MySQL 从库,实时订阅 binlog。配置过滤规则只关注 outbox 表:
canal.filter = .*\\.outbox
CanalKafkaBridge 将 binlog 中的 INSERT 事件解析后序列化为 JSON,推送到 Kafka Topic canal-outbox。只处理 INSERT 类型事件,保证高效。
4.3 消费层:投影更新
java
@KafkaListener(topics = "canal-outbox", groupId = "relation-outbox-consumer")
public void onMessage(String message, Acknowledgment ack) {
RelationEvent evt = parseEvent(message);
// 幂等去重
String dedupKey = "outbox:dedup:" + evt.getOutboxId();
if (!redis.setIfAbsent(dedupKey, "1", Duration.ofMinutes(10))) {
ack.acknowledge();
return;
}
if ("FollowCreated".equals(evt.getType())) {
// 写粉丝表
mapper.insertFollower(...);
// 更新 ZSet 缓存
redis.opsForZSet().add("uf:flws:" + evt.getFromUserId(), ...);
redis.opsForZSet().add("uf:fans:" + evt.getToUserId(), ...);
// 更新计数
userCounterService.incrementFollowings(evt.getFromUserId(), 1);
userCounterService.incrementFollowers(evt.getToUserId(), 1);
} else if ("FollowCanceled".equals(evt.getType())) {
// 逆向操作...
}
ack.acknowledge();
}
幂等设计 :使用 Redis setIfAbsent 做去重键(10 分钟过期),防止 Kafka 重平衡或网络抖动导致的消息重复消费。对于搜索索引场景,使用 ES upsert(覆盖写入),天然幂等。
五、这套架构解决了什么问题?
5.1 彻底消除"双写不一致"
| 方案 | 原子性保证 | 失败影响 |
|---|---|---|
| 直接写 DB + Redis + MQ | 无保证 | 一步失败就永久不一致 |
| 分布式事务 XA/二阶段 | 有保证但代价大 | 锁时间长、性能差、任一参与者挂全挂 |
| Outbox + Canal + Kafka | DB 事务保证 | 下游挂了不影响主链路,事后自动追平 |
5.2 写路径极轻,吞吐大幅提升
传统的"关注时写所有下游"方案:
- 写
following+ 写follower+ 写 Redis 计数 + 写 Redis ZSet = 4 次写入 - 高并发下的网络跳转和锁竞争导致性能急剧下降
Outbox 方案:
- 关注时只做一件事:写
following+ 写outbox(一次 DB 事务) - 其余全部异步"自己去处理",不阻塞用户请求
- 写性能大幅提升,P99 延迟显著降低
5.3 下游服务彻底解耦,角色分明
架构中的角色分工非常明确:
following表 = 权威数据源(Source of Truth)follower表 = 投影(Projection),可随时丢弃并重建- Redis 计数 / ZSet 缓存 = 派生数据,可随时通过事件回放修复
这意味着:下游任何服务挂了,都不会影响主链路。你可以随时通过全量扫描 following 表重建 follower 表,通过事件回放重建计数和缓存。
5.4 无限可扩展
如果未来要新增推荐画像、数据仓库、风控系统、搜索引擎等下游服务:
- 无需修改关注代码
- 只需新增消费者,订阅同一事件,做自己的投影
这就是事件驱动架构最大的价值------业务演进与主线代码彻底解耦。
5.5 失败可恢复:重试 + 重放 + 对账 + 修复
这套架构提供了完整的"可修复"能力:
- Kafka 消息保留 + 消费者组机制:消费者挂了重启后从上次提交的位点继续消费,不丢消息
- Outbox 事件回放:下游投影可随时重建,计数自动修正
- Canal 全量快照 :支持
following→follower全量重建 - 定时对账任务 :每日自动比对
following聚合值与 Redis 计数,发现不一致自动修复
六、项目中的完整事件清单
| 业务场景 | 事件类型 | 消费者 | 处理内容 |
|---|---|---|---|
| 文章元数据更新 | KnowPostMetadataUpdated |
search-index-consumer | ES 索引 upsert |
| 文章发布 | KnowPostPublished |
search-index-consumer | ES 索引 upsert |
| 文章软删除 | KnowPostDeleted |
search-index-consumer | ES 索引软删除 |
| 关注用户 | FollowCreated |
relation-outbox-consumer | 写粉丝表 + ZSet 缓存 + 计数 |
| 取消关注 | FollowCanceled |
relation-outbox-consumer | 删粉丝表 + 移除缓存 + 计数 |
值得指出的是,Outbox 机制在"渐进式发布流程"中也发挥了重要作用------用户点击发布后,只需等数据库事务提交(毫秒级),不需要等 ES 索引构建完成(可能几百毫秒),用户体验更加流畅。
七、常见追问与解答
Q: Outbox 和直接在业务代码里发 MQ 的区别?
A: Outbox 利用 MySQL 事务保证原子性,"发消息"变成"写库",不会丢事件。直接发 MQ 是跨系统操作,没有事务保证------DB 成功了但 MQ 发送失败,数据就丢了。
Q: Canal 挂了怎么办?
A: Canal 原生支持 HA 模式(基于 Zookeeper 选主),挂了自动切备机。即使短暂不可用,MySQL binlog 不会丢,恢复后继续消费。
Q: 消息重复消费怎么处理?
A: 消费者侧做幂等。关系事件用 Redis setIfAbsent 做去重键(10 分钟过期);搜索索引用 upsert(覆盖写入),天然幂等。
Q: Outbox 表会不会无限膨胀?
A: 需要定时清理。可以按 created_at 索引定期删除 N 天前的记录,或者 Canal 消费后标记为已处理再清理。
Q: 为什么用 Canal 而不是 Debezium?
A: Canal 是阿里开源,国内社区活跃,中文文档丰富,轻量且与项目技术栈高度匹配。Debezium 也很优秀,但更重,需要额外部署 Kafka Connect。
Q: 为什么 Canal 要先推到 Kafka,而不是直接写 ES?
A: 解耦。Kafka 作为中间层,允许多个消费者组独立消费同一事件------搜索索引、关系缓存、计数服务各自独立,互不影响。
Q: 事件的顺序性怎么保证?
A: Kafka 同一 key 的消息会发到同一分区,分区内有序。Outbox 消息按 created_at 排序,Canal 按 binlog 顺序推送,共同保证了事件的先后顺序。
Q: payload 为什么设计成 JSON?
A: 灵活。不同事件类型有不同字段,JSON 不需要改表结构,下游消费者按需解析。
八、总结
这套 Canal + Outbox + Kafka 事件驱动架构的精髓可以浓缩为一句话:
把"发消息"变成"写数据库",利用 MySQL 本地事务保证原子性,再通过 binlog CDC 异步投递,让下游消费者各自独立处理投影。
它带来的好处是实打实的:
- 业务代码简单,不需要操心分布式事务
- 不丢消息,事件与业务数据严格一致
- 下游完全解耦,挂了不影响主链路
- 天然支持幂等,数据可修复、可重建
- 新功能零侵入,只需增加消费者即可
在这个"高变更 + 高查询 + 大体量"的社交关系场景中,这套架构经受住了考验,也为未来的业务演进留下了充足的想象空间。