《Java 消息中间件·可靠消息与消费治理》10.2 热点分区与 key 设计

单调递增的 id 在 Kafka 默认分区器下其实很均匀,12 000 条实测最大分区只有平均值的 1.04 倍;真正打满一个分区的是范围分区器、单个热 key 和错误的加盐。本篇用六组 12 000 条实验量出 12 倍、10.9 倍、2.85 倍的分布,并给出盐值与分区的落点校验方法。

热点是「一个分区吃掉了大部分流量」

分区是 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 路盐:

方案各分区条数最大/平均最大分区占比
单热 key10896 在 p1010.9090.8%
盐 4 路2852 / 2792 / 2767 / 2733 四峰2.8523.8%
盐 12 路2781 / 1921 / 1875 / 1039 …2.7823.2%
盐 48 路1848 / 1704 / 1417 …1.8515.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 级顺序瞬时集中无状态、可任意并行的任务

三条落地规则:

  1. key 取「需要保序的最小实体」,不要取「方便聚合的最大实体」。用 bookId 而不是 libraryId,用 loanId 而不是 memberId,除非业务确实要求会员级全序。
  2. 不要用单调值配范围分区器。如果必须用范围语义,把范围边界做成动态的,并且监控最后一个分区的写入速率。
  3. 加盐前先算落点,盐表落点必须覆盖目标分区数;否则只是把热点从 1 个分区换成 3 个分区。

常见误解

  • 「key 单调就一定倾斜」:在 Kafka 默认 murmur2 分区器下不成立,实测最大/平均 1.04。
  • 「key 一样就会均匀」:错。一个 key 只对应一个分区,key 越少越容易倾斜。
  • 「加盐越多越均匀」:错。盐值也要哈希,12 路盐可能只落在 8 个分区。
  • 「热点只影响生产端」:错。热点分区的消费 lag、磁盘、段滚动、leader 切换全都集中在一个 broker 上。
  • 「增加分区数能解决热 key」:错。单 key 永远只落一个分区,加分区只改变它落在哪一个。

本节判据

  1. 默认分区器下单调 key 为什么均匀? 答:partitionForKey 用 murmur2 对序列化后的 key 做哈希再取模,雪崩效应打散了相邻 key。
  2. 范围分区器为什么能把单调 id 打满一个分区? 答:id / chunk 把 id 映射成区间,超出预设范围的新 id 全部被 min(..., n-1) 压到最后一个分区。
  3. 加盐 4 路有效、加盐 12 路反而没更好的原因? 答:盐值也要经过哈希,12 个盐只落在 8 个分区,p5 一个分区吃了 3 个盐。
  4. 观测热点最直接的两个命令? 答:kafka-log-dirs.sh --describe 看分区字节数,kafka-get-offsets.sh 看分区末端位移(条数)。
  5. 加盐的代价是什么? 答:同一逻辑 key 的顺序被打散,消费端必须按逻辑 key 归并,lag 与位移也要按多个分区聚合。

阅读导航:上一节 10.1 顺序的粒度与代价 | 下一节 10.3 重分区与历史数据

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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