《后端架构设计·分布式与数据一致性》5.1 分片键、热点与一致性哈希

从订单按 order_id 还是 user_id 分片的具体取舍出发,说明分片键的业务约束与热点识别,给出带虚拟节点的一致性哈希 Go 实现,并用 10 万个订单键实测分布均匀性、虚拟节点降偏斜与 4→5 扩容的迁移比例,附可复现命令、原始输出与扩容判据。

一个订单按什么键分片

arch-lab 的订单表准备从单库拆成 4 个分片。团队里最常见的两种意见是:按 order_id 分,写入一定均匀;按 user_id 分,“我的订单”列表只查一个分片。两边都能说出理由,但真正要先回答的不是“哪个更均匀”,而是“哪些操作必须只靠请求参数就能定位分片”。

下单时,订单号由 ID 生成器先分配,user_id、order_id、商品和金额同时已知。支付回调只带 order_id;客服按订单号查详情;用户按 user_id 查历史订单;运营按 merchant_id 拉当日成交。四个入口里,只有 order_id 和 user_id 是稳定的业务主键,merchant_id 的列表天然跨分片。

错误直觉是“哈希函数均匀,所以分片键随便选”。哈希只能保证键值到分片的映射均匀,不能保证请求分布均匀,也不能把跨键查询变成单分片查询。分片键一旦写进数据,变更成本接近一次全量迁移;这个决定要在建表前用查询清单验证,而不是上线后靠加机器补救。站内 一致性哈希与数据分片 已经介绍过 Range 与 Hash 的算法对比,本节只讨论订单场景下的取舍与可验证的判据。

分片键的三条业务约束

第一条是可路由。分片键必须能从请求里直接得到,或者能由一张稳定的小表反查。order_id 满足前者;user_id 对“我的订单”满足,但支付回调要先按 order_id 找到订单,再得到 user_id,这就多了一次全局索引查询。索引表本身也要分片,故障面反而扩大。

第二条是事务边界。订单、库存预占、支付是三个聚合,库存按 SKU 与仓库归属,订单按用户或订单号归属,它们不可能总是落在同一分片。把 Order 和 InventoryReservation 强行放进同一分片,会让库存分片随订单分片数量一起膨胀;拆开则必须接受跨分片事务,后续用 Saga 或 outbox 处理。分片键的选择不能假装这个边界不存在。

第三条是分布与局部性的取舍。按 order_id 哈希,写入均匀,但用户订单列表要扇出到全部分片;按 user_id 哈希,列表单分片,但大商户、直播促销账号会把请求压到单个分片。两种方案都不是免费的,区别只是把成本放在读放大还是热点上。

候选键写入分布典型查询主要风险适用前提
order_id均匀按订单号查、支付回调用户列表扇出订单号全局唯一且先分配
user_id依赖用户活跃度我的订单、用户维度统计大用户热点用户量足够大且无超级账号
merchant_id高度倾斜商户对账、结算头部商户打爆分片仅适合作为二级分区
(tenant_id, order_id)租户内均匀租户隔离查询大租户仍需二次拆分多租户 SaaS

如果业务同时要求“按订单号直达”和“按用户单分片”,常见做法是以 user_id 为主分片键,另建 order_id → user_id 的全局路由索引。索引可以放在独立的 KV 或路由服务里,但必须接受它与订单数据不一致的窗口,并让查询在索引缺失时回源扫描。这个方案的成本要提前算进延迟预算,而不是等到上线后才发现支付回调多了一次跨服务调用。

热点是请求维度的问题,不是哈希维度

即使 4 个分片各拿到 25% 的订单行,也不代表各拿到 25% 的请求。一个直播间在 10 分钟内产生大量订单,这些订单的 order_id 仍然均匀散在 4 个分片,但库存扣减、优惠券校验、风控查询都围绕同一个 user_id 或 promotion_id。哈希看不见这种集中,因为热点不在分片键上。

