《MMO 服务器开发》4.3 优雅停服与有状态服务发布

优雅停服不是收到信号就退出,而是四步:停收新连接、排空在途请求、落库、退出。这一节先讲这四步的次序与跳过代价,再用一个真起子进程、真发 SIGTERM 的测试,断言在途请求不丢、账本落盘;最后讲有状态服务的代次交接,用单调递增的 epoch 挡住脑裂。

4.3 优雅停服与有状态服务发布

4.1 定下了「排空预算必须小于宽限期」这条清单约束,4.2 讲了三种发布策略都会在某一刻让旧实例「走」。这一节回答最后一个问题:旧实例收到停服信号之后,到底该做什么、按什么次序做、怎么证明它做对了。

很多线上事故的根因不是「发不出去」,而是「收不干净」:进程收到 SIGTERM 直接 os.Exit,飞到一半的请求没了、还没落库的那一笔丢了、正在交接的状态悬空了。对无状态服务这可能只是丢一次请求;对游戏服务端,丢的可能是玩家刚买的一件装备、刚打完的一场战斗。所以停服不是「关掉进程」,而是一段有次序的收尾。

这一节先把这段收尾拆成四步,再用一个真起子进程、真发 SIGTERM 的测试,断言在途请求一条不丢、账本一条不少;最后讲有状态服务的发布为什么不能只靠停服——它还需要一个**代次(epoch)**来保证同一时刻只有一个主。

优雅停服的四步

进程从收到 SIGTERM 到退出,次序是固定的,每一步都可以观测:

  1. 停收新连接。先把监听关掉,让编排系统与负载均衡知道「这个实例不再接客」。这一步不处理任何已有请求,只是关上一扇门。
  2. 排空在途请求。等已经进来的请求处理完。等待是有预算的——这个预算就是 4.1 里必须小于宽限期的那条 DrainBudgetSeconds。预算内排干净就继续,超预算就只能放弃(宁可有界地失败,也不被 SIGKILL 砍在半路)。
  3. 落库。把内存里的状态(账本、脏数据、最后一批提交)刷到持久化层。这一步不做,前两步就白做了——请求处理完了,结果却没留下来。
  4. 退出。四步走完,进程正常退 0。

次序不能乱:先落库再停连接会让新请求涌进来污染状态;先退出再落库等于没落库。跳过第 2 步会丢在途请求,跳过第 3 步会丢最后提交——这两个「跳过」正是本节测试要挡住的错误。

真跑:发一个 SIGTERM,看它排空与落库

光讲次序不够,得有一个真进程被真信号打断。下面这个 Server 是 L2:它监听 TCP、每连接处理一个请求、在 Shutdown 里严格执行上面四步。下面这段是 L2,本机真起进程、真发 SIGTERM,go test 可以跑通:

// file: internal/deploy/shutdown.go
// tier: L2
// verify: go test ./internal/deploy/ -run TestGracefulShutdown -v
// 4.3 的优雅停服:SIGTERM 到退出,次序固定——停收新连接、排空在途请求、落
// 库、返回退 0。跳过排空会丢飞到一半的请求,跳过落库会丢最后一次提交。
package deploy

import (
	"bufio"
	"fmt"
	"io"
	"net"
	"os"
	"strings"
	"sync"
	"sync/atomic"
	"time"
)

// Server 是极小的 TCP 节点:每连接处理一个请求——读一行、干活、写 OK、记账。
type Server struct {
	ln           net.Listener
	inFlight     atomic.Int64
	drainTimeout time.Duration
	mu           sync.Mutex
	ledger       []string
}

// NewServer 返回一个排空最多等 drainTimeout 的 Server。
func NewServer(drainTimeout time.Duration) *Server {
	return &Server{drainTimeout: drainTimeout}
}

// Listen 在 addr 上开始接受连接,返回实际绑定的地址。
func (s *Server) Listen(addr string) (string, error) {
	ln, err := net.Listen("tcp", addr)
	if err != nil {
		return "", err
	}
	s.ln = ln
	go s.accept()
	return ln.Addr().String(), nil
}

func (s *Server) accept() {
	for {
		c, err := s.ln.Accept()
		if err != nil {
			return // 监听已被 Shutdown 关掉
		}
		go s.serve(c)
	}
}

func (s *Server) serve(c net.Conn) {
	defer c.Close()
	s.inFlight.Add(1)
	defer s.inFlight.Add(-1)
	line, err := bufio.NewReader(c).ReadString('\n')
	if err != nil {
		return
	}
	time.Sleep(50 * time.Millisecond) // 代替真实的请求处理
	s.mu.Lock()
	s.ledger = append(s.ledger, strings.TrimRight(line, "\r\n"))
	s.mu.Unlock()
	io.WriteString(c, "OK\n")
}

