从一次关注到最终一致:Canal、Outbox 与 Kafka 如何解决关系链双写问题

从一次关注到最终一致:Canal、Outbox 与 Kafka 如何解决关系链双写问题

知光是一个知识获取与分享社区。用户可以发布图文"知文"、浏览首页 Feed、进入详情页查看作者,也可以关注作者。在页面上,一次关注看起来只是把按钮从"关注"切换成"已关注",后台却要同时回答几类问题:A 关注了谁、谁关注了 B、A 一共关注了多少人、B 一共有多少粉丝,以及下一次打开列表时怎样快速返回结果。

这些问题对应的并不是一份完全相同的数据。following 保存主动关系,适合从关注者的角度查询"我关注了谁";follower 保存反向关系,适合从被关注者的角度查询"谁关注了我";Redis ZSet 缓存两种列表,用户计数则保存在另一份紧凑的 Redis 数据结构中。

最直观的实现是:A 关注 B 时先写 following(A,B),再根据固定规则写入 follower(B,A),最后更新两个缓存和两个计数。这种推导没有任何困难。事件驱动设计也没有放弃这条推导规则,而是改变了它发生的位置:请求线程只确认 following 这一份业务事实,并在同一事务中留下 Outbox 事件;follower、计数和缓存随后根据事件异步生成。

这篇文章要回答的核心问题因此不是"系统能不能直接反向写 follower",而是:当一份关注事实会驱动越来越多的下游状态时,为什么要把可推导的数据从同步写路径中移出去,以及 Canal、Outbox、Kafka 和关系事件处理器分别解决什么问题。

需求背景决定了不能只看眼前的两次 INSERT

如果需求永远停留在"保存一条关注关系",一次事务写两张表就足够了。关系链成为系统能力以后,设计目标会发生变化。

第一,关注结果必须立刻明确。用户点击按钮后,需要马上知道这次操作是否成立,再次查询关系状态时也必须得到确定答案。因此至少要有一份能够立即提交和查询的权威数据。

第二,关注列表和粉丝列表都是高频读接口,而且访问方向相反。following 以关注者为入口,follower 以被关注者为入口。普通用户两边规模相近,热门作者却可能拥有远多于关注数的粉丝数,这两个方向的容量、缓存和分片策略并不相同。

第三,列表、计数和缓存允许短暂延迟。按钮状态要求立即正确,不代表页面上的粉丝总数也必须在同一毫秒变化。只要系统能在稍后追平,并能在异常后重新计算,这些读取数据就可以离开核心事务。

第四,关注事件会被其他业务复用。今天的下游只有 follower、ZSet 和用户计数,明天可能增加通知、推荐、搜索、风控和数据分析。如果每新增一个需求都修改关注事务,核心接口会逐渐变成所有系统的同步编排器。

这四项需求构成了架构选择的前提:关系状态需要一份立即一致的事实,反向列表和聚合结果则追求读性能、独立扩展和最终一致。Canal + Outbox + Kafka 不是从中间件出发设计业务,而是从这种数据等级差异出发,将必须同步的部分和允许异步的部分拆开。

一次关注实际会修改哪些数据

项目为关注关系建立了三张表。followingfollower 保存同一条关系的两个查询方向,outbox 保存已经发生但还需要向外传播的事件。

db/schema.sql

