《游戏服务端编程实践》5.4.3 压测与连接池管理

从网关到后端实例的连接池出发,给出最大连接、最大空闲、获取超时、空闲超时与健康检查的取值方法,用可运行的 Pool 实现 Get/Put/Close 与坏连接摘除,并给出本机 benchmark、压测指标口径、三类常见故障的排查路径与复测方法。

一、池里放的是什么

网关到每个后端实例维持一组长连接。池化的不是“请求”,而是已建立的 TCP 连接:建连要握手、可能还要 TLS 和鉴权,成本远高于一次读写。池的目标是让正常请求直接拿到可用连接,同时在突发流量下有一个明确的上限。

二、五个参数

参数作用取值建议
MaxOpen到单个后端实例的最大连接数按后端并发能力与网关实例数反推
MaxIdle保留的空闲连接数小于等于 MaxOpen,避免闲置占端口
AcquireTimeout拿不到连接时最多等多久明显小于客户端请求超时
IdleTimeout空闲连接保留多久小于 NAT/防火墙的空闲回收时间
Check借出前验证连接可用轻量 ping 或协议层探活

MaxOpen 不是越大越好。后端每个连接都有内存与 goroutine 成本,网关侧连接数乘以网关副本数,才是后端真正承受的连接总量。压测要验证的是这个乘积,而不是单机数字。

三、Get / Put 状态机

当前状态调用结果
有可用空闲连接Get直接复用,不新建
无空闲且未达上限Get新建连接,open++
已达上限Get等待归还或超时
连接损坏MarkBroken + Put关闭并 open--

关键点是 open 统计“已建立连接”,等待者通过一个可替换的 wait channel 被唤醒。不能用信号量同时表达“连接数上限”和“当前借出数”,否则复用空闲连接时计数会持续上涨;open 长期高于空闲数与借出数之和,就是连接泄漏的信号,应对它做告警。

四、健康检查与摘除

空闲连接可能已经被对端关闭、被中间设备回收,或者协议状态已经错位。Check 在借出前做一次轻量验证,失败就关闭并补建;MarkBroken 用于调用方已经确认写失败、读到半帧等损坏场景。两者分工不同:前者防“看起来还活着”的死连接,后者防“刚刚坏掉”的连接被放回池里;健康检查不能太重。每条消息前做一次完整 RPC 会把延迟翻倍;更合理的做法是借出时做一次非阻塞探活,运行中由真实调用的错误触发摘除。

五、连接池实现

Pool 只暴露 Get、Put、Close。Conn 包一层 net.Conn,记录是否损坏、是否已经归还,防止同一个连接被重复放回池里。

// file: ch05/backend/pool.go
// tier: L1
// verify: go test ./ch05/backend/

package backend

import (
	"context"
	"errors"
	"net"
	"sync"
	"time"
)

var (
	ErrPoolClosed     = errors.New("backend: pool closed")
	ErrAcquireTimeout = errors.New("backend: acquire timeout")
)

// PoolConfig 的零值不可用:Dial 必填,MaxOpen 至少为 1。
type PoolConfig struct {
	Dial           func(context.Context) (net.Conn, error)
	MaxOpen        int
	MaxIdle        int
	AcquireTimeout time.Duration
	IdleTimeout    time.Duration
	Check          func(net.Conn) error
}

type Pool struct {
	cfg    PoolConfig
	mu     sync.Mutex
	idle   []idleConn
	open   int
	closed bool
	wait   chan struct{}
}

type idleConn struct {
	conn net.Conn
	at   time.Time
}

// Conn 是借出的连接。确认损坏时先 MarkBroken 再 Put,池会关闭它而不是复用。
type Conn struct {
	net.Conn
	pool     *Pool
	mu       sync.Mutex
	broken   bool
	returned bool
}

func NewPool(cfg PoolConfig) *Pool {
	if cfg.MaxOpen <= 0 {
		cfg.MaxOpen = 1
	}
	if cfg.MaxIdle <= 0 || cfg.MaxIdle > cfg.MaxOpen {
		cfg.MaxIdle = cfg.MaxOpen
	}
	return &Pool{cfg: cfg, wait: make(chan struct{})}
}

