《游戏服务端架构·核心系统与契约》12.2 多游戏复用与租户隔离

《群岛纪元》的一次全服活动把平台数据库打满,另外两款游戏的登录同时抖动;渠道服又要求按渠道隔离存档。本文比较逻辑隔离、schema 隔离与库隔离的代价,给出 game/channel/env 三元组、跨租户拒绝与按租户配额的 Go 实现,并说明平台升级时旧版本游戏如何不被破坏。

一次活动,三款游戏一起抖动

《群岛纪元》在周五 20:00 开全服 boss。活动开始三分钟,平台社交服务的数据库连接池被打满:每个在线玩家都在刷新参战名单,名单查询没有按 game_id 做配额。二十秒后,《裂隙战场》的登录开始超时,因为登录也要读同一套社交关系表;《泡泡工坊》的房间创建变慢,因为房间邀请要查好友。

事后复盘发现,代码里其实有 game_id 字段,查询也带了条件。问题不在“有没有隔离”,而在三件事同时缺失:租户上下文没有贯穿调用链,跨租户读取没有硬拒绝,单个租户没有独立配额。字段只是数据,约束才叫隔离。

租户不只是 game_id

我们最初也以为 game_id 就是租户。渠道服上线后这个假设立刻破了:同一个 game_id 下,官方服和渠道服的账号体系、分成口径、数据留存要求都不同;预发环境的一次压测写入,也不应该出现在生产报表里。最终租户定义为三元组:

维度为什么需要隔离还是共享
game_id不同产品的角色、订单、关系图隔离
channel渠道账号、分成、合规口径不同隔离
env生产、预发、压测的数据不能混隔离
region全球同服的延迟与数据驻留按产品决定,默认共享逻辑表

region 放在最后,是因为它不该由平台一刀切。《群岛纪元》全球同服,跨区读取是产品需求;《裂隙战场》分服竞技,跨区只会增加延迟。平台提供维度,产品决定是否使用。

三元组还解决了一个客服问题:玩家说“我在渠道服充的钱不见了”,过去只能按账号查全部订单;现在租户是查询条件的一部分,查错租户会直接返回拒绝,而不是返回另一款游戏的数据。

三种数据隔离的代价

方案隔离强度迁移成本跨租户查询运维成本适用
逻辑隔离(共享表 + 租户列)依赖代码自律,最弱低,加列即可一条 SQL 就能绕过低,一套库大多数产品、大多数表
schema 隔离(每租户一套 schema)中,权限可收敛中,建表与迁移要乘租户数需要显式跨 schema 授权中,迁移脚本变多渠道合规、单游戏多环境
库隔离(每租户独立库)强,物理分开高,连接与备份都要分只能经数据平台高,实例数量翻倍强合规、并购来的独立品牌

北辰的选择是混合:默认逻辑隔离,所有平台表必须带 game_id/channel/env 三列并由统一的 key 生成器写入;渠道服要求独立留存时升到 schema 隔离;只有收购来的独立品牌用库隔离。混合的关键不是“三种都支持”,而是默认路径只有一种,升级隔离是例外流程,需要平台与产品共同签字。

逻辑隔离最危险的错觉是“查询带了 game_id 就安全”。只要有一个后台脚本漏了条件,隔离就破了。所以约束不能放在评审里,要放在数据访问层:key 必须由租户上下文生成,跨租户比较直接返回错误。

跨租户查询为什么必须禁止

平台需要统计,产品需要报表,运营需要看大盘。这些需求听起来都需要跨租户查询,但“统计”和“读取”是两件事。

  • 统计走事件管道:平台消费脱敏后的事件,按租户聚合,只输出计数与比率。
  • 读取走租户上下文:任何返回具体玩家、订单、关系行的接口,必须绑定单一租户。
  • 运营排障走审批通道:临时授权、限时、留痕,默认脱敏,且不能批量导出。

代码里没有“平台管理员可以读所有租户”的旁路。管理员也是一个租户上下文,只是它的权限不同;真正的跨租户能力属于数据平台,不属于在线服务。

配额与 noisy neighbor

隔离解决“看不看得见”,配额解决“抢不抢得到”。一个租户的活动流量不应该把共享资源吃光,这是平台侧最容易被忽略的 SLA。

我们用按租户的令牌桶:Rate 控制持续速率,Burst 控制瞬时尖峰。新租户第一次请求时创建桶,空闲时按时间补充,不需要后台任务扫描。配额不是一个全局数字,而是按能力分别设置:

能力默认配额(示例值)活动期调整方式
登录每游戏 500 QPS,突发 1000提前报备,按开服计划临时提高
社交读每租户 2000 QPS,突发 4000活动页面走缓存,不直接放大配额
支付回调每渠道 200 QPS,突发 500不随意提高,优先排队与重试
数据写入每租户 5000 EPS,突发 10000走缓冲队列,平台按容量分批

配额之上还要留共享余量。所有租户配额之和不能等于集群容量,否则一次大面积活动仍然会打满。我们的经验值是租户配额总和不超过容量的七成,剩下三成留给突发与故障切换;这个比例是示例值,需要按实际压测调整。

