《游戏服务端架构·核心系统与契约》2.2 网关路由与连接归属

网关只终结连接、认证、路由和限流,不承载玩法。真正的难题是连接归属放在哪:网关本地表、共享存储还是 rendezvous 计算。本节比较三种归属方案的故障代价,给出加权 rendezvous 路由、节点摘除后的重分配与归属令牌校验的可运行实现。

一条连接应该落在哪

上一节解决了“凭证是否有效”。凭证有效之后,下一个问题立刻出现:这条连接应该由哪个网关承载?

《裂隙战场》在版本更新后遇到过一次典型故障。运维把 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 登录排队、限流与开服洪峰 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查