func (p *Pool) Get(ctx context.Context) (*Conn, error) {
	if p.cfg.AcquireTimeout > 0 {
		var cancel context.CancelFunc
		ctx, cancel = context.WithTimeout(ctx, p.cfg.AcquireTimeout)
		defer cancel()
	}
	for {
		p.mu.Lock()
		if p.closed {
			p.mu.Unlock()
			return nil, ErrPoolClosed
		}
		if len(p.idle) > 0 {
			ic := p.idle[len(p.idle)-1]
			p.idle = p.idle[:len(p.idle)-1]
			p.mu.Unlock()
			if p.cfg.IdleTimeout > 0 && time.Since(ic.at) > p.cfg.IdleTimeout {
				p.discard(ic.conn)
				continue
			}
			if p.cfg.Check != nil && p.cfg.Check(ic.conn) != nil {
				p.discard(ic.conn)
				continue
			}
			return &Conn{Conn: ic.conn, pool: p}, nil
		}
		if p.open < p.cfg.MaxOpen {
			p.open++
			p.mu.Unlock()
			conn, err := p.cfg.Dial(ctx)
			if err != nil {
				p.mu.Lock()
				p.open--
				p.notifyLocked()
				p.mu.Unlock()
				return nil, err
			}
			return &Conn{Conn: conn, pool: p}, nil
		}
		wait := p.wait
		p.mu.Unlock()
		select {
		case <-wait:
		case <-ctx.Done():
			return nil, ErrAcquireTimeout
		}
	}
}

func (p *Pool) discard(conn net.Conn) {
	_ = conn.Close()
	p.mu.Lock()
	p.open--
	p.notifyLocked()
	p.mu.Unlock()
}

func (p *Pool) Put(c *Conn) {
	if c == nil || c.Conn == nil {
		return
	}
	c.mu.Lock()
	if c.returned {
		c.mu.Unlock()
		return
	}
	c.returned = true
	broken := c.broken
	c.mu.Unlock()

	p.mu.Lock()
	if broken || p.closed || len(p.idle) >= p.cfg.MaxIdle {
		p.open--
		p.notifyLocked()
		p.mu.Unlock()
		_ = c.Conn.Close()
		return
	}
	p.idle = append(p.idle, idleConn{conn: c.Conn, at: time.Now()})
	p.notifyLocked()
	p.mu.Unlock()
}

func (c *Conn) MarkBroken() {
	c.mu.Lock()
	c.broken = true
	c.mu.Unlock()
}

func (p *Pool) notifyLocked() {
	close(p.wait)
	p.wait = make(chan struct{})
}

func (p *Pool) Close() error {
	p.mu.Lock()
	if p.closed {
		p.mu.Unlock()
		return nil
	}
	p.closed = true
	idle := p.idle
	p.idle = nil
	p.open -= len(idle)
	p.notifyLocked()
	p.mu.Unlock()
	for _, ic := range idle {
		_ = ic.conn.Close()
	}
	return nil
}

六、测试

测试用 net.Pipe 造连接,覆盖复用、坏连接丢弃、上限阻塞与关闭。上限用例把 MaxOpen 设为 1、获取超时设为 30 ms,先借走唯一连接,第二次 Get 必须在约 30 ms 后返回 ErrAcquireTimeout;归还后同一连接应被复用。

// file: ch05/backend/pool_test.go
// tier: L1
// verify: go test ./ch05/backend/

package backend

import (
	"context"
	"errors"
	"io"
	"net"
	"sync/atomic"
	"testing"
	"time"
)

func pipeDialer() (func(context.Context) (net.Conn, error), *atomic.Int32) {
	var calls atomic.Int32
	dial := func(context.Context) (net.Conn, error) {
		calls.Add(1)
		client, server := net.Pipe()
		go func() {
			_, _ = io.Copy(io.Discard, server)
			_ = server.Close()
		}()
		return client, nil
	}
	return dial, &calls
}

