用过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端可靠性、消费端可靠性。每一部分都有对应的配置项和代码习惯,不能顾此失彼。只把acks=all设置好、其他环节全裸奔,照样会丢数据。
2. 生产端:消息发出去了,不等于真的被接收了
2.1 acks=0 和 acks=1,一个主动放弃,一个半吊子
生产者通过acks参数决定发送消息后要等待什么程度的确认。这个参数有两个常见坑:
acks=0:消息发出去就完事,不管Broker有没有收到。这时候网络抖动、连接超时、Broker短暂不可用,消息都可能直接丢。这个配置适合日志类、允许少量丢失的场景,但无论如何你都谈不上“可靠性”。acks=1:Leader收到并写入本地日志就算成功,不等待Follower复制。听起来比0靠谱,但问题在于:如果这个Leader随后宕机,而Follower没有同步这条消息,那么消息在切换之后就会从消费者视角“回退”消失。
很多团队把acks设为1,觉得“反正Leader收到了嘛”,然后某天Leader节点磁盘故障,副本里没有那条数据,只能眼睁睁看着消息蒸发。这是我见过的最常见的可靠性翻车现场之一。
应该怎么选?从可靠性角度看,业务消息至少要acks=all,即所有ISR副本都确认。如果你追求极致性能,可以考虑acks=1,但必须接受极端情况下的少量丢失。推荐拿业务流量和非核心流量的价值对比后再决定,不要一刀切。
2.2 acks=all 也不保险,min.insync.replicas 才是底线
很多人以为设了acks=all就万事大吉,却不知道这个“all”到底有多少个。
在Kafka中,acks=all意味着Leader要等所有“正在同步”的副本(即ISR中的副本)都写入成功。但ISR是会动态收缩的:假如某个Follower落后太多,或者暂时挂掉,它会被踢出ISR。当ISR只剩下Leader自己时,acks=all实际上退化为acks=1。
这个退化过程不会报错,不会打日志,而消息可能就在这个状态下丢失。
解决办法是同时配置:
acks=allmin.insync.replicas不小于2
min.insync.replicas的含义是:如果ISR里的副本数小于这个值,Broker直接拒绝写入,抛出NotEnoughReplicasException。这种“宁可写入失败,也不静默丢失”的做法,才是可靠性应用该有的姿态。
生产者还要配套设置retries和enable.idempotence:
retries=2147483647 # 或一个较大值 max.in.flight.requests.per.connection=5 enable.idempotence=true幂等是解决“重发造成重复”的办法,不是解决丢失的办法。但结合高重试次数和acks=all,可以把生产端消息丢失的概率压到极低。
不过这里必须坦白:即使生产端全部配好,你发送消息后如果回调返回了异常,而你的代码没有处理重试,消息照样不会进入Kafka。所以生产端的可靠性,最终落地在代码对异常的处理上,配置只是前提。
3. Broker端:页缓存、刷盘和副本同步其实都留了缝隙
3.1 写入Broker内存,不等于写进磁盘
Kafka性能好的一个重要原因是它充分利用了操作系统的页缓存(PageCache)。消息发到Broker后,实际上先写入Leader进程的文件描述符对应的页缓存,再由操作系统在合适的时机刷到磁盘。这个过程看起来高效,但也意味着:
- 如果机器突然断电,操作系统来不及把页缓存写回磁盘,未刷盘的数据就丢了。
- 如果进程崩溃但操作系统存活,通常数据还在页缓存里,重启后可能保留;但断电场景是不受Kafka控制的。
有人可能会说:“那我把log.flush.interval.messages=1或者log.flush.interval.ms调小不就行了?”理论上可以,但强制每次写消息都刷盘,会严重拖垮吞吐。实际生产中,单机断电造成的丢失窗口通常在几秒到几十秒之间,这条风险主要靠集群多副本机制来兜底:Leader挂了,还有副本。
所以对于Broker端的可靠性,正确的思路不是死磕刷盘,而是确保数据有足够多的副本。刷盘参数用来控制“副本如果也没同步,会不会连内存态都没有”。
3.2 副本同步:Leader切换才暴露真正的问题
副本同步是整个Kafka可靠性里最核心、也最容易被误解的部分。
正常情况下,Producer把消息写到Leader,ISR中的Follower主动拉取消息并写入本地日志。看起来没问题。但异常场景就多了:
- Follower虽然还连在ISR里,但同步进度落后很多(比如机器负载高、磁盘慢),此时Leader宕机,数据差的那部分就丢了。
- 网络分区:Leader和Follower之间的网络断开,少数派中的Follower被踢出ISR,但它其实还活着。这种情况下,ISR缩小后,
acks=all的效果同样退化。 - 磁盘损坏:副本磁盘坏了,本地日志无法恢复。
这些问题单个看都是小概率,但集群规模一大、节点数一多,小概率事件就变成了日常。尤其是很多团队把副本数只配成1,或者虽然副本数是3但min.insync.replicas=1,等于裸奔。
一个比较务实的Broker端可靠性配置组合是:
default.replication.factor=3 min.insync.replicas=2 unclean.leader.election.enable=falseunclean.leader.election.enable=false这条尤其重要。如果设置为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.commit=true时,消费者在轮询消息后就会周期性地提交当前拉取的位置。如果你的业务逻辑是“拿到消息 -> 写数据库”,而数据库写入失败,但offset已经被提交,那么这条消息就彻底丢掉了,重启后也不会再拉取到。
解决方案是:
- 设
enable.auto.commit=false - 在业务处理成功后再手动提交offset
手动提交时需要小心:
// 错误示范 consumer.poll(1000); consumer.commitSync(); // 还没处理就提交了 // 正确姿势:先处理业务,再提交 List<ConsumerRecord> 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、IsrExpandPerSec:ISR是否频繁收缩。NetworkRequestAvgTimeMs/RequestLocalTimeMs:Broker是否长期处于高负载状态。
第二步看消费者日志:
- 是否出现过
CommitFailedException,这往往和max.poll.interval.ms太小、处理超时有关。 - 是否频繁触发Rebalance,
Coordinator相关日志。 - 消费组是否出现
OffsetOutOfRange,说明offset已经跳到日志范围之外(可能日志被清理了)。
第三步查代码逻辑:
- 生产者有没有正确处理回调异常?很多早期的客户端异步发送代码忽略了异常,消息发送失败根本没人知道。
- 消费者是在处理前还是处理后提交offset?
- 业务处理有没有“先更新数据库、后提交offset”的顺序保证?
6.2 几个容易被忽略的配置组合
排查看不出明显问题时,往往是几个配置组合产生的隐性效果。
举个例子,生产端设置了retries高重试,但delivery.timeout.ms太小。Kafka 2.x之后,重试总时间受delivery.timeout.ms限制,如果超时时间小于重试次数理论上需要的时间,生产者会在重试中途放弃,消息照样发不进去。
再举个例子,消费端设置了enable.auto.commit=false,但用了commitAsync(),而且没有在回调里处理失败。异步提交本身没问题,但如果在关闭消费者前没有等待异步提交完成,那么提交可能根本没送达Broker,重启后消费起点回退,造成重复,不是丢失。
还有一个容易被忽视的:扩展分区后,消息从旧分区到新分区的生产量分配变化,如果某个分区长期没有数据,会被认为很空闲,而你的消费端恰好用assign()手动指定了旧分区集合,新分区永远没被消费,数据堆积并最终被清理。这种情况表面上看不出来“消息丢失”,但业务数据确实没有及时处理。
建议把下面这套配置作为核心业务Topic的基线,再根据吞吐情况调整:
# Producer acks=all enable.idempotence=true retries=2147483647 delivery.timeout.ms=120000 max.in.flight.requests.per.connection=5 # Broker/Topic replication.factor=3 min.insync.replicas=2 unclean.leader.election.enable=false # Consumer enable.auto.commit=false max.poll.interval.ms=300000 max.poll.records=500不要只看单个参数,这几个配置是互相配合的。少了任何一个,可靠性都可能出现漏洞。
6.3 真实排查手记:送走的三起“丢消息”事故
这里记录一例比较典型的模拟项目X中的问题,方便你对照。
当时某系统统计链路出现数据缺口,现象是每天凌晨有大约千分之二的消息对不上。从现象看,只丢凌晨的数据,白天正常。第一反应是消费者处理到凌晨时发生了异常,但查看日志没有任何异常记录。
后来通过监控确认,消费者在凌晨确实触发过多次Rebalance,原因是新部署的版本里,一批消费者节点在凌晨被滚动重启,重启过程比预期长。而消费者配置里session.timeout.ms默认值太小,节点监控线程认为消费者已死,把分区踢掉并触发再平衡。重启结束后,消费者把分区接回来,但接回来时部分offset已经提交成功,而那批正在处理中的消息就丢在了“提交成功但处理未完成”的窗口里。
修复方法并不复杂:把滚动重启改成分批,确保每次只有一个组成员离线;同时把session.timeout.ms调到明显大于实例停止响应的时间;再把手动提交改成“处理完一批再提交”。类似这样的问题,单看任何一环都合理,组合起来就丢数据,这是分布式系统的常态。
7. 到底能不能做到100%不丢
就我个人实际操作中的体会而言,Kafka在“满足全部前提条件”时,能做到的是接近100%不丢,而不是绝对100%。分布式系统里总有超出软件控制范围的意外:机房断电、磁盘物理损坏、网络长时间分区、人为误操作。任何系统都没法在这些情况下给出绝对承诺。
但要注意,实践中遇到的绝大部分消息丢失,不是因为Kafka做不到,而是因为使用者没有配好参数、没有处理好异常、没有设计好消费流程。官方文档里那些可靠性保证都是真实有效的,只是它们都绑定着一系列使用前提。
最后再分享一个我自己的经验:不要等到出了事故才去查可靠性。每次新增一个业务Topic,都应该顺手核一遍生产者的acks配置、Broker的副本因子和min.insync.replicas、消费者的offset提交方式。这套自查只花十分钟,但能避免你半夜爬起来查数据对不上的痛苦。把这些要点固定成团队的基线规范,可靠性才不是一句口号,而是真正落到每一次消息的旅程里。