“Kafka能保证消息不丢吗?”这个问题我被人问了不下几十次,每次数据对不上账,第一反应就怀疑Kafka是不是偷偷吞了消息。先说结论:Kafka的可靠性设计在大规模消息系统里确实属于第一梯队,但它离“100%不丢”这个绝对承诺,永远差那么一口气。这口气不是工程能力不行,而是分布式系统在一致性、可用性、性能之间,本来就得做选择题。这篇文章从生产者、Broker、消费者三个环节,拆开讲讲Kafka到底把消息丢在哪些地方,以及我们能用哪些配置把这些洞尽可能堵上。
1. 先搞清楚Kafka到底承诺了什么
1.1 三个消息语义级别
在讨论丢消息之前,先对齐一个概念:消息系统一般都谈三种投递语义。最多一次(at-most-once)的意思是消息可能丢,但绝不会重复;至少一次(at-least-once)的意思是消息不会丢,但可能会重复;精确一次(exactly-once)是最理想的状态,不丢也不重复。
Kafka的杀手锏之一,就是它在 0.11 版本以后通过幂等生产者加事务API,能在“单个分区内”做到精确一次。注意这个前提:单个分区内。跨分区、跨会话、跨应用的端到端精确一次,至今依然是一件极其复杂的事,绝大多数团队根本没有走到那一步。
很多人以为“Kafka不丢消息”是它的默认属性,其实这是个误解。Kafka默认的配置,在大多数版本下只能给你“至少一次”的保证,而且这个保证还有一串前提条件。换句话说,默认配置下,消息确实不容易丢,但一旦你踩中某些边界条件,它丢给你看。
1.2 默认配置离100%有多远
我们平时聊Kafka的可靠性,通常都是“配置达成”的可靠性,不是“天生自带”的可靠性。所谓配置达成,就是你得同时满足好几个条件,缺一个,保护网就破一个洞。
比如生产者端要设置acks=all,Broker端的副本数要大于等于某个值,min.insync.replicas要给到合理配置,消费者端要关掉自动提交,要做幂等,还要处理Rebalance等等。这一整套下来,你才能在实践中达到“消息基本不丢”的效果。
但“基本不丢”和“100%不丢”之间,依然隔着一条天然的鸿沟。只要你的数据是跑在多台机器、多个进程之上的分布式系统里,网络分区、节点宕机、磁盘故障、断电这种物理事件就永远客观存在。配置只能缩小窗口,不能消除窗口。搞清楚这一点,再看后面所有的细节,你就会发现所有的丢消息事故都能归因到某个窗口里。
2. 生产者端:你说“发成功”和服务器“真的收到”之间,隔着什么
2.1 从send到ack,链路里每一步都可能出问题
先看一个典型的生产者发送流程。业务代码调用 producer.send() 的时候,消息不是马上飞到服务器上的,而是一路经历本地缓冲、网络发送、Broker接收写入,最后由Broker应答ack,回调告诉业务方“发送成功”。
这里每一步都有坑。本地缓冲阶段,如果客户端进程在消息还没刷进网络之前崩了,这条消息就没了。网络发送阶段,消息可能已经到达Broker,但Broker在处理过程中宕机了,或者客户端没收到ack,这条消息到底有没有被成功写入,客户端自己也无法确认。
之后你只能靠重试。重试本身是一件好事,但如果你没开幂等,重试会导致消息重复。这时候你面临的两难是:重试可能造成重复,不重试可能造成丢失。于是Kafka从0.11版本引入了幂等生产者,在单分区内通过序列号机制解决了这种因重试导致的重复问题。
2.2 acks参数决定你“信”多少
生产者端最核心的参数就是acks,它直接控制你等待多少确认。
acks=0 表示发送出去就算完事,完全不等服务器回话。这个配置下丢消息是常态,飞机会掉,网线会抖,你根本不知道服务器收到没有。一般在日志采集、指标上报这种可以容忍少量丢失的场景才敢这么用。
acks=1 表示Leader副本收到并写入页缓存之后就返回ack。注意是页缓存,不是落盘,而且只等Leader一个副本确认。Leader一旦宕机,数据还在页缓存里没刷脏,或者还没同步给Follower,这条消息就跟着丢了。这是Kafka的默认配置,性能好,但可靠性只能说“一般够用”。
acks=all 表示要等所有参与复制的ISR副本都确认接收才返回ack。这个配置能扛住单个副本宕机,因为在Leader眼中的有效副本都已经握过手了。但如果你只配了acks=all,却没有配min.insync.replicas,那么ISR列表里只有一个Leader存在时,它照样会自己给自己确认,实际上退化成acks=1的效果。
这里有一个我踩过多次的坑:很多人把acks=all当万能钥匙,以为配上就稳了,却没关注副本数和min.insync.replicas的联动关系。结果上线的还是单副本Topic,acks=all形同虚设。
2.3 重试和幂等能解决什么,不能解决什么
生产端第二个关键组合是 retries 和 enable.idempotence。开了幂等并且把retries调到合理值,网络抖动时的临时失败就能被自动消化,不会出现重试导致的重复。
但幂等生产者的保护范围只覆盖“生产者进程与单个分区Leader的会话”范围内。如果生产者本身以事务方式写了多个分区,中途进程崩了,这个多分区的原子性就需要事务API来保障。不加事务的话,一部分分区可能提交了,另一部分没提交,你就得靠下游去对账。
从可靠性角度讲,生产端的建议很直接:retries 调大到足以应对瞬时故障,acks=all,enable.idempotence=true。再把max.in.flight.requests.per.connection控制好,5个以内配合幂等都是安全的。
2.4 生产端丢数据场景小结
生产端最容易丢数据的场景,按发生频率排个序。
第一,进程在消息未发送或未确认时崩溃,消息在内存缓冲里直接蒸发。第二,acks=0或1配置下,Leader宕机导致已确认消息丢失。第三,网络分区把客户端和Broker隔开,客户端重试投递到错误Broker,或异常没有正确捕获导致消息直接被丢弃。
第四,业务代码里自己吞了异常。send方法是异步的,如果你不在回调里处理失败结果,也没设置重试,发送失败的消息就悄无声息地没了。不少人排查半天,最后发现是自己代码里try...catch空实现,把发送失败吞进了肚子。
提示:生产端的可靠配置必须与Broker端配合,单独只看生产端参数是没有意义的。
3. Broker端:数据明明已经“入队”了,到底哪里丢了
3.1 刷盘:Kafka为什么故意不急着落盘
Kafka的性能神话有很大一部分来自它“延迟刷盘”的设计。消息写入Leader的时候,其实先落进操作系统页缓存(Page Cache),Kafka并不会立刻调用fsync强制刷到磁盘。这个设计让Kafka能承受极高的写入吞吐,因为顺序写内存再异步刷盘,比每次同步刷盘快了好几个数量级。
但代价就在这里:Page Cache里的数据,在宕机或者断电时可能来不及落盘。尤其是遇到整机断电,内存一失电,所有没刷盘的数据全没了。
你可以通过配置 log.flush.interval.messages 和 log.flush.interval.ms 来调整刷盘节奏,理论上把刷盘间隔调得很小可以缩小丢失窗口,但代价是写入延迟上去了,吞吐下降。这里没有银弹,你只能根据业务对可靠性诉求,在性能和风险之间选一个平衡点。大多数生产环境不会走极端,因为副本机制已经能覆盖单机故障场景,没必要用刷盘来牺牲性能。
3.2 ISR机制:副本追不上怎么办
Kafka的高可用主要靠多副本,但多副本之间不是强同步,而是基于ISR(In-Sync Replicas)的动态同步机制。
ISR就是当前与Leader保持同步的副本集合。Follower会周期性拉取Leader的数据,只要它落后太多,或者长时间没有拉取请求,就会被踢出ISR。Leader处理写入时,只需要等ISR里的副本确认,不需要等所有副本。
这里就出现一个窗口:如果某个Follower因为网络延迟或负载过高落后了,但它还在ISR里,这时候Leader把数据写进去了并返回了ack,然后Leader挂了,那么这个落后的Follower被选为新Leader,新Leader上就没有刚刚那条已经ack过的消息。
你可能会说,把滞后阈值调小一点,比如 replica.lag.time.max.ms 调小,落后就立刻踢出去,窗口不就缩小了吗?确实能缩小,但踢得太快也有副作用:万一网络只是抖了一两秒,副本就被踢进非同步列表,此时如果恰好Leader宕机,甚至触发unclean选举,情况会更糟。
3.3 未同步副本当选之后,丢数据就变成现实
Kafka的领导人选举有个参数叫 unclean.leader.election.enable,默认是false。这个参数控制的是:当Leader挂了,ISR里没有其他可用副本时,是否允许一个不同步的副本(非ISR副本)当选为新Leader。
如果设为true,系统可用性优先——只要还有活的Broker就能继续服务,哪怕它上面的数据是旧的。这会直接导致之前写入并被ack过的数据丢失。如果设为false,宁可让分区短暂不可用,也不允许丢数据。
这个选择本质上就是CAP里的可用性和一致性之争。大部分金融、交易类业务会把unclean.leader.election.enable设为false,宁可暂停几分钟,也不接受静默丢数据。而日志、监控类场景可能更在乎可用性,选true也无妨,但要清楚代价。
注意:设置unclean.leader.election.enable=true,等于亲手打开了丢消息的大门,这不是Bug,是设计选择。
3.4 min.insync.replicas 如何兜底
acks=all之所以能起作用,前提是ISR里至少有一定数量的副本。min.insync.replicas 就是定义这个最低数量的参数。
比如你设置了 replication.factor=3,min.insync.replicas=2,那么只有当ISR里至少有两个副本时,分区才能正常接受读写。如果只有一个副本,生产者会被拒答,宁可不写入也不给你假确认。
这个配置配合acks=all,才能把“已确认消息”和“副本安全落位”绑定在一起。否则,当只剩一个Leader副本时,acks=all照样能自嗨式确认,消息就丢得悄无声息。
我见过很多团队配置了副本数3,min.insync.replicas却保持默认1,结果机房某个机架上两台机器同时断电,另一台在ISR里,写入依然照常,等机器起来发现数据落后了一大截。回过头来看,只怪当初没把min.insync.replicas抬高。
3.5 极端场景:整个副本组都没了
有了多副本、ISR、acks=all、min.insync.replicas,是不是就能拍胸脯说100%不丢了?还不是。
你要考虑一个场景:某个分区的所有副本,正好同时被拉进同一个故障域里。比如一个机架断电,副本刚好都在这一个机架上;或者云厂商某个可用区故障,而你所有副本都部署在这个可用区,那就没有任何副本能救你。
还有另一个隐蔽场景:Leader宕机后,ISR里的Follower接管了分区,但旧Leader经过一段时间的隔离后被重新拉回集群,如果它上面有一些未被同步的“脏数据”,Old Leader的分区数据会被截断覆盖。注意,这里不是追加而是对齐——Kafka会强制旧Leader向新Leader看齐,那么那些“领先”于新Leader的数据就会被删除。这些数据虽然在旧Leader上存在过,但对客户端来言已经不可见了。很多人所说的“Kafka丢数据”,有一部分就是这种Leader交替时的数据截断。
想对抗这种极端情况,只能靠跨机房部署副本、灾备集群或者备份。这个话题第五部分再展开。
4. 消费者端:优先级最高的“幽灵丢数据”
4.1 自动提交:20ms后,你想回滚都来不及
很多业务排查丢消息,最后发现消息其实一直在队列里躺着,是消费者根本没处理完。消费者端丢数据的头号嫌疑人是自动提交偏移量(enable.auto.commit=true)。
自动提交默认每隔5秒提交一次offset。这5秒内如果消费者处理完了一批数据,但还没来得及提交offset,然后进程崩了,再重启就会从旧offset继续消费,这部分消息被重复消费一遍。反过来,更危险的场景是:如果消息拉取之后、还没处理完成,自动提交周期到了,offset已经往后挪了,然后消费者进程崩溃或业务处理抛异常,重启后直接跳过了这批消息。这就是真实的“消费侧丢消息”。
这种丢法最憋屈,因为Broker那边的数据明明完好无损,是你的消费进度把数据“跳”过去了。
4.2 手动提交的两种姿势,都不完美
关闭自动提交之后,你面临手动提交的设计选择。先处理完业务再提交offset,这是标准推荐,但它带来的副作用是:消息处理完了、offset还没提交的时候,一旦消费者崩溃,重启后它会重新消费这批消息,造成重复。这是“至少一次”语义的正常表现,不能算“丢”。
反过来,如果你图快,先提交了offset再做业务处理,那业务处理一旦失败或崩溃,offset已经前移,消息再也不会被这个消费组消费到。这属于自己把自己坑了——消息没丢,但你的消费逻辑把它弄丢了。手动提交时还要注意提交的频率和粒度。每次poll一批数据就频繁提交,性能差;跨批次累积很久再提交,重复窗口变大。比较稳妥的做法是,每次poll后处理完本批,立即同步或异步提交本批最大offset,同时在业务侧记录处理进度,必要时做幂等。
4.3 Rebalance:消费者组里的一次“地震”
消费者组发生重平衡(Rebalance)时,分区所有权会重新分配。在旧方案里,一个消费者正在处理某个分区,重平衡来了,它被安排处理另一个分区,而新接管的消费者从已提交的offset开始消费。
如果之前处理的这批消息还没来得及提交offset,重平衡之后这些消息就会被重新分配给新的消费者重新消费,造成重复。这里不会直接导致丢失,但如果你在处理过程中依赖了外部状态,并且没有做好幂等,重复消费可能造成脏数据,最终对账的时候看起来就像丢消息或者错乱了一样。
更麻烦的是,如果消费者处理消息的耗时比max.poll.interval.ms长,消费者会被判定为卡住,被踢出消费者组,触发重平衡。那种“消费者被踢出去,又加回来,又踢出去”的反复抖动,最容易导致消息处理的空窗期。
4.4 消费侧预防丢数据的通用原则
消费侧的可靠性,本质上不靠Kafka参数,靠业务代码和数据设计。
接口要做到幂等,这是老生常谈但必须做。因为你无论怎么配置,至少一次语义意味着重复消费是常态而非偶然。数据库写入时用唯一键约束,外部调用前先查重,或者给每条消息生成唯一业务ID。其次,独立维护消费位点,不要完全信任Kafka的offset。你可以把已处理消息的最大offset写进外部存储,重启或重平衡后,先从外部存储恢复位点,尤其是那些极端依赖不丢消息的场景。
还有一点常被忽略:消费者处理的失败异常,一定要重试而不是直接忽略。结合死信队列,把实在处理不了的消息单独捞出来人工介入,比硬着头皮继续消费要可靠得多。
5. 如何用工程手段逼近“100%不丢”
5.1 生产环境推荐配置组合
如果把可靠性当作系统需求来认真设计,以下一套配置组合是很多有经验的团队在生产环境会采用的基线,覆盖了“生产者发得稳、Broker存得稳、消费者消费得稳”三个环节:
| 环节 | 参数 | 推荐值 |
|---|---|---|
| 生产者 | acks | all |
| 生产者 | enable.idempotence | true |
| 生产者 | retries | 尽量调大,建议不低于5 |
| 生产者 | max.in.flight.requests.per.connection | 5(需配合幂等) |
| Broker | default.replication.factor | 3 |
| Broker | min.insync.replicas | 2 |
| Broker | unclean.leader.election.enable | false |
| Broker | replica.lag.time.max.ms | 30000(按网络状况调整) |
| 消费者 | enable.auto.commit | false |
| 消费者 | max.poll.records | 按单批处理耗时控制 |
| 消费者 | max.poll.interval.ms | 适当放宽,避免误踢 |
需要留意的是,这套组合不是无脑照搬,要根据业务对性能的要求做取舍。min.insync.replicas=2意味着在ISR副本不足时写入会拒绝,瞬时可用性下降。如果业务无法接受写入被拒绝,就得牺牲一部分“确认安全度”。
5.2 你能为“不丢”付多少性能税
可靠性每提高一档,性能总会付出代价。
举个例子,acks=0的时候,单分区的写入吞吐可以拉满,但故障时几乎必丢。acks=1能扛住部分故障,性能和可靠性相对均衡。acks=all + min.insync.replicas=2时,Leader要等两个副本确认,写入延迟会明显上升,尤其是跨机架部署时,网络往返时间直接加进每次写入的耗时。
我自己做过一个简单的压测对比,同样的Topic、同样的数据量,从acks=1切到acks=all + min.insync=2,吞吐大约下降20%到30%,延迟增加明显,具体数字跟网络和副本部署位置有关。这个代价对绝大多数核心交易链路来说是可以接受的,但对那种每秒百万级流量的日志场景,可能就有点肉疼了。
建议大家上线前一定要做一个PIT压测,用真实的数据模型和部署拓扑,测一测这种可靠性配置下你的业务能不能顶得住峰值流量。我见过有团队上了全可靠配置,结果大促时生产者retries疯狂堆积,最后反而拖垮了链路。
5.3 监控指标清单:不要等丢完了才报警
配置再全,也保不齐遇到那些没法提前预判的故障。所以,可靠的运维体系比任何参数都重要。你需要盯几类核心指标。
ISR收缩和扩张,是副本健康状况的晴雨表。只要ISR里有副本被踢出,说明这个分区的冗余能力已经下降,此时恰好Leader宕机,就极可能丢数据。建议对ISR变化做告警,而不是等分区不可用了才看到。
生产者端要盯错误重试率和发送失败率。如果重试率持续上升,大概率是集群网络或者负载出了问题。消费端要盯堆积量(Lag),堆积突然归零不一定是好事,有可能是offset提交异常;堆积暴涨则说明消费能力跟不上或者消费者被踢出了。
建议把Kafka的JMX指标接入你的监控系统。重点看UnderReplicatedPartitions、OfflinePartitionsCount、ActiveOffsetManagerCount这些关键项。每个指标背后都值得单独写一篇排查文章,但对大多数团队来说,先把这几个监控做扎实,就已经能把90%的问题暴露在早期。
5.4 最后一道保险:备份和双写
即使你做到上面所有配置,依然存在最后一道暗门:物理灾难。整个机房的电断了,磁盘阵列烧了,云厂商把某个可用区撤了,这些属于小概率但必须考虑的事件。
应对思路就两条。一是跨可用区部署Kafka副本,把副本分散到不同的故障域,从物理上避免单点。二是搭建灾备集群,通过MirrorMaker之类的工具做异步跨集群同步。这里注意,异步同步意味着灾备集群的数据天然有一小段延迟窗口,极端情况下主集群丢的数据,在灾备集群里也可能不存在。所以灾备集群只能兜底“主集群整体不可用”的恢复场景,不能兜底“所有副本同时损坏”的数据安全。
更狠一点的做法,是业务侧双写两个独立集群,然后下游消费者做去重和合并。这种方案的成本和创新性都比较高,一般只有金融级系统才用得起。但是你可以想一想,如果一条消息真的要求100%不能丢,那么只靠Kafka本身是没有解的,必须靠业务侧的数据冗余来兜底。
写在最后的一点体会
从业这些年来,我最大的感受是:Kafka的可靠性问题,永远不是某个单一参数能解决的,它是生产者、Broker、消费者三个环节的联合工程。不要被“Kafka不丢消息”这种口号迷惑,你得清楚每一种配置选择对应的成本和风险窗口。
我给团队定的默认原则很简单:核心业务链路,无条件采用最高等级可靠性配置,哪怕付出一些性能和可用性代价;非核心的日志、埋点、监控类数据,可以放宽配置,换取吞吐和成本优势。遇到“消息到底丢没丢”的争议,先查消费端有没有做幂等,再查生产端是不是真正开了全部保护,最后再怀疑集群本身。
最后分享一个亲测有效的小技巧:每次上线前,故意做一次破坏性演练,直接杀掉一个Broker,观察整个链路的堆积和消费情况,再杀掉一个消费者,看看重复消费窗口有多大。这种演练比任何文档都更能暴露你系统的真实弱点。尽早把这些窗口找出来,别等到大促当天才被现实上一课。