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 上跑过。

①为什么要学:下单之后,三个下游都要知道

订单服务直接调三个下游,一个慢就全慢,一个挂就下单失败。换成发一条“订单已创建”的消息,各下游自己去取。

订单服务 下单 → 发一条事件 topic:order-created 按顺序往后追加,默认保留 7 天 库存服务 · 组 inventory 实例 1 实例 2 积分服务 · 组 points 实例 1 短信服务 · 组 sms 实例 1 1 2 3
订单服务只管写,不知道下游有几个。三个服务各自从 topic 里读,读多快自己定;大促时消息先攒在 Kafka 里,下游慢慢消化。
1一条都不能丢

库存少扣一次就超卖,积分少发一次就有人投诉。服务器宕机、网络抖动时也一样。

本页上半:可靠性
2每个下游各收一份

以后加风控、数仓,不用改订单服务,再挂一个组就行。

消费组
3组内分着干,挂一台别的接手

库存服务开两台,消息分着处理;一台挂了,另一台把它的活接过去。

消费组和 rebalance

②老办法行不行

先用已经会的东西做一遍:直接调接口,或者用学过的 Redis Stream。都能做,但疼的地方正好是 Kafka 的精华。

同步调接口能做,耦合太紧

订单→库存50ms→积分80ms→短信超时
  • 下单接口至少 430 ms,最慢的那个说了算
  • 短信服务挂了,下单跟着失败,或者要自己写重试
  • 加一个下游,就要改一次订单服务、发一次版
  • 大促时流量直接压到每个下游,没有缓冲

Redis Stream能做,不够稳、扩不开

订单→Stream内存→库存组积分组
  • 有消费组、ACK、待确认列表,组内分活、组间各一份都能做
  • 消息在内存里,下游停几个小时,积压就可能把内存撑爆
  • AOF 每秒刷盘,断电丢约 1 秒;主从异步复制,切换时丢最后一点(WAIT 能等从节点,但官方说它不能让 Redis 变成强一致)
  • 一个 Stream 是一个 key,只在一个节点上,要扩容得自己拆成多个 key
缺的那块
就是精华

写到磁盘、等够几份副本才算成功:机器挂一台,消息还在别处,而且“成功”的标准可以配。

按分区并行,每个组自己记读到哪:消息读完不删,加下游、回放历史只是多一个书签;组内按分区分活,加分区就能扩。

不懂精华时的典型误用
  • 当成普通队列用,以为“消费完就删了”。其实消息一直在,默认存 7 天,别的组照样能读,也能重读。
  • 以为加实例就能提速。实例数超过分区数,多出来的拿不到分区,白开着。
  • 以为配了 acks=all 就一定不丢。Broker 默认 min.insync.replicas=1,同步副本只剩 leader 时,它和 acks=1 一样。

③选谁:读完就删的队列,还是读完还在的日志

能干这活的有好几个。先看最根本的差别:消息被读过之后还在不在。

队列:读完就删RabbitMQ 经典队列 m5 m4 m3 m2 m1 ack 后删除 库存 实例1 库存 实例2 两个实例抢同一个队列 积分 实例1 积分要另建一个队列,由交换机再投一份 读过的消息回不去,新下游看不到历史 日志:读完还在Kafka 分区 012345678 库存组 → 9(读完了) 积分组 → 5(还差 4 条) 新接的数仓组 → 0,从头读 多一个下游只是多一个书签 消息按时间或大小过期,不因为被读而删除
左边读一条少一条,几个实例抢着拿;右边谁都不删,每个组记自己的位置(▲)。Kafka 的“消费组”就是这样一个个书签。
Redis StreamRabbitMQRocketMQKafka
模型日志,放内存队列,确认后删除(3.9 起另有 Stream 队列)队列,底层是一份提交日志日志,放磁盘
怎样算写成功主节点写进内存publisher confirm;仲裁队列要多数节点写入可选同步刷盘、同步复制acks=all + min.insync.replicas
组内并行单位每条消息每条消息(多个消费者抢一个队列)队列(MessageQueue)分区
回放历史按 ID读完就没了按时间重置位点按 offset 或时间
吞吐中,受单节点限制中高很高
重试、死信、延迟都要自己做死信交换机、TTL自带重试、死信、延迟消息都不内置;Spring Kafka 有重试和死信
适合已有 Redis 的轻量任务复杂路由、单条确认的业务消息交易类业务消息事件流、日志、一条消息多个下游
选择规则
  1. 同一条消息要被好几个系统各读一份、量大、还想回放:Kafka。
  2. 要按规则路由,每条单独确认、重试:RabbitMQ。
  3. 交易类消息,想要延迟消息、事务消息开箱即用:RocketMQ。
  4. 量小、已经有 Redis、能接受极端情况下丢一点:Redis Stream。

