36.5 单元化 / Set 化架构

一句话

单元化是把“一个用户的所有请求与数据”锁进一个自闭环的单元,让故障、扩容、灰度都以单元为单位;代价是跨单元的一切(转账、库存、全局 ID、搜索)都要重新设计。

三个诉求 → 单元化怎么满足

诉求 不做单元化的后果 单元化怎么满足
多活的写冲突 同一用户的两次请求落到两个地域,各自写各自的库,库存扣两次、余额对不上 用户与单元静态绑定,同一用户永远单写一个单元,冲突从“算法问题”退化成“路由问题”
爆炸半径 一个机房/一组 DB 挂了,全国用户一起不可用 挂 1/4 单元只影响 25% 用户,其余 75% 完全无感
扩容粒度 加机器要重分片,全量数据搬一遍,搬的窗口里不敢发布 加一个单元只搬 1/(N+1)(4→5 实测 20.31%),且可与在线流量并行

三条里最关键的是第一条:单元化首先是“消除写冲突”的手段,其次才是“隔离故障”的手段。只为隔离而做、不做用户绑定的方案,落地一定变成双写,拓扑账见 36.4 两地三中心与多活。

术语切分:分区 / 分片 / 单元 / 多活

四个词经常被混着用,混用之后设计必然漏层:

术语 切的是什么 解决什么 谁的主场
分区(partition) 单机内 / 单库内的数据(表分区、Range 分区、哈希分区) 单表过大、扫描量、冷热分离 19.1 分区键设计、37.3 分片与分区
分片(sharding) 跨库 / 跨节点的数据 单库容量与写入上限 44.14 分库分表
单元(unit / Set) 数据 + 流量 + 中间件 + 部署的完整闭环 故障隔离 + 扩容粒度 + 单元级灰度 本节
多活(multi-active) 不切东西,是流量的使用方式:多个单元同时接流量 业务连续、地域级故障用户无感 36.4 两地三中心与多活

判据只有一句:只切了数据、没切流量的,叫分片;数据、流量、中间件、部署四件一起切的,才叫单元。 多活是在单元之上做的选择:单元建好了,接不接流量是另一件事。

一个单元的必备件清单

少一件,单元化就退化成“只拆了数据”,而且退化后比不拆更糟(多了一层路由,却没拿到隔离收益):

必备件 作用 少了会怎样
流量路由(按 user_id / tenant_id) 把请求钉在正确单元 退化成随机负载均衡,同一用户来回跳单元,缓存全失效
单元内服务 无状态服务全集(不是“核心服务”) 依赖中心服务 → 中心成了单点,单元白切
单元内 DB(分库) 数据闭环,单元内可强一致 共享一个库 → 隔离为零,只是换了个部署姿势
单元内缓存 / MQ 热点与异步在单元内闭环 跨单元连 Redis/Kafka → 多一跳 RTT,且中心集群故障全域扩散
单元内配置 灰度按单元下发 改配置所有单元同时生效,做不了单元级灰度
单元级监控 指标按单元维度打标 全国均值掩盖单单元异常,故障被“平均”掉

🔑 判断一个团队是不是真做了单元化,只看一件事:拔掉一个单元的网线,剩下的单元里有没有任何一条请求变慢。变慢了,说明还有共享依赖。

路由表:整套架构的单点

三种算法,差别在“扩容时搬多少”和“能不能指定”:

算法 定位方式 4→5 迁移比例 优点 缺点
取模 unit = user_id % N 80%(N/(N+1),实测 80.00%) 零存储、可算、范围查询友好 加一个单元几乎全量搬迁
一致性哈希 环上顺时针找第一个节点 20.31%(100 虚拟节点实测) 只搬 1/(N+1),虚拟节点可控均衡 范围查询难、有热点弧、实现易错
映射表 查表 user_id → unit 想搬谁搬谁(可搬迁 1 个用户) 可指定(大客户独占单元)、可任意再平衡 表本身是单点,多一次查询