因此监控要同时看两层:分片层的 QPS、CPU、慢查询,以及逻辑键层的请求计数。下面的 HotspotWindow 按逻辑键累计窗口内访问量,超过阈值就进入热点清单。它能回答“哪个用户在打爆系统”,而 Distribution 只能回答“每个分片存了多少行”。两者不是替代关系。

识别热点之后的处置顺序通常是:先做请求合并与缓存,避免同一商品的库存查询打到存储;再把超热键单独路由到专用分片或副本;最后才考虑给热点键加后缀拆分。拆后缀会改变键的语义,必须同步修改路由表和历史数据,属于有状态变更,不能当开关用。

一致性哈希解决的是扩容迁移量

取模分片 hash(key) % N 的问题是扩容时几乎全部键都要搬家。4 个分片扩到 5 个,只有约 20% 的键恰好保持 hash % 4 == hash % 5,其余约 80% 需要迁移。一致性哈希把节点和键都映射到同一个环上,扩容一个节点只接管环上相邻的一段,理论上只移动 1/(N+1) 的键;4→5 时约 20%。

虚拟节点解决的是均匀性。每个物理分片只放一个环上位置时,4 个点把环切成 4 段,段长由哈希值决定,可能极度不均;给每个分片放 256 个虚拟节点后,段长被切得更碎,单个分片的份额更接近 1/N。虚拟节点越多,分布越稳,但环上点数、内存和 sort.Search 的比较次数也线性增加。256 是一个常见起点,不是固定答案。

还有一个常被忽略的边界:一致性哈希只保证“搬得少”,不保证“搬得对”。如果分片键本身会更新,例如订单从个人用户转给企业账户,哈希环无法处理归属变化,仍然要双写和迁移。下一节会专门处理这种有状态迁移。

可运行实现

下面的包只依赖标准库,包含环构建、路由、分布统计、迁移计数和热点窗口。Ring 构建后不可变,Route 可以并发调用;每个物理节点放置 replicas 个虚拟节点,环上位置按哈希排序,查找用二分。

// file: internal/platform/sharding/ring.go
// tier: L1
// verify: go test ./internal/platform/sharding/ -count=1
package sharding

import (
	"fmt"
	"hash/crc32"
	"sort"
)

// Node identifies one shard.
type Node string

type point struct {
	hash uint32
	node Node
}

// Ring is an immutable consistent-hash ring. Build it once and route
// concurrently; Route has no mutable state.
type Ring struct {
	points   []point
	nodes    []Node
	replicas int
}

// NewRing places replicas virtual nodes for every shard on a CRC32 ring.
func NewRing(nodes []Node, replicas int) (*Ring, error) {
	if len(nodes) == 0 {
		return nil, fmt.Errorf("sharding: no nodes")
	}
	if replicas <= 0 {
		return nil, fmt.Errorf("sharding: replicas must be positive, got %d", replicas)
	}
	r := &Ring{replicas: replicas}
	seen := make(map[Node]bool, len(nodes))
	for _, n := range nodes {
		if n == "" {
			return nil, fmt.Errorf("sharding: empty node name")
		}
		if seen[n] {
			return nil, fmt.Errorf("sharding: duplicate node %q", n)
		}
		seen[n] = true
		r.nodes = append(r.nodes, n)
		for i := 0; i < replicas; i++ {
			r.points = append(r.points, point{
				hash: hashKey(fmt.Sprintf("%s#%d", n, i)),
				node: n,
			})
		}
	}
	sort.Slice(r.points, func(i, j int) bool {
		if r.points[i].hash != r.points[j].hash {
			return r.points[i].hash < r.points[j].hash
		}
		return r.points[i].node < r.points[j].node
	})
	return r, nil
}

func hashKey(key string) uint32 {
	return crc32.ChecksumIEEE([]byte(key))
}

// Route returns the shard that owns key.
func (r *Ring) Route(key string) Node {
	h := hashKey(key)
	i := sort.Search(len(r.points), func(i int) bool {
		return r.points[i].hash >= h
	})
	if i == len(r.points) {
		i = 0
	}
	return r.points[i].node
}

