《MMO 服务器开发》4.3 连接排空(draining)

网关重启不该把在线玩家直接踢下线。本节实现连接排空:停止接受新连接、广播 CmdDrainNotice 0x1001、下发 CmdReconnectTicket 0x1002、等旧连接自然结束、超时兜底,并真起进程 + 客户端跑完整排空流程,含千连接错峰压测。

4.3 连接排空(draining)

上一节把玩家「该去哪个网关」定了下来,但发布时要真正把一台网关摘掉,还差最后一步:怎么让这台网关上的在线玩家平滑离开,而不是被一刀切断线。 直接 kill -9 是最省事的做法,也是最伤玩家的:一次滚动发布,全服玩家体验一遍「连接已断开」。连接排空(draining)就是把「下线」从一次瞬断,变成一段可观测、可退让的过渡。

排空要同时满足两条硬指标:排空期间在线玩家不掉线(连接还能收发),排空后新连接被拒(不再把新玩家引进一条正在死去的连接上)。本节把排空状态机从零写出来,再真起一个网关进程 + 客户端跑完整流程,最后用 1000 条连接做一次错峰压测。

排空的五步时序

排空不是「关服务」,而是一条固定顺序的时序。aegis 的网关按下线信号后依次做五件事:

  1. 停止接受新连接:关闭监听 socket。此后的新连接要么被拒(connection refused),要么被 accept 后立即关闭。
  2. 广播排空通知:向所有在线连接发 CmdDrainNotice 0x1001,告诉客户端「本网关即将下线」。
  3. 下发重连票:发 CmdReconnectTicket 0x1002,载荷是一张签名票据,写死客户端该去的新网关。客户端拿票直连新网关,不必回查目录。
  4. 等旧连接自然结束:玩家收到通知后自行断开并携票重连;网关只是等,不主动踢。这样正在传输的帧不会丢。
  5. 超时兜底:给一个上限(本节 5 秒)。超过还没走完的连接,说明客户端卡住或断网,只能兜底断开,避免排空永远不结束。

第 4 步是「优雅」的核心:等,而不是赶。这也解释了为什么排空需要一个超时——没有超时,一个卡死的客户端就能让发布永远卡在最后一台机器上。

排空状态机:从零实现

状态机只有三态:accepting → draining → closed。它负责回答两个问题——「还要不要接新连接」(AddConn 在非 accepting 态返回 false)、「旧连接都走完了没有」(live 归零时关闭 empty 通道唤醒等待方)。真正的广播与断开交给 onDrain 回调,于是状态机本身可以在纯逻辑下测。下面这段是 L1,go test ./internal/gateway/ 可以直接跑:

// file: internal/gateway/drain.go
// tier: L1
// verify: go test ./internal/gateway/ -run TestDrain
package gateway

import (
	"sync"
	"time"
)

// State 是网关生命周期的三态。
type State int

const (
	StateAccepting State = iota // 正常接客
	StateDraining               // 排空中:不再接受新连接,等旧连接自然结束
	StateClosed                 // 已关闭
)

func (s State) String() string {
	switch s {
	case StateAccepting:
		return "accepting"
	case StateDraining:
		return "draining"
	default:
		return "closed"
	}
}

// DrainResult 是一次排空的结果。TimedOut 为真表示有连接在超时前没走,
// 只能兜底断开。
type DrainResult struct {
	Drained   bool
	TimedOut  bool
	Remaining int
	Waited    time.Duration
}

// Drainer 是排空状态机:accepting → draining → closed。它只负责两件事——
// 决定「还要不要接新连接」,以及「旧连接都走完了没有」。真正的广播与断开
// 由 onDrain 回调和调用方完成,这样状态机本身可以在纯逻辑下测。
type Drainer struct {
	mu          sync.Mutex
	state       State
	live        int
	empty       chan struct{} // live 归零且已进入 draining 时关闭
	emptyClosed bool
	onDrain     func() // 进入 draining 时触发一次,用于广播排空通知
}

// NewDrainer 构造一个处于 accepting 态的状态机。onDrain 在进入 draining 时
// 被调用一次,用来向所有在线连接广播 CmdDrainNotice 与 CmdReconnectTicket。
func NewDrainer(onDrain func()) *Drainer {
	return &Drainer{state: StateAccepting, empty: make(chan struct{}), onDrain: onDrain}
}

// AddConn 尝试登记一条新连接。只有 accepting 态才接受;draining 之后返回
// false,调用方应立即关掉这条连接——这就是「排空后新连接被拒」。
func (d *Drainer) AddConn() bool {
	d.mu.Lock()
	defer d.mu.Unlock()
	if d.state != StateAccepting {
		return false
	}
	d.live++
	return true
}

