第 20 章 Kafka 与消息队列:异步、解耦与削峰(从 Producer 到 Consumer)
你接手的下单服务里,有一行
kafkaTemplate.send("cache-invalidate", "user:" + id)——那是第 19 章讲的”写 DB 后异步删缓存”的兜底。某天大促,订单暴涨三倍,你的接口 RT 没怎么涨,可隔壁组的”发券服务”却在疯狂重复发券,用户一人领了八张券。你打开监控,Kafka 消费积压两百万条,consumer 实例在反复 rebalance。你心里咯噔一下:同样一个send,怎么有人靠它削峰,有人被它坑崩?这一章就把消息队列这头兽从鼻子摸到尾巴——它为什么能解耦、异步、削峰,又为什么能把”重复发券”这种坑稳稳地埋给你。
20.1 为什么需要消息队列:三板斧与”本地队列”的边界
先别记概念。你先想一个最朴素的问题:下单成功后,你要”发短信通知""加积分""删用户缓存""推给推荐系统”,这些事必须和”下单”在同一个请求里同步做完吗?
显然不用。用户要的是”下单成功”这个动作本身快,至于三秒后收到短信还是五秒后收到,他没有体感差异。于是你本能地把这些”后置动作”从主流程摘出去——这叫异步。
问题 1: 那”摘出去”的物理载体是什么?你第一时间想到的,是不是一个队列?
太对了。你在 Go 里写过 ch := make(chan OrderEvent, 10000),下单往里丢,另起 goroutine 从 ch 里取出来发短信。Redis 那边也能 LPUSH order-event、另一个进程 BRPOP 消费。这些都能”异步”。那为什么大厂不用 Redis 列表或 Go channel 扛核心链路,非要上 Kafka 这种重家伙?
答案在”分布式”三个字上。你列一张表,把”本地队列”和”消息队列”摆平:
| 维度 | Go channel / Redis 列表(本地队列) | Kafka(分布式消息队列) |
|---|---|---|
| 进程边界 | 进程内(channel)或单机(Redis) | 跨进程、跨机器,broker 集群独立部署 |
| 持久化 | channel 在内存,进程挂就没;Redis 可持久化但非为队列设计 | 分区日志落盘,可配置副本,broker 重启不丢 |
| 多消费者 | channel 只能被同进程消费;Redis 列表被一个 BRPOP 抢走就没了 | consumer group 天然支持多实例分摊、多组全量订阅 |
| 堆积能力 | 受单机内存限制,堆多了 OOM 或撑爆 Redis | 磁盘日志,堆积百万千万条是日常 |
| 重放 | 消费即删除,没法”重新看一遍” | offset 可重置,消息保留期内能重放、可回溯 |
本质区别:本地队列是”我自己的线程间传个话”,消息队列是”一个独立的中间件,生产者和消费者互不相识、互不在线也能通信”。大促时下单服务实例可能扩到 50 个、发券服务只有 5 个,它们之间靠 channel 是连不上的——channel 在进程里,跨进程不存在。Kafka 的 broker 是第三方,谁都能连、谁都能按自己的节奏消费,这才是”解耦”。
解耦的含义你先吃透:下单服务现在只依赖”Kafka 还活着”,而不依赖”发券服务、短信服务、积分服务当前是否健康、是否扩容、是否重启”。这些下游挂了,消息堆在 Kafka 里,它们恢复后接着消费,下单服务全程无感。这就是第 19 章”用 MQ 兜底删缓存”能成立的根本原因——删缓存这个动作和写订单彻底脱钩。
削峰则是解耦的孪生兄弟:大促瞬时流量先涌进 Kafka 的分区日志(磁盘顺序写,极快),下游消费者按自己平稳的 TPS 慢慢 poll 出来处理。请求洪峰被”摊平”成一条平稳的消费曲线,DB 和下游不会被瞬间冲垮。你品一下:这和 19 章”缓存挡 DB”是同一类思想——都是用一层缓冲把”不均匀的输入”变成”均匀的 처理”,只是缓存缓冲的是读、MQ 缓冲的是写。
【思考】既然 Go channel 也能异步、也能解耦同进程的逻辑,那”分布式队列的坑(重复消费、丢消息、堆积)“是不是 Java/Kafka 特有,Go 那边就不存在?
参考答案
直接答案:完全不是 Kafka 特有。只要你是”一个生产者丢消息、另一个消费者取出来处理、且两边可能崩溃”,重复消费和丢消息的坑就必然存在——和语言、和中间件无关。Go 用 channel 时若 close(ch) 后没消费完、或消费者处理到一半 panic 没记录位点,同样丢;如果消费失败又没去重,重试也会重复处理。Kafka 只是把这些”坑”显式化了(offset、commit、rebalance 都是为此设计的),而 channel 把这些坑藏在了”进程内、你往往没意识到要去管”的地方。
展开:channel 的”消费位点”是什么?是 goroutine 从 ch 里取出来的那一刻——但取出来之后、处理完之前如果进程崩了,那条消息既不在 channel 里(已被取走)也没被处理,凭空消失。Kafka 的 offset 显式由你提交,正好补上这个空档:poll 到了不代表处理完了,必须 commitSync 之后 broker 才认为你”消费成功”。这个”取”和”完成”之间的 Gap,是所有队列系统的核心战场。
Go 对照:Go 的 kafka-go 客户端 r.CommitMessages(ctx, m) 就是 Kafka 的 offset 提交;如果你不提交、进程崩了,重启后同一条消息会被重新 ReadMessage 读到——这和 Java 的 commitSync 语义一字不差。所以”at-least-once 导致重复消费、要靠业务幂等兜底”这个洞察,在 Go 的 sarama / kafka-go 里一模一样成立。
更深一层:分布式系统的”恰好一次”是出了名的难,因为”网络发出去了 + 对方收到了 + 对方处理完了”这三件事没法在一次原子操作里同时确认。任何队列只要把”投递”和”处理”拆开(MQ 必然拆开,否则不叫解耦),就必然面对”投递了没处理 / 处理了没确认”的灰色地带。这是物理规律级的设计约束,不是 Kafka 写得不好。你从 Go 转 Java,别以为换了框架坑就变了——坑是同一批,只是 Kafka 把坑的接口标准化了。
20.2 Kafka 核心概念:topic、partition、offset、consumer group
你写 kafkaTemplate.send("order-event", key, value),这串字符背后到底发生了什么?先把四个名词钉死,它们是你排错时的坐标。
- topic:逻辑主题,一个业务事件流一个 topic,比如
order-event、cache-invalidate。生产者往 topic 发,消费者从 topic 取。 - partition:topic 的物理分片。一个 topic 可以有多个 partition,消息按 key 的 hash 落到某个 partition。顺序保证只在 partition 内成立——同一 partition 里的消息严格 FIFO,跨 partition 不保证全局顺序。
- offset:消息在 partition 内的序号(从 0 递增)。它是 consumer 的”消费位点”,标记”我读到哪了”。
- consumer group:消费者组。同一个 group 内的多个 consumer 实例分摊 partition(一个 partition 同一时刻只被组内一个 consumer 持有);不同 group 订阅同一个 topic 则是全量独立消费(各读各的,互不影响)。
流向一句话:Producer → Broker(topic/partition 日志) → Consumer(按 group 拉自己的 partition)。
问题 2: 为什么顺序只保证在 partition 内?我想”全局有序”不行吗?
行,但代价是你只能有 1 个 partition——那这个 topic 就失去了水平扩展能力,所有消息挤在一条日志上,消费也只能串行,TPS 天花板极低。所以工程上”需要顺序的业务”(如同一订单的状态变更)靠”用订单 id 当 key,让同一订单永远落同一 partition”来保序,而不是追求全局有序。这是用”局部顺序 + 扩展性”换”全局顺序”的典型取舍。
问题 3: 一个 group 有 5 个 consumer,topic 只有 3 个 partition,会发生什么?反过来 2 个 consumer、6 个 partition 呢?
前者:3 个 partition 只能分给 3 个 consumer,剩下 2 个 consumer 闲着(被分配 0 个分区),白占资源。后者:6 个 partition 分给 2 个 consumer,每个 consumer 扛 3 个 partition。结论先记住:consumer 实例数超过 partition 数没有意义,最佳并发等于 partition 数——这点在 20.6 和案例三会咬你。
下面这张是本章的 Go ↔ Java 总对照表,把”概念”和”两边怎么叫、怎么用”钉死:
| 概念 | Go | Java |
|---|---|---|
| 客户端库 | sarama(原 Shopify、现 IBM 维护)、confluent-kafka-go(librdkafka 绑定)、kafka-go(纯 Go) | kafka-clients(官方)、Spring Kafka(@KafkaListener 封装) |
| 发消息 | producer.SendMessage(msg)(sarama 同步/异步) | KafkaProducer.send(rec, callback) / kafkaTemplate.send(...) |
| 消费 | r.ReadMessage(ctx)(kafka-go 拉模型) | consumer.poll(duration) / @KafkaListener |
| offset 提交 | r.CommitMessages(ctx, m) 手动 | commitSync() / commitAsync() / AckMode.MANUAL |
| 消费组 | ReaderConfig.GroupID | group.id / @KafkaListener(groupId=) |
| 幂等生产者 | config.Producer.Idempotent = true | enable.idempotence=true |
| 本地等价物 | chan 进程内队列 | 无内建,需引外部(如 Spring 的 Queue) |
你看,名字不同、API 不同,但”生产—broker—消费—提交位点”这套骨架,Go 和 Java 是同一个模子刻出来的。
20.3 Producer 端:acks、批量、幂等与 exactly-once 的边界
生产者端最容易拍错的是”可靠性 vs 吞吐 vs 顺序”这三个旋钮。先看一组配置:
Properties props = new Properties();
props.put("bootstrap.servers", "kafka1:9092,kafka2:9092");
// key/value 序列化器(Kafka 只传字节,对象你得自己序列化,呼应 19.3 的坑)
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("acks", "all"); // 全 ISR 副本落盘才返回成功,最可靠;0/1/all 三选一
props.put("enable.idempotence", true); // 幂等生产者:broker 按 <pid,partition,seq> 去重
props.put("retries", Integer.MAX_VALUE); // 发送失败无限重试(配合幂等才安全)
props.put("batch.size", 16384); // 攒够 16KB 或到 linger.ms 才发一批,吞吐换延迟
props.put("linger.ms", 5); // 最多等 5ms 凑批,避免一条一条发拖垮吞吐
KafkaProducer<String, String> producer = new KafkaProducer<>(props);
// 异步发送,结果在回调里拿;不阻塞主流程(呼应 20.1 的"异步")
producer.send(new ProducerRecord<>("order-event", orderId, payload),
(metadata, exception) -> { // 回调:发送成功/失败都在这里
if (exception != null) log.error("send failed", exception);
});acks 三档的取舍你必须会推导边界:
acks=0:生产者发完就当成功,不等 broker 确认。吞吐最高、最不可靠——网络抖一下、broker 挂了,消息静默丢失。适合”丢了也无所谓”的日志埋点。acks=1:leader 副本写入就返回。leader 落了但还没同步给 follower 就崩,这条消息丢。是”可靠和吞吐”的折中。acks=all:所有 ISR(in-sync replica)副本都落盘才返回。最可靠,但要多等 follower 的 ack,延迟和吞吐有代价。
问题 4: 既然 acks=all 最可靠,那我全都配 all 不就完了?
没完,因为还有”重试导致乱序”这个副作用。retries>0 时,一条消息发送失败会被重试,而重试的消息可能后发先至,打乱 partition 内的顺序。Kafka 给的解法是幂等生产者(enable.idempotence=true):broker 给每个生产者分配 pid,每条消息带递增 sequence,broker 端按 (pid, partition, seq) 去重——同一批重试消息只生效一次,且保证 partition 内顺序。代价:幂等要求 acks=all 且 max.in.flight.requests.per.connection<=5(旧版本甚至要求 =1),吞吐再让一点。
【思考】幂等生产者保证了”不重复、不乱序”,那它是不是就等于 exactly-once(恰好一次)了?为什么书里还说”exactly-once 成本高、多数系统选 at-least-once + 幂等”?
参考答案
直接答案:不等。幂等生产者只保证”Producer 到 Broker 这一跳不重复、不乱序”,即发送端的 exactly-once。但 exactly-once 的完整含义是”从生产、到存储、到消费、到对下游的副作用(如发券、写 DB),整条链路恰好生效一次”——这要求消费端处理 + 写结果 + 提交 offset 这三件事是原子的,而 Kafka 单独做不到。Kafka 的”事务 + 幂等”能做到的最强形态是读-处理-写(consume-transform-produce)的原子性(用事务把”消费位点”和”产出消息”绑定提交),但”消费后去调一个外部 HTTP 发券”这种跨系统的副作用,Kafka 管不了。
展开:幂等生产者解决的是”我因为网络重试发了两次,broker 只存一次”。它管不到”消费者拿到消息、处理了、提交 offset 前崩了、重启后又消费一次”——这一跳的重复是消费端的事,靠业务幂等(去重表/唯一键)兜,不是靠 Producer 端的幂等。所以”端到端 exactly-once”在工程上要么极贵(要事务消息 + 下游也支持事务回滚),要么干脆放弃,退守 at-least-once + 幂等。
代码锚点——Kafka 事务(最强保障,但只覆盖”消费→产出”这一段):
props.put("enable.idempotence", true);
props.put("transactional.id", "coupon-tx-1"); // 事务 id,broker 用来恢复未完成事务
producer.initTransactions();
producer.beginTransaction();
producer.send(new ProducerRecord<>("coupon-result", k, v)); // 处理结果写回
// 把"消费到的 offset"和"上面这条产出"在一次事务里原子提交
producer.sendOffsetsToTransaction(offsetMap, "coupon-group");
producer.commitTransaction(); // 要么都成,要么回滚重来注意:这保证的是”消费位点”和”产出消息”一致,避免”处理了但没记位点”或”记了位点但没产出”。但它不保证你 process() 里调的那个外部发券接口只调一次——那个接口的副作用在 Kafka 事务边界之外。
更深一层:exactly-once 的”成本”不在 Kafka 配置,而在”它要求链路上的每一个参与者都支持事务且能回滚”。真实业务里下游是 MySQL(能回滚)、Redis(能删)、第三方发券接口(没法回滚),只要有一个 participants 不支持回滚,端到端 exactly-once 就破功。所以工程现实是:把”不重复”的责任下推到业务幂等(同一订单 id 发券前先查去重表),而不是指望 MQ 替你把整条链路变成原子的。这是 20.4 的核心。
20.4 Consumer 端:拉模型、offset 提交与重复消费的必然性
消费者是坑的重灾区。Kafka 是拉模型:consumer 主动 poll 一批消息回来,自己处理,再自己决定”告诉 broker 我读到哪了”(提交 offset)。这个”告诉”的时机,决定了你丢不丢消息、重不重复。
props.put("enable.auto.commit", false); // 关掉自动提交,改手动,否则可能丢消息
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(List.of("order-event"));
while (true) {
// 拉模型:主动 poll,最多等 500ms;broker 不会主动推
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
for (ConsumerRecord<String, String> r : records) {
process(r); // 先处理业务(发券/删缓存/写 DB)
}
consumer.commitSync(); // 全部处理完,再同步提交 offset
} // 崩在这一行之前 => 重启后这批重消费(at-least-once)自动提交(enable.auto.commit=true)的陷阱:broker 按 auto.commit.interval.ms(默认 5s)定时提交”当前 poll 到的最大 offset”,和处理成不成功无关。设想你 poll 到 100 条、处理了 10 条、正处理第 11 条时进程崩了,但自动提交刚把 offset=100 交了——那剩下 90 条永远不会再被消费,静默丢失。这就是第 19 章”延迟双删靠 sleep 猜窗口”那种粗糙的同类毛病:用时间掩盖时序。
手动提交两种:commitSync() 同步阻塞、稳妥但慢;commitAsync() 异步不阻塞、快但失败不重试(且可能乱序提交)。生产常用”正常异步提交 + 关闭时同步提交兜底”。
重复消费的必然性:手动提交下,如果你 process 完了、但 commitSync 之前崩了,重启后 broker 还以为你没消费,这批消息重新发给你——你处理了两次。这就是 at-least-once(至少一次):不丢,但可能重复。要”不重复”的工程现实,是业务自己幂等:
// 业务幂等:发券前先查去重表,同一 orderId 只发一次
public void sendCoupon(String orderId) {
if (couponSentRepository.existsByOrderId(orderId)) return; // 已发过,直接跳过
couponSentRepository.markSent(orderId); // 先落去重记录(唯一键兜底并发)
couponClient.grant(orderId); // 再真正发券
}去重表用唯一索引 UNIQUE(order_id) 防并发下的双重插入——这跟 17 章事务、19 章唯一键思路一脉相承。
真实案例 ①:自动提交 offset 导致消费者处理中崩溃、消息丢失
现象:某天发券服务上线后,运营发现”部分用户下单后没收到券”,但 Kafka 监控显示消费位点一直往前走、没堆积、没报错。查 DB 发券记录数远小于订单数。
排查过程:
- 看消费者配置,
enable.auto.commit=true(默认值),auto.commit.interval.ms=5000。 - 看发券逻辑:
poll回来一批,for循环里逐个调外部发券接口(网络调用,偶发慢),循环中进程因为一次 Full GC 停顿超时被 K8s 杀掉重启。 - 关键:自动提交在后台每 5s 把”已 poll 的最大 offset”交了,但那批里还有没处理完的。重启后 offset 已经在 100,broker 认为 0~100 都消费完了,剩下没发券的订单石沉大海。
根因:自动提交把”已拉取”当成”已处理”,两者脱钩,崩溃窗口里拉了没处理的消息永久丢失。
修复:关自动提交,改手动 commitSync()(处理完一批再提交);同时发券逻辑加去重表兜底(防止提交前崩溃导致的重复)。两道一起上:手动提交避免丢,幂等避免重。
教训:消费端”丢消息”最常见的不是 broker 丢,是你提交了你没处理完的位点。凡是不带业务幂等的自动提交,都是一颗定时炸弹——尤其是在”消费逻辑里有外部调用/慢处理”时。
【思考】手动同步提交
commitSync()能避免丢,但会重复(崩溃在 process 后 commit 前)。有没有一种提交方式能”既不丢也不重”?
参考答案
直接答案:在”网络分区 + 崩溃”这个物理前提下,没有。“不丢”和”不重”是 CAP 意义上的两难:你要么先处理后提交(崩在中间 → 重,at-least-once),要么先提交后处理(崩在中间 → 丢,at-most-once)。想要”既不丢又不重”必须引入事务(exactly-once,见 20.3),但事务只覆盖 Kafka 内部那一跳,覆盖不到”提交后、处理时调的外部接口”的副作用。所以工程上公认的做法是:接受 at-least-once,把”不重”的责任交给业务幂等(去重表/唯一键),而不是幻想在提交时机上找到两全其美。
展开:commitSync 保证”处理了才提交”,所以不丢;代价是”处理了没提交成”时会重。这不是 bug,是 at-least-once 的定义。你能在”提交粒度”上优化:比如每条处理完立刻 commitAsync(单条 offset),把重复范围从”一批”缩到”一条”,但重复本身消不掉。真正的消重只在业务层——同一 orderId 用唯一键挡。
Go 对照:sarama 的 config.Consumer.Offsets.AutoCommit.Enable=false + 手动 session.MarkMessage(msg, "") 配合 defer session.Commit(),语义和 Java 的”手动提交 + 幂等”完全同构。kafka-go 的 r.CommitMessages(ctx, m) 也是处理完才调。Go 侧同样没有”既不丢又不重”的银弹,照样要业务幂等。
更深一层:这是分布式系统最底层的定理之一——在”异步网络 + 节点可能崩溃”的模型里,你无法同时保证”每条消息至少被处理一次”和”最多被处理一次”且零成本。MQ 把选择明摆给你:要不丢(at-least-once)还是要不重(at-most-once)。生产选 at-least-once 是因为”重复”能用幂等消化、“丢失”却是资损或功能缺失、不可接受。记住这个判据,你自己设计任何队列消费时都不会迷路。
20.5 rebalance:分区重分配与”重复处理”的放大器
consumer group 最反直觉的机制是 rebalance(再均衡):当组内消费者数量变化(扩容、缩容、某实例心跳超时被判死)、或订阅的 topic 分区数变化,Kafka 会触发一次分区重分配——所有 consumer 暂停消费, coordinator 重新把 partition 分给活着的 consumer。
rebalance 期间有两个要命的点:
- 消费会停顿:重分配完成前,所有 consumer 不
poll,业务卡住。频繁 rebalance 直接拖垮吞吐。 - 可能重复处理:rebalance 前 consumer A 已经
poll了一批、处理了一半、还没提交 offset;rebalance 把 A 的 partition 抢给 B,B 从”已提交 offset”开始消费——A 没提交的那部分被 B 重新处理。如果 A 其实处理成功了只是没提交,那就重复了。
max.poll.interval.ms 是关键旋钮:consumer 两次 poll 的最大间隔,默认 5 分钟。如果你一次 poll 一批后,单条处理极慢(比如一条要调 10 秒外部接口,一批 500 条就是 5000 秒),超过这个间隔还没 poll 下一次,coordinator 认为你”死了”,把你踢出组触发 rebalance——然后你的 partition 被别人抢走,你这边还在慢慢处理,两边都在处理同一批,重复翻倍。
问题 5: 那我把 max.poll.interval.ms 调很大、或者一次 poll 很少行不行?
调大间隔只是把”被判死”的阈值放宽,但治标不治本——真处理慢,你放宽到一小时,rebalance 是少了,可消费 TPS 也上不去(一个 consumer 卡在慢处理上)。一次 poll 很少(如 max.poll.records=1)能缩短单批处理时间、减小重复范围,但吞吐下降。根因还是”单条处理太重”,正解是”把慢处理异步化 / 批量处理 / 提并发”,见案例三。
【思考】rebalance 导致的重复,和 20.4 说的”at-least-once 必然重复”是一回事吗?能不能靠幂等一起兜住?
参考答案
直接答案:是同一类”重复”(都源于”处理了没提交位点”),只是触发时机不同:at-least-once 的重复来自”崩溃在 process 和 commit 之间”,rebalance 的重复来自”还没 commit 就被踢出组、partition 易主”。业务幂等(去重表/唯一键)能一勺烩兜住两者——因为不管重复是怎么来的,落到业务层都是”同一 orderId 的同一事件被处理了两次”,去重表认的是 orderId,不关心你是因为崩溃还是因为 rebalance 重复的。所以你只要在 20.4 把幂等做好,rebalance 的重复自动被消化,不用为 rebalance 单独写一套防护。
展开:rebalance 放大重复的范围在于”它可能在你毫无崩溃的情况下发生”——比如你正常处理一批花了 6 分钟(超过默认 5 分钟间隔),coordinator 主动 rebalance,你没崩、只是被挪走了。这种”安静的重平衡重复”比崩溃更隐蔽,因为监控上没报错、没 OOM、没异常,只是发券数翻倍。所以幂等不是”锦上添花”,是”只要你用 consumer group,就必须有”的底座。
更深一层:rebalance 暴露的是”分区所有权”和”处理进度”两个状态没绑在一起——Kafka 只知道”partition 现在归谁”,不知道”你处理到这条消息的哪一步了”。它用”提交位点”近似表达进度,但位点提交是你的自由,于是”所有权易主时进度可能滞后”成了结构性漏洞。幂等是把这个漏洞在业务层补平的唯一稳妥手段;另一个思路是” Cooperative Sticky 再均衡策略”(减少重分配范围),但那只是减小震动、消不掉重复。
真实案例 ②:consumer 处理超时触发 rebalance,分区被抢走又抢回,重复发券
现象:大促期间发券服务监控出现”同一用户短时间内收到多张券”,且 consumer 日志里反复出现 Revoking partitions、Partitions reassigned 的 rebalance 日志。发券 TPS 上不去,反而重复率高。
排查过程:
- 看 consumer 配置,
max.poll.interval.ms是默认 300000(5 分钟),max.poll.records=500。 - 看业务逻辑:每条订单事件要同步调”风控接口 + 积分接口 + 发券接口”三个外部 HTTP,平均单条 3 秒,一批 500 条要 1500 秒 ≈ 25 分钟,远超 5 分钟。
- 于是 consumer 在处理这批的 25 分钟里没机会
poll下一次,coordinator 在 5 分钟时判定它死、触发 rebalance,把它的 partition 分给别的实例;原实例处理完、尝试提交时被拒绝(分区已不属于它),重启后 partition 又被分回来,从旧 offset 重读——这批订单被处理了两次以上。
根因:单批处理耗时超过 max.poll.interval.ms,触发rebalance,分区易主导致未提交位点的消息被重复消费,叠加发券逻辑无幂等,重复变发券。
修复三连:① 发券逻辑加去重表(UNIQUE(order_id)),重复直接跳过——这是止血;② 把 max.poll.records 降到 50、并把三个外部调用改并发(CompletableFuture 或虚拟线程,呼应第 12 章),单批耗时压到 5 分钟以内;③ 如仍重,调大 max.poll.interval.ms 并改用 CooperativeStickyAssignor 减小重分配抖动。
教训:rebalance 不是”运维事件”,是”你的消费逻辑慢”的报警器。它一出现,先问”我单次处理是不是太重、是不是有慢外部调用”,而不是只去调 max.poll.interval.ms 把报警静音——静音了,重复发券照发。
20.6 Spring Kafka:@KafkaListener、异常处理与并发
你从 16 章知道,Spring 喜欢把”样板代码”包成注解。消费 Kafka 也是:@KafkaListener 让你写一个方法就能消费,不用手撸 poll 循环。
// 注解式消费:框架帮你建 consumer、poll、提交,你只写业务
@KafkaListener(topics = "order-event", groupId = "coupon-service",
concurrency = "3") // 并发消费者数,理想等于分区数
public void onOrder(ConsumerRecord<String, String> record,
Acknowledgment ack) { // 注入 ack 做手动提交
sendCoupon(record.key()); // 业务:发券(已带去重表幂等)
ack.acknowledge(); // 处理完才提交 => at-least-once
}@SendTo 用于”消费后把结果发到另一个 topic”(请求-应答模式),比如消费 order-event 产出 coupon-result:
@KafkaListener(topics = "order-event")
@SendTo("coupon-result") // 方法返回值自动发到这个 topic
public CouponResult onOrder(ConsumerRecord<String, String> record) {
return new CouponResult(record.key(), sendCoupon(record.key()));
}异常处理与死信队列(DLQ):消费抛异常时,默认会无限重试、把分区卡住(因为位点不前进)。生产要的是”重试几次还不行就丢进死信队列,别阻塞主流”。Spring Kafka 2.8 前用 SeekToCurrentErrorHandler(把出错 offset seek 回当前、重读),2.8 起统一为 DefaultErrorHandler,默认就是 seek-to-current 语义,配 DeadLetterPublishingRecoverer 投死信:
@Bean
public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory(
ConsumerFactory<String, String> cf, KafkaTemplate<String, String> kt) {
var f = new ConcurrentKafkaListenerContainerFactory<String, String>();
f.setConsumerFactory(cf);
f.setConcurrency(3); // 并发 = 分区数最优
// 死信恢复器:重试耗尽后把消息发到 <topic>.DLT
var recoverer = new DeadLetterPublishingRecoverer(kt);
// 默认 DefaultErrorHandler 即 seek-to-current;FixedBackOff 控制重试间隔与次数
f.setCommonErrorHandler(new DefaultErrorHandler(recoverer,
new FixedBackOff(1000L, 3))); // 隔 1s 重试 3 次,仍失败进 DLT
return f;
}concurrency 的真相:@KafkaListener(concurrency=3) 或工厂 setConcurrency(3) 是在同一个 JVM 内起 3 个 consumer 线程,各自领 partition。它等于 partition 数时每个线程一个 partition,最均衡;超过 partition 数则多余线程闲着(呼应 20.2 问题 3)。要真正水平扩展消费能力,得加 partition 数(扩集群),不是只加 concurrency——这是案例三的主线。
真实案例 ③:大促消息堆积,消费 TPS 上不去,分区数=3 并发=1
现象:大促订单峰值,Kafka 里 order-event 堆积两百万条,发券服务消费 TPS 卡在 200/s,怎么加机器都没用。运维加了两个 consumer 实例,TPS 纹丝不动。
排查过程:
- 看 topic 元数据:
order-event只有 3 个 partition。 - 看消费端:原
@KafkaListener没设 concurrency,默认 1 个 consumer 线程;后来又起了 2 个实例,共 3 个实例、每个 1 线程。 - 按 20.2 的规则:3 个 partition、3 个 consumer 实例,正好一人一个 partition,已经分到头了——再加实例也没 partition 可分,多余实例闲着。TPS 卡在 200/s,是因为单 partition 的消费速度 = 单线程处理速度,而单线程里每条还要同步调三个慢接口(同案例二)。
- 根因不是”机器不够”,是”并行度被 partition 数锁死在 3,且单线程处理太重”。
修复:
- 短期:把单条处理里的三个外部调用改并发,单线程 TPS 从 200 提到 800。
- 中期:给 topic 扩 partition 到 12(
kafka-topics.sh --alter --partitions 12),并把 concurrency 提到 12,消费并行度从 3 升到 12,TPS 线性涨到近万。 - 对照 11 章线程池思路:partition 数就是”消费侧的线程池大小”,分区不够 = 线程池容量不够,加 consumer 实例像”加线程池外的线程”,进不了池就没活干。
教训:消费堆积第一反应不是”加机器”,是”看 partition 数 × concurrency 是不是已经到天花板,以及单条处理是不是太重”。partition 数决定了消费并行度的硬上限,这是 MQ 和线程池最像、也最容易被忽略的一点。
【思考】扩 partition 能让消费并行度上去,那我一开始就把 partition 设成一万个,不就永远不堆了?
参考答案
直接答案:不行,partition 数不是越大越好,它是一个有副作用的旋钮。每个 partition 在 broker 端要占文件句柄、内存、副本同步开销;partition 过多会让 leader 选举、元数据传播、consumer 重平衡变慢,甚至拖垮 broker。而且 partition 数决定了”同一 key 的顺序边界”和”未来能扩的并行度上限”——设太小以后要扩(扩 partition 会让按 key 的 hash 重分布,已存在的 key 可能跳 partition,破坏某些顺序假设),设太大则日常空耗资源。经验值是”按你预期峰值 TPS / 单分区消费 TPS 估算,再留 2~3 倍余量”,而不是拍个万。
展开:partition 数一旦设定,生产端按 hash(key) % partitionCount 路由。你扩 partition 后,同一个 key 的 hash 落点变了,历史消息在旧 partition、新消息在新 partition——如果你依赖”同一 key 全局顺序”,扩容瞬间会乱序(虽然旧消息已消费完通常无所谓)。所以 partition 数要在”设计初期按峰值估准”,避免频繁扩容。这是和线程池 maximumPoolSize 一样的权衡:太小顶不住峰值、太大空耗且重平衡慢。
Go 对照:sarama / kafka-go 里 partition 的语义、扩 partition 的副作用完全一致——这不是 Spring 的事,是 Kafka broker 的事。Go 侧 AdminClient.CreatePartitions 扩分区,后果和 Java 侧 kafka-topics.sh --alter 一模一样。再次印证:分布式队列的坑与语言无关。
更深一层:任何”并行度旋钮”都有”设置成本 + 上限 + 副作用”三件套。partition 数对应线程池容量,concurrency 对应线程数,单条处理耗时对应单任务耗时——这三样的乘积才是你的消费 TPS。排堆积时,按顺序问:分区够吗?并发到分区数了吗?单条处理能再快吗?回答了这三个,堆积问题必有解,且解法和 11 章线程池排错同构。
20.7 消费堆积排查思路:呼应排错方法论
把 20.6 案例三的方法论化,遇到”消费积压”按这条链查(呼应第 08 章”先问现象对应哪种根因”):
- 先看并行度上限:
partition 数是多少?concurrency是多少?实例数 × concurrency 是否已超过 partition 数(超了就是白加机器)。并行度硬上限 = partition 数。 - 再看单条处理耗时:消费逻辑里有没有慢外部调用(HTTP/DB/Redis)?有没有同步串行?用 11 章线程池/虚拟线程思路把串行改并发。
- 看是否频繁 rebalance:日志里有没有
revoking/reassigned?有就查max.poll.interval.ms和单批耗时(max.poll.records)。 - 看 consumer 是否背压:消费慢导致
poll间隔长,进而触发 rebalance,进而重复,进而更慢——恶性循环。 - 最后才扩资源:扩 partition(改 broker 侧并行度)+ 提 concurrency(改应用侧并行度),而非无脑加实例。
你会发现,这和第 08 章”监控指标成对看、先定位是哪一类根因”完全一致:积压不是一种病,是”并行度不够 / 单条慢 / rebalance 抖”三种病共用的症状。你得像 19.9 那样”指标成对看”,别盯着堆积数一个数瞎调。
20.8 消息语义层级:at-most / at-least / exactly-once
把全章的语义收个口,这是你选型时的总纲:
| 语义 | 含义 | 怎么做到 | 代价 |
|---|---|---|---|
| at-most-once(至多一次) | 可能丢、不重复 | 先提交 offset 再处理;或 acks=0 | 丢消息(资损/功能缺失),多数业务不可接受 |
| at-least-once(至少一次) | 不丢、可能重复 | 处理后提交 offset(commitSync / 手动 ack) | 重复消费,需业务幂等兜底 |
| exactly-once(恰好一次) | 不丢不重 | 事务 + 幂等(仅 Kafka 内部跳)+ 下游也事务 | 极重,链路任一带外副作用即破功 |
工程结论:绝大多数系统选 at-least-once + 业务幂等。原因在 20.3/20.4 已推导:exactly-once 要求链路每一环都能回滚,而真实业务里有第三方接口、有 Redis 这种不回滚的存储,端到端 exactly-once 几乎不可能;at-least-once 的”重复”能用去重表/唯一键廉价消化,“丢失”却不可接受。所以别被”恰好一次”的诱人名字带偏——它成本高、覆盖窄,绝大多数场景你用不上。
20.9 Go 程序员的 Kafka 对照:分布式队列的坑与语言无关
你写 Go 大概率碰过 sarama 或 kafka-go。这一节把”坑”摆平,让你确认:从 Go 转 Java,你踩的坑一个都不会少。
三个 Go 客户端怎么选:
sarama(原 Shopify、现 IBM 维护):最老牌、功能最全,API 偏底层,同步/异步都支持,社区项目多见。confluent-kafka-go:绑定 librdkafka(C 库),性能最好、最贴近 Kafka 协议,但要带 CGO、部署要 librdkafka 动态库,跨平台稍麻烦。kafka-go(Segment 开源,现 Redis 维护):纯 Go、API 现代、上手快,新项目常被推荐。
并排看一个”消费 + 手动提交”的 Go 写法,对照 20.4 的 Java:
// Go:kafka-go 拉模型 + 手动提交(语义与 Java 的 commitSync 一致)
r := kafkago.NewReader(kafkago.ReaderConfig{
Brokers: brokers,
Topic: "order-event",
GroupID: "coupon-service",
})
for {
m, err := r.ReadMessage(ctx) // 等价 Java 的 poll(),拉一条
if err != nil { break }
sendCoupon(m.Key) // 业务处理(同样要带去重表幂等)
r.CommitMessages(ctx, m) // 处理完才提交 => at-least-once,崩在中间则重
}// Java:KafkaConsumer.poll + commitSync,骨架逐行对应
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
for (ConsumerRecord<String, String> r : records) {
sendCoupon(r.key()); // 业务处理(去重表幂等)
}
consumer.commitSync(); // 处理完才提交 => at-least-once
}sarama 的等价生产者(对应 20.3 的 acks=all + 幂等):
config := sarama.NewConfig()
config.Producer.Return.Successes = true
config.Producer.RequiredAcks = sarama.WaitForAll // 等价 acks=all
config.Producer.Idempotent = true // 等价 enable.idempotence
config.Net.MaxOpenRequests = 1 // 幂等要求的并发约束
producer, _ := sarama.NewSyncProducer(brokers, config)
msg := &sarama.ProducerMessage{
Topic: "order-event",
Key: sarama.StringEncoder(orderId),
Value: sarama.StringEncoder(payload),
}
_, _, err := producer.SendMessage(msg) // 同步发送,语义同 JavaGo channel vs Kafka 这个对照值得你刻进脑子:channel 是进程内、内存、无持久化、无多消费者组的”本地队列”;Kafka 是跨进程、落盘、可多 group 全量订阅的”分布式队列”。你用 channel 解耦的是”同一进程里的两个 goroutine”,用 Kafka 解耦的是”两个互不相识的服务”。channel 没有 offset 概念(取走即消失),所以 channel 场景下”消费到一半崩了”的消息直接蒸发——Kafka 用显式 offset 提交补上了这个漏洞。但补上漏洞的代价是你要自己管提交时机、要自己搞幂等。所以不是 Kafka 比 channel 高级,是它们解决的问题域不同:进程内用 channel 足够且最快,跨服务必须用 Kafka 这类中间件。
Go 程序员最容易踩的 Java 专属坑:① 以为 @KafkaListener 加个注解就万事大吉,没设幂等,rebalance/崩溃一重复就发重券;② 用默认自动提交(enable.auto.commit=true 是默认!),处理中崩溃静默丢消息;③ 并发只堆实例数,忘了 partition 数才是并行度硬上限,加机器无效;④ 单条处理慢外部调用,触发 rebalance 还以为是 Kafka bug。这些坑在 Go 的 sarama/kafka-go 里一模一样存在——只是 Java 这边 Spring 把 poll 藏进了注解,让你更容易”忘了底下还有 offset 和 rebalance 在运作”。
20.10 本章核心结论
如果这一章你只看这一段:
- 消息队列解决三件事:解耦(生产者不依赖下游生死)、异步(主流程快速返回)、削峰(洪峰堆 Kafka、下游匀速消费);它和 Redis 列表 / Go channel 的本质区别是”分布式、可持久化、可多消费者组”。
- 四个坐标必须钉死:topic(逻辑主题)、partition(物理分片,顺序只在 partition 内)、offset(消费位点)、consumer group(同组分摊分区、不同组全量订阅);并行度硬上限 = partition 数。
- Producer 端
acks=all+enable.idempotence保证”发送跳不重复不乱序”,但端到端 exactly-once 要链路全事务,成本高、覆盖窄,多数系统用不上。 - Consumer 端是拉模型;自动提交会在处理中崩溃时静默丢消息,必须关掉改手动提交(处理完再
commitSync/ack.acknowledge())。 - 重复消费是 at-least-once 的必然产物,唯一稳妥解法是业务幂等(去重表/唯一键),不是去调提交时机——崩溃重复和 rebalance 重复都能被同一套幂等兜住。
- rebalance 是”消费逻辑慢”的报警器:
max.poll.interval.ms内没poll就触发分区易主、导致重复;先查单条处理是不是太重,别只调大间隔把报警静音。 - 消费堆积排查链:partition 数 → concurrency → 单条处理耗时 → rebalance,与第 08 章、第 11 章排错方法论同构;扩 partition 才是提并行度,加实例超过 partition 数无效。
- Go 的 sarama / kafka-go / confluent-kafka-go 与 Java 的 Spring Kafka 骨架同构,分布式队列的坑(丢、重、堆、rebalance)与语言无关——你从 Go 带来的经验直接可迁移,只是 Java 把
poll藏进@KafkaListener后更容易”忘了底下还有 offset”。
20.11 深度思考题
题目 1(语义选型): 你的业务是”用户支付成功后给用户加积分”。如果消息重复消费,用户会多加分——多加分比”少加分(丢消息)“更不可接受吗?这种情况下你该选 at-most-once 还是 at-least-once + 幂等?
【思考】“重复”和”丢失”哪个更不可接受,是怎么决定你选型的?这个判断依据是不是和 19 章”超卖 vs 少卖”同一个逻辑?
参考答案
直接答案:多加分(重复)通常比少加分(丢失)更不可接受——因为少加分可以后续对账补、用户投诉能人工补,多加分是直接资损且难追回。但注意:选 at-least-once + 幂等后,“重复”已经被去重表消掉了,所以你实际既不会多加也不会少加(不丢 + 不重)。真正该选 at-most-once 的场景极少,只有”丢了也无所谓”的日志/埋点类。这和 19 章”超卖(重复扣)比少卖更不可接受”逻辑同源:都是先判断”哪边代价大”,再决定架构把哪边堵死。
展开:积分场景”重复发”的代价是资损,“丢失”的代价是体验/对账,前者更硬,所以你天然倾向”不丢”→ at-least-once。而 at-least-once 的”重”用 UNIQUE(user_id, trade_no) 去重表一招制敌。所以现实里你基本无脑选 at-least-once + 幂等,把”重”消化掉,同时享受”不丢”。at-most-once 只在”消息本就可丢弃”时才有用。
更深一层:所有”语义选型”的本质都是”为不可接受的代价筑墙”。你先回答”丢和重哪个要命”,墙就筑在哪侧。这和第 19 章缓存一致性的”业务 SLA 决定架构”、第 17 章事务隔离级别的”正确性 vs 性能”是同一个思维骨架——技术选型永远在被业务代价牵引。
题目 2(rebalance 根因): 你监控到 consumer group 每分钟 rebalance 一次,但单条处理只要 10ms,max.poll.interval.ms 也远大于处理时间。还可能是哪些原因触发的 rebalance?
【思考】rebalance 的触发条件只有”处理慢”吗?心跳这东西在 consumer 里扮演什么角色?
参考答案
直接答案:不止处理慢。rebalance 触发条件还有:① consumer 心跳超时——session.timeout.ms 内没向 coordinator 发心跳(如 GC 长暂停、网络抖动、进程被调度挂起),coordinator 判死踢人;② 消费者实例频繁上下线(滚动发布、K8s 健康检查误杀、OOM 重启);③ 订阅 topic 的分区数变更;④ heartbeat.interval.ms 配得过大导致心跳不及时。处理慢是 max.poll.interval.ms 那一维,心跳是 session.timeout.ms 那一维,两维独立。
展开:consumer 有两个独立计时器——max.poll.interval.ms(两次 poll 间隔,管”你是不是卡在慢处理”)和 session.timeout.ms + heartbeat.interval.ms(心跳,管”你是不是还活着”)。你处理快但 GC 停顿超过 session.timeout.ms,心跳发不出,照样被踢。所以看到频繁 rebalance,别只盯处理耗时,还要看 GC 日志、网络、实例稳定性、以及心跳/会话超时配置(session.timeout.ms 应大于 heartbeat.interval.ms 的若干倍)。
更深一层:rebalance 是”组成员资格”机制,它关心的不是”你处理得快不快”,而是”你还是不是组里活跃的一员”。任何让 coordinator 认为你”失联”的信号都会触发它。所以排 rebalance 要分两路:一路查”处理卡不卡”(poll 间隔),一路查”活着没”(心跳/会话/实例存活)。这和第 08 章”一个症状可能对应多类根因、指标成对看”完全同构。
题目 3(Go 对照): 你在 Go 里用 kafka-go 的 r.CommitMessages 手动提交,和 Java 的 @KafkaListener 注入 Acknowledgment 手动 ack.acknowledge(),语义完全一致吗?Spring 的”自动 ack 模式”默认是哪种、会不会也踩 20.4 的自动提交丢消息坑?
【思考】Spring 把 poll 藏进注解后,offset 提交的默认行为是不是也”藏”成了危险默认值?
参考答案
直接答案:语义一致——都是”处理完才提交,at-least-once”。但 Spring Kafka 的 ack 模式(AckMode)默认是 RECORD(每条处理完立即提交)或 BATCH(每批处理完提交),不是 Kafka 原生客户端的”定时自动提交”。关键在于:Spring 的提交仍以”你 ack.acknowledge() 或被框架按 AckMode 处理完”为触发,不会像原生 enable.auto.commit=true 那样”定时盲交”。但如果你用 AckMode.RECORD 而 process 抛异常没 ack,那条会重试;若你压根没注入 Acknowledgment 且用 BATCH,框架在处理完一批后提交——崩溃在批处理中途仍可能重复(at-least-once 本质),不会”静默丢”。所以 Spring 默认不等于原生自动提交的丢消息坑,但”重复”依然在,幂等不能省。
展开:原生 enable.auto.commit=true 的危险是”后台定时提交已 poll 的最大 offset,和处理脱钩”;Spring 的 AckMode 是”框架在 poll 循环里、按记录或批次、处理完才提交”,和处理挂钩,因此不丢。但 Spring 容器底层仍基于 KafkaConsumer,如果你显式把 enable.auto.commit 设 true 又用手动 ack,会冲突——应以 Spring 的 AckMode 为准,别两个机制混用。
Go 对照:kafka-go 默认 CommitMessages 是你显式调才提交,等于 Spring 的手动 ack;sarama 的 AutoCommit.Enable=false + session.MarkMessage 同理。三语言/三客户端的”手动提交 = 不丢 + 可能重”完全一致。
更深一层:Spring 用注解把 poll 循环藏起来,是”显式 vs 隐式”的老故事(呼应 16 章)——它让你少写样板,但也容易让你”忘了底下还有 offset 和 rebalance”。Spring 的默认 AckMode 比原生自动提交安全,但”重复消费要业务幂等”这条铁律没有因为换了框架而消失。你从 Go 转来,别以为 @KafkaListener 帮你把坑填平了,它只是把坑的接口标准化了。
题目 4(开放题,无标准答案): 如果让你从零设计一个”订单超时未支付自动关单”的功能,你会用 Kafka 的延迟消息、还是用定时任务扫 DB、还是用 Kafka 的”消费时判断时间”模式?各自在什么规模下划算?
【思考】Kafka 原生并不支持”延迟投递”(没有原生 delay topic),那”延迟消息”在 Kafka 里到底怎么实现?这个事实会不会反过来影响你的选型?
参考答案
方向提示(非唯一解):Kafka 原生没有延迟消息(不像 RocketMQ 有 delayLevel、不像 RabbitMQ 有 x-delayed-message),所以”Kafka 延迟消息”通常是绕出来的——要么发到一个特殊 topic、consumer 拉到后判断”还没到执行时间就 seek 回去等会儿再读”(会阻塞分区、不推荐),要么专门起一个”时间轮/定时扫描”服务。中小规模用”定时任务扫 DB 的待支付订单、超时就关”最简单可靠;量大到扫 DB 扛不住时,用”下单时发一条事件到 Kafka,独立关单服务消费,但关单服务用一个时间轮/DelayQueue 在内存里等到期再处理”——把 Kafka 当”事件源”、把延迟逻辑放在消费侧。选型看你的订单量和”精确到期”要求:不精确、量小 → 扫 DB;量大 → Kafka + 消费侧延迟。
更深一层:这道题考的是”别被框架能力绑架”。Kafka 强在”高吞吐流式处理”,弱在”精确的定时/延迟投递”——它没有原生 delay 语义,硬要用就得自己造轮子,而轮子往往不如 RabbitMQ/RocketMQ 的延迟消息或干脆一个 Cron 扫描来得稳。技术选型要”让工具做它擅长的事”:流式、削峰、解耦用 Kafka;定时、延迟、精确一次用专门的延时队列或定时任务。把 Kafka 当万能药,反而会在延迟消息这种点上栽跟头。
下一章预告
第 21 章讲 怎么读开源项目与框架源码:你现在已经会用 Spring、会用 Kafka 客户端了,但”会用”和”看懂它为什么这么写”之间还差一层——下一章带你从一条异常栈反推调用链、从 @KafkaListener 怎么被注册成 consumer 钻进 Spring 的 Bean 生命周期、从测试类和 issue 里快速摸清一个库的边界与坑。我们会讲调试技巧(远程调试、条件断点、@Conditional 装配的来龙去脉)、Spring 源码的入口(AbstractApplicationContext.refresh 那条主路)、以及如何用”最小可复现 demo + 单测”撬开一个你不熟悉的库。承接关系很清楚:这一章你学会了”调 Kafka”,下一章要让你能钻进 Spring Kafka 内部,看懂 @KafkaListener 底下那个 KafkaMessageListenerContainer 到底怎么 poll、怎么提交、rebalance 时它干了什么——把”知其然”补成”知其所以然”。