☰
Kafka分区策略与数据倾斜排查实战:从热点键到消费者Lag
2026/10/14 4:50:42 网站建设 项目流程

前阵子帮一个团队排查Kafka消费积压的问题,现象非常典型:主题开了12个分区,消费组里也启动了12个消费者,结果只有3个消费者忙得不行,剩下9个几乎空转,Lag全部堆积在3个分区上。团队一开始怀疑是消费者分配策略不对,又是改JoinGroup配置又是换Assignor,折腾了一整天没动静。后来我把生产端的消息键拉出来统计了一下,才发现真正的病灶在源头:某几个业务键占掉了90%的流量,而这两个键的哈希值又恰好落在同一个分区上。

这种问题在Kafka日常运维里太常见了。很多人一说“数据不均衡”,第一反应就是“分区策略有问题”,于是赶紧把默认分区器换成自定义实现,结果往往越改越糟。因为分区策略只是数据均衡链路里的一环,消息键的分布、分区数的设定、消费者端分配策略都会影响最终结果。这篇文章就把Kafka分区策略的前因后果、内置实现的取舍逻辑、自定义分区器的边界,以及生产环境里排查数据倾斜的完整思路讲清楚,让后来的人少走几趟弯路。

1. 均衡这个词在Kafka里有三层含义,先分清再动手

1.1 分区策略管的是哪一段路由

一条消息从生产者写入Kafka,到被消费者取走,中间会经过两次“分发”。第一次是生产端把消息写进某个分区,由分区器(Partitioner)决定;第二次是消费端把分区分配给消费者组里的具体实例,由分区分配器(Assignor)决定。很多人讨论分区策略时,实际说的是第一次分发,也就是生产者调用send()的时候,这条消息会被塞到哪个分区里去。

这里的关键点在于:分区器只在生产者的客户端进程里运行,它不做任何存储层面的调度。消息被写进分区之后,后续的存储、冗余、读写都由Broker按分区为粒度处理。所以生产者想通过分区器实现“数据均衡”,能调控的只有消息到分区的映射关系。

到了消费端,均衡的含义又变了。一个消费组里有多个消费者,每个消费者负责一个或多个分区,谁分到哪些分区是由分配策略决定的。默认的RangeAssignor在分区数和消费者数不成比例时,就会出现一部分消费者分到多个分区、一部分消费者分不到分区的情况。所以排查问题前,先要明确自己说的是哪一层的均衡,否则很容易像开头那个团队一样,一层的问题没解决,先去改了另一层的配置。

1.2 生产均衡、存储均衡、消费均衡之间的差别

这三层均衡经常被人混在一起说,但它们的作用范围完全不同,指标也不一样。

生产均衡关注的是消息写入速率在各分区间是否接近。如果某个分区每秒写入10万条,另一个分区每秒只写100条,那生产端一定存在不均匀的键分布或分区策略缺陷。判断方法是看各分区的LogEndOffset增长速率,或者看Broker上每个分区目录的文件增长速率。

存储均衡关注的是分区在磁盘上的最终体积是否接近。理论上,只要生产持续均衡,存储也会均衡。但实际中,消息有保留时间、有压缩策略,不同分区的清理进度可能不同,所以存储分布也会偏离写入分布。

消费均衡关注的是每个消费者实例的处理负载是否接近,直接看各分区的Consumer Lag就行。一个分区Lag持续上涨,另一个分区Lag始终为0,说明消费者侧的处理能力不均衡,或者某个分区对应的业务处理逻辑特别耗CPU。这三层虽然是串联关系,但每一层都需要单独观察,不要用一个指标代替另一个。

2. 内置分区器的行为差异:粘性不一定“均匀”,却更实际

2.1 默认分区器的哈希路由:键到分区的稳定映射

先看Kafka默认分区器的行为。当消息携带Key时,默认分区器会计算一个哈希值,再把哈希值映射到分区上。具体算法是Murmur2哈希,然后把结果与Integer.MAX_VALUE取与,再对分区数取模。代码逻辑大致是:

int partition = (Utils.murmur2(keyBytes) & Integer.MAX_VALUE) % numPartitions;

