一条连接应该落在哪
上一节解决了“凭证是否有效”。凭证有效之后,下一个问题立刻出现:这条连接应该由哪个网关承载?
《裂隙战场》在版本更新后遇到过一次典型故障。运维把 gw-a 摘除,客户端的重连请求被负载均衡器轮询到 gw-b,但 gw-b 的本地归属表里没有这些账号,于是玩家看到“连接成功,但角色加载失败”。另一边,《群岛纪元》因为全球同服,欧洲玩家重连时被分到亚洲网关,延迟从四十毫秒涨到三百毫秒。两个故障都不是协议问题,而是连接归属没有单一权威。
本节不重写网络库,也不讨论 WebSocket 与 QUIC 的握手细节;这些属于连接层实现。这里只讨论架构决策:网关的职责边界、归属表放在哪里、节点故障后谁来重新分配。
网关只做四件事
网关是连接层与业务层之间的窄腰。它的职责可以压缩成四件事:
| 职责 | 具体内容 | 不该做的事 |
|---|---|---|
| 连接终结 | TLS、握手、心跳、断线检测 | 不解析战斗指令 |
| 认证 | 校验票据与会话令牌 | 不查密码、不做账号找回 |
| 路由 | 把消息转发给正确的业务服 | 不决定匹配规则、不结算奖励 |
| 限流与协议转换 | 边缘限速、压缩、版本兼容 | 不保存角色状态 |
这条边界一旦模糊,网关就会变成“第二个业务服”。常见的越界是:网关为了“减少一次转发”,直接缓存玩家背包;或者为了“快速失败”,在网关里判断技能冷却。前者制造了两份角色状态,后者把玩法规则散落到无法独立发布的进程里。
网关可以有状态,但只能是连接状态和归属状态,不能是玩法状态。连接状态包括 socket、心跳、已认证身份;归属状态是“这个账号当前由哪个节点负责”。两者都属于基础设施,不属于某个玩法模块。
连接归属的三种放法
归属表回答两个问题:账号应该路由到哪个节点;当前这条连接是不是该账号的合法连接。可选方案有三种:
| 方案 | 路由方式 | 节点故障时 | 主要代价 |
|---|---|---|---|
| 网关本地表 | 每个网关维护自己连接的账号 | 其他网关不知道,需要广播或重连 | 表与真实连接容易偏差 |
| 共享存储 | Redis 等保存账号到节点映射 | 读取共享表即可重分配 | 每次路由一次网络往返,存在过期写 |
| rendezvous 计算 | 用账号和节点列表哈希出节点 | 节点列表变化后重新计算 | 无法表达“临时迁移”,需要额外归属令牌 |
北辰三款产品的选择不同:
- 《泡泡工坊》用网关本地表。大厅无状态,房间由独立的房间服分配,网关只需要知道“这条连接属于谁”,不需要全局路由。
- 《裂隙战场》用 rendezvous 计算。房间服数量少、生命周期短,账号到房间的映射由匹配服务决定,网关只负责把连接送到正确的房间入口。
- 《群岛纪元》用“rendezvous 计算 + 共享归属记录”。路由结果可以本地算,但跨服迁移和故障重分配需要一份带版本号的归属记录做仲裁。
三种方案没有绝对优劣,关键是把“计算路由”和“拥有连接”分开:路由可以确定性计算,拥有关系必须有明确的写入点和失效规则。
粘性会话的代价
只要连接有状态,就存在粘性。粘性的好处是简单:同一条连接始终落在同一个节点,缓存和会话都在本地。代价会在三个时刻集中出现:
| 时刻 | 表现 | 缓解 |
|---|---|---|
| 扩容 | 新节点只接管一部分账号,旧节点仍需保留状态 | rendezvous 只迁移应属新节点的账号,迁移量可预测 |
| 缩容或摘除 | 被摘节点的玩家集体重连 | 先停止接新连接,再排空,最后摘除 |
| 节点假死 | 归属表仍指向该节点,连接已经不可用 | 心跳加租约,租约过期后允许重新归属 |
最危险的是“缩容即摘除”。节点还在处理连接时被直接从负载均衡器移除,客户端收到断线后立刻重连,所有重连请求同时涌向剩余节点。如果重连没有抖动和排队,就会形成重连风暴;这部分在下一节展开。
另一个隐蔽代价是归属表与真实连接的偏差。网关崩溃后,共享存储里的记录不会自动消失;新网关看到“账号已归属 gw-a”,可能拒绝接管。解决办法不是让记录永不过期,而是给归属加租约:网关定期续租,租约过期才允许其他节点接管。租约时间要明显大于一次正常心跳间隔,否则网络抖动会引发误接管。
谁拥有连接状态
必须明确一条规则:网关拥有连接,业务服拥有角色。 两者不能同时声称自己权威。
- 网关知道 socket 是否存活、身份是否已认证、消息从哪来。
- 业务服知道角色在哪张地图、当前血量、背包内容。
- 归属令牌(ownership token)是两者之间的 fencing token:每次归属变更都递增,旧网关转发的消息携带旧令牌,业务服直接拒绝。
这条规则和上一节的会话版本号是同一种思路:不依赖“旧连接已经断开”这种假设,而是让旧连接即使还活着也无法写入。令牌由归属记录的写入方生成,不能由客户端携带,也不能由网关自行编造。
代码:摘除节点与重分配
下面的 owner.go 实现两种摘除语义:
RemoveNode用于排空:删除节点和它的归属记录,返回需要重连的账号。客户端重新走登录或重连流程,服务端不做后台迁移。Failover用于故障:删除节点后立即为受影响的账号重新计算归属,并生成新的归属令牌。旧连接的下一条消息会因为令牌不匹配被拒绝。
路由计算在 pick.go,使用加权 rendezvous:每个节点按 weight / -ln(u) 打分,取最高分。它的性质是:增加节点只影响应属新节点的账号;删除节点只影响原本属于该节点的账号。
// file: internal/gwroute/owner.go
// tier: L1
// verify: go test ./internal/gwroute/
// Package gwroute assigns accounts to gateway nodes with weighted rendezvous
// hashing and tracks which node currently owns each account's connection.
package gwroute
import "sort"
// RemoveNode forgets a drained node and drops its ownership records. The
// returned accounts must reconnect and claim on the recomputed node.
func (r *Router) RemoveNode(id string) ([]string, error) {
r.mu.Lock()
defer r.mu.Unlock()
if err := r.removeLocked(id); err != nil {
return nil, err
}
var orphaned []string
for account, own := range r.owners {
if own.NodeID == id {
delete(r.owners, account)
orphaned = append(orphaned, account)
}
}
sort.Strings(orphaned)
return orphaned, nil
}
// Failover removes a failed node and reassigns its accounts server-side.
// Ownership tokens change, so messages from the old connection are fenced.
func (r *Router) Failover(id string) (map[string]Owner, error) {
r.mu.Lock()
defer r.mu.Unlock()
if _, err := r.indexLocked(id); err != nil {
return nil, err
}
if len(r.nodes) == 1 {
return nil, ErrNoNodes
}
if err := r.removeLocked(id); err != nil {
return nil, err
}
moved := map[string]Owner{}
for account, own := range r.owners {
if own.NodeID != id {
continue
}
target, _ := r.pickLocked(account)
r.seq++
next := Owner{NodeID: target, Token: r.seq, Since: r.now()}
r.owners[account] = next
moved[account] = next
}
return moved, nil
}
// Owner reports the current claim, if any.
func (r *Router) Owner(accountID string) (Owner, bool) {
r.mu.Lock()
defer r.mu.Unlock()
o, ok := r.owners[accountID]
return o, ok
}
// Validate is the check every message passes before the gateway forwards it.
func (r *Router) Validate(accountID, nodeID string, token uint64) error {
r.mu.Lock()
defer r.mu.Unlock()
o, ok := r.owners[accountID]
if !ok {
return ErrNoOwner
}
if o.NodeID != nodeID {
return ErrNotOwner
}
if o.Token != token {
return ErrStaleToken
}
return nil
}
func (r *Router) Nodes() []string {
r.mu.Lock()
defer r.mu.Unlock()
ids := make([]string, len(r.nodes))
for idx, n := range r.nodes {
ids[idx] = n.id
}
return ids
}
测试覆盖三种情况:非末节点故障后所有受影响账号都获得新归属;未知节点返回明确错误;摘除最后一个节点必须失败,不能把集群摘空。
// file: internal/gwroute/failover_test.go
// tier: L1
package gwroute
import (
"errors"
"fmt"
"testing"
)
func TestFailoverReassigns(t *testing.T) {
tests := []struct {
name string
fail string
wantErr error
wantMoved bool
}{
{"failed non-last node", "gw-b", nil, true},
{"unknown node", "gw-x", ErrUnknownNode, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r := newTestRouter(t)
before := map[string]Owner{}
for n := 0; n < 300; n++ {
acct := fmt.Sprintf("acc-%d", n)
owner, err := r.Assign(acct)
if err != nil {
t.Fatalf("Assign(%s): %v", acct, err)
}
before[acct] = owner
}
moved, err := r.Failover(tc.fail)
if !errors.Is(err, tc.wantErr) {
t.Fatalf("got %v, want %v", err, tc.wantErr)
}
if err != nil {
return
}
if tc.wantMoved && len(moved) == 0 {
t.Fatal("failover moved no accounts")
}
for acct, owner := range moved {
if owner.NodeID == tc.fail {
t.Fatalf("%s still points at failed node", acct)
}
picked, err := r.Pick(acct)
if err != nil || picked != owner.NodeID {
t.Fatalf("%s picked %q, owner says %q (%v)", acct, picked, owner.NodeID, err)
}
if err := r.Validate(acct, owner.NodeID, owner.Token); err != nil {
t.Fatalf("%s new ownership rejected: %v", acct, err)
}
}
for acct, owner := range before {
if owner.NodeID == tc.fail {
continue
}
now, ok := r.Owner(acct)
if !ok || now.NodeID != owner.NodeID || now.Token != owner.Token {
t.Fatalf("%s ownership changed although it was not on %s", acct, tc.fail)
}
}
})
}
single := New(nil)
if err := single.AddNode("only", 1); err != nil {
t.Fatalf("AddNode: %v", err)
}
if _, err := single.Failover("only"); !errors.Is(err, ErrNoNodes) {
t.Fatalf("last-node failover error = %v, want ErrNoNodes", err)
}
if len(single.Nodes()) != 1 {
t.Fatal("failed failover must not remove the last node")
}
}
本机验证
$ cd .local/checks/gsarch-lab
$ gofmt -l internal/gwroute
$ go vet ./internal/gwroute/...
$ go test -count=1 ./internal/gwroute/...
ok gsarchlab/internal/gwroute
gofmt -l 与 go vet 均无输出。go test 在本机通过 20 个用例,覆盖路由稳定性、节点增删的迁移范围、归属校验、故障重分配和错误边界。测试里的账号数量是示例规模,不代表生产容量。
失败模式与兜底
| 失败 | 表现 | 兜底 |
|---|---|---|
| 节点假死 | 归属仍指向不可用节点 | 心跳租约,过期后允许接管 |
| 摘除最后一个节点 | 集群没有可用节点 | 拒绝摘除,先扩容再摘 |
| 归属记录过期写 | 两个网关同时认为自己是权威 | 写入带版本号,旧版本写入失败 |
| 扩容迁移量超预期 | 大量玩家同时重连 | rendezvous 保证只迁移应属新节点的账号,分批发布 |
| 客户端缓存旧路由 | 连到已摘除节点 | 服务端返回新节点地址,客户端重新解析 |
| 归属令牌泄露 | 旧连接伪造新令牌 | 令牌由服务端生成并随连接下发,客户端不可写 |
常见错误与边界
第一,把负载均衡器的轮询当成路由。轮询只保证连接分散,不保证账号落在正确的业务节点。
第二,归属表只写不删。网关崩溃后记录残留,新连接被拒绝,玩家只能等租约过期。
第三,网关缓存角色状态。两份状态一旦分叉,回滚和补偿都比省下的那次转发昂贵。
第四,故障时同时做“摘除”和“迁移”。先摘除再迁移,顺序反了会让旧节点继续接收消息。
第五,忘记处理“最后一个节点”。故障转移函数必须拒绝把集群摘空,否则一次误操作会放大成全服不可用。
小结
网关的路由决策可以很轻:节点列表加账号哈希就能算出目标。真正需要设计的是归属关系:谁写入、谁续租、谁失效、旧连接如何被 fencing。把“计算路由”和“拥有连接”分开,节点故障时才不会出现两个权威。
下一节讨论登录链路的最后一个问题:当所有玩家在同一时刻到达,排队和限流应该如何分工,才能避免“排队、超时、重试、更拥堵”的正反馈。
阅读导航:上一节:2.1 登录态、票据与续期 · 下一节:2.3 登录排队、限流与开服洪峰 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。