本页学 Kafka。订单事件要扇出给库存、积分、短信,以后数仓、风控也要接,“读完还在”和“按分区扩”最关键。

④基本原理:一本账存三份,每个组夹自己的书签

把一个 topic 想成几本账(分区)。每本账复印三份放在三台机器上(副本),只有一份是正本(leader),写和读都找它。每个部门(消费组)在每本账里夹自己的书签(offset),部门里的人分账来看,一本账同一时间只归一个人。

订单服务 key = 订单号 hash(key) % 3 决定进哪个分区 分区 0leader 在 B1,副本在 B2、B3 分区 1leader 在 B2,副本在 B3、B1 分区 2leader 在 B3,副本在 B1、B2 库存组 · 实例 c1 读 积分组 · 实例 c1 读 库存组 · 实例 c1 读 积分组 · 实例 c1 读 库存组 · 实例 c2 读 积分组 · 实例 c1 读 库存组的书签 积分组的书签(下次从这里读)
同一个订单号总进同一个分区,所以同一订单的事件有先后;不同分区之间没有顺序。每个组在每个分区各记一个 offset,互不影响:积分组落后了,不耽误库存组。

一条消息的一生

三段里任何一段松了都会丢,第⑥步的故障实验台可以挨个试。HW、ISR 进出、Leader Epoch 这些细节,放到第⑥步第三圈。

3 个分区 · 2 个实例
c1
p0p1
c2
p2

不平均也没关系,按分区分。

3 个分区 · 3 个实例
c1
p0
c2
p1
c3
p2

一人一个,并行度到顶了。

3 个分区 · 4 个实例
c1
p0
c2
p1
c3
p2
c4闲着

多出来的拿不到分区。

另一个组 · 1 个实例
积分 c1
p0p1p2

组和组互不影响,各读一份。

最少的代码spring-kafka
// 生产:订单号当 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
}
最少的配置application.yml
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 以上。下面的输出都是真实运行时打出来的,只删掉了时间和线程名。

1起一个 Kafka二选一
# 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
2pom.xml只要两个依赖
<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>
3src/main/resources/application.yml
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
4src/main/java/demo/OrderApp.java生产者和两个消费组都在这一个类里
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();
    }
}
5运行两个终端
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
6实测输出节选;行首 A、B 表示哪个实例
# 只有 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 立刻接手。

到这里已经能交差了。下单发事件,库存和积分各收一份;库存服务多开一台,分区自动分摊;挂一台,另一台接手。
已知还没处理的细节

⑥细节 · 规范 · 优化

初级看到前三条,高手看到第九条。分三圈:不知道就出事故的坑、团队里该怎么配、调优和底层机制。先动手:在实验台里配一套参数,扔一个故障进去。

故障实验台

1000 个订单 · 3 副本

生产者

acks
enable.idempotence
send 的回调

这个故障和生产者的配置无关

Broker · topic

min.insync.replicas
副本数3,unclean.leader.election.enable=false(默认)

这个故障里 Broker 参数不起作用

消费者

提交 offset 的时机
业务幂等(按订单号去重)

这个故障和消费者无关

