《游戏服务端编程实践》5.1.3 消息路由与负载均衡

把游戏网关的路由拆成“网关到后端服务”和“服务到具体实例”两层,比较轮询、最少连接、一致性哈希、取模与加权随机五种负载均衡策略,说明游戏为什么通常按 playerID 或 roomID 哈希,并给出支持权重、摘除和动态换表的路由器 Go 实现与稳定性、重分配测试。

网关完成认证后,每条消息都要回答同一个问题:它应该交给谁处理。HTTP 系统可以把请求随机分给任意副本,因为副本之间不共享会话;游戏不行。玩家 A 的背包在 3 号地图服,玩家 B 的交易请求必须找到 B 所在的实例,房间 C 的战斗帧只能由持有该房间的进程处理。路由错了,轻则消息丢失,重则两个实例同时写同一份状态。

本节把路由拆成两层,比较四种常见负载均衡策略,解释游戏为什么偏爱哈希而不是随机,并讨论路由表从哪来、扩缩容时怎么迁移。最后给出一个支持权重、健康检查和动态换表的 Go 路由器,用测试验证同一玩家稳定落点、摘除后端只影响该后端上的玩家。

一、两层路由

游戏消息通常要经过两次选择:先从网关选后端服务,再从服务选具体实例。两层解决的问题不同,不能混在一起。

层级输入选择目标典型依据
网关到后端服务消息类型 + 玩家上下文地图服、战斗服、聊天服、交易服协议号、playerID、roomID、sceneID
后端服务到实例业务键持有该状态的具体进程playerID 哈希、roomID 分片、一致性哈希环

第一层是“找对服务”。登录消息去登录服,移动消息去地图服,聊天消息去聊天服,交易消息去交易服。这一层通常是静态映射,按消息号或服务名查表即可,变化频率低。

第二层是“找对实例”。同一个地图服可能部署了 20 个实例,每个实例只负责一部分玩家或房间。玩家在哪个实例上,取决于进入地图时的分配结果。这一层必须有状态,且所有网关看到的分配结果要一致,否则同一个玩家的消息会被送到不同实例。

两层混在一起会带来两个问题。第一,消息号到服务的映射被实例地址污染,扩缩容时所有网关都要改配置;第二,玩家迁移后只更新了实例地址,没有更新服务映射,消息会在错误的层被丢弃。正确做法是让第一层稳定、第二层可动态变化。

二、四种负载均衡策略

策略做法优点缺点游戏适用场景
轮询 Round Robin按顺序依次分配实现简单、分布均匀不考虑连接数和状态,扩缩容会打乱映射无状态服务,如日志上报、公告拉取
最少连接 Least Connections选当前连接最少的实例能适应长连接分布不均需要全局或近全局连接数,实例间同步有延迟短连接代理、HTTP 入口
一致性哈希 Consistent Hashing把键和后端映射到环上,顺时针找增删节点只影响邻近区间实现复杂,虚拟节点调参麻烦缓存、会话粘滞
按 ID 取模 Modulohash(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、错误率不上升”为准。

一次安全的扩缩容至少包含以下步骤:

  1. 新实例启动并预热,加入路由表但权重设为 0;
  2. 逐步提高新实例权重,观察错误率与延迟;
  3. 停止向待摘除实例分配新玩家;
  4. 迁移或等待存量玩家重连,监控剩余连接数;
  5. 权重降到 0 后继续观察一个心跳周期;
  6. 从路由表摘除实例,再关闭进程。

任何一步跳过,都可能让玩家在迁移过程中丢失消息。

六、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同一玩家重复选择、重建路由器落点始终一致
TestPickSpreadsPlayers3,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 哈希不需要显式虚拟节点,权重已经能表达容量差异。如果使用一致性哈希环,则需要虚拟节点来改善分布,节点数越少越需要。

边界二:玩家迁移后哈希结果变了怎么办? 哈希只负责初始分配。玩家迁移后应以中心路由服务的显式映射为准,网关先查映射,查不到再回退到哈希。两者要有明确的优先级。

边界三:后端不健康时能否直接摘除? 可以,但摘除会触发该后端上玩家的重新分配。应先停止新分配、等待心跳超时或主动迁移,再摘除,避免大量玩家在同一秒被重新路由。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist