Kafka 与事务 Outbox

Kafka 与事务 Outbox:构建低延迟、可恢复的事件链路

在事件驱动系统中,我们经常同时面对两个要求:事件要尽快到达消费者,而且不能因为进程崩溃而丢失。直接发送 Kafka 延迟低,却可能与数据库事务产生不一致;先写数据库再定时扫描比较稳妥,却可能平白增加数秒延迟。

本文使用通用通知任务作为示例,说明 Kafka 与事务 Outbox 如何协作,以及幂等、分区、状态机和对账分别解决什么问题。

一、双写为什么会产生不一致

假设一个接口需要创建通知任务,并发布 NOTIFICATION_CREATED 事件。最直观的代码是:

java 复制代码
notificationRepository.insert(task);
kafkaTemplate.send("notification-created", event);

这两次操作属于两个不同系统,无法依靠本地数据库事务同时提交,可能出现两种结果:

  1. 数据库写入成功,Kafka 发送失败,消费者永远不知道任务存在;
  2. 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 还没出问题,应用已经因为内存压力退出。

八、用对账补齐最后一道防线

实时链路再完善,也要考虑应用在任意两行代码之间退出。对账任务应检查:

  • 长时间停留在 NEWPUBLISHING 的 Outbox;
  • 已发布但消费侧没有结果的事件;
  • 消费记录存在但业务状态没有推进的异常事务;
  • 死信队列持续增长的事件类型。

对账的目标是发现并恢复不一致,而不是定期重新执行所有任务。每一种修复动作都需要幂等,并保留审计记录。

建议监控 Outbox 未发布数量、最老事件年龄、Kafka Consumer Lag、消费耗时分位数、重试率和死信数量。这些指标能够快速定位延迟是在数据库、发布器、Broker 还是消费者。

总结

Kafka 负责高吞吐事件传递,事务 Outbox 负责解决数据库与消息系统的双写一致性。提交后唤醒可以降低延迟,周期扫描和对账则负责故障恢复。

再配合合适的分区键、消费者幂等、有界重试和背压控制,就能构建一条既实时又可恢复的事件链路。可靠性并不是依靠某一个中间件自动获得的,而是由每个失败窗口的处理方式共同决定。

相关推荐
IT_陈寒1 小时前
Vite的静态资源路径搞得我头秃,原来这样配才对
前端·人工智能·后端
BerryS3N2 小时前
前端应用的离线暂停更新策略:构建稳定可靠的渐进式部署方案
前端
LaughingZhu2 小时前
Product Hunt 每日热榜 | 2026-07-17
前端·数据库·人工智能·经验分享·mysql·chatgpt·html
潇凝子潇2 小时前
UnsupportedOperationException
java·前端·数据库
卷无止境2 小时前
Python 跨平台 GUI 解决方案全景报告
前端·python
万少9 小时前
用腻了 WorkBuddy 的默认界面?这个开源小工具给它换上了毛玻璃
前端·javascript·后端
海上彼尚13 小时前
Nodejs也能写Agent - 16.LangGraph篇 - 条件分支与循环
前端·后端·langchain·node.js
To_OC14 小时前
从 “卡死半天” 到 “打字机效果”:大模型流式输出前端实现全记录
前端·javascript·llm
SoaringHeart14 小时前
Flutter最佳实践:键盘辅助视图输入框天添加图片附件
前端·flutter