一台节点坏了,为什么 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 台节点。下表全部是示例值,用于推演口径,不是线上实测:
| 故障 | 影响房间 | 影响比例 | 玩家表现 | 预期处理 |
|---|---|---|---|---|
| 单房间进程 | 1 | 0.01% | 5 人掉线 | 单局补偿,不惊动全局 |
| 单节点 | 250 | 2.5% | 1250 人掉线 | 自动迁移,节点摘除 |
| 单机架 | 1000 | 10% | 5000 人掉线 | 跨机架扩容,人工确认 |
| 单可用区 | 2500 | 25% | 12500 人掉线 | 跨区切流,事故流程 |
| 单地域 | 10000 | 100% | 全部玩家 | 业务级风险,只能演练 |
这张表能直接回答“单房间崩溃是否影响全场”:不会,只要房间进程之间没有共享的可写状态。真正会让单房间故障放大的是共享分配器锁、共享结算队列或共享连接池;这些耦合在部署图上看不见,只能通过影响面模型和故障注入找出来。
验证方法:故障注入与预算核对
- 在灰度集群选一间房,杀掉房间进程,确认只有该局受影响,分配器没有回收其他房间;再在同一节点上杀掉一个房间,确认同节点其他房间继续 tick,排除进程间共享状态;
- 摘除一台灰度节点,记录重新分配的房间数、玩家重连率和结算补偿量,与 250 间的预期对照;
- 对共享组件做单副本故障演练,确认依赖方是否有本地缓存或降级路径;然后用生产容量数据重算每个层级的比例,和预算表逐行核对,超出预算的层级进入隔离改造。演练要在灰度环境做,并且提前准备回滚;不要在生产高峰用“杀一个节点”验证隔离,那不是演练,是事故。
常见错误与边界
第一,用服务器数量算爆炸半径。一台机器上可能跑了 250 间房,也可能只跑了一个空闲 worker;必须用容量或玩家数。第二,只做进程级隔离:进程之间不共享状态,不代表节点故障不会一起带走它们;副本要跨节点、跨机架、跨可用区。第三,忽略共享组件的故障域:会话存储、数据库主库、网关和配置中心通常不在战斗部署图里,却决定了最坏情况。第四,把“自动迁移”当成隔离:迁移能恢复容量,但不能阻止故障瞬间的影响;预算要按故障发生的那一刻计算。第五,预算只按单层设定,不考虑相关故障:机架断电会同时带走多台节点,可用区故障会同时带走多个机架,层级之间要能叠加推演。第六,隔离做完不加观测:没有按故障域聚合的错误率、延迟和容量指标,就无法判断一次故障到底影响了多少,预算表会退化成拍脑袋。
小结
故障域是分层的:进程、节点、机架、可用区、地域。爆炸半径应该用容量占比度量,并按层级设预算。共享组件会把任意层级的故障放大到全平台,去相关部署和舱壁是主要的隔离手段,代价是资源利用率和运维复杂度。把故障域树和放置策略写成代码,可以让“单房间崩溃是否影响全场”从争论变成一次可重复的计算和演练。下一章进入架构表达:用 C4、部署视图和数据流把这一章的决策画成团队能共同维护的图。
阅读导航:上一节:11.2 有状态服务的边界 · 下一节:12.1 C4、部署视图与数据流 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。