《游戏服务端编程实践》5.3.2 Gateway 横向扩展策略

把网关拆成可水平扩展的无状态层:连接留在本地、会话与路由放共享存储,扩容只接新连接,缩容先排空再退出;附可运行的 Go 加权 Balancer 与 Drain 流程,覆盖权重分配、停止接新、在途连接释放和全部排空后的降级,并给出注册表租约与故障降级建议。

一、网关的状态边界

网关能水平扩展的前提是“连接在本地,事实在共享层”。一旦把玩家存档、背包、房间归属写进网关进程内存,这个实例就不可替换,扩容和缩容都会变成数据迁移问题。

状态放哪里原因
TCP/WebSocket 连接、读写缓冲网关本地无法跨进程迁移,重建连接是唯一办法
会话 ID、账号、gateway_id共享注册表登录服和所有网关都要能查到
玩家存档、背包、任务游戏服或数据库网关不参与业务裁决
路由信息(玩家在哪个场景服)共享路由表任意网关都能把消息转给正确后端

网关重启的代价应该只有“连接重建”,而不是“数据丢失”。如果一次发布需要停服导出内存、再导入新实例,说明状态边界没有划清。把网关当成可随时丢弃的接入层,扩缩容才只是调度问题。

二、扩容:新连接分到新实例

扩容不会迁移旧连接。TCP 连接绑定在四元组上,没有内核支持就无法把 socket 从一个进程搬到另一个进程;即使能做到,已加密的会话状态、半包缓冲也很难转移。正确做法是让新实例进入负载均衡池,只承接新连接,旧连接随客户端自然重连逐步稀释。

具体步骤是:启动新网关并预热后端,向注册表登记 gateway_id、地址与权重,加入四层负载均衡或 DNS,观察连接建立速率、首包成功率和错误率,稳定后再逐步调权重;不要一次把全部流量切过去。

这里不需要一致性哈希。网关之间不共享连接,客户端连到哪个实例只影响它自己;真正的路由靠会话表里的 gateway_id,而不是靠哈希环。

三、缩容:先排空再退出

缩容最忌讳直接杀进程。进程消失时,内核会替所有连接发 FIN 或 RST,客户端在同一时刻集体重连,登录服和负载均衡瞬间被打满。正确顺序是“先停止接新,再等在途连接自然结束”。

阶段动作判据
摘流从 LB/DNS 移除,注册表标记 draining新连接不再落到该实例
通知向客户端推送“请重连”,带退避时间客户端开始分批重连
等待保留已有连接,继续转发在途连接数持续下降
兜底超过最长等待时间后主动关闭剩余连接强制断开

等待时间要覆盖客户端的心跳周期和重连退避。若心跳 20 秒、超时 60 秒,排空窗口至少给到 2~3 分钟。缩容和发布都应该走同一套排空流程,避免“滚动更新”变成“批量踢人”。

四、共享注册表与降级

注册表可以选 Redis、etcd 或自建服务,关键是明确一致性要求。网关注册需要租约:实例定期续期,过期自动摘除,避免进程崩溃后留下幽灵地址。会话表写入要能容忍短暂失败:连接建立时写失败应拒绝接入,运行中续期失败则应告警并本地缓存,不能在网络抖动时把全部在线玩家踢掉。
注册表本身不能是单点。至少主从或三节点集群,客户端配置多个地址;读取侧加本地缓存和短 TTL,写侧失败时进入降级模式:新连接只分配到本机已知的健康实例,同时上报异常。降级的目标是“少接新玩家”,而不是“现有玩家掉线”。

五、连接分配与会话保持

方式优点缺点适用
DNS 轮询简单、无额外组件TTL 缓存导致切换慢小规模、客户端可重试
四层负载均衡健康检查、权重、连接数均衡多一层运维大多数生产环境
客户端直连列表可绕开 LB、便于灰度列表下发和更新复杂自研 SDK、专有网络
会话保持重连回到原网关,推送简单实例故障时粘性失效强依赖本地缓存的场景

网关无状态时,会话保持不是必需:客户端重连到任意实例,凭 token 重建上下文,再通过共享路由找到后端。若确实需要粘性(例如本地缓存了大量房间订阅),也要设置失效兜底,不能假设原实例永远存在。

六、失败场景:缩容直接杀进程

