集群规模到了一定程度,「一台机器出问题」就不再是运维事件,而是全站事件:一次慢查询打满连接池、一个坏配置推全量、一次机房网络抖动,都会沿着共享的中间件与数据库扩散到所有业务。根因是共享——所有人共用同一套注册中心、同一套数据库、同一批连接,故障自然也就共享。
单元化(Cell-based Architecture)的核心思路很朴素:把系统切成若干个自包含的单元,每个单元拥有独立的接入、计算、存储与依赖,只服务一部分用户;单元内闭环,单元间尽量不调用。这样一个单元挂了,受影响的只是它承载的那部分流量。本文讲清楚单元怎么切、路由怎么做、数据怎么分,以及落地时最容易踩的坑。
一句话:单元化不是「把服务复制几份」,而是「让每个副本只对一部分用户负责,并且互不依赖」。
1. 为什么需要单元化
1.1 单体集群的三个天花板
| 天花板 | 表现 | 根因 |
|---|---|---|
| 爆炸半径失控 | 单点故障波及全站 | 所有流量共享同一套依赖 |
| 跨机房调用 | P99 被跨城 RTT 拖到几十毫秒 | 数据与计算不在同一故障域 |
| 容量无法线性扩展 | 加机器收益递减 | 数据库、注册中心成为瓶颈 |
这三者其实是同一个问题的三种表象:系统的所有部分耦合在一起,规模上去之后耦合成本超过了并行收益。
1.2 单元化的定义
单元(Cell)是一个可独立承接一部分用户流量、内部自包含的最小部署单位。它包含:
一个单元(Cell)的组成:
接入层 : 网关 / 负载均衡 / 服务发现(单元内)
计算层 : 业务服务的完整实例集(无状态 + 有状态)
存储层 : 数据库分片、缓存、消息队列(单元内)
依赖 : 配置中心、注册中心、监控(单元内或就近)
关键约束是单元封闭(Cell Isolation):一次用户请求从进入到返回,理想情况下只在单元内部完成,不产生跨单元同步调用。
1.3 单元与分区、多活的区别
| 概念 | 切分维度 | 是否要求闭环 | 主要目的 |
|---|---|---|---|
| 分区(Sharding) | 数据 | 否 | 扩展存储容量 |
| 单元化(Cell) | 用户 + 数据 | 是 | 隔离故障、就近接入 |
| 多活(Active-Active) | 机房/地域 | 部分 | 容灾与低延迟 |
单元化是「按用户切、并保证闭环」,分区只是「按数据切」。一个单元内部通常还要再做一次分区。
2. 单元化架构的分层
2.1 接入层:单元路由
用户请求进入的第一跳就要决定「去哪个单元」,这个决策叫单元路由(Cell Routing)。常见做法:
| 路由方式 | 依据 | 优点 | 缺点 |
|---|---|---|---|
| DNS / GSLB | 用户 IP 地理 | 无需应用改造 | 精度粗,切换慢 |
| 网关路由表 | 用户 ID 哈希 | 精确、可控 | 需要网关承载全量映射 |
| 客户端路由 | SDK 内置规则 | 一跳直达 | 客户端升级成本高 |
单元路由表(示例)
用户 ID 0x0000_0000 ~ 0x3FFF_FFFF -> cell-a (华东)
用户 ID 0x4000_0000 ~ 0x7FFF_FFFF -> cell-b (华北)
用户 ID 0x8000_0000 ~ 0xBFFF_FFFF -> cell-c (华南)
兜底 / 未登录用户 -> default cell(就近)
路由表的生成规则必须是纯函数:同一个用户在任何入口、任何时候都算出同一个单元。一旦引入随机或时间因素,用户就会在两个单元间漂移,数据一致性立刻崩掉。单元内的流量分发则可交给常规算法,见 https://plumephp.com/load-balancing-algorithms/。
2.2 计算层:单元内自包含
计算层要做到「单元内一套完整服务」,难点不在无状态服务,而在有状态依赖:
必须单元内化的依赖(否则故障会跨单元传播):
- 数据库(按用户分片到单元)
- 缓存(单元内独立实例,不共享)
- 消息队列(单元内 Topic 或按用户路由的共享 Topic)
- 注册中心 / 配置中心(单元内视图,或全局但可降级为本地缓存)
- 定时任务调度(按用户分片,单元内独立触发)
经验判断法:如果某个依赖挂了会导致所有单元一起不可用,它就不该是全局单例。注册中心与配置中心是典型的灰色地带——可以全局部署,但必须保证客户端有本地缓存,能在中心不可用时以旧配置继续服务。
2.3 数据层:单元归属
数据层是单元化最硬的部分。核心原则是数据的单元归属与用户的单元归属一致:
用户 U 归属 cell-a
-> U 的订单、账户、购物车都写在 cell-a 的存储
-> 读 U 的数据时,也只在 cell-a 读
-> 跨单元查询 = 需要聚合,尽量避免
例外是全局数据:商品目录、汇率、城市列表这类所有用户共享的只读数据。处理方式通常有三种:
| 方案 | 机制 | 代价 |
|---|---|---|
| 全局单副本 + 只读缓存 | 每单元缓存一份,异步刷新 | 短时陈旧 |
| 每单元一份副本 | 通过消息广播同步 | 需要幂等与顺序保证 |
| 按需拉取 | 访问时从全局库拉取并缓存 | 首次延迟高 |
2.4 单元数量与规模
单元数不是越多越好。单元数 N 意味着 N 套独立的部署、监控、发布与容量水位:
| 单元数 | 单单元故障影响 | 运维成本 | 适用阶段 |
|---|---|---|---|
| 2 | 50% | 低 | 初步隔离,容灾演练 |
| 4 ~ 8 | 12.5% ~ 25% | 中 | 大多数业务的最优区间 |
| 16 ~ 32 | 3% ~ 6% | 高(发布批次多) | 超大规模、强合规隔离 |
| > 64 | < 1.6% | 极高 | 通常只在云厂商内部出现 |
判断依据是故障影响面与运维成本的交叉点:当「再拆一个单元带来的隔离收益」小于「多一套部署带来的发布与排障成本」时,就该停止拆分。对绝大多数业务,4~8 个单元是甜点区。
3. 数据一致性设计
3.1 单元封闭原则
单元封闭是单元化的第一性原理。判断一个设计是否合格,只需要问一句话:这个请求路径上有跨单元的同步调用吗?
反例(破坏封闭):
用户请求 -> cell-a 网关 -> cell-a 服务
-> cell-b 的账户服务(同步 RPC) <-- 单元 b 挂 -> 单元 a 也挂
正例(保持封闭):
用户请求 -> cell-a 网关 -> cell-a 服务
-> cell-a 账户服务(本地 RPC)
3.2 跨单元数据的三种处理
| 场景 | 处理方式 | 一致性 |
|---|---|---|
| 只读全局数据 | 单元内缓存 + 定时刷新 | 最终一致 |
| 用户数据跨单元访问 | 禁止,路由纠正回归属单元 | 不适用 |
| 全局唯一约束(如用户名) | 全局注册表 + 单元内缓存 | 弱一致 + 冲突兜底 |
跨单元写入应当改写为异步:先本地落库并返回,再通过消息同步到目标单元。同步失败必须可重放、可对账,否则数据会静默丢失。
3.3 单元间通信的取舍
单元间通信应当遵循「能不同步就不同步,能异步就不查询」:
单元间通信优先级(从优到劣):
1. 不通信(数据本地化)
2. 异步消息(单元间最终一致)
3. 同步只读 RPC(有超时、熔断、降级)
4. 同步读写 RPC(应视为设计缺陷)
第 3 级必须配齐超时、重试预算与熔断。超时值应当小于上游的 SLA 预算,而不是「随便给个 3 秒」。
3.4 单元内一致性与跨单元补偿
单元化把「全局强一致」拆成了「单元内强一致 + 单元间最终一致」,这要求业务把跨单元的操作显式建模成补偿流程:
// 跨单元转账:本地强一致 + 异步补偿
public void transfer(String fromUser, String toUser, long amount) {
String fromCell = router.cellOf(fromUser);
String toCell = router.cellOf(toUser);
if (fromCell.equals(toCell)) {
// 同单元:走本地数据库事务,强一致
localTx.transfer(fromUser, toUser, amount);
return;
}
// 跨单元:本地扣减 + 落补偿记录 + 异步加钱
localTx.debitAndEnqueue(fromUser, amount, toUser); // 同一本地事务
// 下游消费补偿记录后到目标单元入账,失败则重试直至成功
}
注意 debitAndEnqueue 必须把「扣减」与「补偿记录」放在同一个本地事务里,否则扣了钱但记录没落,钱就凭空消失。这就是经典的本地消息表 + 幂等消费模式,幂等键通常取补偿记录的唯一 ID。
4. 故障隔离与容量
4.1 爆炸半径收敛
单元化的收益可以用爆炸半径来量化:
| 部署形态 | 单元数 | 单单元故障影响面 |
|---|---|---|
| 单体集群 | 1 | 100% |
| 按机房分 | 3 | 约 33% |
| 按用户单元化(8 单元) | 8 | 约 12.5% |
| 单元化 + 单元内冗余 | 8 × 2 | 约 12.5%,且可自动切换 |
注意最后一行:单元化不等于放弃冗余。单元内部仍然需要多副本,单元化解决的是「故障域大小」,冗余解决的是「单点存活」。
4.2 单元级降级与熔断
单元化之后,降级策略也要按单元粒度设计:
# 单元级熔断配置示例
circuit_breaker:
scope: cell # 熔断按单元隔离,不跨单元统计
failure_threshold: 50%
window: 10s
half_open_probes: 5
fallback:
- serve_from_cache # 降级:返回本地缓存
- degrade_to_readonly # 降级:只读模式
- shed_load # 降级:按用户 ID 采样丢弃
按单元隔离统计的意义在于:某个单元网络异常不会让其他单元的错误率一起上升,从而避免全局熔断误伤健康单元。
4.3 容量规划
单元化把容量规划从「总量够不够」变成「每个单元够不够 + 单元间是否可借」:
单元容量规划三步
1. 按路由规则统计每个单元承载的用户量与峰值 QPS
2. 单单元容量 = 峰值 QPS x 安全系数(通常 1.3 ~ 1.5)
3. 冗余:单单元可承受"同组另一单元故障后"的转移流量
注意:单元间借容量意味着流量会跨单元,
这是"容量冗余"与"单元封闭"的直接冲突,必须显式权衡
实践中常见折中:平时不借,故障时允许短时借,并在路由层设置借容量上限(如 20%),防止雪崩式转移把健康单元也拖垮。这与高可用设计的整体思路一致,可参考 https://plumephp.com/distributed-high-availability-patterns/。
4.4 按单元打标与可观测性
单元化的前提是「能看清每个单元的状态」。如果监控面板只给出全站聚合指标,一次单元级故障会被平均值掩盖:
必须携带 cell 标签的维度
- 请求量 / 错误率 / P99 延迟(按 cell 分组)
- 数据库连接池使用率(按 cell)
- 缓存命中率与内存(按 cell)
- 消息队列积压(按 cell 与 topic)
- 发布批次与配置版本(按 cell)
告警规则
- 单 cell 错误率 > 5% 持续 1 分钟 -> 单元级告警
- cell 间 P99 差异 > 3 倍 -> 疑似单元倾斜
- 跨 cell 调用量突然上升 -> 单元封闭被破坏
最后一条尤其重要:跨单元调用量是单元化健康度的直接指标,它上升说明有服务在绕过路由直连,是封闭性退化的早期信号。
5. 落地路径
5.1 分片键选择
分片键决定用户归属哪个单元,是整个架构的地基:
| 候选键 | 均匀性 | 稳定性 | 问题 |
|---|---|---|---|
| 用户 ID | 好 | 永久稳定 | 需全局 ID 体系支撑 |
| 手机号 | 中(号段不均) | 可能变更 | 换号后数据搬家 |
| 设备 ID | 好 | 不稳定(多设备) | 同人多设备落入不同单元 |
| 地理位置 | 差(人口集中) | 会迁移 | 出差即跨单元 |
结论:用户 ID 是首选,但前提是 ID 生成本身是全局有序且不依赖中心发号器。分片键一旦选定,迁移成本极高,必须在上线前把「号段扩容」「新单元加入」「老单元下线」三种场景都推演一遍。
5.2 灰度与迁移
从单体迁到单元化不可能一次切换,标准路径是:
迁移四阶段
阶段 1:旁路。新单元并行部署,只承接影子流量,验证正确性
阶段 2:灰度。按用户 ID 号段切 1% -> 5% -> 20%,每档观察
阶段 3:双写。旧库与新单元库双写,用对账工具校验差异
阶段 4:切换。停写旧库,读切新单元,保留回滚开关
每个阶段都必须有可回滚开关与数据对账工具。没有对账的双写等于把数据一致性交给运气。
5.3 与多集群、多活的关系
单元化与多活经常被混为一谈,实际是正交的两件事:
| 维度 | 单元化 | 多活 |
|---|---|---|
| 切分对象 | 用户 | 机房/地域 |
| 目标 | 故障隔离 | 容灾、低延迟 |
| 单元间数据 | 相互独立 | 需要同步 |
| 组合方式 | 单元可跨机房部署 | 每个机房含多个单元 |
典型组合是「多机房 × 每机房多单元」:机房级故障由多活兜底,单元级故障由单元隔离兜底。跨机房数据同步与冲突处理的细节,见 https://plumephp.com/distributed-multi-cluster-cross-cloud/;单元内服务间的通信治理则常交给服务网格,参考 Kubernetes 服务网格 的实践。
6. 常见坑
| 坑 | 后果 | 修法 |
|---|---|---|
| 路由表非纯函数 | 用户在两单元间漂移,数据分裂 | 路由规则固定且可复现 |
| 单元内仍有全局依赖 | 一处故障全单元不可用 | 逐项排查依赖,本地化或加缓存 |
| 共享消息队列 | 队列积压跨单元传染 | 按用户分 Topic 或单元内独立队列 |
| 借容量无上限 | 故障转移引发雪崩 | 设置跨单元流量上限与快速熔断 |
| 忽略单元间时钟与版本 | 异步同步乱序覆盖 | 携带版本号或逻辑时钟 |
| 单元数与团队数不匹配 | 运维复杂度爆炸 | 单元数控制在可运维范围(通常 4~16) |
总结
| 主题 | 关键内容 |
|---|---|
| 单元化动机 | 收敛爆炸半径、消除跨机房调用、突破共享瓶颈 |
| 单元定义 | 接入 + 计算 + 存储 + 依赖自包含,单元内闭环 |
| 单元路由 | 纯函数路由表,用户 ID 哈希,全局一致 |
| 数据设计 | 数据归属与用户归属一致,全局数据缓存化 |
| 故障隔离 | 单元级熔断与降级,容量冗余显式权衡 |
| 落地 | 分片键优先用户 ID,四阶段灰度迁移,可回滚可对账 |
单元化本质上是用「重复部署」换取「故障可控」:多花一些资源,换来一个确定的上界——任何单点故障最多影响 1/N 的用户。它不是一个开关式的改造,而是从分片键、路由表、依赖清单到降级策略的一整套约束。落地时最容易犯的错是「只做了部署切分,没做依赖切分」,结果单元之间依然通过共享中间件连成一片,单元化只停留在纸面上。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。