// Shutdown 执行四步排空,返回落库了多少条记录。
func (s *Server) Shutdown(flushPath string) (int, error) {
	if s.ln != nil {
		s.ln.Close() // 1. 停收新连接
	}
	deadline := time.Now().Add(s.drainTimeout) // 2. 排空在途请求
	for s.inFlight.Load() > 0 {
		if time.Now().After(deadline) {
			return 0, fmt.Errorf("drain timed out with %d in flight", s.inFlight.Load())
		}
		time.Sleep(2 * time.Millisecond)
	}
	s.mu.Lock() // 3. 落库
	records := append([]string(nil), s.ledger...)
	s.mu.Unlock()
	f, err := os.Create(flushPath)
	if err != nil {
		return 0, err
	}
	defer f.Close()
	w := bufio.NewWriter(f)
	for _, r := range records {
		fmt.Fprintln(w, r)
	}
	if err := w.Flush(); err != nil {
		return 0, err
	}
	return len(records), nil // 4. 返回,调用方退 0
}

测试用一个「辅助进程」模式把服务器放进子进程:父进程开 8 条连接各发一个请求,在它们都在途时向子进程发真 SIGTERM,然后断言三件事——在途请求全部拿到 OK、子进程报出落库 8 条、账本文件里正好 8 行:

// file: internal/deploy/shutdown_test.go
// tier: L2
// verify: go test ./internal/deploy/ -run TestGracefulShutdown -v
package deploy

import (
	"bufio"
	"fmt"
	"io"
	"net"
	"os"
	"os/exec"
	"os/signal"
	"strings"
	"syscall"
	"testing"
	"time"
)

// TestHelperProcess 不是真测试:AEGIS_HELPER=1 时在子进程里跑服务器,好让父
// 进程发一个真 SIGTERM。
func TestHelperProcess(t *testing.T) {
	if os.Getenv("AEGIS_HELPER") != "1" {
		return
	}
	srv := NewServer(2 * time.Second)
	addr, err := srv.Listen("127.0.0.1:0")
	if err != nil {
		fmt.Println("ERR listen:", err)
		os.Exit(2)
	}
	fmt.Println("READY", addr)
	os.Stdout.Sync()
	sig := make(chan os.Signal, 1)
	signal.Notify(sig, syscall.SIGTERM)
	<-sig
	flushed, err := srv.Shutdown(os.Getenv("AEGIS_FLUSH"))
	if err != nil {
		fmt.Println("ERR shutdown:", err)
		os.Exit(3)
	}
	fmt.Printf("SHUTDOWN flushed=%d\n", flushed)
	os.Exit(0)
}

func TestGracefulShutdown(t *testing.T) {
	flushPath := t.TempDir() + "/ledger.txt"
	cmd := exec.Command(os.Args[0], "-test.run=TestHelperProcess")
	cmd.Env = append(os.Environ(), "AEGIS_HELPER=1", "AEGIS_FLUSH="+flushPath)
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		t.Fatal(err)
	}
	if err := cmd.Start(); err != nil {
		t.Fatal(err)
	}
	sc := bufio.NewScanner(stdout)
	if !sc.Scan() || !strings.HasPrefix(sc.Text(), "READY ") {
		t.Fatalf("no READY line, got %q", sc.Text())
	}
	addr := strings.TrimSpace(strings.TrimPrefix(sc.Text(), "READY "))

	// 8 条连接各发一个请求;handler 睡 50ms,发信号时它们都在途。
	const n = 8
	conns := make([]net.Conn, 0, n)
	for i := 0; i < n; i++ {
		c, err := net.Dial("tcp", addr)
		if err != nil {
			t.Fatalf("dial %d: %v", i, err)
		}
		fmt.Fprintf(c, "req-%d\n", i)
		conns = append(conns, c)
	}
	time.Sleep(10 * time.Millisecond)
	if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
		t.Fatal(err)
	}

	// 在途请求必须全部拿到响应:是排空,不是丢弃。
	for i, c := range conns {
		c.SetReadDeadline(time.Now().Add(3 * time.Second))
		buf := make([]byte, 3)
		if _, err := io.ReadFull(c, buf); err != nil || string(buf) != "OK\n" {
			t.Errorf("conn %d not drained: resp=%q err=%v", i, buf, err)
		}
		c.Close()
	}
	// 监听已关:新连接必须被拒。
	if !eventuallyRefused(addr) {
		t.Error("listener still accepted a connection after SIGTERM")
	}
	// 子进程干净退出,并报出落库条数。
	var line string
	for sc.Scan() {
		if strings.HasPrefix(sc.Text(), "SHUTDOWN ") {
			line = sc.Text()
		}
	}
	if err := cmd.Wait(); err != nil {
		t.Fatalf("child exited with error: %v", err)
	}
	if want := fmt.Sprintf("SHUTDOWN flushed=%d", n); line != want {
		t.Fatalf("child said %q, want %q", line, want)
	}
	// 落库真的写到磁盘,一条不少。
	data, err := os.ReadFile(flushPath)
	if err != nil {
		t.Fatal(err)
	}
	if got := strings.Count(string(data), "\n"); got != n {
		t.Errorf("ledger has %d records, want %d", got, n)
	}
}

