《游戏服务端架构·规模化、全球化与成本》9.2 资源配额与混部

《泡泡工坊》的离线报表和《裂隙战场》的房间服混在同一批节点上,活动夜一次内存溢出把正在对局的房间踢了下去。本节给出产品、环境、玩法三层配额模型,区分可超卖与不可超卖的资源,定义驱逐顺序与隔离检查,并用可运行的 Go 夹具演示配额校验、超卖准入与混部冲突检测。

三款产品挤进同一个集群

北辰工作室把三款产品的服务端放进了同一个 northstar-platform 集群。理由很直接:单独为《泡泡工坊》维持一套低负载集群不划算,混部可以把夜间的闲置容量利用起来。

混部第一个月就出了事故。活动夜,《群岛纪元》的赛季结算批处理在晚上八点启动,导入任务没有设置内存上限,把节点页缓存吃满;同一节点上的《裂隙战场》房间服因为内存压力被内核杀掉,六个正在进行的对局中断。告警只写了“节点内存不足”,没人能立刻判断该杀谁、该保谁。

问题不在混部本身,而在于混部缺少三样东西:配额说明每个产品最多能用多少,隔离说明用了别人的资源会怎样,驱逐顺序说明资源不够时先牺牲谁。这一节按这个顺序展开。

配额是分配上限,不是物理容量

配额回答“谁能用多少”,容量回答“总共有多少”。两者用一个超卖比连接:可售总量 = 物理容量 × 超卖比。把配额直接设成物理容量,等于放弃混部;把配额设成容量的三倍,等于赌所有产品不会同时峰值。

配额至少分三层,缺一层就会出现无法解释的争抢:

层级例子作用
产品《裂隙战场》整体上限防止单个产品吃掉整个集群
环境prod / staging / perf防止压测挤占生产
玩法或服务房间服、结算、回放转码防止单个功能拖垮产品

环境这层最容易被省略。压测环境用同一套配额键,跑一次全链路压测就把生产房间服挤到没有余量。正确做法是让 env 进入配额主键,并且生产配额只能由发布系统修改,压测账号无权调整。

配额校验要在准入时做,不能只在月底看账单。需要拦住的请求有四类:超出单维度上限、累计超出、配额不存在、维度未声明。累计校验尤其重要:单次申请 40 核看起来没超 100 核的上限,但前面已经批了 70 核,第二个请求就该被拒绝。拒绝时返回“哪个产品、哪个环境、哪个维度、已用多少”,而不是只回一个 429。

什么能超卖,什么不能

超卖的本质是“假设不会同时用满”。这个假设对某些资源成立,对另一些会直接变成事故:

资源能否超卖原因保护手段
CPU可以,谨慎大部分服务平均利用率低于峰值request/limit、CFS 配额、离线限速
内存不可以超卖导致节点级 OOM,杀谁由内核决定硬 limit、禁止无 limit 的 Burstable
网络带宽不可以按均值计费与拥塞都看峰值入口整形、离线任务限速
磁盘 IOPS谨慎离线任务会拖高在线延迟IO 权重、独立磁盘
连接数可以空闲连接不占实际处理能力连接池上限、每产品配额

内存是最不能赌的一项。CPU 超卖后表现是变慢,内存超卖后表现是进程消失,而且内核选择受害者的依据是 OOM score,不是业务优先级。一个没有 memory limit 的离线导入任务,可能比房间服更容易被杀,也可能反过来把房间服挤死;结果不可预测,就不能称为隔离。

超卖比例要有依据。示例做法是看过去 30 天同时段峰值的 P95:如果三款产品的峰值很少同时出现,1.5 倍是保守起点;如果《群岛纪元》的结算和《裂隙战场》的晚高峰重叠,比例要降到 1.2 以下。比例不是拍脑袋的静态参数,要按季度用真实峰值复核。

混部的干扰来自四个方向

混部收益来自共享,事故也来自共享。干扰主要有四类:

CPU 争抢。 在线房间服需要稳定 tick,离线转码会短时打满核心。只设 request 不够,必须设 limit 或 CPU 配额;对 tick 敏感的房间服还应该绑核,避免和批处理调度到同一组核心。

内存与页缓存。 离线任务读大文件会占据页缓存,在线服务的冷启动因此变慢。内存 limit 能防 OOM,但不能防页缓存竞争;需要给离线任务设置 memory.high 或定期丢弃缓存,或者干脆把重 IO 任务放到独立节点池。

网络与磁盘。 离线备份、日志上传会占满出口带宽,导致房间服心跳超时。按产品做带宽整形,离线任务限速到链路的一定比例,比“出问题再排查”便宜得多。

调度抖动。 混部节点上的 Pod 密度更高,任何一次驱逐都会影响更多服务。节点要预留系统资源,不能让可分配量等于物理量;否则 kubelet、日志代理和内核自己先饿死。

CoLocationConflicts 把其中两条硬规则编码成检查:两个绑核的在线关键服务不能同节点;没有内存 limit 的 Burstable 任务不能和在线关键服务同节点。规则要少而硬,能自动阻断的就不要留给评审。

资源不够时,先杀谁

驱逐顺序必须在事故前定义,而不是在告警群里投票。排序依据是“中断代价”:

  1. 离线任务优先:报表重算、历史导入、备份校验,杀了可以重跑。
  2. 在线非关键其次:商城、个人中心、排行榜刷新,短暂不可用可接受。
  3. 在线关键最后:正在承载对局的房间服、匹配服、登录服。
  4. 同层内低优先级先杀;回收量大的先杀;新启动的先杀(重启成本低);最后按名称排序保证确定性。

“新启动的先杀”有一个前提:新实例还没有承载长连接或房间。对已经接量的房间服,启动时间不再是依据,应该看它承载的对局数和可迁移性。驱逐策略要和优雅下线配合,先停止接新房间、把现有对局迁走或等待结束,再释放资源;直接 SIGKILL 会把一次容量调整变成玩家投诉。

还要保留“不可驱逐”集合:支付回调、登录、赛季结算的关键实例。这个集合要小,通常不超过集群的 10%,否则容量紧张时没有可压缩空间。

数据模型与可运行片段

internal/resourcequota 把配额、超卖和驱逐落成可测试的函数:Validate 做逐请求与累计校验,Pool 计算可售量与余量,Admit 判断是否放行,EvictionOrder 给出确定性的牺牲顺序,CoLocationConflicts 检查混部硬规则。

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

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

// Kind is a quota dimension. Units are fixed by the platform, not by the
// caller, so two products cannot compare CPU with different meanings.
type Kind string

const (
	CPU     Kind = "cpu_millicore"
	Memory  Kind = "memory_mib"
	Network Kind = "network_mbps"
)

// Quota is a hard ceiling for one product in one environment.
type Quota struct {
	Product string
	Env     string
	Limit   map[Kind]int64
}

// Request is one admission check. Amount is in the unit of its Kind.
type Request struct {
	Product string
	Env     string
	Kind    Kind
	Amount  int64
}

// Violation names the request index and the rule it broke.
type Violation struct {
	Index  int
	Reason string
}

func quotaKey(product, env string) string {
	return product + "/" + env
}