// RemoveConn 注销一条连接。若正处于 draining 且这是最后一条,关闭 empty,
// 唤醒 WaitDrained。
func (d *Drainer) RemoveConn() {
	d.mu.Lock()
	defer d.mu.Unlock()
	if d.live > 0 {
		d.live--
	}
	if d.state == StateDraining && d.live == 0 && !d.emptyClosed {
		close(d.empty)
		d.emptyClosed = true
	}
}

// TryAccept 供 accept 循环快速判断是否还应继续接客。
func (d *Drainer) TryAccept() bool {
	d.mu.Lock()
	defer d.mu.Unlock()
	return d.state == StateAccepting
}

// BeginDrain 进入 draining 态并触发一次广播。重复调用返回 false。
func (d *Drainer) BeginDrain() bool {
	d.mu.Lock()
	if d.state != StateAccepting {
		d.mu.Unlock()
		return false
	}
	d.state = StateDraining
	if d.live == 0 && !d.emptyClosed {
		close(d.empty)
		d.emptyClosed = true
	}
	cb := d.onDrain
	d.mu.Unlock()
	if cb != nil {
		cb()
	}
	return true
}

// WaitDrained 阻塞等待旧连接自然结束;超时则兜底,返回剩余连接数。
func (d *Drainer) WaitDrained(timeout time.Duration) DrainResult {
	t0 := time.Now()
	select {
	case <-d.empty:
		d.mu.Lock()
		d.state = StateClosed
		d.mu.Unlock()
		return DrainResult{Drained: true, Waited: time.Since(t0)}
	case <-time.After(timeout):
		d.mu.Lock()
		rem := d.live
		d.state = StateClosed
		d.mu.Unlock()
		return DrainResult{TimedOut: true, Remaining: rem, Waited: time.Since(t0)}
	}
}

// Drain 是 BeginDrain + WaitDrained 的合并入口。
func (d *Drainer) Drain(timeout time.Duration) DrainResult {
	d.BeginDrain()
	return d.WaitDrained(timeout)
}

// State 返回当前状态。
func (d *Drainer) State() State {
	d.mu.Lock()
	defer d.mu.Unlock()
	return d.state
}

// Live 返回当前在线连接数。
func (d *Drainer) Live() int {
	d.mu.Lock()
	defer d.mu.Unlock()
	return d.live
}

L1 测试覆盖三条路径:旧连接自然走完 → drained=true;有连接赖着不走 → 超时并报剩余数;排空前已无连接 → 立即完成。三者都断言「排空后 AddConn 返回 false」。

真起进程 + 客户端跑完整排空

状态机讲清了,但真实网关是一个独立进程。下面这段是 L2:可排空网关。它维护在线连接表,收到 SIGTERM 后关监听、BeginDrain(触发 broadcastDrain 广播通知与重连票)、WaitDrained 等旧连接结束。go test ./internal/gateway/ -run TestL2Drain 会编译它、拉起进程、连客户端再断言:

// file: cmd/gwd/main.go
// tier: L2
// verify: go build ./cmd/gwd/ && go test ./internal/gateway/ -run TestL2Drain
package main

import (
	"flag"
	"log"
	"net"
	"os"
	"os/signal"
	"sync"
	"syscall"
	"time"

	"aegis/internal/gateway"
)

const (
	cmdHeartbeatReq  = 0x0001
	cmdHeartbeatResp = 0x0002
	cmdLoginReq      = 0x0010
	cmdLoginResp     = 0x0011
)

// server 是可排空的网关:accepting 态接客,收到 SIGTERM 后关闭监听、广播
// 排空通知与重连票、等旧连接自然结束,超时兜底退出。
type server struct {
	mu    sync.Mutex
	conns map[net.Conn]struct{}
	drain *gateway.Drainer
	key   []byte
}

func (s *server) serve(c net.Conn) {
	if !s.drain.AddConn() { // draining 之后进来的连接一律拒绝
		_ = c.Close()
		return
	}
	s.mu.Lock()
	s.conns[c] = struct{}{}
	s.mu.Unlock()
	defer func() {
		s.mu.Lock()
		delete(s.conns, c)
		s.mu.Unlock()
		s.drain.RemoveConn()
		_ = c.Close()
	}()
	for {
		f, err := gateway.ReadFrame(c)
		if err != nil {
			return
		}
		switch f.Cmd {
		case cmdHeartbeatReq:
			_ = gateway.WriteFrame(c, gateway.Frame{Cmd: cmdHeartbeatResp, Seq: f.Seq})
		case cmdLoginReq:
			_ = gateway.WriteFrame(c, gateway.Frame{Cmd: cmdLoginResp, Seq: f.Seq})
		}
	}
}

