1. 先说结论:Kafka的“不丢失”是有前提的
Kafka声称能保证消息不丢失,但这个承诺不是无条件的。很多初学者上来就以为“用了Kafka就万事大吉”,结果线上丢数据之后一脸懵。实际上,Kafka的消息可靠性是一个“端到端”问题,从生产者发送消息的那一刻起,经过Broker存储,一直到消费者处理完业务逻辑,每一环都有对应的保障机制,任何一环配置不当,都可能导致消息丢失。
简单来说,Kafka通过三个层面的协作来实现消息不丢失:
- 生产者端:消息发出后必须得到确认,失败就重试,配合幂等性防止重复写入
- Broker端:多副本冗余存储,只有多个副本都写入成功才对外确认,故障时自动切换到新Leader
- 消费者端:先处理业务逻辑再提交消费偏移量,防止“处理完但没提交导致重启后重复消费”或者“提交了但没处理完导致丢消息”
这三者的关系可以用一个场景来理解:你寄一个贵重包裹,自己会盯着快递单号直到显示“已签收”(生产者确认),快递公司会把包裹在多个中转站各存一份复印件(多副本),收件人必须亲自拆开包裹确认东西没坏后才在签收单上签字(消费者手动提交)。
这篇文章会按照“发送端 → 存储端 → 消费端”的顺序,把每条保障机制的原理和关键参数都拆开讲透,最后还会给出我自己实际生产环境中验证过的配置组合,以及排查丢消息问题的思路。适合正在使用Kafka、准备上Kafka,或者面试前想系统梳理这块知识的朋友参考。
2. 生产端:消息从源头就不能丢
2.1 ACK机制:Broker到底什么时候算“收到”了?
生产者发送消息时,有一个核心参数叫acks,它决定了一条消息要等多少个副本确认后才算发送成功。这个参数有三个取值,每个取值都是一套完全不同的可靠性策略。
acks=0意味着生产者发出去就不管了,不等待任何确认。这时候吞吐量最高,但消息发出去到底有没有到Broker完全不知道,网络抖动、Broker宕机都会导致消息静默丢失。这个配置只适合日志采集等对丢数据完全无感的场景。
acks=1表示Leader副本写入成功就返回确认。这是很多人生产环境默认用的配置,但它有一个隐患:如果Leader在返回确认之后、还没来得及把消息同步给Follower之前宕机了,这条消息就丢了。为什么?因为新的Leader是从ISR里选出来的,旧Leader上那条消息还没同步给任何Follower,新Leader上自然就没有。
acks=all(或者acks=-1,两者等价)要求所有ISR中的副本都写入成功后才返回确认。这才是真正意义上的“不丢”。这里要注意,ISR(In-Sync Replica)是“在同步中的副本”集合,指的是那些跟Leader保持同步的副本。只有ISR里的副本都确认了,消息才算真正持久化成功。
我刚接触Kafka那会儿觉得acks=all意味着所有副本都要响应,后来才意识到它其实是“所有ISR副本”响应即可,ISR之外的副本不参与确认。这个区别很重要,因为ISR是一个动态集合,副本同步滞后超过阈值会被踢出ISR,之后就不再参与ack确认了。
2.2 重试机制:网络抖动了怎么办?
即使设置了acks=all,消息仍然可能发送失败,比如网络超时、Broker返回NotLeaderForPartitionException、或者分区Leader正在选举中。这时候如果没有重试机制,生产者会直接把异常抛给业务代码,消息就丢了。
所以生产者的retries参数必须大于0,推荐设置为一个较大的值(比如Integer.MAX_VALUE),同时配合delivery.timeout.ms来控制总的重试时限。注意retries只是重试次数,真正决定“最多重试多久”的是delivery.timeout.ms,默认是120秒,也就是说从发送消息到最终放弃,最长等待120秒。
这里有一个很多文章没讲透的点:重试可能导致消息乱序。如果一个批次的消息发送失败后重试,而这个分区后续批次的消息已经发送成功了,那么重试的消息就会排在后面,造成顺序错乱。解决方法是开启enable.idempotence=true,幂等生产者内部会自动处理序列号,保证重试时消息按原始顺序追加。实际上,在Kafka 0.11版本之后,只要设置了幂等,生产者会自动把acks调整为all,并处理重试相关的顺序问题。
2.3 幂等性与事务:不只是“不丢”,还要“不重不漏”
幂等性解决的是另一个维度的可靠性问题。什么是幂等?就是同一个操作执行多少次,结果都是一样的。Kafka的幂等生产者给每条消息分配一个序列号,Broker端会检查序列号是否连续,如果发现重复的序列号就直接拒绝写入,这样就避免了因为客户端重试而导致的重复消息。
开启幂等很简单,只要设置enable.idempotence=true即可。但它有一个限制:只能保证单分区内的幂等。如果你需要跨分区的事务性写入(比如同一个业务逻辑产生的多条消息要写入多个分区,要么全部成功要么全部失败),那就需要用到事务API——initTransactions、beginTransaction、commitTransaction这套方法。
事务的实际使用中有一个容易踩的坑:事务超时时间transaction.timeout.ms默认是60秒,如果事务执行时间超过这个值,Coordinator会主动中止事务。对于处理链路较长的场景,一定要根据实际情况调大这个参数。
3. Broker端:数据落到磁盘才算真正“有”
3.1 副本机制:一份数据多个备份
光有生产端的确认还不够,Kafka的Broker本身也可能宕机、磁盘可能损坏。所以Kafka设计了分区的多副本机制:每个分区有多个副本,其中一个是Leader(负责读写),其余是Follower(负责同步)。生产者只跟Leader通信,Follower从Leader拉取消息进行同步。
这里要理清一个容易混淆的概念:Follower副本不参与消费和写入,它们存在的唯一意义就是备份和故障转移。Kafka的读写路径是“一主多从”,跟很多数据库的主从架构类似,但Follower不会分担读压力。
副本数量由replication.factor决定,生产环境建议设置为3。设置为1意味着只有一份数据,Broker宕机或磁盘损坏就直接丢数据,完全没有任何保障。设置为2虽然多一份备份,但两个副本可能同时挂掉(比如机器同时断电),也没那么保险。3个副本是工业界公认的安全基线。
3.2 ISR机制与min.insync.replicas:确认的副本数不能靠运气
副本列表里的所有副本并不是一直都能跟上Leader的同步速度。某台机器发生长时间GC、磁盘性能波动、网络延迟升高,都可能导致Follower的同步进度落后。Kafka通过replica.lag.time.max.ms(默认30秒)来判断一个副本是否“跟得上”Leader,如果超过30秒没有追上最新的消息位移,这个副本就会被移出ISR。
为什么强调必须有acks=all和min.insync.replicas配合?因为如果min.insync.replicas=1,那么ISR里只要有一个副本(Leader自己)写入成功就算成功,本质上跟acks=1没什么区别。正确做法是:
replication.factor=3min.insync.replicas=2acks=all
这样即使一个副本挂掉,ISR里还剩2个副本,消息提交需要至少2个副本都写入成功,可靠性才有保障。如果ISR里的副本数低于min.insync.replicas,Broker会直接拒绝写入请求,返回NotEnoughReplicasException,宁可不可用,也不丢数据。
这里有个人人都会忽略的点:min.insync.replicas=2不代表消息只需要2个副本确认。在正常状态下ISR有3个副本,acks=all要求3个都确认。只有当某个副本被踢出ISR后,才退化为2个确认。所以min.insync.replicas是“下限”,不是“目标”。
3.3 刷盘策略:数据在内存不算数,落盘才算数
Broker收到消息后,数据先写入操作系统的Page Cache,然后由操作系统异步刷入磁盘。这个设计让Kafka具备了极高的写入吞吐,但它也让一些人担心:Page Cache还没刷盘时机器断电,数据不就丢了吗?
确实有这种可能。Kafka提供了两个刷盘参数:log.flush.interval.messages(累积多少条消息强制刷盘)和log.flush.interval.ms(隔多少毫秒强制刷盘)。但实际生产中,大多数团队不调整这两个参数,原因是Kafka对单个Broker有多副本保障,即使一个Broker断电丢了一些Page Cache里的数据,其他副本上还有完整数据,可以恢复。
真正需要刷盘策略的场景是:replication.factor=1的单副本部署,或者在极端追求数据安全且能接受性能损失的情况下。我自己的经验是,与其调刷盘参数,不如保证副本数和min.insync.replicas的配置合理,这才是性价比最高的可靠性方案。
3.4 选举机制:leader挂了,新leader不能选个“落后的”
分区Leader宕机后,Kafka会从ISR中选举一个新的Leader。这里有一个非常重要的参数:unclean.leader.election.enable。
默认情况下这个参数是false,意味着只有ISR中的副本才有资格被选举为Leader。ISR保证了候选副本的消息完整度跟原Leader至少一致(或者非常接近),这样切Leader后不会丢消息。
但如果把这个参数设置为true,那么当ISR中所有副本都挂了(极端情况),Kafka会允许一个不在ISR中的、消息落后的副本成为Leader。这样做的代价是:旧Leader上那些还没有被这个落后副本同步的消息,会永久丢失。这个配置是为了“保可用”而牺牲“不丢消息”,所以生产环境必须保持unclean.leader.election.enable=false。
之前我参与过的一个系统,某个Topic的数据量不大,业务方为了省磁盘把副本数调成了1,结果一台物理机宕机后整个分区不可用,恢复后数据还是有部分丢失。后来复盘时发现,即便副本数是3,如果unclean.leader.election.enable=true,同样会在极端情况下丢数据。所以这两个参数都要盯住。
4. 消费端:处理成功才算“消费成功”
4.1 消费位移:Kafka怎么知道你有没有处理完?
Kafka的消费者通过提交offset(消费位移)来记录自己消费到了哪条消息。offset的提交时机,直接决定了消息“不丢失”还是“不重复”。
消费者提交offset有两种方式:
- 自动提交:
enable.auto.commit=true,默认每5秒提交一次当前消费到的offset - 手动提交:
enable.auto.commit=false,由开发者在业务代码里显式调用commitSync()或commitAsync()
自动提交有一个致命问题:提交时机跟消息处理时机是分离的。假设消费者拉取了一批消息,正在处理第100条时,自动提交器把当前offset(假设已经轮到第200条)提交了,此时进程崩溃。重启后消费者从第200条继续消费,那第100条到第199条虽然被“标记为已消费”,但业务上根本没处理完,数据就丢了。
所以生产环境必须设置enable.auto.commit=false,并且在消息处理完成之后手动提交offset。推荐的模式是:
- 拉取消息
- 处理业务逻辑(写数据库、调用接口等)
- 处理成功后,再提交这条消息对应的offset
这里有一个顺序问题很多人会搞反:先提交offset再处理业务,会导致崩溃时丢消息;先处理业务再提交offset,会导致崩溃时重复消费。Kafka的选择是“先处理后提交”,因为重复消费可以通过下游的幂等性来兜底,而丢消息往往没法补救。
4.2 手动提交的正确姿势:同步还是异步?
手动提交有两个API,选错也可能出问题。
commitSync()会阻塞等待Broker确认提交成功,保证本次提交一定成功后才继续。它的缺点是吞吐量受损,因为每条消息或每批消息都要等一次网络往返。commitAsync()不会等待确认,所以吞吐更高,但如果你在回调里忽略异常,提交失败的消息offset不会重试,可能导致重复消费。
业内比较稳妥的做法是:定期用commitAsync()提交,然后在消费者关闭或Rebalance之前,用commitSync()做一次最终的同步提交。这样兼顾了吞吐和可靠性。
另外,如果你用的是assign方式手动指定分区消费(没有加入Consumer Group),那么Rebalance机制不生效,提交offset的方式也要用commitSync保证可靠性。我之前在写一个数据同步工具时用过assign模式,忘记手动提交offset,每次重启都从头消费,把下游数据库写了一大堆重复数据,排查半天才反应过来。
4.3 消费端幂等:从“至少一次”到“恰好一次”
Kafka的默认语义是“至少一次”(At-Least-Once),也就是说在“先处理后提交”的模式下,消息不会丢,但可能重复。很多业务场景下,重复消费是不可接受的,比如重复扣款、重复下单。
解决方案是在消费端做幂等。常见做法有:
- 数据库唯一键:用消息里的业务主键作为数据库表的唯一索引,重复插入时直接冲突跳过
- Redis SETNX:处理前先尝试写入一个去重标记,写入成功才继续处理
- 状态机判断:根据业务状态判断这条消息是否已经处理过
本质上,Kafka在0.11版本之后也提供了事务API,可以实现端到端的“恰好一次”(Exactly-Once)语义,但使用条件苛刻——下游必须支持事务(典型的是把处理结果和offset一起写到同一个事务里),配置和使用复杂度都不低。对于绝大多数场景,我更推荐“至少一次 + 消费端幂等”的组合,简单、可维护、应用范围广。
5. 一套我自己验证过的生产配置清单
上面讲了很多零散的参数,这里整理一份我在生产环境实际使用过的配置组合,可以直接抄作业。当然,配置要结合你的实际业务调整,但可靠性相关的基础配置可以照搬。
5.1 Producer端核心配置
acks=all enable.idempotence=true retries=Integer.MAX_VALUE delivery.timeout.ms=120000 max.in.flight.requests.per.connection=5 linger.ms=5 batch.size=16384说明:acks=all保证所有ISR副本确认;enable.idempotence=true启动幂等并自动处理重试乱序问题;max.in.flight.requests.per.connection=5在幂等开启时可以在保证有序的前提下提高吞吐。如果你需要极致的顺序保证(比如同一个Key的消息必须严格有序),可以把它调整为1,但吞吐会下降。
5.2 Broker端核心配置
replication.factor=3 min.insync.replicas=2 unclean.leader.election.enable=false log.dirs=/data/kafka-logs这里要特别注意:min.insync.replicas=2必须配合生产者的acks=all才有效。如果生产者强制acks=1,Broker端设置再多的min.insync.replicas也没用,因为生产者根本不等待那么多个确认。两者的关系可以用一个公式概括:
可靠性的上限 = min(生产者等待的确认数, Broker要求的确认数)
换句话说,有一方偷懒,整体的可靠性就打折扣。
另外,创建Topic时建议显式指定副本数,不要依赖Broker默认值。你可以用命令:
kafka-topics.sh --create --topic your-topic \ --partitions 12 \ --replication-factor 3 \ --config min.insync.replicas=2 \ --bootstrap-server broker1:9092partitions的数量要根据业务流量来估算,一般建议分区数不小于消费者线程数,否则多余消费者会闲置。但也不要盲目设多,分区越多,每个分区的Leader选举和元数据管理开销就越大。
5.3 Consumer端核心配置
Properties props = new Properties(); props.put("enable.auto.commit", "false"); props.put("auto.offset.reset", "earliest"); props.put("max.poll.records", "500"); props.put("session.timeout.ms", "10000");关键点解析:
enable.auto.commit=false:手动提交offset,这是消费端不丢消息的前提auto.offset.reset=earliest:当没有offset或offset失效时,从最早的消息开始消费。如果你希望新消费者只读最新消息,可以设置为latest,但要清楚这会导致历史消息不消费max.poll.records=500:限制单次poll返回的消息条数,避免处理时间过长触发Rebalancesession.timeout.ms=10000:消费者心跳超时时间。如果处理一批消息超过这个时间还没发心跳,会被判定为死亡触发Rebalance
消费的核心代码模式如下:
while (true) { ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000)); for (ConsumerRecord<String, String> record : records) { // 1. 处理业务逻辑 process(record.value()); // 2. 处理成功后提交offset consumer.commitSync(); } }注意这里commitSync()放在每条消息处理之后,如果一批消息有500条,那就提交500次,性能很差。实际项目中我会改成批量处理完后提交一次,但前提是批处理逻辑本身具备“失败可重试”或“幂等”的特性。更细的做法是使用commitSync(Map<TopicPartition, OffsetAndMetadata>)提交具体的分区位移,这样更精细,但代码复杂度也更高。
6. 消息真的丢了?按这条路逐步排查
即使你上面的配置都设好了,线上仍然可能出现“消息似乎丢了”的现象。根据我的经验,很多时候并不是Kafka丢数据,而是业务代码对“丢”的定义跟Kafka对“丢失”的语义不一致。这里整理几类我实际遇到和排查过的问题。
6.1 现象一:生产端报错后,消息没有写入
如果Producer发送时抛出异常且重试耗尽,消息确实没有进入Kafka。这种情况不是Kafka“丢”数据,是生产端根本没有成功投递。排查重点:
- 检查
delivery.timeout.ms是否太短,导致网络抖动时重试还没完成就放弃了 - 检查Broker是否返回超时(
TimeoutException),可能是分区Leader发生了长时间不可用 - 查看Producer日志里有没有
NotEnoughReplicasException,说明ISR副本数低于min.insync.replicas,这时Broker拒绝写入,不是消息丢了,而是系统为了不丢而选择了不可用
6.2 现象二:消费端没有报错,但下游没数据
这种情况最常见。消费者已经提交了offset,但消息还没真正处理完。可能的场景:
- 消费者处理完部分数据后,对每条消息都先提交offset再调用下游接口,接口超时后消息丢了
- 消费逻辑里用了多线程异步处理,主线程提交了offset,但子线程还在处理数据,主线程崩溃后子线程的数据丢了
- 下游接口是最终一致性的(比如异步更新缓存),消费者以为处理完了,其实缓存还没生效
这种问题的本质是“offset提交时机”跟“业务完成时机”不一致,只能通过代码层面修正:确保offset提交发生在业务真正完成之后,并且每个消息的处理结果有明确的成功/失败标记。
6.3 现象三:消费没丢,但“重复”了怎么办
再强调一遍:Kafka只能保证“至少一次”,不保证“不重复”。如果你发现重复消费,首先要确认这是不是你的代码问题——比如手动提交失败后重试导致的重复。如果重复率不高,可以通过幂等解决;如果重复率很高,就要检查是不是开启了自动提交又设置了很短的auto.commit.interval.ms,或者消费者Rebalance太频繁。
我碰到过一个诡异案例:消费者在处理每条消息时都调用consumer.commitAsync(),但因为回调里没有检查异常,实际上部分offset并没有提交成功,导致重启后大量重复消费。后来改成“定期异步提交 + 关闭前同步提交”的模式,重复率才降下来。
6.4 排查工具与方法
推荐几个我常用的排查入口:
kafka-consumer-groups.sh --describe --group your-group:查看消费者组的消费进度(LAG)。如果LAG持续增长,说明消费能力不足或消费者挂掉了kafka-log-dirs.sh --describe:查看副本的磁盘分布和存储状态,判断是否有副本落后或掉线- Brokers日志里搜索
ISR shrink、ISR expand,关注ISR变动情况 - 用
kafka-run-class.sh手动消费指定分区的消息,核对数据是否真的存在
如果发现某个分区的ISR只剩一个副本且这一段时间没有恢复,说明这台Broker可能有性能问题或网络问题,需要重点排查机器的负载和磁盘状态。
7. 一些参数之外的思考
配置和代码都搞对了,Kafka的消息可靠性问题就解决了吗?根据我多年用Kafka的体会,还有两个容易被忽略的方面。
第一,跨集群复制时的数据一致性。如果你使用MirrorMaker、Replicator之类的跨集群同步方案,同步本身就是一种“消费+生产”的过程,源集群的数据到了目标集群之后,目标集群的可靠性依然受它自己的副本和配置影响。跨集群同步不是Kafka自身保证消息不丢失的范畴,但如果你有灾备需求,这部分同样要按生产端的标准来配置。
第二,监控与告警比配置本身更重要。Kafka的可靠性是一个“动态”问题——配置再好,运行中也可能出现磁盘故障、网络隔离、代码bug。所以我一直建议团队至少监控这几项指标:
- UnderReplicatedPartitions:存在副本落后的分区数,持续大于0要马上查原因
- OfflinePartitionsCount:离线分区数,大于0说明有分区不可用
- RequestsQueueTimeMs:请求在队列里等待的时间,过长说明Broker繁忙
- ConsumerLag:消费滞后量,突然飙升说明消费者异常
没有监控的Kafka集群,就像没有仪表的飞机,飞得再高心里也没底。
最后说一个我自己的经验:很多团队在项目初期为了快速上线,把acks设成了1,副本数用了默认的1,然后在上线后的某次机器故障中才发现数据丢了。这种坑几乎没有人能完全避开。Kafka的可靠性不是一两个参数的事,而是生产端、Broker端、消费端三个环节的协同结果。建议在系统设计评审时,就把这三端的可靠性配置当成硬性要求,而不是上线后的补救项。我把这套配置清单放在一个项目里持续更新,每遇到一次线上问题就补充一条注释,现在再看,这些注释比参数本身更有价值。