func TestPoolReuseAndDiscard(t *testing.T) {
	dial, calls := pipeDialer()
	p := NewPool(PoolConfig{Dial: dial, MaxOpen: 2, MaxIdle: 2})
	defer p.Close()
	c1, err := p.Get(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	p.Put(c1)
	c2, err := p.Get(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	if c2.Conn != c1.Conn || calls.Load() != 1 {
		t.Fatalf("connection not reused: calls=%d", calls.Load())
	}
	c2.MarkBroken()
	p.Put(c2)
	c3, err := p.Get(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	if c3.Conn == c2.Conn || calls.Load() != 2 {
		t.Fatalf("broken connection reused: calls=%d", calls.Load())
	}
	p.Put(c3)
	if err := p.Close(); err != nil {
		t.Fatal(err)
	}
	if _, err := p.Get(context.Background()); !errors.Is(err, ErrPoolClosed) {
		t.Fatalf("get after close: %v", err)
	}
}

func TestPoolAcquireTimeout(t *testing.T) {
	dial, _ := pipeDialer()
	p := NewPool(PoolConfig{Dial: dial, MaxOpen: 1, MaxIdle: 1, AcquireTimeout: 30 * time.Millisecond})
	defer p.Close()
	c1, err := p.Get(context.Background())
	if err != nil {
		t.Fatal(err)
	}
	start := time.Now()
	if _, err := p.Get(context.Background()); !errors.Is(err, ErrAcquireTimeout) || time.Since(start) < 20*time.Millisecond {
		t.Fatalf("err = %v, elapsed = %v", err, time.Since(start))
	}
	p.Put(c1)
	c2, err := p.Get(context.Background())
	if err != nil {
		t.Fatalf("after put: %v", err)
	}
	p.Put(c2)
}

func BenchmarkPoolGetPut(b *testing.B) {
	p := NewPool(PoolConfig{Dial: func(context.Context) (net.Conn, error) {
		client, server := net.Pipe()
		go func() {
			_, _ = io.Copy(io.Discard, server)
			_ = server.Close()
		}()
		return client, nil
	}, MaxOpen: 8, MaxIdle: 8})
	defer p.Close()
	b.ReportAllocs()
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			c, err := p.Get(context.Background())
			if err != nil {
				b.Fatal(err)
			}
			p.Put(c)
		}
	})
}

测试命令为 go test ./ch05/backend/ -run 'Pool' -count=1 -v,本机两个用例均通过,包级结果为 ok gspractice/ch05/backend。

七、压测口径

网关压测不能只报一个 QPS。至少同时记录连接数、消息速率、p50/p99 延迟、错误率和内存;否则吞吐上涨可能只是把延迟堆进了队列。

指标采集方式本机结果
池 Get/Put 开销go test -bench BenchmarkPoolGetPut -benchmem415.8–416.9 ns/op,160 B/op,2 allocs/op
固定连接往返延迟自建回环客户端,固定 1/10/100 连接示例值,需按部署环境实测
连接建立速率每秒成功 Dial 次数示例值
后端故障错误率停掉一个后端,统计 50301 占比示例值
go test ./ch05/backend/ -run '^$' -bench 'BenchmarkPoolGetPut' -benchmem -count=3
BenchmarkPoolGetPut-10  2801174  416.8 ns/op  160 B/op  2 allocs/op
BenchmarkPoolGetPut-10  2852833  415.8 ns/op  160 B/op  2 allocs/op
BenchmarkPoolGetPut-10  2895294  416.9 ns/op  160 B/op  2 allocs/op

测试机为 Apple M1 Pro(10 逻辑核),GOMAXPROCS=10。这些数字只代表本机、本实现,不能直接当作生产容量;生产必须用真实后端、真实包大小和真实网络复测。

八、失败场景:池被打满与连接假活

池打满时,新请求在 Get 上排队。如果 AcquireTimeout 大于客户端超时,客户端已经断开,网关还在等连接,goroutine 与请求对象一起滞留;正确顺序是让获取超时明显小于客户端超时,并在超时后快速回 50301。连接假活更隐蔽:对端进程已经崩溃,但本端还没收到 FIN;Get 拿到连接、写入成功、读超时,这条连接才被标记损坏。健康检查能把发现时间从“下一次真实请求”提前到“借出时”,代价是每次借出多一次探活。

小结

连接池的正确性由三件事决定:open 计数只统计真实连接,等待者有明确的超时,坏连接绝不回到空闲队列。参数上,MaxOpen 要按“网关副本数 × 每实例连接数”核算后端总连接,AcquireTimeout 要短于客户端超时。压测则要同时看延迟、错误率和连接数,不能只报峰值 QPS。

常见错误与边界

  • Get 不设超时,池打满后请求永久排队,goroutine 与内存持续上涨。
  • 连接写失败后仍然 Put 回池,下一次借出继续失败,错误被放大。
  • 池没有上限,突发流量把后端连接数打爆,后端先于网关崩溃。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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