从一次关注到最终一致: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 不是从中间件出发设计业务,而是从这种数据等级差异出发,将必须同步的部分和允许异步的部分拆开。

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

项目为关注关系建立了三张表。following 和 follower 保存同一条关系的两个查询方向,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 接收的是字符串和统一信封,关系处理器需要的是强类型 RelationEvent。CanalOutboxConsumer 位于二者之间。

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 用户主页计数

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

处理器首先用 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 的根本原因。

相关推荐
Thomas.Sir6 天前
第21课:PyTorch|GPU多卡训练与分布式训练基础【让多卡并行成为你的加速引擎】
人工智能·pytorch·分布式
Cicada1286 天前
库存消息消费的正确性设计——从幂等窗口到批量流水线
分布式·系统架构
Nano叶落6 天前
Kafka 消费积压排查入门:Docker 搭环境亲手制造一次 ‘消息堵死‘,10 分钟看懂 Lag
kafka
Francek Chen6 天前
【大数据处理与分析】数据仓库Hive:04 数据仓库Hive概述
大数据·数据仓库·hive·hadoop·分布式
Gl�ria7 天前
Hadoop/YARN 集群缩容:下线DN节点
大数据·hadoop·分布式
吉甫作诵7 天前
Kafka 集群安装与运维实战:消费组排查、Offset 重置与副本重分配
大数据·运维·分布式·kafka·消息队列
imDwAaY7 天前
消息队列四大核心问题:顺序性、幂等性、可靠性与一致性
学习·kafka·rabbitmq
wno7047 天前
Spring Boot整合Kafka
spring boot·kafka
是Dream呀7 天前
Harness 工程:让 Agent 真正把任务做完
人工智能·分布式·缓存·agent