《游戏服务端架构·品类、状态与拓扑》11.3 故障域与爆炸半径

《裂隙战场》一台战斗节点故障带走 250 间房,而一个单实例会话存储故障能让全部品类同时掉线。本节把故障域分为进程、节点、机架、可用区和地域,用容量占比度量爆炸半径,给出舱壁、配额、独立连接池与去相关部署的取舍,并用 Go 实现的故障域树计算单房间、单节点与单可用区故障的影响面。

一台节点坏了,为什么 250 间房一起没了

《裂隙战场》的战斗集群按 40 台节点、每台 250 间房规划,周末峰值约 1 万间对局。一个周六晚上,某台节点加载了一张异常大的地图资源,内存被打满,OOM 杀掉了节点上的全部房间进程。250 间房、1250 名玩家同时掉线。

同一周还有一次更隐蔽的故障。会话存储在 zone-a 只有一个主实例,zone-a 的网络分区让三个品类同时无法鉴权:正在进行的房间没有立刻崩,但新登录、重连和结算回调全部失败。一台节点坏了影响 2.5% 的对局,一个共享组件坏了影响 100% 的产品。这两个数字都不是“服务器数量”能解释的:它们取决于故障域的层级,以及组件在层级里的位置。这一节给出一套可计算的爆炸半径口径,以及把隔离策略写成代码的方法。

故障域层级与爆炸半径

故障域是“会一起坏掉的一组东西”。层级越低,故障越常见但影响越小;层级越高,故障越少但影响越大:

层级典型例子一次故障的影响常见检测方式隔离成本
进程单间房、单个 worker一局或一个任务心跳、退出码低
节点一台物理机或虚机该节点上的全部房间节点探针、内核日志中
机架同一供电与交换机的节点数台节点机架级监控中高
可用区独立供电与网络的机房段该区全部实例区域探针、跨区延迟高
地域城市级地理区域整个区域业务指标、外部探测极高

爆炸半径用容量占比度量,而不是用进程数:

爆炸半径 = 单个故障域失效时损失的容量 / 总容量

“容量”可以是房间数、在线玩家数、每秒结算数或收入。用进程数会低估大房间的影响,用服务器数会忽略单机超卖。北辰工作室给每个层级设一个预算,示例值如下:

层级单次故障预算(示例值)超出后的动作
进程≤ 0.1%单局补偿,不触发全局告警
节点≤ 5%自动迁移,容量告警
机架≤ 12%切流到其他机架,人工确认
可用区≤ 30%跨区扩容,进入事故流程
地域业务级风险不设技术预算,只做演练与保险

预算不是“允许坏这么多”,而是“超过这个数,说明隔离设计失效了”。单节点坏掉损失 2.5% 在预算内;如果同一节点上的房间因为共享连接池被放大到 40%,问题就不在节点,而在隔离。

共享组件的隐性耦合

最容易被忽略的爆炸半径来自共享组件。它们通常不在战斗集群的部署图里,却决定了所有依赖者的最坏情况:

共享组件故障表现放大后的爆炸半径缓解手段
单实例会话存储无法鉴权、无法重连全部品类多副本、跨区副本、本地缓存降级
单主数据库写入排队、连接耗尽全部写路径分库、读写分离、连接池隔离
共享网关新连接全部失败全部新玩家多网关、按品类分池
全局配置中心所有服务读到空配置全部服务本地快照、发布校验、灰度

共享组件一旦落在某个故障域里,所有依赖它的工作负载的爆炸半径都至少等于那个故障域。把它放进 zone-a 的单节点,等于把整个平台的可用性押在一台机器上。去相关部署(decorrelated deployment)要解决的正是这个问题:副本不仅要跨进程,还要跨节点、跨机架、跨可用区。

隔离手段与代价

手段做法收益代价
舱壁按品类或房间池拆进程、线程池、连接池故障不跨池传播资源利用率下降,运维单元增加
配额每个租户、品类、接口设上限防止单点打满共享资源需要容量模型和动态调整
独立连接池登录、地图、结算各用各的池慢查询只拖垮自己的池连接数总量上升
去相关部署副本跨节点、跨机架、跨可用区单层故障不带走全部副本跨区延迟与出网成本
单元化(cell)把玩家切成独立单元,单元内闭环故障限制在单元内路由、迁移和容量规划复杂

