Kafka 消费一致性

重复消费、丢数和「看起来对不上」

Posted by shichaofaan on August 6, 2025

Kafka 本身提供的是「分区内有序 + at-least-once / exactly-once 语义可组合」的能力,不等于业务表一定不重不丢。线上最常见的投诉是两句:数多了 和 数少了。多半不是 Broker 丢了消息,而是消费位点、事务、幂等、下游写入这几层没对齐。

这篇用一次对账任务把几类一致性问题串起来讲。

案例背景

有一条链路:

业务库事务提交 
  -> 变更投递到 Kafka(topic: pay_success)
  -> 消费组 cg_pay_dwd 写入明细表 dwd_pay
  -> 小时任务汇总到 ads_pay_hour

财务按「支付成功流水号」对账,发现某日:

  • Kafka 当天消息量(按分区 log end - 日初 offset 估算):约 1,842 万
  • dwd_pay 去重后支付单数:约 1,851 万(偏多约 9 万)
  • 其中约 1.2 万笔在业务库当天不存在对应成功单(像凭空多出来)
  • 另有约 3 千笔业务库有、明细表没有

「又多又少」同时出现,说明不是单一的重复或单一的丢失。

问题一:重启导致重复写(at-least-once)

消费端是自研 Java 消费者,逻辑大致:

while (running) {
    ConsumerRecords<String, byte[]> records = consumer.poll(Duration.ofMillis(500));
    for (ConsumerRecord<String, byte[]> r : records) {
        PayEvent e = parse(r.value());
        jdbc.upsert(e);          // 写库
    }
    consumer.commitSync();       // 整批提交位点
}

表面是「写完再 commit」。实际有两个口子:

  1. 进程在 upsert 成功后、commitSync 前被杀
    重启后从旧位点重放,已写入的行会再写一遍。若 upsert 按支付单号幂等,数量不增;若当时用的是 insert,就会重复。
  2. 批量里部分成功
    中间某条 JDBC 失败抛异常,整批未 commit,重放时前面已成功的又写一次。

对账偏多的主体,来自一次发布窗口:滚动重启未排空,加上早期表结构是普通 insert,没有唯一键。后来补了 pay_id 唯一索引 + upsert,重复写入不再增行,但历史脏数据还在。

改法:

  • 下游表必须有业务主键 / 唯一键,写入用 upsert。
  • 提交位点与写入绑定:能事务就本地事务(库事务 + 位点),或改成 Flink / Kafka Streams 的事务性 sink。
  • 发布时先停消费、等 in-flight 写完再切,或接受短暂重复但靠幂等消化。

问题二:位点被误重置(「少了」)

偏少的那约 3 千笔,集中在一个分区。查消费组:

kafka-consumer-groups.sh --bootstrap-server $BROKERS \
  --describe --group cg_pay_dwd

发现该分区的 current offset 曾在某小时出现一次向前跳变。对应操作记录:有人在测试环境用了同一 group.id 连生产 Broker(网络策略当时没隔离好),并执行了:

kafka-consumer-groups.sh ... --group cg_pay_dwd --reset-offsets --to-latest --execute

测试同学本意是重置测试组,组名抄成了生产。to-latest 一执行,未消费完的消息直接跳过,造成丢数。

改法:

  • 生产 / 测试 group id 强制加环境前缀,Broker 账号分权。
  • 重置位点走工单,禁止个人笔记本直连生产改 offset。
  • 对账任务增加「分区水位 vs 明细最大事件时间」监控,位点突跳直接告警。

问题三:事务消息与隔离级别

还有一类「看起来多出来」:业务库当天查不到,但 Kafka 里有。

原因是上游用了 Kafka 事务生产者,下游消费者 isolation.level 当时是默认 read_uncommitted。事务中止后,未提交的消息仍可能被读到(取决于版本与配置组合下的可见性),下游当成功支付写进了明细。后来统一改成:

isolation.level=read_committed

并在解析层校验消息头里的业务状态,拒绝 status!=SUCCESS 的事件。那 1.2 万「凭空」流水里,大部分能对上被回滚的事务或重试中间态。

问题四:「消息量」和「业务量」本就不是同一个指标

对账时用 Kafka 消息条数直接对比支付成功单数,本身就不严谨:

  • 同一支付单可能有多次状态变更消息。
  • 重试、补偿会再次投递。
  • 多分区估算「日增量」有边界误差。

更稳的对账口径:

业务库:当日支付成功 pay_id 集合
明细表:当日 pay_id 集合(按业务时间)
Kafka:仅作链路延迟与堆积参考,不做最终对账主键

一致性要定义在业务主键上,而不是 topic 条数上。

一套可落地的消费约定

结合这次事故,我们后来定了消费侧基线:

  1. 幂等:下游按业务主键 upsert,禁止裸 insert。
  2. 位点:生产组名不可复用;重置必须审计;Flink 任务优先靠 checkpoint 恢复。
  3. 事务:有事务生产必 read_committed。
  4. 对账:按 pay_id 集合差分,输出「库有表无 / 表有库无」两类修复队列。
  5. 发布:变更消费逻辑时先看「失败重试是否会造成重复写」。

如果用 Flink 消费,exactly-once 还要求 checkpoint + 两阶段提交 sink 都就绪;只开 checkpoint、sink 仍是普通 JDBC insert,仍然只是 at-least-once。

小结

这次「又多又少」拆开后很清楚:

  • 多:重启重放 + 无唯一键 + 读到未提交事务
  • 少:生产消费组位点被误重置

Kafka 很少「自己弄丢」,更多是消费语义和运维操作把一致性打穿。把幂等、位点、隔离级别、对账口径四件事写进规范,比事后对数字要省心得多。