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 年容量规划分区数(分区多不影响小流量性能,只是元数据开销),避免在线扩容。生产上最常见的是 ③ + 必要时 ①。