43.3 顺序与分区

一句话

消息队列只保证分区内有序,而“哪些消息进同一个分区”由 key 决定——所以顺序不是配置出来的,是用 key 换来的:key 越细(同一实体)顺序越强、倾斜越重;key 越粗(随机)吞吐越均匀、顺序越弱。

顺序的三个层级

层级 保证 代价 实现
全局有序 整个 topic 一个消费者、一个分区 吞吐 = 单分区吞吐,无法水平扩展 单分区 topic / 单消费者
分区内有序 同 key 的消息按写入顺序消费 key 的选择决定倾斜 hash(key) % partitions
无序 吞吐最高 没有顺序保证 轮询/随机分区

🔑 判据:业务需要“同一实体的操作按顺序生效”吗? 需要 → 用实体 ID 做 key(如 order_id、user_id);只要求“最终都处理” → 随机分区,别自找倾斜。

代码:key 决定倾斜,扩容打乱映射

package main

import (
	"fmt"
	"hash/fnv"
	"math"
	"math/rand"
	"sort"
)

const (
	nKeys     = 1000
	nParts    = 4
	alpha     = 1.0 // Zipf:少数 key 占据大部分流量
	hotKeyPct = 0.30 // 最热的那个 key 占 30% 的流量
)

func hashKey(s string) uint32 {
	h := fnv.New32a()
	h.Write([]byte(s))
	return h.Sum32()
}

func main() {
	// ① 构造 Zipf 流量分布:key i 的权重 ∝ 1/(i+1)^alpha
	w := make([]float64, nKeys)
	sum := 0.0
	for i := range w {
		w[i] = 1 / math.Pow(float64(i+1), alpha)
		sum += w[i]
	}
	for i := range w {
		w[i] = w[i] / sum * (1 - hotKeyPct)
	}
	w[0] += hotKeyPct // key 0 是超级热点

	// ② 按 hash(key) % 分区,统计每个分区的流量占比
	load := make([]float64, nParts)
	keysPerPart := make([]int, nParts)
	for i := 0; i < nKeys; i++ {
		p := int(hashKey(fmt.Sprintf("key:%d", i)) % nParts)
		load[p] += w[i]
		keysPerPart[p]++
	}
	fmt.Printf("=== %d 个 key、%d 个分区(key 0 占 %.0f%% 流量)===\n", nKeys, nParts, hotKeyPct*100)
	fmt.Printf("%-8s %-14s %-10s\n", "分区", "流量占比", "key 数")
	for p := 0; p < nParts; p++ {
		fmt.Printf("%-8d %-14s %-10d\n", p, fmt.Sprintf("%.1f%%", load[p]*100), keysPerPart[p])
	}
	sort.Float64s(load)
	fmt.Printf("倾斜度 max/avg = %.2f(理想值 1.00)\n", load[nParts-1]/(1.0/nParts))

	// ③ 扩容:两种规模,看取模要搬多少 key
	movedTo := func(newParts int) int {
		m := 0
		for i := 0; i < nKeys; i++ {
			k := fmt.Sprintf("key:%d", i)
			if int(hashKey(k)%4) != int(hashKey(k)%uint32(newParts)) {
				m++
			}
		}
		return m
	}
	fmt.Printf("\n=== 扩容时 key 的迁移比例 ===\n")
	fmt.Printf("取模 4 → 5(加 1 个分区):%d/%d = %.0f%% 的 key 换分区\n",
		movedTo(5), nKeys, float64(movedTo(5))*100/nKeys)
	fmt.Printf("取模 4 → 8(翻倍):      %d/%d = %.0f%% 的 key 换分区\n",
		movedTo(8), nKeys, float64(movedTo(8))*100/nKeys)
	fmt.Printf("一致性哈希 4 → 5:约 1/5 = 20%%(只迁新节点接管的那段弧)\n")
	fmt.Printf("一致性哈希 4 → 8:约 1/2 = 50%%(新增节点数与原节点数相同)\n")
	fmt.Printf("结论:翻倍扩容时两者差不多;“加一个节点”才是一致性哈希的主场\n")

	// ④ 热点 key 无法靠加分区解决:同一个 key 永远落一个分区
	r := rand.New(rand.NewSource(1))
	_ = r
	fmt.Printf("\n热点 key 的流量 %.0f%% 全部压在 1/%d 的分区上——加分区不会让它分散\n", hotKeyPct*100, nParts)
}

