4.3 连接排空(draining)
上一节把玩家「该去哪个网关」定了下来,但发布时要真正把一台网关摘掉,还差最后一步:怎么让这台网关上的在线玩家平滑离开,而不是被一刀切断线。 直接 kill -9 是最省事的做法,也是最伤玩家的:一次滚动发布,全服玩家体验一遍「连接已断开」。连接排空(draining)就是把「下线」从一次瞬断,变成一段可观测、可退让的过渡。
排空要同时满足两条硬指标:排空期间在线玩家不掉线(连接还能收发),排空后新连接被拒(不再把新玩家引进一条正在死去的连接上)。本节把排空状态机从零写出来,再真起一个网关进程 + 客户端跑完整流程,最后用 1000 条连接做一次错峰压测。
排空的五步时序
排空不是「关服务」,而是一条固定顺序的时序。aegis 的网关按下线信号后依次做五件事:
- 停止接受新连接:关闭监听 socket。此后的新连接要么被拒(connection refused),要么被 accept 后立即关闭。
- 广播排空通知:向所有在线连接发
CmdDrainNotice 0x1001,告诉客户端「本网关即将下线」。 - 下发重连票:发
CmdReconnectTicket 0x1002,载荷是一张签名票据,写死客户端该去的新网关。客户端拿票直连新网关,不必回查目录。 - 等旧连接自然结束:玩家收到通知后自行断开并携票重连;网关只是等,不主动踢。这样正在传输的帧不会丢。
- 超时兜底:给一个上限(本节 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 实例调度器 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。