平台升级时,旧游戏怎么办

平台服务多款游戏,最现实的问题是版本不可能同时升。我们保留三条兼容规则:

变更类型兼容性做法
新增字段或接口向后兼容旧调用方忽略新字段,平台不要求升级
行为变更条件兼容新行为挂在能力版本下,按租户灰度
删除字段或接口不兼容先标记废弃,保留至少两个大版本,再按租户确认

“按租户灰度”意味着同一个平台进程可能同时服务 v1 和 v2 的调用方。这不是理想状态,而是多游戏复用的必然代价。平台不能用“统一升级”解决版本问题,只能用契约和灰度把升级变成产品团队的选择。

代码:租户上下文与按租户配额

internal/tenantisolation 把上面两条约束写成代码。tenant.go 负责三元组的校验、key 生成、上下文传递和跨租户拒绝;limiter.go 负责每个租户独立的令牌桶。

key 的生成顺序固定为 game/channel/env/id,租户字段永远在前,避免不同租户的 id 在字符串层面碰撞。Authorize 比较完整三元组,而不是只比 game_id:预发写入生产、渠道服串官方服,都是同一类事故。

// file: internal/tenantisolation/tenant.go
// tier: L1
// verify: go test ./internal/tenantisolation/

// Package tenantisolation binds every request to one (game, channel,
// environment) tuple and refuses reads or writes that cross that boundary.
package tenantisolation

import (
	"context"
	"errors"
	"fmt"
	"strings"
)

// Tenant is the isolation key shared by all platform services. Game alone is
// not enough: a staging write must not be visible in production, and two
// channels of the same game must not share player rows.
type Tenant struct {
	GameID  string
	Channel string
	Env     string
}

var (
	ErrIncompleteTenant = errors.New("tenantisolation: incomplete tenant")
	ErrBadSegment       = errors.New("tenantisolation: tenant segment contains a slash")
	ErrNoTenant         = errors.New("tenantisolation: tenant missing from context")
	ErrCrossTenant      = errors.New("tenantisolation: cross-tenant access denied")
	ErrBadKey           = errors.New("tenantisolation: key must not be empty or contain a slash")
	ErrBadQuota         = errors.New("tenantisolation: quota must have positive rate and burst >= 1")
	ErrBadCost          = errors.New("tenantisolation: cost must be positive")
)

// Validate rejects partially filled tenants. Callers should fail at the edge
// rather than write rows with an empty channel or environment.
func (t Tenant) Validate() error {
	if t.GameID == "" || t.Channel == "" || t.Env == "" {
		return ErrIncompleteTenant
	}
	for _, part := range []string{t.GameID, t.Channel, t.Env} {
		if strings.Contains(part, "/") {
			return ErrBadSegment
		}
	}
	return nil
}

// Key builds the storage key. Tenant fields always come first so a key from
// one tenant can never collide with a key from another.
func (t Tenant) Key(id string) (string, error) {
	if err := t.Validate(); err != nil {
		return "", err
	}
	if id == "" || strings.Contains(id, "/") {
		return "", ErrBadKey
	}
	return t.GameID + "/" + t.Channel + "/" + t.Env + "/" + id, nil
}

func (t Tenant) String() string {
	return t.GameID + "/" + t.Channel + "/" + t.Env
}

type tenantKey struct{}

// WithTenant stores the tenant in the request context. It refuses invalid
// tenants so a missing field cannot travel deeper into the stack.
func WithTenant(ctx context.Context, t Tenant) (context.Context, error) {
	if err := t.Validate(); err != nil {
		return ctx, err
	}
	return context.WithValue(ctx, tenantKey{}, t), nil
}

func FromContext(ctx context.Context) (Tenant, error) {
	t, ok := ctx.Value(tenantKey{}).(Tenant)
	if !ok {
		return Tenant{}, ErrNoTenant
	}
	return t, nil
}

// Authorize runs before a service touches a row. It compares the full tuple,
// not just GameID, because staging and production must not share data either.
func Authorize(actor, target Tenant) error {
	if err := actor.Validate(); err != nil {
		return err
	}
	if err := target.Validate(); err != nil {
		return err
	}
	if actor != target {
		return fmt.Errorf("%w: %s -> %s", ErrCrossTenant, actor, target)
	}
	return nil
}

配额部分刻意不做全局锁:每个租户一个桶,桶内用互斥锁保护,租户之间不共享状态。一个租户把桶打空,只会拒绝它自己的请求。

// file: internal/tenantisolation/limiter.go
// tier: L1
// verify: go test ./internal/tenantisolation/
package tenantisolation

import (
	"sync"
	"time"
)

// Quota is a per-tenant token bucket. Rate is tokens per second; Burst is the
// maximum bucket size and therefore the largest single spike a tenant can
// create without waiting.
type Quota struct {
	Rate  float64
	Burst float64
}

// Limiter keeps one bucket per tenant key. A tenant that saturates its own
// bucket cannot consume another tenant's tokens.
type Limiter struct {
	mu       sync.Mutex
	now      func() time.Time
	defaultQ Quota
	buckets  map[string]*bucket
}