本机实测(macOS,Apple M1,Go 1.27):

=== 1000 个 key、4 个分区(key 0 占 30% 流量)===
分区       流量占比           key 数     
0        15.4%          250       
1        17.5%          250       
2        52.5%          250       
3        14.6%          250       
倾斜度 max/avg = 2.10(理想值 1.00)

=== 扩容时 key 的迁移比例 ===
取模 4 → 5(加 1 个分区):811/1000 = 81% 的 key 换分区
取模 4 → 8(翻倍):      498/1000 = 50% 的 key 换分区
一致性哈希 4 → 5:约 1/5 = 20%(只迁新节点接管的那段弧)
一致性哈希 4 → 8:约 1/2 = 50%(新增节点数与原节点数相同)
结论:翻倍扩容时两者差不多;“加一个节点”才是一致性哈希的主场

热点 key 的流量 30% 全部压在 1/4 的分区上——加分区不会让它分散

读数:

现象 含义
倾斜度远大于 1 哈希只保证“key 数量”大致均匀,不保证“流量”均匀——Zipf 下少数 key 决定分区负载
30% 的热点全压在一个分区 顺序要求(同 key 同分区)与负载均衡天然冲突
4 → 5 时 80% 的 key 换分区 迁移期间同 key 的消息可能在两个分区被消费,顺序被打断

🔑 两个必须接受的结论:① 要顺序就要接受倾斜(同 key 必须同分区);② 顺序保证在扩容时最脆弱——迁移过程中同一个 key 可能短暂落在两个分区,出现“后写的先被消费”。缓解手段是“停写迁移”或“迁移期让消费者按 key 去重/排序”,而不是假装它不会发生。

顺序被破坏的四个来源

来源 机制 解法
生产端重试 + 多连接 重试的消息可能晚于后续消息到达 开幂等生产(保持序列号顺序)
分区扩容 key 映射变化,迁移期跨分区 停写迁移 / 双读过渡
消费端并发 同一分区被多个线程并行处理 单分区单线程,或按 key 哈希到固定 worker
失败重试 失败消息进重试队列,晚于新消息 重试队列也按同一 key 分区

消费位点与状态的快照语义见 25.5 分布式快照 (Chandy-Lamport)。

反模式

反模式 后果
用随机分区却要求“订单状态按顺序” 状态错乱(先收到“已支付”再收到“已创建”)
用 user_id 做 key 却只有 4 个分区 大客户全压一个分区,其他分区空闲
为了均匀把 key 加随机后缀 顺序彻底丢失,且同一实体的消息分散
消费端用线程池处理同一分区 分区内顺序被打乱
频繁扩分区 每次迁移都打断顺序,且消费者要处理“同 key 两分区”
用一个分区保证全局顺序 吞吐被单分区锁死(43.1 的放大与消费能力)

自检

Q1 为什么“同一个 key 一定进同一个分区”,却仍然可能出现乱序?

▸ 答案:因为“进同一个分区”只保证写入分区后的日志顺序,不保证“写入顺序 = 业务发生顺序”。三个破坏点:① 生产端重试,前一条失败重发,后一条已经写入;② 分区扩容,迁移期同一 key 可能落在新旧两个分区;③ 消费端并发处理同一分区的消息。要真正有序,必须同时约束“生产端幂等有序 + 分区不迁移 + 单分区串行消费”。

Q2 一个订单系统用 order_id 做 key,分区数 8、订单量 1 亿/天。这个设计有什么问题?

▸ 答案:order_id 通常近似随机(雪花 ID/自增),分布均匀,倾斜不大——这点没问题。但**“同一订单”的顺序保证了,“同一用户”的顺序没保证**:如果业务要求“用户的订单状态变更按顺序”,就要用 user_id 做 key,而这会把大用户压到一个分区。所以要先明确“顺序的实体是谁”:订单级(order_id)、用户级(user_id)、还是全局。三者的分区数与倾斜代价完全不同。

Q3 分区扩容时怎么保证顺序不被打断?

▸ 答案:三种做法:① 停写迁移(最稳):暂停生产 → 消费者消费完 → 迁移 → 恢复,代价是停机;② 双读过渡:迁移期消费者同时读新旧分区,按 key 去重并按时间戳排序,代价是复杂度;③ 不扩分区:初期按 3–5 年容量规划分区数(分区多不影响小流量性能,只是元数据开销),避免在线扩容。生产上最常见的是 ③ + 必要时 ①。

关联