《MMO 服务器开发》4.2 粘性策略与发布期行为

新连接该被分到哪个网关?会话粘性有三种实现:一致性哈希、目录查表、客户端重连票。本节把前两种从零实现,量出「摘掉一个节点时迁移多少人」并给出可判定的取舍结论,再讲清网关滚动发布期间在线玩家到底会看到什么、哪种组合才能做到发布无感。全部代码在本机真跑。

4.2 粘性策略与发布期行为

上一节回答了「玩家现在挂在哪个网关」,那是查询。这一节回答一个更靠前的问题:新连接进来时,该把它分到哪个网关? 这就是会话粘性(session stickiness)。

为什么需要粘性?因为网关不只是一根网线。每个玩家在网关上还挂着一点状态:会话、加密密钥、限流计数、以及「他正在哪个场景」的缓存。如果同一个玩家的两条连接随机落到两个网关,这些状态就要么得共享、要么会打架。粘性策略决定了「同一玩家的连接尽量落在同一网关」这件事怎么做到,以及——最关键——节点增减时,多少玩家会被迫换网关。

换网关对玩家是可感知的:轻则重连一次、画面卡一下,重则掉线重登。所以「发布(上线新版本、缩容、故障摘除)时迁移多少人」是评价粘性策略的第一指标。本节把主流的两三种做法从零实现、量出数字,并回答:发布期间,玩家到底会看到什么。

三种粘性策略与它们的代价

行业里做粘性,本质只有三条路:

策略谁决定去向节点增减时的迁移面额外组件适合
一致性哈希网关侧按玩家 ID 算~1/N(只迁被增删节点那一圈)无网关节点稳定、要省一次查表
目录查表目录服务说了算0(目录显式指派,可原地不动)一个目录要求发布无感、能接受一次 RTT
客户端重连票服务端签发令牌,客户端直连0(令牌里写死了目标节点)密钥管理断线重连、跨网关迁移

三者不是互斥的:真实系统常常「目录查表做主,重连票做快路径,哈希做兜底」。本节把一致性哈希和重连票从零写出来(目录查表上一节已经实现),并量出一致性哈希相对取模哈希的优势。

一致性哈希:从零实现

一致性哈希的想法是:把节点和玩家都散到一个 64 位环上,玩家归属「环上顺时针第一个 ≥ 自己散列值的节点」。节点增减时,只有落在被增删节点区间上的玩家会迁移,平均 1/N。为了让各节点分到的区间均匀,每个物理节点挂多个虚拟节点。下面这段是 L1,go test ./internal/gateway/ 可以直接跑:

// file: internal/gateway/ring.go
// tier: L1
// verify: go test ./internal/gateway/
package gateway

import (
	"encoding/binary"
	"sort"
	"strconv"
)

// hashBytes 是 FNV-1a 64 位散列,末尾再过一个 splitmix64 收尾函数。
// 收尾是必须的:玩家 ID 是 uint64,小号的高位全是 0,直接对 8 字节做 FNV
// 会让相邻 ID 的散列挤在一起(前导零字节对混合几乎无贡献),环上就退化成
// 「所有小号落同一个节点」。splitmix64 收尾把这点雪崩开。
func hashBytes(b []byte) uint64 {
	const (
		offset64 = 1469598103934665603
		prime64  = 1099511628211
	)
	h := uint64(offset64)
	for _, c := range b {
		h ^= uint64(c)
		h *= prime64
	}
	return mix64(h)
}

// mix64 是 splitmix64 的收尾混合,保证输出对输入的每一位都敏感。
func mix64(x uint64) uint64 {
	x ^= x >> 30
	x *= 0xbf58476d1ce4e5b9
	x ^= x >> 27
	x *= 0x94d049bb133111eb
	x ^= x >> 31
	return x
}

// hashPlayer 把玩家 ID 散成环上的一个点。
func hashPlayer(p PlayerID) uint64 {
	var b [8]byte
	binary.BigEndian.PutUint64(b[:], uint64(p))
	return hashBytes(b[:])
}

// vnodeCount 是每个物理节点的虚拟节点数。虚拟节点越多,环上各节点负责的
// 区间越均匀;160 是经验值,再往上收益递减。
const vnodeCount = 160

type ringPoint struct {
	h    uint64
	node NodeID
}

// Ring 是一致性哈希环。它把「玩家 → 网关」的映射做成纯函数:只要节点集合
// 不变,映射就稳定;节点增减时只有落在被增删节点区间上的玩家会迁移。
type Ring struct {
	points []ringPoint // 按 h 升序
	nodes  map[NodeID]struct{}
}