这些是部署约束,不是某个编排工具的教程。本机没有 Kubernetes 实跑,本节也不声称验证过任何集群调度行为;可验证的是下面的容量模型和校验规则。

用代码计算影响面

下面的 internal/faultdomain 把故障域树、影响面计算和隔离策略校验写成一个包。容量只挂在叶子节点上,内部节点只描述“谁和谁一起坏”:

// file: internal/faultdomain/faultdomain.go
// tier: L1
// verify: go test ./internal/faultdomain/
package faultdomain

import (
	"errors"
	"fmt"
	"sort"
	"strings"
)

// Level runs from the smallest failure domain to the largest.
type Level int

const (
	LevelProcess Level = iota
	LevelNode
	LevelRack
	LevelZone
	LevelRegion
	levelCount
)

var levelNames = [...]string{"process", "node", "rack", "zone", "region"}

func (l Level) String() string { return levelNames[l] }

var (
	ErrBlankID          = errors.New("faultdomain: node id must not be blank")
	ErrBadLevel         = errors.New("faultdomain: level out of range")
	ErrNegativeCapacity = errors.New("faultdomain: capacity must not be negative")
	ErrDuplicateNode    = errors.New("faultdomain: duplicate node id")
	ErrUnknownParent    = errors.New("faultdomain: parent does not exist")
	ErrUnknownNode      = errors.New("faultdomain: unknown node")
	ErrLevelOrder       = errors.New("faultdomain: child level must be finer than parent level")
)

// Node is a container in the fault-domain tree. Only leaves should carry a
// non-zero capacity; internal nodes describe how leaves fail together.
type Node struct {
	ID       string
	Level    Level
	Parent   string
	Capacity int
}

type Tree struct {
	nodes map[string]Node
}

func NewTree() *Tree {
	return &Tree{nodes: make(map[string]Node)}
}

func (t *Tree) Add(n Node) error {
	if strings.TrimSpace(n.ID) == "" {
		return ErrBlankID
	}
	if n.Level < LevelProcess || n.Level >= levelCount {
		return ErrBadLevel
	}
	if n.Capacity < 0 {
		return ErrNegativeCapacity
	}
	if _, ok := t.nodes[n.ID]; ok {
		return ErrDuplicateNode
	}
	if n.Parent != "" {
		parent, ok := t.nodes[n.Parent]
		if !ok {
			return ErrUnknownParent
		}
		if n.Level >= parent.Level {
			return fmt.Errorf("%w: %s(%s) under %s(%s)", ErrLevelOrder,
				n.ID, n.Level, parent.ID, parent.Level)
		}
	}
	t.nodes[n.ID] = n
	return nil
}

// Impact is one failure: dead nodes, lost capacity and its share of the total.
type Impact struct {
	Failed   string
	Level    Level
	Nodes    []string
	Capacity int
	Ratio    float64
}

func (t *Tree) ImpactOf(id string) (Impact, error) {
	node, ok := t.nodes[id]
	if !ok {
		return Impact{}, ErrUnknownNode
	}
	children := make(map[string][]string, len(t.nodes))
	total := 0
	for nodeID, candidate := range t.nodes {
		total += candidate.Capacity
		if candidate.Parent != "" {
			children[candidate.Parent] = append(children[candidate.Parent], nodeID)
		}
	}
	var nodes []string
	var walk func(string)
	walk = func(nodeID string) {
		nodes = append(nodes, nodeID)
		for _, child := range children[nodeID] {
			walk(child)
		}
	}
	walk(id)
	sort.Strings(nodes)
	capacity := 0
	for _, nodeID := range nodes {
		capacity += t.nodes[nodeID].Capacity
	}
	ratio := 0.0
	if total > 0 {
		ratio = float64(capacity) / float64(total)
	}
	return Impact{id, node.Level, nodes, capacity, ratio}, nil
}

// Workload is a set of instances placed on domains. Shared marks a component
// other workloads depend on, such as a session store.
type Workload struct {
	Name      string
	Instances []string
	Shared    bool
}

// Policy is the blast-radius budget. ImpactLevels defaults to process..zone;
// region-wide failure is usually accepted as a business-level risk.
type Policy struct {
	MaxImpactRatio float64
	MinDistinct    map[Level]int
	ImpactLevels   []Level
}

const (
	RuleBlankName       = "blank_name"
	RuleNoInstances     = "no_instances"
	RuleUnknownDomain   = "unknown_domain"
	RuleDomainSpread    = "domain_spread"
	RuleImpactBudget    = "impact_budget"
	RuleSharedSingleton = "shared_singleton"
)

type Issue struct {
	Workload string
	Rule     string
	Detail   string
}

func Validate(t *Tree, workloads []Workload, policy Policy) []Issue {
	var issues []Issue
	for _, workload := range workloads {
		name := strings.TrimSpace(workload.Name)
		if name == "" {
			issues = append(issues, Issue{"<unnamed>", RuleBlankName, ""})
			continue
		}
		domains := make([]string, 0, len(workload.Instances))
		domainSeen := make(map[string]bool, len(workload.Instances))
		for _, raw := range workload.Instances {
			domain := strings.TrimSpace(raw)
			if domain == "" {
				continue
			}
			if _, ok := t.nodes[domain]; !ok {
				issues = append(issues, Issue{name, RuleUnknownDomain, domain})
				continue
			}
			if !domainSeen[domain] {
				domainSeen[domain] = true
				domains = append(domains, domain)
			}
		}
		if len(domains) == 0 {
			issues = append(issues, Issue{name, RuleNoInstances, ""})
			continue
		}

		if workload.Shared && distinctAncestors(t, domains, LevelNode) < 2 {
			issues = append(issues, Issue{name, RuleSharedSingleton,
				"shared component has no independent node failure domain"})
		}
		for _, level := range []Level{LevelProcess, LevelNode, LevelRack, LevelZone, LevelRegion} {
			if required := policy.MinDistinct[level]; required > 0 {
				if got := distinctAncestors(t, domains, level); got < required {
					issues = append(issues, Issue{name, RuleDomainSpread,
						fmt.Sprintf("level=%s got=%d want>=%d", level, got, required)})
				}
			}
		}
		if policy.MaxImpactRatio <= 0 {
			continue
		}
		levels := policy.ImpactLevels
		if len(levels) == 0 {
			levels = []Level{LevelProcess, LevelNode, LevelRack, LevelZone}
		}
		for _, level := range levels {
			groups := make(map[string]int)
			for _, domain := range domains {
				if ancestor, ok := t.ancestorAt(domain, level); ok {
					groups[ancestor]++
				}
			}
			worst, worstCount := "", 0
			for ancestor, count := range groups {
				if count > worstCount || (count == worstCount && ancestor < worst) {
					worst, worstCount = ancestor, count
				}
			}
			if worstCount == 0 {
				continue
			}
			ratio := float64(worstCount) / float64(len(domains))
			if ratio > policy.MaxImpactRatio {
				issues = append(issues, Issue{name, RuleImpactBudget,
					fmt.Sprintf("level=%s ancestor=%s replicas=%d/%d ratio=%.2f",
						level, worst, worstCount, len(domains), ratio)})
			}
		}
	}
	sort.Slice(issues, func(i, j int) bool {
		if issues[i].Workload != issues[j].Workload {
			return issues[i].Workload < issues[j].Workload
		}
		if issues[i].Rule != issues[j].Rule {
			return issues[i].Rule < issues[j].Rule
		}
		return issues[i].Detail < issues[j].Detail
	})
	return issues
}

// ancestorAt walks up until it finds level, or reports that level is finer.
func (t *Tree) ancestorAt(id string, level Level) (string, bool) {
	node, ok := t.nodes[id]
	if !ok {
		return "", false
	}
	for {
		if node.Level == level {
			return node.ID, true
		}
		if node.Level > level || node.Parent == "" {
			return "", false
		}
		node = t.nodes[node.Parent]
	}
}

func distinctAncestors(t *Tree, domains []string, level Level) int {
	seen := make(map[string]bool, len(domains))
	for _, domain := range domains {
		if ancestor, ok := t.ancestorAt(domain, level); ok {
			seen[ancestor] = true
		}
	}
	return len(seen)
}

