网关完成认证后,每条消息都要回答同一个问题:它应该交给谁处理。HTTP 系统可以把请求随机分给任意副本,因为副本之间不共享会话;游戏不行。玩家 A 的背包在 3 号地图服,玩家 B 的交易请求必须找到 B 所在的实例,房间 C 的战斗帧只能由持有该房间的进程处理。路由错了,轻则消息丢失,重则两个实例同时写同一份状态。
本节把路由拆成两层,比较四种常见负载均衡策略,解释游戏为什么偏爱哈希而不是随机,并讨论路由表从哪来、扩缩容时怎么迁移。最后给出一个支持权重、健康检查和动态换表的 Go 路由器,用测试验证同一玩家稳定落点、摘除后端只影响该后端上的玩家。
一、两层路由
游戏消息通常要经过两次选择:先从网关选后端服务,再从服务选具体实例。两层解决的问题不同,不能混在一起。
| 层级 | 输入 | 选择目标 | 典型依据 |
|---|---|---|---|
| 网关到后端服务 | 消息类型 + 玩家上下文 | 地图服、战斗服、聊天服、交易服 | 协议号、playerID、roomID、sceneID |
| 后端服务到实例 | 业务键 | 持有该状态的具体进程 | playerID 哈希、roomID 分片、一致性哈希环 |
第一层是“找对服务”。登录消息去登录服,移动消息去地图服,聊天消息去聊天服,交易消息去交易服。这一层通常是静态映射,按消息号或服务名查表即可,变化频率低。
第二层是“找对实例”。同一个地图服可能部署了 20 个实例,每个实例只负责一部分玩家或房间。玩家在哪个实例上,取决于进入地图时的分配结果。这一层必须有状态,且所有网关看到的分配结果要一致,否则同一个玩家的消息会被送到不同实例。
两层混在一起会带来两个问题。第一,消息号到服务的映射被实例地址污染,扩缩容时所有网关都要改配置;第二,玩家迁移后只更新了实例地址,没有更新服务映射,消息会在错误的层被丢弃。正确做法是让第一层稳定、第二层可动态变化。
二、四种负载均衡策略
| 策略 | 做法 | 优点 | 缺点 | 游戏适用场景 |
|---|---|---|---|---|
| 轮询 Round Robin | 按顺序依次分配 | 实现简单、分布均匀 | 不考虑连接数和状态,扩缩容会打乱映射 | 无状态服务,如日志上报、公告拉取 |
| 最少连接 Least Connections | 选当前连接最少的实例 | 能适应长连接分布不均 | 需要全局或近全局连接数,实例间同步有延迟 | 短连接代理、HTTP 入口 |
| 一致性哈希 Consistent Hashing | 把键和后端映射到环上,顺时针找 | 增删节点只影响邻近区间 | 实现复杂,虚拟节点调参麻烦 | 缓存、会话粘滞 |
| 按 ID 取模 Modulo | hash(key) % N | 实现最简单、落点直观 | 节点数变化时几乎所有键都要迁移 | 分片数固定的场景 |
| 加权随机 Weighted Random | 按权重随机选择 | 实现简单、长期分布接近权重 | 单次分配不稳定,同一玩家可能落到不同实例 | 无状态且可重试的任务 |
游戏网关最常用的是“按 playerID 哈希选实例”,但哈希函数和节点变化方式很关键。简单取模在扩容时会把 N 从 20 变成 21,导致大部分玩家换实例;一致性哈希和 rendezvous 哈希能把迁移范围限制在受影响节点上。本文的 route.go 使用加权 rendezvous 哈希:每个后端根据 (playerID, backendID) 计算一个分数,取分数最优者。它的好处是实现比一致性哈希环简单,权重表达自然,摘除节点时只有原本落在该节点上的键会移动。
“最少连接”在游戏里还有一个隐蔽问题:连接数不等于负载。一个玩家可能只挂机收邮件,另一个玩家在打 40 人团本;前者占一条连接但几乎不发消息,后者一条连接可能每秒产生上百条战斗指令。按连接数均衡会把大量“轻连接”堆到同一实例,看起来连接数很平均,CPU 却被少数战斗实例打满。如果确实要按负载均衡,应该用消息速率、CPU 或房间数作为权重,而不是只看连接数。
2.1 选择策略前先问三个问题
- 同一个业务键的两次请求,能否落到不同实例?
- 后端实例是否有本地状态,迁移成本多高?
- 实例数量变化频率如何,扩容时能接受多大比例的玩家迁移?
如果第一题答案是“能”,轮询或随机就够;如果“不能”,必须用哈希,并根据第二、三题决定用取模、一致性哈希还是 rendezvous。取模适合分片数长期固定的场景,一致性哈希和 rendezvous 适合实例数会变化的场景。
三、为什么游戏常用哈希而不是随机
随机分配的前提是“任意实例都能处理任意请求”。游戏里这个前提经常不成立。
3.1 玩家状态有归属
玩家的位置、背包、任务进度通常只由一台地图服持有。消息随机落到另一台实例,要么找不到状态,要么触发一次昂贵的远程读取。哈希让同一 playerID 始终落到同一实例,网关不需要维护“玩家在哪”的完整映射,只需要在玩家迁移时更新一次。
这里有一个前提:哈希的输入必须在玩家整个会话期间保持不变。如果客户端重连后 accountID 不变但 playerID 重新分配,哈希落点就会改变。因此 playerID 必须由服务端稳定签发,且跨重连复用;临时会话 ID、连接 ID 都不能作为路由键。
3.2 房间和战斗有所有权
房间 C 的战斗帧、技能结算、掉落分配都应由同一个进程串行处理,否则需要分布式锁或事务。按 roomID 哈希可以把同一房间的消息固定到一个实例,把并发问题转化为单进程内的顺序处理。
3.3 交易和邮件需要幂等边界
交易请求涉及两个玩家、多份物品和一条订单记录。如果同一笔交易的不同消息被路由到不同实例,幂等键和锁的边界会变得非常复杂。按玩家或订单 ID 哈希,可以让同一笔交易落在同一实例,简化去重和重试。
3.4 跨服玩法需要可预测的落点
跨服战场、跨服拍卖行需要把大量玩家分配到少量跨服实例。哈希可以让所有网关在不共享随机种子的情况下得到相同结果,减少协调成本。
随机分配并非完全不能用。公告推送、日志采集、无状态的排行榜刷新可以用随机或轮询,因为这些任务可以重试、不依赖本地状态。判断标准是:同一业务键的两次请求,是否允许落到不同实例?允许就用随机,不允许就用哈希。
选择路由键时还要注意粒度:
- 按 playerID 路由适合背包、任务、个人战斗这类玩家私有状态;
- 按 roomID 路由适合房间战斗、观战和帧同步;
- 按 tradeID 或 orderID 路由适合交易、拍卖等需要单点串行的业务;
- 按 guildID 路由适合公会仓库、公会战,但要注意大公会可能形成热点。
路由键越粗,单实例承载的状态越多,热点风险越大;路由键越细,跨键操作越难保证原子性。没有通用答案,只能按业务一致性要求选择。
四、路由表从哪来
路由表至少有三个来源,复杂度和一致性依次上升。
| 来源 | 做法 | 优点 | 风险 |
|---|---|---|---|
| 静态配置 | 网关启动时读取配置文件 | 简单、可审计、启动快 | 扩缩容要改配置并重启或热加载 |
| 服务发现 | 从注册中心订阅实例列表 | 自动感知上下线 | 注册中心抖动会导致路由表频繁变化 |
| 中心路由服务 | 独立服务维护玩家到实例的映射 | 支持玩家迁移和精细控制 | 中心服务成为关键路径,需要高可用 |
小规模项目用静态配置足够:实例数固定,扩缩容走发布流程。中型项目通常用服务发现拿实例列表,再由网关本地做哈希。大型项目需要中心路由服务,因为玩家会在实例之间迁移,光靠哈希无法表达“这个玩家现在在 7 号实例,而不是哈希算出来的 3 号”。
无论用哪种来源,路由表都要带版本号。网关在处理消息时记录自己使用的表版本,后端发现版本过旧可以拒绝并返回“请刷新路由表”。没有版本号,排障时无法判断一条消息是按哪份拓扑转发的。
服务发现的抖动值得单独处理。注册中心网络波动时,实例列表可能在一秒内反复增删同一个节点,如果网关每次都立即重建路由表,会造成大量玩家在实例之间来回迁移。常见做法是给实例列表加稳定窗口:新实例可以立即加入,摘除实例要连续多个心跳周期确认后才生效;同时给路由表变更加最小间隔,避免短时间内多次重排。
一张可维护的路由表至少记录这些信息:
epoch:单调递增的版本号,用于比较新旧;services:消息类型或服务名到后端组的映射;instances:每个后端组的实例 ID、地址、权重、健康状态;assignment:需要显式迁移的玩家或房间到实例的覆盖映射;updated_at:生成时间,用于排查发布延迟。
其中 assignment 是可选项,小项目可以只用哈希。一旦支持玩家迁移,它就必须存在,否则哈希无法表达“这个玩家现在不在默认落点”。
五、扩缩容时的重平衡
扩缩容有两种处理方式:迁移会话,或让连接重连。选择取决于状态大小和玩家体验要求。
| 方式 | 做法 | 优点 | 代价 | 适用 |
|---|---|---|---|---|
| 迁移会话 | 把玩家状态和连接所有权转移到新实例 | 玩家无感、连接不断 | 实现复杂,需要状态快照、双写或交接协议 | 大地图、长周期玩法 |
| 让连接重连 | 关闭旧连接,客户端重新登录并落到新实例 | 实现简单、边界清晰 | 玩家短暂掉线,可能触发重连风暴 | 房间制、短会话玩法 |
| 混合 | 空闲玩家重连,战斗中的玩家迁移 | 平衡体验与复杂度 | 需要判定“是否可迁移” | 大多数中型项目 |
最容易犯的错误是“扩缩容只改路由表,不处理已有连接”。新表生效后,新消息按新表转发,但旧连接仍指向旧实例,玩家的状态被拆成两半。正确顺序是:先让新实例就绪,再逐步把新连接导入,然后处理旧实例上的存量连接,最后摘除旧实例。每一步都要有可观测指标,例如“旧实例剩余连接数”。
哈希路由的迁移成本可以估算。使用 rendezvous 哈希时,摘除一个权重为 1 的后端,只有原本落在该后端上的键会重新分配,比例约等于该后端权重占总权重的比例。三个等权后端摘除一个,约 1/3 的玩家需要迁移;如果后端权重是 1:1:2,摘除权重 1 的后端只影响约 1/4 的玩家。这个比例是设计扩缩容窗口的重要依据。
迁移窗口还要考虑“迁移风暴”的叠加效应。假设一次扩容要迁移 5,000 名玩家,如果迁移是瞬时完成的,新实例会在几秒内同时加载 5,000 份玩家状态,数据库和缓存可能被打满。更稳妥的做法是分批迁移,按每秒几十到几百人的速率推进,并在每批之间观察新实例的 CPU、内存和加载耗时。迁移速率没有通用数字,口径应以“新实例 P99 加载延迟不超过 200 ms、错误率不上升”为准。
一次安全的扩缩容至少包含以下步骤:
- 新实例启动并预热,加入路由表但权重设为 0;
- 逐步提高新实例权重,观察错误率与延迟;
- 停止向待摘除实例分配新玩家;
- 迁移或等待存量玩家重连,监控剩余连接数;
- 权重降到 0 后继续观察一个心跳周期;
- 从路由表摘除实例,再关闭进程。
任何一步跳过,都可能让玩家在迁移过程中丢失消息。
六、Go 实现:加权 rendezvous 路由
下面的 route.go 只做一件事:给定 playerID 和后端表,返回应该处理该玩家的实例。它支持权重、健康检查和整表替换,所有网关用同一份表就能得到相同结果。
// file: ch05/gateway/route.go
// tier: L1
// verify: go test ./ch05/gateway/
package gateway
import (
"encoding/binary"
"errors"
"hash/fnv"
"math"
"sync"
)
// ErrNoBackend means every candidate is unhealthy or the table is empty.
var ErrNoBackend = errors.New("gateway: no healthy backend")
// Backend is one routable instance. ID must be stable across restarts, for
// example "room-svc/shard-3", not a pod IP that changes on every deploy.
type Backend struct {
ID string
Weight int
Healthy bool
}
// Router maps a player ID to a backend with weighted rendezvous hashing.
// Rendezvous hashing keeps most keys in place when a backend is removed and
// gives every gateway the same answer without a shared random seed.
type Router struct {
mu sync.RWMutex
backends []Backend
}
// NewRouter copies and normalizes the initial table.
func NewRouter(backends []Backend) *Router {
return &Router{backends: normalizeBackends(backends)}
}
// SetBackends swaps the whole table at once. Readers never observe a
// half-applied change.
func (r *Router) SetBackends(backends []Backend) {
r.mu.Lock()
r.backends = normalizeBackends(backends)
r.mu.Unlock()
}
func normalizeBackends(in []Backend) []Backend {
out := make([]Backend, 0, len(in))
for _, b := range in {
if b.ID == "" || b.Weight <= 0 {
continue
}
out = append(out, b)
}
return out
}
// Pick returns the backend that owns playerID. The result is stable for the
// same table and player, regardless of process or gateway.
func (r *Router) Pick(playerID uint64) (Backend, error) {
r.mu.RLock()
defer r.mu.RUnlock()
var best Backend
bestScore := math.Inf(1)
found := false
for _, b := range r.backends {
if !b.Healthy {
continue
}
score := -math.Log(unitHash(playerID, b.ID)) / float64(b.Weight)
if !found || score < bestScore || (score == bestScore && b.ID < best.ID) {
best, bestScore, found = b, score, true
}
}
if !found {
return Backend{}, ErrNoBackend
}
return best, nil
}
// unitHash maps (playerID, backendID) into (0, 1). Keeping half a step of
// offset avoids both log(0) and exactly 1.0.
func unitHash(playerID uint64, backendID string) float64 {
h := fnv.New64a()
var buf [8]byte
binary.LittleEndian.PutUint64(buf[:], playerID)
_, _ = h.Write(buf[:])
_, _ = h.Write([]byte(backendID))
return (float64(h.Sum64()>>11) + 0.5) / (1 << 53)
}
代码的核心是 unitHash 和打分公式。unitHash 把 (playerID, backendID) 映射到 0 到 1 之间的稳定值,-log(u)/weight 把权重转成“分数越小越优先”。权重越大,分母越大,分数越容易变小,被选中的概率越高。这个公式不需要维护哈希环,增删后端时也不会影响未被摘除节点上的键。
SetBackends 用写锁整表替换,读路径用读锁,因此网关可以在不中断消息处理的情况下热更新路由表。生产中还可以在替换前后记录版本号,并把版本号带回后端,便于排查“为什么这条消息去了旧实例”。
这个实现刻意不缓存计算结果。每次 Pick 都重新遍历后端表,单次成本是 O(N),N 通常只有几十,完全够用。如果后端数量达到数百,可以在网关本地加一层 playerID -> backendID 的短 TTL 缓存,但缓存失效逻辑必须和 SetBackends 绑定:换表时清空缓存,否则玩家会在新旧表之间来回跳。缓存只优化性能,不能成为路由事实源。
七、测试与边界
route_test.go 覆盖了路由最容易回退的性质:
| 测试 | 验证点 | 期望 |
|---|---|---|
TestPickIsStableForSamePlayer | 同一玩家重复选择、重建路由器 | 落点始终一致 |
TestPickSpreadsPlayers | 3,000 个玩家分布 | 三个后端都被使用且不过度倾斜 |
TestRemovalMovesOnlyAffectedPlayers | 摘除一个后端 | 原本不在该后端的玩家一个都不移动 |
TestWeightsBiasAssignment | 权重 1:3 | 低权重后端占比约 25% |
TestUnhealthyBackendIsSkipped | 后端标记不健康 | 不会选中不健康实例 |
TestNoHealthyBackend | 全部不健康或空表 | 返回 ErrNoBackend |
TestInvalidBackendsAreDropped | 空 ID、零权重、负权重 | 构造时被过滤 |
权重测试使用 20,000 个固定 ID,结果是确定性的,不依赖随机种子。断言区间设为 0.18–0.32,既验证权重生效,又不会因为哈希函数的微小实现差异而误报。真正上线前,仍应拿真实玩家 ID 分布跑一遍,确认没有明显的热点。
测试里没有覆盖“路由表版本切换”和“并发读写”,因为这两个性质更适合集成测试。单元测试负责守住哈希的数学性质,集成测试负责验证换表、摘除、迁移的完整流程。两层测试分开,失败时更容易定位是算法问题还是流程问题。
集成测试至少要覆盖这些场景:
- 换表过程中持续发送消息,确认没有消息落到已摘除实例;
- 两个网关使用不同版本的路由表,确认后端能拒绝过期版本;
- 后端健康状态翻转,确认流量在确认窗口后才切换;
- 玩家显式迁移后,确认哈希回退不会把消息送回旧实例。
这些场景用单元测试模拟成本很高,更适合起一个本地多进程环境,用真实网络和真实路由表跑一遍。
八、失败场景:路由表不一致导致双写
某项目扩容地图服时,运维先更新了 A 组网关的路由表,B 组网关仍在使用旧表。新表把 30% 的玩家分配到新实例,旧表仍指向旧实例。结果同一玩家的移动消息被两组网关送到不同实例,两个实例各自加载了一份玩家状态,拍卖行出现重复上架,背包出现物品复制。
根因不是哈希函数,而是路由表没有统一版本。修复方案有四条:第一,路由表带单调递增的 epoch,网关只接受比本地新的表;第二,后端在处理消息时校验请求携带的 epoch,落后超过一个窗口就拒绝并返回重定向;第三,路由表变更走统一的发布通道,所有网关在同一个时间窗口内切换;第四,切换期间对写操作加玩家级锁或幂等键,防止双写造成不可逆结果。
更进一步的方案是把“玩家当前在哪个实例”从哈希结果升级为显式映射,由中心路由服务维护,哈希只用于初始分配。这样即使路由表短暂不一致,后端也能通过查询中心服务纠正。代价是每次跨实例消息多一次查询,适合对一致性要求高的交易和背包操作,不适合高频移动消息。
九、落地检查清单
- 路由分两层:消息号到服务、业务键到实例,两层配置分开管理;
- 同一 playerID 或 roomID 在所有网关上落到同一后端,哈希函数和权重配置一致;
- 路由表带版本号或 epoch,网关与后端都能识别过期表;
- 扩缩容有明确的存量连接处理方案,不是只改路由表;
- 后端摘除前先停止新分配,等待存量连接迁移或重连;
- 路由表变更走统一发布通道,避免不同网关长期并存两个版本;
- 监控每个后端的连接数、消息量和迁移比例,及时发现热点与倾斜。
下一节从路由进入消息转发策略,讨论玩家绑定与连接会话如何落到具体后端,见 /game-server-in-action-521/。
小结
游戏路由的本质是“让同一业务键稳定落到同一实例”。第一层按消息类型选服务,第二层按 playerID、roomID 或其他业务键选实例。轮询和最少连接适合无状态请求,游戏更常用哈希,因为玩家状态、房间所有权和交易幂等都依赖稳定落点。路由表可以来自静态配置、服务发现或中心路由服务,但必须带版本号,扩缩容时必须处理存量连接。加权 rendezvous 哈希用较小的实现成本换来了稳定落点、自然权重和最小迁移范围,适合作为网关路由的默认方案。
常见错误与边界
错误一:路由表各网关不一致。 不同网关用不同版本的表,同一玩家会被送到不同实例,轻则消息丢失,重则双写。路由表必须有版本号,并通过统一通道发布。
错误二:按连接数均衡导致同一玩家落到不同后端。 最少连接策略适合无状态代理,不适合有状态游戏。玩家重连后连接数变化,落点随之改变,本地状态立刻失效。
错误三:扩缩容不迁移。 只改路由表不处理存量连接,会让旧实例上的玩家状态变成孤儿。扩缩容必须包含“停止新分配、迁移或重连、摘除旧实例”的完整流程。
错误四:用随机数做玩家路由。 随机分配在单次请求上看不出问题,但同一玩家的连续消息会落到不同实例,状态一致性无法保证。除非业务明确允许无状态重试,否则不要用随机。
边界一:哈希是否需要虚拟节点? rendezvous 哈希不需要显式虚拟节点,权重已经能表达容量差异。如果使用一致性哈希环,则需要虚拟节点来改善分布,节点数越少越需要。
边界二:玩家迁移后哈希结果变了怎么办? 哈希只负责初始分配。玩家迁移后应以中心路由服务的显式映射为准,网关先查映射,查不到再回退到哈希。两者要有明确的优先级。
边界三:后端不健康时能否直接摘除? 可以,但摘除会触发该后端上玩家的重新分配。应先停止新分配、等待心跳超时或主动迁移,再摘除,避免大量玩家在同一秒被重新路由。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。