// NewRing 返回一个空环。
func NewRing() *Ring {
	return &Ring{nodes: make(map[NodeID]struct{})}
}

// Add 把一个物理节点挂到环上(同时挂 vnodeCount 个虚拟节点)。
func (r *Ring) Add(node NodeID) {
	if _, ok := r.nodes[node]; ok {
		return
	}
	r.nodes[node] = struct{}{}
	for i := 0; i < vnodeCount; i++ {
		h := hashBytes([]byte(string(node) + "#" + strconv.Itoa(i)))
		r.points = append(r.points, ringPoint{h: h, node: node})
	}
	sort.Slice(r.points, func(i, j int) bool { return r.points[i].h < r.points[j].h })
}

// Remove 把一个物理节点从环上摘掉(连同它的虚拟节点)。
func (r *Ring) Remove(node NodeID) {
	if _, ok := r.nodes[node]; !ok {
		return
	}
	delete(r.nodes, node)
	kept := r.points[:0]
	for _, p := range r.points {
		if p.node != node {
			kept = append(kept, p)
		}
	}
	r.points = kept
}

// Pick 返回玩家应落在的节点:环上第一个 hash ≥ hash(player) 的点,绕回到头。
func (r *Ring) Pick(p PlayerID) NodeID {
	if len(r.points) == 0 {
		return ""
	}
	h := hashPlayer(p)
	// 手写二分,找第一个 h >= player 的点。
	i := sort.Search(len(r.points), func(i int) bool { return r.points[i].h >= h })
	if i == len(r.points) {
		i = 0
	}
	return r.points[i].node
}

// Nodes 返回环上节点数。
func (r *Ring) Nodes() int { return len(r.nodes) }

有一个坑必须点出来:散列函数对短输入要雪崩。最初我用纯 FNV-1a 散 uint64 玩家 ID,结果小号(高位全 0)的散列值全挤在同一个区间,2 万个玩家全落到了同一个网关——环形同虚设。修法是在 FNV 之后加一步 splitmix64 收尾混合,代码里就是那个 mix64。这个 bug 很隐蔽:大号(随机 ID)看不出来,一上线全是小号才炸。

量出迁移面:一致性哈希 vs 取模

「~1/N」到底是多少,得跑出来。测试 TestRemapFraction 建 8 节点环、撒 2 万个玩家,记录归属;摘掉其中 1 个节点,再数多少人换了网关;同时用取模哈希(hash % N)做对照。下面是它的核心,go test ./internal/gateway/ -run TestRemapFraction 直接跑:

// snippet: internal/gateway/ring_test.go 节选
// 摘掉 1/8 节点,统计迁移比例。
ring.Remove("gateway-03")
moved := 0
for i := 0; i < nPlayers; i++ {
	if ring.Pick(PlayerID(i)) != before[PlayerID(i)] {
		moved++
	}
}
// 对照:取模哈希 hash(player) % N,N 从 8 变 7。

本机实跑结果:一致性哈希摘掉 1/8 节点只迁移 12.0% 的玩家(理论值 1/8 = 12.5%,误差来自虚拟节点分布),取模哈希迁移 87.4%(理论值 7/8 = 87.5%)。这组数字是同机相对量级,绝对数值不可移植,但两个比例的差是结构性的:取模哈希改一次节点数几乎全员换网关,一致性哈希只换一小圈。这就是「网关集群为什么不用取模」的量化答案。

重连票:把「去哪」写进一张签名令牌

一致性哈希解决「分到哪」,但断线重连时玩家不该重新被分——他应该回到刚才那个网关(或指定的新网关)。重连票的做法是:服务端签一张短令牌,里面写死「玩家 P 应去节点 N、有效期到 T」,用 HMAC 签名。客户端拿着它直连目标网关,网关本地验签即可放行,不必回查目录。下面这段是 L1,go test ./internal/gateway/ 可以直接跑:

// file: internal/gateway/ticket.go
// tier: L1
// verify: go test ./internal/gateway/
package gateway

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/binary"
	"errors"
)

// 重连票据的失败原因,客户端据此决定是「静默重连」还是「弹登录框」。
var (
	ErrTicketBadMAC  = errors.New("gateway: ticket mac mismatch")
	ErrTicketExpired = errors.New("gateway: ticket expired")
	ErrTicketFormat  = errors.New("gateway: ticket malformed")
)

