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) 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。