game-bass 是一个给独立游戏团队用的后端模板,卖出去几十份。每份模板跑在同一个服务器上,但数据必须完全隔离——A 游戏的排行榜不能出现在 B 游戏里。解决方案是多租户架构:一个 Redis 实例,用 key 前缀隔离所有游戏项目的数据。

租户 = 游戏项目

每个游戏项目注册后获得一组凭证:

1
2
3
4
5
6
7
8
// internal/tenant/tenant.go
type Info struct {
TenantID string `json:"tenant_id"` // t_1, t_2, ...
AppID string `json:"app_id"` // app_ + 64-bit 随机
AppSecret string `json:"app_secret"` // sk_ + 128-bit 随机
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
}

AppID 是公开标识(类似 AWS Access Key ID),AppSecret 是密钥(类似 AWS Secret Access Key)。Secret 只在注册时返回一次,后续不可查询——这是安全设计的核心。

注册流程:IP 限流 + 原子写入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func (m *Manager) Register(ctx context.Context, w http.ResponseWriter, r *http.Request) {
// Step 1: IP 限流(5 次/小时/IP)
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
if !m.allowRegister(ip) {
resp.Error(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}

// Step 2: Redis INCR 生成自增 tenantID
id, _ := m.rdb.Incr(ctx, idCounter).Result()
tenantID := fmt.Sprintf("t_%d", id)
appID := "app_" + randomHex(16) // 64-bit 随机
appSecret := "sk_" + randomHex(32) // 128-bit 随机

// Step 3: Pipeline 原子写入两条 Redis 记录
pipe := m.rdb.Pipeline()
pipe.Set(ctx, keyAppID+appID, data, 0) // appID → 租户 JSON
pipe.Set(ctx, keySecret+appSecret, appID, 0) // secret → appID
pipe.Exec(ctx)

// Step 4: 响应中一次性返回 secret
resp.JSON(w, http.StatusCreated, map[string]interface{}{
"app_secret": appSecret,
"note": "⚠️ Save the app_secret now. It will not be shown again.",
})
}

为什么用 Pipeline 原子写入?如果 Set appID 成功但 Set secret 失败,就会出现”有 appID 但找不到 secret”的脏数据。

Redis Key 设计:前缀隔离

所有租户数据用 key 前缀隔离:

Key 模式 值 用途
baas:tenant:appid:{appID} 租户 JSON 正向查找
baas:tenant:secret:{secret} appID 反向查找
baas:tenant:id_counter 自增整数 ID 生成
baas:leaderboard:{tenantID}:{board} ZSet 排行榜数据
baas:friends:{tenantID}:{playerID} Set 好友关系

排行榜、好友、聊天等业务数据都带 {tenantID} 前缀,天然隔离。不需要额外的数据库或表,一个 Redis 实例搞定。

认证中间件:两步查找

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func (m *Manager) TenantAuthMiddleware(next core.Handler) core.Handler {
return func(ctx context.Context, w http.ResponseWriter, r *http.Request) {
// Step 1: 从 Authorization 头提取 secret
secret := r.Header.Get("Authorization")
if len(secret) > 7 && secret[:7] == "Bearer " {
secret = secret[7:]
}

// Step 2: secret → appID → TenantInfo
appID, err := m.LookupBySecret(ctx, secret)
info, err := m.LookupByAppID(ctx, appID)

// Step 3: 注入 context,后续 handler 通过 core.TenantFromContext(ctx) 获取
ctx = core.WithTenant(ctx, &core.TenantInfo{
TenantID: info.TenantID,
AppID: info.AppID,
})
next(ctx, w, r)
}
}

两步查找(secret → appID → info)而不是直接 secret → info,是因为 LookupBySecret 只返回 appID 字符串,不存完整 JSON,节省 Redis 内存。

IP 限流:内存滑动窗口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const registerRateLimit = 5    // 每 IP 每小时最大注册次数
const rateMapMaxSize = 1000 // 触发清理的 IP 上限

func (m *Manager) allowRegister(ip string) bool {
m.rateMu.Lock()
defer m.rateMu.Unlock()

now := time.Now()
// 超出容量阈值时清理过期条目
if len(m.rateMap) >= rateMapMaxSize {
for k, w := range m.rateMap {
if now.After(w.reset) { delete(m.rateMap, k) }
}
}

w, ok := m.rateMap[ip]
if !ok || now.After(w.reset) {
m.rateMap[ip] = &rateWindow{count: 1, reset: now.Add(time.Hour)}
return true
}
if w.count >= registerRateLimit { return false }
w.count++
return true
}

用内存 map 而不是 Redis 做限流,因为注册是低频操作,不需要分布式限流。rateMapMaxSize = 1000 防止内存泄漏——超过 1000 个 IP 时自动清理过期条目。

安全设计要点

安全措施 实现方式
Secret 不可查询 只在注册响应中返回一次,Redis 中只存 secret → appID 映射
密码学安全随机 crypto/rand 生成,不用 math/rand
IP 限流 5 次/小时/IP,防刷注册
Pipeline 原子写入 防止 appID/secret 数据不一致
Bearer 前缀兼容 支持标准 Authorization: Bearer sk_xxx 格式

数据隔离验证

排行榜的 Redis key 是 baas:leaderboard:{tenantID}:{board}。租户 A 的 daily 排行榜和租户 B 的 daily 排行榜完全独立,因为 tenantID 不同。不需要额外的隔离层。

总结

多租户的核心不是”怎么隔离”,是”用最小代价隔离”。一个 Redis 实例 + key 前缀 + 认证中间件,233 行代码覆盖了注册、认证、限流、数据隔离的完整链路。

如果你的游戏后端要服务多个项目,你会选择共享数据库还是 key 前缀隔离?