sql 复制代码
CREATE TABLE IF NOT EXISTS outbox (
    id BIGINT UNSIGNED NOT NULL,
    aggregate_type VARCHAR(64) NOT NULL,
    aggregate_id BIGINT UNSIGNED NULL,
    type VARCHAR(64) NOT NULL,
    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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS 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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS 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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

假设用户 1001 关注用户 2002,following 中保存的核心事实是:

text 复制代码
from_user_id = 1001
to_user_id   = 2002
rel_status   = 1

follower 中保存的是同一事实的反向查询形式:

text 复制代码
to_user_id   = 2002
from_user_id = 1001
rel_status   = 1

两张表不是保存两种业务含义。真正的业务含义只有"1001 正在关注 2002",follower 只是按照被关注者组织的数据副本。它可以由 following 确定性生成,所以它具备成为投影的第一个条件:即使短时间缺失,也有明确的重建来源。

两张表的索引方向也说明了这种读模型分工。following.idx_from_created 让系统沿 from_user_id 查询关注列表,follower.idx_to_created 则让系统沿 to_user_id 查询粉丝列表。在单库中,也可以只保留 following 并建立反向索引;但在大规模关系链中,两种数据往往采用不同的分片键:following 按关注者分片,follower 按被关注者分片。A 和 B 可能落在不同数据库,反向表就不再是一次普通的同库插入。

传统同步双写的问题不只是多执行一条 SQL

当前 Mapper 已经提供了正向和反向关系的写入语句。

RelationMapper.xml

xml 复制代码
<insert id="insertFollowing">
    INSERT INTO following (id, from_user_id, to_user_id, rel_status, created_at, updated_at)
    VALUES (#{id}, #{fromUserId}, #{toUserId}, #{relStatus}, NOW(3), NOW(3))
    ON DUPLICATE KEY UPDATE rel_status=VALUES(rel_status), updated_at=VALUES(updated_at)
</insert>

<update id="cancelFollowing">
    UPDATE following SET rel_status=0, updated_at=NOW(3)
    WHERE from_user_id=#{fromUserId} AND to_user_id=#{toUserId}
</update>

<insert id="insertFollower">
    INSERT INTO follower (id, to_user_id, from_user_id, rel_status, created_at, updated_at)
    VALUES (#{id}, #{toUserId}, #{fromUserId}, #{relStatus}, NOW(3), NOW(3))
    ON DUPLICATE KEY UPDATE rel_status=VALUES(rel_status), updated_at=VALUES(updated_at)
</insert>

<update id="cancelFollower">
    UPDATE follower SET rel_status=0, updated_at=NOW(3)
    WHERE to_user_id=#{toUserId} AND from_user_id=#{fromUserId}
</update>

如果两张表永远位于同一个 MySQL 数据库,确实可以在一个 @Transactional 方法里连续调用 insertFollowing()insertFollower()。任意一条 SQL 失败,事务一起回滚。这种方案能够解决同库两张表之间的原子性,没有必要为了两次 MySQL 写入强行引入 Kafka。

问题出现在关注关系还要同步驱动缓存和计数之后。一次请求会逐渐变成:

text 复制代码
写 following
→ 写 follower
→ 更新 A 的关注列表 ZSet
→ 更新 B 的粉丝列表 ZSet
→ A 的关注数 +1
→ B 的粉丝数 +1
→ 将来再发送通知
→ 将来再更新推荐画像

MySQL 本地事务只能覆盖 MySQL 连接中的操作,不能让 Redis 和 Kafka跟着一起提交或回滚。先提交 MySQL 再更新 Redis,可能在中间宕机;先更新 Redis 再提交 MySQL,又可能出现 Redis 已经变化而数据库回滚。Lua 能让同一个 Redis 实例中的多个命令原子执行,却不能把 MySQL 插入放进 Redis Lua,也不能把两个存储系统变成一个事务。

如果把所有步骤同步串在接口中,请求延迟会随着下游数量增长:

text 复制代码
T响应 = Tfollowing + Tfollower + TZSet1 + TZSet2 + Tcounter1 + Tcounter2 + ...

任何非核心组件故障也会反向影响关注接口。Redis 超时、计数结构异常或通知服务不可用,都可能让"关注"表现成失败。更麻烦的是,当 following 和 follower 按两个方向分库后,同步双写需要跨库事务、二阶段提交或复杂补偿;热门用户还会让其 follower 分片、计数键和通知链路同时成为热点。

可以用一次具体故障看出"直接写完所有地方"的困难。假设 A 关注 B,请求已经向 MySQL 插入 following 和 follower,随后准备修改 Redis。此时进程可能落在以下任意状态:

故障时刻 已成功的数据 尚未成功的数据 外部表现
following 后宕机 following follower、缓存、计数 A 显示已关注,B 的粉丝列表没有 A
follower 后宕机 两张关系表 缓存、计数 数据库正确,页面仍读到旧缓存
第一个 ZSet 后宕机 关系表、A 的关注缓存 B 的粉丝缓存、计数 双向列表观察到不同结果
第一个计数后宕机 关系表、列表缓存、A 计数 B 的粉丝计数 关注数和粉丝数无法对应
通知调用超时 核心数据可能都成功 接口无法判断是否该报错 用户重试后可能重复执行

同一个 MySQL 事务能消除表内前两次写入之间的空档,却无法覆盖 Redis 和外部服务。把 Redis 异常捕获后继续返回,只是选择忽略不一致;让异常向上抛出,又无法自动撤销已经提交的外部调用。引入 XA 或两阶段提交理论上可以协调部分资源,但会扩大锁持有时间和故障影响范围,不适合把缓存、通知和推荐全部绑进关注事务。

事件驱动采用另一种思路:不追求所有副本同一时刻变化,而是保证每一次权威事实变化都有一个不会凭空消失的事件。下游收到事件后分别推进自己的状态;失败的一方停在自己的消费位点,不要求已经成功的 following 跟着回滚。系统把难以实现的"跨所有存储瞬时原子"转换成可以操作的"本地事务原子 + 消息至少一次 + 消费幂等 + 定期对账"。

这种转换也重新定义了接口成功。同步双写中,任何一步失败都可能让调用者不知道关注是否成立;单一事实模型中,接口只以 following 的提交结果为准。用户重试、下游恢复和投影重建都有同一个判断依据,不需要在多份互相冲突的数据之间猜测哪一份正确。

事件驱动架构优化的并不是系统总写入次数。引入 Outbox、Canal 和 Kafka 后,总操作数实际上更多。它优化的是用户必须等待的关键路径,并让不同投影拥有独立的故障边界和处理速度。

把 following 定义成唯一业务事实

项目的写路径先经过 Redis Lua 令牌桶,再在 follow() 中写入 following 和 Outbox。

RelationServiceImpl.java · follow()

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 == 0L) {
        return false;
    }

    long id = ThreadLocalRandom.current()
            .nextLong(Long.MAX_VALUE);
    int inserted = mapper.insertFollowing(
            id, fromUserId, toUserId, 1);

    if (inserted > 0) {
        try {
            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
            );
        } catch (Exception ignored) {}

        return true;
    }
    return false;
}

