Kafka 与事务 Outbox:构建低延迟、可恢复的事件链路
在事件驱动系统中,我们经常同时面对两个要求:事件要尽快到达消费者,而且不能因为进程崩溃而丢失。直接发送 Kafka 延迟低,却可能与数据库事务产生不一致;先写数据库再定时扫描比较稳妥,却可能平白增加数秒延迟。
本文使用通用通知任务作为示例,说明 Kafka 与事务 Outbox 如何协作,以及幂等、分区、状态机和对账分别解决什么问题。
一、双写为什么会产生不一致
假设一个接口需要创建通知任务,并发布 NOTIFICATION_CREATED 事件。最直观的代码是:
java
notificationRepository.insert(task);
kafkaTemplate.send("notification-created", event);
这两次操作属于两个不同系统,无法依靠本地数据库事务同时提交,可能出现两种结果:
- 数据库写入成功,Kafka 发送失败,消费者永远不知道任务存在;
- Kafka 发送成功,数据库事务随后回滚,消费者拿到一个不存在的任务。
调整调用顺序并不能解决本质问题,只会交换两种异常出现的方向。
事务 Outbox 的做法是把业务数据和待发布事件写进同一个数据库:
sql
START TRANSACTION;
INSERT INTO notification_task
(id, target_id, content, status, created_at)
VALUES
(?, ?, ?, 'CREATED', NOW());
INSERT INTO event_outbox
(id, aggregate_id, event_type, payload, status, created_at)
VALUES
(?, ?, 'NOTIFICATION_CREATED', ?, 'NEW', NOW());
COMMIT;
本地事务保证两条记录同时成功或同时失败。之后由独立发布器把 Outbox 事件发送到 Kafka。
二、Outbox 不等于低频定时扫描
有些实现每五秒扫描一次 event_outbox,于是事件平均要等待 2.5 秒,最差接近 5 秒。可靠性得到了保证,但实时性被调度周期拖慢。
可以采用"提交后唤醒、定时任务兜底"的组合:事务提交后只通知发布线程立即工作;定时扫描负责恢复通知丢失或进程崩溃后遗留的记录。
java
@Transactional
public void createNotification(CreateNotification command) {
taskRepository.insert(command.toTask());
outboxRepository.append(command.toEvent());
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
publisherSignal.wakeup();
}
});
}
wakeup() 不携带业务数据,也不替代 Outbox。它只是减少等待时间,数据库记录仍是可靠事实来源。
更大规模时,可以使用 CDC 订阅数据库变更日志,由 Debezium 等组件把 Outbox 表变更推送到 Kafka。是否引入 CDC,应根据团队运维能力决定;中小系统使用轻量发布器通常更容易维护。
三、发布器如何安全抢占事件
多个应用实例可能同时扫描 Outbox。为了避免重复处理,可以使用数据库行锁:
sql
SELECT id, event_type, payload
FROM event_outbox
WHERE status = 'NEW'
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;
每个发布器锁定不同记录,发送成功后更新为 PUBLISHED。如果发送失败,则增加重试次数并记录下一次执行时间。
需要注意,不能在持有数据库事务和行锁期间长时间等待 Kafka。更稳妥的流程是短事务认领一批事件,把状态更新为 PUBLISHING 并提交;随后发送 Kafka;最后用另一个短事务写回结果。被认领后长期没有更新的记录,由恢复任务重新放回待发布状态。
四、Kafka 分区键定义顺序范围
Kafka 只保证同一分区内的顺序。分区键应当对应业务真正需要保持顺序的最小范围。
以协作文档事件为例:同一文档的操作需要有序,不同文档可以并行,因此使用 documentId 作为 Key。以设备遥测为例,同一设备的数据要保持顺序,可以使用 deviceId。
如果把随机 eventId 当作 Key,同一对象的事件可能落到不同分区;如果所有事件使用同一个固定 Key,虽然顺序最简单,却失去了并行能力。
事件结构建议包含统一信封:
json
{
"eventId": "01J...",
"eventType": "NOTIFICATION_CREATED",
"aggregateId": "10001",
"occurredAt": "2026-07-17T10:00:00Z",
"traceId": "8f9...",
"schemaVersion": 1,
"data": {}
}
不要直接序列化数据库实体。实体字段服务于持久化,事件字段服务于跨系统契约,两者变化节奏不同。
五、至少一次投递要求消费者幂等
Outbox 发布器可能在"Kafka 已成功接收、数据库状态尚未更新"时崩溃。恢复后它会再次发送同一事件。因此,系统通常提供的是至少一次投递,消费者必须能处理重复事件。
最直接的办法是在消费端保存 eventId:
sql
CREATE TABLE consumed_event (
consumer_name VARCHAR(100) NOT NULL,
event_id VARCHAR(64) NOT NULL,
consumed_at DATETIME(6) NOT NULL,
PRIMARY KEY (consumer_name, event_id)
);
消费事件和修改本地业务数据必须处于同一事务。插入 consumed_event 发生唯一键冲突时,说明该消费者已经处理过事件,可以直接确认消息。
Redis SET NX 可以减少数据库压力,但如果 Redis 写入成功、业务事务失败,事件会被误认为已经处理,因此它更适合作为快速过滤层,而不是唯一事实来源。
六、不要把重试和死循环混为一谈
网络超时等瞬时故障适合指数退避重试,数据格式错误、缺少必填字段等永久故障则不应无限重试。
可以把失败分为:
RETRYABLE:连接超时、Broker 临时不可用;NON_RETRYABLE:事件结构不合法、版本不支持;UNKNOWN:调用已经发出,但无法确认对端是否完成。
超过最大次数的事件进入死信 Topic,并触发告警。死信不是垃圾桶,必须保留原始事件、异常类型、消费组和失败时间,并提供重新驱动工具。
对于 UNKNOWN,不能立即重复执行可能产生外部副作用的操作。应先通过业务幂等键或查询接口确认结果,再决定是否重试。
七、背压从消费者并发开始
Kafka 中有积压并不可怕,无法观察和控制积压才可怕。如果下游数据库每秒只能稳定处理 1000 次写入,把消费者并发提高到 100 并不会提升整体吞吐,反而会耗尽连接池。
需要联合调整:
- Topic 分区数;
- Consumer 实例数与并发数;
- 单次拉取数量;
- 数据库连接池;
- 单任务执行成本。
当下游过载时,可以暂停分区、降低拉取数量或启用有界队列。不要把 Kafka 消息无边界搬到 JVM 内存中,否则 Broker 还没出问题,应用已经因为内存压力退出。
八、用对账补齐最后一道防线
实时链路再完善,也要考虑应用在任意两行代码之间退出。对账任务应检查:
- 长时间停留在
NEW或PUBLISHING的 Outbox; - 已发布但消费侧没有结果的事件;
- 消费记录存在但业务状态没有推进的异常事务;
- 死信队列持续增长的事件类型。
对账的目标是发现并恢复不一致,而不是定期重新执行所有任务。每一种修复动作都需要幂等,并保留审计记录。
建议监控 Outbox 未发布数量、最老事件年龄、Kafka Consumer Lag、消费耗时分位数、重试率和死信数量。这些指标能够快速定位延迟是在数据库、发布器、Broker 还是消费者。
总结
Kafka 负责高吞吐事件传递,事务 Outbox 负责解决数据库与消息系统的双写一致性。提交后唤醒可以降低延迟,周期扫描和对账则负责故障恢复。
再配合合适的分区键、消费者幂等、有界重试和背压控制,就能构建一条既实时又可恢复的事件链路。可靠性并不是依靠某一个中间件自动获得的,而是由每个失败窗口的处理方式共同决定。