虚拟节点数是硬参数,不是随便填的。实测(本节代码输出):

虚拟节点/单元 迁移比例 最大单元占比 最小单元占比
1 29.72% 44.75% 3.47%
10 15.53% 38.00% 8.88%
100 20.31% 27.59% 22.06%
500 20.47% 25.25% 24.74%

虚拟节点 = 1 时,某个单元能独占 44.75% 的用户、另一个只有 3.47%——负载差 13 倍,而且迁移比例也不收敛(29.72%,不是理论值 20%)。要均衡到接近 25%,虚拟节点得上到 100 以上。

路由表本身是单点,怎么兜住

  • 三级缓存:SDK 进程内存(TTL 30s)→ 本机文件快照 → 中心路由服务。中心挂了,进程里的旧表还能顶住绝大多数请求。
  • 兜底必须存在:拿不到路由表时按 hash(user_id) % N 临时算——宁可均匀地错,也不要瞬间全打到一个单元。
  • 变更带版本号:路由表双写 + 版本推进,避免一半机器用旧表、同一用户被两边各写一次。
  • ⚠️ 路由错了比不可用更糟。写到错误单元 = 数据错位:这个用户的余额在一号单元,订单写到了三号单元,之后所有读都会“查不到”,而对账脚本会把它当成“新用户”。防线是在单元内 DB 上存 unit_id 列,写入时校验,不匹配就拒绝(fail fast),再加异步对账兜最后一道。切流期间的一致性做法见 36.6 切换时的数据一致性。

全量单元 vs 分片单元

维度 全量单元 分片单元
数据 每个单元都有全量数据(商品、库存、配置) 每个单元只有一部分用户的数据
一致性 写要广播到所有单元,或单向同步(主单元写) 单元内强一致,跨单元最终一致
存储成本 N 倍数据 1 份数据
扩容方式 加单元 = 复制全量(慢,且写入放大 N 倍) 加单元 = 搬 1/(N+1) 用户
跨单元调用 少(数据都在本地) 多(4 单元时 74.77% 的请求跨单元)
适合 读多写少:商品详情、库存展示、城市配置、费率表 用户、订单、账户、消息、购物车

现实系统是混合:商品与库存走全量单元,用户与订单走分片单元。

跨单元场景怎么处理

单元化的代价全部集中在这张表里——跨单元不是“多一次调用”,是“失去了本地事务”:

场景 方案 代价
转账(A 单元 → B 单元) 两阶段:A 单元冻结 + B 单元入账 + 冲正;或中心单元统一记账 多一次跨单元 RTT,要有冻结表与补偿任务,失败态必须可查
库存扣减(买家与卖家不同单元) 库存按 seller_id 归属单元,下单时跨单元同步调用 大卖家所在单元成热点;容量要按热点单元单独扩
全局唯一 ID 号段模式(每单元一次领 1000 个)+ 雪花算法兜底 号段有浪费;时钟回拨要处理
全局计数(点赞数、库存总量) CRDT / 异步汇总,展示近似值 不精确,且要接受短暂不一致
搜索 索引集群独立于单元,全量消费 binlog 有同步延迟(秒级),搜索结果可能“刚下单搜不到”
跨单元列表页(关注、推荐、订单列表) 聚合层并发调多个单元 + 截断 长尾放大:一次页面渲染要等最慢的单元(P99 叠加)
全局唯一约束(手机号不能重复注册) 中心去重服务或独立索引 又造出一个跨地域单点,只能同步调用

🔑 跨单元比例可以估:对端是“随机另一个用户”时,比例就是 1 − 1/N。实测 4 单元 74.77%、8 单元 87.40%、16 单元 93.68%。这说明社交、转账、撮合这类有关系数据的业务,按 user_id 随机分单元是错的,得按关系聚类(同城、同商户、同群)打映射表。

扩容与迁移

