一个单元故障为什么拖垮了全局
订单入口按用户分单元:华东用户落在 sh-a,华南落在 sh-b,海外落在 hz-c。某次 sh-b 的交换机故障,按预期只有华南用户受影响。实际的故障单里,华东用户的支付回调也开始超时:回调服务虽然部署在 sh-a,但它同步调用的库存预占接口只有 sh-b 一个实例,因为“库存是全局服务,所有单元共用”。这不是拓扑失效,而是故障边界从来没有按单元切开。
错误直觉是“多活等于任意机房挂了,另一个机房自动接管全部流量”。多活首先回答的是隔离问题,不是接管问题:哪些请求只依赖本单元、哪些数据只有一个归属单元、一个单元失效时写入要不要继续。把这三个问题回答清楚,拓扑才有意义;否则三个机房只是三个会一起挂的副本。
站内 单元化架构 讨论过单元划分原则,多地域双活 讨论过流量调度;本节不重复这些概念,而是把拓扑写成可校验的放置计划,让“单单元故障影响多少租户”变成能算出来的数。
三种拓扑的隔离边界
三种拓扑常被混为一谈,因为都叫“多活”。真正的区别在故障域、副本方式和写路径。
| 维度 | 单元化 | 同城双活 | 异地多活 |
|---|---|---|---|
| 故障域 | 单元及其私有依赖 | 同城两个单元 | 一个地域 |
| 数据副本 | 单元主加冷备或温备 | 同城同步复制 | 跨地域异步复制 |
| 单单元故障后写 | 该单元租户阻塞,等待归属转移 | 同左 | 同左 |
| 单单元故障后读 | 冷备不可读,直接不可用 | 温备可读,已提交数据不丢(设计目标) | 异步备可读,存在陈旧窗口 |
| 爆炸半径 | 由放置计划决定,通常接近 1/N | 同左 | 同左,但跨地域延迟会放大 |
| 主要成本 | 单元内重复建设 | 同城专线加同步写延迟 | 跨地域带宽加冲突处理 |
这张表里最容易被忽略的是“单单元故障后写”。三种拓扑在写路径上是一样的:归属单元失效后,备用单元不能凭“我在另一个机房”就把写接过来。接写需要一次有记录的归属转移,否则两个单元会同时认为自己拥有同一批订单。读路径的差异只决定故障期间能不能看到旧数据,不决定写能不能继续。
因此“多活”不自动等于“少停机”。它先买到的是故障隔离:一个单元的故障不会传播到其他单元。能不能把停机压到秒级,取决于归属转移的速度,那是 12.3 要测的 RTO,不是拓扑图能承诺的。
单单元故障不等于 1/N 流量
很多方案评审会默认“三个单元坏一个,损失三分之一”。只有租户在单元间均匀分布、备用单元不重叠、且没有共享依赖时,这个结论才成立。订单系统里三个条件通常都不满足:大客户会被单独放在一个单元,备用单元会交叉放置,支付回调和库存查询可能仍然指向单点。
所以爆炸半径要按放置计划算,而不是按机房数量算。需要区分的量至少有四个:
- 受影响租户数:主单元落在故障单元里的租户数量;
- 写入阻塞数:这些租户的写请求全部无法提交,即使读还能继续;
- 读到旧数据的租户数:备用单元能提供读、但副本是异步的;
- 完全不可用数:主单元和备用单元同时失效,连读也没有。
下面把放置计划写成可校验的数据结构,让这四个量从计划里直接算出来,而不是在故障发生后靠人肉统计。
把拓扑写成可校验的放置计划
模型只有三个类型:Cell 是故障域,Placement 把一个租户的主单元和备用单元钉死,Plan 是两者的集合。Validate 检查五条不变量:单元 ID 唯一、容量非负、租户不重复、主备单元都存在且不相同、主单元承载的租户数不超过容量。
把容量校验放在放置阶段而不是故障阶段,是因为容量错误在故障时会被放大:平时每个单元装 60%,故障后幸存单元要接收两倍流量。这个模型只检查主单元容量;备用单元在 N-1 场景下是否超容,需要单独的容量计划,本节不把它伪装成已经解决。
Impact 只做一件事:给定失效单元集合,遍历放置计划,按拓扑模式决定读路径的结果。写入规则三种模式完全一致,都是主单元失效即阻塞;读规则则分别返回不可用、温备可读、陈旧可读。这样的实现刻意不包含自动接管,因为自动接管属于状态机和演练范围,混进拓扑计算会让“影响范围”变成“假设接管成功后的影响范围”,两个问题会互相污染。
可运行实现
// file: internal/platform/multisite/plan.go
// tier: L1
// verify: go test ./internal/platform/multisite/ -count=1
package multisite
import (
"fmt"
"sort"
)
// Mode names the topology being compared.
type Mode string
const (
// ModeCell pins every tenant to one cell and keeps a cold standby.
ModeCell Mode = "unitized"
// ModeDualCity runs two cells in the same metro with synchronous copy.
ModeDualCity Mode = "same-city-dual"
// ModeRemote runs cells in different regions with asynchronous copy.
ModeRemote Mode = "geo-multi-active"
)
// Cell is one failure domain: its racks share power, switch and network fate.
type Cell struct {
ID string
Region string
Capacity int
}
// Placement assigns a tenant to a home cell and a standby cell.
type Placement struct {
Tenant string
Home string
Standby string
}
// Plan is a topology plus the placement of every tenant.
type Plan struct {
Mode Mode
Cells []Cell
Tenants []Placement
}
// Validate rejects plans whose blast radius cannot be computed.
func (p Plan) Validate() error {
if p.Mode != ModeCell && p.Mode != ModeDualCity && p.Mode != ModeRemote {
return fmt.Errorf("multisite: unknown mode %q", p.Mode)
}
if len(p.Cells) == 0 {
return fmt.Errorf("multisite: plan has no cells")
}
capacity := make(map[string]int, len(p.Cells))
for _, c := range p.Cells {
if c.ID == "" {
return fmt.Errorf("multisite: cell with empty id")
}
if _, dup := capacity[c.ID]; dup {
return fmt.Errorf("multisite: duplicate cell %q", c.ID)
}
if c.Capacity < 0 {
return fmt.Errorf("multisite: cell %q has negative capacity", c.ID)
}
capacity[c.ID] = c.Capacity
}
seen := make(map[string]struct{}, len(p.Tenants))
homes := make(map[string]int, len(p.Cells))
for _, t := range p.Tenants {
if t.Tenant == "" {
return fmt.Errorf("multisite: tenant with empty name")
}
if _, dup := seen[t.Tenant]; dup {
return fmt.Errorf("multisite: duplicate tenant %q", t.Tenant)
}
seen[t.Tenant] = struct{}{}
if _, ok := capacity[t.Home]; !ok {
return fmt.Errorf("multisite: tenant %q has unknown home %q", t.Tenant, t.Home)
}
if _, ok := capacity[t.Standby]; !ok {
return fmt.Errorf("multisite: tenant %q has unknown standby %q", t.Tenant, t.Standby)
}
if t.Home == t.Standby {
return fmt.Errorf("multisite: tenant %q uses %q as its own standby", t.Tenant, t.Home)
}
homes[t.Home]++
}
ids := make([]string, 0, len(capacity))
for id := range capacity {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
if homes[id] > capacity[id] {
return fmt.Errorf("multisite: cell %q hosts %d tenants above capacity %d", id, homes[id], capacity[id])
}
}
return nil
}
// Impact is the cost of losing a set of cells.
type Impact struct {
TenantsAffected int
WritesBlocked int
ReadsServed int // served by a warm standby with a synchronous copy
ReadsDegraded int // served stale from an asynchronous standby
Unavailable int // no read path at all
RedundancyLost int // standby lost while the home stayed healthy
}
// Impact computes the blast radius of losing the named cells.
func (p Plan) Impact(failedCells ...string) Impact {
failed := make(map[string]bool, len(failedCells))
for _, id := range failedCells {
failed[id] = true
}
var out Impact
for _, t := range p.Tenants {
homeDown := failed[t.Home]
standbyDown := failed[t.Standby]
if !homeDown {
if standbyDown {
out.RedundancyLost++
}
continue
}
out.TenantsAffected++
out.WritesBlocked++
switch {
case standbyDown:
out.Unavailable++
case p.Mode == ModeCell:
out.Unavailable++
case p.Mode == ModeDualCity:
out.ReadsServed++
case p.Mode == ModeRemote:
out.ReadsDegraded++
}
}
return out
}
模型里没有“自动切换”字段,这是有意的。拓扑计划描述的是稳态归属和故障时的可读性,切换是另一套状态机;把两者混在一起,评审时很容易把“计划里写了备用单元”误读成“故障时一定切得过去”。
本机运行结果
测试用三个单元、六个租户:sh-a 与 sh-b 同城,hz-c 异地,主备交叉放置。分别验证放置校验、单单元故障、双单元同时故障、仅备用单元故障和参数顺序无关。命令与原始输出:
$ go test ./internal/platform/multisite/ -v -count=1
=== RUN TestValidateRejectsBadPlans/duplicate_cell
plan_test.go:70: rejected duplicate cell: multisite: duplicate cell "sh-a"
=== RUN TestValidateRejectsBadPlans/capacity_exceeded
plan_test.go:70: rejected capacity exceeded: multisite: cell "sh-a" hosts 2 tenants above capacity 1
=== RUN TestImpactByMode/unitized_loses_reads_on_home_failure
plan_test.go:118: mode=unitized failed=[sh-b] affected=2 writes_blocked=2 reads_served=0 reads_degraded=0 unavailable=2 redundancy_lost=2
=== RUN TestImpactByMode/same-city_dual_serves_from_warm_standby
plan_test.go:118: mode=same-city-dual failed=[sh-b] affected=2 writes_blocked=2 reads_served=2 reads_degraded=0 unavailable=0 redundancy_lost=2
=== RUN TestImpactByMode/geo_multi-active_serves_stale_reads
plan_test.go:118: mode=geo-multi-active failed=[sh-b] affected=2 writes_blocked=2 reads_served=0 reads_degraded=2 unavailable=0 redundancy_lost=2
=== RUN TestImpactByMode/two_cells_down_expose_shared_standby
plan_test.go:118: mode=same-city-dual failed=[sh-b hz-c] affected=4 writes_blocked=4 reads_served=2 unavailable=2 redundancy_lost=2
=== RUN TestStandbyLossKeepsWrites
plan_test.go:143: standby_only affected=0 writes_blocked=0 redundancy_lost=1
=== RUN TestImpactIsDeterministic
plan_test.go:156: deterministic affected=4 writes_blocked=4 reads_degraded=2
PASS
ok archlab/internal/platform/multisite 0.397s
读数分三组。单单元故障时,六个租户里有两个主单元在 sh-b,受影响比例是 2/6,不是 1/3;三种模式下写入都阻塞 2 个租户,差别只在读:单元化两个租户完全不可用,同城双活两个租户由温备提供读,异地多活两个租户读到可能陈旧的数据。同时故障 sh-b 和 hz-c 时,受影响租户升到 4/6,因为备用单元也落在故障集合里:四个受影响租户中只有两个还能通过 sh-a 读,另外两个连备用也没有。冗余损失单独计数,避免把“备用没了但主还活着”误算成业务受损。
这些数字只说明模型在给定放置下的算术结果,不代表任何真实机房的能力。真实爆炸半径还取决于共享的 DNS、配置中心、支付通道和专线,必须在演练中逐项确认,不能由拓扑图推导。
拓扑选择与故障处置决策表
| 业务约束 | 判据 | 选择 | 复核与退出条件 |
|---|---|---|---|
| 大客户要求物理隔离、单元内闭环 | 单单元故障只影响该单元租户 | 单元化 | 审计共享依赖,确认无跨单元同步调用 |
| 城市级容灾、可接受同步写延迟 | 同城专线可支撑同步复制 | 同城双活 | 演练确认已提交写不丢、温备可读 |
| 地域级容灾、可接受陈旧读 | 业务能容忍异步复制窗口 | 异地多活 | 演练确认 RPO 与冲突处置(12.2) |
| 备用单元容量低于主单元峰值 | N-1 后容量不足 | 不做自动全量接管 | 降级、排队或限流,容量补齐后再评估 |
| 单单元故障已发生 | 主单元不可写 | 冻结写入并按归属转移 | 转移完成且校验通过才恢复写(12.3) |
最后一行是本节和下一章的分界:拓扑只能告诉你“谁受影响”,不能告诉你“多久能恢复”。恢复时间由检测、决策、归属转移、DNS 生效和校验共同决定,这些必须在演练里逐项测量。
常见错误与边界
第一,把“多活”当成“多副本”。三个机房各放一份数据,但写路径仍然只有一个主单元,这不叫多活,只是异地备份;故障时既不能就近写,也不能自动接管。
第二,备用单元按正常流量规划容量。平时每个单元 60% 负载,单单元故障后幸存单元要接收接近两倍的租户,容量校验必须在放置阶段做 N-1 检查,而不是等故障时才发现。
第三,让同一个租户在两个单元同时可写。拓扑层看起来只是“就近接入”,数据层会立刻变成双向复制的冲突问题;归属必须唯一,跨单元写只能通过归属转移,不能靠最后写入获胜。
第四,用机房数量估算爆炸半径。租户分布、主备交叉和共享依赖都会改变结果;本机测试里两个单元同时故障就影响了 4/6 租户,其中两个连读都不可用,这个结论无法从“三单元坏两个等于三分之二”直接得到。
小结
多活拓扑的第一产出不是“切换多快”,而是“故障能隔离到哪里”。单元化把爆炸半径限制在单元内,同城双活用同步复制换到可读的温备,异地多活用异步复制换地域级容灾,但三者都不会自动解决写归属。把放置计划写成可校验的数据结构后,受影响租户、写入阻塞、陈旧读和完全不可用可以分别计算,评审时不再用“大概三分之一”这种口径。
拓扑确定后,下一个问题立刻出现:如果两个单元都想写同一批订单,数据层靠什么阻止互相覆盖。下一节讨论双向复制的回环、归属与版本向量。
阅读导航:上一节:延迟任务、时间轮与错过触发的补偿 · 下一节:数据同步与冲突:双向复制、回环与归属 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。