func eventuallyRefused(addr string) bool {
	for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); {
		c, err := net.DialTimeout("tcp", addr, 100*time.Millisecond)
		if err != nil {
			return true
		}
		c.Close()
		time.Sleep(20 * time.Millisecond)
	}
	return false
}

本机实跑结果(go test ./internal/deploy/ -run TestGracefulShutdown -v):

=== RUN   TestGracefulShutdown
--- PASS: TestGracefulShutdown (0.12s)
ok  	aegis/internal/deploy	0.377s

这个测试证明的是四步次序真的被执行了:8 条连接在信号到达时都在途,如果进程收到 SIGTERM 就 os.Exit,那 8 个 io.ReadFull 会因连接被重置而报错;如果它排空了但没落库,账本文件的行数会是 0。两者都过了,才说明「停收 → 排空 → 落库 → 退出」这条链路是通的。

有状态发布:代次与状态交接

无状态节点停服只要「排空 + 落库」就够了,有状态节点还多一层风险:新主接管旧主的状态时,怎么保证不是两个主同时在服务? 这正是脑裂。解决办法是一个单调递增的代次:每个有状态实例带一个 epoch,状态上戳着它最后一次被写入时的代次。新主只有在自己的 epoch 严格大于状态上的代次时,才被允许接管。下面这段是 L1,go test 可以直接跑:

// file: internal/deploy/epoch.go
// tier: L1
// verify: go test ./internal/deploy/ -run TestEpoch
// 4.3 的有状态半边:发布一个持单写者状态的节点(一个场景实例、一个世界分
// 片),不只是「起个新 Pod」。新主必须证明自己比它接管的状态更新,否则两个
// 主会同时服务同一个实例——脑裂。
//
// 每个有状态实例带一个代次 epoch:单调递增的世代号。只有当来者的 epoch 严格
// 大于状态上戳的 epoch,交接才被允许。
package deploy

// Epoch 是有状态实例的世代号。
type Epoch uint64

// CanTakeOver 报告 epoch 为 e 的主能否接管戳着 current 的状态。相等的 epoch
// 也被拒——两个主处在同一代,正是要防的脑裂。
func (e Epoch) CanTakeOver(current Epoch) bool { return e > current }

// Next 返回新主该采用的 epoch,永远严格大于它接管的状态。
func (e Epoch) Next() Epoch { return e + 1 }

// Handover 是一次有状态发布决策的结果。
type Handover struct {
	From    Epoch
	To      Epoch
	Allowed bool
	Reason  string
}

// PlanHandover 判定 epoch 为 to 的主能否接管戳着 from 的状态。
func PlanHandover(from, to Epoch) Handover {
	if to.CanTakeOver(from) {
		return Handover{From: from, To: to, Allowed: true, Reason: "newer generation"}
	}
	return Handover{From: from, To: to, Allowed: false, Reason: "epoch not newer: refusing to split brain"}
}

配套测试断言三条:5 → 6 允许(更新的主)、5 → 5 拒绝(同代,脑裂)、5 → 4 拒绝(过期的旧主)。把「同一时刻只有一个主」这条分布式约束,落成一条会失败的断言——这正是 4.1 里 StatefulSet 的 OrderedReady 想要的语义:逐台替换,每台新主都带着一个更大的代次。

本节小结

这一节把「旧实例怎么走」拆成两件事:

  1. 优雅停服是四步,不是一步:停收新连接、排空在途请求、落库、退出。次序固定,跳过排空丢在途请求,跳过落库丢最后提交。测试用一个真子进程 + 真 SIGTERM 把这条链路钉住。
  2. 有状态发布多一层代次:无状态节点排空即可,有状态节点还要证明「新主比旧状态更新」——用单调递增的 epoch 挡住脑裂,这正对应 StatefulSet 的逐台有序替换。

至此,部署与发布这一章走完了:镜像与清单(4.1)、发布策略(4.2)、停服与交接(4.3)。但「发上去」不等于「改得动」——线上最频繁的动作其实是改配置:改一个掉落率、调一个冷却、换一个活动开关。下一章讲配置中心与热更新,回答「哪些能热更、怎么版本化、改坏了怎么回滚」。

阅读导航:上一节:4.2 蓝绿、灰度与滚动发布 · 下一节:5.1 配置版本化与差量分发 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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