《游戏服务端架构·品类、状态与拓扑》10.2 UGC 地图的隔离、审核与发布

《长风原》的 UGC 地图编辑器上线前,一张 300 MB、四万实体的地图把沙箱打爆,另一张地图的依赖在审核后被悄悄换版本。本节给出不可变版本、内容寻址、依赖锁定、沙箱配额、灰度与回滚组成的发布流水线,并说明为什么回滚只能指向曾经发布过的版本。

一张地图,两个事故

《长风原》的 UGC 地图编辑器先对一小批创作者开放。第一周就遇到两个事故:

  • 一张 300 MB、四万个实体的地图(示例值)在沙箱里申请了无上限的内存,沙箱进程被系统杀掉,同批次其他创作者的审核任务一起丢失;
  • 地图 B 依赖地图 A。审核通过时 A 的版本号是 7,发布前作者重新上传了内容不同、版本号仍是 7 的 A,B 的行为在没有任何审核记录的情况下变了。

第一个是资源隔离问题,第二个是版本可信问题。它们看起来分别属于“运行时”和“流程”,根因却是同一个:服务端把 UGC 当成“一份可以随时覆盖的文件”,而不是“一组不可变的字节”。

UGC 的发布管线必须同时回答三件事:这份内容到底是什么(内容寻址)、它依赖谁(依赖锁定)、它在什么条件下可以被玩家看到(灰度与回滚)。三者缺一,审核结论就不可复现。

流水线:六道门加一个回滚开关

阶段检查什么失败动作谁能豁免
上传大小、配额、依赖是否锁定拒绝,不进入队列无人
静态校验入口、资源清单、schema、实体数标记 rejected无人
沙箱运行内存、CPU、墙钟、崩溃标记 rejected,不重试无人
审核内容合规、玩法与付费风险标记 rejected仅官方内容
灰度崩溃率、tick、举报、退款回到 canary 或 rollback运营与值班
发布指针切换到该版本保持上一版发布负责人

回滚不是第七道门,而是一个指针操作:它只能在“已经发布过”的版本之间切换,不能把草稿直接推上线。这条限制让回滚永远有一个已知可用的目标。

版本不可变:审的是字节,不是标签

版本号只是地图内部的单调序号,真正的主键是内容摘要。上传时对字节做 SHA-256,得到 64 位十六进制摘要;同一张地图的每个版本都有唯一摘要,摘要相同就视为同一份内容。

三个直接后果:

  • 重复上传不产生新版本。 作者重新上传完全相同的字节,返回原版本,既不能重置审核状态,也不能绕过配额。
  • 审核结论绑定摘要。 审核员批准的是摘要 X,之后任何字节变化都会产生摘要 Y,必须重新走管线。
  • 回滚有确定目标。 回滚指向版本号,但版本号对应的摘要不可变,所以回滚后的行为可复现。

版本号仍然有用:它给作者和运营一个易读的序列,也方便灰度按“第 N 版”沟通。但服务端内部的引用、缓存键和审计记录都应以摘要为准。

依赖锁定:不能指向“最新”

UGC 地图可以复用别人的 prefab、规则包或子地图。依赖如果写成“地图 A 的最新版”,审核就失去意义:审核时看到的是 A@7,运行时加载的可能是 A@8。

《长风原》的依赖是一个三元组:(map_id, version, digest)。上传时校验三件事:

  1. 三元组完整,摘要必须是 64 位十六进制;
  2. 同一份依赖不能重复声明;
  3. 依赖不能形成环,也不能依赖自己。

环检测只沿“已发布版本”的依赖边走。草稿之间的互相引用不构成运行时环,也不应该阻止作者编辑;一旦要发布,依赖链必须落在已发布的子图上。这样既不会误伤编辑过程,也不会让未审核的内容进入运行时。

隔离:沙箱不是虚拟机

沙箱的第一目标是“坏内容不能影响别人”,不是“坏内容跑得快”。资源上限至少包括四项:峰值内存、CPU 时间、墙钟时间、是否正常退出。任何一项超限,版本直接 rejected,不在同一个 worker 上重试。

资源示例上限超限后的现象处理
峰值内存512 MBworker 被 OOM killrejected + 告警
CPU1000 ms审核队列堆积rejected
墙钟2 s任务超时rejected
实体数由信任等级决定加载与 tick 超预算上传阶段拒绝

