一、现象与背景:一次 1420 条数据消失的事故复盘
1.1 事故复现
去年 Q4,我们支付回调落库链路遭遇严重故障:Kafka 3.5(3 broker)、pay-callback topic 16 分区、消费组 8 实例、MySQL 8.0。QPS 峰值约 6200 msg/s,消费侧采用 batch_size=500、flush_interval=500ms 的攒批策略。一次滚动重启导致 6 个实例中有 3 个带着「已 poll 出来、还没 flush」的在途批被 kill 掉,合计 1,420 条回调被 offset 推进了、但数据从没落进 MySQL。
1.2 事故影响
- 对账异常:因代码里是「先
commitSync成功、再flush」,broker 侧 offset 早就 durable 了,所以任何「按 offset 消费成功」口径的对账都是平的,只有下游对账才暴露出 1,420 条缺失。 - 业务损失:这些回调涉及用户账户余额变更,重启后系统从新 offset 开始消费,导致 1,420 条账务变更被静默丢弃。
- 排查成本:由于对账表未记录 offset 与业务落库的时序关系,整个故障排查耗时 3 天,最终通过 Kafka 消费位点与 MySQL binlog 的时序比对才定位到问题。
1.3 核心矛盾
这个事故暴露了一个根本性问题:offset 提交与业务落库的顺序,直接决定了故障时是「静默丢数据」还是「可幂等吸收的重复」。多数团队默认写成「先 commit、再 flush」,以为先记账更稳——恰恰相反,这是最危险的一种写法,它把 Kafka 承诺的 at-least-once 语义悄悄降级成了 at-most-once。
二、根因分析:offset 与业务落库的「时序博弈」
2.1 两个独立的提交点
把一次消费拆成两个动作:
flush:把内存里的 500 条攒批写进 MySQL(一次事务提交),这是业务侧的持久化;commit:向 broker 声明「这个 partition 我已消费到 offset N+1」,这是Kafka 侧的持久化。
Kafka 的 at-least-once 语义成立,有一个隐含前提:offset 的推进必须严格晚于业务的持久化。只要这个前提被打破,语义就塌了。
2.2 易失性不对称
关键在于两者的易失性不对称:
- broker 侧的 committed offset 是 durable 的(写进
__consumer_offsets,ISR 复制); - 你内存里的在途批是易失的(进程一死就没了)。
所以「先 commit、再 flush」等于:用 durable 的那个动作,先行承认了「这批已完成」,而真正没 durable 的那批数据还在内存里飘着。一旦进程在两步之间被 kill / OOMKilled / 被滚动重启打断,Kafka 认为「这批消费成功了、offset 已经往前了」,重启后从新 offset 接着读——那 500 条就永久丢了。
2.3 量化风险模型
假设 batch_size=500、flush_interval=500ms,那么:
- 最大丢数据窗口 =
batch_size * flush_interval=500 * 500ms = 250,000ms = 250s。 - 实际丢数据窗口 =
max_poll_interval_ms - flush_time(假设max_poll_interval_ms=300,000ms,flush_time=400ms,则实际窗口是 299,600ms)。
这意味着:一次进程崩溃可能导致最多 299,600ms 的数据丢失窗口,而这个窗口的大小完全取决于 batch_size 和 flush_interval 的配置。
三、具体做法:顺序铁律 + 幂等兜底(可复现)
3.1 关自动提交,改手动 + 同步
enable.auto.commit=false
max.poll.interval.ms=300000 # 默认 5min,下面校验 flush 耗时是否安全
session.timeout.ms=45000
heartbeat.interval.ms=15000
3.2 主循环:poll → 攒批 → flush → commitSync,顺序不能反
while (running) {
ConsumerRecords<Long, String> recs = consumer.poll(Duration.ofMillis(500));
if (recs.isEmpty()) continue;
List<Event> batch = decode(recs); // ① 攒批(≤500)
tx.flush(batch); // ② 先落库:MySQL 单事务多值 INSERT,失败抛异常
// ③ 落库成功后才推进 offset:取本批每条 partition 的最后一条 offset + 1
Map<TopicPartition, OffsetAndMetadata> toCommit = new HashMap<>();
for (TopicPartition tp : recs.partitions()) {
long last = recs.records(tp).get(recs.records(tp).size() - 1).offset();
toCommit.put(tp, new OffsetAndMetadata(last + 1));
}
consumer.commitSync(toCommit);
}
3.3 幂等表设计:用数据库的「唯一约束」兜底
CREATE TABLE idempotent_table (
event_id VARCHAR(64) PRIMARY KEY,
event_data JSON NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
3.4 幂等逻辑:用数据库的「唯一约束」兜底
try {
// 先落库
tx.flush(batch);
// 再推进 offset
consumer.commitSync(toCommit);
} catch (DuplicateKeyException e) {
// 重复消费,直接忽略
log.warn("重复消费事件:{}", eventId);
}
四、踩过的坑与反直觉之处
4.1 反直觉 1:先 commit 会「提前锁住 offset」
当使用 commitSync 时,Kafka 会阻塞直到 offset 写入 ISR。这意味着:
- 如果 flush 失败(比如数据库连接中断),offset 已经被推进,但数据还在内存里,重启后会丢数据;
- 如果 flush 成功,但 commitSync 失败,offset 会被回滚,但数据已经落库,重启后会重复消费。
4.2 反直觉 2:batch_size 越大,风险窗口越大
虽然 batch_size 能摊薄数据库写压力,但同时也放大了风险窗口。例如:
batch_size=500:单条消息的丢数据风险是 500 条;batch_size=100:单条消息的丢数据风险是 100 条;batch_size=1:单条消息的丢数据风险是 1 条。
4.3 反直觉 3:flush_interval 不能随意调小
虽然调小 flush_interval 能降低风险窗口,但会增加数据库写压力。例如:
flush_interval=100ms:每秒 flush 10 次,数据库写压力是 6200/10=620 次/s;flush_interval=500ms:每秒 flush 2 次,数据库写压力是 6200/2=3100 次/s。
4.4 反直觉 4:MySQL 的 binlog 无法直接定位 offset
虽然 binlog 可以记录数据变更,但无法直接映射到 Kafka 的 offset。需要通过 Kafka 消费位点与 MySQL binlog 的时序比对,才能定位到具体 offset。
五、结论与可复用清单
5.1 核心结论
- offset 提交必须严格晚于业务落库,否则会把 Kafka 的 at-least-once 语义降级为 at-most-once;
- 先 flush、再 commitSync 是唯一安全的顺序,确保任何故障只会导致「重复消费」而非「静默丢数据」;
- 幂等表是必要的兜底手段,但不能替代正确的顺序。
5.2 可复用清单
-
配置原则:
enable.auto.commit=falsemax.poll.interval.ms≥flush_interval * batch_size(确保 flush 能完成)session.timeout.ms≥max.poll.interval.ms(确保 session 不超时)
-
代码原则:
poll → 攒批 → flush → commitSync的顺序不能反- 使用数据库的「唯一约束」实现幂等
-
监控原则:
- 监控 Kafka 消费位点与 MySQL 的落库时序
- 监控 flush 成功率与 commit 成功率
-
容灾原则:
- 定期做 Kafka offset 与 MySQL 的时序对账
- 保留至少 7 天的 binlog 用于回溯
5.3 最终建议
- 不要相信「先记账更稳」的直觉,这是最危险的写法;
- 把 offset 提交视为「业务落库的承诺」,而不是「消费完成的标志」;
- 用数据库的「唯一约束」兜底,但不要依赖它,它只能处理重复消费,不能防止数据丢失。
六、延伸思考:一致性预算的量化模型
6.1 一致性预算的定义
一致性预算 = max_poll_interval_ms - flush_time,这是故障时的最大数据丢失窗口。
6.2 一致性预算的计算
max_poll_interval_ms:Kafka 客户端配置,控制 poll 的最大间隔;flush_time:数据库 flush 所需时间,受flush_interval和batch_size影响。
6.3 一致性预算的控制
- 降低
batch_size:减少单次 flush 的数据量,降低风险窗口; - 缩短
flush_interval:增加 flush 频率,降低风险窗口; - 提升
max_poll_interval_ms:增加 poll 的间隔,降低数据库写压力。
6.4 一致性预算的监控
- 监控 Kafka 消费位点:确保消费位点与业务落库的时序一致;
- 监控 MySQL 的落库时序:确保数据落库时间早于 offset 提交;
- 监控 flush 成功率:确保 flush 不会失败。
6.5 一致性预算的决策
- 核心交易链路(如支付、扣款):一致性预算 ≤ 100ms;
- 辅助链路(如商品浏览、订单列表):一致性预算 ≤ 1s;
- 后台报表:一致性预算 ≤ 30s。
七、对比与选型建议
7.1 与 RocketMQ 的对比
- RocketMQ 的 commitSync:与 Kafka 的 commitSync 类似,但支持更细粒度的 offset 提交;
- RocketMQ 的批量消费:与 Kafka 的攒批类似,但支持更灵活的批量策略。
7.2 与 Kafka 0.11+ 的对比
- Kafka 0.11+ 的 idempotent consumer:内置幂等机制,但无法解决 offset 提交顺序问题;
- Kafka 0.11+ 的 transactional producer:适合需要强一致性的场景,但增加了复杂度。
7.3 与消息中间件的选型建议
- 核心交易链路:选择 Kafka 或 RocketMQ,确保一致性预算 ≤ 100ms;
- 辅助链路:选择 Kafka 或 RocketMQ,确保一致性预算 ≤ 1s;
- 后台报表:选择 Kafka 或 RocketMQ,确保一致性预算 ≤ 30s。
八、总结与展望
8.1 核心价值
- 避免了 1,420 条数据的永久丢失,保障了业务数据的完整性;
- 建立了「一致性预算」的量化模型,为架构决策提供了数据支持;
- 通过幂等表兜底,确保了重复消费的可恢复性。
8.2 未来展望
- 将「一致性预算」纳入 SLO 管理,与业务指标(如投诉率、资损率)挂钩;
- 开发「一致性预算」监控仪表盘,实时展示各接口的一致性窗口;
- 探索「一致性预算」的自动调优,根据负载动态调整
batch_size和flush_interval。
8.3 最后的话
一致性不是技术选型的问题,而是架构师的决策。不要相信「先记账更稳」的直觉,而是用数据说话。当你的团队在讨论「要不要强一致」时,不妨问一句:你的「一致性预算」是多少?