// Distribution counts keys per shard.
func Distribution(r *Ring, keys []string) map[Node]int {
	out := make(map[Node]int, len(r.nodes))
	for _, k := range keys {
		out[r.Route(k)]++
	}
	return out
}

// MaxDeviation returns the largest distance between a shard's key share and
// the ideal 1/len(nodes).
func MaxDeviation(r *Ring, keys []string) float64 {
	if len(keys) == 0 {
		return 0
	}
	ideal := 1.0 / float64(len(r.nodes))
	dist := Distribution(r, keys)
	var worst float64
	for _, n := range r.nodes {
		d := float64(dist[n])/float64(len(keys)) - ideal
		if d < 0 {
			d = -d
		}
		if d > worst {
			worst = d
		}
	}
	return worst
}

// Moved counts keys whose owner changes between two rings.
func Moved(oldRing, newRing *Ring, keys []string) int {
	moved := 0
	for _, k := range keys {
		if oldRing.Route(k) != newRing.Route(k) {
			moved++
		}
	}
	return moved
}

// HotKey is one logical key observed above the hotspot threshold.
type HotKey struct {
	Key   string
	Count int
}

// HotspotWindow counts accesses per logical key. It catches business hotspots
// that a uniform physical shard key cannot see.
type HotspotWindow struct {
	minCount int
	counts   map[string]int
}

// NewHotspotWindow flags keys with at least minCount observations.
func NewHotspotWindow(minCount int) *HotspotWindow {
	if minCount < 1 {
		minCount = 1
	}
	return &HotspotWindow{minCount: minCount, counts: make(map[string]int)}
}

// Observe adds n accesses for key.
func (w *HotspotWindow) Observe(key string, n int) {
	if n > 0 {
		w.counts[key] += n
	}
}

// HotKeys returns flagged keys by count descending, then key ascending.
func (w *HotspotWindow) HotKeys() []HotKey {
	var hot []HotKey
	for k, c := range w.counts {
		if c >= w.minCount {
			hot = append(hot, HotKey{Key: k, Count: c})
		}
	}
	sort.Slice(hot, func(i, j int) bool {
		if hot[i].Count != hot[j].Count {
			return hot[i].Count > hot[j].Count
		}
		return hot[i].Key < hot[j].Key
	})
	return hot
}

这个实现有三个刻意选择。第一,哈希用 crc32.ChecksumIEEE,只用标准库,确定性好,适合复现实验;它不是抗碰撞哈希,面对恶意构造的键会被引导到同一段环,生产上应换成带随机种子的哈希或加盐。第二,环上的点允许哈希碰撞,排序时用节点名打破平局,保证构建结果确定。第三,NewRing 拒绝空节点、重复节点和非正虚拟节点数,避免一个配置错误悄悄退化成单点。

本机实测:分布、偏斜与迁移比例

测试用 10 万个合成订单键(order-%07d),4 个分片,每片 256 个虚拟节点,然后比较 4→5 扩容前后的归属。命令和原始输出如下:

$ go test ./internal/platform/sharding/ -v -count=1
=== RUN   TestRingDistribution
    ring_test.go:26: shard=shard-0 keys=28139 share=0.2814
    ring_test.go:26: shard=shard-1 keys=22112 share=0.2211
    ring_test.go:26: shard=shard-2 keys=23075 share=0.2308
    ring_test.go:26: shard=shard-3 keys=26674 share=0.2667
--- PASS: TestRingDistribution (0.02s)
=== RUN   TestVirtualNodesReduceSkew
    ring_test.go:46: replicas=1 max_deviation=0.7359
    ring_test.go:47: replicas=256 max_deviation=0.0331
--- PASS: TestVirtualNodesReduceSkew (0.00s)
=== RUN   TestMigrationRatio
    ring_test.go:67: moved=21981/100000 share=0.2198
