4.1 连接位置与路由目录
单服时代,「玩家在哪」是个不需要回答的问题——只有一台机器,玩家当然在这台机器上。一旦网关是一群,这个问题立刻变成集群里最高频、也最要命的一问:玩家的 TCP 长连接只挂在某一个网关进程上,别的进程、跨服调用、后台工具想知道「他现在挂在哪个网关」,都得先能查到答案。
这一问的答案就是路由目录:一张记录「玩家 → 网关节点」的表。它必须同时满足两条硬要求:读得极频繁(每次跨服回推、每次重连都要查),写得极小心(一个玩家在任一时刻只能挂在一个网关上,写错了就是两个进程都以为自己持有该玩家)。本节把这张表从零写出来,重点解决它最容易出错的时刻——玩家换服:新连接在 B 网关登录,旧连接在 A 网关还浑然不知地挂着,直到它的 socket 才被发现已死,跑一遍清理逻辑。这遍迟到的清理绝不能把新连接刚建立的绑定删掉。
本节与站内既有内容的分工
站内已经有三篇架构文章分别讲过网关路由方案、连接排空、以及登录高峰的应对。本节不重复那些方案层面的取舍,只把「连接位置」这件事的数据结构与竞态从零写出来、跑通。
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/game-server-gateway-router-architecture-design/ | 网关路由的整体架构与分流方案 | 从零实现「玩家 → 网关」的路由目录与失效 |
/game-server-gateway-connection-draining-architecture/ | 网关优雅下线的架构设计 | 只讲换服时旧连接如何被安全摘除 |
/how-game-gateway-services-survive-login-peaks/ | 登录高峰如何不把网关打垮 | 只讲目录在高频读写下的正确性 |
一句话:别人讲「网关集群该怎么摆」,本节讲「连接位置这张表怎么写、怎么证明它在换服时不串」。
谁持有连接:网关是唯一的长连接持有者
在 aegis 的集群拓扑里,只有网关进程持有玩家的 TCP 长连接。场景服、战斗服、跨服玩法都不直接和客户端建连——它们要发消息给玩家,就查路由目录找到网关,把消息转发过去。这条「唯一持有者」的约束是整个设计的基石,因为一旦允许两个进程都能给同一个玩家直接发包,连接位置的唯一性就没了。
固定头复用卷一 §3.5 的口径,本节所有跨进程报文都走它,不另造格式:
固定头 11 字节:length(4, BE) | cmd(2, BE) | seq(4, BE) | flags(1)
length = 7 + N(7 = cmd(2)+seq(4)+flags(1)),帧总长 = 11 + N
HeaderSize = 11,MaxPayload = 1 << 20
网关与目录之间复用服务发现域的命令号:节点注册走 CmdNodeRegisterReq 0x0F01,玩家路由表的同步走 CmdRouteTableSync 0x0F03,用 flags 区分子操作(注册 / 绑定 / 解绑 / 查表),从而不新增任何命令号。
路由目录:维护与失效
目录的核心是一张 map[PlayerID]Binding,但真正让它正确的不是这张 map,而是 Binding 里的 Gen——绑定代次。每次 Bind 都递增一个全局序号并记进这条记录;Unbind 必须带上调用方当初拿到的那个代次,只有代次仍是最新时才允许摘除。这样「旧连接的迟到断开」天然被挡在门外:它带的是旧代次,比对失败即被忽略。下面这段是 L1,go test ./internal/gateway/ 可以直接跑:
// file: internal/gateway/directory.go
// tier: L1
// verify: go test ./internal/gateway/
package gateway
import "sync"
// PlayerID 是全服唯一角色 ID(卷三共享类型)。
type PlayerID uint64
// NodeID 是集群节点标识,形如 gateway-03。
type NodeID string
// Binding 是「某个玩家当前挂在哪个网关」的一条路由记录。Gen 是绑定代次:
// 同一玩家换服时,旧连接迟到的断开事件带的是旧代次,只有代次吻合才允许
// 摘除,否则会把新连接刚建立的绑定误删。
type Binding struct {
Node NodeID
Gen uint64
}
// RouteDirectory 维护「玩家 → 网关节点」的路由目录。它是整个网关集群里唯一
// 知道「谁在哪」的地方:客户端重连、场景服回推、跨服调用都查它。
type RouteDirectory struct {
mu sync.RWMutex
routes map[PlayerID]Binding
seq uint64
}
// NewRouteDirectory 返回一个空目录。
func NewRouteDirectory() *RouteDirectory {
return &RouteDirectory{routes: make(map[PlayerID]Binding)}
}
// Bind 把玩家绑定到某网关节点,返回本次绑定的代次。若该玩家已挂在别的
// 节点上,旧绑定被覆盖——这正是「换服」:调用方拿到新代次后,负责去关
// 旧连接;旧连接后续的断开事件会因为代次不匹配而被忽略。
func (d *RouteDirectory) Bind(p PlayerID, node NodeID) uint64 {
d.mu.Lock()
defer d.mu.Unlock()
d.seq++
d.routes[p] = Binding{Node: node, Gen: d.seq}
return d.seq
}
// Lookup 查玩家当前所在节点。
func (d *RouteDirectory) Lookup(p PlayerID) (NodeID, bool) {
d.mu.RLock()
defer d.mu.RUnlock()
b, ok := d.routes[p]
return b.Node, ok
}
// LookupGen 同时返回代次,供网关在排空时判断自己是否仍持有该玩家。
func (d *RouteDirectory) LookupGen(p PlayerID) (Binding, bool) {
d.mu.RLock()
defer d.mu.RUnlock()
b, ok := d.routes[p]
return b, ok
}
// Unbind 摘除绑定,但只在该代次仍是最新时生效。旧连接的迟到断开事件带的
// 是旧代次,比对失败即被忽略,不会误删新连接建立的绑定。
func (d *RouteDirectory) Unbind(p PlayerID, gen uint64) bool {
d.mu.Lock()
defer d.mu.Unlock()
b, ok := d.routes[p]
if !ok || b.Gen != gen {
return false
}
delete(d.routes, p)
return true
}
// Count 返回在线绑定数。
func (d *RouteDirectory) Count() int {
d.mu.RLock()
defer d.mu.RUnlock()
return len(d.routes)
}
// NodeLoad 统计每个节点当前持有的连接数,排空时用它判断某节点是否已经空了。
func (d *RouteDirectory) NodeLoad() map[NodeID]int {
d.mu.RLock()
defer d.mu.RUnlock()
out := make(map[NodeID]int)
for _, b := range d.routes {
out[b.Node]++
}
return out
}
读路径用 RWMutex 的读锁:查表是最热的操作,不能和别的查表互相阻塞。写路径只有两处——Bind 与 Unbind——都走写锁,保证「检查代次 + 改表」是一个原子动作。这里有一个必须讲清的取舍:代次是全局递增的,不是每玩家一个。全局序号让实现只有一行,代价是代次会随全服绑定次数单调增长;对 uint64 来说这个上界遥不可及(每秒一亿次绑定也要跑上千年),所以不额外做每玩家的代次表。
玩家换服:旧连接怎么处理
换服的时序是本节的核心。玩家在 A 网关断线后立刻在 B 网关重登:B 调 Bind 拿到新代次,目录指向 B;此时 A 的连接可能还活着几百毫秒,直到它自己的读循环发现 socket 已死,才跑 Unbind——而它带的是旧代次。下面这段是 L1 测试的核心,go test 可直接跑:
// snippet: internal/gateway/directory_test.go 节选
// TestStaleUnbindIgnored 是本节的核心用例:玩家换服时,旧连接迟到的断开
// 事件不能把新连接刚建立的绑定删掉。代次不匹配即拒绝摘除。
func TestStaleUnbindIgnored(t *testing.T) {
d := NewRouteDirectory()
oldGen := d.Bind(2002, "gateway-01") // 玩家先挂在 gw-01
newGen := d.Bind(2002, "gateway-02") // 换服到 gw-02,旧绑定被覆盖
if d.Unbind(2002, oldGen) { // 旧连接迟到的断开事件
t.Fatal("stale unbind must be rejected")
}
if node, ok := d.Lookup(2002); !ok || node != "gateway-02" {
t.Fatalf("after stale unbind lookup = %q,%v want gateway-02,true", node, ok)
}
if !d.Unbind(2002, newGen) { // 新连接正常断开
t.Fatal("current-gen unbind should succeed")
}
}
这 20 行是整节的「锚」:Unbind(2002, oldGen) 返回 false,目录仍指向 gateway-02。换一个不写代次的实现——直接 delete(routes, p)——这个用例立刻红,因为它会把 B 刚建立的绑定删掉,玩家表现为「刚登进去就被踢」。同样的代次思路,卷一 7.2 已经用在顶号上;这里把它从「会话表」上移到了「路由目录」上。
真起多个网关进程 + 客户端断言
内存目录讲清了,但真实场景里目录是一个独立进程,网关是另外几个进程。下面这段是 L2:一个最小网关进程,启动时向目录注册自己(CmdNodeRegisterReq 0x0F01),每接一个客户端就把「玩家 → 本节点」写进目录,连接断开时按代次解绑。它真起进程,go test ./internal/gateway/ -run TestL2RouteDirectory 会编译它、拉起两个实例再断言:
// file: cmd/gw/main.go
// tier: L2
// verify: go build ./cmd/gw/ && go test ./internal/gateway/ -run TestL2RouteDirectory
package main
import (
"flag"
"log"
"net"
"sync"
"aegis/internal/gateway"
)
// gw 是一个最小网关节点:启动时向路由目录注册自己,之后每接一个客户端,
// 就把「玩家 → 本节点」写进目录;连接断开时按代次解绑。
type gw struct {
node string
dir *dirConn
}
// dirConn 是到目录的一条长连接。目录操作是请求/应答式,用互斥量串行化,
// 保证一次只有一条在途请求,读到的应答不会串台。
type dirConn struct {
mu sync.Mutex
conn net.Conn
seq uint32
}
func dialDir(addr, node string) (*dirConn, error) {
c, err := net.Dial("tcp", addr)
if err != nil {
return nil, err
}
d := &dirConn{conn: c}
// 注册节点:CmdNodeRegisterReq 0x0F01,载荷是节点名。
if err := gateway.WriteFrame(c, gateway.Frame{Cmd: gateway.CmdNodeRegisterReq, Payload: []byte(node)}); err != nil {
return nil, err
}
if _, err := gateway.ReadFrame(c); err != nil { // 读注册应答
return nil, err
}
return d, nil
}
func (d *dirConn) call(f gateway.Frame) (gateway.Frame, error) {
d.mu.Lock()
defer d.mu.Unlock()
d.seq++
f.Seq = d.seq
if err := gateway.WriteFrame(d.conn, f); err != nil {
return gateway.Frame{}, err
}
return gateway.ReadFrame(d.conn)
}
func (d *dirConn) bind(p gateway.PlayerID, node string) (uint64, error) {
r, err := d.call(gateway.Frame{Cmd: gateway.CmdRouteTableSync, Flags: gateway.OpBind, Payload: gateway.EncodeBind(p, gateway.NodeID(node))})
if err != nil {
return 0, err
}
return gateway.GetU64(r.Payload), nil
}
func (d *dirConn) unbind(p gateway.PlayerID, gen uint64) error {
_, err := d.call(gateway.Frame{Cmd: gateway.CmdRouteTableSync, Flags: gateway.OpUnbind, Payload: gateway.EncodeUnbind(p, gen)})
return err
}
// serve 处理一条客户端连接:登录帧 → 绑定 → 保持在线 → 断开时解绑。
func (g *gw) serve(c net.Conn) {
defer c.Close()
f, err := gateway.ReadFrame(c)
if err != nil {
return
}
if f.Cmd != 0x0010 || len(f.Payload) < 8 { // CmdLoginReq
return
}
pid := gateway.PlayerID(gateway.GetU64(f.Payload[:8]))
gen, err := g.dir.bind(pid, g.node)
if err != nil {
return
}
// 应答里带代次与节点名,客户端据此知道自己被路由到了哪个网关。
ack := make([]byte, 8+len(g.node))
gateway.PutU64(ack, gen)
copy(ack[8:], g.node)
if err := gateway.WriteFrame(c, gateway.Frame{Cmd: 0x0011, Seq: f.Seq, Payload: ack}); err != nil {
return
}
// 保持在线:应答心跳,直到客户端关闭或出错。
for {
f, err := gateway.ReadFrame(c)
if err != nil {
break
}
if f.Cmd == 0x0001 { // CmdHeartbeatReq
_ = gateway.WriteFrame(c, gateway.Frame{Cmd: 0x0002, Seq: f.Seq})
}
}
_ = g.dir.unbind(pid, gen)
}
func main() {
dirAddr := flag.String("dir", "127.0.0.1:32001", "route directory address")
node := flag.String("node", "gateway-01", "node id")
listen := flag.String("listen", "127.0.0.1:32011", "client listen address")
flag.Parse()
d, err := dialDir(*dirAddr, *node)
if err != nil {
log.Fatalf("gw %s: dial dir: %v", *node, err)
}
ln, err := net.Listen("tcp", *listen)
if err != nil {
log.Fatalf("gw %s: listen: %v", *node, err)
}
log.Printf("gw %s listening on %s", *node, *listen)
g := &gw{node: *node, dir: d}
for {
c, err := ln.Accept()
if err != nil {
return
}
go g.serve(c)
}
}
测试 TestL2RouteDirectory 在本进程内起一个真 TCP 目录服务,再用 go build 编译出 gw 二进制、拉起 gateway-01 与 gateway-02 两个独立进程。它按顺序断言四件事:玩家 555 在 gw-01 登录后目录指向 gateway-01;同一账号在 gw-02 重登后目录切到 gateway-02;此时关掉 gw-01 那条旧连接,目录仍指向 gateway-02(旧代次解绑被拒);两条连接都关掉后目录里该玩家消失。四条全绿,说明代次机制在多进程真环境下同样成立。
本节小结 / 实测结论
连接位置这件事,落到数据结构上只有一张 map[PlayerID]Binding,但让它正确的关键全在 Gen 这一个字段上:它把「旧连接的迟到断开」这种时序竞态,变成了一次可判定的大小比较。本节把内存目录(L1)与真进程网关(L2)都跑通了:go test ./... 全绿,L2 用例真起了两个网关进程与一个目录服务。还有一个问题本节没有回答:目录说玩家在哪个网关,那新连接该被分到哪个网关? 这正是下一节的主题——会话粘性。它有三种实现(一致性哈希、目录查表、客户端重连票),代价各不相同,发布期间玩家看到的画面也不同。
阅读导航:上一节:目录 · 下一节:4.2 粘性策略与发布期行为 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。