某次晚高峰扩容后,运维在低峰期把多余的三个网关直接 kill。六万条连接同时断开,客户端 SDK 的重连没有退避,全部在 5 秒内重试。登录服 QPS 瞬间涨到平时的 20 倍,Redis 连接池耗尽,连正常登录也开始失败。根因不是扩容,而是缩容没有排空,也没有让客户端分批重连。
修复分三步:缩容前先从 LB 摘流并标记 draining;向客户端下发带随机退避的重连指令;等待在途连接降到阈值以下再退出。注册表租约负责兜底,进程崩溃后地址会自动过期,但正常缩容不应该依赖兜底。

七、Go 实现:Balancer 与 Drain

下面的 Balancer 用平滑加权轮询分配新连接,Drain 只停止接新并返回在途连接数。它不迁移连接,也不保存玩家数据;排空完成的判据是 conns == 0。

// file: ch05/scale/gateway_scale.go
// tier: L1
// verify: go test ./ch05/scale/

package scale

import (
	"errors"
	"sync"
)

var (
	ErrGatewayExists   = errors.New("gateway already registered")
	ErrGatewayNotFound = errors.New("gateway not found")
	ErrNoGateway       = errors.New("no gateway available")
)

// GatewayStat 是注册表对外暴露的只读快照。
type GatewayStat struct {
	ID          string
	Weight      int
	Connections int
	Draining    bool
}

type gatewayState struct {
	weight   int
	current  int // 平滑加权轮询的当前权重
	conns    int
	draining bool
}

// Balancer 只负责“新连接分配到哪个网关”和“实例是否已排空”。
// 玩家存档、会话事实源不在这里;网关之间不需要迁移连接。
type Balancer struct {
	mu          sync.Mutex
	order       []string
	gates       map[string]*gatewayState
	totalWeight int
}

func NewBalancer() *Balancer {
	return &Balancer{gates: make(map[string]*gatewayState)}
}

func (b *Balancer) Register(id string, weight int) error {
	if id == "" {
		return ErrGatewayNotFound
	}
	if weight <= 0 {
		weight = 1
	}
	b.mu.Lock()
	defer b.mu.Unlock()
	if _, ok := b.gates[id]; ok {
		return ErrGatewayExists
	}
	b.gates[id] = &gatewayState{weight: weight}
	b.order = append(b.order, id)
	b.totalWeight += weight
	return nil
}

// Acquire 用平滑加权轮询挑选一个未排空的网关,并把它计入在途连接。
func (b *Balancer) Acquire() (string, error) {
	b.mu.Lock()
	defer b.mu.Unlock()
	best := ""
	bestCurrent := 0
	for _, id := range b.order {
		g := b.gates[id]
		if g.draining {
			continue
		}
		g.current += g.weight
		if best == "" || g.current > bestCurrent {
			best = id
			bestCurrent = g.current
		}
	}
	if best == "" {
		return "", ErrNoGateway
	}
	b.gates[best].current -= b.totalWeight
	b.gates[best].conns++
	return best, nil
}

// Release 在连接关闭后调用;在途计数是判断排空完成的唯一依据。
func (b *Balancer) Release(id string) error {
	b.mu.Lock()
	defer b.mu.Unlock()
	g, ok := b.gates[id]
	if !ok {
		return ErrGatewayNotFound
	}
	if g.conns > 0 {
		g.conns--
	}
	return nil
}

// Drain 停止该实例接收新连接,返回剩余在途连接数与是否已排空。
// 重复调用是幂等的,不会重复扣减总权重。
func (b *Balancer) Drain(id string) (remaining int, drained bool, err error) {
	b.mu.Lock()
	defer b.mu.Unlock()
	g, ok := b.gates[id]
	if !ok {
		return 0, false, ErrGatewayNotFound
	}
	if !g.draining {
		g.draining = true
		g.current = 0
		b.totalWeight -= g.weight
	}
	return g.conns, g.conns == 0, nil
}

func (b *Balancer) Closed(id string) bool {
	b.mu.Lock()
	defer b.mu.Unlock()
	g, ok := b.gates[id]
	return ok && g.draining && g.conns == 0
}

func (b *Balancer) Snapshot() []GatewayStat {
	b.mu.Lock()
	defer b.mu.Unlock()
	out := make([]GatewayStat, 0, len(b.order))
	for _, id := range b.order {
		g := b.gates[id]
		out = append(out, GatewayStat{ID: id, Weight: g.weight, Connections: g.conns, Draining: g.draining})
	}
	return out
}

测试覆盖权重比例、draining 后新连接只去剩余实例、在途连接释放后才算关闭,以及全部排空时的 ErrNoGateway:

// file: ch05/scale/gateway_scale_test.go
// tier: L1
// verify: go test ./ch05/scale/

package scale

import (
	"errors"
	"testing"
)

func TestWeightedAllocation(t *testing.T) {
	b := NewBalancer()
	if err := b.Register("gw-a", 2); err != nil {
		t.Fatal(err)
	}
	if err := b.Register("gw-b", 1); err != nil {
		t.Fatal(err)
	}
	counts := map[string]int{}
	for i := 0; i < 300; i++ {
		id, err := b.Acquire()
		if err != nil {
			t.Fatal(err)
		}
		counts[id]++
		if err := b.Release(id); err != nil {
			t.Fatal(err)
		}
	}
	if counts["gw-a"] != 200 || counts["gw-b"] != 100 {
		t.Fatalf("allocation=%v, want gw-a=200 gw-b=100", counts)
	}
}

func TestDrainStopsNewConnectionsAndWaitsForRelease(t *testing.T) {
	b := NewBalancer()
	for _, id := range []string{"gw-a", "gw-b"} {
		if err := b.Register(id, 1); err != nil {
			t.Fatal(err)
		}
	}
	for i := 0; i < 6; i++ {
		if _, err := b.Acquire(); err != nil {
			t.Fatal(err)
		}
	}

	remaining, drained, err := b.Drain("gw-a")
	if err != nil {
		t.Fatal(err)
	}
	if remaining != 3 || drained {
		t.Fatalf("remaining=%d drained=%v, want 3 false", remaining, drained)
	}
	for i := 0; i < 4; i++ {
		id, err := b.Acquire()
		if err != nil {
			t.Fatal(err)
		}
		if id != "gw-b" {
			t.Fatalf("new connection went to draining gateway %q", id)
		}
		if err := b.Release(id); err != nil {
			t.Fatal(err)
		}
	}
	if b.Closed("gw-a") {
		t.Fatal("gw-a closed before in-flight connections released")
	}
	for i := 0; i < 3; i++ {
		if err := b.Release("gw-a"); err != nil {
			t.Fatal(err)
		}
	}
	if !b.Closed("gw-a") {
		t.Fatal("gw-a should be closed after all connections released")
	}
	remaining, drained, err = b.Drain("gw-a")
	if err != nil {
		t.Fatal(err)
	}
	if remaining != 0 || !drained {
		t.Fatalf("idempotent drain remaining=%d drained=%v", remaining, drained)
	}
	stats := b.Snapshot()
	if len(stats) != 2 || stats[0].ID != "gw-a" || !stats[0].Draining || stats[1].Draining {
		t.Fatalf("snapshot=%+v", stats)
	}
}

func TestAllDrainedReturnsNoGateway(t *testing.T) {
	b := NewBalancer()
	if err := b.Register("gw-a", 1); err != nil {
		t.Fatal(err)
	}
	if _, _, err := b.Drain("gw-a"); err != nil {
		t.Fatal(err)
	}
	if _, err := b.Acquire(); !errors.Is(err, ErrNoGateway) {
		t.Fatalf("acquire error=%v, want ErrNoGateway", err)
	}
	if _, _, err := b.Drain("missing"); !errors.Is(err, ErrGatewayNotFound) {
		t.Fatalf("drain error=%v", err)
	}
}

八、运行与验证

cd .local/checks/gs-practice-lab && gofmt -l ch05/scale && go vet ./ch05/scale/ && go test ./ch05/scale/ -run 'Gateway|Weighted|Drain|AllDrained' -count=1 -v
--- PASS: TestWeightedAllocation (0.00s)
--- PASS: TestDrainStopsNewConnectionsAndWaitsForRelease (0.00s)
--- PASS: TestAllDrainedReturnsNoGateway (0.00s)
ok  	gspractice/ch05/scale	0.242s

小结

网关横向扩展的核心是状态边界:连接留在本地,会话、路由和存档放在共享层,实例才能随时替换。扩容只让新连接进入新实例,旧连接随重连自然稀释;缩容必须先摘流、标记 draining、等待在途连接释放,再注销退出。注册表要有租约、多副本和降级策略,会话保持只在确有本地缓存依赖时使用。

常见错误与边界

  • 网关保存玩家存档或背包,实例一换就丢数据,扩容退化成数据迁移。
  • 缩容直接杀进程,客户端集体重连打垮登录服。
  • 所有网关共享一个 Redis 单点,注册表故障时全站不可接入。
  • 只从 LB 摘流就认为排空完成,忽略进程内仍在处理的连接。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist