《游戏服务端架构·平台化与架构治理》1.2 内部开发者平台

新游戏开一套环境要拉几个群、等几周,是内部平台最常见的失败形态。本文把新建环境、申请配额、接入观测、发布流水线收敛成 Golden Path,讲清声明式配置、审批链、幂等创建与环境状态机,并用 Go 实现配额校验、重复提交识别和生命周期推进。

三周才能开一套环境

《裂隙战场》要做一次跨区联调,需要一套隔离的预发布环境。负责人拉了七个群:找平台开机器、找 DBA 建库、找观测配看板、找发布平台接流水线、找安全过白名单。每一步都有人负责,但没有一步有明确的等待时间。以下场景与数字均为示例值。

三周后环境终于可用,需求已经改了两版。更糟的是,这套环境没有登记:配额从共享池里扣了,负责人却不知道扣了多少;联调结束后没人回收,机器又空转了一个月。

这就是内部开发者平台要解决的问题。它不是把工单系统搬到网页上,而是把“开环境”这件事变成一条有默认值、有审批边界、有状态、可回收的路径。

自助能力清单

平台先要明确哪些动作可以自助。北辰收敛出四类,每一类都给 Golden Path 和例外流程:

自助能力Golden Path 产物例外流程
新建游戏/环境命名空间、数据库、缓存、默认告警特殊规格、跨区部署、独立账号体系
申请配额CPU、内存、连接数、存储的租户配额超过池上限、临时活动扩容
接入观测日志、指标、链路、默认看板自定义指标、独立采集器
发布流水线构建、测试、灰度、回滚模板特殊发布顺序、外部依赖审批

Golden Path 的关键不是“快”,而是“默认安全”。默认开启日志、默认有告警、默认能回滚、默认登记归属。走例外流程不是禁止,而是要多一个具名审批和到期时间。

Golden Path 与例外流程的边界

维度Golden Path例外流程
审批自动通过或单人确认具名审批人,记录理由
时限分钟级到小时级明确 SLA,例如两个工作日(示例值)
配额从租户池自动扣减人工评估共享池余量
回收到期自动提醒并回收到期前人工复核
变更平台统一升级冻结版本,单独维护

例外流程必须有到期时间。没有到期的例外,最后都会变成事实标准,反过来绑架平台演进。

门户不是唯一真相

很多内部平台的第一版是一个门户:表单提交、后台调 API、写数据库。半年后会发现,门户数据库成了唯一真相,Git 里的声明式配置、流水线里的环境定义、监控里的标签各自漂移。

正确的顺序是 API 与声明式配置优先,门户只是视图:

层承担什么不应该承担什么
声明式配置环境期望状态、配额、归属运行时状态
平台 API校验、审批、状态推进、幂等长期保存业务数据
门户展示、发起申请、查看进度直接改数据库、绕过审批
控制器对齐期望状态与实际状态接受无审计的手工变更

门户可以是唯一入口,但不能是唯一真相。判断标准很简单:关掉门户,API 和控制器还能不能继续工作?如果答案是否定的,平台就被自己的 UI 锁死了。

环境状态机

环境申请不是布尔值,而是一条状态链。每个状态都要有明确的进入条件、责任方和超时动作。

状态含义允许的下一步责任方
requested已提交,等待决策approved、rejected平台 API
approved已批准,等待执行provisioning、rejected平台控制器
provisioning正在创建资源ready、failed平台控制器
ready可用,已计入配额无(回收走独立流程)平台控制器
failed创建失败,可重试provisioning、rejected平台控制器
rejected已拒绝,终态无审批人

状态只能通过显式动作推进,不能让调用方直接写 state 字段。否则门户、API、脚本会各自发明一套状态,排障时无法判断环境到底在哪一步。

幂等创建

创建环境是最容易重复的请求:客户端超时重试、用户连点两次、流水线失败后重跑。如果每次重试都新建一套资源,配额会被迅速耗尽。

幂等创建要满足三条规则:

  1. 同一个幂等键加同一个请求体,返回第一次的结果,不重复创建。
  2. 同一个幂等键配不同请求体,直接报冲突,不能悄悄覆盖。
  3. 幂等键有有效期,过期后允许复用,但复用记录要可审计。

第三条经常被忽略。永久保存幂等键会带来存储和隐私问题;保存太短又会让迟到的重试创建出第二套环境。有效期应该覆盖客户端的最长重试窗口,而不是拍一个“七天”。

配额校验放在哪一层

配额不能只在前端校验。前端可以提示,但真正的拒绝必须发生在平台 API,并且要在两个时点各查一次:

  • 提交时校验,避免明显超限的请求进入审批队列;
  • 批准时再校验,因为池子可能在等待期间缩小或被他人在用。

配额的使用量只在环境进入 ready 时计入。创建失败的尝试不扣配额,否则一次失败的编排会永久占用容量。回收时再减回去,形成闭环。

平台自身的度量

内部平台也需要 SLO,否则它会成为“没有告警的关键路径”。建议至少看五个指标:

指标定义示例目标
接入时长从提交到 ready 的 p50/p95p95 小于 30 分钟
自助率不走例外流程的申请占比大于 80%
变更成功率平台变更未导致回滚的比例大于 95%
平台可用性API 与控制器可用时间99.9%
例外存量未到期的例外申请数量逐月下降

这些数字都是示例值,不能直接当行业基准。接入时长要按环境类型分开统计:预发布环境和生产环境的风险不同,混在一起看会掩盖长尾。

六种反模式

反模式表现后果
门户直接写库表单提交后后台拼 SQL绕过审批、状态不一致
审批靠聊天“领导在群里说可以”无法追责、无法审计
配额只在前端页面提示余额不足,API 不校验脚本绕过限制打满共享池
创建接口非幂等重试就多一套环境配额泄漏、资源僵尸
平台无 SLO平台挂了,产品只能等关键路径没有告警
例外无到期临时方案永久保留平台版本无法收敛

上线检查表

  • 四类自助能力都有 Golden Path 和例外流程。
  • 环境状态机有明确的状态、动作和责任方。
  • 创建接口按幂等键去重,冲突时返回明确错误。
  • 配额在提交与批准两个时点校验。
  • 配额只在 ready 时计入,失败与回收能正确冲销。
  • 声明式配置是期望状态的来源,门户不直接改库。
  • 所有例外申请都有具名审批人和到期时间。
  • 平台 API、控制器、门户分别有可用性监控。
  • 接入时长按环境类型分桶统计。
  • 环境到期会自动提醒并进入回收流程。

可运行片段

下面的包实现环境生命周期、配额校验和幂等提交。状态与请求定义在配套的 state.go,这里展示真正决定行为的部分:转移表、幂等键、配额复查和用量计费。

// file: internal/idpportal/portal.go
// tier: L1
// verify: go test ./internal/idpportal/
package idpportal

import (
	"fmt"
	"strings"
	"sync"
)

// Quota is the pool a portal may hand out, not a suggestion. Usage is charged
// when an environment becomes ready, so failed attempts do not consume it.
type Quota struct {
	MaxCPU      int
	MaxMemoryGB int
	MaxEnvs     int
}

type Usage struct {
	CPU      int
	MemoryGB int
	Envs     int
}

func (q Quota) Validate() error {
	if q.MaxCPU <= 0 || q.MaxMemoryGB <= 0 || q.MaxEnvs <= 0 {
		return fmt.Errorf("%w: quota limits must be positive", ErrInvalid)
	}
	return nil
}

func (q Quota) Check(r Request, used Usage) error {
	if used.Envs+1 > q.MaxEnvs {
		return fmt.Errorf("%w: environments %d+1 > %d", ErrQuota, used.Envs, q.MaxEnvs)
	}
	if used.CPU+r.CPU > q.MaxCPU {
		return fmt.Errorf("%w: cpu %d+%d > %d", ErrQuota, used.CPU, r.CPU, q.MaxCPU)
	}
	if used.MemoryGB+r.MemoryGB > q.MaxMemoryGB {
		return fmt.Errorf("%w: memory %d+%d > %d", ErrQuota, used.MemoryGB, r.MemoryGB, q.MaxMemoryGB)
	}
	return nil
}

// Portal owns the state transitions. Callers may read requests but cannot
// assign a state; this keeps the API and the UI from drifting apart.
type Portal struct {
	mu       sync.Mutex
	quota    Quota
	requests map[string]Request
	keys     map[string]string
	usage    Usage
}

func New(q Quota) (*Portal, error) {
	if err := q.Validate(); err != nil {
		return nil, err
	}
	return &Portal{quota: q, requests: map[string]Request{}, keys: map[string]string{}}, nil
}

func (p *Portal) Usage() Usage {
	p.mu.Lock()
	defer p.mu.Unlock()
	return p.usage
}

func (p *Portal) Get(id string) (Request, error) {
	p.mu.Lock()
	defer p.mu.Unlock()
	r, ok := p.requests[id]
	if !ok {
		return Request{}, fmt.Errorf("%w: %s", ErrNotFound, id)
	}
	return r, nil
}

// Submit is idempotent per key. Replaying the same intent returns the stored
// request with created=false; reusing a key for a different intent fails
// loudly instead of silently creating a second environment.
func (p *Portal) Submit(key string, r Request) (Request, bool, error) {
	if strings.TrimSpace(key) == "" {
		return Request{}, false, fmt.Errorf("%w: idempotency key is empty", ErrInvalid)
	}
	if err := r.Validate(); err != nil {
		return Request{}, false, err
	}
	p.mu.Lock()
	defer p.mu.Unlock()

	if id, ok := p.keys[key]; ok {
		stored := p.requests[id]
		if stored.fingerprint() != r.fingerprint() {
			return Request{}, false, fmt.Errorf("%w: key %s", ErrKeyReuse, key)
		}
		return stored, false, nil
	}
	if _, ok := p.requests[r.ID]; ok {
		return Request{}, false, fmt.Errorf("%w: %s", ErrDuplicate, r.ID)
	}
	if err := p.quota.Check(r, p.usage); err != nil {
		return Request{}, false, err
	}
	r.State = StateRequested
	p.requests[r.ID] = r
	p.keys[key] = r.ID
	return r, true, nil
}