单元数怎么定,由两个相反的力量夹出来:

  • 下限:N ≥ 峰值 QPS ÷ 单单元承载上限,且 N ≥ 2。
  • 上限:跨单元比例随 N 上升(74.77% → 93.68%),单单元利用率随 N 下降。
  • 惯例取 4 的倍数:挂一个还剩 3 个,流量按 4:3 重分,容量预留 133%(N/(N−1));8 单元只要预留 114%,16 单元 107%。单元越多,冗余成本越低——这是唯一“越大越省”的维度。

加一个单元要搬多少,两个公式:

算法 公式 4→5 实测 沿 4→5→6→7→8 累计
取模 N/(N+1) 80.00% 336.5%(≈3.4 倍全量数据)
一致性哈希 1/(N+1) 20.31% 64.6%(≈0.65 倍)

累计那一列才是决策依据:一次扩容两种方案都“能忍”,但连续扩 4 次,取模要搬 3.4 倍数据量,一致性哈希只要 0.65 倍——差 5.2 倍。

迁移要多久,按带宽算:单元数据 20 TiB、专线 200 MB/s、限流用一半(100 MB/s),20 × 1024 × 1024 MiB ÷ 100 MB/s ≈ 58 小时(2.4 天)。这 2.4 天必须双写 + 双向校验 + 读仍走旧单元,校验过了才切读——只双写不校验,等于把数据错位推迟到切读那天才暴露。切流期的一致性做法见 36.6 切换时的数据一致性。

爆炸半径与单元数的权衡

单元数 挂 1 个影响用户 跨单元请求(实测) 每单元容量冗余 1/(N−1) 单单元故障后剩余承载 运维复杂度
2 50% 50.0% 100% 各 100% 低
4 25% 74.77% 33.3% 各 133% 中
8 12.5% 87.40% 14.3% 各 114% 高
16 6.25% 93.68% 6.7% 各 107% 很高

读法:4 → 8 单元,受影响用户从 25% 降到 12.5%,代价是跨单元比例从 74.77% 涨到 87.40%,且部署、监控、演练的工作量翻倍。4 到 8 通常划算,8 到 16 基本不划算——除非业务本身就把跨单元调用压得很低(按商户聚类,跨商户请求本来就少)。受影响比例要先折成“单次 RTO 覆盖多少人”再和预算比,口径见 36.1 RTO / RPO。

我的判断是:多数业务的最优点在 4 单元(冗余 133%),只有“随机关系请求占比 < 10%”的业务才值得上 8 单元以上。这个比例能从接入层日志直接统计。

可运行的单元路由与迁移模拟

package main

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

type token struct {
	h    uint64
	unit int
}

// FNV-1a 对“前缀相同、只差尾部一两个字符”的 key 雪崩很差:
// "unit0#0" 与 "unit0#1" 的高 32 位几乎一样,直接当环坐标会让所有虚拟节点挤在一小段弧上。
// 所以再过一遍 murmur3 的 64 位 finalizer。
func mix(x uint64) uint64 {
	x ^= x >> 33
	x *= 0xff51afd7ed558ccd
	x ^= x >> 33
	x *= 0xc4ceb9fe1a85ec53
	x ^= x >> 33
	return x
}

func h64(s string) uint64 {
	f := fnv.New64a()
	_, _ = f.Write([]byte(s))
	return mix(f.Sum64())
}

// 建环:每个单元放 vnodes 个虚拟节点,按 hash 排序
func buildRing(units, vnodes int) []token {
	r := make([]token, 0, units*vnodes)
	for u := 0; u < units; u++ {
		for v := 0; v < vnodes; v++ {
			key := "unit" + strconv.Itoa(u) + "#" + strconv.Itoa(v)
			r = append(r, token{h64(key), u})
		}
	}
	sort.Slice(r, func(i, j int) bool { return r[i].h < r[j].h })
	return r
}