这里的 insertFollowing() 决定关注事实是否成立。它使用 (from_user_id,to_user_id) 唯一键执行 UPSERT,已有关系会恢复为 rel_status=1。只要这条事实存在,系统就能够回答"A 是否关注 B",也能够重新推导 follower、列表缓存和计数。

outboxMapper.insert() 不直接更新下游,而是在数据库中写下一封"待投递的信"。它与 following 使用同一个数据源,并被 @Transactional 包围。标准 Outbox 模式要求两次写入形成一个不可分割的本地事务:following 成功时必然留下事件,Outbox 失败时 following 也必须回滚。

同步请求由此只等待:

text 复制代码
following 事实写入 + outbox 事件写入

它不等待 follower、两个 ZSet 和两个计数全部完成。对于用户而言,只要权威关系已经提交,"关注成功"就可以返回;粉丝列表和计数允许经过一个短暂的最终一致窗口。

取消关注采用相同规则,只是把主表状态改为 0,并产生 FollowCanceled

RelationServiceImpl.java · unfollow()

java 复制代码
@Override
@Transactional
public boolean unfollow(long fromUserId, long toUserId) {
    int updated = mapper.cancelFollowing(
            fromUserId, toUserId);

    if (updated > 0) {
        try {
            Long outId = ThreadLocalRandom.current()
                    .nextLong(Long.MAX_VALUE);
            String payload = objectMapper.writeValueAsString(
                    new RelationEvent(
                            "FollowCanceled",
                            fromUserId,
                            toUserId,
                            null
                    )
            );
            outboxMapper.insert(
                    outId,
                    "following",
                    null,
                    "FollowCanceled",
                    payload
            );
        } catch (Exception ignored) {}
        return true;
    }
    return false;
}

关注与取消关注都没有把 follower 当成第二份事实。它们只改变 following,再留下描述事实变化的事件。这样做的关键不是少写 follower,而是让所有下游从同一条事件出发,避免每个下游各自理解一次"A 关注 B"。

Outbox 为什么是数据库与 Kafka 之间的保险带

如果服务直接采用下面的顺序:

text 复制代码
1. 提交 following
2. kafka.send(FollowCreated)

数据库提交后、Kafka 调用前的进程崩溃会让事件永久丢失。反过来先发 Kafka 再写数据库,又可能让消费者处理一条最终没有落库的关注关系。普通 @Transactional 无法同时支配 MySQL 和 Kafka,这就是典型的数据库与消息队列双写空档。

Outbox 把"需要发送的事件"先变成一条普通数据库记录。

OutboxMapper.xml

xml 复制代码
<mapper namespace="com.tongji.relation.outbox.OutboxMapper">
    <insert id="insert">
        INSERT INTO outbox (
            id,
            aggregate_type,
            aggregate_id,
            type,
            payload,
            created_at
        )
        VALUES (
            #{id},
            #{aggregateType},
            #{aggregateId},
            #{type},
            #{payload},
            NOW(3)
        )
    </insert>
</mapper>

Outbox 的其他列像信封标签:id 标识事件,aggregate_type 表示事件属于哪类聚合,aggregate_id 指向关系记录,type 表示事件类型。payload 才是信封里的正文,保存消费者完成投影所需的全部业务字段。

关系事件使用一个 Java record 表达。

RelationEvent.java

java 复制代码
public record RelationEvent(
        String type,
        Long fromUserId,
        Long toUserId,
        Long id) {
}

用户 1001 关注用户 2002 时,payload 类似:

json 复制代码
{
  "type": "FollowCreated",
  "fromUserId": 1001,
  "toUserId": 2002,
  "id": 7890123
}

这条 JSON 已经包含反向生成 follower(2002,1001) 所需的全部信息。中间的 Canal 和 Kafka 不需要理解关注业务,只负责可靠搬运 payload。

Canal 为什么读取 Outbox,而不是业务代码直接生产消息

Outbox 解决了"事实和事件同时落库",但数据库记录不会自动进入 Kafka。系统还需要一个发布器发现新事件。可以定时扫描 Outbox,也可以通过 CDC 订阅 MySQL Binlog。当前项目选择 Canal,并把过滤范围配置为 zhiguang.outbox

application.yml

yaml 复制代码
spring:
  kafka:
    bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer
      acks: all
      retries: 3
      linger: 10ms
      properties:
        enable.idempotence: true
        max.in.flight.requests.per.connection: 1
    consumer:
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      auto-offset-reset: latest
      enable-auto-commit: false
    listener:
      type: single
      ack-mode: manual

canal:
  enabled: ${CANAL_ENABLED:true}
  host: ${CANAL_HOST:localhost}
  port: ${CANAL_PORT:11111}
  destination: ${CANAL_DESTINATION:example}
  username: ${CANAL_USERNAME:canal}
  password: ${CANAL_PASSWORD:Canal@123456}
  filter: 'zhiguang\.outbox'
  batchSize: 1000
  intervalMs: 1000

与轮询 SELECT outbox WHERE status='NEW' 相比,Binlog CDC 不需要业务应用不断扫描事件表,也不需要多个发布实例争抢同一批记录。Canal 沿数据库变更日志读取已经写入的 Outbox 行,Bridge 再把 Canal 协议中的行变化转换为 Kafka 消息。

在当前代码里,CanalKafkaBridge 就是关系事件的 Kafka Producer/Relay。它不是新的业务层,只承担两种协议之间的转换。