// Decide re-checks quota because the pool may have shrunk since submission.
// The golden path is auto-approved; the exception path needs a named approver.
func (p *Portal) Decide(id, approver string) (Request, error) {
	p.mu.Lock()
	defer p.mu.Unlock()

	r, ok := p.requests[id]
	if !ok {
		return Request{}, fmt.Errorf("%w: %s", ErrNotFound, id)
	}
	if err := CanTransition(r.State, StateApproved); err != nil {
		return r, err
	}
	if r.Path == ExceptionPath && strings.TrimSpace(approver) == "" {
		return r, fmt.Errorf("%w: %s", ErrApproval, id)
	}
	if r.Path == GoldenPath && strings.TrimSpace(approver) == "" {
		approver = "auto:golden-path"
	}
	if err := p.quota.Check(r, p.usage); err != nil {
		return r, err
	}
	r.State = StateApproved
	r.Approver = approver
	p.requests[id] = r
	return r, nil
}

func (p *Portal) Reject(id, reason string) (Request, error) {
	return p.move(id, StateRejected, func(r *Request) error {
		r.Reason = reason
		return nil
	})
}

func (p *Portal) Start(id string) (Request, error) {
	return p.move(id, StateProvisioning, nil)
}

// Finish is the only place usage is charged. A failed provisioning attempt
// leaves the pool untouched, so retries cannot leak capacity.
func (p *Portal) Finish(id string, success bool) (Request, error) {
	to := StateReady
	if !success {
		to = StateFailed
	}
	return p.move(id, to, func(r *Request) error {
		if to == StateReady {
			p.usage.CPU += r.CPU
			p.usage.MemoryGB += r.MemoryGB
			p.usage.Envs++
		}
		return nil
	})
}

func (p *Portal) move(id string, to State, before func(*Request) error) (Request, error) {
	p.mu.Lock()
	defer p.mu.Unlock()

	r, ok := p.requests[id]
	if !ok {
		return Request{}, fmt.Errorf("%w: %s", ErrNotFound, id)
	}
	if err := CanTransition(r.State, to); err != nil {
		return r, err
	}
	if before != nil {
		if err := before(&r); err != nil {
			return r, err
		}
	}
	r.State = to
	p.requests[id] = r
	return r, nil
}

读代码:四个约束点

第一,CanTransition 用显式转移表拒绝非法跳转。requested 不能直接到 ready,ready 也不能回到 provisioning。状态机不是文档,而是代码里的白名单。

第二,Submit 先查幂等键,再查请求 ID。同一个键配不同请求体会返回 ErrKeyReuse,而不是静默覆盖。这样客户端重试是安全的,误用键则会被立即发现。

第三,Decide 在批准时重新执行 quota.Check。提交时通过不代表批准时还能通过,共享池的余量会变化,复查是防止超卖的最后一道闸。

第四,Finish 只在成功时累加用量。创建失败不扣配额,重试成功后只扣一次。这个顺序如果写反,失败重试会不断泄漏容量。

失败路径与兜底

失败路径现象处理
编排卡在 provisioning资源创建一半,状态不前进超时标记 failed,触发补偿清理
门户不可用用户无法提交申请API 与流水线保持可用,门户只做视图
配额漂移实际用量大于账面定期对账,以资源标签为准修正
幂等键冲突两个不同请求用了同一个键返回冲突,要求换键并记录审计
环境被遗忘联调结束无人回收到期提醒、宽限期、自动回收

兜底的核心是“可回收”。平台能创建资源,就必须能识别、标记并回收资源。只会创建不会回收的平台,最终会把成本问题留给下一年。

与相邻章节的边界

这一节讲平台怎么把能力交付给产品。能力本身承诺什么,在 1.1 平台团队与产品团队的契约 ;平台服务怎么升级而不破坏调用方,在 1.3 平台服务的版本与兼容 。

观测接入的具体指标设计可以对照 5.1 日志、指标与链路追踪 ,平台规则怎么自动化检查可以对照 8.3 架构规则的自动化检查 。

小结

内部开发者平台的价值,不是把工单搬到网页,而是把“开环境”变成一条有默认值、有审批边界、有状态、可回收的路径。Golden Path 负责覆盖大多数请求,例外流程负责处理少数风险,状态机和幂等键负责让重复操作安全。

平台越自助,越需要契约和边界。下一节讨论平台服务自身的版本演进:当能力要升级、旧客户端还没走完时,怎么避免把玩家卡在登录页。

阅读导航:上一节:1.1 平台团队与产品团队的契约 · 下一节:1.3 平台服务的版本与兼容 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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