Kafka 顺序消息为什么乱了:分区数变化与重试的 3 类原因

我不会起名字322 · 后端 / 算法 / 数据库

📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL

文章目录

  • [Kafka 顺序消息为什么乱了:分区数变化与重试的 3 类原因](#Kafka 顺序消息为什么乱了:分区数变化与重试的 3 类原因)
    • [一、先说清楚 Kafka 的顺序保证边界](#一、先说清楚 Kafka 的顺序保证边界)
    • [二、原因 1:分区数变了](#二、原因 1:分区数变了)
    • [三、原因 2:生产端重试导致的乱序](#三、原因 2:生产端重试导致的乱序)
    • [四、原因 3:消费端并发导致的乱序](#四、原因 3:消费端并发导致的乱序)
    • 五、还有一个坑:异步落库
    • 六、带走物

Kafka 顺序消息为什么乱了:分区数变化与重试的 3 类原因

一、先说清楚 Kafka 的顺序保证边界

一句话:Kafka 只保证分区内有序,不保证 topic 全局有序。

这不是缺陷,是设计取舍------全局有序意味着只能有一个分区、一个消费者,吞吐直接归零。

所以"顺序乱了"这件事,第一步要确认的是:你依赖的顺序,是不是跨分区的?

bash 复制代码
# 看 topic 有几个分区
kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic order-event
  • 消息按 order_id 分区,你要求"同一个订单的事件有序" → Kafka 能满足,只要 key 用对;
  • 你要求"所有订单的事件全局按时间有序" → Kafka 不满足,这是需求设计错了。

如果是后者,解法不是改 Kafka,而是在消费端做业务侧排序(比如按事件时间戳做窗口排序),或者干脆换成单分区 topic(牺牲吞吐)。

二、原因 1:分区数变了

这是最隐蔽的一类。

Kafka 的默认分区器是:hash(key) % partitionCount 。注意分母是分区数。

text 复制代码
分区数 = 3 时:  order_id=100 → hash % 3 = 1  → 分区 1
分区数 = 4 时:  order_id=100 → hash % 4 = 0  → 分区 0

同一个 key 在扩分区前后会落到不同的分区。 这就意味着:

  1. 扩容瞬间,正在途中的消息(旧分区)和新消息(新分区)会分成两拨;
  2. 如果消费者此时正在消费旧分区的一批,新分区的那批可能被另一个消费者更早消费;
  3. 结果就是同一个 key 的两条消息,后发的先被处理。

结论:扩容分区会短暂破坏按 key 的顺序性。 如果你的业务严格依赖顺序,扩分区要在低峰期 + 消费无积压时做,并且接受短暂的顺序抖动。

另外还有一类更简单的:

go 复制代码
// 错误:没指定 Key,走轮询分区
producer.Input() <- &sarama.ProducerMessage{
    Topic: "order-event",
    Value: sarama.StringEncoder(data),
    // 没有 Key → 轮询,同一个订单的消息散到不同分区
}

// 正确:用业务主键做 Key
producer.Input() <- &sarama.ProducerMessage{
    Topic: "order-event",
    Key:   sarama.StringEncoder(orderID),   // 同一个订单永远进同一个分区
    Value: sarama.StringEncoder(data),
}

"顺序乱了"有相当一部分是这个原因------压根没设 Key,消息从一开始就是散的。

三、原因 2:生产端重试导致的乱序

这是第二大类,而且和"可靠性配置"直接冲突。

场景:生产者发了两条消息 A、B(同一个分区),A 发送失败触发重试,B 发送成功。结果 B 先落盘,A 后落盘,顺序反了。

它和这个参数有关:

properties 复制代码
max.in.flight.requests.per.connection=5   # 默认 5

这个参数允许"最多 5 个请求同时在途、不用等前一个的确认"。在途请求多了,重试就可能插队。

旧版本的解法是把它设成 1(串行发送),代价是吞吐大幅下降:

properties 复制代码
max.in.flight.requests.per.connection=1   # 牺牲吞吐换顺序

更好的解法是开启幂等生产者(Kafka 0.11+):

properties 复制代码
enable.idempotence=true
max.in.flight.requests.per.connection=5   # 幂等模式下最多 5,且不会乱序

原理:幂等生产者给每条消息带上序列号(每个分区一个递增 seq),broker 侧按序列号重新排序并去重。所以即使 A 重试后晚到,broker 也会把它放回正确位置,同时把重复的那条丢掉。

这是唯一一个"既不牺牲吞吐、又能保证顺序 + 不重复"的方案。 顺序敏感的场景应该默认开启。

go 复制代码
config := sarama.NewConfig()
config.Producer.Idempotent = true
config.Producer.RequiredAcks = sarama.WaitForAll
config.Producer.Retry.Max = 3

注意:开启幂等后,acks 会被强制成 all,retries 会被强制成大于 0------这是 Kafka 的要求,不是 bug。

四、原因 3:消费端并发导致的乱序

这一类和 Kafka 无关,纯粹是消费者代码的问题:分区内是严格有序的,但你把它并发处理了。

go 复制代码
// 错误:把同一个分区的消息丢进 goroutine 池
for msg := range claim.Messages() {
    go handle(msg)      // 并发 → 处理完成顺序不确定
}

即使 Kafka 保证了分区内顺序,你这么一并发,顺序就没了。

正确的做法是"分区内串行、分区间并行":

go 复制代码
func (h *handler) ConsumeClaim(sess sarama.ConsumerGroupSession, claim sarama.ConsumerGroupClaim) error {
    // 这个方法对每个分区各跑一个 goroutine:分区间是并行的
    for msg := range claim.Messages() {
        // 而这里对单个分区是串行的:顺序得到保证
        if err := handle(msg); err != nil {
            return err
        }
        sess.MarkMessage(msg, "")
    }
    return nil
}

ConsumeClaim 的语义就是"每个分区一个 goroutine",所以默认写法天然就是"分区内串行 + 分区间并行" 。你要做的是别在里面再开 goroutine。

如果单分区处理太慢,正确的扩展方式是:

text 复制代码
单分区慢 → 加分区数(让更多分区并行)→ 加消费者实例
而   不是 → 在分区内开 goroutine

五、还有一个坑:异步落库

最后一类特别隐蔽:消费端按顺序处理了,但下游写入乱了。

go 复制代码
// 顺序处理,但异步写库
for msg := range claim.Messages() {
    go db.Save(parse(msg))    // 写库并发 → 到达顺序不定
}

如果业务依赖"后一条覆盖前一条"(比如状态机 创建 → 支付 → 完成),并发写会让旧状态覆盖新状态。

解法要么串行写,要么在写入时带上版本号/时间戳做乐观锁:

sql 复制代码
UPDATE order SET status = ?, version = version + 1
WHERE id = ? AND version < ?

六、带走物

排查顺序乱了,按这四步走:

  1. 有没有设 Key? 没设就是轮询分区,顺序从一开始就不存在。用业务主键做 Key。
  2. 分区数最近变过吗? 变过的话,扩容瞬间会有顺序抖动,无法完全避免,只能挑低峰期做。
  3. 开了幂等生产者吗? 没开的话重试会乱序。开 enable.idempotence=true,不要靠把 max.in.flight 设成 1 来换顺序。
  4. 消费端有没有在分区内开 goroutine? 有的话去掉,ConsumeClaim 本身已经是"分区内串行 + 分区间并行"。

一句话结论:Kafka 的顺序保证是"分区内 + 幂等生产者开启"这两个前提同时成立才有效的;而消费端要做的恰恰是什么都别做------不要自己再并发。

相关推荐
每天一道题18 小时前
Kafka 是如何工作的?从 Broker、Partition、Offset 到 Consumer Group
分布式·kafka
ClouGence1 天前
还在用命令行管理 Kafka?开源 CloudDM 让操作更简单
数据库·kafka·开源
Sayai1 天前
Kafka 磁盘空间治理实战:retention 保留策略、log.dirs 在线扩容、分区跨盘迁移与不停机整体换盘
大数据·运维·分布式·kafka·消息队列
孤狼GPT1 天前
ChatGPT、Codex排查实录:Kafka消费者日志明明显示处理成功,为什么同一条消息还是被重复消费?
kafka·springboot·codex·chatgptplus·chatgptpro
starzy19901 天前
KafkaProducer 源码深度剖析:batch 发送的条件
大数据·kafka
SL-staff2 天前
JVS私有化部署运维避坑指南:Kafka容灾、ES冷热分离、RabbitMQ可观测性三实战检查项
elasticsearch·kafka·jvs运维·es冷热分离·rabbitmq监控·中间件巡检·sre实践
starzy19902 天前
KafkaProducer 源码深度剖析:支撑高吞吐的数据结构设计
大数据·数据结构·kafka
starzy19903 天前
KafkaProducer 源码深度剖析:RecordAccumulator 封装消息流程
大数据·kafka