一张地图,两个事故
《长风原》的 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)。上传时校验三件事:
- 三元组完整,摘要必须是 64 位十六进制;
- 同一份依赖不能重复声明;
- 依赖不能形成环,也不能依赖自己。
环检测只沿“已发布版本”的依赖边走。草稿之间的互相引用不构成运行时环,也不应该阻止作者编辑;一旦要发布,依赖链必须落在已发布的子图上。这样既不会误伤编辑过程,也不会让未审核的内容进入运行时。
隔离:沙箱不是虚拟机
沙箱的第一目标是“坏内容不能影响别人”,不是“坏内容跑得快”。资源上限至少包括四项:峰值内存、CPU 时间、墙钟时间、是否正常退出。任何一项超限,版本直接 rejected,不在同一个 worker 上重试。
| 资源 | 示例上限 | 超限后的现象 | 处理 |
|---|---|---|---|
| 峰值内存 | 512 MB | worker 被 OOM kill | rejected + 告警 |
| CPU | 1000 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 玩家创造内容的服务端信任模型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。