分区与 Offset
分区不是 Kafka 的"功能",而是它在单机吞吐碰壁后被迫做出的拆分。理解这个拆分的动机,后面的一切——并行度、顺序性、消费模型、Rebalance——都是自然推论。
1. 从一个朴素的问题开始
假设你只有一台 Broker,所有消息都写进同一个日志文件。写入速度取决于磁盘顺序写的速度——机械硬盘大约一两百 MB/s,SSD 更快,但终究有上限。
现在问题来了:你的业务每秒产生 200MB 的消息数据,单台 Broker 还扛得住。但当业务增长到每秒 2GB 呢?
你不能让磁盘转得更快,也不能让 CPU 算得更快。唯一的出路是把数据拆开,分散到多台机器上并行写入。
这就是 Partition 存在的根本原因。它不是 Kafka 的一个"特性",而是分布式系统面对单机瓶颈时的标准答案——就像数据库分表、HDFS 分块一样。
2. 拆分带来了什么
把一个 Topic 拆成多个 Partition,每个 Partition 放在不同的 Broker 上,立刻得到两个结果:
并行度:3 个 Partition 分布在 3 台 Broker 上,生产者可以同时向 3 台机器写入,消费者也可以同时从 3 台机器读取。吞吐量理论上变成 3 倍。
顺序性的局部化:每个 Partition 是一条独立的日志,消息按写入顺序排列,单分区内天然有序。但跨分区之间没有全局时钟——两条消息分别进入 Partition 0 和 Partition 1,你无法判断谁先谁后。
这里有一个关键的设计决策:Kafka 选择了"单分区内严格有序,跨分区无序"。这个选择不是偷懒,而是有意为之——如果要保证全局有序,就必须有一个全局的写入协调点,那就会重新回到单机瓶颈。有序性和并行度是互斥的,Kafka 把选择权交给了用户。
3. Partition 落到磁盘上长什么样
一个 Partition 不是一个文件,而是一个目录。目录里按"日志段"(Segment)分文件:
orders-0/
├── 00000000000000000000.log ← 消息数据
├── 00000000000000000000.index ← Offset → 文件位置的映射
├── 00000000000000000000.timeindex ← 时间戳 → Offset 的映射
├── 00000000000000001234.log
├── 00000000000000001234.index
├── 00000000000000005678.log ← 当前活跃段为什么要分段,而不是整个 Partition 写成一个大文件?两个原因:
删除粒度:Kafka 的数据保留策略是按时间或大小删除旧数据。如果是一个大文件,你没法只删除"3天前的部分"——你得重写整个文件。分段后,过期的整个 Segment 文件直接删掉就行,不需要重写仍需保留的数据。
索引效率:每个 Segment 有自己的索引文件。查找一条消息时,先在 Segment 列表里二分定位目标 Segment,再在 Segment 内的索引里定位文件位置。索引越小,查找越快。
文件名的 20 位数字是该 Segment 的 base offset——这个 Segment 里第一条消息的 Offset。用 Offset 做文件名,是为了在查找时直接通过比较数字定位目标 Segment,不需要打开文件读内容。
4. Offset:分区内的寻址
每条消息在分区日志里有一个从 0 开始递增的整数编号,这就是 Offset。严格说它是消息在日志中的位置偏移量,是分区内的唯一地址,而不一定等价于当前可见消息的连续序号——日志压缩、事务控制记录等都可能让实际读取到的 Offset 出现间隔。
Partition 0:
Offset 0: {orderId: 1001, status: "created"}
Offset 1: {orderId: 1002, status: "created"}
Offset 2: {orderId: 1001, status: "paid"}
Offset 3: {orderId: 1003, status: "created"}Offset 的设计决定了 Kafka 的两个核心能力:
回溯消费:消费者提交的消费位置表示下一条待读取记录的 Offset,比如提交位置是 100,下次从 101 开始读取。想重新消费时,在日志保留范围内把 Offset 重置到更早的位置即可。这是 Kafka 支持"消息回溯"的基础——消费本身不会删除消息,消息能否回溯取决于日志保留策略以及是否启用了日志压缩。
精确定位:消费者说"给我 Partition 0 从 Offset 1234 开始的消息",Broker 通过索引直接定位到文件位置,不需要扫描整个日志。这个查找过程的细节见 日志分段与索引。
5. 消息怎么决定去哪个分区
每条消息发送时,必须有一个规则决定它去哪个 Partition。Kafka Java Producer 默认使用 DefaultPartitioner 分区器,它有三条规则(Kafka 2.4 起对无 Key 消息默认采用粘性分区策略):
1. 你指定了分区 → 直接发到那个分区
2. 你给了 Key → 对 Key 做哈希,取模分区数(分区数不变时,相同 Key 去同一个分区)
3. 你没给 Key → 粘性分区(同一个 batch 内的消息发到同一个分区)规则 2 是最常用的:用业务实体的 ID(如 userId、orderId)作为 Key,保证同一个实体的所有消息都进入同一个分区,从而在单分区内保持顺序。
规则 3 的"粘性分区"是 Kafka 2.4 的改进。之前的做法是 RoundRobin——每条消息轮询到不同分区。问题在于:如果你发 100 条消息到 10 个分区,每条消息单独一个 batch,每个 batch 只有 1 条消息,压缩比极差,网络请求也多。粘性分区让同一个 batch 的消息都去同一个分区,batch 满了再切换,显著提高了发送效率。
6. 分区数怎么选
分区数是一个"选了就很难改"的决策,因为它直接影响并行度和运维成本。
太少:限制了消费并行度。如果你有 3 个分区但 10 个消费者实例,只有 3 个能工作,剩下 7 个空转。
太多:每个分区在 Broker 上对应多个文件(.log、.index、.timeindex),占用文件句柄和内存。更重要的是,分区数增加会扩大集群元数据规模,也会增加 Controller 控制面管理与故障恢复的工作量。
经验公式:
分区数 = max(生产者并发数, 消费者并发数)但这只是起点。你还需要考虑:消费者处理单条消息的耗时、Topic 的消息量增长预期、集群的 Broker 数量。分区数一旦创建就只能增加不能减少——因为已有消息带着各自的 Offset 分布在各分区里,减少分区意味着搬数据、重排 Offset,代价极高。
7. 分区扩展的风险
分区数只能增加。增加分区时有两个风险:
Key 路由变化:有 Key 的消息是按 hash(key) % 分区数 路由的。分区数变了,取模结果就变了——同一个 Key 的新消息可能路由到新分区,而历史消息仍留在原分区、不会被迁移:
扩分区前(4 个分区):
Key=A 的历史消息 → Partition 1
扩分区后(6 个分区):
Key=A 的历史消息 → Partition 1(不迁移)
Key=A 的新消息 → Partition 4(示意,具体取决于哈希与分区器)于是同一个 Key 的消息被拆到两个分区,跨分区消费无法维持它的整体顺序。如果你的业务依赖 Key 的顺序性(比如同一个订单的状态变更必须有序),扩分区会打破这个保证。
Rebalance:分区数变化会触发消费者组的 Rebalance,消费者需要暂停消费、重新分配分区,具体停顿范围取决于分配策略。在高吞吐场景下,这个暂停可能造成短暂的消费延迟。
所以在创建 Topic 时就应预估好分区数,尽量避免后续扩展。
8. 一句话总结
- Partition 是 Kafka 面对单机吞吐瓶颈时的拆分方案,它带来了并行度,代价是顺序性被限制在单分区内。
- Offset 是分区内的寻址机制,它让 Kafka 支持回溯消费和精确定位。
- 分区数是一个需要提前规划的决策,因为它直接影响并行度、运维成本和扩展灵活性。