《Java 消息中间件·Kafka 内核与客户端》1.1 把消息系统看成日志

从队列、发布订阅、日志、流四种形态的差别入手,用单节点 Kafka 发 5 条消息、两个消费组读到不同位置、消费后日志段仍完整保留的实验,说明 Kafka 为什么选分区日志,以及「消费不删数据、位移是读者自己的进度」在工程上到底意味着什么。

一个看起来很简单的问题

消息被消费之后,去哪了?

如果你用过传统队列,答案很直接:被取走了,队列里没有了。如果你用过发布订阅,答案也还算直接:每个订阅者拿到一份副本,各自消费完就删。但如果你在 Kafka 里问这个问题,答案会让人愣一下:哪也没去,还在文件里。

这不是实现细节,而是 Kafka 最根本的设计选择。理解这一点,后面所有的概念——位移、消费组、保留期、重放、积压——才有地方挂。所以这一节不急着讲 API,先把「消息系统有几种形状」这件事说清楚,再用一个五分钟能跑完的实验,把「消费不删数据」变成你亲眼见过的东西。

四种形态,四种删除时机

把消息系统按「数据什么时候消失」分类,比按产品名字分类有用得多。

形态数据保留到什么时候读者之间的关系典型用途
队列(queue)被一个消费者取走并确认后删除竞争,一条消息只给一个人任务分发、工单、命令
发布订阅(pub/sub)所有订阅者都收到后,按订阅策略删除广播,每个订阅者一份通知、缓存失效、事件广播
日志(log)按保留策略删除,与谁读过无关各自持有游标,互不影响事件流、变更记录、重放
流(stream)底层通常还是日志,计算状态另有存储持续计算,窗口与状态实时聚合、流式 ETL

这四种不是互斥的产品分类。很多系统同时支持多种形态,区别在于默认语义。Kafka 的默认语义是日志:消息追加到末尾,读者按位移(offset,首次出现可括注为「偏移量」)顺序读,读完不删除,删除由保留策略(retention)决定。

这里有两个容易混淆的点。

第一,「日志」不是「日志文件」的意思。它是分布式系统里的 log:只能追加、按位置索引、不可原地修改的记录序列。它可以是文件,也可以是内存里的段,也可以是复制到多台机器上的一致序列。Kafka 把它落在磁盘上,但概念上先于存储。

第二,「流」不是第四种存储。流处理是架在日志之上的一层计算:它读日志、维护状态、再写回另一个日志。把「流」和「日志」并列成两种存储,会在后面讨论 Kafka Streams 时产生错误预期。本书卷三会展开这一层,这里只需要记住:日志是底座,流是用法。

队列和日志的真正差别

队列的核心状态是「谁还没处理」。 broker 需要知道每条消息被谁取走、是否确认、超时后是否重新投递。这些状态随消费者数量增长,也随未确认消息数量增长。

日志的核心状态只有两个数字:写到了哪个位置(log end offset),以及每个读者读到了哪个位置(consumer position)。broker 不需要知道「这条消息处理完没有」,因为它不负责删除;它只需要在追加时递增位置,在读请求时按位置返回一段连续记录。

这个差别带来三个直接后果:

  1. 读者数量不影响 broker 的存储状态。十个消费组读同一个 topic,broker 只多存十个小数字,不多存十份消息。
  2. 重放是免费的。把游标拨回去再读一遍就行,不需要重新生产。
  3. 删除变成一件独立的事。删除不再由「消费完成」触发,而由时间或大小触发。这既是优点(可重放),也是责任(磁盘要自己算)。

代价也同样直接:你不能只删掉「那一条处理失败的消息」;消费者必须自己记住进度;读取是拉取而不是推送;保留期一过,没读到的消息也会消失。这些代价会在 2.3 节和卷二里反复出现。

把「消费不删」跑出来

理论说到这里就够了,下面做实验。本节使用一个隔离的单节点 KRaft 实例,宿主机端口 19201,容器内 broker 端口 9092。启动命令与前言里的单节点底座一致,只改了对外端口:

docker run -d --name mq-a1 -p 19201:19201 \
  -e KAFKA_NODE_ID=1 \
  -e KAFKA_PROCESS_ROLES=broker,controller \
  -e KAFKA_LISTENERS='PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093,EXTERNAL://0.0.0.0:19201' \
  -e KAFKA_ADVERTISED_LISTENERS='PLAINTEXT://127.0.0.1:9092,EXTERNAL://127.0.0.1:19201' \
  -e KAFKA_LISTENER_SECURITY_PROTOCOL_MAP='CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,EXTERNAL:PLAINTEXT' \
  -e KAFKA_INTER_BROKER_LISTENER_NAME=PLAINTEXT \
  -e KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER \
  -e KAFKA_CONTROLLER_QUORUM_VOTERS='1@127.0.0.1:9093' \
  -e KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 \
  -e KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=1 \
  -e KAFKA_TRANSACTION_STATE_LOG_MIN_ISR=1 \
  apache/kafka:4.3.0

等 25 秒,容器日志的汇总块是干净的:

Log Summary
   [error] 0 errors (0 unique)
   [warn] 0 warnings (0 unique)
   [info] 17 info messages

建一个只有 1 个分区的 topic:

docker exec mq-a1 /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server 127.0.0.1:9092 \
  --create --topic a1.log-shape --partitions 1 --replication-factor 1
Created topic a1.log-shape.

用一个最小生产者按顺序发 5 条记录,key 是 book-0 到 book-4:

Properties p = new Properties();
p.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "127.0.0.1:19201");
p.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
p.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());

try (KafkaProducer<String, String> producer = new KafkaProducer<>(p)) {
    for (int i = 0; i < 5; i++) {
        String key = "book-" + i;
        RecordMetadata md = producer.send(
                new ProducerRecord<>("a1.log-shape", key, "借阅创建:" + key)).get();
        System.out.println("sent key=" + key + " p=" + md.partition()
                + " off=" + md.offset() + " ts=" + md.timestamp());
    }
}

本机实测(Temurin 25.0.5+8 / Kafka 4.3.0 KRaft 单节点 / 2026-10-29):

sent key=book-0 p=0 off=0 ts=1793247770364
sent key=book-1 p=0 off=1 ts=1793247770515
sent key=book-2 p=0 off=2 ts=1793247770526
sent key=book-3 p=0 off=3 ts=1793247770537
sent key=book-4 p=0 off=4 ts=1793247770548

本节涉及的等待时间与耗时都是单次运行,随机器调度波动,不作基准。

注意 off 是单调递增的,而且 producer 拿到的是 broker 返回的真实位移。单分区下它就是从 0 开始的连续整数。多分区时每个分区各有一套位移,1.2 节会看到。

现在用两个不同的消费组,从最早的位置各读一次。第一个组读完全部 5 条,第二个组只读 2 条:

docker exec mq-a1 /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server 127.0.0.1:9092 \
  --topic a1.log-shape --group a1.group-a --from-beginning \
  --max-messages 5 \
  --formatter-property print.partition=true \
  --formatter-property print.offset=true
Partition:0	Offset:0	借阅创建:book-0
Partition:0	Offset:1	借阅创建:book-1
Partition:0	Offset:2	借阅创建:book-2
Partition:0	Offset:3	借阅创建:book-3
Partition:0	Offset:4	借阅创建:book-4

第二个组用不同的 group id,只读 2 条:

docker exec mq-a1 /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server 127.0.0.1:9092 \
  --topic a1.log-shape --group a1.group-b --from-beginning \
  --max-messages 2 \
  --formatter-property print.partition=true \
  --formatter-property print.offset=true
Partition:0	Offset:0	借阅创建:book-0
Partition:0	Offset:1	借阅创建:book-1

到这里,关键问题来了:group-a 已经读完了 5 条,数据还在吗?用 --describe 看两个组的进度:

docker exec mq-a1 /opt/kafka/bin/kafka-consumer-groups.sh \
  --bootstrap-server 127.0.0.1:9092 --describe --group a1.group-a
GROUP           TOPIC           PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG             CONSUMER-ID     HOST            CLIENT-ID
a1.group-a      a1.log-shape    0          5               5               0               -               -               -
GROUP           TOPIC           PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG             CONSUMER-ID     HOST            CLIENT-ID
a1.group-b      a1.log-shape    0          2               5               3               -               -               -

两行合起来说明三件事:

  • 日志末尾(LOG-END-OFFSET)是 5,没变;
  • group-a 的进度是 5,lag 为 0;
  • group-b 的进度是 2,lag 为 3,但它没有「拿走」后 3 条,只是还没读到。

如果删除由消费触发,那么 group-a 读完的一瞬间,group-b 就永远读不到那 5 条了。事实并非如此。日志段文件还在,而且大小没变:

-rw-r--r-- 1 appuser appuser 10485760 Oct 29 04:22 00000000000000000000.index
-rw-r--r-- 1 appuser appuser      465 Oct 29 04:22 00000000000000000000.log
-rw-r--r-- 1 appuser appuser 10485756 Oct 29 04:22 00000000000000000000.timeindex
-rw-r--r-- 1 appuser appuser        8 Oct 29 04:22 leader-epoch-checkpoint
-rw-r--r-- 1 appuser appuser       43 Oct 29 04:22 partition.metadata

465 字节的 .log 里就是那 5 条记录。用 kafka-dump-log.sh 直接读段文件,不经过任何消费者:

docker exec mq-a1 /opt/kafka/bin/kafka-dump-log.sh \
  --files /tmp/kafka-logs/a1.log-shape-0/00000000000000000000.log \
  --print-data-log

本机实测输出(每个批次一行元数据,下面保留了记录行):

Log starting offset: 0
| offset: 0 CreateTime: 1793247770364 keySize: 6 valueSize: 19 sequence: 0 headerKeys: [] key: book-0 payload: 借阅创建:book-0
| offset: 1 CreateTime: 1793247770515 keySize: 6 valueSize: 19 sequence: 1 headerKeys: [] key: book-1 payload: 借阅创建:book-1
| offset: 2 CreateTime: 1793247770526 keySize: 6 valueSize: 19 sequence: 2 headerKeys: [] key: book-2 payload: 借阅创建:book-2
| offset: 3 CreateTime: 1793247770537 keySize: 6 valueSize: 19 sequence: 3 headerKeys: [] key: book-3 payload: 借阅创建:book-3
| offset: 4 CreateTime: 1793247770548 keySize: 6 valueSize: 19 sequence: 4 headerKeys: [] key: book-4 payload: 借阅创建:book-4

这就是「消费不删数据」的硬证据:两个消费组都读过,段文件里 5 条一条不少。.index 是 10 MB、.timeindex 是 10 MB,不是因为记录多,而是它们预分配了固定大小;这一点在 2.2 节会展开。

位移是读者自己的进度

理解了上面那张 --describe,位移的定义就自然了:位移不是消息的属性,是某个消费组在某个分区上的读取位置。

同一条 offset=2 的记录,对 group-a 来说是「已经读过的第 3 条」,对 group-b 来说可能是「下一条要读的」。它们各自在 __consumer_offsets 里有一份提交记录,互不覆盖。Kafka 不会因为 group-a 读到了 5,就把 group-b 的游标也推到 5。

这里要分清两个词,后面会反复用到:

概念存在哪谁维护什么时候变
消费位置(position)消费者内存客户端每次 poll() 返回记录后前移
提交位移(committed offset)__consumer_offsets客户端提交commitSync / 自动提交时写入

自动提交开启时,这两者会短暂不一致:你可能已经处理到 offset=100,但 broker 里记录的还是 80。进程这时崩溃,重启后从 80 开始读,81 到 100 就会被重复处理。这不是 Kafka 的 bug,而是「读者自己管进度」这个设计的直接后果。卷二第 3 章会把重复的来源逐条拆开。

还有一个推论常被忽略:同一个消费组内,一个分区同时只能有一个消费者。这不是限制,而是「组内进度只有一个数字」的必然结果。两个消费者同时读一个分区、各自提交不同位移,broker 无法判断哪个才是组的进度。1.2 节讲分区与并行度时,这一点会变成「消费者数量超过分区数就会有人闲着」。

日志形态买到了什么,付出了什么

把 Kafka 当成日志,换来的能力有四条:

  1. 重放。把位移拨回去,或者换一个新消费组从最早读,历史事件可以重新处理。重建读模型、补一个新下游、排查昨天的数据,都靠这个。
  2. 多读者独立。同一份数据可以同时喂给实时链路、离线链路、审计链路,互不影响,也不需要 broker 复制多份。
  3. 写入与读取解耦。生产者只看追加位置,消费者只看自己的游标,两边不需要知道对方的存在。这是 Kafka 能扛住大量消费者组的结构性原因。
  4. 顺序的边界清晰。日志只承诺单分区内有序,跨分区没有全局序。这个「不承诺」反而让并行成为可能。

付出的代价也有四条:

  1. 不能按条删除。处理失败的那一条会一直留在日志里,直到保留期结束。要「删掉它」,只能写一条同 key 的墓碑记录,让 compact 去处理(2.3 节)。
  2. 存储上限由保留策略决定,不由消费速度决定。消费者停了,日志照写,磁盘照涨。积压不是「队列变长」,是「保留窗口内的数据都还在」。
  3. 读取是拉取。没有消息时消费者要等(长轮询),低延迟场景要调 fetch.min.bytes 与 fetch.max.wait.ms,而不是等 broker 推送。
  4. 「消费完成」不再是一个 broker 知道的事实。业务是否处理成功,只能靠提交位移来表达;提交早了会丢,提交晚了会重。这个取舍贯穿整本书。

所以「Kafka 适不适合」这个问题,可以先换成:「我的场景需不需要重放和多读者?如果不需要,队列的删除语义是不是更省心?」需要重放、需要多个下游、需要按顺序回放历史,日志形态就是对的;只是一批任务分给几个 worker,队列形态更直接。

一个常见的误读

「Kafka 消费完不删数据,所以它会把磁盘写爆。」

这句话把「不删」和「不回收」混为一谈了。Kafka 不按消费删除,但按保留策略删除。默认的 cleanup.policy=delete 下,段文件超过 retention.ms(默认 7 天)或 retention.bytes 就会被整段删除;cleanup.policy=compact 下,同 key 的旧值会被清理,只保留每个 key 的最新值。两种策略都不看消费者读到哪。

这也解释了一个运维现象:消费者停了很久,重启后不一定能从最早读。如果停机时间超过了保留期,最早那段已经被删了,auto.offset.reset=earliest 也只能从当前最早的可用位移开始。数据不是被「消费」掉的,是被「保留期」清掉的。

2.3 节会把这两条策略的实测做出来:compact topic 里同 key 写多次,最后只剩最后一条;墓碑记录在 delete.retention.ms 之后连自己也会消失。这里先记住判据。

本节判据

合上文章之前,试着回答三个问题:

  1. group-a 读完了 5 条,group-b 只读了 2 条,为什么 group-b 还能读到 offset=2、3、4?——因为读取不改变日志,只改变 group-b 自己的游标。
  2. 如果此时把 retention.ms 改成 1 秒,会发生什么?——段文件会在保留检查周期后被删除,两个组都读不到,和它们读到哪无关。
  3. 为什么同一个消费组里,两个消费者不能同时读同一个分区?——因为组内进度是一个数字,两个人同时推进它就无法定义「组读到哪」。

如果这三问答得出来,你已经把「日志」和「队列」的差别握在手里了。下一节把「一个日志」扩展成「多个分区」,看看并行度和顺序是怎么互相挤压的。

阅读导航:上一节 目录与学习路径 | 下一节 1.2 分区:顺序与并行度的交换

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收