去年有次线上排查让我印象很深。凌晨两点,做实时数仓的同事喊我,说 Kafka 消费延迟从几十秒一路涨到几十分钟,数据库里还多了一批重复订单。查到最后,问题根本不在 Kafka 自身,而是生产端把一个可重试异常当成了成功发送,消费端又没做幂等。Kafka 在大数据链路里几乎是默认的消息总线,但很多团队默认它天生就能保证不丢不重,其实分布式消息中间件从来不承诺这种“白给”的可靠性。
这篇内容聚焦 Kafka 消息可靠性的落地实践。我会按照一条消息从生产、存储到消费的完整路径,把真正会导致丢失或重复的节点逐个拆开,再结合大数据环境下的集群配置、监控和故障演练,讲清楚每个关键参数为什么这么设。适合正在维护 Kafka 集群的工程师、用 Kafka 做数据同步或实时计算的开发,以及所有不想在凌晨爬起来补数据的同学参考。
1. 先把“丢消息”拆到三个环节再谈保障
1.1 一条 Kafka 消息的一生,有三个最容易翻车的时间窗口
任何可靠性保障,前提都是先把“风险面”画清楚。一条 Kafka 消息从业务系统产生,到最终被消费方处理并写入目标存储,经过的位置其实很长。很多人排查问题时只盯着 broker 集群,但真实生产环境里,消息丢失高发在你可能完全想不到的地方。
第一个时间窗口是生产端发送之后、broker 写入并确认之前。客户端调用 send() 方法并不等于消息已经进入 Kafka,它只是把消息放进了本地的发送缓冲区。如果此时服务端无法写入,或者客户端发送模式太粗糙,消息就可能无声无息地消失。第二个窗口在 broker 端:Kafka 先写到操作系统页缓存,再异步刷盘;如果分区只有一份副本,磁盘故障或进程崩溃时,未刷盘的数据就会丢。即便有多副本,如果 Leader 选举策略设置不当,也可能出现“截断数据”的灾难。第三个窗口最容易被忽略——消费端已经拉取到消息,但还没处理完或还没提交位移,一旦发生重平衡或宕机,消息会再次被消费,或者在 at most once 语义下直接跳过。
把这三个窗口记在心里之后,你再去看任何一篇 Kafka 可靠性参数的文档,都会觉得清晰很多。生产端、Broker、消费端各负责一段可靠性,任何一端偷懒,整条链路的保障就是零。
1.2 大数据环境下的可靠性陷阱:默认配置是给“单机玩耍”用的
小规模测试环境里,副本数不够、ack 配置随意、消费端自动提交,这些坑通常不会立刻暴露。但在每天几亿甚至几十亿消息的大数据集群里,流量洪峰、broker 滚动重启、磁盘故障、消费者处理抖动都会被放大成可见事故。
你随便起一个本地 Kafka,用默认配置跑 Demo,消息基本不会感觉丢。可一旦进入多节点生产环境,以下几种情况会像定时炸弹一样埋伏着:自动创建 topic 时副本数只有 1,某台 broker 磁盘坏了,线上立刻出现分区不可用,历史消息无法消费;某个消费者处理一条记录需要查一次外部数据库,耗时一长触发 max.poll.interval.ms 超时,被踢出消费组后触发频繁重平衡,机器一多,全链路抖动成雪崩;数据量上来之后 broker 的请求处理线程被打满,ISR 不断收缩,如果这个阶段再碰上 Leader 宕机,选出来的新副本可能差着十万八千里。
这篇文章接下来要做的,就是把这三段分别配置到真正可靠的状态,并解释这些配置背后“为什么是它”。只有理解了参数之间的关系,你才能在集群规模翻倍时做出正确的取舍,而不是死抄别人博客里的配置。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 生产端可靠性:acks、重试与幂等是三个不能割裂的旋钮
2.1 acks 的三个档位不是“快慢问题”,而是“丢不丢”的问题
生产端最基础的可靠性控制,就是 Producer 的 acks 参数。它决定了消息发送后,什么样的服务端反馈对客户端来说才算“发送成功”。
先看最危险的 acks=0。这个模式下,生产端把消息扔进 socket 缓冲区就不管了,既不关心 broker 是否收到,也不关心是否写入成功。它的吞吐最高,但代价是任何网络闪断、broker 繁忙、分区无 Leader,都会让消息直接蒸发。很多把 Kafka 当“日志管道”用的采集程序为了追求极致吞吐会这么配,但我强烈建议,除非你能明确接受数据丢失,否则别碰。
acks=1 是很多业务系统默认或常用的级别。Leader 收到消息并写入自己的本地日志后,就向生产端返回成功。注意,这里还没等 Follower 副本确认。如果分区只有单副本,Leader 所在的 broker 一旦宕机,消息就随磁盘损坏或日志丢失而消失。即使有多副本,在 Leader 返回成功到 Follower 完成同步之间的窗口期,宕机同样可能造成数据缺口。
acks=all 表示 Leader 需要等待当前 ISR 集合中的所有副本都确认写入后才算成功。这才是真正意义上的“集群确认”。在关键交易、订单、对账等不允许丢消息的大数据链路里,acks=all 应该是唯一选择。付出的代价是单次请求的延迟更高,但相比补数据的代价,这点延迟完全可以接受。
2.2 acks=all 并不等于绝对安全:必须配合 min.insync.replicas
关于 acks=all,有个非常容易被误解的点:如果 ISR 中只剩下 Leader 自己,acks=all 也会退化成 acks=1 的效果。因为“等所有 ISR 副本确认”在 ISR 只有一个成员时,等到的其实只有 Leader 自己的确认。
打个比方:你要求快递必须当面签收,但整个仓库只剩一个仓库管理员了,他签了字,并不代表包裹有第二个人知道。为了堵住这个漏洞,需要设置 min.insync.replicas=2。这个参数的含义是:写入操作至少要确保 ISR 中有多少个副本处于同步状态,否则 broker 直接拒绝写入,并抛出 NotEnoughReplicasException 或类似的异常。
在实际的大数据集群里,一个 topic 如果副本数是 3,min.insync.replicas 设为 2,是最常见的可靠配置。它允许一台 broker 短时不可用而不影响写入,但一旦两台同时出问题,写入就会被拒绝。被拒绝看起来像“故障”,但对可靠性来说,这比假装成功然后悄悄丢数据要安全太多了。生产端的业务代码在捕获到这类异常后,应该走重试或死信队列,而不是把异常吞掉。
2.3 幂等 Producer 到底能管住什么:它不解决跨会话重复
开启幂等 Producer(enable.idempotence=true)后,Kafka 会给每个 Producer 实例分配一个 PID,并为发送到每个分区的每批消息生成单调递增的序列号。Broker 端会检查序列号,发现序列号不连续或重复时,会选择拒绝或去重。这能有效解决因为重试导致同一条消息被写入多次的问题。
但必须清楚幂等的边界。PID 在 Producer 重启后会变化,不同会话之间如果发生了重复发送,单靠幂等机制无法识别。所以生产端幂等只保证“单个 Producer 会话内、单个分区上”的消息不重序、不重复。要获得跨会话、跨分区的强一致语义,需要启用 Kafka 事务,业务端还要配合事务处理,复杂度会上一个台阶。
对大多数大数据同步场景,我建议的方式是把幂等开启,同时在消费端或目标存储层继续做幂等兜底。生产端的幂等可以减少一种不确定性,但它不是全链路唯一的防线。配置上,只要开启幂等,acks 会被强制调整为 all,max.in.flight.requests.per.connection 也允许在不乱序的前提下设到 5,这是官方设计好的组合,直接放心用。
2.4 大数据高吞吐场景下,生产端参数不能照抄小集群
大数据环境里,生产端会长期处于高压力状态。如果只把 acks 设为 all 而忽略批量发送、缓冲区和超时控制,可靠性反而会以另一种方式崩塌:积压消息占满发送缓冲区,send() 方法阻塞,业务线程卡死,最终服务重启,一堆消息永远没发出去。
我见过不少人把 batch.size 和 linger.ms 理解成“为了性能牺牲实时性”的参数,其实它们和可靠性是协作关系。适当增大 batch.size(比如 256KB 到 1MB 之间),可以让更多消息合并成一个请求,减少网络往返;linger.ms 设为 10ms 到 50ms,给生产者一个攒批的窗口。这样写不仅吞吐更高,broker 端的请求压力也更小,系统整体更稳。
下面是一份适合高可靠、综合吞吐场景的生产端参考配置,可以直接抄进初始化代码:
java复制Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka-1:9092,kafka-2:9092,kafka-3:9092");
props.put(ProducerConfig.ACKS_CONFIG, "all");
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
props.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE);
props.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 5);
props.put(ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG, 120000);
props.put(ProducerConfig.BATCH_SIZE_CONFIG, 524288);
props.put(ProducerConfig.LINGER_MS_CONFIG, 20);
props.put(ProducerConfig.BUFFER_MEMORY_CONFIG, 67108864);
props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "lz4");
这里需要特别解释 retries 和 delivery.timeout.ms 的关系。很多人只设 retries=10,觉得重试 10 次就够用了。实际上,如果一个请求在 30 秒内没有成功,超时器到点后会把这条消息判定为失败,哪怕你设置了再多重试也无济于事。所以大数据场景下我会把 retries 设到 Integer.MAX_VALUE,让重试次数不是瓶颈,同时通过 delivery.timeout.ms 控制整体等待上限。120 秒意味着一条消息在极端情况下可以重试一段时间,但不会无限阻塞业务。如果 120 秒内 broker 仍然不能写入,那就说明集群已经出了大问题,应该触发报警而不是继续等。
3. Broker 端存储与副本:真正决定数据“有没有活着”的地方
3.1 ISR 机制与分区副本:Leader 不是唯一的主角
Kafka 高性能的保障是读写分离:每个分区的 Leader 负责所有读写请求,Follower 只负责从 Leader 拉取数据并保持同步。这种设计把并发压力分摊到了多个 broker 上,但也带来了一个关键问题——如果 Leader 宕机了,谁能顶上?
Kafka 的答案是 ISR(In-Sync Replicas,同步中的副本集合)。ISR 里保存的是当前与 Leader 保持“足够同步”的副本。所谓足够同步,不是数据字节完全相同,而是 Follower 在 replica.lag.time.max.ms 配置的时间窗口内跟上了 Leader 的写入进度,没有落后太多。如果 Follower 拉取缓慢、网络拥塞或 broker 负载过高,它会被踢出 ISR,等重新追上了再被加回来。
从可靠性角度看,ISR 集合越大,分区越安全。我在生产环境用 kafka-topics.sh 查看 topic 详情时,输出里有一列 ISR:
code复制Topic: orders Partition: 0 Leader: 2 Replicas: 2,0,1 Isr: 2,0
这段输出里,Partition 0 的 Leader 在 broker 2 上,副本分布在 broker 2、0、1 上,ISR 也正好是这三台。如果 broker 2 宕机,Controller 会从 0、1 中选一个补上。但如果 ISR 里只剩 2,情况就很危险:Leader 活着时数据还能写,可一旦它宕机,整个分区就不可用了。
3.2 unclean.leader.election.enable 为什么必须设为 false
这是整个 Kafka 可靠性体系里我最看重的一个开关。它控制的是:当 Leader 宕机且 ISR 中没有可用副本时,Kafka 是否允许从那些落后很多的“非同步副本”里选一个新 Leader。
如果设置为 true,Kafka 会为了可用性选择丢失数据的副本,并把它变成新的 Leader。这个副本之前没同步到的消息会被直接截断。在业务方毫无感知的情况下,一批已经写入成功并已经向生产端返回 ack 的消息,可能在几秒后就被“合法地”删除了。对大数据链路来说,这种丢失比 broker 宕机更致命,因为它不报错、不报警,直到下游对账时才发现数据缺了一块。
因此,如果可靠性优先,unclean.leader.election.enable 必须显式设为 false。这样做的代价是:如果 ISR 中真的没有任何副本存活,分区会进入 Offline 状态,写入和读取都会被拒绝。这会造成一段时间的服务不可用,但至少消息还在,等原 Leader 恢复或人工介入后,数据不会丢。可靠性业务宁可停机等恢复,也不能不明不白截断数据。
3.3 刷盘与持久化:Kafka 的页缓存设计不背丢数据的锅
Kafka 的高吞吐源自顺序写入和页缓存设计。消息到达 broker 后,先写入操作系统的页缓存,由内核在合适时机异步刷到磁盘。这个过程让 Kafka 的写入速度接近内存速度,但也让很多刚接触的人担心:如果机器断电,页缓存里的数据会不会全部丢失?
理论上会。但要注意,Kafka 默认的可靠性设计并不依赖单机刷盘,而是依赖副本。一条消息只要被写入多个 broker 的页缓存,并让生产端收到 ack=all 的确认,即使某个节点断电崩溃,其它副本仍然有数据。Kafka 的持久化设计思路是:用多副本替代对单机刷盘的强依赖。官方文档也提示,不要把 log.flush.interval.messages 调得很激进来追求可靠性,这样会造成巨大的性能回退,而且在多副本环境中收益很低。
我把 log.flush.interval.messages 和 log.flush.interval.ms 称为“最后一道兜底”。日常维护时,更需要关注的是磁盘健康、broker 日志中是否有“unable to write to log”之类的写入错误,以及分区副本数的实际配置。如果某个集群因为历史原因跑着单副本 topic,那不是调刷盘参数能救回来的,赶紧补副本才是正事。
3.4 副本数和数据分布:用动态调整挽回历史欠账
遇到存量 topic 副本数不足时,可以借助 Kafka 自带的工具进行在线重分配。这里给一个完整的操作思路:
首先确认要调整的 topic 和现有分区分布:
bash复制kafka-topics.sh --bootstrap-server kafka-1:9092,kafka-2:9092,kafka-3:9092 \
--describe --topic orders
然后生成一份候选的重分配方案,重点查看生成的 JSON 文件里 Replicas 列表是否覆盖了不同 broker:
bash复制kafka-reassign-partitions.sh --bootstrap-server kafka-1:9092,kafka-2:9092,kafka-3:9092 \
--generate --topics-to-move-json-file topics-to-move.json \
--broker-list 0,1,2
执行并定期检查执行状态:
bash复制kafka-reassign-partitions.sh --bootstrap-server kafka-1:9092,kafka-2:9092,kafka-3:9092 \
--reassignment-json-file reassignment.json --execute
kafka-reassign-partitions.sh --bootstrap-server kafka-1:9092,kafka-2:9092,kafka-3:9092 \
--reassignment-json-file reassignment.json --verify
重分配过程中会产生额外的副本复制流量。在大数据高负载集群上执行时,建议把 throttle 参团加上,比如在 --execute 命令后面追加 --throttle 50000000,把限速控制在每秒 50MB 左右,避免对正常业务造成冲击。
4. 消费端不重不丢:位移提交、再均衡与幂等消费
4.1 “已处理但没提交”和“已提交但没处理”,选择哪个
消费端可靠性不能只盯着“不丢”,还要看“不重”。因为 Kafka 的消费语义从底层上只有两种:
at most once 是“先提交位移,再处理业务”。如果消息处理中途挂了,offset 已经前移,这条消息就会永久跳过。对订单、支付、权益发放这类业务来说,这就是直接丢数据。
at least once 是“先处理业务,再提交位移”。处理成功后如果在提交前宕机或触发重平衡,这条消息会被重新消费一次,造成重复。这是大多数大数据链路的默认选择,因为重复可以通过下游幂等来消化。
Kafka 并不存在一个参数能让集群做到通用的“精确一次消费”。所有所谓 exactly-once 的实现,本质都是 at least once 加幂等,或者在 Kafka Streams/Flink 这类框架里,通过事务和分布式快照在特定语义下达成端到端精确一次。所以消费端的第一步是想清楚:你的业务能接受重复,还是能接受丢失?大多数业务应该选择“可能重复,然后做幂等”,而不是承担丢数据的风险。
4.2 自动提交和手动提交的正确用法
如果关键业务还在用 enable.auto.commit=true,我会建议尽快改掉。自动提交默认每 5 秒提交一次,它不关心你上一批消息到底处理成功没有。一旦处理耗时超过提交间隔,或重平衡发生在上次提交之后、本次处理完成之前,都会出现位移和数据处理结果不一致的情况。
手动提交也有讲究。最稳妥的做法是关闭自动提交,在处理完当前批次消息后主动提交。这里有一个异步加同步的经典组合:正常处理流程中用 commitAsync 异步提交,避免阻塞消费主线程;在优雅关闭前或重平衡监听器里,用 commitSync 做最后一次同步提交,确保没有遗留未提交位移。
从可靠性角度,我给出的最基础消费循环长这样:
java复制while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
for (ConsumerRecord<String, String> record : records) {
process(record); // 业务处理成功后再继续下一条
}
consumer.commitAsync(); // 失败时打印日志,下一轮再试
}
注意,process 方法内部如果有异常,不要让异常直接吞掉后继续向后提交。抛出异常会导致当前 poll 的批次无法整体提交,重平衡时这批消息就会重新被消费。处理这些失败的记录时,建议把它们写入可靠的死信 topic 并记录原因,而不是假装成功。
4.3 再均衡是消费可靠性的头号暗坑
消费组的再均衡(Rebalance)是 Kafaka 保障消费组灵活性的核心机制,但它也是消费端丢消息和重复处理的高发场景。触发原因五花八门:消费者心跳超时、处理耗时超过 max.poll.interval.ms、订阅的 topic 新增分区、消费者实例加入或退出。
大数据环境里最常见的情况是:某个消费者处理一条消息时需要调用外部服务或查询大表,耗时从几百毫秒飙升到几十秒,一旦超过 max.poll.interval.ms 默认的 300 秒,broker 就会判定该消费者“还活着但处理不动”,把它踢出消费组,触发新一轮分区分配。这一轮重平衡里,原本由该消费者负责的分区会被分配给其他消费者,而它刚刚处理完但还没来得及提交位移的消息,就会被其他消费者再处理一次。
解决重平衡问题的思路不是单一参数能搞定的。首先,调整 max.poll.records 和 max.poll.interval.ms 只能缓解症状,核心还是要提升单条消息的处理能力,或者把耗时的外部调用异步化。其次,可以通过配置 group.instance.id 启用静态消费成员,让消费者短暂重启时不会被立刻移出消费组。第三,使用 CooperativeStickyAssignor 这类合作式分配策略,减少重平衡时分区的大范围变动。
4.4 消费端的幂等设计是最后一张安全网
既然 at least once 是大多数链路的选择,消费端就应该默认“同一条消息可能来两次”。这个意识要刻进骨子里。
最简单的幂等方案是给每条消息生成一个全局唯一的业务键。比如订单同步消息可以用 order_id 作为唯一键,消费时在目标表里建立唯一索引,插入时发现重复就跳过。另一种常见方案是维护一张消费记录表:先查本地表判断消息 ID 是否已处理,没处理过才执行业务逻辑并写入记录,两步操作放到同一个本地事务里。
在大数据实时链路中,如果使用 Flink 消费 Kafka 再写入下游,可以利用 Flink 的 checkpoint 机制结合 Kafka 事务实现端到端精确一次。但这要求下游必须支持事务性写入或幂等写入,比如 Kafka sink 本身的事务,或者 Doris、ClickHouse 等支持幂等导入的存储。空谈“Flink 精确一次”没有意义,要看整条数据链路具备不具备这样的能力。
5. 大数据集群里的可靠性监控与故障演练
5.1 需要长期盯死的六个关键指标
配置做得再完善,没有监控和告警,一切可靠性设计都是空中楼阁。大数据集群规模一大,单靠人工看日志根本不现实。我维护集群时,至少会盯住下面这六类指标。
第一个是 UnderReplicatedPartitions。它表示有多少分区当前没有达到目标副本数,或者有 Follower 在同步进度上严重落后。这个指标偶尔短暂大于 0 可能是正常的副本追赶,但持续大于 0 就必须排查。第二个是 OfflinePartitions,分区 Leader 全部离线。这个数字一旦大于 0,就会立刻影响生产消费,应该作为最高优先级告警。第三个是 ActiveControllerCount,正常只能等于 1。如果这个值在多个 broker 间跳变,说明 Controller 在频繁切换,往往伴随着网络分区或 GC 异常。
第四个是 RequestHandlerAvgIdlePercent,它反映 broker 请求处理线程的繁忙程度。如果这个值低于某个阈值且持续不恢复,broker 已经处于过载状态,复制和心跳都会受到影响。第五个是 IsrShrinksPerSec 和 IsrExpandsPerSec,ISR 的频繁收缩和扩张,说明 follower 在不停地被踢出和追回,通常能从侧面暴露磁盘延迟或网络问题。第六个是消费组 Lag,可以直接通过 kafka-consumer-groups.sh 去查,它代表了生产速度和消费速度之间的差值,Lag 突增时说明消费链路出了问题。
这些指标不是看一遍就行,建议全部接入 Prometheus 和 Alertmanager,配上合理的阈值:
| 指标 | 参考告警条件 | 含义 |
|---|---|---|
| UnderReplicatedPartitions | 持续大于 0 超过 5 分钟 | 副本同步异常 |
| OfflinePartitions | 大于 0 立即告警 | 分区 Leader 不可用 |
| ActiveControllerCount | 不等于 1 | Controller 异常切换 |
| RequestHandlerAvgIdlePercent | 低于 30% 持续 3 分钟 | broker 请求处理过载 |
| IsrShrinksPerSec | 突增超过日常基线 | 副本频繁掉队 |
| Consumer Group Lag | 超过业务容忍阈值 | 消费积压或消费端故障 |
5.2 故障演练:拔掉一个 Broker,看看配置到底靠不靠谱
配置完成之后,想验证它真的可靠,最快的方式是主动做故障演练。我习惯在低峰期或者灰度环境里,用测试 topic 模拟 broker 宕机,观察生产端、消费端和监控系统的真实反应。
演练前,先准备一个 3 副本、min.insync.replicas=2 的测试 topic,并持续灌入消息。压测命令可以这样写:
bash复制kafka-producer-perf-test.sh \
--topic reliability-test \
--num-records 2000000 \
--record-size 1024 \
--throughput 50000 \
--producer-props bootstrap.servers=kafka-1:9092,kafka-2:9092,kafka-3:9092 \
acks=all enable.idempotence=true linger.ms=20 batch.size=524288
压测过程中,直接停掉一台 broker 的进程。这时你观察到的现象应该是:Controller 检测到 Leader 不可用后,从剩余 ISR 副本中选出新 Leader,客户端在短暂重试后恢复写入。如果 topic 里恰好有分区只存在单副本,写入会直接失败,这正好暴露了历史欠账。
演练后,执行 kafka-topics.sh --describe --topic reliability-test,检查 ISR 列是否重新追满。如果 Leader 没有回到原先的 preferred replica,可以用工具触发一次 preferred leader election,让分区分布回归均衡。经过这么几轮测试,你对集群可靠性的信心,会比看一百篇文档都有用。
5.3 给不同业务可靠性级别的一份配置模板
也不是所有 topic 都需要最高的可靠性配置。大数据集群里通常同时跑着交易数据、用户行为日志、监控指标等多种类型。一视同仁地全配最高可靠性,会白白浪费机器和性能;全配最低可靠性,又会在关键链路上埋雷。我的建议是分成三档。
| 业务类型 | 副本因子 | min.insync.replicas | acks | 消费语义 | 典型例子 |
|---|---|---|---|---|---|
| 核心交易/对账 | 3 | 2 | all | at least once + 幂等 | 订单、支付、账务 |
| 一般业务数据 | 3 | 2 | all | at least once + 尽量幂等 | 用户行为、业务状态 |
| 日志/监控指标 | 2 | 1 | 1 | at most once 可接受 | 系统日志、Metrics |
如果你想把集群的 broker 默认配置统一抬高,可以在 server.properties 里显式设置一些关键项:
code复制