配额是另一层:每个创作者能有多少张地图、总共多少字节、每张地图多少版本、每个版本多少依赖。配额在上传时检查,并且要在同一把锁里完成“读用量 + 写新版本”,否则并发上传会一起越过上限。

需要明确边界:本节的可运行代码只建模“超限就拒绝”的决策,不包含真正的容器、cgroup 或 seccomp,也不解析地图 schema;静态校验在代码里体现为大小、配额与依赖检查。生产环境应把沙箱放在一次性 worker 里,用操作系统限制兜底,并假设脚本一定会尝试逃逸。

灰度与回滚

灰度不是“先给 1% 玩家试试”这么简单,它需要预先定义观测窗口和退出条件:

信号示例阈值说明
沙箱/运行时崩溃率> 0.5%脚本错误或资源不足
tick 超时率> 1%地图逻辑拖慢房间
举报率高于同类地图 3 倍内容或玩法风险
退款/客服工单出现聚集付费点设计问题

灰度比例从 1%–50% 之间取值,观察窗口按地图类型设定,示例值 24 小时。窗口内任何一项越界,回到上一个已发布版本;窗口结束后才允许 Publish 把指针切到新版本。

回滚只做三件事:把当前版本标记为 rolled_back、把指针切到目标版本、记录操作人与原因。它不删除坏版本,也不修改目标版本的内容。保留坏版本是为了复盘和复现,删除它只会让事故无法解释。

代码:不可变上传与依赖锁

internal/ugc 把版本、摘要、依赖和配额写成一个不依赖对象存储的模型。下面这段是上传与依赖校验;状态机、沙箱、灰度、发布和回滚在同一个包的 pipeline.go 里,接口与错误值不变。

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

import (
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"sync"
	"time"
)

var (
	ErrEmptyMap          = errors.New("ugc: empty map id")
	ErrEmptyContent      = errors.New("ugc: empty content")
	ErrBadQuota          = errors.New("ugc: quota must not be negative")
	ErrQuotaMaps         = errors.New("ugc: map quota reached")
	ErrQuotaBytes        = errors.New("ugc: byte quota reached")
	ErrQuotaVersions     = errors.New("ugc: version quota reached")
	ErrQuotaDeps         = errors.New("ugc: dependency quota reached")
	ErrUnlockedDep       = errors.New("ugc: dependency is not pinned by digest")
	ErrDuplicateDep      = errors.New("ugc: duplicate dependency")
	ErrDependencyCycle   = errors.New("ugc: dependency cycle")
	ErrVersionNotFound   = errors.New("ugc: version not found")
	ErrIllegalTransition = errors.New("ugc: illegal status transition")
	ErrNotValidated      = errors.New("ugc: sandbox requires a validated version")
	ErrNotApproved       = errors.New("ugc: canary requires an approved version")
	ErrCanaryPercent     = errors.New("ugc: canary percent must be in [1,50]")
	ErrResourceBomb      = errors.New("ugc: sandbox limits exceeded")
	ErrNotPublished      = errors.New("ugc: map has no live version")
	ErrRollbackTarget    = errors.New("ugc: rollback target is not a published version")
)

type Status string

const (
	Uploaded   Status = "uploaded"
	Validated  Status = "validated"
	Sandboxed  Status = "sandboxed"
	InReview   Status = "in_review"
	Approved   Status = "approved"
	Canary     Status = "canary"
	Published  Status = "published"
	Rejected   Status = "rejected"
	RolledBack Status = "rolled_back"
)

// transitions is the generic review pipeline. Sandbox, Canary, Publish and
// Rollback are deliberately absent: they have their own gates, and a plain
// status change must never make a version serve traffic.
var transitions = map[Status][]Status{
	Uploaded:   {Validated, Rejected},
	Validated:  {Rejected},
	Sandboxed:  {InReview, Rejected},
	InReview:   {Approved, Rejected},
	Approved:   {Rejected},
	Canary:     {Rejected},
	Published:  {},
	Rejected:   {},
	RolledBack: {},
}

func (s Status) CanTransition(to Status) bool {
	for _, candidate := range transitions[s] {
		if candidate == to {
			return true
		}
	}
	return false
}

type Quota struct {
	MaxMaps     int
	MaxBytes    int64
	MaxVersions int
	MaxDeps     int
}