这样做的效果是:同一个Key永远落到同一个分区,只要分区数不变。这个特性极其重要,因为它保证了同一个Key的消息在分区内保持写入顺序,消费端拿到这个分区后就能按顺序处理同一个业务实体的所有事件。

但也正是这个特性,埋下了数据倾斜的隐患。如果业务键的取值空间本来就不均匀,比如只有0到9这10个键,而分区有100个,那么无论哈希算法多均匀,最终也只会有10个分区被用到,剩下90个分区永远闲置。这种倾斜不是分区器的问题,而是键基数太小导致的必然结果。

2.2 粘性分区与轮询分区:为什么不追求每条都散开

当消息的Key为null时,生产端无法做键哈希,只能随机选分区。早期的Kafka用的是简单的轮询策略,来一条消息换一个分区,一圈一圈轮流写。这样做的确“均匀”,但代价是每条消息都可能单独成一个批次,或者跟不同分区的消息凑不成批量,导致生产者发给Broker的请求数量暴增,吞吐量下降。

所以从Kafka 2.4开始,默认行为改了:分区器会先随机选一个分区,然后连续把一批消息都塞进这个分区,直到一个批次满了或到了linger.ms的时间上限,再随机换到下一个分区。这个策略叫粘性分区(Sticky Partition),现在生产者的DefaultPartitioner和UniformStickyPartitioner底层都是这套逻辑。

粘性分区的聪明之处在于,它牺牲了一点“每条消息都绝对均匀”的理想主义,换来了更高的批次填充率。一个批次能被填满再发送,生产者到Broker的网络往返就少,整体吞吐反而更高。而分区间的写入分布,只要消息量足够多,粘性结果和轮询结果在统计意义上差别很小。

这里要补充一个常见的误区:很多人以为粘性分区会把消息长期固定在一个分区上,导致负载不均。实际上,粘性只是“一段短时间内固定”,一旦批次触发或linger.ms到期,就会重新随机选分区。持续写入的场景下,各分区拿到的消息量会趋于均衡。真正会长期把某个分区“焊死”的,是带Key的消息,因为哈希映射是固定的。

3. 自定义分区策略的边界:热点键与业务路由

3.1 自定义分区器的实现思路与示例

说完了内置策略,再看什么时候真需要自己实现分区器。我的结论是:绝大多数业务不需要自定义分区器,尤其是团队里没有专门维护Kafka基础设施的人时,自己写的分区器往往比默认的更容易出问题。只有两类场景值得考虑:一是有明确业务路由需求,比如同一个租户的数据必须落在同一个分区;二是存在无解的热点键,默认哈希把热点和冷门键混在一起,确实需要特殊处理。

自定义分区器需要实现Partitioner接口,核心就是一个partition()方法。下面是一个给热点键加盐的示例:

public class SaltedHashPartitioner implements Partitioner { private int saltFactor = 4; private static final Set<String> HOT_KEYS = Set.of("hot-product-1024", "hot-user-888"); @Override public void configure(Map<String, ?> configs) { Object factor = configs.get("salted.partitioner.salt.factor"); if (factor != null) { saltFactor = Integer.parseInt(factor.toString()); } } @Override public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) { List<PartitionInfo> partitions = cluster.partitionsForTopic(topic); int numPartitions = partitions.size(); if (keyBytes == null || keyBytes.length == 0) { // key为空时直接返回一个随机分区 return ThreadLocalRandom.current().nextInt(numPartitions); } String keyStr = (String) key; String effectiveKey = keyStr; if (HOT_KEYS.contains(keyStr)) { // 热点键追加随机后缀,把一个键打散到多个分区 effectiveKey = keyStr + "-" + ThreadLocalRandom.current().nextInt(saltFactor); } return (effectiveKey.hashCode() & Integer.MAX_VALUE) % numPartitions; } @Override public void close() { } }

配置生产者时,在ProducerConfig里指定partitioner.class为这个类,并传入linger.ms等参数即可。

这个思路的核心是“加盐”:原本一个热点键只能落一个分区,追加了随机后缀后,同一个热点键的消息会被拆到多个分区,压力分散到不同消费者上。

3.2 加盐散热的取舍:顺序性、重试与可观测性

加盐方案看着简单,但实施前必须想清楚三个代价。

第一是顺序性。同一个键的消息一旦被加盐分散到多个分区,消费端就再也拿不到这个键的全局顺序了。对订单事件、库存变更这类强顺序场景,加盐是致命的。所以加盐前一定要跟业务确认:这个键的消息如果乱序到达,下游能不能接受?如果不能,就不要加盐,要么扩容分区、要么调整键设计。

第二是重试问题。生产者在发送失败重试时,不会重新执行分区器,而是沿用第一次算出的分区号。这意味着,如果你的分区器里用了随机数或外部状态,重试消息和首发消息可能不在同一个分区,也会打乱顺序。所有自定义分区器的实现必须保证:同一条消息在配置相同、分区数不变的条件下,每次调用partition()返回的结果都一致。像上面示例里的热点键加盐,如果生产者因为超时重试,重试消息的随机后缀变了,就会跑到另一个分区,顺序就乱了。稳妥做法是加盐后的键落在哪个分区,最终发送时把这个分区记录下来,或者只在正常发送路径上接受乱序代价。

第三是可观测性。自定义分区器让路由变得不透明,你必须额外记录“每个键实际落在哪个分区”的指标,否则出了事根本无从查起。很多自定义分区器在生产环境里翻车,不是因为路由逻辑写错了,而是因为加了路由逻辑之后,监控体系还停留在默认策略的认知上。

3.3 不要忽略消费端分配策略对均衡的影响

自定义分区器调好了生产端,消费端分配策略不匹配,照样会“局部过载”。Kafka消费端的分配策略有RangeAssignor、RoundRobinAssignor、StickyAssignor和CooperativeStickyAssignor几种。

RangeAssignor是历史默认,它把连续的分区按顺序切块分给消费者。如果有两个消费者、六个分区,它会先给第一个消费者分0到2号分区,再给第二个消费者分3到5号分区。单独看是均等的,但如果不同分区的消息量差异很大,第一个消费者的压力就会明显高于第二个。RoundRobinAssignor把分区一轮一轮轮流分给消费者,比Range更平摊。Sticky系列则在Rebalance时尽量不回滚已有分配,减少分区在消费者之间迁移带来的重复消费。

生产端分区策略管的是“消息写入是否均匀”,消费端分配策略管的是“分区归属是否均匀”,两层必须联合看。当你发现某个消费者一直Lag很高、别的消费者很闲,第一件事不是改分配策略,而是确认是不是高Lag分区本身消息量就大。如果是,改分配策略只是把压力从左肩挪到右肩,真正要做的是让热点键散开到多个分区。

4. 从一次积压问题反推倾斜根因:从Lag到磁盘

4.1 现象:消费组里个别消费者持续高水位

回到开头那个真实场景。某内部系统有一个订单事件主题,12个分区,消费组里12个消费者,每个消费者理论上分到一个分区。问题出现时,3个消费者的Lag涨到几十万,其余9个消费者的Lag始终在几十条附近徘徊。最初怀疑是Rebalance分配不均,但用命令行查看消费组成员后发现,分配是正常的,每个消费者都分到了一个分区。

接着看各分区的写入情况。用kafka-run-class.sh kafka.tools.GetOffsetShell分别查每个分区的当前位点,很快就发现了规律:12个分区里,3个分区的偏移量已经跑到几亿,另外9个分区只有几千万,写入量差了将近一个数量级。这说明生产端的消息分发倾斜得厉害,不是消费端分配策略的问题。

4.2 排查链路:从分区写入速率到消息键分布

第一步,确认倾斜发生在哪个环节。先看生产端各分区的消息速率,再看Broker上各分区目录的磁盘增长速率,两者能对上,基本就排除了消费端干扰。

第二步,抓消息键分布。这是最关键的一步。用Kafka自带的ConsoleConsumer配合--property print.key=true,抽样抓一批消息,用脚本统计每个Key的出现次数。没有现成工具的话,也可以写一个简单的消费者程序,把消息体里某个业务字段抽出来group by。那个团队就是在这里发现问题的:抽样10万条消息里,有两个商品ID的订单量占了接近87%,而这两个商品ID的哈希值又非常不巧地命中了同一个分区。其他分区也有该商品的消息,但量级差了三个数量级。

第三步,确认分区数是否合理。12个分区对于订单量级来说不算少,但由于热点键只命中了少数分区,再多的分区也救不了。到这里结论已经清楚:分区逻辑没有问题,分区器没问题,消费者分配也没问题,是业务键的取值分布先天畸形。

4.3 为什么“默认哈希”在热点键面前无能为力

有人会问:Murmur2哈希不是号称均匀吗?为什么两个热点键会精确地撞在一起?这里要澄清一个概念:哈希算法的均匀性是针对大量随机键统计意义上的分布,它保证不了“我随便选两个业务键就落在不同分区”。尤其当业务键基数较小时,碰撞概率会被显著放大。假设一个主题有12个分区,任意两个键落在不同分区的概率只有11/12,相同分区的概率是1/12。当你的业务里有几百上千个商品ID时,这1/12的碰撞几乎必然会发生在流量最大的几个键之间。

所以排查这类问题的正确姿势不是调整分区器的哈希算法,而是先量化键的流量分布,再决定要不要针对热点键做特殊路由。哈希算法再均匀,也无法把“一个几十万流量的热点键只落在单分区”的局面变均衡,因为单键静态哈希必然只能落到一个分区。要打散单键热点,只能让分区器具备一定的随机化能力,也就是前面提到的加盐方案。而这个方案能否使用,又取决于业务对顺序性的要求。

5. 数据均衡的工程调整:分区扩容、重分配与权衡

5.1 分区只能增加,不能减少,动手前先想清楚

当确认数据倾斜无法单纯靠分区器解决,就需要在分区数上做文章了。Kafka的分区数只能增加不能减少,因为减少分区意味着消息需要物理迁移和重新路由,这个操作目前不被支持。所以扩容前要仔细评估,尤其要考虑到扩容后同一个Key的哈希映射会全部失效。

比如一个主题原来有12个分区,key=userA原本固定在0号分区,扩容到24个分区后,userA可能会映射到7号分区。所有历史消息还在旧分区里,新消息去了新分区,如果消费端依赖“同一个Key全量数据在一个分区”,顺序性和全局视图都会被打乱。这也是为什么分区扩容通常要选在业务低峰期,并且要提前跟下游确认能不能接受短暂的乱序窗口。

扩容操作本身很简单:

kafka-topics.sh --bootstrap-server localhost:9092 --alter --topic orders --partitions 24

执行成功后,新分区自动创建,旧的partition数据不动。但扩容解决不了“单个键占90%流量”的问题,它只能把其他常规键分散得更开。如果某个热点键的流量已经压满单个分区,扩容24个分区后,热点键依然在单个分区上,只不过这个分区占整体下载的比例变小了。想真正散开热点键,还是要走加盐或业务路由。

5.2 用kafka-reassign-partitions均衡副本在Broker之间的分布

很多时候数据倾斜不是“消息量倾斜”,而是“分区副本在Broker之间的摆放倾斜”。比如新上了几台Broker,旧Broker上分区数明显更多,新Broker几乎没有分区,那存储和流量自然不均衡。这种情况要用重分配工具,把部分分区副本从负载高的Broker迁到负载低的Broker上。

先准备一个JSON文件,描述要迁移的分区和目标副本位置:

{ "version": 1, "topics": [ { "topic": "orders", "partition": 0, "replicas": [1, 2, 3] }, { "topic": "orders", "partition": 1, "replicas": [1, 3, 2] } ] }

然后生成一个全量迁移计划,让工具基于当前副本分布和Broker负载做推荐:

kafka-reassign-partitions.sh --bootstrap-server localhost:9092 --topics-to-move-json-file topics-to-move.json --broker-list "1,2,3,4" --generate

工具会输出一份候选方案,检查没问题后再执行:

kafka-reassign-partitions.sh --bootstrap-server localhost:9092 --reassignment-json-file reassignment.json --execute

执行期间随时可以用--verify看迁移进度。这里提醒一句,重分配的本质是数据副本迁移,会占用不小的网络和磁盘IO,尽量避免在业务高峰期执行。迁移过程中如果突然加大负载,反而会引起更激烈的分区不平衡。

5.3 数据源头侧的业务字段改造

有些倾斜问题从存量的键分布来看就已经无解了,比如把用户ID、租户ID这类天然归属字段作为消息键,本身会导致大量消息集中在少数活跃用户上。这时可以从数据源头改造,把“单一业务键”改成“业务键+时间维度”或“业务键+子类型维度”。

比如订单事件,如果下游并不需要全局订单顺序,而只需要按订单维度聚合,那么可以直接用订单ID作为键,订单ID基数非常大,天然分布均匀。如果需要按用户聚合,而部分用户流量极大,可以在生产端先做一个两段路由:先在应用层把用户ID映射到一个中间桶,再以“桶号+用户ID”作为Kafka的消息键。中间桶的数量固定,比如64个,这样每个用户最多只会被分散到64个分区,既能散开热点,又不会彻底破坏顺序视图。

这类改造代价较大,牵扯到生产链路、下游消费逻辑甚至数据模型。但如果你评估后确认热点键会长期存在,源头改造才是最稳妥的解决方案,因为加盐和扩容都只是缓解,并没有改变键基数的根本问题。

6. 长期维护均衡的监控指标与复盘思路

6.1 可量化的均衡指标与判断基线

排查完一轮,还需要把“均衡”变成可监控、可告警的指标,不然下次还会重复踩坑。实践下来,核心指标有这么几个,推荐单独建一张看板。

分区写入速率是最直接的均衡指标。可以按主题维度,把每个分区的每秒消息数画成曲线,理想情况下各分区曲线应该互相贴近。如果某个分区长期明显高于其他分区,比如高出3倍以上,就值得告警。

Consumer Lag分布是所有指标里最能反映用户感知的。不光要看总Lag,还要看Lag在各分区之间的分布。用监控工具把每个分区的Lag拉出来,如果某几个分区的Lag长期不走、别的分区一直为0,说明消费侧或生产侧已经失衡。

分区目录大小用于检查存储均衡。Linux下直接看每个分区对应的目录大小就行,路径一般是/data/kafka/logs/<topic>-<partition>。定期抽查最大值和最小值的比值,如果超过3倍,检查一下是不是有分区写入异常,或者有大量历史消息清理不掉。

6.2 定期复盘:分区策略、消息键、分区数三者联动

我的习惯是每季度做一次顶层复核,重点看三个问题:

消息键的分布是否发生变化。业务会变,去年流量集中在A商品,今年可能集中在B商品。即使哈希算法没变,键的分布变了,数据均衡依然会被打破。

分区数是否还匹配业务增长。分区扩容后,Broker数、副本因子、消费者并行度都要跟着重算。一个常见的错误是只扩了分区数,消费端还是原来那几个消费者,结果消费者之间的负载差异更大了,因为一个消费者可能分到多个新增分区。

自定义分区器有没有被绕过。代码重构时容易把partitioner.class配置丢掉,或者某个新上线的生产者没有继承自定义配置,悄悄回到了默认分区器。这类问题很难靠监控直接发现,最好在配置管理里做统一约束,禁止裸写producer.send()而不显式声明分区策略。

6.3 值班响应的快速定位模板

最后分享一个应急排查的操作顺序,适合一线值班人员遇到“Kafka不均衡”类工单时直接照做。

第一,先查消费组Lag分布,确认哪些分区Lag高、升高速度如何。第二,查这些高Lag分区对应的生产速率,确认是写入倾斜还是消费处理慢。第三步,抽样看消息键分布,确认是否有少数键占据绝对流量。第四步,如果确认是键分布问题,再决定是启动加盐方案、扩容分区还是跟业务方沟通键改造。整个过程不要超过半小时,先把问题定性,再谈优化方案。最忌讳的就是一上来改配置,改完再看效果,这种试错模式在集群规模大了之后成本极高。

实际维护这套体系几年下来,我最大的感触是:Kafka的均衡问题从来不是靠某一个花哨的配置解决的,它更像一个持续调优的过程。分区策略、分区数、消息键设计、消费者分配、监控告警,每一环都要有人负责。只要有一条链路长期不透明,数据倾斜迟早会在流量变大时现出原形。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询