热点是「一个分区吃掉了大部分流量」
分区是 Kafka 并行的最小单位。一个主题有 12 个分区,理想情况下每个分区承担十二分之一;一旦某个分区的消息量、字节数或消费耗时长期显著高于其他分区,它就是热点。
热点至少有三种,处理方式完全不同:
| 类型 | 表现 | 根因 | 典型处置 |
|---|---|---|---|
| key 倾斜 | 某个分区消息数长期是别人的数倍 | 单个或少数 key 占了大部分流量 | 改 key、加盐、二次分区 |
| 分区器倾斜 | 某些分区永远分不到消息 | 自定义分区器把 key 映射到固定区间 | 换分区器或改映射 |
| 瞬时热点 | 总量均匀,但短时间集中在一个分区 | null key 的粘性分区、批次级切换 | 通常不用处理,看窗口 |
本篇的重点是前两种,因为它们会稳定地把一个分区压成瓶颈,而且从主题总吞吐上看不出来。
先纠正一个流行说法
常见的说法是「用自增 id 当 key 会倾斜,因为 id 是单调的」。这个结论在 Kafka 的默认分区器下不成立。
4.3.0 的默认 key 分区逻辑只有一行:
// BuiltInPartitioner.java:329-331
public static int partitionForKey(final byte[] serializedKey, final int numPartitions) {
return Utils.toPositive(Utils.murmur2(serializedKey)) % numPartitions;
}
murmur2 的雪崩效应会把相邻的 key 打散。实测:12 个分区,12 000 条记录,key 从 loan-0 递增到 loan-11999,各分区条数:
[1043, 997, 1000, 974, 1030, 1009, 981, 981, 1044, 966, 989, 986]
max=1044 min=966 avg=1000.0 maxOverAvg=1.04 maxShare=8.7%
本机实测(Temurin 25.0.5+8 / Kafka 4.3.0 KRaft 单节点 / 2026-10-29;单次运行,随调度波动,不作基准)。最大分区只比平均值高 4%,这属于哈希的正常抖动,不是倾斜。
所以在 Kafka 里,「单调 key 会倾斜」这个经验来自别的系统:范围分区的数据库(按 id 区间切分片)、自己写的 id / chunk 分区器、以及把时间戳直接当分区键的设计。把结论搬到 Kafka 默认分区器上,是张冠李戴。
单调 key 真正会倾斜的写法
自定义范围分区器
如果分区器按 id 区间切:
public int partition(String topic, Object key, byte[] keyBytes, Object value,
byte[] valueBytes, Cluster cluster) {
int n = cluster.partitionCountForTopic(topic);
long id = Long.parseLong(key.toString().substring("loan-".length()));
return (int) Math.min(id / 1000, n - 1);
}
这个分区器假设 id 落在 [0, 12000),每个分区一千个。当天的 id 从 12000 开始,Math.min 把全部新消息压到最后一个分区。实测 12 000 条:
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12000]
max=12000 min=0 avg=1000.0 maxOverAvg=12.00 maxShare=100.0%
一个分区 100%,其余十一个空转。这就是「单调 key 打满一个分区」的真实版本:不是 key 单调本身,而是分区器把单调性解释成了区间。范围分区在需要范围扫描的存储里是合理的,在 Kafka 这种按 key 哈希定位的系统里是错配。
单个热 key
更常见的是业务上真有一个 key 特别热。libraryd 构造的场景:一本书成了借阅榜第一,90% 的借阅事件都带同一个 bookId。12 000 条消息里 10 800 条是 book-B1001,其余 1 200 条从 1 200 个冷门 bookId 里随机抽取(会重复抽到同一个 key):
[112, 98, 102, 117, 81, 88, 99, 94, 105, 102, 10896, 106]
max=10896 min=81 avg=1000.0 maxOverAvg=10.90 maxShare=90.8%
单 key 占 90.8%,热点分区是平均值的 10.9 倍。这种倾斜加分区数没用:不管开 12 个还是 120 个分区,这个 key 永远只落在一个分区上。
null key 的瞬时热点
没有 key 时,默认分区器不是「随机一条一个分区」,而是选中一个分区后粘住一批。BuiltInPartitioner.nextPartition 在没有负载统计时按均匀分布挑一个可用分区,然后在写满 stickyBatchSize 字节(默认取 batch.size,即 16 KB)前保持不动;有负载统计时按权重挑。累计分布可能很均匀,但瞬时写入集中在一个分区,对延迟敏感的消费端仍会看到尖刺。
加盐:有效,但不是万能
对热 key 最直接的办法是给 key 加后缀,把一条逻辑 key 拆成多条物理 key。
四组对照
同一批 12 000 条消息,热 key 部分分别加 4、12、48 路盐:
| 方案 | 各分区条数 | 最大/平均 | 最大分区占比 |
|---|---|---|---|
| 单热 key | 10896 在 p10 | 10.90 | 90.8% |
| 盐 4 路 | 2852 / 2792 / 2767 / 2733 四峰 | 2.85 | 23.8% |
| 盐 12 路 | 2781 / 1921 / 1875 / 1039 … | 2.78 | 23.2% |
| 盐 48 路 | 1848 / 1704 / 1417 … | 1.85 | 15.4% |
盐 4 路把 10.9 倍压到 2.85 倍,效果立竿见影。但盐 12 路几乎没有更好,盐 48 路也只到 1.85 倍。原因不是盐不够多,而是盐值本身也要经过同一个哈希。
盐值也要验落点
把 12 个分区的落点算出来:
salt=4 buckets=4 {1=[0], 2=[1], 4=[3], 5=[2]}
salt=12 buckets=8 {1=[0], 2=[1, 8], 3=[6, 11], 4=[3], 5=[2, 7, 9], 7=[5], 8=[10], 10=[4]}
salt=48 buckets=12 {0=[12, 27, 37], 1=[0, 18], 2=[1, 8, 33, 35], 3=[6, 11, 40, 43], 4=[3, 20, 24, 30, 32, 45, 46], 5=[2, 7, 9, 14, 25, 26, 34, 42], 6=[15, 41], 7=[5, 19, 28], 8=[10, 22, 38, 39, 44, 47], 9=[16, 21, 31], 10=[4, 23, 29, 36], 11=[13, 17]}
盐 4 恰好落在 4 个不同分区,是运气;盐 12 撞成 8 个桶,p5 一个分区吃了 3 个盐;盐 48 里 p5 吃了 8 个盐。这就是为什么盐 12 的最大分区仍是 2 781——它相当于把热 key 拆成 3 份,而不是 12 份。
结论很实际:加盐之前,先用同一套 partitionForKey 把盐值的落点算出来,选那些真正分散的盐。 校验代码很短:
// 选出能把热 key 打到不同分区的盐
List<Integer> saltTable = new ArrayList<>();
Set<Integer> used = new HashSet<>();
for (int salt = 0; salt < 1000 && used.size() < partitions; salt++) {
String physicalKey = logicalKey + "#" + salt;
int p = Utils.toPositive(Utils.murmur2(
physicalKey.getBytes(StandardCharsets.UTF_8))) % partitions;
if (used.add(p)) {
saltTable.add(salt);
}
}
这段代码产出的盐表是确定的、可复现的,不依赖随机数。把它配置化,生产者按 salt = saltTable[orderSeq % saltTable.size()] 取盐即可,orderSeq 是每个逻辑 key 自己的递增序号。
加盐的代价
加盐不是免费的,代价全在顺序和消费端:
- 同一个逻辑 key 的顺序被打散。
book-B1001#0到book-B1001#3分布在 4 个分区,四个分区之间没有顺序。只有当下游按bookId归并、并且能接受乱序到达时,加盐才成立。 - 消费端要做归并。 要么一个消费者同时读四个分区并在内存里按
bookId合并,要么下游状态存储按逻辑 key 收敛。 - lag 被拆成多份。 监控上看到的是四个分区的 lag,判断「这个 bookId 积压了多少」需要自己聚合。
- 偏移量不再对应一条业务流。 按 key 回放历史时,四个分区的位移要一起回退。
所以加盐只适用于「顺序可以放松、但吞吐必须保住」的场景。需要严格按 key 有序的(比如账户余额变动),不能加盐。
显式分区与二次分区
如果顺序可以放松,还有比加盐更可控的做法:直接指定分区,绕开哈希。
int shards = 8;
int shard = Math.floorMod(eventSeq, shards); // eventSeq 是每个逻辑 key 的递增序号
String physicalKey = orderId + "#" + shard;
producer.send(new ProducerRecord<>("library.loan.created", shard, physicalKey, event));
每个逻辑 key 的 eventSeq 递增取模,同一个热 key 的事件就在 8 个分区之间轮转;代价是这条 orderId 的全序被拆成 8 条分片流。显式分区的好处是落点完全可预测,不依赖 murmur2 的分布;坏处是分区数写死在代码里,改分区数就要改代码(10.3 节)。它适合「分片数由业务固定」的场景。
注意这里不能用 orderId.hashCode() % shards 取 shard:那样同一个 orderId 永远得到同一个 shard,热 key 一条都没拆开,等于没加盐。
另一条路是二次分区:第一层用 bookId#bucket 打散写入,第二层由一个聚合消费者按 bookId 归并成逻辑流。这本质上是把「排序」从 Kafka 分区搬到应用层,代价是应用要维护状态和窗口。卷三讲流处理时会展开,本节只把它作为一种可选形态。
还有一种常被忽略的处置:把热 key 单独隔离。给头部客户或爆款商品单独建主题、单独扩容消费者,比在共享主题里加盐更容易运维,也不会污染其他 key 的顺序。
怎么观测倾斜
三个层次,从字节到条数再到消费:
每个分区的日志大小
docker exec mq-ord-lab sh -c 'unset JMX_PORT; /opt/kafka/bin/kafka-log-dirs.sh \
--bootstrap-server 127.0.0.1:9092 --describe --topic-list v2c10.hot.hot-key'
本机热点主题的实测(解析 JSON 后按分区排列):
p10 size=251287
p0 size=2739 p1 size=2438 p2 size=2516 p3 size=2863
p4 size=2075 p5 size=2234 p6 size=2470 p7 size=2350
p8 size=2575 p9 size=2519 p11 size=2603
p10 是 251 KB,其余十一个分区在 2.0–2.9 KB 之间,相差约 100 倍。这个指标最直观,但受压缩率、批次大小影响,不能直接当消息条数用。
每个分区的末端位移
docker exec mq-ord-lab sh -c 'unset JMX_PORT; /opt/kafka/bin/kafka-get-offsets.sh \
--bootstrap-server 127.0.0.1:9092 --topic v2c10.hot.hot-key'
v2c10.hot.hot-key:0:112
v2c10.hot.hot-key:1:98
v2c10.hot.hot-key:10:10896
v2c10.hot.hot-key:11:106
v2c10.hot.hot-key:2:102
...
末端位移就是该分区的消息条数(没有事务、没有压缩清理时)。它和上面的字节数一起看,能区分「条数倾斜」和「单条特别大」。
每个分区的 lag
kafka-consumer-groups.sh --describe --group <g> 的 LAG 列,或者 AdminClient 的 listConsumerGroupOffsets 与 listOffsets 相减。分区条数均匀但 lag 倾斜,说明消费速度不均;条数倾斜且 lag 倾斜,才是生产端热点。
告警阈值不要用绝对值。一个可用的起点是:最大分区条数 / 平均条数 > 2 且持续 30 分钟,或最大分区占比超过 2 / 分区数。低于这个量级的抖动多半是哈希噪声。
热点与消费倾斜不是一回事
生产端热点是「某个分区消息多」,消费倾斜是「某个消费者处理得慢」。两者都会让 lag 看起来集中,但处置方向相反:
- 分区条数倾斜、消费者分配均匀 → 生产端 key 问题,改 key。
- 分区条数均匀、消费者 lag 不均 → 消费能力或分配问题,看 4.1 与 4.2 节。
- 两者同时倾斜 → 先解决生产端,否则再均衡的消费组也会被同一个分区拖住。
判断顺序不能反。先换分配器再查 key,往往是白折腾一轮。
上线前先算一遍
热点最便宜的发现时机是上线前。把线上 key 的样本抽出来,用与生产环境相同的分区数算一遍落点,不需要发消息:
Map<String, Integer> keyCounts = loadKeySample(); // 例如最近一天的业务主键
int partitions = 12;
int[] dist = new int[partitions];
for (Map.Entry<String, Integer> e : keyCounts.entrySet()) {
int p = Utils.toPositive(Utils.murmur2(
e.getKey().getBytes(StandardCharsets.UTF_8))) % partitions;
dist[p] += e.getValue();
}
// 最大分区占比超过 2/partitions 就值得重新设计 key
这一步能在写代码之前暴露单热 key、盐表落点、复合 key 前缀相同等问题,成本只有一次采样和一次哈希。
key 设计清单
| key 形态 | 顺序能力 | 倾斜风险 | 适用场景 |
|---|---|---|---|
| 业务实体 id(bookId、memberId) | 同实体有序 | 单实体过热时高 | 默认选择,绝大多数业务事件 |
| 自增 id / 时间戳 | 同 id 有序,但实际无业务意义 | 默认分区器下低,范围分区器下极高 | 仅当日志流水,且确认分区器是哈希 |
| 复合 key(tenantId + entityId) | 同实体有序,租户内可聚合 | 大租户会倾斜 | 多租户系统,需配合租户隔离 |
| 加盐 key(entityId#salt) | 逻辑 key 无序,需要归并 | 取决于盐表落点 | 热 key 且顺序可放松 |
| null key | 无 key 级顺序 | 瞬时集中 | 无状态、可任意并行的任务 |
三条落地规则:
- key 取「需要保序的最小实体」,不要取「方便聚合的最大实体」。用
bookId而不是libraryId,用loanId而不是memberId,除非业务确实要求会员级全序。 - 不要用单调值配范围分区器。如果必须用范围语义,把范围边界做成动态的,并且监控最后一个分区的写入速率。
- 加盐前先算落点,盐表落点必须覆盖目标分区数;否则只是把热点从 1 个分区换成 3 个分区。
常见误解
- 「key 单调就一定倾斜」:在 Kafka 默认 murmur2 分区器下不成立,实测最大/平均 1.04。
- 「key 一样就会均匀」:错。一个 key 只对应一个分区,key 越少越容易倾斜。
- 「加盐越多越均匀」:错。盐值也要哈希,12 路盐可能只落在 8 个分区。
- 「热点只影响生产端」:错。热点分区的消费 lag、磁盘、段滚动、leader 切换全都集中在一个 broker 上。
- 「增加分区数能解决热 key」:错。单 key 永远只落一个分区,加分区只改变它落在哪一个。
本节判据
- 默认分区器下单调 key 为什么均匀? 答:
partitionForKey用 murmur2 对序列化后的 key 做哈希再取模,雪崩效应打散了相邻 key。 - 范围分区器为什么能把单调 id 打满一个分区? 答:
id / chunk把 id 映射成区间,超出预设范围的新 id 全部被min(..., n-1)压到最后一个分区。 - 加盐 4 路有效、加盐 12 路反而没更好的原因? 答:盐值也要经过哈希,12 个盐只落在 8 个分区,p5 一个分区吃了 3 个盐。
- 观测热点最直接的两个命令? 答:
kafka-log-dirs.sh --describe看分区字节数,kafka-get-offsets.sh看分区末端位移(条数)。 - 加盐的代价是什么? 答:同一逻辑 key 的顺序被打散,消费端必须按逻辑 key 归并,lag 与位移也要按多个分区聚合。
阅读导航:上一节 10.1 顺序的粒度与代价 | 下一节 10.3 重分区与历史数据
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。