活动上线前夜,账单先失控
《泡泡工坊》准备做春节活动:签到、限时皮肤、分享拉新、排行榜、每日挑战、观战回放,一共六个功能。团队只有四名后端,计划两周上线。
评审时架构师只问了三个问题:活动配置怎么回滚?对象存储的每日成本上限是多少?限流按什么维度做?三个问题都没有答案。活动上线第三天,分享图片和回放文件的存储与出流量把预算打穿,临时关功能又因为配置写死在代码里,只能重新发版。
小游戏后端的问题通常不是“功能太复杂”,而是“上线太快,缺少最低限度的闸门”。这一节给出一套能快速上线、又不会在第一天失控的最小架构。
最小架构:四个部件,不是四套集群
客户端 ──► 无状态 API 服务(2 个副本)
│
┌────────┼─────────┐
▼ ▼ ▼
Postgres Redis 对象存储
(主库) (缓存) (图片/回放)
| 部件 | 作用 | 缺失后的表现 | 扩容触发 |
|---|---|---|---|
| 无状态 API | 登录、活动、背包、排行榜查询 | 无法服务 | CPU 或 QPS 持续偏高 |
| Postgres | 账号、订单、背包、活动进度 | 数据无法持久化 | 连接数、慢查询、磁盘 |
| Redis | 会话、排行榜、限流桶、热点缓存 | 数据库压力上升,延迟抖动 | 内存与命中率 |
| 对象存储 | 分享图、回放、静态资源 | 文件丢失或带宽打满 | 容量与出流量 |
“单服务”不是“单进程”。API 至少两个副本,才能滚动发布;数据库至少一个主库加备份,才能谈恢复。除此之外的组件,都应该等到有明确需求再加。
微服务不是“更先进”,而是“用运维复杂度换组织独立性”。四个人维护三个服务,联调成本会比开发成本更高;活动峰值只持续几小时,单服务加副本足够。
什么时候才需要拆分
拆分应该有可观测的触发条件,而不是“感觉代码变大了”。下面这些阈值是示例值,实际要按团队和预算校准:
| 触发条件 | 示例阈值 | 拆分动作 |
|---|---|---|
| 单服务发布互相阻塞 | 每周因发布冲突回滚 ≥ 2 次 | 先拆出改动最频繁的模块 |
| 某模块独占扩容 | 单个接口占 CPU ≥ 50% | 拆出该接口或独立部署 |
| 数据库成为瓶颈 | 慢查询持续影响 p99 | 先加索引/读写分离,再考虑拆库 |
| 团队边界清晰 | 两个小组各自维护一半 | 按所有权拆服务 |
| 故障隔离要求 | 支付故障不能影响对局 | 拆出支付或加舱壁 |
配置必须版本化、可回滚
活动配置最常见的写法是“改数据库里的 JSON”或“改环境变量后重启”。这两种方式都没有版本、没有审计,也无法在出问题时回到上一个已知良好状态。
最低要求是:
- 每次发布生成一个不可变配置快照,带递增版本号;
- 当前版本是一个指针,回滚只是移动指针,不改历史快照;
- 回滚目标必须是已经发布过的更早版本,不能指向未来或不存在的版本;
- 发布时做结构校验:环境合法、限流参数为正、每日成本上限为正、开关名非空;
限流与成本上限是上线门槛
限流不是“防黑客”的可选项,而是保护自己和用户的底线。小游戏后端至少要有三层:
- 入口限流:按玩家 ID 或设备 ID 限制登录、开房、抽奖等写接口;
- 热点保护:按接口维度限制全局 QPS,防止一个活动打垮数据库;
- 成本上限:对象存储、CDN、短信、推送按日预算设置上限,超过阈值降级或停止非核心功能。
代码:发布闸门
下面的 internal/minibackend/minibackend.go 把三件事写成一个包:不可变配置版本与回滚、可注入时钟的令牌桶、上线前检查清单。它不依赖数据库,但接口契约和线上实现一致。
// file: internal/minibackend/minibackend.go
// tier: L1
// verify: go test ./internal/minibackend/
package minibackend
import (
"errors"
"sort"
"strings"
"sync"
"time"
)
var (
ErrBadVersion = errors.New("minibackend: version must be positive")
ErrBadEnv = errors.New("minibackend: env must be staging or prod")
ErrNoLimit = errors.New("minibackend: at least one rate limit is required")
ErrBadLimit = errors.New("minibackend: limit name, per-second and burst must be positive")
ErrNoCostCap = errors.New("minibackend: daily cost cap must be positive")
ErrBadFlag = errors.New("minibackend: flag name must not be blank")
ErrVersionExists = errors.New("minibackend: version already published")
ErrBadRollback = errors.New("minibackend: rollback target must be a published earlier version")
ErrUnknownVersion = errors.New("minibackend: unknown version")
)
type Limit struct {
PerSecond int
Burst int
}
// Config is one immutable release snapshot. Publish clones it, so a caller
// cannot mutate the live config through a retained map reference.
type Config struct {
Version int
Env string
RollbackTo int
Limits map[string]Limit
DailyCostCapCents int
Flags map[string]bool
}
func (c Config) clone() Config {
out := c
out.Limits = make(map[string]Limit, len(c.Limits))
for k, v := range c.Limits {
out.Limits[k] = v
}
out.Flags = make(map[string]bool, len(c.Flags))
for k, v := range c.Flags {
out.Flags[k] = v
}
return out
}
func (c Config) validate() error {
if c.Version <= 0 {
return ErrBadVersion
}
if c.Env != "staging" && c.Env != "prod" {
return ErrBadEnv
}
if len(c.Limits) == 0 {
return ErrNoLimit
}
for name, l := range c.Limits {
if strings.TrimSpace(name) == "" || l.PerSecond <= 0 || l.Burst <= 0 {
return ErrBadLimit
}
}
if c.DailyCostCapCents <= 0 {
return ErrNoCostCap
}
for name := range c.Flags {
if strings.TrimSpace(name) == "" {
return ErrBadFlag
}
}
return nil
}
// Store is a tiny versioned config registry backed by memory. The same
// contract (monotonic versions, explicit rollback target) applies in Redis.
type Store struct {
mu sync.Mutex
configs map[int]Config
current int
}
func NewStore() *Store {
return &Store{configs: map[int]Config{}}
}
func (s *Store) Publish(c Config) error {
if err := c.validate(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.configs[c.Version]; exists {
return ErrVersionExists
}
if c.RollbackTo != 0 {
target, ok := s.configs[c.RollbackTo]
if !ok || target.Version >= c.Version {
return ErrBadRollback
}
}
s.configs[c.Version] = c.clone()
s.current = c.Version
return nil
}
func (s *Store) Current() (Config, bool) {
s.mu.Lock()
defer s.mu.Unlock()
c, ok := s.configs[s.current]
if !ok {
return Config{}, false
}
return c.clone(), true
}
func (s *Store) Rollback(version int) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.configs[version]; !ok {
return ErrUnknownVersion
}
s.current = version
return nil
}
// Limiter is a deterministic token bucket. The clock is injected so tests and
// incident drills can replay the same burst and refill sequence.
type Limiter struct {
mu sync.Mutex
now func() time.Time
limit Limit
buckets map[string]*bucket
}
type bucket struct {
tokens float64
last time.Time
}
func NewLimiter(limit Limit, now func() time.Time) *Limiter {
if now == nil {
now = time.Now
}
return &Limiter{now: now, limit: limit, buckets: map[string]*bucket{}}
}
func (l *Limiter) Allow(key string) bool {
if l.limit.PerSecond <= 0 || l.limit.Burst <= 0 || strings.TrimSpace(key) == "" {
return false
}
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
b, ok := l.buckets[key]
if !ok {
b = &bucket{tokens: float64(l.limit.Burst), last: now}
l.buckets[key] = b
}
if elapsed := now.Sub(b.last).Seconds(); elapsed > 0 {
b.tokens += elapsed * float64(l.limit.PerSecond)
if b.tokens > float64(l.limit.Burst) {
b.tokens = float64(l.limit.Burst)
}
b.last = now
}
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
// Release is what the operator claims about a build. Optional items may be
// waived; blocking items cannot, because they are the minimum launch bar.
type Release struct {
RollbackVersion int
HealthCheck bool
RateLimit bool
CostCap bool
Owner string
Runbook string
Alerts bool
GrayPlan bool
LoadTest bool
Waivers map[string]string
}
type Issue struct {
Item string
Blocking bool
Message string
}
type Report struct {
Ready bool
Issues []Issue
}
type checkItem struct {
id string
blocking bool
present func(Release) bool
}
var checklist = []checkItem{
{"rollback", true, func(r Release) bool { return r.RollbackVersion > 0 }},
{"health_check", true, func(r Release) bool { return r.HealthCheck }},
{"rate_limit", true, func(r Release) bool { return r.RateLimit }},
{"cost_cap", true, func(r Release) bool { return r.CostCap }},
{"owner", true, func(r Release) bool { return strings.TrimSpace(r.Owner) != "" }},
{"runbook", true, func(r Release) bool { return strings.TrimSpace(r.Runbook) != "" }},
{"alerts", true, func(r Release) bool { return r.Alerts }},
{"gray_plan", false, func(r Release) bool { return r.GrayPlan }},
{"load_test", false, func(r Release) bool { return r.LoadTest }},
}
// Preflight returns every missing item, sorted by ID, plus whether the build
// may launch. A waiver on a blocking item is reported, not honored.
func Preflight(r Release) Report {
issues := make([]Issue, 0, len(checklist))
for _, item := range checklist {
if _, waived := r.Waivers[item.id]; waived && !item.blocking {
continue
}
if item.present(r) {
continue
}
issue := Issue{Item: item.id, Blocking: item.blocking}
if item.blocking {
issue.Message = "blocking item missing"
if _, waived := r.Waivers[item.id]; waived {
issue.Message = "blocking item cannot be waived"
}
} else {
issue.Message = "optional item missing; add it or record a waiver"
}
issues = append(issues, issue)
}
sort.Slice(issues, func(i, j int) bool { return issues[i].Item < issues[j].Item })
for _, issue := range issues {
if issue.Blocking {
return Report{Ready: false, Issues: issues}
}
}
return Report{Ready: true, Issues: issues}
}
几个设计点:
Publish先校验再落库,校验失败不会留下半个版本;clone保证调用方后续修改 map 不会污染已发布配置。Rollback只移动当前指针,不删除历史版本。回滚目标是否存在,在Publish时就已经校验。Preflight把阻塞项和可裁剪项分开。阻塞项即使写了豁免也仍然阻塞,避免“特批”绕过上线门槛。
可裁剪与不可裁剪清单
| 能力 | 上线前 | 最小实现 |
|---|---|---|
| 回滚目标 | 必须有 | 上一个版本的配置快照 |
| 健康检查 | 必须有 | /healthz 与 /readyz |
| 限流 | 必须有 | 单接口令牌桶 + 玩家维度 |
| 每日成本上限 | 必须有 | 对象存储/CDN 预算告警 + 降级开关 |
| 负责人 | 必须有 | 一个 on-call 名字和联系方式 |
| 运行手册 | 必须有 | 一页:怎么回滚、怎么关功能、找谁 |
| 告警 | 必须有 | 一个 webhook,至少覆盖 5xx 与成本 |
| 灰度方案 | 可延后 | 先按玩家 ID 百分比放量 |
上线前检查与灰度回滚
- 发布 vN,确认当前版本指针指向 vN;
- 回滚到 vN-1,确认配置、开关、限流参数都回到旧值;
- 用小流量灰度验证核心链路:登录、开房、结算、支付回调;
- 观察 5xx、p99、数据库连接数、对象存储出流量四个指标。
灰度期间要准备“关功能”的开关,而不是“回滚整个版本”。回滚是最后手段,开关是第一步;开关也要纳入配置版本。监控先覆盖请求量、错误率、p99、饱和度,再加一个业务看板即可。
本机验证
internal/minibackend 有 5 个测试函数、29 个子测试,覆盖配置发布、回滚、限流突发与补充、检查清单的阻塞项与豁免。
$ cd .local/checks/gsarch-lab
$ gofmt -l internal/minibackend
$ go vet ./internal/minibackend/
$ go test -count=1 ./internal/minibackend/
ok gsarchlab/internal/minibackend 0.572s
gofmt -l 与 go vet 均无输出,退出码为 0,耗时随机器变化。
常见错误与边界
第一,直接改线上配置文件,没有版本和审计。出问题时不知道改了什么,也回不去。
第二,回滚目标不存在,或者指向一个比当前更新的版本。发布时校验一次,比事故时才发现便宜得多。
第三,限流只按 IP,成本上限只有告警没有降级。前者会误伤 NAT 后的玩家,后者不会自动关掉分享图生成。
小结
小游戏后端的最小组合是:无状态 API + 单主库 + 缓存 + 对象存储。轻量的核心不是组件少,而是每条边界都清楚:配置有版本、回滚有目标、限流有维度、成本有上限、上线有检查清单。等出现可观测的拆分触发条件,再拆也不迟。
阅读导航:上一节:9.2 回合制卡牌的确定性与回放 · 下一节:10.1 无缝世界的分片与边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。