《游戏服务端架构·品类、状态与拓扑》5.2 网关的职责边界与横向扩展

网关最常见的越界,是把业务规则写进转发层,结果一次活动改动就要发网关。本节划定连接终结、认证、路由、限流、协议转换和灰度标签的职责边界,比较粘性会话与无状态路由,用 rendezvous 哈希实现节点摘除后的最小重分配,并给出 drain 发布流程与故障兜底。

一次回滚暴露的职责越界

《裂隙战场》赛季更新那天,网关里多了一条规则:活动期间,未完成实名认证的游客不能进入排位匹配。规则本身没错,错在它被写进了网关的匹配转发路径。

配置漏了一个默认值。上线后,所有没有该标签的玩家都被判定为“未认证”,匹配接口返回 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 会话、重连、顶号与连接迁移 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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