Kafka数据丢失与重复消费:从投递语义到工程实践

📅 发布时间:2026/10/20 3:43:05
Kafka数据丢失与重复消费:从投递语义到工程实践
做 Kafka 生产运维这些年有一个话题永远绕不开就是 Kafka 的数据丢失与数据重复。每次出事故群里最先炸开的永远是两句话“消息是不是丢了” “下游怎么收到两遍” 很多团队第一次用 Kafka 都把它当成一根不会出错的“消息水管”可上了生产跑一阵就会发现Kafka 的默认配置里其实藏着大量可靠性陷阱丢数据可以悄无声息重复数据也未必会立刻报警。先说我处理过的一个典型案子。某电商系统在月底对账的时候发现订单明细表和支付流水怎么都对不上。上游生产日志一切正常数据库也查不到删除记录最后层层排查才发现消费端的某个消费者在重平衡之后把位移回退了两分钟后半段消息重新走了一遍。一部分被幂等逻辑过滤跳过了另一部分被当新消息重复处理。最终数据“多了一截又少了一截”——表面上只是个数字对不上的小问题实际上把 Kafka 的数据丢失与数据重复两个问题同时暴露了。所以这篇就来专门拆这两件事Kafka 到底承诺了怎样的投递语义哪些配置会埋下丢数据的雷哪些重复是重试和重平衡带来的锅线上出了问题怎么定位以及最后用什么工程手段兜底。适合准备把 Kafka 引上线的团队也适合正在被“数据对不上”折磨的开发者、运维和架构师。1. 投递语义——先搞清楚 Kafka 承诺了什么1.1 三种投递语义对应不同忍耐场景接触 Kafka 可靠性首先绕不开的一个基础概念就是投递语义Delivery Semantics。它分为三档最多一次At Most Once、至少一次At Least Once、精确一次Exactly Once。用快递来打比方“最多一次”像快递员向你保证包裹一定不重复送但路上弄丢了他就直接说丢了你永远收不到“至少一次”是快递员保证一定送到但路上即使送了两遍他也会再送一遍“精确一次”是既不丢也不重只送一次但实现起来成本最高。Kafka 默认的行为是“至少一次”。也就是说在合理配置下消息不会因为服务器端正常流程而丢但重复消费是可能发生的。很多开发者第一次听到这话都挺意外因为大家习惯性地认为消息队列就是“必达而且只有一次”。事实上Kafka 把“不丢”和“不重”拆成了两个不同的工程问题不丢靠副本、确认和位移提交不重要靠幂等生产者、事务以及应用层的去重。理解了这三档语义很多配置行为就变得可推演了。比如你设置acks0生产端发完就算成功本质就是你选择了“最多一次”——代价是消息可能在网络途中丢失换来极低的延迟设置acksall并配合副本同步你得到的就是“至少一次”它是绝大多数核心业务默认选项。至于精确一次Kafka 在生产者端是用“幂等 事务”实现的它能保证写到同一个分区里的消息不重复但消费者下游能不能做到精确一次从来都不是 Kafka 单方面能承诺的。1.2 可靠性机制门槛ISR、HW 与位点提交在聊具体丢数据场景之前先把 Kafka 的几个底层机制说清楚否则后面很多参数讲起来会很绕。每个 Topic 的分区有多个副本一个是 Leader负责承接生产者的所有写入其余是 Follower不断从 Leader 拉取数据做同步。能跟上 Leader 进度的 Follower 会进入“同步副本集合”也就是 ISRIn-Sync Replicas。当你设置acksall并不是要求所有副本都确认而是要求 ISR 中所有的副本都确认。此时只要 ISR 里至少有一个健康副本存活消息就不会因为某个 Broker 宕机而丢。HWHigh Watermark是指已经被足够多副本确认、对消费者可见的消息位置。另一个必须理解的概念是“位点提交”。消费者每消费一批消息需要把当前消费到的 Offset 提交给 Kafka。Kafka 本身并不清楚你这批数据的业务处理成功了没有它只按照你提交的位点往下继续。这个“提交时机”就是数据丢失与数据重复的分水岭早提交可能导致业务处理失败时数据丢晚提交可能导致处理成功后推进位点太慢重平衡时重复消费。后面所有故障案例本质上都是“提交时机”和“业务处理结果”没有对齐导致的。2. 数据丢失——大多数事故都踩了这几个配置2.1 生产者配置acks、retries 和幂等的取舍生产者丢数据九成以上都出在三个参数上acks、retries、enable.idempotence。先说acks。它有三个取值分别对应三种可靠性级别acks 取值行为丢数据风险0不等待任何确认发完就当成功非常高任何网络抖动都会丢1Leader 写入本地日志后返回确认中Leader 宕机且副本没同步时会丢allISR 全部写入后才返回确认低但需要结合最小 ISR 配置在记账、订单这类不能丢数据的系统里我几乎不会用 0 或 1。acks0适合日志采集、监控上报这类允许丢一丢的轻量场景前提是整个架构要明确知道“丢数据是预期行为”不能事后去对账。acks1看似折中实际上风险最大——Leader 刚写完本地日志还没来得及同步给 Follower 就宕机了旧 Leader 上的数据在新 Leader 身上找不到消费者一读就是缺的。第二个参数是retries。不少人以为这个参数是万能的其实它是双刃剑。retries0时Broker 正在做 Leader 切换、网络瞬时抖动、请求超时发送就直接失败消息丢了。retries设得太大Produce 请求会不断重试可能把 Broker 压垮。生产环境我通常配合delivery.timeout.ms一起用总超时时间设 2 分钟重试次数用一个大数这样 Kafka 会在时间内尽力重试超过总超时才宣布失败。第三个是幂等生产者enable.idempotencetrue。它通过给每个生产者分配 producerId 和消息序号让 Broker 端能识别重复消息并拒绝重复写入。这条配置解决了“重试导致同一消息被写了两次”的问题但它不是银弹——如果生产者进程重启producerId 变化幂等边界就断了如果一条消息已经由业务下游的另一个系统消费并写入了数据库生产者端的幂等并不能阻止下游重复消费。还有一个容易忽略的生产者丢数据路径linger.ms和batch.size。生产者为了提高吞吐会先把消息攒在内存缓冲区达到条件才批量发送。如果进程被kill -9或发生 OOM缓冲区内没来得及发出去的消息直接丢失。所以生产环境里关闭生产者进程尽量走优雅关闭让 KafkaProducer 把剩余数据 flush 完。2.2 Broker 配置副本、ISR 判定与刷盘策略Broker 侧的丢数据通常不是“消息在磁盘上坏了”而是配置让副本机制失效了。最典型的就是replication.factor1。分区只有 Leader 没有 Follower再牛的 Kafka 也保护不了单点磁盘故障。很多小团队刚开始搭 Kafka 图省事一个 Broker 一台机器Topic 副本数默认是 1等机器磁盘坏了才发现数据全没了。生产环境至少应保证副本数 3且三副本尽量分散在不同机器上。第二个坑是min.insync.replicas与acksall的配合。假设 Topic 配置三副本acksall但min.insync.replicas1那么只要 Leader 自己活着就认为消息已同步。此时 Follower 挂掉两个Kafka 依然能正常写入但 ISR 已经悄悄缩到 1 了一旦 Leader 再挂数据直接丢。正确的做法是acksall必须配合min.insync.replicas2让系统在副本不足时主动拒绝写入而不是用“可用性”掩盖“数据正在丢”的风险。第三个坑是unclean.leader.election.enable。当 Leader 挂了ISR 里的副本全不可用只剩一个落后很多的副本时Kafka 是否允许它上位当新 Leaderfalse表示不允许分区宁可短暂不可用也要等 ISR 副本恢复true表示允许服务是不断了但新 Leader 会丢掉那些它没来得及同步的消息。我处理过的一起事故就是某个团队把这个参数设成了true一次机器宕机后系统确实没停但数据回退了一个多小时——而且发现得晚回退影响早就扩散到了下游好几个微服务。在金融、订单这类场景里我强烈建议保持false并且把监控指标UnderReplicatedPartitions设为长期盯防项。Broker 刷盘参数也要提一嘴。Kafka 默认依赖 OS 页缓存日志文件不是每写一条就 fsync如果宿主机断电或内核崩溃未落盘数据会丢。多副本 acksall其实已经能兜住单机断电风险所以不需要把刷盘阈值调得过于激进否则性能会很难看。真要在临界场景下更强一致通常是结合外部存储做数据校验而不是死磕单机刷盘。最后还有一类特殊“丢失”是日志被保留策略清理了。log.retention.hours、log.retention.bytes如果设置得比某个消费者的消费速度更短消费者还没攒到那批消息数据就被过期清掉。这种现象在日志数据里很容易被误判为丢消息其实是被自己配的 TTL 删了。排查时第一件事就应该确认Topic 的保留窗口和消费者 lag 是否有交集。2.3 消费者配置位移提交和重平衡的连环坑消费者导致的丢失往往比生产者和 Broker 更隐蔽。最常见的失误就是直接使用默认的自动提交enable.auto.committrue默认每 5 秒提交一次位移。想象一个场景你拉了一批 1000 条消息程序处理到第 800 条时刚好到了 5 秒的自动提交点位移提交到了 800。接下来继续处理第 801 条时进程重启。重启后消费者从 800 继续消费中间那 200 条已经处理但没提交的消息被重新消费——这其实是重复不是丢。如果是反过来的顺序程序处理完 800 条但还没到 5 秒点进程就崩了那么位点停在旧位置重启后这 800 条会重新走一遍。对于有状态处理的业务来说如果重新处理时没有幂等保护下游就会看到“重复”。更隐蔽的是自动提交期间某批消息拉下来后poll返回但业务处理还没完成自动提交线程已经帮它提交了新位点这时进程崩溃这批消息实际上没被正确处理位点却已经提交了——数据从消费者视角看是“消费过”了但对下游业务来说就是丢了。所以我的第一建议是核心业务一律enable.auto.commitfalse改用手动提交。手动提交也有讲究commitSync()会阻塞到提交成功可靠但吞吐低commitAsync()不阻塞性能好但失败时不重试就可能会丢位移。稳妥做法是commitAsync()成功则罢失败则进回调重试或兜底。最忌讳的是提交完位移业务数据还没落库——那才是真正的逻辑丢失Kafka 再可靠也救不回来。重平衡也是丢数据的高发场景。消费者组里有新消费者加入、消费者心跳超时、max.poll.interval.ms处理超时都会触发分区重新分配。旧消费者负责的分区被分配给新消费者时如果旧消费者还没来得及提交位移新消费者就会从旧位点重新消费。这里既有重复也可能因为过滤逻辑等问题造成下游数据处理错乱。前面提到的“对不上账”事故本质就是重平衡把位移回退了两分钟。3. 数据重复——重复消息到底是怎么来的3.1 生产者重试带来的重复与顺序问题数据重复最容易被误判为“下游代码 bug”其实源头往往在生产者重试。逻辑是这样生产者发送一条消息到 LeaderLeader 写入成功正要返回 ACK 时网络抖动ACK 没送到生产者。生产者判断发送失败按retries配置重新发送Broker 上就被写了两遍。这是“至少一次”语义下的天然结果。开了幂等生产者enable.idempotencetrue以后Broker 端会通过 producerId 和序列号把同一批重复写入挡掉这个问题在 Kafka 内部算是缓解了。但要注意几个边界幂等生产者在生产端重启后会生成新的 producerId事务型场景中如果事务回滚处理不当消费者还是可能在旧位点反复读而且幂等只解决单分区内的重复如果消息跨分区投递且消费端没有任何去重依然可能出现逻辑上的重复。除了重复重试还会带来乱序。如果max.in.flight.requests.per.connection大于 1而同一个连接上有多个请求在途后发送的消息可能先被 Broker 处理旧请求失败后重试新请求先落盘消息顺序就会颠倒。开幂等后Kafka 对分区内消息有排序机制但如果你显式关掉幂等、又追求高吞吐乱序风险得自己承受。对订单这类对顺序敏感的业务我通常建议要么单分区单连接要么接受乱序后用应用层的序列号去重排序。3.2 消费者重复消费的主要路径消费者端重复基本都围绕“位移提交”和“重平衡”两个词转。第一种路径处理成功但提交失败。你手动用commitSync()提交位移网络超时导致提交返回失败但 Leader 可能已经提交成功了只是响应丢了。此时消费者重试提交逻辑上是重复提交问题不大但如果没做幂等就继续处理业务重复消费就可能发生。第二种路径处理过程中崩溃。poll 出一批消息处理到一半进程崩溃位移还没提交。重启后消费者从上次提交的位点重新消费已处理过的消息再走一遍。这个场景里“丢”不会发生但“重”几乎是必然的。第三种路径重平衡触发。消费者处理耗时太长超过max.poll.interval.msKafka 认为它已经死掉踢出消费者组并触发重平衡。分区重新分配后新消费者会从被踢消费者最后提交的位移开始消费。如果旧消费者在最后并没有及时提交处理到哪这中间的整段数据都会重来。这里还有一个小坑新手很容易混淆新消费者加入时auto.offset.resetearliest或latest不是用来解决重复的而是决定“没有提交过位移”时从哪里开始。如果一位同学把消费者组名写错新组没有任何历史位移默认从末尾开始消费看起来就是少了一大段历史数据其实那只是从latest开始读而已。3.3 Kafka 的“精确一次”能解决到什么程度Kafka 官方提供了“精确一次”的完整方案幂等生产者 事务。生产者通过transactional.id开启事务Broker 端会给生产请求打上事务标记消费者设置isolation.levelread_committed后只会读取已提交事务内的消息。跨分区、跨 Topic 的写入也有了原子性边界。但要特别说清楚这个“精确一次”传达的范围只覆盖“生产端写入 Kafka 到消费者从 Kafka 读取”这一段。消费者拿到消息后去更新数据库、调外部接口、写缓存如果能和位点提交放进同一个本地事务里那么可以达到端到端的精确一次但如果下游是独立的数据库且没有使用“业务操作和位移提交”共用一个事务那 Kafka 的精确一次对下游而言依然只是“至少一次”。所以我在实际项目中很少把希望全部押在 Kafka 事务上而是按“生产者端尽量不重复、消费者端主动去重”的原则设计。Kafka 事务做兜底应用层幂等做最后防线。不是 Kafka 做不到而是很多团队的业务系统根本承接不了事务化读写的性能开销。4. 排查与定位——从“消息不对”到“实锤”4.1 监控指标教你锁定故障面线上出了问题第一步不是去改代码而是先通过监控指标确认故障面在生产端还是消费端、抽 Broker 还是抽网络。我最常盯的几个 Kafka JMX 指标指标含义异常时说明什么UnderReplicatedPartitions分区有多少副本没同步上位长期大于 0副本同步出问题存在丢数据风险IsrShrinksPerSec / IsrExpandsPerSecISR 集合收缩/扩张频次频繁抖动说明有节点不稳定会诱发丢数据ActiveControllerCount集群中活跃 Controller 数量应该在集群里恰好为 1多个或 0 都说明 Controller 选举办砸了RequestHandlerAvgIdlePercentBroker 请求处理器空闲率低于 30% 说明 Broker 繁忙生产端请求可能超时BytesInPerSec / BytesOutPerSec主题流入流出速率突然归零往往伴随生产端故障或消费端故障消费者端最容易看的是消费者组的 lag。用管理工具查一下某消费者的CURRENT-OFFSET和生产端的LOG-END-OFFSET如果 lag 持续增长说明消费速度跟不上如果 lag 突然清零或变成 0反而要警惕是不是重平衡后位移被重置了。4.2 命令行工具查看位移、分区和副本Kafka 自带的命令行工具是定位问题的基本功。查看主题分区和副本状态kafka-topics.sh --bootstrap-server broker_host --describe --topic order_topic输出里能看到每个分区的 Leader、Replicas、Isr。如果某个分区 Isr 只有 Leader 自己说明副本同步已经出状况了后续任何一台机器挂掉都可能丢数据。查看消费者组位点kafka-consumer-groups.sh --bootstrap-server broker_host --group order_consumer --describe输出的CURRENT-OFFSET、LOG-END-OFFSET、LAG三列能一眼看出是否消费落后。如果某个分区的CURRENT-OFFSET比业务逻辑预期值小很多再结合消费日志判断是没消费完还是位点被回退了。怀疑数据被清理时可以用kafka-configs.sh查看 Topic 的保留策略参数再用kafka-dump-log.sh检查日志片段。很多看起来“丢数据”的案例实际只是log.retention.hours设置太短早就把片段删干净了。4.3 结合时间线反推丢还是重一看便知工具看的是静态画面真正把问题钉死需要靠时间线反推。具体做法是把生产和消费两侧日志拉出来做对比。生产侧重点看三个时间点某段时间是否有重试、是否有超时、所在 Broker 是否有 Leader 切换或宕机记录。消费侧重点看三个时间点位移提交是否连续、是否有重平衡、消费者实例是否有重启或超时。我遇到一次诡异的重复消费从指标上什么都看不出来最后发现生产者在某 30 秒内经历了一次网络分区回滚后重试导致大量重复写入又因为消费者当时也在重平衡位移回退两条原因叠加下游某张表里的金额对不上了。时间线一核对两边的时间戳都指向同一个 30 秒窗口问题就立刻清楚了。实际操作里我还会故意做一次小规模验证在测试 Topic 上生产一批带业务流水号的消息用同样的消费者配置跑一遍对比消费到的流水号和发送时是否一致。如果重复一串、缺失一串那基本能判断是消费端位移提交逻辑的问题如果发送侧出现重复但消费者读到的数据量没变多基本就是幂等生产者没有生效。5. 工程实践——配置、去重、对账的最优组合5.1 业务场景配置清单从高可靠到高吞吐不同业务的可靠性诉求不一样配置组合不要太死板。我按两种典型场景给出参考配置。高可靠场景比如订单、支付、余额变动。生产者端配置acksall min.insync.replicas2 retries2147483647 max.in.flight.requests.per.connection5 enable.idempotencetrue delivery.timeout.ms120000 request.timeout.ms30000消费者端配置enable.auto.commitfalse auto.offset.resetearliest max.poll.records500 max.poll.interval.ms300000 heartbeat.interval.ms3000 session.timeout.ms10000高吞吐场景比如访问日志、行为埋点。生产者端可以适当放宽acks0 retries0 linger.ms20 batch.size32768消费者端也可以提高fetch.min.bytes、fetch.max.wait.ms让批量拉取更爽快但这套组合要接受偶尔丢数据、偶尔乱序的事实。更细致的场景选择参考这边场景可靠性要求推荐配置订单支付不丢不重acksall、min.insync.replicas2、幂等生产者、手动提交日志采集可丢部分acks0、retries0、批量发送缓存刷新可接受重acks1、消费者自动提交业务幂等对账系统高度敏感acksall、事务生产者、read_committed5.2 消费幂等唯一键、幂等表、状态校验配置层面做到最佳还是不能根除下游重复消费。所以工程上的重点是把消费幂等做成默认能力。最稳妥的是“数据库唯一索引 业务流水号”。每条消息带一个全局唯一 ID比如订单号事件类型随机数。消费时直接往订单表插入一条记录撞到唯一索引就说明已经处理过直接跳过。这个方案简单可靠但如果消息里没有自带 ID就要在应用层维护一个 ID 生成器。第二个方案是“幂等表”。消费端在同一个数据库事务里先往msg_process_record表插入一条消费记录主键就是消息 ID再执行业务数据更新。如果消息 ID 重复插入数据库会报主键冲突事务回滚业务更新也不会发生。这种做法把“消息去重”和“业务写入”绑进了同一个事务能挡住大多数重复消费。第三个方案是“状态校验去重”。适合业务本身有状态机的场景比如订单状态已经是“已支付”再收到一条支付成功事件就直接忽略。用状态字段做判断比维护一张幂等表更轻量但要小心状态更新不是原子操作时出现并发问题。RedisSETNX做幂等也可以但我不建议作为唯一防线。Redis 缓存总有失效时间万一业务处理超过缓存 TTL重复消息就会绕过缓存进库。比较稳妥的组合是 Redis 拦截快路径数据库唯一索引兜底慢路径。5.3 数据校验、补偿与监控预案光有防御还不够数据可靠性的最后一道防线是“发现异常并补偿”。我建议按天或按小时做离线对账。比如每天凌晨统计某个 Topic 的 BytesIn 总量和业务数据库里的实际记录数做对比消费端每处理一条业务消息往对账表写一条流水第二天拿流水数和 Kafka 消费到的消息数对比差异超过阈值就报警。这类对账任务一开始可能很粗但一旦发现问题能帮你把故障范围快速缩小到某一条链路。还要建立“补发机制”。发现丢数据后人肉去翻原始业务日志是不现实的。正确的方式是单独建一个补偿 Topic业务系统在写消息时同时记录一份关键数据到外部存储或者定期扫描未完成状态的任务列表把遗漏的消息重新生产到补偿 Topic。补偿消息照样走幂等逻辑重复了也不会对下游造成二次脏写。监控预案方面至少要把四类告警做起来消费者 lag 突增、UnderReplicatedPartitions 大于 0、ActiveControllerCount 不等于 1、生产端请求错误率升高。告警之后要有预案比如 lag 突增时是扩容消费者还是先暂停消费Broker 宕机时是先扩容副本还是降级消费这些最好提前演练过免得故障来了现场开脑洞。最后分享一点个人感受。我见过太多团队把“Kafka 不丢不重”当成理所当然出了问题第一反应是怪框架、怪网络。但真正深入排查下来绝大多数事故都出在配置组合、位移提交时机、以及下游没有幂等这三个层面。Kafka 其实已经给了足够多的机制问题是我们有没有认真理解它、正确地使用它。生产环境里配置保守一点、幂等扎实一点、对账勤快一点比追求任何“最先进方案”都更重要。