// 一致性哈希定位:顺时针找第一个 >= key 的虚拟节点
func pick(r []token, key uint64) int {
	i := sort.Search(len(r), func(i int) bool { return r[i].h >= key })
	if i == len(r) {
		i = 0
	}
	return r[i].unit
}

func main() {
	const users = 1_000_000
	const from, to = 4, 5 // 从 4 个单元扩到 5 个

	// 一、取模:加一个单元要搬多少
	moved := 0
	for uid := 0; uid < users; uid++ {
		if uid%from != uid%to {
			moved++
		}
	}
	fmt.Println("【一】取模路由(uid % N)扩容迁移比例")
	fmt.Printf("  %d 单元 → %d 单元:%d / %d 个用户换了单元 = %.2f%%(理论 N/(N+1) = %.2f%%)\n",
		from, to, moved, users, float64(moved)/users*100, float64(from)/float64(to)*100)

	// 二、一致性哈希:虚拟节点数对迁移比例与均衡度的影响
	fmt.Println("\n【二】一致性哈希:虚拟节点数 → 迁移比例 + 均衡度(加到 5 个单元后)")
	fmt.Printf("  %-12s %10s %14s %14s %14s\n", "虚拟节点/单元", "迁移比例", "理论 1/(N+1)", "最大单元占比", "最小单元占比")
	for _, vn := range []int{1, 10, 100, 500} {
		r4, r5 := buildRing(from, vn), buildRing(to, vn)
		cnt := make([]int, from)
		moved := 0
		for uid := 0; uid < users; uid++ {
			k := h64("user" + strconv.Itoa(uid))
			a, b := pick(r4, k), pick(r5, k)
			cnt[a]++
			if a != b {
				moved++
			}
		}
		mx, mn := 0, users
		for _, c := range cnt {
			if c > mx {
				mx = c
			}
			if c < mn {
				mn = c
			}
		}
		fmt.Printf("  %-12d %9.2f%% %13.2f%% %13.2f%% %13.2f%%\n",
			vn, float64(moved)/users*100, 100.0/float64(to),
			float64(mx)/users*100, float64(mn)/users*100)
	}

	// 三、跨单元请求比例:单元越多,跨单元越多
	fmt.Println("\n【三】跨单元请求比例(每个用户 3 个随机对端,抽样 20 万用户)")
	fmt.Printf("  %-10s %12s %14s\n", "单元数", "实测跨单元", "理论 1-1/N")
	for _, n := range []int{4, 8, 16} {
		r := buildRing(n, 100)
		rng := rand.New(rand.NewSource(42))
		unitOf := make([]int, users)
		for uid := 0; uid < users; uid++ {
			unitOf[uid] = pick(r, h64("user"+strconv.Itoa(uid)))
		}
		cross, total := 0, 0
		for i := 0; i < 200_000; i++ {
			me := unitOf[rng.Intn(users)]
			for j := 0; j < 3; j++ {
				peer := unitOf[rng.Intn(users)]
				total++
				if peer != me {
					cross++
				}
			}
		}
		fmt.Printf("  %-10d %11.2f%% %13.2f%%\n", n, float64(cross)/float64(total)*100, (1-1.0/float64(n))*100)
	}

	// 四、沿 4→5→6→7→8 的扩容路径,累计搬了多少数据
	fmt.Println("\n【四】扩容路径 4→5→6→7→8:累计搬迁量(占全量数据的比例)")
	// 取模
	cur := make([]int, users)
	for uid := range cur {
		cur[uid] = uid % 4
	}
	modTotal := 0.0
	for n := 5; n <= 8; n++ {
		ch := 0
		for uid := 0; uid < users; uid++ {
			nu := uid % n
			if nu != cur[uid] {
				ch++
				cur[uid] = nu
			}
		}
		step := float64(ch) / users * 100
		modTotal += step
		fmt.Printf("  取模   4 → %2d:本步搬迁 %5.2f%%\n", n, step)
	}
	fmt.Printf("  取模   累计搬迁 %.1f%%(≈ %.1f 倍全量数据)\n\n", modTotal, modTotal/100)

	// 一致性哈希(100 虚拟节点)
	prev := make([]int, users)
	rPrev := buildRing(4, 100)
	for uid := 0; uid < users; uid++ {
		prev[uid] = pick(rPrev, h64("user"+strconv.Itoa(uid)))
	}
	chTotal := 0.0
	for n := 5; n <= 8; n++ {
		r := buildRing(n, 100)
		ch := 0
		for uid := 0; uid < users; uid++ {
			nu := pick(r, h64("user"+strconv.Itoa(uid)))
			if nu != prev[uid] {
				ch++
				prev[uid] = nu
			}
		}
		step := float64(ch) / users * 100
		chTotal += step
		fmt.Printf("  一致性哈希 4 → %2d:本步搬迁 %5.2f%%\n", n, step)
	}
	fmt.Printf("  一致性哈希 累计搬迁 %.1f%%(≈ %.2f 倍全量数据)\n", chTotal, chTotal/100)
}