--- PASS: TestMigrationRatio (0.02s)
=== RUN   TestHotspotWindow
--- PASS: TestHotspotWindow (0.00s)
=== RUN   TestNewRingRejectsInvalidInput
--- PASS: TestNewRingRejectsInvalidInput (0.00s)
PASS
ok  	archlab/internal/platform/sharding	0.430s

读数要分三层看。分布层,4 个分片的份额是 28.14%、22.11%、23.08%、26.67%,最大份额比理想值高约 3.1 个百分点。偏斜层,每个物理节点只放 1 个虚拟节点时,最大偏差是 73.59%;放到 256 个虚拟节点后降到 3.31%,说明虚拟节点确实在切碎环上的长段,而不是只改善平均值。迁移层,4→5 扩容移动了 21981 个键,占 21.98%,与理论值 20% 同量级。

这些数字的边界要说清楚:这是单机、单进程、CRC32、顺序合成键的结果,不是生产集群的分布证明。真实订单号常带时间前缀或由 Snowflake 生成,低比特有规律,分布可能不同;上线前应取一天的真实键样本重跑同样的三个统计量,而不是直接引用本节数字。测试耗时 0.325 秒只说明本机这组数据规模下的运行时间,不代表生产迁移耗时。

扩容判据

分片不是一次性的容量动作,而是一组需要退出条件的决策。下表把本节可测的量转成动作,避免“感觉不均匀就加机器”。

观测判据动作退出条件
分片份额256 虚拟节点下最大偏差 > 10%先查键生成与哈希,不要加节点连续两次采样偏差 < 5%
逻辑键请求单键窗口占比 > 5%缓存、合并、专用分片热点键回落到 2% 以下
扩容迁移量实测移动比例 > 1/(N+1) + 5pp检查环构建、节点名与键集是否变化固定键集复测回到理论区间
查询模式出现按非分片键的范围查询评估全局索引或改 Range 分区查询能收敛到单分片或有限扇出
分片键可变订单归属会从个人转企业停止扩容,先设计双写迁移路由与历史数据同时可回滚

最容易被跳过的是最后一行。哈希环假设键不变;只要分片键会更新,扩容和归属变更就会纠缠在一起。下一节把迁移状态机、双写和路由版本拆开处理,先保证“搬的过程中不丢更新”,再谈“搬得多快”。

常见错误与边界

第一,用可变字段做分片键。用户 ID、商户 ID、订单归属都可能变化;一旦变化,旧数据留在旧分片,新数据写进新分片,查询要么扇出要么读到旧值。分片键应当是创建后不可变的主键。

第二,只看行数均匀,不看请求均匀。分片各 25% 的行不代表各 25% 的 QPS;热点键、慢查询和批量任务都会让某个分片先饱和。监控必须同时覆盖存储分布和逻辑键访问分布。

第三,虚拟节点越多越好。虚拟节点改善均匀性,但增加环上点数、内存和查找比较次数;当副本数从 256 提到 4096,收益通常远小于成本。先用固定键集测最大偏差,再决定副本数。

第四,把一致性哈希当成迁移方案。它只决定“扩一个节点该搬哪些键”,不负责搬的过程;双写、校验、切换、回滚仍然要单独设计。缺少这些步骤时,扩容期间的写入会丢在旧分片。

小结

分片键首先要满足可路由和不可变,其次才是均匀;均匀是键空间的属性,热点是请求空间的属性,两者要分开监控。一致性哈希用虚拟节点把扩容迁移量压到约 1/(N+1),本机 10 万键实测 4→5 迁移 21.98%,256 个虚拟节点把最大偏差从 73.59% 降到 3.31%。这些数字只对本机合成键成立,真实键样本必须重跑。选型时先写查询清单和事务边界,再决定哈希还是 Range;只要分片键可能变化,就必须进入下一节的迁移与双写流程。

阅读导航:上一节:成员变更、快照与线性一致读 · 下一节:再平衡:迁移、双写与客户端路由 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查