《后端架构设计·分布式与数据一致性》12.1 多活拓扑:单元化、双活与就近接入

从一个单元故障却拖垮全局订单入口的场景出发,拆解单元化、同城双活与异地多活的隔离边界,给出带容量校验和故障影响计算的 Go 拓扑模型,用三组可运行测试对比单单元故障、双单元同时故障与仅备用单元故障时的写入阻塞、读到旧数据和冗余损失,并附拓扑选型决策表与退出条件。

一个单元故障为什么拖垮了全局

订单入口按用户分单元:华东用户落在 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 租户,其中两个连读都不可用,这个结论无法从“三单元坏两个等于三分之二”直接得到。

小结

多活拓扑的第一产出不是“切换多快”,而是“故障能隔离到哪里”。单元化把爆炸半径限制在单元内,同城双活用同步复制换到可读的温备,异地多活用异步复制换地域级容灾,但三者都不会自动解决写归属。把放置计划写成可校验的数据结构后,受影响租户、写入阻塞、陈旧读和完全不可用可以分别计算,评审时不再用“大概三分之一”这种口径。

拓扑确定后,下一个问题立刻出现:如果两个单元都想写同一批订单,数据层靠什么阻止互相覆盖。下一节讨论双向复制的回环、归属与版本向量。

阅读导航:上一节:延迟任务、时间轮与错过触发的补偿 · 下一节:数据同步与冲突:双向复制、回环与归属 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查