本机真实输出(GO111MODULE=off go run main.go,Go 1.27.1 darwin/arm64):

【一】取模路由(uid % N)扩容迁移比例
  4 单元 → 5 单元:800000 / 1000000 个用户换了单元 = 80.00%(理论 N/(N+1) = 80.00%)

【二】一致性哈希:虚拟节点数 → 迁移比例 + 均衡度(加到 5 个单元后)
  虚拟节点/单元            迁移比例     理论 1/(N+1)         最大单元占比         最小单元占比
  1                29.72%         20.00%         44.75%          3.47%
  10               15.53%         20.00%         38.00%          8.88%
  100              20.31%         20.00%         27.59%         22.06%
  500              20.47%         20.00%         25.25%         24.74%

【三】跨单元请求比例(每个用户 3 个随机对端,抽样 20 万用户)
  单元数               实测跨单元       理论 1-1/N
  4                74.77%         75.00%
  8                87.40%         87.50%
  16               93.68%         93.75%

【四】扩容路径 4→5→6→7→8:累计搬迁量(占全量数据的比例)
  取模   4 →  5:本步搬迁 80.00%
  取模   4 →  6:本步搬迁 83.33%
  取模   4 →  7:本步搬迁 85.71%
  取模   4 →  8:本步搬迁 87.50%
  取模   累计搬迁 336.5%(≈ 3.4 倍全量数据)

  一致性哈希 4 →  5:本步搬迁 20.31%
  一致性哈希 4 →  6:本步搬迁 16.30%
  一致性哈希 4 →  7:本步搬迁 14.00%
  一致性哈希 4 →  8:本步搬迁 13.99%
  一致性哈希 累计搬迁 64.6%(≈ 0.65 倍全量数据)

逐条读:

  1. 第一段 80.00% 与公式 N/(N+1) 严丝合缝——取模扩容等于洗牌,4 个单元的缓存、连接池、本地索引在扩容那天全部作废。这就是取模只适合“单元数一次定死”的原因。
  2. 第二段对虚拟节点最敏感:1 个虚拟节点时迁移 29.72%(比理论 20% 高 48%),且最大单元吃 44.75%、最小只有 3.47%;100 个虚拟节点后迁移收敛到 20.31%,最大/最小变成 27.59% / 22.06%。虚拟节点少的一致性哈希,比取模还难运维——它的不均衡是静默的,监控只看总量根本发现不了。
  3. 第三段是单元化的定价单:4 单元 74.77% 的请求跨单元,8 单元 87.40%。一个列表页读 20 个用户必然跨单元,跨单元调用的延迟与失败率会主导 P99。先量这个比例,再定单元数。
  4. 第四段是决策依据:同样从 4 扩到 8,取模累计搬 336.5%,一致性哈希 64.6%,差 5.2 倍。而且取模的每一步都接近全量(80% 起),意味着每次扩容都是一次“全站大迁移”。

