2351 字
约 7 分钟
21
S17-A22 · 消息的分区键:顺序、热点与扩容

一个按订单处理的消费服务,分区键选了 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. 自测

  1. 分区键决定哪三件事?为什么"消费者数不能超过分区数"是最容易被忘掉的约束?
  2. 顺序需求的三层分别对应什么键?为什么多数团队其实只需要第二层?
  3. 热点键的三种来源中,哪一种在头部客户明显的业务里最常见?拆键的代价是什么?
  4. 为什么"加分区"会破坏"同一实体有序"这个前提?切换瞬间会发生什么?
  5. 消费者数超过分区数会发生什么?如何用分区级 LAG 判断瓶颈在哪?
  6. 判断"要不要在分区键上纠结"的那个问题是什么?
S17-A22 · 消息的分区键:顺序、热点与扩容
https://www.clxhxhhr.top/posts/6422/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。