type Limits struct {
	MaxMemoryBytes int64
	MaxCPUMillis   int64
	MaxWallClock   time.Duration
}

// Dependency is a lock, not a range: map + version + digest. A re-tagged
// dependency cannot silently change a reviewed map.
type Dependency struct {
	MapID   string
	Version uint64
	Digest  string
}

type Version struct {
	MapID         string
	Number        uint64
	Digest        string
	Size          int64
	Deps          []Dependency
	Status        Status
	CanaryPercent int
	CreatedAt     time.Time
	UpdatedAt     time.Time
}

type SandboxRun struct {
	Passed          bool
	PeakMemoryBytes int64
	CPUMillis       int64
	WallClock       time.Duration
}

type Store struct {
	mu        sync.Mutex
	quota     Quota
	limits    Limits
	now       func() time.Time
	versions  map[string][]*Version
	published map[string]uint64
}

func NewStore(q Quota, limits Limits, now func() time.Time) (*Store, error) {
	if q.MaxMaps < 0 || q.MaxBytes < 0 || q.MaxVersions < 0 || q.MaxDeps < 0 {
		return nil, ErrBadQuota
	}
	if limits.MaxMemoryBytes <= 0 || limits.MaxCPUMillis <= 0 || limits.MaxWallClock <= 0 {
		return nil, ErrBadQuota
	}
	if now == nil {
		now = time.Now
	}
	return &Store{quota: q, limits: limits, now: now,
		versions:  make(map[string][]*Version),
		published: make(map[string]uint64)}, nil
}

// Upload is content-addressed. Uploading identical bytes again returns the
// existing version and cannot reset its review status; only new bytes create a
// new immutable version.
func (s *Store) Upload(mapID string, data []byte, deps []Dependency) (Version, error) {
	if mapID == "" {
		return Version{}, ErrEmptyMap
	}
	if len(data) == 0 {
		return Version{}, ErrEmptyContent
	}
	sum := sha256.Sum256(data)
	digest := hex.EncodeToString(sum[:])

	s.mu.Lock()
	defer s.mu.Unlock()
	existing := s.versions[mapID]
	for _, v := range existing {
		if v.Digest == digest {
			return *v, nil
		}
	}
	if existing == nil && s.quota.MaxMaps > 0 && len(s.versions) >= s.quota.MaxMaps {
		return Version{}, ErrQuotaMaps
	}
	var total int64
	for _, v := range existing {
		total += v.Size
	}
	if s.quota.MaxBytes > 0 && total+int64(len(data)) > s.quota.MaxBytes {
		return Version{}, fmt.Errorf("%w: %d + %d > %d",
			ErrQuotaBytes, total, len(data), s.quota.MaxBytes)
	}
	if s.quota.MaxVersions > 0 && len(existing) >= s.quota.MaxVersions {
		return Version{}, fmt.Errorf("%w: %d", ErrQuotaVersions, len(existing))
	}
	if s.quota.MaxDeps > 0 && len(deps) > s.quota.MaxDeps {
		return Version{}, fmt.Errorf("%w: %d > %d", ErrQuotaDeps, len(deps), s.quota.MaxDeps)
	}
	if err := s.checkDepsLocked(mapID, deps); err != nil {
		return Version{}, err
	}
	v := &Version{MapID: mapID, Number: uint64(len(existing) + 1), Digest: digest,
		Size: int64(len(data)), Deps: append([]Dependency(nil), deps...),
		Status: Uploaded, CreatedAt: s.now()}
	v.UpdatedAt = v.CreatedAt
	s.versions[mapID] = append(existing, v)
	return *v, nil
}

func (s *Store) checkDepsLocked(mapID string, deps []Dependency) error {
	seen := make(map[string]bool, len(deps))
	for _, d := range deps {
		if d.MapID == "" || d.Version == 0 || len(d.Digest) != 64 {
			return fmt.Errorf("%w: %+v", ErrUnlockedDep, d)
		}
		if _, err := hex.DecodeString(d.Digest); err != nil {
			return fmt.Errorf("%w: digest %q", ErrUnlockedDep, d.Digest)
		}
		if d.MapID == mapID {
			return fmt.Errorf("%w: %s depends on itself", ErrDependencyCycle, mapID)
		}
		key := fmt.Sprintf("%s@%d", d.MapID, d.Version)
		if seen[key] {
			return fmt.Errorf("%w: %s", ErrDuplicateDep, key)
		}
		seen[key] = true
		if s.dependsOnLocked(d.MapID, mapID, make(map[string]bool)) {
			return fmt.Errorf("%w: %s -> %s", ErrDependencyCycle, mapID, d.MapID)
		}
	}
	return nil
}