CanalKafkaBridge.java · 核心循环

java 复制代码
connector = CanalConnectors.newSingleConnector(
        new InetSocketAddress(host, port),
        destination,
        username,
        password
);
connector.connect();
connector.subscribe(filter);
connector.rollback();

while (running) {
    Message message = connector.getWithoutAck(batchSize);
    long batchId = message.getId();

    if (batchId == -1
            || message.getEntries() == null
            || message.getEntries().isEmpty()) {
        try {
            Thread.sleep(intervalMs);
        } catch (InterruptedException ignored) {}
        continue;
    }

    for (CanalEntry.Entry entry : message.getEntries()) {
        if (entry.getEntryType()
                != CanalEntry.EntryType.ROWDATA) {
            continue;
        }

        CanalEntry.RowChange rowChange;
        try {
            rowChange = CanalEntry.RowChange.parseFrom(
                    entry.getStoreValue());
        } catch (Exception e) {
            continue;
        }

        CanalEntry.EventType eventType =
                rowChange.getEventType();
        if (eventType != CanalEntry.EventType.INSERT
                && eventType != CanalEntry.EventType.UPDATE) {
            continue;
        }

        ArrayNode dataArray = objectMapper.createArrayNode();
        for (CanalEntry.RowData rowData
                : rowChange.getRowDatasList()) {
            ObjectNode rowNode = objectMapper.createObjectNode();
            for (CanalEntry.Column col
                    : rowData.getAfterColumnsList()) {
                if ("payload".equalsIgnoreCase(col.getName())) {
                    rowNode.put("payload", col.getValue());
                }
            }
            dataArray.add(rowNode);
        }

        ObjectNode msgNode = objectMapper.createObjectNode();
        msgNode.put("table", entry.getHeader().getTableName());
        msgNode.put(
                "type",
                eventType == CanalEntry.EventType.INSERT
                        ? "INSERT" : "UPDATE"
        );
        msgNode.set("data", dataArray);

        String json = objectMapper.writeValueAsString(msgNode);
        kafka.send(OutboxTopics.CANAL_OUTBOX, json);
    }

    connector.ack(batchId);
}

Bridge 先通过 getWithoutAck() 拉取一批尚未确认的 Canal 消息,只保留行级 INSERT/UPDATE,再从每一行提取 payload。它最终发送的并不是裸 RelationEvent,而是带有表名、操作类型和数据数组的统一信封:

json 复制代码
{
  "table": "outbox",
  "type": "INSERT",
  "data": [
    {
      "payload": "{\"type\":\"FollowCreated\",\"fromUserId\":1001,\"toUserId\":2002,\"id\":7890123}"
    }
  ]
}

Canal 负责发现变化,Bridge 负责转换并生产 Kafka 消息。若基础设施直接配置 Canal 到 Kafka,或者改用 Debezium/Kafka Connect,自定义 Bridge 可以被现成连接器替代;Outbox 模式本身并不强制要求存在这个 Java 类。

Kafka 在这里不是为了"多写一张表"

如果唯一的下游只有 follower,并且它与 following 永远在一个数据库,Kafka 带来的收益有限。标准关系链引入 Kafka,是因为一条关注事件会同时被多个独立下游使用,而且这些下游不应该共同决定关注接口能否成功。

text 复制代码
FollowCreated
├── 关系投影:生成 follower
├── 缓存投影:更新关注/粉丝 ZSet
├── 计数投影:关注数和粉丝数变化
├── 通知服务:通知 B 收到新粉丝
├── 推荐服务:更新社交图谱
├── 风控服务:识别异常批量关注
└── 数据分析:进入离线数仓

Kafka 在中间提供持久化缓冲和消费组隔离。热门作者短时间收到大量关注时,主写路径可以先提交关系事实,事件在 Topic 中排队;关系投影、通知和推荐分别按照自己的容量消费。通知服务故障不会阻止 follower 消费者工作,推荐消费者扩容也不需要改动 follow()

这里可以进一步区分 Canal、Bridge 和 Kafka 的职责,避免把三者笼统理解成"都是转发消息"。Canal 的上游是 MySQL Binlog,它关心数据库哪一行发生了什么变化;Bridge 的上游是 Canal 协议,它关心怎样提取 Outbox payload 并转成统一 Kafka 信封;Kafka 的上游可以有多个 Producer,它负责保存消息、按照 Partition 排序并把相同数据提供给不同消费组。三者解决的是连续但不同的问题。

如果没有 Canal,应用需要自己轮询 Outbox,处理扫描游标、多实例抢占、发送成功标记和历史数据清理。如果没有 Bridge,当前 Canal Client 返回的 RowChange 无法直接成为项目约定的 JSON 消息;但 Bridge 可以被 Canal 原生 Kafka 投递或标准 CDC Connector 替代。如果没有 Kafka,Bridge 就必须逐个同步调用 follower、计数、通知等下游,下游之间再次形成调用耦合。

因此 Kafka 的价值要从消费者数量和流量形态判断。只有一个同库 follower 写入时,它并不比本地事务更经济;当一条 FollowCreated 同时服务多个独立系统,或者关注峰值明显高于某个下游的实时处理能力时,Kafka 才真正成为架构中的缓冲层和分发层。

