当业务规模跨过单机房容量上限,“多机房部署"就不再等于"高可用”。真正让多机房产生容灾价值的,是把整套系统切成若干自包含的单元(Cell),每个单元能独立承载一部分流量、独立完成一次业务闭环、独立承担一次故障。本文从单元划分、路由、闭环、切流四个层面讲清落地细节。
1. 为什么需要单元化
1.1 传统多活的三个死角
很多团队以为"两地三中心 + 负载均衡"就是多活,上线后发现三个问题绕不过去:
| 死角 | 现象 | 根因 |
|---|---|---|
| 数据库仍是单点 | 应用双活,主库一挂全挂 | 数据层未拆分,跨机房写冲突 |
| 全量依赖 | 一个下游抖动,全部机房受影响 | 没有故障隔离边界 |
| 容量天花板 | 单库连接数/QPS 到顶 | 未按业务维度水平切分 |
多机房只是把计算分散了,数据与依赖仍是全局共享的。单元化的本质是:连数据和依赖一起切,让每个单元成为一座"可以独立活下去的小城"。
1.2 单元的定义
单元(Cell)是系统的一个水平切片,包含完整的一整套应用服务、中间件与该切片专属的数据,对外提供与整体等价的业务能力。
传统多活(共享数据) 单元化(数据也切分)
机房 A 机房 B 单元 1 单元 2
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ 应用 A │ │ 应用 B │ │ 应用+ │ │ 应用+ │
└───┬────┘ └───┬────┘ │ 中间件 │ │ 中间件 │
│ │ └───┬────┘ └───┬────┘
└─────┬──────┘ │ │
┌────▼─────┐ ┌──▼──┐ ┌──▼──┐
│ 全局主库 │ ← 单点 │DB-1 │ │DB-2 │
└──────────┘ └─────┘ └─────┘
用户 1~500万 用户 500万+
关键差别在于:单元 1 的数据库只存"属于单元 1 的那部分用户"的数据。单元 1 整体宕机,单元 2 完全无感——因为二者没有任何共享的读写路径。这与 多租户架构设计 的隔离思路同源,但单元化的隔离粒度是故障域而非租户。
2. 单元划分维度与数据分片
2.1 划分维度怎么选
划分维度决定了"哪些流量进哪个单元",一旦定错,后期迁移成本极高。常见维度对比:
| 维度 | 示例 | 优点 | 缺点 |
|---|---|---|---|
| 用户 ID | uid 取模 | 分布均匀、天然隔离 | 跨用户查询需特殊处理 |
| 地理区域 | 华东/华北 | 就近接入、合规友好 | 用户迁移会跨单元 |
| 租户 | 企业客户 | 隔离清晰 | 大客户独占单元,利用率低 |
| 业务线 | 电商/外卖 | 边界清晰 | 同一用户跨业务无法闭环 |
实践中最常用的是用户 ID 为主、地理为辅:以 uid 做分片键保证均匀,同时让单元部署在离用户近的机房,兼顾就近接入。
2.2 分片键与路由键必须一致
一个高频踩坑:写入时按 uid 分片,查询时按订单号查,结果查不到数据。
-- 错误:订单表按 uid 分片,按 order_no 查询会扫全部分片
SELECT * FROM orders WHERE order_no = 'ORD-20261007-001';
-- 正确:路由键(查询条件)必须携带分片键(uid)
SELECT * FROM orders WHERE uid = 10086 AND order_no = 'ORD-20261007-001';
解决方案有两种:冗余分片键(订单表冗余 uid 列,查询强制带上)或建立映射表(维护 order_no -> uid 的全局映射,先查映射再路由)。映射表本身会成为热点,通常配合本地缓存 + 布隆过滤器,命中率可做到 99% 以上。
2.3 单元数量与容量规划
设总容量需求为 C、单单元容量上限为 c,则最小单元数 n = ⌈C / c⌉,实际取值还要额外考虑三点:预留冗余(至少 n+1,保证一个单元故障后其余单元能承接)、灰度需要(留 1 个单元做新版本验证)、搬迁成本(单元越多、单个越小,用户迁移跨单元搬数据的频率越高)。一个常见配置是"同城 3 单元 + 异地 2 单元",与 高可用架构与故障容错 中讨论的故障域划分原则一致。
3. 用户路由表与就近接入
3.1 路由表的结构
路由表回答唯一一个问题:“这个用户属于哪个单元?” 它必须满足三个要求:查询极快(每次请求都要用)、可动态变更(迁移用户时改)、高可用(挂了全站不可用)。
# 路由表逻辑结构(实际存储通常用 Redis Cluster + 本地缓存)
route:
version: 20261007-03 # 版本号,用于全量推送与校验
default_cell: cell-1 # 未命中时的兜底单元
rules:
- range: [0, 5000000] # uid 区间 -> 单元映射
cell: cell-1
room: shanghai-a
- range: [5000001, 10000000]
cell: cell-2
room: shanghai-b
- range: [10000001, 15000000]
cell: cell-3
room: beijing-a
overrides: # 单用户例外映射(迁移中/大客户)
10086: cell-2
20001: cell-3
区间映射(range)比哈希取模更适合单元化:迁移用户时只需改区间边界,不用重算所有哈希。
3.2 路由计算的三层缓存
路由查询是全链路最高频的操作,必须做成多级缓存:L1 进程内本地缓存(TTL 1~5 秒,命中率 ~99%,延迟 <100ns)、L2 同机房 Redis 副本(延迟 ~1ms)、L3 中心化路由服务(延迟 ~10ms,兜底与版本推送源)。
本地缓存 TTL 要短,因为用户迁移时若缓存过期太慢,会出现"用户在新单元、请求还路由到旧单元"的窗口。版本号机制可以在推送新路由表时主动失效本地缓存。
type Router struct {
mu sync.RWMutex
version string
ranges []rangeRule
cache *lru.Cache
}
func (r *Router) CellOf(uid int64) string {
if c, ok := r.cache.Get(uid); ok {
return c.(string)
}
r.mu.RLock()
defer r.mu.RUnlock()
for _, rule := range r.ranges {
if uid >= rule.Lo && uid <= rule.Hi {
r.cache.Add(uid, rule.Cell)
return rule.Cell
}
}
return r.defaultCell
}
// 路由中心推送新版本时调用,版本号变化才重建
func (r *Router) Reload(v string, rules []rangeRule) {
if v == r.version {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.ranges = rules
r.version = v
r.cache.Purge() // 关键:版本变更必须清空本地缓存
}
踩坑点:Reload 里忘记清缓存,导致路由表更新后仍有请求走旧规则。这个 bug 在测试环境几乎测不出来,只在真实迁移时暴露。
3.3 就近接入与 DNS/GSLB
路由表决定"去哪个单元",DNS/GSLB 决定"从哪个入口进",二者配合才能实现就近接入:
用户(上海)
├─ 1. DNS 解析 api.example.com,GSLB 按用户 IP 返回上海入口 VIP
├─ 2. 请求到达上海接入层,接入层查路由表:uid=10086 -> cell-2(上海)
└─ 3. 转发到 cell-2(同城,RTT <2ms)
当路由结果与接入机房不一致时(如上海用户属于北京单元),有两种策略:回源转发(接入层直接转发到目标单元,跨城 RTT 高但简单)或 HTTP 重定向(返回 302 让客户端重新接入目标单元,多一次往返但后续请求闭环)。在线业务通常选回源转发 + 长连接优化,把跨城延迟压到 30ms 以内。
4. 单元内闭环与跨单元调用
4.1 闭环原则
单元内闭环:一次业务请求涉及的所有读写在同一个单元内完成,不跨单元。
这是单元化能隔离故障的根本原因。以"下单"为例,用户服务、商品服务、库存服务、订单服务、支付服务、用户中心 DB 必须全部落在同一个单元。只要有一环是"全局唯一服务"(如全局库存、全局优惠券池),这个单元就不是真正闭环的,该服务一挂所有单元一起挂。
4.2 哪些数据无法闭环
现实中有几类数据天生是全局的:全局配置(费率、开关,只读 + 多单元同步)、全局唯一(序列号,用号段模式)、跨用户(转账、社交关系,由归属单元发起跨单元调用)、全局统计(大盘 GMV,异步汇总允许延迟)。
号段模式是解决全局唯一 ID 的经典方案:中心服务一次分配 1000 个 ID 给某单元,单元内自增用完再取,99.9% 的取号在单元内完成,只有千分之一访问中心。
4.3 跨单元调用的正确姿势
跨单元调用(如 A 单元用户给 B 单元用户转账)必须显式处理,不能靠路由表"碰运气":
// 错误:直接按本地单元的服务地址调用,order 的 uid 可能属于另一个单元
orderService.create(order);
// 正确:先解析归属单元,再路由
String targetCell = router.cellOf(order.getUid());
if (!targetCell.equals(currentCell())) {
return crossCellClient.to(targetCell).create(order);
}
跨单元调用要配套三件事:超时控制(跨城 RTT 高,阈值要放大)、幂等设计(网络抖动重试不能重复扣款)、降级策略(目标单元不可达时的兜底行为)。
5. 故障时单元级切流与演练
5.1 切流的三个层次
单元故障时的切流要分层决策:
| 层次 | 手段 | 影响范围 | 恢复时间 |
|---|---|---|---|
| L1 摘除 | 从负载均衡摘除故障单元 | 该单元流量 | 秒级 |
| L2 改路由 | 修改路由表,把用户划到健康单元 | 迁移用户的请求 | 分钟级 |
| L3 数据接管 | 故障单元数据由健康单元接管 | 全局 | 小时级 |
L1 是常规操作(单台实例故障),L2 是单元级故障(整个单元不可用),L3 是最坏情况(单元数据丢失,需从备份恢复)。
5.2 路由表改写的原子性
L2 切流最容易出事的地方是路由表改到一半:一半用户走新单元、一半走旧单元,导致数据不一致。
def failover(from_cell, to_cell):
current = route_center.get_current() # 读当前路由表
new = copy.deepcopy(current)
for rule in new.ranges:
if rule.cell == from_cell:
rule.cell = to_cell # 全部指向健康单元
new.version = next_version() # 版本号递增
route_center.publish(new) # 一次性发布
route_center.wait_all_nodes_synced(new.version, timeout=30)
assert route_center.synced_nodes() == route_center.total_nodes()
发布必须是整表替换 + 版本号递增,绝不能"逐条改规则"——逐条改会产生大量中间状态,每个中间状态都是一个不一致窗口。
5.3 切流演练清单
切流能力不演练等于没有。建议按季度演练,每次覆盖以下项:单实例故障(验证 L1 摘除,观察流量是否平滑转移)、单元级故障(模拟整个单元不可用,验证 L2 切流耗时)、路由表推送(验证所有节点在 30 秒内同步新版本)、跨单元调用(验证切流后链路的超时与幂等)、数据校验(比对关键表行数与金额汇总)、回切(验证从健康单元切回原单元,比切出去更容易出错)。
演练时要真实摘除流量,而不是"改个开关假装故障"。混沌工程工具(详见 混沌工程实践 )可以自动化这类演练。
5.4 切流后的数据校验
切流只是把流量导走,数据是否完整需单独校验:按 uid 区间对源单元与目标单元做订单的 COUNT(*) 与 SUM(amount) 逐日比对,两边必须完全一致。若不一致,说明切流期间有请求写到了错误单元,需要用补偿任务修复。
6. 踩坑清单
| 坑 | 后果 | 规避 |
|---|---|---|
| 路由缓存不随版本失效 | 迁移后请求打到旧单元 | 版本变更强制清缓存 |
| 路由表逐条更新 | 中间状态数据不一致 | 整表替换 + 版本号 |
| 分片键与查询键不一致 | 查询扫全分片或查不到 | 冗余分片键或映射表 |
| 跨单元调用无幂等 | 重试导致重复扣款 | 全局唯一请求号 + 去重表 |
| 单元数未留冗余 | 一个单元挂了容量不够 | n+1 起步 |
| 只做切出不做回切演练 | 故障恢复后回不去 | 回切纳入演练清单 |
| 全局服务未识别 | 单元化后仍有单点 | 逐个梳理依赖,标注全局项 |
7. 总结
单元化架构的价值不在于"多机房",而在于用数据分片换来真正的故障隔离。落地路径可归纳为四步:选划分维度(以用户 ID 为主、地理为辅,分片键与路由键一致)、建路由能力(路由表 + 三层缓存 + 版本号推送)、保单元闭环(识别并特殊处理全局数据)、练切流演练(分层切流、原子发布、数据校验)。做到这四步,单元化才能把"一个机房挂了业务不受影响"从口号变成现实。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。