// dependsOnLocked walks only published versions. Unpublished drafts are not a
// valid lock target, so they cannot create a cycle that was never reviewed.
func (s *Store) dependsOnLocked(from, target string, visiting map[string]bool) bool {
	if from == target {
		return true
	}
	if visiting[from] {
		return false
	}
	visiting[from] = true
	for _, v := range s.versions[from] {
		if v.Number != s.published[from] {
			continue
		}
		for _, d := range v.Deps {
			if s.dependsOnLocked(d.MapID, target, visiting) {
				return true
			}
		}
	}
	return false
}

func (s *Store) getLocked(mapID string, number uint64) (*Version, error) {
	for _, v := range s.versions[mapID] {
		if v.Number == number {
			return v, nil
		}
	}
	return nil, fmt.Errorf("%w: %s v%d", ErrVersionNotFound, mapID, number)
}

几个设计点:

  • 去重检查放在配额检查之前。否则版本配额用满时,重复上传相同字节会返回“配额不足”,而不是返回原版本,作者会误以为内容被拒绝。
  • 摘要长度和十六进制格式都要校验。只检查长度会放过 zzzz... 这类非法摘要,依赖锁定形同虚设。
  • 自依赖直接拒绝,环检测只沿已发布版本走。草稿之间的引用不影响编辑,发布时才要求依赖子图已经审核。
  • 上传、配额和版本号分配在同一把锁内完成。把“查配额”和“追加版本”拆成两次加锁,并发上传会同时通过检查。
  • 沙箱失败是 fail closed:版本标记 rejected,不自动重试。重试会把资源炸弹反复送进 worker,也会让审核状态变得不可解释。

本机验证

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

8 个测试函数覆盖上传校验、内容寻址、状态机、沙箱配额、灰度比例、发布回滚和依赖完整性;耗时是本机一次运行结果,不应当作性能指标。

失败模式与兜底

失败表现兜底
依赖被重新打标签审核后行为变化依赖锁定到摘要
资源炸弹沙箱 OOM,队列丢失四项上限 + 一次性 worker
灰度指标缺失无法判断是否该回滚指标不全时不发布
回滚目标不存在只能回滚到草稿只允许回滚到已发布版本
重复上传绕过配额摘要相同却占新版本先按摘要去重
并发上传越配额两张地图同时通过检查配额与追加同锁
审核队列饥饿大内容长期占住 worker按大小分队列 + 超时

最后一项常被忽略:沙箱 worker 的数量有限,一张超大但合法的地图可能占满队列,让所有小地图排队。按内容大小分队列,比单纯增加 worker 更有效。

常见错误

第一,用“最新版”作为依赖。审核看到的和执行加载的不是同一份字节,所有结论都失效。

第三,只在应用层限制内存。脚本可以通过大量小对象或递归绕过估算,必须有操作系统级上限。

第四,审核通过后直接发布到 100%。灰度不是可选项,它是发现“审核没覆盖到的问题”的最后一道门。

第五,回滚时删除坏版本。删除会让事故无法复现,也会破坏审计链。

第六,把沙箱配额当成安全边界。配额防的是资源耗尽,不防脚本越权;权限必须由能力白名单单独控制。

小结

UGC 发布管线的核心不是“审核按钮”,而是三个不可变承诺:内容是摘要寻址的、依赖是锁定到摘要的、回滚只能指向曾经发布过的版本。围绕这三个承诺,静态校验、沙箱、审核、灰度和回滚才有意义。

本节的可运行产物是 internal/ugc:ugc.go 负责不可变上传与依赖锁,pipeline.go 负责状态机、沙箱、灰度、发布和回滚,共 8 个测试函数。下一节继续往下:当地图脚本开始影响经济与社交时,服务端应该信任它的数据,还是信任它的逻辑。

阅读导航:上一节:10.1 无缝世界的分片与边界 · 下一节:10.3 玩家创造内容的服务端信任模型 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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