从一次关注到最终一致: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 的根本原因。