一、网关的状态边界
网关能水平扩展的前提是“连接在本地,事实在共享层”。一旦把玩家存档、背包、房间归属写进网关进程内存,这个实例就不可替换,扩容和缩容都会变成数据迁移问题。
| 状态 | 放哪里 | 原因 |
|---|---|---|
| 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 摘流就认为排空完成,忽略进程内仍在处理的连接。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。