《Java 消息中间件·Kafka 内核与客户端》6.1 位移与 __consumer_offsets

位移不是消息的一部分,它被写进一个叫 __consumer_offsets 的内部 topic。本节从段文件里解出真实的 key/value,说明一个组的位移为什么全落在同一个分区,用实测输出区分「消费位置」与「已提交位移」,并把 --describe 的六列和 offsets.retention.minutes 的边界讲清楚。

进度不在消息里

前五章讲的是消息本身:它怎么被写进分区、怎么复制、元数据怎么传播。从这一章开始,视角换到读消息的一方。

Kafka 的消费者不删除消息。消息被读走之后仍然留在日志里,直到保留策略把它清掉。那么「这个消费组读到哪了」这件事,不可能记录在消息上,只能单独存一份。这份单独存下来的东西就是位移(offset)。

位移有两个容易混淆的形态:

  • 消费位置(position):消费者当前读到哪,存在于客户端内存里,poll() 返回一批记录后就前进了。
  • 已提交位移(committed offset):消费者主动告诉服务端「我已经处理到这里」,由服务端持久化。消费者重启、换机器、组内再平衡之后,从这份记录恢复。

这一节要回答三个问题:已提交位移存在哪、以什么结构存、它和消费位置差在哪。全部用本机跑出来的原始输出说话。

__consumer_offsets 是什么

它是一个普通的内部 topic,名字叫 __consumer_offsets。说它普通,是因为你能用 kafka-topics.sh 查看它、用 kafka-dump-log.sh 读它;说它特殊,是因为它的创建不由用户控制,而是第一个消费组提交位移时由协调器自动创建。

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

docker exec mq-b1 /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server 127.0.0.1:9092 --describe --topic __consumer_offsets
Topic: __consumer_offsets	TopicId: rgen7YWqTmOgfgvaARi9gQ	PartitionCount: 50	ReplicationFactor: 1	Configs: compression.type=producer,min.insync.replicas=1,cleanup.policy=compact,segment.bytes=104857600
	Topic: __consumer_offsets	Partition: 0	Leader: 1	Replicas: 1	Isr: 1	Elr: 	LastKnownElr: 
	Topic: __consumer_offsets	Partition: 1	Leader: 1	Replicas: 1	Isr: 1	Elr: 	LastKnownElr: 
	Topic: __consumer_offsets	Partition: 2	Leader: 1	Replicas: 1	Isr: 1	Elr: 	LastKnownElr: 

输出只截了前三行,实际有 50 个分区。三个配置值得逐条读:

  • PartitionCount: 50:由 offsets.topic.num.partitions 决定,默认 50。建好之后不能靠改配置扩容,要扩只能手工做分区重分配。
  • cleanup.policy=compact:这是键值表的存储方式。同一个 key 的新值会覆盖旧值,日志清理后只保留每个 key 的最新一条。位移天生适合这种语义:关心「现在读到哪」,不关心「历史上提交过几次」。
  • ReplicationFactor: 1:本机单节点,启动时用 KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 覆盖了默认值 3。生产环境必须是 3,否则协调器所在 broker 一挂,所有组的位移都不可用。

注意 min.insync.replicas=1 出现在 Configs 里。这是 4.x 给内部 topic 写下的显式配置,和我们在 4.2 节讲的写入门槛是同一套机制。单副本时它只能取 1,没有讨论空间。

一个组的位移落在同一个分区

50 个分区不是按 topic-partition 分散写入的。同一个 group 的所有位移记录都落在同一个 __consumer_offsets 分区,分区号由组名决定。

本机做法是扫描 50 个段文件,找出含目标组名的分区。命令有点长,但很直接:

docker exec mq-b1 sh -c 'for d in /tmp/kafka-logs/__consumer_offsets-*; do
  f=$(ls $d/*.log | head -1)
  n=$(/opt/kafka/bin/kafka-dump-log.sh --offsets-decoder --print-data-log --files $f 2>/dev/null \
      | grep -c "b1.offsets.group")
  if [ "$n" != "0" ]; then echo "PARTITION $(basename $d) hits=$n"; fi
done'

本机实测输出:

PARTITION __consumer_offsets-22 hits=6

组 b1.offsets.group 的 6 条记录全部在 22 号分区。再拿另一个组 b1.proto.consumer.group 做同样的扫描,结果是 18 号分区。把两个组名代进 Java 的字符串哈希:

b1.offsets.group       hashCode=1463411122  abs%50=22
b1.proto.consumer.group hashCode=-1396975668 abs%50=18

abs(groupId.hashCode()) % 50 与实测分区完全一致。这不是巧合,也不是本节自己发明的公式——两个样本、两个不同分区,足以说明定位规则。工程上的含义很实在:一个消费组的位移读写都压在一个分区、一个协调器上。组特别多时,50 个分区就是位移流量的并行上限;组特别大时,单组的提交频率会先撞到这个上限。

键和值长什么样

kafka-dump-log.sh 有一个 --offsets-decoder 选项,专门按 __consumer_offsets 的格式解码。下面是从 22 号分区解出来的原文(为了可读性,只保留与本节有关的四条):

docker exec mq-b1 sh -c 'f=$(ls /tmp/kafka-logs/__consumer_offsets-22/*.log | head -1);
  /opt/kafka/bin/kafka-dump-log.sh --offsets-decoder --print-data-log --files $f'
| offset: 0 ... key: {"type":"2","data":{"group":"b1.offsets.group"}} payload: {"version":"3","data":{"protocolType":"","generation":0,"protocol":null,"leader":null,"currentStateTimestamp":1793247616118,"members":[]}}
| offset: 1 ... key: {"type":"2","data":{"group":"b1.offsets.group"}} payload: {"version":"3","data":{"protocolType":"consumer","generation":1,"protocol":"range","leader":"console-consumer-32a94a99-9134-4cd1-a906-b572d5626d23","currentStateTimestamp":1793247619126,"members":[...]}}
| offset: 2 ... key: {"type":"1","data":{"group":"b1.offsets.group","topic":"b1.offsets.demo","partition":1}} payload: {"version":"4","data":{"offset":12,"leaderEpoch":0,"metadata":"","commitTimestamp":1793247619231,"topicId":"Rfl4WzuKSVOyZ2jXqNldDQ"}}
| offset: 3 ... key: {"type":"1","data":{"group":"b1.offsets.group","topic":"b1.offsets.demo","partition":0}} payload: {"version":"4","data":{"offset":0,"leaderEpoch":-1,"metadata":"","commitTimestamp":1793247619231,"topicId":"Rfl4WzuKSVOyZ2jXqNldDQ"}}

这张表只有两种 key 类型,含义完全不同:

typekey 里的字段值里存什么谁写入
1group + topic + partitionoffset、leaderEpoch、metadata、commitTimestamp、topicId消费者提交位移
2group组的世代、协议、leader、成员列表协调器维护组状态

type=1 才是位移。key 三元组 (group, topic, partition) 唯一确定一个位移,value 里的 offset 就是已提交位移。本机这次提交的三个分区分别是 12、0、0,和后面 --describe 的输出对得上。

两个细节容易被忽略:

  1. leaderEpoch 会写进去。4.x 的提交格式带 leaderEpoch,用于在 leader 切换后判断位移是否还安全。partition=0 那条是 -1,因为它从没被消费过,提交的是起始位置,没有 leader 世代可记。
  2. topicId 也在 value 里。Kafka 4.x 用 topic UUID 而不是 topic 名字来防「删了重建同名 topic 后旧位移被误用」。key 里仍然保留 topic 字符串是为了兼容旧客户端。

提交位移与消费位置不是一回事

光看段文件还不够直观:位置前进时,服务端的那条记录并不会跟着动。写一个最小程序把两个值同时打出来。

import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.serialization.StringDeserializer;

import java.time.Duration;
import java.util.List;
import java.util.Properties;

public class PosVsCommit {
    public static void main(String[] args) {
        Properties p = new Properties();
        p.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "127.0.0.1:19211");
        p.put(ConsumerConfig.GROUP_ID_CONFIG, "b1.pos.group");
        p.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        p.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        p.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
        p.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
        p.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, "5");

        try (KafkaConsumer<String, String> c = new KafkaConsumer<>(p)) {
            c.subscribe(List.of("b1.offsets.demo"));
            c.poll(Duration.ofSeconds(5));
            dump("poll#1", c);
            c.poll(Duration.ofSeconds(3));
            dump("poll#2", c);
            c.commitSync();
            dump("commit", c);
            c.poll(Duration.ofSeconds(3));
            dump("poll#3", c);
        }
    }

    private static void dump(String tag, KafkaConsumer<String, String> c) {
        for (TopicPartition tp : c.assignment()) {
            OffsetAndMetadata cm = c.committed(java.util.Set.of(tp)).get(tp);
            System.out.printf("%-8s %s position=%d committed=%s%n",
                    tag, tp, c.position(tp), cm == null ? "null" : String.valueOf(cm.offset()));
        }
    }
}

本机实测(Temurin 25.0.5+8 / Kafka 4.3.0 KRaft 单节点 / 2026-10-29),去掉配置日志后只留业务行:

[poll#1] records=5
poll#1   b1.offsets.demo-1 position=0 committed=null
poll#1   b1.offsets.demo-0 position=5 committed=null
poll#1   b1.offsets.demo-2 position=0 committed=null
[poll#2] records=5
poll#2   b1.offsets.demo-1 position=4 committed=null
poll#2   b1.offsets.demo-0 position=6 committed=null
poll#2   b1.offsets.demo-2 position=0 committed=null
[commitSync] done
commit   b1.offsets.demo-1 position=4 committed=4
commit   b1.offsets.demo-0 position=6 committed=6
commit   b1.offsets.demo-2 position=0 committed=0
[poll#3] records=5
poll#3   b1.offsets.demo-1 position=9 committed=4
poll#3   b1.offsets.demo-0 position=6 committed=6
poll#3   b1.offsets.demo-2 position=0 committed=0

这张输出里有三件事:

  • poll#1 和 poll#2 之间,position 变了,committed 还是 null。程序没有提交,服务端就没有任何记录,重启后这个组仍然从 auto.offset.reset 的位置开始。
  • commitSync 之后两者相等。提交动作把当时内存里的 position 写进 __consumer_offsets。
  • poll#3 之后 position 到了 9,committed 还停在 4。这是常态:只要没提交,读得再远也不算数。

「消费位置」描述的是这一次运行的进度;「已提交位移」描述的是可恢复的进度。再平衡、重启、换机器,恢复到的是后者。前面那个 poll#3 如果此时进程被杀,重新分配后消费者会从 4 继续,而不是 9——那 5 条已经处理过的记录会被再读一遍。这是至少一次语义的来源,卷二会展开;本节只需要记住:提交是唯一的持久化动作。

–describe 的六列怎么读

日常看得最多的是这张表。本机对 b1.pos.group 的实测输出:

docker exec mq-b1 /opt/kafka/bin/kafka-consumer-groups.sh \
  --bootstrap-server 127.0.0.1:9092 --describe --group b1.pos.group
Consumer group 'b1.pos.group' has no active members.

GROUP           TOPIC           PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG             CONSUMER-ID     HOST            CLIENT-ID
b1.pos.group    b1.offsets.demo 0          6               6               0               -               -               -
b1.pos.group    b1.offsets.demo 1          4               12              8               -               -               -
b1.pos.group    b1.offsets.demo 2          0               12              12              -               -               -

逐列解释:

列含义读的时候注意
CURRENT-OFFSET已提交位移,读的是 __consumer_offsets 里的值不是消费位置;不提交就永远不动
LOG-END-OFFSET该分区当前日志末端位移,即下一条待写消息的位置只说明写入量,不说明消费量
LAGLOG-END-OFFSET - CURRENT-OFFSET用的是已提交位移,不含「已拉取但未提交」的部分
CONSUMER-ID当前持有该分区的成员没有活跃成员时是 -
HOST成员连接协调器时用的地址默认 bridge 网络里常是 172.17.0.1
CLIENT-ID客户端自报的标识排查「是谁在消费」时比 CONSUMER-ID 好认

这里 p1 的 lag 是 8、p2 是 12,合计 20;而日志末端一共 30 条,已提交 10 条,账对得上。注意 CURRENT-OFFSET 用的是已提交位移,不是消费者内存里的 position。如果消费者正在跑、每秒拉取但不提交,lag 会看起来「不动」,这不代表它没在消费。

还有一个容易误读的地方:--describe 默认只列有位移记录的分区。如果一个组从没提交过,输出可能只有组名和「no active members」,没有任何分区行。想看成员和分配,用 --members --verbose;想看组状态,用 --state。

offsets.retention.minutes 的边界

已提交位移不是永久的。控制它寿命的是 broker 配置 offsets.retention.minutes,本机实测默认值:

docker exec mq-b1 /opt/kafka/bin/kafka-configs.sh --bootstrap-server 127.0.0.1:9092 \
  --describe --entity-type brokers --entity-name 1 --all | grep offsets.retention
  offsets.retention.check.interval.ms=600000 sensitive=false synonyms={DEFAULT_CONFIG:offsets.retention.check.interval.ms=600000}
  offsets.retention.minutes=10080 sensitive=false synonyms={DEFAULT_CONFIG:offsets.retention.minutes=10080}

10080 分钟是 7 天。语义是:当组里没有任何活跃成员,且最后一次提交已经超过这个时长,位移记录会被清理。清理不是实时的,后台任务按 offsets.retention.check.interval.ms(10 分钟)扫描一次,所以实际删除时间会在 7 天之后的某个扫描点。

这里有个值得动手验证的细节。我尝试把它动态改小:

docker exec mq-b1 /opt/kafka/bin/kafka-configs.sh --bootstrap-server 127.0.0.1:9092 \
  --alter --entity-type brokers --entity-name 1 --add-config offsets.retention.minutes=1

本机实测返回:

InvalidRequestException: Cannot update these configs dynamically: [offsets.retention.minutes]

也就是说,这个参数和 4.2 节的 replica.lag.time.max.ms 一样,不能在线改,必须写进 broker 配置后重启。这一点在很多「调参清单」里被写成可直接 --alter,实测不成立。想验证清理行为,只能接受「改配置 + 重启 + 等 10 分钟扫描」的成本;本节没有在本机等待这个窗口,所以不给「多久后消失」的数字。

还有两个相关参数容易混:

  • offsets.topic.segment.bytes(默认 100 MB):内部 topic 的段大小。位移写入量很小,一般很久才滚一次段。
  • offsets.topic.compression.codec(默认 0,不压缩):位移记录的压缩方式。

它们影响的是存储与 IO,不影响「位移保留多久」。

一次完整的复现路径

把本节实验按顺序串起来,命令如下。前提是已经起好单节点 broker,宿主机端口 19211,容器内 PLAINTEXT 端口 9092。注意容器内命令用 9092 而不是 19092:19092 是 EXTERNAL 监听,advertised 成宿主机地址 19211,在容器里连它会拿到一个自己不可达的地址。

# 1. 建一个 3 分区 topic
docker exec mq-b1 /opt/kafka/bin/kafka-topics.sh --bootstrap-server 127.0.0.1:9092 \
  --create --topic b1.offsets.demo --partitions 3 --replication-factor 1

# 2. 写 30 条带 key 的消息
python3 - <<'EOF' | docker exec -i mq-b1 /opt/kafka/bin/kafka-console-producer.sh \
  --bootstrap-server 127.0.0.1:9092 --topic b1.offsets.demo \
  --property parse.key=true --property key.separator=:
for i in range(1, 31):
    print(f"book-{i % 5}:loan-{i}")
EOF

# 3. 用组消费 12 条后退出(退出时会提交)
docker exec mq-b1 /opt/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server 127.0.0.1:9092 --topic b1.offsets.demo \
  --group b1.offsets.group --from-beginning --max-messages 12

# 4. 看提交结果
docker exec mq-b1 /opt/kafka/bin/kafka-consumer-groups.sh \
  --bootstrap-server 127.0.0.1:9092 --describe --group b1.offsets.group

第 3 步的本机输出里有 12 条 loan-N,并且末尾是:

Processed a total of 12 messages

第 4 步在本机得到的表是:

GROUP            TOPIC           PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG             CONSUMER-ID     HOST            CLIENT-ID
b1.offsets.group b1.offsets.demo 0          0               6               6               -               -               -
b1.offsets.group b1.offsets.demo 1          12              12              0               -               -               -
b1.offsets.group b1.offsets.demo 2          0               12              12              -               -               -

消费的 12 条全部落在 p1,所以 p1 的 CURRENT-OFFSET 是 12、lag 是 0;p0 和 p2 从没被这个组读过,提交的是起始位置 0,lag 分别是 6 和 12。这条数据同时说明:位移是按分区记的,不是按组记一个总数。

本节判据

读完这一节,你应该能回答:

  1. 已提交位移存在哪个 topic、什么结构? 答:__consumer_offsets,key 是 type=1 的 (group, topic, partition) 三元组,value 里是 offset、leaderEpoch、metadata、commitTimestamp、topicId。
  2. 为什么一个组的记录都在同一个分区? 答:分区号由组名哈希决定,本机两个组分别落在 22 和 18,和 abs(hashCode) % 50 一致。
  3. position 和 committed offset 差在哪? 答:position 在客户端内存里随 poll() 前进;committed 只有提交才写服务端,是重启和再平衡后恢复的依据。
  4. --describe 的 LAG 用哪个值算? 答:用已提交位移,LOG-END-OFFSET - CURRENT-OFFSET,不含已拉取未提交的部分。
  5. offsets.retention.minutes 能不能在线改? 答:本机实测不能,返回 Cannot update these configs dynamically。

下一节把视角从「位移存在哪」推到「一群消费者怎么分这些分区」。经典再平衡协议的 JoinGroup、SyncGroup、Heartbeat 三个阶段,以及再平衡期间应用线程到底卡在哪一行代码,都会用两个消费者的实测时间线来说明。

阅读导航:上一节 5.3 客户端的元数据刷新 | 下一节 6.2 经典再平衡协议

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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