栈:Kafka 3.5.1(KRaft,3 broker,8C32G,NVMe SSD)、Spring Kafka 3.1、Java 17、MySQL 8.0 兜底 场景:订单域
order-event(16 分区、RF=3、retention 72h,日均约 920 万条)+ 一个 compacted 的order-state-kv(12 分区)
一、现象:一次「顺手缩分区」的操作,换来 3.5 小时积压和 11% 重复处理
去年 11 月,我们观察到 order-event 有 3 个分区长期只有个位数 QPS(热 key 集中在另外 6 个分区),于是运维同学做了个「优化」:kafka-topics --delete 删掉 order-event,再用同名命令以 8 个分区重建,理由是「反正 retention 只有 72h,重放几天就追平了」。
结果:
- 重建后 10 分钟内,下游订单状态机消费组 lag 从 0 飙到 64 万条,持续 3.5 小时才追平;
- 追平后对账发现 11% 的状态变更被重复处理(业务幂等表挡掉,但通知类下游重复发了约 7 万条);
- 更隐蔽的是
order-state-kv(compacted topic)也被同名重建,重建后 20 分钟内旧日志被清掉,部分 key 的最新状态值直接消失,状态机拿到的 KV 快照回退到两天前的旧版本,触发了 200+ 笔「已支付订单被重新走创建流程」的脏单。
整条链路 Kafka 侧零 ERROR 日志——它只是按新分区数正常写、正常读。
二、根因:分区数是「路由常量」,不是「容量参数」
两层机制叠加,缺一不可:
1)key → partition 的哈希依赖分区数本身。 Kafka 默认分区器是 murmur2(bytes(key)) % numPartitions。分区数从 16 变 8,取模基数变了,同一个 key 的落点大概率改变(16→8 时,原来落在偶数分区 0/2/4… 的 key 有约一半会挪窝)。这不是「部分 key 受影响」,而是全量 key 的路由表被重写。
后果具体化:
- 消费者组里「分区 ↔ 下游本地状态」的绑定假设被打破:我们的状态机消费者按「每个分区的 key 固定落在我这个实例的某个分片表」做本地路由,分区数一变,同一个订单的两条消息先后落到不同分区 → 不同实例 → 不同的本地分片表,先到的那条查不到前置状态,走了「创建」分支——这就是 11% 重复处理与脏单的来源;
- 老分区 0–15 的 committed offset 全部对不上新分区 0–7:分区 8–15 的 offset 记录直接悬空,分区 0–7 的 offset 仍指向旧日志位置,但旧日志已经随 topic 删除被清掉了——按站内那位作者的说法,offsets 本来就是个「带 TTL 的缓存」,这次连它缓存的底稿都没了,只能按
auto.offset.reset重置,于是出现「lag 瞬间归零但数据已丢」和「从 earliest 重放导致重复」两种症状同时出现。
2)compacted topic 的同名重建是「语义级删除」。 cleanup.policy=compact 的价值在于「每个 key 保留最后一个版本」的持久语义,这个语义建立在日志连续存在之上。同名重建 = 旧日志物理删除 + 新日志从 0 开始,compaction 的「最后一个版本」瞬间退化为「重建后写入的最后一个版本」。重建后 20 分钟内 cleaner 把新 segment 压缩完,任何还没追平的 consumer 用 offset=-2 或 offset=0 读,拿到的 KV 视图已经和重建前不可比了——旧 key 的 tombstone/最新值全丢,新 key 只有重建后的写入。这和 compaction 本身「后台批量 GC、swap 前旧记录仍可见」的机制是两回事:前者是语义还在、只是没来得及 GC;后者是语义本身被删库了。
3)为什么「只能增不能减」是协议级约束。 分区号参与了 offset 编码、partition leader 选举、consumer 的 assignment 结构,Kafka 没有「收缩」API。kafka-topics --alter --partitions 只接受更大的值。所以「缩分区」在 Kafka 里从来不存在,唯一合法路径是换名重建 + 迁移。
三、正确的做法:换名、双写、排空、再退役(可复现清单)
第 0 步:先让「删 topic」这个动作本身变得昂贵
# server.properties(KRaft 模式同样生效)
delete.topic.enable=false
配合 CMDB 把 topic 删除操作列入变更评审。我们后来给运维脚本加了硬校验:--delete 前必须传 --confirmed-topic 参数,值必须等于「topic 名 + 当前分区数」,防止手滑。
第 1 步:新建目标 topic,命名带版本
kafka-topics --bootstrap-server prod:9092 --create \
--topic order-event-v2 \
--partitions 8 \
--replication-factor 3 \
--config retention.ms=259200000 \
--config min.insync.replicas=2 \
--config cleanup.policy=delete
注意:compacted topic 同理换名(order-state-kv-v2),不要试图在旧 topic 上「等它自然排空」来复用——见第四节的坑。
第 2 步:生产者双写(或 gateway 转发)
我们用一个 producer interceptor 做双写,失败只记 metric 不阻塞主链路:
public class DualWriteInterceptor implements ProducerInterceptor<String, String> {
@Override
public void onSend(ProducerRecord<String, String> record, RecordMetadata metadata) {
if (record.topic().equals("order-event")) {
asyncProducer.send(new ProducerRecord<>("order-event-v2",
record.key(), record.value(), record.headers()));
}
}
}
双写窗口我们留了 2 个完整业务日(约 1840 万条),用 kafka-consumer-groups --describe --group order-event-consumer 确认旧 topic 全部分区 lag = 0 后才切流。
第 3 步:消费者切组名,不切 offset
新消费组 order-event-v2-consumer 从 earliest 消费 v2;旧组在旧 topic 上只读、不再提交业务副作用,等旧 topic retention 到期(72h)自然清空后下线。compacted 的 KV 快照:v2 上线后由状态机全量重放(从 MySQL 兜底库导出全量 key 重新写入 v2),不做增量追平——因为旧日志的「最后值」和 v2 的「最后值」没有可比性,增量追平只会把脏状态带过去。
第 4 步:退役前做一次「key 落点基线」核对
我们写了个 30 行脚本:取 1000 个真实订单 key,用 murmur2 分别对 8 和 16 取模,确认 v2 的落点分布(8 分区下各分区 QPS 标准差/均值 < 0.25),再确认业务侧「按分区做本地路由」的代码已经改成「按 key 哈希路由」。这一步能提前抓住第二节里 11% 重复的根因。
四、踩过的坑(反直觉清单)
- lag 归零 ≠ 追平,可能是重置。 同名重建后旧 offset 全部失效,consumer 按 reset 策略重新起步,Grafana 上 lag 曲线「瞬间归零」和「catch up 完成」长得一模一样。我们的告警只盯 lag 数值,所以 3.5 小时里监控全绿。后来加了一条:consumer 启动日志里出现
reset offset关键字直接 P0 报警。 - compacted topic 排空 ≠ 可以重建。 有人建议「让旧 compacted topic 等 consumer 读完再删」。不行——compaction 会持续删旧记录,consumer 的「读完」和「读到的最后值」是两回事;你读到的可能是 compaction 后的稀疏视图,把它当全量搬过去,丢的正是最早的那批 key 的最终值。全量重放必须从业务库出发,不能从旧 topic 出发。
delete.topic.enable=false不是银弹。 它只挡AdminClient.deleteTopics,挡不住kafka-topics --delete直连 bootstrap 的脚本路径(我们这次就是这么删的,当时集群该配置还是 true)。所以配置 + 脚本校验 + 评审三层都要有。- 分区数「看起来可以缩」的错觉来自监控。 长期低 QPS 的分区不等于「浪费的分区」——它可能是冷 key 的专属落点。缩分区的正确姿势是先看 key 分布(
kafka-run-class kafka.tools.ExitAfterWritingMetrics或自建埋点),确认是「key 集中、分区闲置」再考虑合并,而合并的载体永远是新 topic。
五、结论与可复用清单
- Kafka topic 的分区数只能增、不能减;「缩分区」= 换名重建 + 迁移,任何同名 delete+create 都是数据事故。
- 同名重建的杀伤面按顺序是:① 全量 key 路由表重写(murmur2 取模基数变了);② committed offset 全部悬空;③ compacted topic 的「每 key 最后值」语义被物理删除。三者都零 ERROR 日志。
- 上线前 checklist:
delete.topic.enable=false+ 脚本硬校验 + 变更评审;迁移走「新名 → 双写 ≥ 2 个业务日 → 旧组 lag=0 再切流 → retention 到期后退役」;compacted topic 的全量重建必须从业务库重放,禁止从旧 topic 增量追平。 - 监控补两条:consumer 启动日志
reset offset关键字告警;key→partition 落点基线脚本纳入发布流程,分区数一变就重跑。
一句话给后来者:在 Kafka 里,topic 的名字是身份,分区数是路由常量,retention 是租约——三者任何一项「顺手改一下」,代价都不是几分钟,而是对账窗口。