一次活动,三款游戏一起抖动
《群岛纪元》在周五 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 与产品团队的契约 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。