1000 个订单的下场(一格 10 个,从 #1 排到 #1000)
正常悄悄丢了没写成,业务知道处理了两次重复了,被幂等挡住
    B 按 Kafka 4.1 新集群算(默认开启 ELR),3.x 的差别写在结论里。
    第一圈

    注意事项:不知道就会出事故

    01发了就不管,失败没人知道
    现象
    Broker 出问题时接口照样返回成功,消息却没进 Kafka。
    原因
    send() 是异步的,消息进了内存批次就返回。失败要重试到 delivery.timeout.ms(默认 2 分钟)才交给回调。实测同步副本不够时,日志里反复打 NOT_ENOUGH_REPLICAS … retrying,回调最后拿到的是 TimeoutException。实测
    做法
    回调里处理失败:记录、落本地表、定时补发。关键消息用第二圈的事务发件箱。
    02acks=1,leader 一挂就丢最后一段
    现象
    实测: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 直接启动报错。
    03acks=all 也会丢:min.insync.replicas 还是 1
    现象
    实测:两个 follower 掉出 ISR 后,acks=all 照样回成功;leader 再一挂,分区下线,这些消息只在那一台上。实测
    原因
    “all”指的是 ISR 里的全部。ISR 缩到只剩 leader,就只等它自己。Broker 的 min.insync.replicas 默认是 1。
    做法
    topic 3 副本 + min.insync.replicas=2:同步副本不到 2 个就拒写,宁可报错也不假装成功。
    04自动建出来的 topic 只有 1 个副本、1 个分区
    现象
    往一个还不存在的 topic 发消息,没报错;后来那台 Broker 坏了,消息全没了。死信 topic 也一样,实测被自动建成 1 分区 1 副本。实测
    原因
    auto.create.topics.enable 默认 true,自动建时用 num.partitions=1、default.replication.factor=1。
    做法
    生产环境关掉自动建;用 kafka-topics.sh 或 NewTopic 显式指定分区数和副本数,死信 topic 也提前建。
    05先提交再处理,消息丢了还看不出来
    现象
    消费者重启后,有一段消息没处理过,消费进度却显示早读完了。
    原因
    offset 先交了,处理到一半崩溃。常见写法:手动提交写在业务前面;或者开自动提交,又把消息丢给线程池异步处理,下一次 poll() 就把 offset 交掉了。
    做法
    处理完再提交。Spring Kafka 2.3 起默认关掉 enable.auto.commit,按 AckMode.BATCH 在一批处理完后提交,别自己改回去。
    06处理完再提交,就一定会重复
    现象
    库存偶尔被扣两次。
    原因
    处理完、提交前崩溃或 rebalance,这批会被重新投递。Kafka 默认给的是“至少一次”。
    做法
    消费端幂等:订单号做唯一键;或建去重表,和业务在同一个事务里先插入再处理;或用状态机 UPDATE … WHERE status='待扣减'。
    07一批处理太久,被踢出组,越踢越乱
    现象
    日志里反复 rebalance,同一批消息被不同实例处理好几遍,积压越来越多。
    原因
    两次 poll() 间隔超过 max.poll.interval.ms(默认 5 分钟),就被认为卡死、踢出组;这批没提交,交给别人重做;它回来又触发一次 rebalance。一次最多拉 max.poll.records(默认 500)条。
    做法
    调小 max.poll.records,让一批在时限内做完;外部调用加超时;确实慢再调大 max.poll.interval.ms。
    08一条坏消息,默认重试 10 次后被跳过
    现象
    实测:某条消息一直抛异常,同一条连着处理 10 次,然后一行 Backoff FixedBackOff{interval=0, currentAttempts=10, maxAttempts=9} exhausted,这条就过去了。实测
    原因
    Spring Kafka 的 DefaultErrorHandler 默认不等待、再试 9 次,之后只记日志、提交 offset、跳过。
    做法
    配 DeadLetterPublishingRecoverer 把它转进死信 topic,加退避间隔,给死信 topic 配告警(配置见第二圈)。
    09新消费组上线,之前的消息一条没收到
    现象
    新加的短信组上线后,只收到上线之后的订单。
    原因
    一个组第一次消费、还没提交过 offset 时,按 auto.offset.reset 决定从哪读,默认 latest,只读新的。
    做法
    要补历史就设 earliest;或上线前用 kafka-consumer-groups.sh --reset-offsets 指定位置。
    10加了实例,消费速度没变
    现象
    积压了,把库存服务从 3 台加到 6 台,还是 3 台在干活。
    原因
    一个分区同一时间只归组里一个消费者。分区只有 3 个,多出来的拿不到。Spring 的 concurrency: 3 也算 3 个消费者。
    做法
    分区数按“高峰要几个消费者”提前定好、留余量。临时加分区会改变 key 到分区的映射,同一订单的新旧消息可能落在两个分区,顺序会乱一阵。
    11停了一周的消费组,回来时位置没了
    现象
    一个组停了 8 天,重启后要么跳过一大段,要么从头全部重放。
    原因
    消息默认保留 7 天(log.retention.hours=168)。组里没有活跃实例时,提交过的 offset 也只保留 7 天(offsets.retention.minutes=10080),过期后按 auto.offset.reset 重来。
    做法
    积压告警要远早于保留期;长期下线的组,恢复前先确认并手动设好 offset。
    第二圈

    使用规范:团队里该怎么配、怎么写

    topic 和 Broker运维建 topic 时
    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                       # 副本分到不同机架 / 可用区
    application.yml生产环境
    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 ≤ 分区数
    坏消息进死信 topicSpring Boot 会把这个 bean 装到监听容器上(实测)
    @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 用业务主键

    订单号当 key,同一订单的“创建、支付、取消”进同一个分区,才有先后。只有分区内有序。

    消息自带唯一 ID

    消息体里放事件 ID 和发生时间,消费端拿它去重、排查。

    topic 命名

    <业务域>.<事件>,比如 order.created;死信、重试 topic 跟在后面。

    盯消费延迟

    kafka-consumer-groups.sh 里的 LAG 列接进监控,按“落后多少分钟”告警,不要等积压了才发现。

    正常停机

    发版用 SIGTERM(Spring 会关监听容器,实例主动离组,立刻交接);kill -9 要等 45 秒。

    写库和发消息要一致

    在 @Transactional 方法里直接 send():事务回滚了,消息已经发出去;或者消息发了,提交时数据库失败。用下面的事务发件箱。

    同一个数据库事务:写 orders + 写 outbox→ 后台任务扫 outbox(或 Debezium 读 binlog)→ 发 Kafka,acks=all→ 成功后标记已发;失败下次再发

    事务发件箱:订单和“要发的消息”一起提交,要么都有,要么都没有。发送可能重复(标记前崩了会再发一次),所以下游还是要幂等。事务的写法见 Spring AOP 与事务。

    第三圈

    优化和深处:副本怎么对齐、组怎么分、精确一次管到哪

    leader B1follower B2follower B3 HW = 6 消费者只能读到这条线之前 LEO 8LEO 8LEO 6(还在追) LEO:下一条要写的位置 HW:ISR 里都有的位置
    offset 6、7 只有两份,还不算“已提交”,消费者看不到;B3 追上来以后 HW 才前进。acks=all 的生产者也是在这条线越过自己那条消息时才收到成功。

    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.size16384一个分区一批最多 16 KB;调大能多攒,延迟略升
    linger.ms3.x:0 · 4.x:5等多久再发;攒批越多,请求越少
    compression.typenonelz4、zstd 省网络和磁盘,批越大压得越好
    max.in.flight.requests.per.connection5开幂等时不超过 5,重试也能保住分区内顺序
    max.poll.records500一次拉多少;要和单条处理耗时、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 天的保留期。

    ↺回到第⑤步:拿⑥改自己的案例

    学一圈、用一圈、再深一圈。每个练习都在⑤的 OrderApp 上加一条⑥的内容。

    给库存加幂等

    建表 stock_log(order_id varchar(32) primary key)。在 @Transactional 方法里先插入,DuplicateKeyException 就直接 ack 跳过,否则扣库存。处理中途 kill -9 一个实例,看重投的那几条有没有被挡住。

    用到:06 一定会重复

    加死信

    把第二圈的 DefaultErrorHandler bean 加进去,在 deductStock 里遇到 bad- 开头的订单就抛异常。发一个 bad-1,用 kafka-console-consumer.sh --topic order-created-dlt --from-beginning 看它进了死信。

    用到:08 坏消息、04 自动建 topic

    换成 3 个 Broker

    起 3 个 Broker,topic 建成 --replication-factor 3 --config min.insync.replicas=2。停 1 台照常写;停 2 台,看生产者日志里的 NOT_ENOUGH_REPLICAS,以及回调里 2 分钟后的 TimeoutException。

    用到:03 min.insync.replicas、01 发了不管

    自测

    用一句话说出 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 台拿不到分区。要先加分区(注意顺序会乱一阵),或者一开始就按高峰规划分区数。