一次机房级故障暴露的问题
假设「回声」已经从一个机房扩到三个地域,每个地域有完整的应用集群和数据库。某天凌晨,华东机房的交换机故障,机房内的应用和数据库同时不可用。团队原以为“多地域部署”已经足够,结果监控显示:故障开始后的 40 秒内,三个地域的用户一起报错,连华北和华南的用户也无法登录。
原因不复杂。三个地域共享同一个用户库和同一个订单库,主库在华东。华东一挂,所有写入都失败;读请求虽然能走本地副本,但登录校验、下单、支付这些强依赖主库的路径全部阻塞。更隐蔽的是,网关的会话状态存在华东的 Redis 里,用户被负载均衡随机分到其他地域后,会话找不到,表现为“刚登录又被踢下线”。
这次故障之后,团队提出了一个词:单元化。但第一次评审就卡住了。有人问,单元化是不是把服务复制三份、让每个地域都能处理所有请求?如果是,那和原来的多地域部署有什么区别?也有人问,既然单元化不能让单次请求更快,为什么值得付出数据同步和路由改造的代价?
这两个问题正好是本章的起点。单元化解决的不是“性能更好”,而是“故障不扩散”:把用户和数据切成互不依赖的集合,让一个单元的故障只影响这个单元里的用户。它换取的是可预期的故障半径,代价是路由、同步和运维复杂度。
单元化不是性能优化
先把概念拆开。一个单元(cell)是一组可以独立服务某批用户的资源集合:应用实例、数据库分片、缓存、消息队列和定时任务都齐备。单元之间尽量不共享同步调用,用户被稳定地映射到某个单元,这个单元称为用户的“家单元”(home cell)。
| 方案 | 故障半径 | 数据写入 | 扩容方式 | 主要代价 |
|---|---|---|---|---|
| 单机房多副本 | 整个产品 | 单点主库 | 垂直加机器 | 机房级故障无法兜底 |
| 多地域共享主库 | 全局写入 | 跨地域写主库 | 读副本扩容 | 主库故障全站不可写 |
| 多地域双活 | 取决于冲突处理 | 两地都写 | 按地域扩 | 冲突与数据归属复杂 |
| 单元化 | 单个单元 | 单元内闭环 | 按单元复制 | 路由与同步成本 |
表里的判断标准不是“哪个方案更先进”,而是“故障发生时,最坏会影响多少用户”。单元化的目标是把最坏情况从“全站”压缩到“一个单元”,并且让其余单元继续正常服务。
这也解释了为什么单元化不能只改部署。应用复制三份很容易,难的是三件事:
- 数据要按用户切开,并且每个单元的写入在单元内闭环,不能偷偷跨单元读主库。
- 用户到单元的映射要稳定,今天在 A 单元的用户,明天不能因为一次发布漂到 B 单元。
- 单元故障后的降级路径要明确:是切换到备用单元,还是只读,还是排队等恢复。
如果这三点没有同时成立,所谓的单元化只是“多部署了几套服务”,故障来时依然会通过共享数据库、共享缓存或共享配置扩散回全局。
还需要强调,单元化通常会让单次请求变慢一点点,而不是更快。家单元优先意味着大部分请求不需要跨单元,但路由查询、映射缓存和同步链路都会增加固定开销。它换来的是故障隔离和容量可复制,不是延迟优化。把单元化包装成性能项目,往往会在预算评审时被问倒,也会让团队在延迟回退时怀疑方案本身。
单元大小由什么决定
单元不是越小越好。单元太小,单元数量变多,映射表、同步链路和运维成本都会上升;单元太大,故障半径又压不下来。常见的决定因素有四个:
- 单个单元要能承接多少用户,才能让故障时“剩余容量接得住”。
- 数据库分片是否容易按用户 ID 切开,跨分片事务能否避免。
- 发布和演练能否按单元灰度,而不是一次动全站。
- 值班团队能否同时维护的单元数量。
这些因素没有统一答案。案例假设值是每个单元承载全站 10% 到 20% 的用户,故障时其余单元至少保留 30% 的余量。这个数字不是标准,只是提醒:单元化的容量规划必须把“少一个单元”当成常态,而不是极端情况。
路由必须稳定且可解释
用户路由是单元化的入口。它要做的事情听起来很简单:给定一个用户和一组单元,返回一个单元名。但生产环境里,路由函数必须同时满足四个约束。
第一,优先回家单元。用户的数据写在哪个单元,请求就应该去哪个单元,否则要么跨单元读,要么读到旧数据。家单元是数据归属,不是负载均衡的偏好。
第二,家单元不可用时,降级必须确定性。如果每次故障都随机挑一个健康单元,同一用户在一次会话里可能被分到不同单元,缓存、会话和幂等键都会跟着漂移。确定性降级至少让“这次故障期间这个用户在哪”可以被解释和复现。
第三,结果要可重复。同一组输入重复调用必须得到同一结果,否则测试无法回归,排障也无法复现。路由函数里不能有随机数、当前时间或遍历 map 的顺序。
第四,失败要显式。没有健康单元时必须报错,而不是返回空字符串让上层猜。空字符串会一路传到数据访问层,最后变成一条难以定位的“未知单元”错误。
| 路由策略 | 稳定性 | 故障时行为 | 适用场景 |
|---|---|---|---|
| 随机分配 | 差 | 每次结果不同 | 无状态短任务 |
| 轮询 | 差 | 结果随调用次数变化 | 纯无状态读 |
| 一致性哈希 | 较好 | 节点变化时部分重映射 | 缓存分片 |
| 家单元 + 字典序降级 | 好 | 结果可复现 | 有状态用户请求 |
表里最后一行就是本章实现采用的策略。它不追求把流量均摊得最漂亮,而是追求“故障期间行为可解释”。在单元化场景里,可解释性比均摊更重要,因为排障、回切和数据校验都依赖同一个映射。
降级为什么用字典序
字典序看起来随意,但它满足两个工程要求:不依赖输入顺序,也不依赖运行时状态。只要单元集合不变,排序结果就不变;即使调用方把单元列表打乱,输出仍然一致。
有人会问,为什么不按剩余容量或延迟选择?可以,但那会把路由函数变成依赖实时指标的动态决策,稳定性和可测试性都会下降。更合理的做法是把“选哪个健康单元”留在路由层之外:路由只负责给出确定性的候选顺序,容量调度和权重可以再叠加一层,但必须保证同一用户在同一次故障中的落点不跳变。
如果业务确实需要按权重分配,也应该把权重做成显式输入,并记录决策依据。最忌讳的是在路由函数里读一个全局配置,配置一改,所有用户的路由结果同时变化,故障复盘时没人说得清当时用的是哪版规则。
一次“只切一半流量”的事故
案例假设:某产品有三个单元,分别承载 40%、35% 和 25% 的用户。华东单元发生网络分区,应用还能启动,但数据库连接时好时坏。值班同学看到健康检查仍然通过,因为健康检查只探测了应用端口,没有探测数据库。
于是路由层认为华东单元健康,继续把 40% 的用户送进去。这些用户的请求一半成功、一半超时;客户端重试又把流量放大了一倍。更糟的是,部分写请求在数据库恢复的瞬间成功,部分失败,用户看到的是“订单有时存在、有时不存在”。
事后复盘发现三个问题:
- 健康检查的粒度和路由依赖的粒度不一致。应用端口活着,不代表单元能完成一次真实写入。
- 降级没有和故障状态绑定。路由层不知道数据库异常,只能相信一个过粗的健康位。
- 重试没有幂等约束。超时后的重试在同一单元内制造了重复写入。
这次事故的直接损失是 40% 的用户在 25 分钟内体验受损,间接损失是数据对账花了三天。根本原因不是路由算法写错了,而是“健康”的定义太窄。单元化的路由必须建立在“这个单元能否完成目标业务”的判断上,而不是“进程是否还活着”。
修复方案分三层:健康检查增加一次真实的读和一次带幂等的写探针;路由层接收单元级熔断状态,熔断后立即把家单元视为不可用;客户端重试必须携带幂等键,服务端按用户和请求 ID 去重。三层都到位后,同样的网络分区只会影响探测失败的那部分请求,不会把 40% 的用户一起拖下水。
用 Go 实现用户路由
下面的代码来自本书配套的 webapp-lab。Route 的输入是用户和单元列表,输出是单元名。它先看家单元是否健康,再按单元名字典序选择第一个健康单元;同名单元重复出现时,只有全部健康才算健康,避免输入顺序影响结果。
// file: internal/cell/route.go
// tier: L1
// verify: go test ./internal/cell/
package cell
import (
"errors"
"fmt"
"sort"
)
// ErrNoHealthyCell 表示没有任何单元可以接收流量。
var ErrNoHealthyCell = errors.New("cell: no healthy cell")
// Cell 是一个独立部署、独立承载流量的单元。
type Cell struct {
Name string
Healthy bool
}
// User 只需要归属单元,不需要完整画像。
type User struct {
ID string
HomeCell string
}
// Route 优先把用户送回 HomeCell;家单元不可用时,按单元名的字典序
// 选择第一个健康单元。字典序是确定性的,保证同一用户重复调用结果稳定。
func Route(u User, cells []Cell) (string, error) {
if len(cells) == 0 {
return "", ErrNoHealthyCell
}
healthy := make(map[string]bool, len(cells))
names := make([]string, 0, len(cells))
for _, c := range cells {
if c.Name == "" {
continue
}
if _, ok := healthy[c.Name]; !ok {
names = append(names, c.Name)
healthy[c.Name] = c.Healthy
continue
}
// 同名单元重复上报时,只有全部健康才算健康,避免顺序影响结果。
healthy[c.Name] = healthy[c.Name] && c.Healthy
}
if healthy[u.HomeCell] {
return u.HomeCell, nil
}
sort.Strings(names)
for _, name := range names {
if healthy[name] {
return name, nil
}
}
return "", fmt.Errorf("%w for user %q", ErrNoHealthyCell, u.ID)
}
几个实现细节值得说明。
ErrNoHealthyCell是一个可被errors.Is识别的哨兵错误。上层可以据此决定返回 503、进入只读模式,还是把请求排队,而不是靠字符串匹配。- 家单元判断使用
healthy[u.HomeCell]。如果用户的家单元根本不在列表里,这个查询返回false,自然进入降级路径,不需要额外分支。 - 同名单元合并采用“与”逻辑,而不是“后者覆盖前者”。健康上报可能来自多个探针,只要有一个探针说它不健康,就不应该把流量送进去。
- 排序发生在收集完所有单元之后。这样无论调用方如何排列输入,输出都一致。
- 返回错误时带上用户 ID,方便日志定位;但错误文本不包含完整用户信息,避免把敏感标识写进日志。
代码刻意没有做的事也要说清楚:它不查询数据库,不读配置中心,不做权重分配,也不缓存结果。这些都属于路由函数的外围。把纯函数保持得足够小,才能用可回归测试锁住行为,再把缓存、熔断和配置放在可观测的外层。
测试:家单元、降级与稳定性
路由的测试重点不是“能返回一个单元”,而是“在边界上行为不变”。下面的用例覆盖家单元优先、家单元故障后的字典序降级、重复调用稳定、无健康单元、同名单元冲突和空家单元六种情况。
// file: internal/cell/route_test.go
// tier: L1
// verify: go test ./internal/cell/
package cell
import (
"errors"
"testing"
)
func TestRoutePrefersHomeCell(t *testing.T) {
cells := []Cell{{Name: "sh", Healthy: true}, {Name: "bj", Healthy: true}}
got, err := Route(User{ID: "u1", HomeCell: "bj"}, cells)
if err != nil {
t.Fatal(err)
}
if got != "bj" {
t.Fatalf("route = %q, want bj", got)
}
}
func TestRouteFallsBackInSortedOrder(t *testing.T) {
cells := []Cell{
{Name: "sz", Healthy: true},
{Name: "bj", Healthy: true},
{Name: "sh", Healthy: false},
}
got, err := Route(User{ID: "u2", HomeCell: "sh"}, cells)
if err != nil {
t.Fatal(err)
}
if got != "bj" {
t.Fatalf("route = %q, want bj", got)
}
}
func TestRouteIsStableForSameUser(t *testing.T) {
cells := []Cell{
{Name: "c", Healthy: true},
{Name: "a", Healthy: true},
{Name: "b", Healthy: true},
}
first, err := Route(User{ID: "u3", HomeCell: "gone"}, cells)
if err != nil {
t.Fatal(err)
}
for i := 0; i < 20; i++ {
got, err := Route(User{ID: "u3", HomeCell: "gone"}, cells)
if err != nil {
t.Fatal(err)
}
if got != first {
t.Fatalf("call %d route = %q, want %q", i, got, first)
}
}
}
func TestRouteWithoutHealthyCell(t *testing.T) {
cells := []Cell{{Name: "sh", Healthy: false}, {Name: "bj", Healthy: false}}
if _, err := Route(User{ID: "u4", HomeCell: "sh"}, cells); !errors.Is(err, ErrNoHealthyCell) {
t.Fatalf("err = %v, want ErrNoHealthyCell", err)
}
if _, err := Route(User{ID: "u4"}, nil); !errors.Is(err, ErrNoHealthyCell) {
t.Fatalf("nil cells err = %v, want ErrNoHealthyCell", err)
}
}
func TestRouteDuplicateCellHealthIsConservative(t *testing.T) {
cells := []Cell{
{Name: "sh", Healthy: true},
{Name: "sh", Healthy: false},
{Name: "bj", Healthy: true},
}
got, err := Route(User{ID: "u5", HomeCell: "sh"}, cells)
if err != nil {
t.Fatal(err)
}
if got != "bj" {
t.Fatalf("route = %q, want bj", got)
}
}
func TestRouteEmptyHomeCellUsesFallback(t *testing.T) {
cells := []Cell{{Name: "bj", Healthy: true}, {Name: "sh", Healthy: true}}
got, err := Route(User{ID: "u6"}, cells)
if err != nil {
t.Fatal(err)
}
if got != "bj" {
t.Fatalf("route = %q, want bj", got)
}
}
最值得关注的是 TestRouteIsStableForSameUser。它连续调用 20 次,确认结果不随调用次数变化。这个测试看起来笨,却能挡住未来有人为了“让流量更均匀”偷偷加入随机数或时间因子。
TestRouteFallsBackInSortedOrder 固定了降级顺序。输入里 sz 排在 bj 前面,但期望结果是 bj,因为函数会先排序再选择。这个用例的价值在于:以后有人把实现改成“按输入顺序取第一个健康单元”,测试会立刻失败。
TestRouteDuplicateCellHealthIsConservative 固定了同名单元的合并语义。健康上报出现重复并不罕见,如果实现改成“最后一条覆盖”,测试会暴露这个变化。保守策略的代价是可能误判一个单元不健康,但它避免了把流量送进一个至少有一个探针认为有问题的单元。
测试没有覆盖的部分同样重要:它不验证健康检查本身是否准确,不验证单元容量是否足够,也不验证跨单元数据是否一致。这些需要集成测试和演练来回答,单元测试只负责把路由函数的契约钉死。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/cell/ -count=1
一次本机运行的真实输出如下,耗时随机器变化:
ok webapp/internal/cell 0.233s
ok 表示包内所有测试通过。因为没有加 -v,输出不会逐个列出用例;需要确认具体用例时可以加 -v 再跑一次。判定标准始终是退出码为 0 且没有 FAIL。
小结
单元化首先是一个故障隔离方案,而不是性能方案。它把用户和数据切成互不依赖的集合,让一个单元的故障只影响这个单元的用户;代价是路由、同步和运维复杂度上升。判断一个系统是否真的单元化,不要看部署了几套服务,而要看数据写入是否在单元内闭环、用户映射是否稳定、降级路径是否明确。
用户路由是单元化的入口,必须满足家单元优先、降级确定、结果可重复、失败显式四个约束。本章的实现用字典序降级换取可解释性,用同名单元“与”合并换取保守安全,用哨兵错误换取上层可判断的失败语义。
下一节会进入单元之间最难的部分:同一条数据在两个单元被同时修改时,怎样判定冲突、保留哪一份,以及为什么不能靠时间戳静默覆盖。
阅读导航:上一节:目录与学习路径 · 下一节:2.2 数据同步与冲突 。
常见错误与边界
- 把单元化当成性能优化。单元化通常增加固定开销,它换的是故障半径,不是更低延迟。
- 健康检查只探端口。应用活着但数据库不可用,路由仍会把流量送进故障单元。
- 降级用随机或轮询。同一用户在故障期间落点漂移,缓存、会话和幂等键都会失效。
- 同名单元后者覆盖前者。健康上报重复时,这会把一个已知不健康的单元标记为健康。
- 家单元不在列表里就报错。正确行为是进入降级路径,而不是拒绝服务。
- 路由函数读全局配置。配置一变所有用户同时漂移,故障复盘无法还原当时的决策。
- 没有健康单元时返回空字符串。空值会一路下传,最后变成难以定位的“未知单元”错误。
- 只做单元拆分,不做数据归属。共享主库会让单元故障重新扩散成全局故障。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。