一个按订单处理的消费服务,分区键选了 order_id。某天大促,几个头部商家的订单量暴涨,对应的分区消费延迟到几十分钟,而其它分区几乎没有积压。加消费者没有用——分区数固定,多余的消费者只能空转。
分区键是整个消息架构里最不可改的一个决定:它决定顺序、决定热点、决定扩容上限。而它通常在项目早期、用一行代码定下来,之后再也没人看过。
1. 分区键决定了三件事
| 决定 | 机制 |
|---|---|
| 顺序范围 | 同一键的消息进同一分区,因此有序 |
| 负载分布 | 键的分布决定分区分布 |
| 扩容上限 | 消费者数不能超过分区数 |
这三个决定里,第一个在写代码时被考虑得最多,后两个几乎从不讨论。而它们恰恰是不可逆的部分:顺序需求可能变,负载分布一定会变,分区数改起来最贵。
第一项是分区键存在的理由:需要"同一实体的事件有序",就把该实体的标识作为键。第三项是它的隐藏约束:分区数一旦设定,消费者并行度的上限就定了。
2. 顺序需求的三个层次
| 需求 | 需要的键 |
|---|---|
| 全局有序 | 单分区(等于放弃并行) |
| 按实体有序 | 实体 ID(order_id、user_id) |
| 无序 | 随机或轮询 |
还有一个常被忽略的中间层:按"父子实体"有序。子订单必须跟主订单同分区,否则两者的状态可能错乱。这时键应当选父 ID 而不是子 ID,代价是同一父下所有子消息串行处理。
多数业务真正需要的是第二层,而不是全局有序。误以为需要全局有序的团队,会得到一个只能单线程消费的管道,然后花很多时间试图优化它。
// 判断顺序需求的问法
Q: 两条消息乱序处理,会造成什么后果?
无影响 → 无序,随机键
同一订单的状态算错 → 按 order_id 分区
全局计数错乱 → 需要单分区或改用外部序号
3. 热点的三种来源
| 来源 | 机制 | 缓解 |
|---|---|---|
| 实体热度不均 | 少数 ID 数据量极大 | 拆键(加后缀)或分流 |
| 键的选择过粗 | 用 country 这类低基数字段 |
换更高基数的键 |
| 键分布倾斜 | 哈希不匀或键值本身偏斜 | 加盐后哈希 |
倾斜可以用一个很简单的检查发现:统计每个键的消息量分布。如果 Top 1% 的键占了 30% 以上的量,热点已经存在,无论当前有没有表现出来。
// 从生产日志或抽样统计键的分布
zcat app.log.gz | jq -r '.partition_key' | sort | uniq -c | sort -rn | awk '{a[NR]=$1; s+=$1} END {print "Top1% 占比:", a[1]*100/s"%", "总键数:", NR}'
// Top1% 超过 30% → 分区分布必然倾斜,扩容前先解决热点
第一项在"头部客户"明显的业务里最常见:20% 的实体承载 80% 的消息量,于是对应的分区成为瓶颈。
// 拆键:把热点实体的消息分散到 N 个子键,牺牲部分顺序换吞吐
String partitionKey = isHot(orderId) ? orderId + "#" + (seq % 8) : orderId;
// 代价:同一订单的消息可能进不同分区,需在消费侧按 orderId 聚合
// 前提:业务能接受"同一订单内的小范围乱序"
4. 扩容的两种做法与代价
| 做法 | 效果 | 代价 |
|---|---|---|
| 加消费者(≤ 分区数) | 提升消费并行度 | 受分区数上限 |
| 增加分区数 | 提升上限 | 键到分区的映射改变,顺序被打乱 |
| 新建 topic(更多分区) | 干净扩容 | 需要迁移与双消费 |
分区数定多少也有个经验算法:按未来两三年的峰值吞吐 ÷ 单分区处理能力,再向上取个整数。定得太小会反复扩容(每次都破坏顺序),定得太大只是元数据与文件句柄的开销——后者远比前者便宜。
第二项的代价最容易被低估:加分区之后,同一个键可能落到不同分区,于是"同一订单有序"这个前提在扩容瞬间失效。正在处理中的消息可能被两个消费者同时处理。
// 加分区的影响范围
加分区前:order_id=123 → partition 3
加分区后:order_id=123 → partition 7
结果:切换瞬间,partition 3 里的旧消息与 partition 7 里的新消息并行处理
所以正确做法是:要么一开始就把分区数定得足够大(留出数年余量),要么在业务低峰做一次完整的迁移。
扩容还有一个容易被忽略的前置条件:分区再平衡会暂停消费。消费者组发生变化时,分区在成员间重新分配,这段时间内该组停止消费。分区越多、成员越多,重平衡越慢,短暂的重平衡风暴会直接表现为积压。
5. 分区数与消费者数的关系
| 关系 | 结果 |
|---|---|
| 消费者 < 分区 | 部分消费者承担多个分区,可扩展 |
| 消费者 = 分区 | 一对一,无空闲 |
| 消费者 > 分区 | 多余消费者空转,浪费 |
这里有个有用的经验:消费吞吐的瓶颈经常不是消费者数量,而是下游写入能力。十个消费者同时写一个库,实际吞吐与两个消费者差不多,只是把压力更均匀地摊给了数据库。
第三种是最常见的浪费:以为"加消费者能提升吞吐",加了之后发现吞吐不变,因为并行度被分区数卡住了。
// 观察分区级积压,判断瓶颈在分区还是消费者
kafka-consumer-groups --describe --group order-worker \
--bootstrap-server localhost:9092
// 若某几个分区 LAG 持续增长而其它为 0 → 热点,不是消费者不足
// 若所有分区 LAG 同步增长 → 消费者不足或下游慢
6. 排错:五种"消费跟不上"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 少数分区积压 | 热点键 | 拆键或按实体分流 |
| 全部积压 | 消费者不足或下游慢 | 扩消费者、查下游 |
| 扩消费者无效 | 消费者数已达分区数 | 加分区(需迁移) |
| 重复处理 | 加分区导致键映射变化 | 幂等 + 迁移期双消费 |
| 顺序错乱 | 键选择与顺序需求不匹配 | 改用实体 ID 作键 |
第四项与"加分区"直接相关:切换期间新旧分区映射并存,同一键的消息可能被两边处理。这类问题只能靠消费端幂等兜住,架构上无法完全避免。
7. 边界:什么情况不必纠结分区键
| 场景 | 为什么 | 做法 |
|---|---|---|
| 消息量小(每秒几十) | 单分区足够 | 单分区或随机键 |
| 完全无序的处理(如发通知) | 无需顺序 | 随机键,最大化并行 |
| 已有外部顺序保证(如单库自增) | 顺序由数据库给 | 分区只做负载均衡 |
| 短生命周期 topic | 无长期扩容需求 | 按当前量定分区 |
还有一条与运维有关的边界:分区数变更应当走变更流程。它影响顺序语义与消费并行度,属于架构级参数,而不是"调大一点没关系"的容量旋钮。把它当成后者,往往在一次低峰顺手做完,然后在下一周收到"数据错乱"的工单。
判断口径一句话:同一实体的两条消息乱序,会不会算错。会,键必须是实体标识;不会,键只需要均匀。
8. 自测
- 分区键决定哪三件事?为什么"消费者数不能超过分区数"是最容易被忘掉的约束?
- 顺序需求的三层分别对应什么键?为什么多数团队其实只需要第二层?
- 热点键的三种来源中,哪一种在头部客户明显的业务里最常见?拆键的代价是什么?
- 为什么"加分区"会破坏"同一实体有序"这个前提?切换瞬间会发生什么?
- 消费者数超过分区数会发生什么?如何用分区级 LAG 判断瓶颈在哪?
- 判断"要不要在分区键上纠结"的那个问题是什么?