这种拆分还带来部署上的独立性。关系消费者可以根据数据库写入压力扩容,通知消费者可以根据发送渠道限速,推荐消费者可以批量处理,数据仓库消费者可以允许更长延迟。它们共享同一份关注事件,却不必共享线程池、超时配置和发布周期。主服务只需要稳定地产生事实,不需要知道每个下游当前有多少实例。

这将请求延迟从"等待所有下游"改为"等待权威事实和事件落库":

text 复制代码
传统同步路径:
T响应 = Tfollowing + Tfollower + T缓存 + T计数 + T通知 + ...

事件驱动路径:
T响应 = Tfollowing + Toutbox

异步耗时:
T投影 = TCanal + TKafka + Tconsumer

异步架构把延迟移到了允许延迟的投影上。它没有消灭工作,而是让请求线程更快释放,并允许消费者独立扩缩容。这也是为什么 follower 必须被视为可延迟、可修复的读模型,而不能与 following 共同拥有业务真相。

Consumer 为什么仍然检查消息

Producer 已经只发送 INSERT/UPDATE,Consumer 仍然要校验表名和事件类型。判断被集中在 OutboxMessageUtil.extractRows() 中。

OutboxMessageUtil.java

java 复制代码
public static List<JsonNode> extractRows(
        ObjectMapper objectMapper,
        String message) {
    try {
        JsonNode root = objectMapper.readTree(message);

        JsonNode table = root.get("table");
        if (table == null
                || !"outbox".equals(table.asText())) {
            return Collections.emptyList();
        }

        JsonNode type = root.get("type");
        if (type == null
                || (!"INSERT".equals(type.asText())
                && !"UPDATE".equals(type.asText()))) {
            return Collections.emptyList();
        }

        JsonNode data = root.get("data");
        if (data == null || !data.isArray()) {
            return Collections.emptyList();
        }

        List<JsonNode> rows = new ArrayList<>();
        data.forEach(rows::add);
        return rows;
    } catch (Exception e) {
        return Collections.emptyList();
    }
}

Bridge 的过滤和 Consumer 的校验处在两个不同边界。Bridge 根据自己的订阅配置处理上游数据;Consumer 面对的是一个可由多个生产者写入、可能保留历史消息的 Kafka Topic。它不能假设每条消息都来自当前版本的 Bridge,因此必须在入口再次确认"这是 outbox 的 INSERT/UPDATE,并且 data 是数组"。

这种重复不是业务逻辑重复,而是边界校验。Producer 保证自己尽量不发送无关消息,Consumer 保证即使上游配置变化、历史数据存在或者未来出现其他生产者,也不会直接把任意 JSON 当成关系事件。

Consumer 只负责翻译,Processor 负责关注映射

Kafka Consumer 接收的是字符串和统一信封,关系处理器需要的是强类型 RelationEventCanalOutboxConsumer 位于二者之间。

CanalOutboxConsumer.java

java 复制代码
@Service
public class CanalOutboxConsumer {
    private final ObjectMapper objectMapper;
    private final RelationEventProcessor processor;

    public CanalOutboxConsumer(
            ObjectMapper objectMapper,
            RelationEventProcessor processor) {
        this.objectMapper = objectMapper;
        this.processor = processor;
    }

    @KafkaListener(
            topics = OutboxTopics.CANAL_OUTBOX,
            groupId = "relation-outbox-consumer"
    )
    public void onMessage(
            String message,
            Acknowledgment ack) {
        try {
            List<JsonNode> rows =
                    OutboxMessageUtil.extractRows(
                            objectMapper, message);

            if (rows.isEmpty()) {
                ack.acknowledge();
                return;
            }

            for (JsonNode row : rows) {
                JsonNode payloadNode = row.get("payload");
                if (payloadNode == null) {
                    continue;
                }

                RelationEvent evt = objectMapper.readValue(
                        payloadNode.asText(),
                        RelationEvent.class
                );
                processor.process(evt);
            }

            ack.acknowledge();
        } catch (Exception ignored) {}
    }
}

这个类完成三次收窄:先从 Kafka String 得到 Canal 消息,再从统一消息得到 Outbox 行,最后从 payload 得到 RelationEvent。全部处理完成后才调用 ack.acknowledge(),请求提交 Kafka Offset。

它没有直接写 follower,是为了隔离消息接入与领域逻辑。Consumer 负责 Topic、JSON 信封和位点;Processor 只认识 FollowCreated/FollowCanceled。将来通过补偿任务重放事件时,可以直接复用 Processor;如果消息中间件发生变化,也不需要重写关系映射规则。

RelationEventProcessor 生成所有下游投影

消费者还原出 RelationEvent 后,才真正执行"A 关注 B,所以 B 的粉丝中出现 A"这条固定规则。

RelationEventProcessor.java

java 复制代码
@Service
public class RelationEventProcessor {
    private final RelationMapper mapper;
    private final StringRedisTemplate redis;
    private final UserCounterService userCounterService;

    public RelationEventProcessor(
            RelationMapper mapper,
            StringRedisTemplate redis,
            UserCounterService userCounterService) {
        this.mapper = mapper;
        this.redis = redis;
        this.userCounterService = userCounterService;
    }

