单元化架构:单元划分、流量路由与容灾切换

单元化架构的完整落地路径:从单元划分维度与分片键选择讲起,覆盖用户路由表与就近接入、单元内闭环与跨单元调用设计、故障时单元级切流与容灾演练,并讨论单元数量与容量规划,附路由缓存失效、分片键与查询键不一致、单元数冗余不足、数据校验与回切演练等完整踩坑清单与规避方案。

当业务规模跨过单机房容量上限,“多机房部署"就不再等于"高可用”。真正让多机房产生容灾价值的,是把整套系统切成若干自包含的单元(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 划分维度怎么选

划分维度决定了"哪些流量进哪个单元",一旦定错,后期迁移成本极高。常见维度对比:

维度示例优点缺点
用户 IDuid 取模分布均匀、天然隔离跨用户查询需特殊处理
地理区域华东/华北就近接入、合规友好用户迁移会跨单元
租户企业客户隔离清晰大客户独占单元,利用率低
业务线电商/外卖边界清晰同一用户跨业务无法闭环

实践中最常用的是用户 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 为主、地理为辅,分片键与路由键一致)、建路由能力(路由表 + 三层缓存 + 版本号推送)、保单元闭环(识别并特殊处理全局数据)、练切流演练(分层切流、原子发布、数据校验)。做到这四步,单元化才能把"一个机房挂了业务不受影响"从口号变成现实。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「架构」更多文章

  1. 韧性工程与错误预算:从 SLO 到故障演练
  2. 微前端架构:组合、隔离与独立部署
  3. 数据网格(Data Mesh):领域数据产品与去中心化治理