还有个坑:FNV-1a 直接当环坐标会失效。unit0#0 与 unit0#1 只差一个字符,FNV 高 32 位几乎相同,虚拟节点会挤在一小段弧上——实测表现为迁移比例 86.79%、最大单元占 89.61%。加一遍 murmur3 finalizer 才恢复正常;漏掉这一步,得到的环“能跑但严重倾斜”。

反模式

反模式 后果
只拆库不拆流量 数据分片了,请求还是随机落;同一用户跨库读写,缓存穿透 + 事务跨库,比不拆更慢
路由表写死没兜底 路由服务一挂,全部请求按“默认单元”打过去,单点过载 → 全站故障
单元间同步调用 调用链跨单元,一个单元慢全站慢,隔离收益归零;应改为异步 + 对账
单元内还有共享 DB / 共享 Redis 拔一个单元网线其他单元变慢,“单元”退化成“多一套应用部署”
按机器数而不是按用户分单元 单元边界与用户边界不一致,扩容时数据要重分布,等于每次都全量迁移
没有单元级灰度 新版本一次铺满所有单元,出错就是全域;单元化的灰度能力是白送的,不用就浪费
迁移期只双写不校验 数据错位在切读那天才暴露,回滚要回滚两次写入

关联

自检

Q1 团队说“我们做完单元化了”,但某个单元 DB 故障时,全站 30% 请求超时——受影响比例本应只有 25%。最可能的两个原因是什么?

▸ 答案:① 单元内还有共享依赖:共享 Redis / Kafka / 配置中心 / 中心化服务,所有单元都要经过它,它一挂就全域受影响;② 单元间同步调用:A 单元处理本地请求时同步调 B,B 慢则 A 也慢,隔离在调用链上被打穿。验证只有一个办法:拔掉一个单元的网线,看其余单元的错误率与 P99 有没有变化——变一点就说明还有共享依赖。

Q2 4 个单元扩到 5 个,取模要搬 80%、一致性哈希只搬 20.31%。那为什么还有大量团队用取模?

▸ 答案:取模换来三样东西:路由可算(不存路由表,SDK 一行代码)、范围查询友好(user_id % N 的边界可推导,按区间能定位库)、没有热点弧(虚拟节点不够的一致性哈希会出现 44.75% vs 3.47% 的倾斜)。它的真实前提是“单元数一次定死”——接受这个前提,80% 的迁移代价永远不用付。单元数一旦要增长,取模的累计迁移(4→8 搬 336.5%)就无法承受。

Q3 转账为什么不能在“付款人所在单元”里直接改收款人的余额?

▸ 答案:收款人账户在另一个单元,本地事务改不到。硬改就是把两个单元放进同一事务——跨单元 2PC 会把 RTT 与失败率叠起来,且隔离失效。正确做法是跨单元两阶段 + 冻结:付款单元冻结金额(本地事务)、调收款单元入账、失败冲正;或把转账收敛到中心单元统一记账。两种方案都必须有可查的中间态与补偿任务,因为“冻结了但没入账”一定会出现。

Q4 单元数从 4 加到 16,故障影响面从 25% 降到 6.25%,看起来很划算。代价是什么?什么情况下这个升级是负收益?

▸ 答案:真代价是两笔:① 跨单元请求从 74.77% 涨到 93.68%(实测),跨单元调用主导延迟与失败率;② 部署、监控、演练、发版工作量随 N 线性上涨,每次扩容的迁移与回滚都是固定投入。单单元冗余从 33.3% 降到 6.7% 那一条是省钱的,算收益不算代价。负收益场景很明确:请求有强关系(转账、社交、撮合),跨单元已占九成,再切只增加跳数;以及没有单元级灰度与演练能力,16 个单元里哪个出问题都定位不到。