一次回滚暴露的职责越界
《裂隙战场》赛季更新那天,网关里多了一条规则:活动期间,未完成实名认证的游客不能进入排位匹配。规则本身没错,错在它被写进了网关的匹配转发路径。
配置漏了一个默认值。上线后,所有没有该标签的玩家都被判定为“未认证”,匹配接口返回 403。更麻烦的是回滚:业务服可以热更新,网关却要重新发版,因为规则编译在网关二进制里。四十分钟后网关回滚完成,玩家已经在社区里贴出了“服务器崩了”的截图。
复盘时团队发现,问题不是某一行代码,而是网关的职责没有写下来。只要“顺手在这里加个判断”没有成本,边界就一定会被突破。这一节先划边界,再谈横向扩展。
网关只做六件事
网关是接入层,不是业务层。它应该只做以下六类工作:
| 职责 | 输入 | 输出 | 不应该做的事 |
|---|---|---|---|
| 连接终结 | TCP/WebSocket/QUIC 连接 | 稳定的会话通道 | 解析战斗指令 |
| 认证 | 登录 token、设备信息 | 已验证的账号身份 | 计算VIP等级、判断活动资格 |
| 路由 | 账号、房间、赛区 | 目标业务节点 | 决定玩家该匹配到谁 |
| 限流 | 连接数、消息速率、账号维度 | 拒绝或排队 | 按业务规则扣体力 |
| 协议转换 | 客户端协议 | 内部 RPC/消息 | 改写业务字段语义 |
| 灰度标签 | 版本、渠道、白名单 | 路由标签 | 在网关里做 A/B 业务逻辑 |
这张表的关键不是“六件事”,而是最后一列。任何需要读业务表、改业务状态、或者随运营活动频繁变化的判断,都不属于网关。
网关越界的代价有三个:
- 发布耦合:业务规则变化要发网关,回滚窗口从分钟级变成小时级;
- 故障放大:网关是所有玩家的入口,一条错误规则影响全量;
- 责任模糊:出了问题,接入团队和业务团队都能说“不是我的代码”。
鉴权放网关还是业务服
这是边界讨论里最常见的争议。三种做法:
| 方案 | 登录延迟 | 权限变更生效 | 故障影响 | 适用场景 |
|---|---|---|---|---|
| 网关只验签,业务服做授权 | 低 | 下一次请求生效 | 业务服故障只影响对应功能 | 大多数游戏 |
| 网关查权限缓存 | 中 | 缓存过期后生效 | 缓存故障可能全站拒绝 | 权限模型极稳定 |
| 业务服全做 | 低 | 立即生效 | 每个业务服都要重复实现 | 服务数量很少 |
北辰工作室的选择是第一种:网关验签,业务服授权。
网关只做三件与身份有关的事:验证 token 签名与过期时间、提取账号 ID 和设备 ID、把身份放进内部请求头。它不查“这个玩家能不能进这个副本”,也不查“这个账号是否被禁言”。这些是业务服的职责。
但有一个例外:全局封禁。封号、冻结、全局黑名单这类“所有入口都必须拒绝”的状态,网关需要一份只读黑名单。它不是业务规则,而是接入安全策略;数据量小、变更不频繁、可以全量同步到每个网关节点。
判断标准很简单:如果规则需要读多张业务表、需要事务、或者运营每周都要改,就放业务服;如果规则是“这个身份还能不能建立连接”,可以放网关。
横向扩展:粘性不是万能
网关扩容时,第一个问题是“新连接怎么分”。第二个问题是“旧连接怎么办”。很多团队把两个问题都交给“粘性会话”,结果把扩展性问题变成了路由问题。
三类状态,三种策略
| 连接承载的状态 | 例子 | 路由策略 | 节点故障时 |
|---|---|---|---|
| 无状态请求 | 登录、商城、邮件 | 轮询或随机 | 直接重试其他节点 |
| 房间内状态 | 5v5 战斗、牌桌 | 按房间 ID 粘性 | 房间整体迁移或重开 |
| 会话级状态 | 聊天、在线状态 | 按账号粘性,可恢复 | 重连到新节点并恢复 |
只有第二类必须严格粘性。第三类可以粘性,但必须有恢复路径;第一类不应该粘性,否则扩容时新节点接不到流量。
一致性哈希环与 rendezvous
粘性路由的常见实现有两种:
| 维度 | 一致性哈希环 | rendezvous 哈希 |
|---|---|---|
| 节点变化时的迁移量 | 只迁移相邻区间 | 只迁移被摘除节点的键 |
| 实现复杂度 | 需要虚拟节点处理倾斜 | 每个键对每个节点算一次分数 |
| 节点权重 | 通过虚拟节点数量近似 | 通过重复票数或加权分数 |
| 小规模集群 | 可以用,但环维护麻烦 | 更简单,节点数少时开销可忽略 |
| 缓存友好 | 依赖环布局 | 键到节点的映射直观 |
《裂隙战场》的网关集群只有十几个节点,选择 rendezvous。它的性质正好匹配发布流程:摘除一个节点,只会重新分配原本属于它的键;其他键的归属不变。 这意味着一台网关下线,不会引发全量会话迁移。
有状态节点的摘除顺序
节点摘除不是 kill,而是一个状态转换:
healthy -> draining -> removed
进入 draining 后,节点不再接受新路由,但继续服务已有连接;等在途消息处理完、房间结束后,再真正移除。如果没有 draining 阶段,直接摘除会让该节点上的所有会话在同一时刻断线,重连风暴会打到剩余节点。
代码:rendezvous 路由表
下面的实现把成员校验、路由、摘除和排空写成一个小表。它不依赖环,也不依赖外部协调服务;成员表由配置中心下发,每个网关副本用同一份配置得到同一张表。
// file: internal/gateway/table.go
// tier: L1
// verify: go test ./internal/gateway/
package gateway
import (
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"sort"
)
var (
ErrNoNodes = errors.New("gateway: no nodes")
ErrEmptyNode = errors.New("gateway: empty node id")
ErrDuplicateNode = errors.New("gateway: duplicate node id")
ErrNodeNotFound = errors.New("gateway: node not found")
ErrNoTarget = errors.New("gateway: no route target")
)
// Node is a gateway process. Draining keeps existing sessions but refuses new
// routes, so a deploy can move traffic before the process exits.
type Node struct {
ID string
Draining bool
}
// Table is an immutable membership list plus mutable drain flags. Rendezvous
// hashing needs no ring or virtual nodes: a key belongs to the node with the
// highest hash(key, nodeID), and removing a node only moves that node's keys.
type Table struct {
nodes []Node
}
// New validates membership and sorts it, so two replicas that load the same
// configuration produce the same routes regardless of input order.
func New(nodes []Node) (*Table, error) {
seen := make(map[string]bool, len(nodes))
out := make([]Node, 0, len(nodes))
for _, n := range nodes {
if n.ID == "" {
return nil, ErrEmptyNode
}
if seen[n.ID] {
return nil, fmt.Errorf("%w: %s", ErrDuplicateNode, n.ID)
}
seen[n.ID] = true
out = append(out, n)
}
if len(out) == 0 {
return nil, ErrNoNodes
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return &Table{nodes: out}, nil
}
// Nodes returns a copy for diagnostics.
func (t *Table) Nodes() []Node { return append([]Node(nil), t.nodes...) }
// Drain marks a node unavailable for new routes. It is idempotent.
func (t *Table) Drain(id string) error {
for i := range t.nodes {
if t.nodes[i].ID == id {
t.nodes[i].Draining = true
return nil
}
}
return fmt.Errorf("%w: %s", ErrNodeNotFound, id)
}
// Remove takes a node out of membership entirely, for permanent loss.
func (t *Table) Remove(id string) error {
for i := range t.nodes {
if t.nodes[i].ID == id {
t.nodes = append(t.nodes[:i], t.nodes[i+1:]...)
return nil
}
}
return fmt.Errorf("%w: %s", ErrNodeNotFound, id)
}
// rendezvous must avalanche: FNV over nearly identical node IDs clusters
// keys on one node, so the score uses a digest of key and node together.
func rendezvous(key, node string) uint64 {
sum := sha256.Sum256([]byte(key + "\x00" + node))
return binary.BigEndian.Uint64(sum[:8])
}
// Pick routes a new connection to a live, non-draining node.
func (t *Table) Pick(key string) (Node, error) { return t.pick(key, "") }
// Reroute picks the best node other than failed. It is used when the previous
// owner died and the session layer has already invalidated the old binding.
func (t *Table) Reroute(key, failed string) (Node, error) { return t.pick(key, failed) }
func (t *Table) pick(key, exclude string) (Node, error) {
var best Node
var bestScore uint64
found := false
for _, n := range t.nodes {
if n.Draining || n.ID == exclude {
continue
}
s := rendezvous(key, n.ID)
if !found || s > bestScore || (s == bestScore && n.ID < best.ID) {
best, bestScore, found = n, s, true
}
}
if !found {
return Node{}, ErrNoTarget
}
return best, nil
}
// Healthy reports how many nodes can still accept new routes.
func (t *Table) Healthy() int {
count := 0
for _, n := range t.nodes {
if !n.Draining {
count++
}
}
return count
}
几个设计点:
- 构造时校验空 ID 和重复 ID,并按 ID 排序。排序不是为了好看,而是为了让“输入顺序不同的两份配置”产生完全相同的路由结果。
Pick跳过Draining节点,保证新连接不会落到正在下线的节点。Reroute接收一个failed节点,用于节点已经失联、但会话层还没完成重绑定的窗口。Remove直接修改成员表;生产环境应通过配置版本下发,避免每个副本各自删除。
rendezvous 使用 sha256(key + 0x00 + nodeID) 的前 8 字节,而不是 FNV。原因是节点 ID 往往只差一两个字符,FNV 的输出在这种输入上分布很差;实际测试中,四个节点里有一个拿到了 50% 的键。换成摘要后,分布才回到可用范围。
节点摘除测试证明了什么
测试覆盖四类性质:
- 同样的键和成员表,重复调用得到同一个节点;
- 节点输入顺序不同,路由结果一致;
- 摘除一个节点,只有原本属于它的键会移动,其他键不动;
- 四个节点、两万个键时,每个节点的份额落在 15% 到 35% 之间。
最后一条不是容量承诺,只是分布健康检查。真实集群的键空间、节点数和哈希实现都不同,必须用生产键样本重新测。
本机验证
$ cd .local/checks/gsarch-lab
$ gofmt -l internal/gateway
$ go vet ./internal/gateway/
$ go test -count=1 ./internal/gateway/ -v
...
PASS
ok gsarchlab/internal/gateway 0.568s
gofmt -l 与 go vet 均无输出。6 个测试函数、20 个子用例,共 26 个 PASS,覆盖成员校验、顺序无关、摘除迁移、排空、分布和故障重路由。耗时来自本机一次运行,随负载变化。
失败模式与兜底
| 失败 | 表现 | 兜底 |
|---|---|---|
| 节点假死 | 健康检查通过,但事件循环卡住 | 应用层心跳 + 主动摘除,不只看端口 |
| 摘除引发重连风暴 | 剩余节点 CPU 飙高 | 分批摘除 + 客户端重连抖动 |
| 成员表漂移 | 不同副本路由到不同节点 | 配置版本号 + 启动时校验哈希 |
| 粘性会话丢失 | 玩家被分到新节点 | 走会话恢复;不能恢复就重建 |
| 限流误伤 | NAT 后大量玩家共享 IP | 账号维度优先,IP 只做粗粒度保护 |
| 灰度标签泄漏 | 内部标签被客户端伪造 | 标签只在入口计算,内部请求签名 |
常见错误与边界
第一,在网关里写业务规则。活动资格、匹配条件、背包校验都应该在业务服。
第二,用客户端 IP 做粘性。NAT、代理和移动网络会让大量玩家共享出口 IP。
第三,一致性哈希环不设虚拟节点。节点少时倾斜严重,扩容后迁移量也不可控。
第四,摘除节点直接杀进程。没有排空阶段,所有会话同时断线。
第五,所有请求都粘性。无状态的商城、邮件请求被粘住,扩容时新节点分不到流量。
第六,把认证和授权混为一谈。网关验签是身份问题,业务服授权是权限问题,两者变更频率不同。
第七,成员表没有版本。配置中心推送失败时,副本之间会长期不一致。
小结
网关的边界可以用一句话概括:只处理“连接能不能建立、消息该去哪里”,不处理“玩家能不能做这件事”。 边界清楚之后,横向扩展才有意义:无状态请求不粘性,房间和会话按需粘性,节点摘除走排空而不是直接杀。
internal/gateway 用 rendezvous 哈希实现了最小重分配的路由表。下一节进入会话本身:顶号、重连和跨网关迁移如何用同一个 epoch 保证旧连接不再写入。
阅读导航:上一节:5.1 长连接、短连接、WebSocket 与 QUIC 的取舍 · 下一节:5.3 会话、重连、顶号与连接迁移 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。