type bucket struct {
	tokens float64
	last   time.Time
	quota  Quota
}

func NewLimiter(defaultQ Quota) *Limiter {
	return &Limiter{
		now:      time.Now,
		defaultQ: defaultQ,
		buckets:  map[string]*bucket{},
	}
}

// SetClock replaces the clock. Tests use it to make refill deterministic.
func (l *Limiter) SetClock(now func() time.Time) {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.now = now
}

// SetQuota overrides the default for one tenant, for example during a launch
// event that the platform agreed to absorb. The bucket starts full so the
// override takes effect immediately.
func (l *Limiter) SetQuota(t Tenant, q Quota) error {
	if err := t.Validate(); err != nil {
		return err
	}
	if q.Rate <= 0 || q.Burst < 1 {
		return ErrBadQuota
	}
	l.mu.Lock()
	defer l.mu.Unlock()
	l.buckets[t.String()] = &bucket{tokens: q.Burst, last: l.now(), quota: q}
	return nil
}

// Allow consumes cost tokens if the tenant has them. Refill is computed from
// elapsed time, so an idle tenant starts its next burst full without a
// background job and without scanning every other tenant.
func (l *Limiter) Allow(t Tenant, cost float64) (bool, error) {
	if err := t.Validate(); err != nil {
		return false, err
	}
	if cost <= 0 {
		return false, ErrBadCost
	}
	l.mu.Lock()
	defer l.mu.Unlock()
	key := t.String()
	b, ok := l.buckets[key]
	if !ok {
		b = &bucket{tokens: l.defaultQ.Burst, last: l.now(), quota: l.defaultQ}
		l.buckets[key] = b
	}
	l.refill(b)
	if b.tokens < cost {
		return false, nil
	}
	b.tokens -= cost
	return true, nil
}

// Remaining reports the tokens available right now without consuming any.
func (l *Limiter) Remaining(t Tenant) (float64, error) {
	if err := t.Validate(); err != nil {
		return 0, err
	}
	l.mu.Lock()
	defer l.mu.Unlock()
	b, ok := l.buckets[t.String()]
	if !ok {
		return l.defaultQ.Burst, nil
	}
	l.refill(b)
	return b.tokens, nil
}

func (l *Limiter) refill(b *bucket) {
	now := l.now()
	elapsed := now.Sub(b.last).Seconds()
	if elapsed <= 0 {
		return
	}
	b.tokens += elapsed * b.quota.Rate
	if b.tokens > b.quota.Burst {
		b.tokens = b.quota.Burst
	}
	b.last = now
}

本机验证

$ cd .local/checks/gsarch-lab
$ gofmt -l internal/tenantisolation
$ go vet ./internal/tenantisolation/...
$ go test -count=1 ./internal/tenantisolation/...
ok  	gsarchlab/internal/tenantisolation

gofmt -l 与 go vet 均无输出。tenantisolation 的 6 个测试函数共 30 个子用例通过,覆盖租户校验、key 生成、上下文往返、跨租户矩阵、配额隔离与非法输入。配额测试使用固定时钟,避免依赖真实时间。

失败模式与兜底

失败表现兜底
租户上下文丢失请求落到默认租户或空租户入口强制注入,缺失直接拒绝
key 拼错顺序不同租户的 key 碰撞key 只能由 Tenant.Key 生成,禁止手拼
后台脚本漏条件跨租户批量读写数据访问层强制租户参数,脚本走同一层
单租户打满共享资源其他游戏一起变慢按租户令牌桶 + 预留容量
配额总和超过容量大面积活动仍然打满配额总和不超过容量七成(示例值)
平台升级破坏旧调用旧游戏报错或行为变化能力版本 + 按租户灰度 + 废弃窗口

常见错误与边界

第一,只用 game_id 做租户。渠道、环境、区域任意一个缺失,都会在某次上线后变成数据事故。

第二,把隔离寄托在 SQL 条件上。没有统一的 key 生成器和访问层,漏条件是时间问题。

第三,给平台管理员开跨租户读权限。排障需要的是限时、留痕、脱敏的通道,不是常驻后门。

第四,配额只设总量不分租户。总量保护集群,租户配额保护邻居,两者不能互相替代。

第五,升级靠“大家一起发”。多游戏复用下,版本并存是常态,兼容规则要先于代码存在。

小结

租户隔离不是加一个 game_id 字段,而是三件事同时成立:租户上下文贯穿调用链,跨租户访问在数据层被拒绝,单个租户的流量被独立配额约束。三元组让隔离边界可表达,三种隔离方案让成本可选,版本兼容规则让平台升级不再要求所有游戏同时行动。

隔离和配额解决了“平台能不能稳定服务多款游戏”,但还有一个问题没有回答:当平台真的出错时,谁来判断有没有违约、谁来承担降级、产品团队能期待什么。这就是下一节的 SLA 契约。

阅读导航:上一节:12.1 账号、支付、社交、数据平台的职责 · 下一节:12.3 平台 SLA 与产品团队的契约 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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