// broadcastDrain 在进入 draining 时触发:先发 CmdDrainNotice 0x1001,再发
// CmdReconnectTicket 0x1002,客户端据此换网关重连。
func (s *server) broadcastDrain() {
	tok := gateway.IssueTicket(s.key, 0, "gateway-next", time.Now().Add(time.Minute).Unix())
	s.mu.Lock()
	defer s.mu.Unlock()
	for c := range s.conns {
		_ = gateway.WriteFrame(c, gateway.Frame{Cmd: gateway.CmdDrainNotice})
		_ = gateway.WriteFrame(c, gateway.Frame{Cmd: gateway.CmdReconnectTicket, Payload: []byte(tok)})
	}
}

func main() {
	listen := flag.String("listen", "127.0.0.1:32211", "listen addr")
	timeout := flag.Duration("drain-timeout", 5*time.Second, "drain timeout")
	flag.Parse()

	ln, err := net.Listen("tcp", *listen)
	if err != nil {
		log.Fatalf("listen: %v", err)
	}
	s := &server{conns: make(map[net.Conn]struct{}), key: []byte("aegis-dist-ch04-drain")}
	s.drain = gateway.NewDrainer(s.broadcastDrain)

	go func() {
		for {
			c, err := ln.Accept()
			if err != nil {
				return
			}
			go s.serve(c)
		}
	}()
	log.Printf("gwd listening on %s", *listen)

	sig := make(chan os.Signal, 1)
	signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
	<-sig
	log.Printf("draining: stop accepting, live=%d", s.drain.Live())
	_ = ln.Close() // 1) 停止接受新连接
	s.drain.BeginDrain()
	res := s.drain.WaitDrained(*timeout)
	log.Printf("drain done: drained=%v timedout=%v remaining=%d waited=%s",
		res.Drained, res.TimedOut, res.Remaining, res.Waited)
}

测试 TestL2Drain 拉起进程、连 3 个客户端登录,然后发 SIGTERM 触发排空,逐条断言:每个客户端先收到 CmdDrainNotice、再收到 CmdReconnectTicket,且票据能被 VerifyTicket 验签通过(目标节点 gateway-next);随后每个客户端发心跳仍能收到应答——这就是「排空期间在线玩家不掉线」;此时再拨号连网关被拒——这就是「排空后新连接被拒」;最后关掉旧连接,进程日志打印 drained=true 并正常退出。四条断言全绿。

千连接排空:必须错峰连接

把排空放到 1000 条连接上跑,能暴露小规模测不出的问题。macOS 的 kern.ipc.somaxconn 只有 128——一次 accept 队列最多堆 128 条未处理连接,如果测试一次性 dial 1000 条,第 129 条起就会被内核丢掉,表现为「连接超时」。所以压测必须错峰连接:每批 50 条、批间 sleep 3ms,让 accept 循环来得及消化。

TestL2DrainThousand 正是这么做的:错峰连满 1000 条 → 触发 SIGTERM → 1000 个客户端并发读排空通知与重连票,全部 1000 条都必须收到(漏一条即失败)→ 关闭全部连接 → 断言进程日志仍是 drained=true、remaining=0。本机实跑通过,耗时约 3.9 秒。这条用例证明排空是「连接数无关」的:1000 条连接不会让任何一条被漏掉通知,也不会因为批量太大而打爆内核队列。

本节小结 / 实测结论

连接排空到此完整:三态状态机(L1)、可排空网关进程(L2)、以及 1000 条连接的错峰压测。三条硬结论都在本机真跑验证过——排空期间在线玩家不掉线(心跳仍通)、排空后新连接被拒(监听已关)、所有在线连接都收到通知与重连票(1000/1000)。排空是「目录查表 + 重连票」发布方案的最后一环:上一节说「要发布无感,目录 + 排空 + 重连票是必需的」,这一节把排空这条链路真正跑通了。

到这里,第 4 章把网关集群的三件事——连接位置、会话粘性、优雅下线——都落到了可运行代码。第 5 章换一个方向:网关只是把玩家「接进来」,玩家接进来之后要进入哪个场景实例、实例怎么调度、崩溃了怎么重建,那是调度器的事。

阅读导航:上一节:4.2 粘性策略与发布期行为 · 下一节:5.1 实例调度器 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标