    public void process(RelationEvent evt) {
        String dk = "dedup:rel:"
                + evt.type() + ":"
                + evt.fromUserId() + ":"
                + evt.toUserId() + ":"
                + (evt.id() == null
                        ? "0" : String.valueOf(evt.id()));

        Boolean first = redis.opsForValue().setIfAbsent(
                dk, "1", Duration.ofMinutes(10));

        if (first == null || !first) {
            return;
        }

        if ("FollowCreated".equals(evt.type())) {
            mapper.insertFollower(
                    evt.id(),
                    evt.toUserId(),
                    evt.fromUserId(),
                    1
            );

            long now = System.currentTimeMillis();
            redis.opsForZSet().add(
                    "uf:flws:" + evt.fromUserId(),
                    String.valueOf(evt.toUserId()),
                    now
            );
            redis.opsForZSet().add(
                    "uf:fans:" + evt.toUserId(),
                    String.valueOf(evt.fromUserId()),
                    now
            );
            redis.expire(
                    "uf:flws:" + evt.fromUserId(),
                    Duration.ofHours(2)
            );
            redis.expire(
                    "uf:fans:" + evt.toUserId(),
                    Duration.ofHours(2)
            );

            userCounterService.incrementFollowings(
                    evt.fromUserId(), 1);
            userCounterService.incrementFollowers(
                    evt.toUserId(), 1);

        } else if ("FollowCanceled".equals(evt.type())) {
            mapper.cancelFollower(
                    evt.toUserId(),
                    evt.fromUserId()
            );

            redis.opsForZSet().remove(
                    "uf:flws:" + evt.fromUserId(),
                    String.valueOf(evt.toUserId())
            );
            redis.opsForZSet().remove(
                    "uf:fans:" + evt.toUserId(),
                    String.valueOf(evt.fromUserId())
            );
            redis.expire(
                    "uf:flws:" + evt.fromUserId(),
                    Duration.ofHours(2)
            );
            redis.expire(
                    "uf:fans:" + evt.toUserId(),
                    Duration.ofHours(2)
            );

            userCounterService.incrementFollowings(
                    evt.fromUserId(), -1);
            userCounterService.incrementFollowers(
                    evt.toUserId(), -1);
        }
    }
}

对于 FollowCreated(1001,2002),处理器依次生成四类结果:

投影 写入结果 服务的查询
follower (to=2002, from=1001, status=1) 2002 的粉丝列表
uf:flws:1001 ZSet 成员 2002 1001 的关注列表缓存
uf:fans:2002 ZSet 成员 1001 2002 的粉丝列表缓存
ucnt:* 1001 关注数 +1,2002 粉丝数 +1 用户主页计数

这里最值得注意的是:一条事件统一驱动所有投影。传统同步代码容易让不同入口各写一部分状态;事件模型则规定所有下游都只消费 FollowCreatedFollowCanceled,关系含义只有一套。

处理器首先用 SET NX 建立十分钟去重键,是因为 Canal/Kafka 链路采用至少一次方向的处理模型,同一条消息可能重放。follower 插入本身使用唯一键 UPSERT,列表缓存使用 ZSet member,也天然具有覆盖性质;但计数的 +1/-1 不是天然幂等,所以更依赖稳定的事件标识和消费去重。

把一次真实执行拆成状态快照,会更容易理解"唯一事实"和"投影"的区别。

请求到达前,系统中不存在 following(1001,2002)。用户发起关注后,主事务同时写入 following 和 Outbox。事务提交的一刻,业务状态已经变成"1001 关注 2002",但 follower、缓存和计数仍然可能保持旧值。这段时间就是系统主动接受的最终一致窗口。

Canal 随后从 Binlog 看到 Outbox INSERT,Bridge 将 payload 发到 Kafka。此时即使关系消费者还没有运行,事件已经能够在消息系统中等待。消费者恢复后还原 FollowCreated,Processor 先写入 follower(to=2002,from=1001),再把两个用户 ID 放进对应 ZSet,最后修改两个计数段。

投影全部完成后,四种读取会得到一致结果:关系状态查询从 following 判断为 true;1001 的关注列表包含 2002;2002 的粉丝列表包含 1001;双方计数分别增加。它们在最终状态上描述同一件事,但到达最终状态的时间不必完全相同。

如果 follower 写入失败,following 不会因为投影失败而消失。关系消费可以重试相同事件,或者运维程序扫描 following 重建反向表。如果 Redis ZSet 丢失,读路径可以从关系表回填;如果计数损坏,可以从权威关系聚合。能否完成这些恢复,是判断一份数据有没有资格成为"投影"的关键,而不是看它是否存放在 MySQL。

取消关注也是同一状态机的下一次变化。FollowCanceled 不表示删除历史事实,而是要求各投影最终达到 rel_status=0、ZSet 不包含对方、计数回落的目标状态。如果创建和取消事件可能快速连续发生,系统还需要保证同一关系的事件顺序,例如用关系 ID 或用户对作为 Kafka Key,并在消费者侧识别事件版本。否则不同 Partition 或重放顺序可能让旧事件覆盖新状态。

这也解释了为什么 Processor 要和 Consumer 分开。Consumer 的成功标准是消息格式合法并完成交付,Processor 的成功标准是业务投影达到目标状态。前者关注 Topic、反序列化、Offset、重试和死信;后者关注关系状态、缓存键、计数下标和幂等。把两种标准写在一个大方法里,任何消息格式调整都可能碰到业务代码,任何业务投影增加也可能改变位点处理。

计数为什么也作为投影

关注总数可以通过 SQL COUNT 得到,但用户主页每次打开都聚合大表,会把高频读取转化为数据库压力。项目把用户的关注数、粉丝数、发文数、获赞数和获藏数压进一个 Redis String,每段占四个字节。

UserCounterServiceImpl.java · 增量入口

java 复制代码
@Override
public void incrementFollowings(long userId, int delta) {
    String key = UserCounterKeys.sdsKey(userId);
    redis.execute(
            incrScript,
            List.of(key),
            "5",
            "4",
            "1",
            String.valueOf(delta)
    );
}

@Override
public void incrementFollowers(long userId, int delta) {
    String key = UserCounterKeys.sdsKey(userId);
    redis.execute(
            incrScript,
            List.of(key),
            "5",
            "4",
            "2",
            String.valueOf(delta)
    );
}

下标 1 表示关注数,下标 2 表示粉丝数。Lua 在 Redis 内部读取对应四字节整数、应用增量并写回,因此同一个计数段的修改是原子的。

UserCounterServiceImpl.java · INCR_FIELD_LUA

lua 复制代码
local cntKey = KEYS[1]
local schemaLen = tonumber(ARGV[1])
local fieldSize = tonumber(ARGV[2])
local idx = tonumber(ARGV[3])
local delta = tonumber(ARGV[4])

local function read32be(s, off)
  local b = {string.byte(s, off+1, off+4)}
  local n = 0
  for i=1,4 do
    n = n * 256 + b[i]
  end
  return n
end

local function write32be(n)
  local t = {}
  for i=4,1,-1 do
    t[i] = n % 256
    n = math.floor(n/256)
  end
  return string.char(unpack(t))
end

local cnt = redis.call('GET', cntKey)
if not cnt then
  cnt = string.rep(
      string.char(0),
      schemaLen * fieldSize
  )
end

local off = (idx - 1) * fieldSize
local v = read32be(cnt, off) + delta
if v < 0 then
  v = 0
end

local seg = write32be(v)
cnt = string.sub(cnt, 1, off)
    .. seg
    .. string.sub(cnt, off+fieldSize+1)

redis.call('SET', cntKey, cnt)
return 1

Lua 解决的是 Redis 内部的并发更新,不能替代 Outbox。两者位于不同的一致性边界:Outbox 负责 MySQL 事实和事件同时落库,Lua 负责一条事件到达后对 Redis 计数段进行原子修改。

计数之所以适合作为投影,是因为它也能从关系事实重新聚合。增量消费负责日常低成本更新,全量统计负责缓存缺失或对账时修复。标准设计中,粉丝数最终应当以 following WHERE to_user_id=? AND rel_status=1 为事实聚合来源,而不能把可能损坏的 follower 投影再次当成最终事实。

这套设计相对同步双写的核心优化

将整个方案放在一起,可以看出它并不是为了炫技增加中间件,而是在改变一致性边界。

维度 传统同步双写 Canal + Outbox + Kafka
业务真相 following、follower 容易被共同视为真相 只有 following 是事实,其余是投影
请求关键路径 等待 follower、缓存、计数等全部完成 只等待 following 和 Outbox 本地事务
Redis 故障 容易让关注接口失败或留下半成品 事件积压,恢复后继续生成投影
热点流量 瞬时压力直接打到所有下游 Kafka 缓冲,下游按容量消费
分库分片 正反向关系可能需要跨库同步事务 following 先提交,follower 异步落到反向分片
新增下游 修改关注服务,增加同步依赖 新增消费者组,不改变关注事务
修复能力 某次写漏后缺少统一重放来源 从事实扫描或事件重放重建投影
一致性表现 尝试立即一致,但跨存储无法由本地事务覆盖 明确接受短暂延迟,追求最终一致

这里最重要的变化是从"每次调用同时维护所有副本"转向"只提交一次事实,再由事实持续生成读模型"。总写入量没有减少,系统甚至增加了 Outbox 和 Kafka 消息;但用户请求的工作量保持稳定,下游数量不会无限拉长主链路。

这也是一种 CQRS 思路:following 更接近命令侧模型,负责接受关注和取消关注;follower、ZSet 和计数是为不同查询准备的读模型。读模型可以按照查询方向建立索引、独立扩容、丢弃后重建,而不需要反向影响命令侧事务。

需要注意,CQRS 在这里并不等于完整的 Event Sourcing。项目仍然把 following 的当前状态保存在普通关系表中,Outbox 负责传播状态变化,并没有要求仅靠全部历史事件还原业务实体。准确说法是"权威状态表 + 事务 Outbox + 异步读模型",而不是把 Kafka 当作唯一事实来源。

这个边界让方案更容易落地:业务查询关系状态时不需要回放事件,只要读取 following;Kafka 历史保留不足时,仍然可以扫描主表重建 follower;Outbox 与消息链路出现积压时,也不会改变已经提交的关注事实。事件服务于传播和投影,而不是替代关系数据库。

"从根本上解决双写不一致"需要哪些前提

Outbox 模式能关闭数据库与消息队列之间的双写空档,但不是只要出现 outbox 表名就自动可靠。完整语义需要满足以下闭环:

text 复制代码
1. following 与 outbox 必须同事务成功或同事务回滚
2. Canal 位点只能在 Kafka 确认发送成功后推进
3. Consumer 失败必须进入明确的重试或死信流程
4. 投影处理必须使用持久化 eventId 做业务幂等
5. follower、计数和缓存必须能够从 following 重建
6. 必须有监控、积压告警、对账和修复工具

当前源码已经搭出了完整链路,但还存在几处不能忽略的实现差距。

第一,follow()unfollow() 捕获并吞掉了 Outbox 插入异常。这样会出现 following 已写入、Outbox 失败、方法仍返回成功。生产实现必须让异常继续抛出,使 Spring 回滚整个事务。

java 复制代码
// 当前实现:会破坏本地事务原子性
try {
    outboxMapper.insert(
            outId,
            "following",
            id,
            "FollowCreated",
            payload
    );
} catch (Exception ignored) {}

// 生产要求:不要吞掉异常
outboxMapper.insert(
        outId,
        "following",
        id,
        "FollowCreated",
        payload
);

第二,Bridge 调用 kafka.send() 后立即确认 Canal 批次,但异步发送结果尚未等待。如果 Broker 稍后返回失败,Canal 位点可能已经推进。生产实现应等待发送确认,或者使用具备可靠位点联动的 CDC Connector。

java 复制代码
// 当前实现
kafka.send(OutboxTopics.CANAL_OUTBOX, json);
connector.ack(batchId);

// 语义要求:所有发送成功后才能 ACK Canal 批次
kafka.send(OutboxTopics.CANAL_OUTBOX, json).get();
connector.ack(batchId);

第三,Consumer 使用 catch (Exception ignored) {} 吞掉异常,没有日志,也没有把失败交给 Spring Kafka 错误处理器。正确做法是让异常触发重试、退避和死信处理,而不是静默返回。

第四,Processor 在所有副作用之前写入一个只有十分钟 TTL 的去重键。如果 follower 已更新但计数失败,重试期间可能被去重键直接拦截;十分钟后再次重放,又可能重复增加计数。生产级幂等需要持久化事件处理记录,或把每个投影设计成基于目标状态的幂等覆盖,而不是仅依赖短 TTL。

第五,当前计数重建从 follower 统计粉丝数。若 follower 本身就是需要修复的投影,它不能再充当最终对账来源。真正坚持 following 唯一可信时,反向粉丝数和 follower 重建都应该扫描 following。

这些问题不否定架构目标,反而说明事件驱动系统的可靠性来自完整闭环,而不是来自中间件名称。只有事实、事件、位点、幂等、重试和重建全部对齐,才能把"最终一致"从一句设计口号变成可以验证的工程能力。

总结

A 关注 B 时,系统当然可以立即推导 follower(B,A)。Canal + Outbox + Kafka 没有改变这条简单规则,只是把规则从用户请求线程移到了可以独立运行、失败重试和水平扩展的投影链路中。

Outbox 让 following 与事件先在一个 MySQL 事务里留下不可分割的事实;Canal 从 Binlog 发现已经提交的事件;Bridge 把 Canal 行变化转换成 Kafka 消息;Kafka 为多个下游提供缓冲、保留和消费组隔离;Consumer 负责边界校验和反序列化;RelationEventProcessor 最终生成 follower、列表缓存和计数。

这套设计的价值不在于减少一次 SQL,而在于建立清晰的数据等级:

text 复制代码
following:唯一可信、必须正确的业务事实

follower:为反向列表查询生成的关系投影
计数:为高频读取生成的聚合投影
缓存:为降低查询延迟生成的性能投影

当投影可以从事实确定性推导、允许短暂延迟并且能够重建时,就没有必要让它们共同阻塞核心写路径。系统用短暂的最终一致窗口,换来了稳定的请求延迟、下游故障隔离、热点削峰、分库分片能力和新增消费者时的低耦合。这才是标准关系链项目选择 Canal + Outbox + Kafka 的根本原因。

相关推荐
creator_Li1 小时前
Kafka的死信队列
kafka
MC丶科1 小时前
软考架构师90天冲刺|DAY38·第二阶段综合测试-分布式与云原生
分布式·云原生·架构·serverless·service_mesh·软考架构师
hm宋2 小时前
ZooKeeper 容灾切换免重启实践:域名化配置与客户端自动迁移
分布式·zookeeper·云原生
kruptos10 小时前
分布式系统怎么“选主“?Raft 共识一次讲清
开发语言·分布式·php·共识算法
小智老师PMP12 小时前
2026深度解析|PMP第八版与NPDP核心侧重点本质区别(管理类证书怎么选)
开发语言·分布式·算法·职场和发展·产品经理
会周易的程序员14 小时前
5Draft(五帝)测试报告
服务器·c++·分布式·raft·共识算法·共识·算力服务器
想要打 Acm 的小周同学呀20 小时前
Kafka消息队列出现挤压如何解决?
分布式·kafka
stark张宇1 天前
分布式事务最全图解(6种方案):从强一致的2PC到最终对账,彻底搞懂数据一致性
分布式·后端
会周易的程序员1 天前
5Draft使用说明书
服务器·c++·分布式·raft·共识