三个部分对应三个问题:ImpactOf 回答“这个节点坏了,损失多少容量”,它遍历子树、累加叶子容量,再除以总容量得到比例;Validate 回答“副本放得够不够散”,MinDistinct 要求副本在指定层级落到不同祖先,MaxImpactRatio 拒绝任何单层故障超过预算的放置;Shared 回答“共享组件是不是单点”,共享工作负载如果在节点层级没有两个独立故障域,直接报 shared_singleton。

本机验证

internal/faultdomain 有 5 个测试函数、34 个测试运行,覆盖故障域建模、影响面、祖先层级与放置校验。实际运行结果(耗时随机器变化):

$ cd .local/checks/gsarch-lab
$ gofmt -l internal/faultdomain
$ go vet ./internal/faultdomain/
$ go test -count=1 ./internal/faultdomain/
ok  	gsarchlab/internal/faultdomain	0.324s

单房间崩溃的推演

假设《裂隙战场》有 1 万间对局、40 台节点、每台 250 间,机架放 4 台节点,可用区放 10 台节点。下表全部是示例值,用于推演口径,不是线上实测:

故障影响房间影响比例玩家表现预期处理
单房间进程10.01%5 人掉线单局补偿,不惊动全局
单节点2502.5%1250 人掉线自动迁移,节点摘除
单机架100010%5000 人掉线跨机架扩容,人工确认
单可用区250025%12500 人掉线跨区切流,事故流程
单地域10000100%全部玩家业务级风险,只能演练

这张表能直接回答“单房间崩溃是否影响全场”:不会,只要房间进程之间没有共享的可写状态。真正会让单房间故障放大的是共享分配器锁、共享结算队列或共享连接池;这些耦合在部署图上看不见,只能通过影响面模型和故障注入找出来。

验证方法:故障注入与预算核对

  1. 在灰度集群选一间房,杀掉房间进程,确认只有该局受影响,分配器没有回收其他房间;再在同一节点上杀掉一个房间,确认同节点其他房间继续 tick,排除进程间共享状态;
  2. 摘除一台灰度节点,记录重新分配的房间数、玩家重连率和结算补偿量,与 250 间的预期对照;
  3. 对共享组件做单副本故障演练,确认依赖方是否有本地缓存或降级路径;然后用生产容量数据重算每个层级的比例,和预算表逐行核对,超出预算的层级进入隔离改造。演练要在灰度环境做,并且提前准备回滚;不要在生产高峰用“杀一个节点”验证隔离,那不是演练,是事故。

常见错误与边界

第一,用服务器数量算爆炸半径。一台机器上可能跑了 250 间房,也可能只跑了一个空闲 worker;必须用容量或玩家数。第二,只做进程级隔离:进程之间不共享状态,不代表节点故障不会一起带走它们;副本要跨节点、跨机架、跨可用区。第三,忽略共享组件的故障域:会话存储、数据库主库、网关和配置中心通常不在战斗部署图里,却决定了最坏情况。第四,把“自动迁移”当成隔离:迁移能恢复容量,但不能阻止故障瞬间的影响;预算要按故障发生的那一刻计算。第五,预算只按单层设定,不考虑相关故障:机架断电会同时带走多台节点,可用区故障会同时带走多个机架,层级之间要能叠加推演。第六,隔离做完不加观测:没有按故障域聚合的错误率、延迟和容量指标,就无法判断一次故障到底影响了多少,预算表会退化成拍脑袋。

小结

故障域是分层的:进程、节点、机架、可用区、地域。爆炸半径应该用容量占比度量,并按层级设预算。共享组件会把任意层级的故障放大到全平台,去相关部署和舱壁是主要的隔离手段,代价是资源利用率和运维复杂度。把故障域树和放置策略写成代码,可以让“单房间崩溃是否影响全场”从争论变成一次可重复的计算和演练。下一章进入架构表达:用 C4、部署视图和数据流把这一章的决策画成团队能共同维护的图。

阅读导航:上一节:11.2 有状态服务的边界 · 下一节:12.1 C4、部署视图与数据流 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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