用过Kafka的人都会对它“高吞吐、高可靠”的宣传印象深刻但真正在生产环境追过数据丢失的人往往对这个说法持保留态度。这个标题其实问得很准Kafka确实不能100%保证消息不丢失。原因不复杂——一条消息从生产者发出到消费者真正处理完成中间要经过网络传输、Broker内存、磁盘刷写、副本同步、消费提交等多个环节每个环节都存在数据丢失的窗口。这篇文章从这条链路一处处拆讲清楚Kafka到底能保证什么、不能保证什么以及我们在使用中怎么把丢消息的概率降到最低。1. Kafka到底承诺了什么别把“不丢失”理解成绝对1.1 可靠性从来不是Kafka单方面的保证Kafka官方文档里有句经常被引用的话“If a message is committed, it will not be lost.” 很多人只看后半句以为只要消息进了Kafka就不会丢。但关键的限定词是committed。什么叫committed在Kafka的语境里一条消息只有同时满足两个条件才算真正被确认消息写入Leader副本对应的分区并成功追加到日志的末尾。该消息被足够多的ISR同步副本复制成功达到min.insync.replicas指定的数量。这两条说的是Broker端的状态。但消费者能看到的“不丢失”还取决于消费端有没有正确提交offset。换句话说Kafka只在“你按要求配置、按规范使用”的前提下保证数据不丢。任何不满足前提的做法都可能让数据静默消失。这里用一个现实生活中的类比你把快递交给了快递员快递公司说“只要录入了系统就不会丢”。但如果你寄的是易碎品却没有任何保护或者收货人地址写错快递员可以放心地说“这不是我丢的”。Kafka也是一样它有自己的一套“运输规则”你把东西交给它之前得先确保包装合适、地址正确。1.2 可靠性依托的是三个环节的接力从端到端的视角看一条消息的完整生命周期是这样的生产者 - 网络 - Broker Leader - 磁盘/页缓存 - ISR副本 - 消费者网络 - 消费者处理 - 提交offset你只要把这条链路拆开看就知道“100%不丢失”为什么做不到。生产端可能因为网络超时回调处理不当而丢消息Broker端可能因为Leader切换、磁盘损坏、页缓存未刷盘而丢消费端可能因为处理失败却把offset提交了而丢。这三个环节任何一个失守消息就没了。所以本文将可靠性拆成三部分来讨论生产端可靠性、Broker端可靠性、消费端可靠性。每一部分都有对应的配置项和代码习惯不能顾此失彼。只把acksall设置好、其他环节全裸奔照样会丢数据。2. 生产端消息发出去了不等于真的被接收了2.1 acks0 和 acks1一个主动放弃一个半吊子生产者通过acks参数决定发送消息后要等待什么程度的确认。这个参数有两个常见坑acks0消息发出去就完事不管Broker有没有收到。这时候网络抖动、连接超时、Broker短暂不可用消息都可能直接丢。这个配置适合日志类、允许少量丢失的场景但无论如何你都谈不上“可靠性”。acks1Leader收到并写入本地日志就算成功不等待Follower复制。听起来比0靠谱但问题在于如果这个Leader随后宕机而Follower没有同步这条消息那么消息在切换之后就会从消费者视角“回退”消失。很多团队把acks设为1觉得“反正Leader收到了嘛”然后某天Leader节点磁盘故障副本里没有那条数据只能眼睁睁看着消息蒸发。这是我见过的最常见的可靠性翻车现场之一。应该怎么选从可靠性角度看业务消息至少要acksall即所有ISR副本都确认。如果你追求极致性能可以考虑acks1但必须接受极端情况下的少量丢失。推荐拿业务流量和非核心流量的价值对比后再决定不要一刀切。2.2 acksall 也不保险min.insync.replicas 才是底线很多人以为设了acksall就万事大吉却不知道这个“all”到底有多少个。在Kafka中acksall意味着Leader要等所有“正在同步”的副本即ISR中的副本都写入成功。但ISR是会动态收缩的假如某个Follower落后太多或者暂时挂掉它会被踢出ISR。当ISR只剩下Leader自己时acksall实际上退化为acks1。这个退化过程不会报错不会打日志而消息可能就在这个状态下丢失。解决办法是同时配置acksallmin.insync.replicas不小于2min.insync.replicas的含义是如果ISR里的副本数小于这个值Broker直接拒绝写入抛出NotEnoughReplicasException。这种“宁可写入失败也不静默丢失”的做法才是可靠性应用该有的姿态。生产者还要配套设置retries和enable.idempotenceretries2147483647 # 或一个较大值 max.in.flight.requests.per.connection5 enable.idempotencetrue幂等是解决“重发造成重复”的办法不是解决丢失的办法。但结合高重试次数和acksall可以把生产端消息丢失的概率压到极低。不过这里必须坦白即使生产端全部配好你发送消息后如果回调返回了异常而你的代码没有处理重试消息照样不会进入Kafka。所以生产端的可靠性最终落地在代码对异常的处理上配置只是前提。3. Broker端页缓存、刷盘和副本同步其实都留了缝隙3.1 写入Broker内存不等于写进磁盘Kafka性能好的一个重要原因是它充分利用了操作系统的页缓存PageCache。消息发到Broker后实际上先写入Leader进程的文件描述符对应的页缓存再由操作系统在合适的时机刷到磁盘。这个过程看起来高效但也意味着如果机器突然断电操作系统来不及把页缓存写回磁盘未刷盘的数据就丢了。如果进程崩溃但操作系统存活通常数据还在页缓存里重启后可能保留但断电场景是不受Kafka控制的。有人可能会说“那我把log.flush.interval.messages1或者log.flush.interval.ms调小不就行了”理论上可以但强制每次写消息都刷盘会严重拖垮吞吐。实际生产中单机断电造成的丢失窗口通常在几秒到几十秒之间这条风险主要靠集群多副本机制来兜底Leader挂了还有副本。所以对于Broker端的可靠性正确的思路不是死磕刷盘而是确保数据有足够多的副本。刷盘参数用来控制“副本如果也没同步会不会连内存态都没有”。3.2 副本同步Leader切换才暴露真正的问题副本同步是整个Kafka可靠性里最核心、也最容易被误解的部分。正常情况下Producer把消息写到LeaderISR中的Follower主动拉取消息并写入本地日志。看起来没问题。但异常场景就多了Follower虽然还连在ISR里但同步进度落后很多比如机器负载高、磁盘慢此时Leader宕机数据差的那部分就丢了。网络分区Leader和Follower之间的网络断开少数派中的Follower被踢出ISR但它其实还活着。这种情况下ISR缩小后acksall的效果同样退化。磁盘损坏副本磁盘坏了本地日志无法恢复。这些问题单个看都是小概率但集群规模一大、节点数一多小概率事件就变成了日常。尤其是很多团队把副本数只配成1或者虽然副本数是3但min.insync.replicas1等于裸奔。一个比较务实的Broker端可靠性配置组合是default.replication.factor3 min.insync.replicas2 unclean.leader.election.enablefalseunclean.leader.election.enablefalse这条尤其重要。如果设置为true发生Leader宕机时Kafka允许从“落后很多甚至没有同步数据的副本”中选举新的Leader。这样做的结果是Leader迅速恢复但消费者看到的数据倒退或丢失。可靠性场景下必须关闭这种“脏选举”。另外建议把副本同步相关指标监控起来。重点看UnderReplicatedPartitions和IsrShrinksPerSec。前者不为0说明有分区副本数少于期望的副本因子后者反映ISR在持续收缩这些都是在丢消息之前就出现的前兆。3.3 日志保留策略和删除丢了并不是故障但也是“丢失”Broker端还有一个容易被忽略的“丢失”来源Kafka本身会按照保留策略清理消息。log.retention.hours、log.retention.bytes及基于key的压缩清理log.cleanup.policy都会主动删掉旧数据。如果你默认配置是7天而消费者因为故障落后了10天那么消费者回来时会发现部分消息已经被清理看起来就是“消息丢了”。这不算Kafka故障但确实会让业务方困惑。解决办法是分析消费者最大允许落后时间把保留时间配置得比这个时间明显更长同时监控ConsumerLag超过阈值就要告警而不是等消费者自己慢慢追。很多实际事故都是“消费者卡了两周没处理回来之后发现数据已经被清理”导致的。4. 消费端大部分丢消息其实都是自己造成的4.1 自动提交offset处理还没完成确认已经给了Kafka消费者有个默认行为定期自动提交offset。enable.auto.committrue时消费者在轮询消息后就会周期性地提交当前拉取的位置。如果你的业务逻辑是“拿到消息 - 写数据库”而数据库写入失败但offset已经被提交那么这条消息就彻底丢掉了重启后也不会再拉取到。解决方案是设enable.auto.commitfalse在业务处理成功后再手动提交offset手动提交时需要小心// 错误示范 consumer.poll(1000); consumer.commitSync(); // 还没处理就提交了 // 正确姿势先处理业务再提交 ListConsumerRecord records consumer.poll(1000); for (ConsumerRecord record : records) { process(record); // 确保成功 } consumer.commitSync();如果process(record)抛异常就跳过提交这样下次poll还能再拿到这条消息。当然如果process一直失败你就会遇到“消息反复重试”的环形局面所以记得配套重试次数和死信策略。4.2 Rebalance是消费端丢消息的高发时刻消费者组里的成员发生变化时新成员加入、成员超时、主动退出会触发再平衡。再平衡过程中分区会重新分配部分分区的消费权从旧消费者转移到新消费者。如果旧消费者在再平衡之前已经拉取了一批消息但还没处理完同时offset又已经自动提交了那么在分区所有权转移之后这批未处理完的消息就处于“既落地了offset、实际业务又没处理完”的状态本质等于丢失。更隐蔽的版本是消费者在poll()循环里处理消息时间太长超过了max.poll.interval.ms被判定为死亡触发再平衡而处理到一半的消息还在你的业务代码里没有任何补偿机制。实践上的防护手段设置足够大的max.poll.interval.ms并控制单次poll的消息量max.poll.records。处理完一批消息再poll下一批不要在poll循环里夹带耗时操作。用独立的消费者线程池模式把poll和业务处理分离但这时需要自己管理offset提交时机。很多团队在低版本客户端里还踩过“提交offset在再平衡前”的坑后来会使用ConsumerRebalanceListener在分区撤销前提交offset这也是一个值得掌握的技巧。5. 为什么精确一次Exactly-Once那么难还得接受“大概率不丢”5.1 端到端的精确一次需要跨越多个系统提到不丢消息就绕不开精准一次的话题。Kafka本身提供了幂等生产者Idempotent Producer和事务Transaction API可以让“写入Kafka”这个过程具备幂等性避免重试造成的重复数据。但端到端的精确一次还需要Kafka和外部系统的操作保持原子性。最常见的模式是把offset提交外部系统的事务绑定在一起。比如你在处理消息时需要更新数据库可以把“业务写入”和“offset写入”放在同一个事务里要么都成功要么都失败。Kafka提供了sendOffsetsToTransaction方法配合事务API使用能实现“消费-处理-提交”的一体化。但这套方案实现复杂度不低对事务协调器、网络环境、外部系统都提出更高的要求。生产环境里很多团队最终选择的是“大概率不丢 少量重复 业务幂等”的组合即消息尽量不丢配置可靠性相关参数消息可能重复消费失败重试、网络重发下游系统用幂等性消化重复唯一键、状态机、去重表这种方式能覆盖绝大多数业务场景而且工程上容易落地。5.2 兜底方案关键消息的备份和比对我不建议把可靠性押在所有配置都完美的假设上。实际操作里更稳的做法是针对核心链路增加旁路兜底。比如每个周期对核心Topic的生产和消费数量做累计计数比对。Producer在发送成功后在外部存储累加一个“已发送总数”消费者处理完消息后在外部存储累加一个“已消费总数”。两个数对不上就能及时发现丢失。再比如对超高价值的消息可以在发送Kafka的同时发送一份落到备用存储如另一套消息系统或对象存储定期抽样比对。这种“双写对账”听起来笨重但在金融、订单等场景里确实是必要的。这不是Kafka本身的缺陷而是分布式系统“网络不可靠、进程不可靠、磁盘不可靠”的本质决定了你无法依赖单一组件提供绝对保证。Kafka能做的是把丢失的概率降到很低并给你一套机制去发现和补偿剩余的风险。6. 排查消息丢失的实操记录6.1 从指标和日志里定位丢失环节我遇到过几次线上丢消息的排查老实说每次的根因都不一样所以积累一套排查顺序很重要。第一步看监控指标RecordsIn和RecordsOut看生产总量与消费总量是否有持续差异。UnderReplicatedPartitions是否有分区副本同步不足。IsrShrinksPerSec、IsrExpandPerSecISR是否频繁收缩。NetworkRequestAvgTimeMs/RequestLocalTimeMsBroker是否长期处于高负载状态。第二步看消费者日志是否出现过CommitFailedException这往往和max.poll.interval.ms太小、处理超时有关。是否频繁触发RebalanceCoordinator相关日志。消费组是否出现OffsetOutOfRange说明offset已经跳到日志范围之外可能日志被清理了。第三步查代码逻辑生产者有没有正确处理回调异常很多早期的客户端异步发送代码忽略了异常消息发送失败根本没人知道。消费者是在处理前还是处理后提交offset业务处理有没有“先更新数据库、后提交offset”的顺序保证6.2 几个容易被忽略的配置组合排查看不出明显问题时往往是几个配置组合产生的隐性效果。举个例子生产端设置了retries高重试但delivery.timeout.ms太小。Kafka 2.x之后重试总时间受delivery.timeout.ms限制如果超时时间小于重试次数理论上需要的时间生产者会在重试中途放弃消息照样发不进去。再举个例子消费端设置了enable.auto.commitfalse但用了commitAsync()而且没有在回调里处理失败。异步提交本身没问题但如果在关闭消费者前没有等待异步提交完成那么提交可能根本没送达Broker重启后消费起点回退造成重复不是丢失。还有一个容易被忽视的扩展分区后消息从旧分区到新分区的生产量分配变化如果某个分区长期没有数据会被认为很空闲而你的消费端恰好用assign()手动指定了旧分区集合新分区永远没被消费数据堆积并最终被清理。这种情况表面上看不出来“消息丢失”但业务数据确实没有及时处理。建议把下面这套配置作为核心业务Topic的基线再根据吞吐情况调整# Producer acksall enable.idempotencetrue retries2147483647 delivery.timeout.ms120000 max.in.flight.requests.per.connection5 # Broker/Topic replication.factor3 min.insync.replicas2 unclean.leader.election.enablefalse # Consumer enable.auto.commitfalse max.poll.interval.ms300000 max.poll.records500不要只看单个参数这几个配置是互相配合的。少了任何一个可靠性都可能出现漏洞。6.3 真实排查手记送走的三起“丢消息”事故这里记录一例比较典型的模拟项目X中的问题方便你对照。当时某系统统计链路出现数据缺口现象是每天凌晨有大约千分之二的消息对不上。从现象看只丢凌晨的数据白天正常。第一反应是消费者处理到凌晨时发生了异常但查看日志没有任何异常记录。后来通过监控确认消费者在凌晨确实触发过多次Rebalance原因是新部署的版本里一批消费者节点在凌晨被滚动重启重启过程比预期长。而消费者配置里session.timeout.ms默认值太小节点监控线程认为消费者已死把分区踢掉并触发再平衡。重启结束后消费者把分区接回来但接回来时部分offset已经提交成功而那批正在处理中的消息就丢在了“提交成功但处理未完成”的窗口里。修复方法并不复杂把滚动重启改成分批确保每次只有一个组成员离线同时把session.timeout.ms调到明显大于实例停止响应的时间再把手动提交改成“处理完一批再提交”。类似这样的问题单看任何一环都合理组合起来就丢数据这是分布式系统的常态。7. 到底能不能做到100%不丢就我个人实际操作中的体会而言Kafka在“满足全部前提条件”时能做到的是接近100%不丢而不是绝对100%。分布式系统里总有超出软件控制范围的意外机房断电、磁盘物理损坏、网络长时间分区、人为误操作。任何系统都没法在这些情况下给出绝对承诺。但要注意实践中遇到的绝大部分消息丢失不是因为Kafka做不到而是因为使用者没有配好参数、没有处理好异常、没有设计好消费流程。官方文档里那些可靠性保证都是真实有效的只是它们都绑定着一系列使用前提。最后再分享一个我自己的经验不要等到出了事故才去查可靠性。每次新增一个业务Topic都应该顺手核一遍生产者的acks配置、Broker的副本因子和min.insync.replicas、消费者的offset提交方式。这套自查只花十分钟但能避免你半夜爬起来查数据对不上的痛苦。把这些要点固定成团队的基线规范可靠性才不是一句口号而是真正落到每一次消息的旅程里。