// IssueTicket 签发一张重连票据。它把「玩家 → 目标节点」这一事实做成一条
// 带 HMAC 的短令牌:客户端拿着它直连目标网关,网关本地校验签名即可放行,
// 不必回查目录——这是「客户端重连票」粘性策略的关键。
func IssueTicket(key []byte, p PlayerID, node NodeID, exp int64) string {
	var head [16]byte
	binary.BigEndian.PutUint64(head[:8], uint64(p))
	binary.BigEndian.PutUint64(head[8:], uint64(exp))
	body := string(head[:]) + string(node)
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(body))
	sig := mac.Sum(nil)[:16]
	raw := string(head[:]) + string(node) + string(sig)
	return base64.RawURLEncoding.EncodeToString([]byte(raw))
}

// VerifyTicket 校验票据:先验签名,再看是否过期。返回玩家与目标节点。
// 解析从尾部切固定长度,不做分隔符扫描——签名是二进制,可能含任意字节,
// 用分隔符会切错位置。
func VerifyTicket(key []byte, tok string, now int64) (PlayerID, NodeID, error) {
	raw, err := base64.RawURLEncoding.DecodeString(tok)
	if err != nil {
		return 0, "", ErrTicketFormat
	}
	if len(raw) < 16+1+16 { // head(16) + 至少 1 字节节点名 + sig(16)
		return 0, "", ErrTicketFormat
	}
	sig := raw[len(raw)-16:]
	body := raw[:len(raw)-16]
	mac := hmac.New(sha256.New, key)
	mac.Write(body)
	if !hmac.Equal(sig, mac.Sum(nil)[:16]) {
		return 0, "", ErrTicketBadMAC
	}
	p := PlayerID(binary.BigEndian.Uint64(body[:8]))
	exp := int64(binary.BigEndian.Uint64(body[8:16]))
	node := NodeID(body[16:])
	if now > exp {
		return 0, "", ErrTicketExpired
	}
	return p, node, nil
}

票据的解析有一个容易写错的点:签名是二进制,可能含任意字节。我第一版用 | 当分隔符切载荷与签名,结果签名里恰好出现 | 时切错位置,校验恒失败。正确做法是从尾部切固定 16 字节当签名,前面整段是载荷——不依赖任何分隔符。测试覆盖了有效、篡改、过期、换密钥四条路径。

发布期间玩家会看到什么

现在回答那个实际问题:网关滚动发布(一台台重启)时,玩家会看到什么,取决于粘性策略。

  • 一致性哈希 + 无迁移:节点被摘掉,环上那一圈玩家下次重连时才会被分到新节点。已经在线的玩家不受影响,直到他自己的连接断开。玩家感知:发布期间不断线,但若此刻断线重连,可能落到不同网关,场景数据重新加载一次。
  • 目录查表 + 显式指派:目录可以把待下线网关上的玩家主动改派到新网关,配合下一节的排空流程,玩家收到重连票、平滑换网关。玩家感知:画面可能闪一下,但不掉线、不重登。
  • 客户端重连票:把「去哪」提前写进令牌,客户端断线后直连指定节点,省掉一次查表。玩家感知:重连最快,但仍是一次连接重建。

可判定的结论:如果发布要求「在线玩家不掉线、最多闪一下」,就必须用目录查表 + 排空 + 重连票的组合(下一节展开);纯一致性哈希只能保证「不主动踢人」,做不到「平滑迁移」。如果只是缩容、且能接受「谁刚好断线谁才换」,一致性哈希够用,还省一个目录组件。

本节小结 / 实测结论

会话粘性三条路各有代价:一致性哈希零组件、迁移面 ~1/N,但发布时只能被动等玩家断线;目录查表能显式指派、发布可做到迁移面为 0,代价是多一个目录与一次 RTT;重连票把去向写进签名令牌、重连最快,代价是密钥管理与票据生命周期。本机实测量化了一致性哈希相对取模的优势:摘掉 1/8 节点,前者迁移 12.0%、后者 87.4%(同机相对量级)。结论很硬:要发布无感,目录 + 排空 + 重连票是必需的。下一节就把「排空」这条链路从零跑通:停止接受新连接、广播 CmdDrainNotice 0x1001、下发 CmdReconnectTicket 0x1002、等旧连接自然结束、超时兜底。

阅读导航:上一节:4.1 连接位置与路由目录 · 下一节:4.3 连接排空(draining) 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标