解决双写不一致!Canal+Outbox+Kafka 高可靠事件驱动架构

如何用 Canal + Outbox + Kafka 构建高可靠的事件驱动架构

一个"关注"操作背后,到底该写几次数据库、更新几层缓存?本文聊聊我在项目中基于 Outbox 模式的事件驱动实践。


一、从"双写不一致"说起

社交产品里,用户点击"关注"是一个看似简单、实则暗藏玄机的操作。一个关注行为发生后,系统至少需要做这些事:

  1. 写入 following 表(记录关注关系)
  2. 写入 follower 表(更新粉丝列表)
  3. 更新 Redis 关注数 / 粉丝数计数
  4. 更新 Redis ZSet 缓存(关注列表 / 粉丝列表)

如果未来还有推荐系统、搜索索引、数据仓库......那写入点只会更多。

最直接的做法:在业务代码里依次写 DB、写 Redis、发 MQ。这样做的问题是什么呢?只要任意一步失败,数据就永远不一致了。而且 DB 事务只能管住 DB 自己,管不了 Redis,更管不了 Kafka。

这就是经典的"双写不一致"问题:两个或多个数据源之间没有原子性保证,一步成功、一步失败,后续就是无穷无尽的数据对账和人工修复。


二、核心思路:把"发消息"变成"写数据库"

我们采用的方案是 Canal + Outbox 表 + Kafka 的事件驱动架构。它只有一个核心思想:

把"发消息"变成"写数据库",利用 MySQL 本地事务保证原子性,再通过 Canal 监听 binlog 异步投递。

整个数据流是这样的:

复制代码
业务层                             中间层                        消费层
+----------------+    binlog    +----------------+   Kafka   +--------------------+
|  写 following  | ----------> | CanalKafka     | --------> | 粉丝表更新          |
| + outbox 表    |             | Bridge         |           | ZSet 缓存更新       |
| (同一事务)      |             | (CDC 桥接)     |           | 计数更新            |
+----------------+             +----------------+           | ES 搜索索引更新     |
                                                            +--------------------+

关键步骤只有三步:

  1. 业务层 :在 @Transactional 事务中,同时写入业务表(如 following)和 outbox 事件表。DB 事务提交成功,事件一定生成;DB 回滚,事件一定不会产生。

  2. 中间层 :Canal 伪装成 MySQL 从库,监听 outbox 表的 binlog 变更,通过 CanalKafkaBridge 将事件推送到 Kafka。

  3. 消费层:多个独立的消费者组订阅 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 全量快照 :支持 followingfollower 全量重建
  • 定时对账任务 :每日自动比对 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 异步投递,让下游消费者各自独立处理投影。

它带来的好处是实打实的:

  • 业务代码简单,不需要操心分布式事务
  • 不丢消息,事件与业务数据严格一致
  • 下游完全解耦,挂了不影响主链路
  • 天然支持幂等,数据可修复、可重建
  • 新功能零侵入,只需增加消费者即可

在这个"高变更 + 高查询 + 大体量"的社交关系场景中,这套架构经受住了考验,也为未来的业务演进留下了充足的想象空间。


相关推荐
basketball6161 小时前
Redis基础:2. Redis 常用命令
数据库·redis·缓存
商业模式源码开发1 小时前
知识付费推三返一模式详解:规则设计、分红算法与合规架构
算法·架构·推三返一
GIOTTO情2 小时前
智能媒介投放技术迭代:从人工规则调控到AI全域动态调度的架构演进
人工智能·架构
东方巴黎~Sunsiny2 小时前
实战:RocketMQ 幂等 + Redis 分布式锁 + 异常重试 保姆级教程
redis·分布式·rocketmq
珠海西格电力2 小时前
西格电力零碳园区管理系统的技术架构是怎样的?
大数据·运维·人工智能·物联网·架构·能源
zhangfeng11332 小时前
定制化,面向大语言模型的GPU,Etched 把 Transformer 架构直接“烧“进硅片
语言模型·架构·transformer·芯片
basketball6162 小时前
Redis基础:3. Redis 持久化(重要)
redis·bootstrap·mybatis
AI科技星2 小时前
引电统一方程:严格推导与量纲零错误验证
人工智能·算法·机器学习·架构·学习方法