KAFKA · ACKS · ISR · MIN.INSYNC.REPLICAS · OFFSET · CONSUMER GROUP · REBALANCE
写够副本才算数,处理完了再挪书签
Kafka 把消息记在一本只往后追加、存好几份的日志里,消费者只记“读到哪了”(offset)。不丢消息要三段一起守:生产者等 ISR 里的副本都写到(acks=all),Broker 保证同步副本够数(min.insync.replicas),消费者处理完再提交 offset。消费组让一个分区同一时间只归组里一个实例,组和组之间各读一份。
假设场景你在订单服务里接 Kafka:下单后通知库存、积分、短信三个下游,一条都不能丢。这也是 Kafka 面试问得最多的两块,第⑥步末尾附了面试题。页面里标“实测”的地方,都在本机的 Kafka 4.1.2 集群和 Spring Boot 3.5.16 上跑过。
①为什么要学:下单之后,三个下游都要知道
订单服务直接调三个下游,一个慢就全慢,一个挂就下单失败。换成发一条“订单已创建”的消息,各下游自己去取。
库存少扣一次就超卖,积分少发一次就有人投诉。服务器宕机、网络抖动时也一样。
本页上半:可靠性以后加风控、数仓,不用改订单服务,再挂一个组就行。
消费组库存服务开两台,消息分着处理;一台挂了,另一台把它的活接过去。
消费组和 rebalance②老办法行不行
先用已经会的东西做一遍:直接调接口,或者用学过的 Redis Stream。都能做,但疼的地方正好是 Kafka 的精华。
同步调接口能做,耦合太紧
- 下单接口至少 430 ms,最慢的那个说了算
- 短信服务挂了,下单跟着失败,或者要自己写重试
- 加一个下游,就要改一次订单服务、发一次版
- 大促时流量直接压到每个下游,没有缓冲
Redis Stream能做,不够稳、扩不开
- 有消费组、ACK、待确认列表,组内分活、组间各一份都能做
- 消息在内存里,下游停几个小时,积压就可能把内存撑爆
- AOF 每秒刷盘,断电丢约 1 秒;主从异步复制,切换时丢最后一点(
WAIT能等从节点,但官方说它不能让 Redis 变成强一致) - 一个 Stream 是一个 key,只在一个节点上,要扩容得自己拆成多个 key
就是精华
写到磁盘、等够几份副本才算成功:机器挂一台,消息还在别处,而且“成功”的标准可以配。
按分区并行,每个组自己记读到哪:消息读完不删,加下游、回放历史只是多一个书签;组内按分区分活,加分区就能扩。
- 当成普通队列用,以为“消费完就删了”。其实消息一直在,默认存 7 天,别的组照样能读,也能重读。
- 以为加实例就能提速。实例数超过分区数,多出来的拿不到分区,白开着。
- 以为配了
acks=all就一定不丢。Broker 默认min.insync.replicas=1,同步副本只剩 leader 时,它和acks=1一样。
③选谁:读完就删的队列,还是读完还在的日志
能干这活的有好几个。先看最根本的差别:消息被读过之后还在不在。
| Redis Stream | RabbitMQ | RocketMQ | Kafka | |
|---|---|---|---|---|
| 模型 | 日志,放内存 | 队列,确认后删除(3.9 起另有 Stream 队列) | 队列,底层是一份提交日志 | 日志,放磁盘 |
| 怎样算写成功 | 主节点写进内存 | publisher confirm;仲裁队列要多数节点写入 | 可选同步刷盘、同步复制 | acks=all + min.insync.replicas |
| 组内并行单位 | 每条消息 | 每条消息(多个消费者抢一个队列) | 队列(MessageQueue) | 分区 |
| 回放历史 | 按 ID | 读完就没了 | 按时间重置位点 | 按 offset 或时间 |
| 吞吐 | 中,受单节点限制 | 中 | 高 | 很高 |
| 重试、死信、延迟 | 都要自己做 | 死信交换机、TTL | 自带重试、死信、延迟消息 | 都不内置;Spring Kafka 有重试和死信 |
| 适合 | 已有 Redis 的轻量任务 | 复杂路由、单条确认的业务消息 | 交易类业务消息 | 事件流、日志、一条消息多个下游 |
- 同一条消息要被好几个系统各读一份、量大、还想回放:Kafka。
- 要按规则路由,每条单独确认、重试:RabbitMQ。
- 交易类消息,想要延迟消息、事务消息开箱即用:RocketMQ。
- 量小、已经有 Redis、能接受极端情况下丢一点:Redis Stream。
本页学 Kafka。订单事件要扇出给库存、积分、短信,以后数仓、风控也要接,“读完还在”和“按分区扩”最关键。
④基本原理:一本账存三份,每个组夹自己的书签
把一个 topic 想成几本账(分区)。每本账复印三份放在三台机器上(副本),只有一份是正本(leader),写和读都找它。每个部门(消费组)在每本账里夹自己的书签(offset),部门里的人分账来看,一本账同一时间只归一个人。
一条消息的一生
生产者:等够了再说成功
acks=all:ISR 里的副本都写到才回成功- 失败自动重试,开幂等防重复
- 回调里看结果,失败要处理
Broker:副本要够
- 每个分区 3 个副本
min.insync.replicas=2:同步副本不到 2 个就拒写- 落后的副本不许当 leader(默认)
消费者:做完再挪书签
- 处理完再提交 offset
- 崩溃后会重来一遍,业务要幂等
- 同一组的实例分摊分区
三段里任何一段松了都会丢,第⑥步的故障实验台可以挨个试。HW、ISR 进出、Leader Epoch 这些细节,放到第⑥步第三圈。
不平均也没关系,按分区分。
一人一个,并行度到顶了。
多出来的拿不到分区。
组和组互不影响,各读一份。
// 生产:订单号当 key,同一订单进同一分区
kafkaTemplate.send("order-created", orderId, json);
// 消费:同一个 groupId 的实例分摊分区
@KafkaListener(topics = "order-created", groupId = "inventory")
void onOrder(String json, Acknowledgment ack) {
deductStock(json);
ack.acknowledge(); // 处理完再提交 offset
}
spring:
kafka:
producer:
acks: all # 配合 min.insync.replicas,见⑥
listener:
ack-mode: manual # 其他提交方式,见⑥
# topic 端(运维建 topic 时):
# 3 副本 + min.insync.replicas=2,见⑥
⑤跑起来:一个订单服务,两个消费组
实测环境:Kafka 4.1.2 单机(KRaft),Spring Boot 3.5.16(自带 spring-kafka 3.3.16、kafka-clients 3.9.2),JDK 17 以上。下面的输出都是真实运行时打出来的,只删掉了时间和线程名。
# Docker:官方镜像默认就是单机 KRaft,监听 localhost:9092
docker run -d --name kafka -p 9092:9092 apache/kafka:4.1.2
# 不用 Docker:解压 kafka_2.13-4.1.2.tgz 后
bin/kafka-storage.sh format --standalone \
-t $(bin/kafka-storage.sh random-uuid) -c config/server.properties
bin/kafka-server-start.sh config/server.properties
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
</parent>
<groupId>demo</groupId>
<artifactId>kafka-order</artifactId>
<version>1.0</version>
<properties><java.version>17</java.version></properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
</dependencies>
<build><plugins><plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin></plugins></build>
spring:
kafka:
bootstrap-servers: localhost:9092
producer:
acks: all # ISR 里的副本都写到才算成功
properties:
enable.idempotence: true # 重试不会写出重复
consumer:
auto-offset-reset: earliest # 新消费组从头读,不漏上线前的消息
enable-auto-commit: false
listener:
ack-mode: manual # 业务处理完,手动提交 offset
package demo;
import org.apache.kafka.clients.admin.NewTopic;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.config.TopicBuilder;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
@RestController
public class OrderApp {
private static final Logger log = LoggerFactory.getLogger(OrderApp.class);
private final KafkaTemplate<String, String> kafka;
public OrderApp(KafkaTemplate<String, String> kafka) {
this.kafka = kafka;
}
public static void main(String[] args) {
SpringApplication.run(OrderApp.class, args);
}
/** 启动时建 topic:3 个分区。单机只能 1 个副本,生产环境是 3 */
@Bean
NewTopic orderCreated() {
return TopicBuilder.name("order-created").partitions(3).replicas(1).build();
}
/** 生产者:订单号当 key,同一个订单永远进同一个分区 */
@PostMapping("/orders/{id}")
public String create(@PathVariable String id) {
kafka.send("order-created", id, "{\"orderId\":\"" + id + "\"}")
.whenComplete((r, ex) -> {
if (ex != null) {
log.error("发送失败 order={}", id, ex); // 第⑥步:落库,稍后重发
} else {
log.info("已写入 order={} 分区={} offset={}", id,
r.getRecordMetadata().partition(), r.getRecordMetadata().offset());
}
});
return "ok\n";
}
/** 消费组 inventory:开几个实例,3 个分区就在它们之间分 */
@KafkaListener(topics = "order-created", groupId = "inventory")
public void deductStock(ConsumerRecord<String, String> rec, Acknowledgment ack) {
log.info("[库存] 扣减 order={} 分区={} offset={}", rec.key(), rec.partition(), rec.offset());
ack.acknowledge(); // 处理完再提交
}
/** 消费组 points:另一个组,每条消息再收一份 */
@KafkaListener(topics = "order-created", groupId = "points")
public void addPoints(ConsumerRecord<String, String> rec, Acknowledgment ack) {
log.info("[积分] 加积分 order={} 分区={}", rec.key(), rec.partition());
ack.acknowledge();
}
}
mvn -q package -DskipTests
# 终端 A:先只开一个实例,发 6 个订单
java -jar target/kafka-order-1.0.jar
for i in 1001 1002 1003 1004 1005 1006; do
curl -X POST localhost:8080/orders/$i; done
# 终端 B:再开一个实例,再发 3 个
java -jar target/kafka-order-1.0.jar --server.port=8081
for i in 1007 1008 1009; do
curl -X POST localhost:8080/orders/$i; done
# 看消费组:每个分区读到哪、落后多少
docker exec kafka /opt/kafka/bin/kafka-consumer-groups.sh \
--bootstrap-server localhost:9092 --describe --group inventory
# 只有 A:两个组都拿到全部 3 个分区
inventory: partitions assigned: [order-created-0, order-created-1, order-created-2]
points: partitions assigned: [order-created-0, order-created-1, order-created-2]
已写入 order=1001 分区=0 offset=0
已写入 order=1002 分区=0 offset=1
已写入 order=1003 分区=1 offset=0
已写入 order=1005 分区=2 offset=0
[库存] 扣减 order=1001 分区=0 offset=0
[积分] 加积分 order=1001 分区=0
# B 加入:A 先交出全部,再领回一个
A inventory: partitions revoked: [order-created-0, order-created-1, order-created-2]
A inventory: partitions assigned: [order-created-2]
B inventory: partitions assigned: [order-created-0, order-created-1]
B [库存] 扣减 order=1007 分区=0 offset=2
B [库存] 扣减 order=1009 分区=1 offset=2
# kafka-consumer-groups.sh(9 个订单都消费完,列有删减)
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG
inventory order-created 0 4 4 0
inventory order-created 1 3 3 0
inventory order-created 2 2 2 0
再试一下:把 A kill -9,实测 B 等了 45 秒(session.timeout.ms 的默认值)才把分区 2 接过去,这期间分区 2 没人读。在终端里按 Ctrl+C 正常停,B 立刻接手。
- 发送失败只打了日志,没补发 → ⑥ 发了不管
- 单机只有 1 个副本,acks=all 还没真正起作用 → ⑥ min.insync.replicas
- 崩溃重启会重复消费,扣库存没做幂等 → ⑥ 一定会重复
- 处理抛异常时,默认重试 10 次后直接跳过 → ⑥ 坏消息
- 分区数 3 定死了,开到第 4 个实例就闲着 → ⑥ 加实例不变快
⑥细节 · 规范 · 优化
初级看到前三条,高手看到第九条。分三圈:不知道就出事故的坑、团队里该怎么配、调优和底层机制。先动手:在实验台里配一套参数,扔一个故障进去。
故障实验台
1000 个订单 · 3 副本生产者
acksenable.idempotence这个故障和生产者的配置无关
Broker · topic
min.insync.replicas3,unclean.leader.election.enable=false(默认)这个故障里 Broker 参数不起作用
消费者
这个故障和消费者无关
注意事项:不知道就会出事故
- 现象
- Broker 出问题时接口照样返回成功,消息却没进 Kafka。
- 原因
send()是异步的,消息进了内存批次就返回。失败要重试到delivery.timeout.ms(默认 2 分钟)才交给回调。实测同步副本不够时,日志里反复打NOT_ENOUGH_REPLICAS … retrying,回调最后拿到的是TimeoutException。实测- 做法
- 回调里处理失败:记录、落本地表、定时补发。关键消息用第二圈的事务发件箱。
- 现象
- 实测:acks=1 发 200 条,全部回成功;follower 还没拉走时 kill -9 leader,新 leader 上一条都没有。实测
- 原因
- acks=1 只等 leader 写进自己的日志。kafka-clients 2.x 默认就是 1,Spring Boot 2.5 及更早带的是 2.x。
- 做法
- 用
acks=all。别为了快改回 1:实测显式写acks=1时,默认开着的幂等会被悄悄关掉;再写enable.idempotence=true直接启动报错。
- 现象
- 实测:两个 follower 掉出 ISR 后,acks=all 照样回成功;leader 再一挂,分区下线,这些消息只在那一台上。实测
- 原因
- “all”指的是 ISR 里的全部。ISR 缩到只剩 leader,就只等它自己。Broker 的
min.insync.replicas默认是 1。 - 做法
- topic 3 副本 +
min.insync.replicas=2:同步副本不到 2 个就拒写,宁可报错也不假装成功。
- 现象
- 往一个还不存在的 topic 发消息,没报错;后来那台 Broker 坏了,消息全没了。死信 topic 也一样,实测被自动建成 1 分区 1 副本。实测
- 原因
auto.create.topics.enable默认 true,自动建时用num.partitions=1、default.replication.factor=1。- 做法
- 生产环境关掉自动建;用
kafka-topics.sh或NewTopic显式指定分区数和副本数,死信 topic 也提前建。
- 现象
- 消费者重启后,有一段消息没处理过,消费进度却显示早读完了。
- 原因
- offset 先交了,处理到一半崩溃。常见写法:手动提交写在业务前面;或者开自动提交,又把消息丢给线程池异步处理,下一次
poll()就把 offset 交掉了。 - 做法
- 处理完再提交。Spring Kafka 2.3 起默认关掉
enable.auto.commit,按AckMode.BATCH在一批处理完后提交,别自己改回去。
- 现象
- 库存偶尔被扣两次。
- 原因
- 处理完、提交前崩溃或 rebalance,这批会被重新投递。Kafka 默认给的是“至少一次”。
- 做法
- 消费端幂等:订单号做唯一键;或建去重表,和业务在同一个事务里先插入再处理;或用状态机
UPDATE … WHERE status='待扣减'。
- 现象
- 日志里反复 rebalance,同一批消息被不同实例处理好几遍,积压越来越多。
- 原因
- 两次
poll()间隔超过max.poll.interval.ms(默认 5 分钟),就被认为卡死、踢出组;这批没提交,交给别人重做;它回来又触发一次 rebalance。一次最多拉max.poll.records(默认 500)条。 - 做法
- 调小
max.poll.records,让一批在时限内做完;外部调用加超时;确实慢再调大max.poll.interval.ms。
- 现象
- 实测:某条消息一直抛异常,同一条连着处理 10 次,然后一行
Backoff FixedBackOff{interval=0, currentAttempts=10, maxAttempts=9} exhausted,这条就过去了。实测 - 原因
- Spring Kafka 的
DefaultErrorHandler默认不等待、再试 9 次,之后只记日志、提交 offset、跳过。 - 做法
- 配
DeadLetterPublishingRecoverer把它转进死信 topic,加退避间隔,给死信 topic 配告警(配置见第二圈)。
- 现象
- 新加的短信组上线后,只收到上线之后的订单。
- 原因
- 一个组第一次消费、还没提交过 offset 时,按
auto.offset.reset决定从哪读,默认latest,只读新的。 - 做法
- 要补历史就设
earliest;或上线前用kafka-consumer-groups.sh --reset-offsets指定位置。
- 现象
- 积压了,把库存服务从 3 台加到 6 台,还是 3 台在干活。
- 原因
- 一个分区同一时间只归组里一个消费者。分区只有 3 个,多出来的拿不到。Spring 的
concurrency: 3也算 3 个消费者。 - 做法
- 分区数按“高峰要几个消费者”提前定好、留余量。临时加分区会改变 key 到分区的映射,同一订单的新旧消息可能落在两个分区,顺序会乱一阵。
- 现象
- 一个组停了 8 天,重启后要么跳过一大段,要么从头全部重放。
- 原因
- 消息默认保留 7 天(
log.retention.hours=168)。组里没有活跃实例时,提交过的 offset 也只保留 7 天(offsets.retention.minutes=10080),过期后按auto.offset.reset重来。 - 做法
- 积压告警要远早于保留期;长期下线的组,恢复前先确认并手动设好 offset。
使用规范:团队里该怎么配、怎么写
kafka-topics.sh --bootstrap-server … --create \
--topic order.created --partitions 12 \
--replication-factor 3 \
--config min.insync.replicas=2
# server.properties
auto.create.topics.enable=false
unclean.leader.election.enable=false # 默认就是 false
broker.rack=az-1 # 副本分到不同机架 / 可用区
spring:
kafka:
producer:
acks: all
properties:
enable.idempotence: true # 3.x 起默认开,显式写上
delivery.timeout.ms: 120000
consumer:
auto-offset-reset: earliest
max-poll-records: 100 # 一批要在 5 分钟内处理完
listener:
ack-mode: batch # 默认值:一批处理完再提交
concurrency: 3 # 实例数 × 3 ≤ 分区数
@Bean
DefaultErrorHandler errorHandler(KafkaTemplate<String, String> template) {
var recoverer = new DeadLetterPublishingRecoverer(template); // 转发到死信 topic
return new DefaultErrorHandler(recoverer, new FixedBackOff(1000L, 2)); // 隔 1 秒,再试 2 次
}
// 实测 spring-kafka 3.3:死信 topic 叫 order-created-dlt,老版本叫 order-created.DLT
// 消息头里带着原 topic、分区、offset 和异常信息,排查完可以再投回去
订单号当 key,同一订单的“创建、支付、取消”进同一个分区,才有先后。只有分区内有序。
消息体里放事件 ID 和发生时间,消费端拿它去重、排查。
<业务域>.<事件>,比如 order.created;死信、重试 topic 跟在后面。
kafka-consumer-groups.sh 里的 LAG 列接进监控,按“落后多少分钟”告警,不要等积压了才发现。
发版用 SIGTERM(Spring 会关监听容器,实例主动离组,立刻交接);kill -9 要等 45 秒。
在 @Transactional 方法里直接 send():事务回滚了,消息已经发出去;或者消息发了,提交时数据库失败。用下面的事务发件箱。
事务发件箱:订单和“要发的消息”一起提交,要么都有,要么都没有。发送可能重复(标记前崩了会再发一次),所以下游还是要幂等。事务的写法见 Spring AOP 与事务。
优化和深处:副本怎么对齐、组怎么分、精确一次管到哪
ISR 怎么进出,切换时怎么对齐
- follower 超过
replica.lag.time.max.ms(默认 30 秒)没追上,leader 把它踢出 ISR;追上了再加回来。KRaft 下 Broker 心跳断了超过broker.session.timeout.ms(9 秒),控制器直接把它移出 ISR,实测十几秒内就只剩 leader。 - Leader Epoch(0.11 起):每任 leader 有个任期号。切换后副本按“上一任写到哪”截断对齐,修掉了只靠 HW 截断时丢数据、副本不一致的问题。
- ELR(KIP-966):ISR 掉到
min.insync.replicas以下时,被踢出的副本记进 ELR,表示“它有全部已提交的数据”,leader 挂了它能干净接班。实测 4.1 新集群默认开启:min.insync.replicas=2时 kill -9 leader,follower 自动接班;=1 时分区直接下线。 unclean.leader.election.enable=true:没有合格副本时,让落后的副本当 leader,换回可用性,代价是丢数据。
不刷盘,靠副本
log.flush.interval.messages默认是 Long 的最大值:消息写进操作系统的页缓存就算写入,不等 fsync。- 所以“不丢”靠的是多份副本同时在内存或磁盘里。三个副本放在同一个机架、同一路电上,一起断电时,没刷到磁盘的部分可能一起没了。
- 用
broker.rack让副本分散到不同机架或可用区;真要每条刷盘,代价是吞吐大降。
消费组怎么分区,rebalance 时谁停
实例加入、离开、心跳超时(session.timeout.ms,3.0 起 45 秒,之前 10 秒)、处理超时、分区数变化,都会触发 rebalance。默认的 partition.assignment.strategy 是 [RangeAssignor, CooperativeStickyAssignor],实际用 Range,走 eager 协议:所有实例先交出全部分区,⑤ 里实测看到的 revoked 全部 3 个就是它。
示意:实例按名字排序;真实的 Range 按 member.id 排序,谁拿多一个不一定。
少停一点
- CooperativeSticky:只交出要挪的分区,其余照常消费。从 Range 换过来要分两次滚动发布,按官方升级说明做。
- 静态成员
group.instance.id(2.3 起):实例在session.timeout.ms内重启回来,不触发 rebalance,适合 K8s 滚动发布。 - 新协议 KIP-848(Kafka 4.0 起正式可用):消费者设
group.protocol=consumer,分配挪到 Broker 端做(默认 uniform 分配器),增量调整,不再整组停下。实测 kafka-clients 4.4.0 的默认值仍是classic,要自己打开。
“精确一次”管到哪
- 幂等生产者:每个生产者有 Producer ID,每个分区的批次带序列号,Broker 丢掉重复的。只管同一个生产者实例、同一个分区的重试,进程重启换了 ID 就管不到。
- 事务:
transactional.id(Spring 里是transaction-id-prefix),消费者设isolation.level=read_committed(默认read_uncommitted)。适合“读 Kafka → 处理 → 写 Kafka”,offset 和输出一起提交。 - 管不到的:写数据库、调外部接口。Kafka 事务和数据库事务是两个事务,这部分还是靠业务幂等。
| 吞吐参数 | 默认值(实测) | 作用和取舍 |
|---|---|---|
batch.size | 16384 | 一个分区一批最多 16 KB;调大能多攒,延迟略升 |
linger.ms | 3.x:0 · 4.x:5 | 等多久再发;攒批越多,请求越少 |
compression.type | none | lz4、zstd 省网络和磁盘,批越大压得越好 |
max.in.flight.requests.per.connection | 5 | 开幂等时不超过 5,重试也能保住分区内顺序 |
max.poll.records | 500 | 一次拉多少;要和单条处理耗时、5 分钟时限一起算 |
| 消费并行度 | — | 分区数 ≥ 实例数 × concurrency;单分区里想并行,要按 key 分到不同线程,并自己管 offset |
版本差异:Kafka 2.x / 3.x / 4.x,Spring Boot 2 / 3,JDK 8 / 17
kafka-clients 2.x
Boot ≤ 2.5 · JDK 8- 实测默认:
acks=1、幂等关、session.timeout.ms=10000、只有 RangeAssignor - Broker 还要 ZooKeeper
- spring-kafka 2.x:
send()返回ListenableFuture,用addCallback
kafka-clients 3.x
Boot 2.6 – 3.5 · JDK 8 / 17- 实测默认:
acks=all、幂等开、45 秒会话超时、[Range, CooperativeSticky] - 早期 3.x 小版本出过默认值没生效的问题,生产配置里显式写上
- 3.x 客户端还能跑在 JDK 8 上(3.0 起标记废弃),Boot 2.7 带 3.1.2
- spring-kafka 3.0 起
send()返回CompletableFuture,用whenComplete;2.8 起错误处理统一成DefaultErrorHandler
Kafka 4.x
Boot 4 · 客户端 JDK 11+ · Broker JDK 17+- 只有 KRaft,ZooKeeper 去掉了
linger.ms默认改成 5(实测)- 新消费组协议 KIP-848 可用,要设
group.protocol=consumer - 新集群默认开 ELR(实测 4.1)
- Boot 4.0 带 kafka-clients 4.1,Boot 4.1 带 4.2;4.x Broker 只接受 2.1 及以上的客户端
面试官会怎么问
Kafka 怎么保证消息不丢?
- 生产者:
acks=all、重试、处理回调里的失败。 - Broker:3 副本、
min.insync.replicas=2、不开 unclean 选举、副本跨机架。 - 消费者:处理完再提交 offset。
- 再补一句:
acks=all单独用不够,ISR 只剩 leader 时它等于acks=1。
怎么保证不重复消费?
- 做不到“不投递重复”,只能做到“重复了不重复生效”。
- 生产端开幂等(Producer ID + 序列号)挡住重试造成的重复。
- 消费端是至少一次,崩溃和 rebalance 都会重投,用唯一键、去重表或状态机做业务幂等。
- Kafka 事务的精确一次只覆盖 Kafka 到 Kafka。
怎么保证顺序?
- 只有分区内有序。要求有序的消息用同一个 key(订单号)。
- 开幂等时
max.in.flight.requests.per.connection不超过 5,重试也不乱序;没开幂等又要严格有序,得设成 1。 - 消费端多线程处理时,按 key 分到固定线程;加分区会打乱 key 的映射。
消费者比分区多会怎样?rebalance 有什么影响?
- 多出来的消费者闲着。
- rebalance 在实例进出、心跳超时、处理超时、分区变化时发生。eager 协议下整个组先交出全部分区,期间都停;还会造成重复消费。
- 减少:CooperativeSticky、静态成员、控制单批处理时长、正常停机,Kafka 4 可以换 KIP-848 新协议。
ISR、HW、LEO 是什么?
- ISR:跟得上 leader 的副本集合(含 leader)。
- LEO:每个副本下一条要写的位置。HW:ISR 里所有副本都有的位置,消费者只能读到 HW 之前。
- acks=all 等的是 ISR 里的全部,所以 ISR 的大小决定了它有多可靠。
线上积压了怎么办?
- 先看是不是卡住了:消费报错重试、慢的外部调用、反复 rebalance。
- 分区够就加实例或调大 concurrency;分区不够,加分区(注意顺序),或临时建一个分区更多的 topic,把消息转过去并行消费。
- 同时确认积压不会超过 7 天的保留期。
下一圈
这一层没有尽头。本页没展开、以后可以单独做一页的方向:
- 存储:segment 文件、稀疏索引、页缓存和零拷贝,顺序写磁盘为什么这么快
- 事务细节:事务协调器、LSO,
read_committed怎么跳过未提交的消息 - 新消费组协议和共享组:KIP-848 的服务端分配,KIP-932 让多个消费者按条分享一个分区
- 分层存储:旧数据下沉到对象存储,保留期拉长到几个月
- Spring Kafka 非阻塞重试:
@RetryableTopic和延迟重试 topic - 运维:分区重分配、副本均衡、lag 告警、Cruise Control
- 流处理:Kafka Streams 和 Flink 怎么读同一份日志
- 和 RocketMQ 比:事务消息、延迟消息、消费重试的不同做法
↺回到第⑤步:拿⑥改自己的案例
学一圈、用一圈、再深一圈。每个练习都在⑤的 OrderApp 上加一条⑥的内容。
给库存加幂等
建表 stock_log(order_id varchar(32) primary key)。在 @Transactional 方法里先插入,DuplicateKeyException 就直接 ack 跳过,否则扣库存。处理中途 kill -9 一个实例,看重投的那几条有没有被挡住。
加死信
把第二圈的 DefaultErrorHandler bean 加进去,在 deductStock 里遇到 bad- 开头的订单就抛异常。发一个 bad-1,用 kafka-console-consumer.sh --topic order-created-dlt --from-beginning 看它进了死信。
换成 3 个 Broker
起 3 个 Broker,topic 建成 --replication-factor 3 --config min.insync.replicas=2。停 1 台照常写;停 2 台,看生产者日志里的 NOT_ENOUGH_REPLICAS,以及回调里 2 分钟后的 TimeoutException。
自测
用一句话说出 Kafka 可靠性的精华。
写够副本才算成功(acks=all + min.insync.replicas),处理完再挪书签(提交 offset),三段一起守,少一段都会丢。
用 Redis Stream 做订单消息,疼在哪几处?
消息在内存里,积压多了撑爆内存;AOF 每秒刷盘、主从异步复制,断电或切换会丢最后一点;一个 Stream 只在一个节点上,扩容要自己拆 key。
想让两个实例分摊消费,最少要做什么?
两个实例用同一个 groupId,topic 的分区数至少 2。分区数决定了组内最多几个实例能同时干活。
头号坑:acks=all 为什么还会丢?
“all”是 ISR 里的全部。Broker 默认 min.insync.replicas=1,follower 掉队后 ISR 只剩 leader,它写完就回成功,leader 再挂就丢了。要配 3 副本 + min.insync.replicas=2。
改成“处理完再提交”之后,还剩什么问题?
会重复:处理完、提交前崩溃或 rebalance,这批会再来一次。消费端要幂等。
库存服务从 3 台加到 6 台没变快,为什么?
topic 只有 3 个分区,一个分区同一时间只归组内一个消费者,多出来的 3 台拿不到分区。要先加分区(注意顺序会乱一阵),或者一开始就按高峰规划分区数。