一个看起来很简单的问题
消息被消费之后,去哪了?
如果你用过传统队列,答案很直接:被取走了,队列里没有了。如果你用过发布订阅,答案也还算直接:每个订阅者拿到一份副本,各自消费完就删。但如果你在 Kafka 里问这个问题,答案会让人愣一下:哪也没去,还在文件里。
这不是实现细节,而是 Kafka 最根本的设计选择。理解这一点,后面所有的概念——位移、消费组、保留期、重放、积压——才有地方挂。所以这一节不急着讲 API,先把「消息系统有几种形状」这件事说清楚,再用一个五分钟能跑完的实验,把「消费不删数据」变成你亲眼见过的东西。
四种形态,四种删除时机
把消息系统按「数据什么时候消失」分类,比按产品名字分类有用得多。
| 形态 | 数据保留到什么时候 | 读者之间的关系 | 典型用途 |
|---|---|---|---|
| 队列(queue) | 被一个消费者取走并确认后删除 | 竞争,一条消息只给一个人 | 任务分发、工单、命令 |
| 发布订阅(pub/sub) | 所有订阅者都收到后,按订阅策略删除 | 广播,每个订阅者一份 | 通知、缓存失效、事件广播 |
| 日志(log) | 按保留策略删除,与谁读过无关 | 各自持有游标,互不影响 | 事件流、变更记录、重放 |
| 流(stream) | 底层通常还是日志,计算状态另有存储 | 持续计算,窗口与状态 | 实时聚合、流式 ETL |
这四种不是互斥的产品分类。很多系统同时支持多种形态,区别在于默认语义。Kafka 的默认语义是日志:消息追加到末尾,读者按位移(offset,首次出现可括注为「偏移量」)顺序读,读完不删除,删除由保留策略(retention)决定。
这里有两个容易混淆的点。
第一,「日志」不是「日志文件」的意思。它是分布式系统里的 log:只能追加、按位置索引、不可原地修改的记录序列。它可以是文件,也可以是内存里的段,也可以是复制到多台机器上的一致序列。Kafka 把它落在磁盘上,但概念上先于存储。
第二,「流」不是第四种存储。流处理是架在日志之上的一层计算:它读日志、维护状态、再写回另一个日志。把「流」和「日志」并列成两种存储,会在后面讨论 Kafka Streams 时产生错误预期。本书卷三会展开这一层,这里只需要记住:日志是底座,流是用法。
队列和日志的真正差别
队列的核心状态是「谁还没处理」。 broker 需要知道每条消息被谁取走、是否确认、超时后是否重新投递。这些状态随消费者数量增长,也随未确认消息数量增长。
日志的核心状态只有两个数字:写到了哪个位置(log end offset),以及每个读者读到了哪个位置(consumer position)。broker 不需要知道「这条消息处理完没有」,因为它不负责删除;它只需要在追加时递增位置,在读请求时按位置返回一段连续记录。
这个差别带来三个直接后果:
- 读者数量不影响 broker 的存储状态。十个消费组读同一个 topic,broker 只多存十个小数字,不多存十份消息。
- 重放是免费的。把游标拨回去再读一遍就行,不需要重新生产。
- 删除变成一件独立的事。删除不再由「消费完成」触发,而由时间或大小触发。这既是优点(可重放),也是责任(磁盘要自己算)。
代价也同样直接:你不能只删掉「那一条处理失败的消息」;消费者必须自己记住进度;读取是拉取而不是推送;保留期一过,没读到的消息也会消失。这些代价会在 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 当成日志,换来的能力有四条:
- 重放。把位移拨回去,或者换一个新消费组从最早读,历史事件可以重新处理。重建读模型、补一个新下游、排查昨天的数据,都靠这个。
- 多读者独立。同一份数据可以同时喂给实时链路、离线链路、审计链路,互不影响,也不需要 broker 复制多份。
- 写入与读取解耦。生产者只看追加位置,消费者只看自己的游标,两边不需要知道对方的存在。这是 Kafka 能扛住大量消费者组的结构性原因。
- 顺序的边界清晰。日志只承诺单分区内有序,跨分区没有全局序。这个「不承诺」反而让并行成为可能。
付出的代价也有四条:
- 不能按条删除。处理失败的那一条会一直留在日志里,直到保留期结束。要「删掉它」,只能写一条同 key 的墓碑记录,让 compact 去处理(2.3 节)。
- 存储上限由保留策略决定,不由消费速度决定。消费者停了,日志照写,磁盘照涨。积压不是「队列变长」,是「保留窗口内的数据都还在」。
- 读取是拉取。没有消息时消费者要等(长轮询),低延迟场景要调
fetch.min.bytes与fetch.max.wait.ms,而不是等 broker 推送。 - 「消费完成」不再是一个 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 之后连自己也会消失。这里先记住判据。
本节判据
合上文章之前,试着回答三个问题:
- group-a 读完了 5 条,group-b 只读了 2 条,为什么 group-b 还能读到 offset=2、3、4?——因为读取不改变日志,只改变 group-b 自己的游标。
- 如果此时把
retention.ms改成 1 秒,会发生什么?——段文件会在保留检查周期后被删除,两个组都读不到,和它们读到哪无关。 - 为什么同一个消费组里,两个消费者不能同时读同一个分区?——因为组内进度是一个数字,两个人同时推进它就无法定义「组读到哪」。
如果这三问答得出来,你已经把「日志」和「队列」的差别握在手里了。下一节把「一个日志」扩展成「多个分区」,看看并行度和顺序是怎么互相挤压的。
阅读导航:上一节 目录与学习路径 | 下一节 1.2 分区:顺序与并行度的交换
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。