// Validate checks every request against its product/environment quota and
// against requests already accepted in the same batch. It never mutates the
// quota definitions.
func Validate(quotas []Quota, requests []Request) []Violation {
	limits := make(map[string]map[Kind]int64, len(quotas))
	for _, q := range quotas {
		key := quotaKey(q.Product, q.Env)
		if _, exists := limits[key]; exists {
			continue // first definition wins; duplicates are a config bug, not a request bug
		}
		limits[key] = q.Limit
	}

	used := make(map[string]int64)
	var out []Violation
	for i, r := range requests {
		if r.Amount <= 0 {
			out = append(out, Violation{Index: i, Reason: "amount must be positive"})
			continue
		}
		limit, ok := limits[quotaKey(r.Product, r.Env)]
		if !ok {
			out = append(out, Violation{Index: i, Reason: "no quota for product/environment"})
			continue
		}
		ceiling, ok := limit[r.Kind]
		if !ok {
			out = append(out, Violation{Index: i, Reason: "kind not declared in quota"})
			continue
		}
		key := quotaKey(r.Product, r.Env) + "|" + string(r.Kind)
		used[key] += r.Amount
		if used[key] > ceiling {
			out = append(out, Violation{Index: i, Reason: "quota exceeded"})
		}
	}
	return out
}

// Pool describes one schedulable resource pool.
type Pool struct {
	Capacity   int64   // provisioned units
	Guaranteed int64   // reserved for online-critical workloads
	Ratio      float64 // oversell ratio, >= 1
}

// Validate rejects configurations that would silently sell nothing or sell
// below the guaranteed reservation.
func (p Pool) Validate() error {
	if p.Capacity <= 0 {
		return errors.New("resourcequota: capacity must be positive")
	}
	if p.Guaranteed < 0 || p.Guaranteed > p.Capacity {
		return fmt.Errorf("resourcequota: guaranteed=%d outside [0,%d]", p.Guaranteed, p.Capacity)
	}
	if p.Ratio < 1 {
		return fmt.Errorf("resourcequota: ratio=%.2f must be >= 1", p.Ratio)
	}
	return nil
}

// Sellable is the total number of units the scheduler may hand out. It is
// floored to avoid promising a fraction of a CPU.
func (p Pool) Sellable() int64 {
	if p.Ratio <= 0 {
		return p.Capacity
	}
	return int64(float64(p.Capacity) * p.Ratio)
}

// Headroom is what is left after current usage. It may be negative when the
// pool is already over its sellable limit.
func (p Pool) Headroom(used int64) int64 {
	return p.Sellable() - used
}

// Admit answers whether a request fits. The returned headroom is computed
// before the request so callers can log the margin that was consumed.
func Admit(p Pool, used, request int64) (bool, int64) {
	headroom := p.Headroom(used)
	if request <= 0 || request > headroom {
		return false, headroom
	}
	return true, headroom
}

// Tier orders workloads by how acceptable it is to interrupt them.
type Tier int

const (
	TierOffline        Tier = iota // batch, backfill, report rebuild
	TierOnline                     // matchmaking, profile, shop
	TierOnlineCritical             // room servers serving live matches
)

// Workload is the minimum the eviction policy needs to know.
type Workload struct {
	Name        string
	Tier        Tier
	Priority    int   // larger means more important inside the same tier
	Reclaim     int64 // units freed if evicted
	StartedUnix int64
}

// EvictionOrder returns a copy sorted by "who gets killed first": offline
// before online, lower priority first, larger reclaim first, younger first,
// and finally name for a stable result.
func EvictionOrder(workloads []Workload) []Workload {
	out := append([]Workload(nil), workloads...)
	sort.SliceStable(out, func(i, j int) bool {
		a, b := out[i], out[j]
		if a.Tier != b.Tier {
			return a.Tier < b.Tier
		}
		if a.Priority != b.Priority {
			return a.Priority < b.Priority
		}
		if a.Reclaim != b.Reclaim {
			return a.Reclaim > b.Reclaim
		}
		if a.StartedUnix != b.StartedUnix {
			return a.StartedUnix > b.StartedUnix
		}
		return a.Name < b.Name
	})
	return out
}

// Shape is what a workload asks the node for when co-located.
type Shape struct {
	Name        string
	Tier        Tier
	Burstable   bool // may consume spare CPU when idle
	PinnedCPU   bool // owns dedicated cores
	MemoryLimit bool // hard memory cgroup
}

// Conflict is a rejected co-location pair with a reason.
type Conflict struct {
	A      string
	B      string
	Reason string
}

// CoLocationConflicts reports pairs that must not share a node. The rules
// encode the isolation the platform actually enforces, not a wish list.
func CoLocationConflicts(shapes []Shape) []Conflict {
	var out []Conflict
	for i := 0; i < len(shapes); i++ {
		for j := i + 1; j < len(shapes); j++ {
			a, b := shapes[i], shapes[j]
			switch {
			case a.Tier == TierOnlineCritical && b.Tier == TierOnlineCritical &&
				a.PinnedCPU && b.PinnedCPU:
				out = append(out, Conflict{A: a.Name, B: b.Name,
					Reason: "two pinned online-critical workloads compete for cores"})
			case a.Burstable && b.Tier == TierOnlineCritical && !a.MemoryLimit:
				out = append(out, Conflict{A: a.Name, B: b.Name,
					Reason: "burstable workload without memory limit may starve match server"})
			case b.Burstable && a.Tier == TierOnlineCritical && !b.MemoryLimit:
				out = append(out, Conflict{A: a.Name, B: b.Name,
					Reason: "burstable workload without memory limit may starve match server"})
			}
		}
	}
	sort.SliceStable(out, func(i, j int) bool {
		if out[i].A != out[j].A {
			return out[i].A < out[j].A
		}
		return out[i].B < out[j].B
	})
	return out
}

// Describe renders the quota table in a log-friendly, sorted form.
func Describe(quotas []Quota) string {
	lines := make([]string, 0, len(quotas))
	for _, q := range quotas {
		kinds := make([]string, 0, len(q.Limit))
		for k, v := range q.Limit {
			kinds = append(kinds, fmt.Sprintf("%s=%d", k, v))
		}
		sort.Strings(kinds)
		lines = append(lines, quotaKey(q.Product, q.Env)+" "+strings.Join(kinds, ","))
	}
	sort.Strings(lines)
	return strings.Join(lines, "; ")
}

本机验证命令与结果:

cd .local/checks/gsarch-lab
go test -count=1 ./internal/resourcequota/
ok  gsarchlab/internal/resourcequota

该包共有 6 个表驱动测试,覆盖配额内、累计超限、配额缺失、维度未声明、超卖准入边界、配置校验、驱逐排序、混部冲突与配额描述;gofmt -l 无输出,go vet 退出码为 0。

配额的三种失效方式

配额碎片。 每个产品都只用了配额的六成,但集群整体没有余量,因为配额按产品切得太碎,无法互相借用。解决办法是保留一部分共享池,允许产品在低峰时借用,高峰前归还;共享池不能太大,否则等于没有配额。

优先级反转。 离线任务的优先级被配成比在线还高,资源紧张时先杀房间服。优先级只能由平台统一维护,产品不能自报;每次变更都要走和容量模型同样的评审。

把限流当隔离。 网关限流只能限制请求进入,不能阻止进程占满 CPU 或内存。隔离必须发生在资源层:cgroup、绑核、内存 limit、带宽整形。只在应用层做并发限制,节点仍然会被拖垮。

小结

配额是分配上限,容量是物理供给,两者用超卖比连接。配额分产品、环境、玩法三层,环境层不能省。CPU 可以谨慎超卖,内存不能,网络按峰值算。混部干扰来自 CPU、内存与页缓存、网络磁盘、调度密度四个方向,隔离要落在资源层而不是应用层。驱逐顺序在事故前定义:离线先走,在线关键最后,同层按优先级、回收量、启动时间和名称确定。

常见错误有五个:配额不含环境、内存不设 limit、超卖比拍脑袋、把限流当隔离、事故时临时决定杀谁。它们都会把一次可预测的容量调整变成不可预测的玩家事故。下一篇讨论成本与体验的取舍:配额管住上限,但省下来的钱值不值得用体验去换,需要另一套评分方法。

阅读导航:上一节:9.1 成本归因与单位经济 · 下一节:9.3 成本与体验的取舍 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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