一、池里放的是什么
网关到每个后端实例维持一组长连接。池化的不是“请求”,而是已建立的 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 -benchmem | 415.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回池,下一次借出继续失败,错误被放大。 - 池没有上限,突发流量把后端连接数打爆,后端先于网关崩溃。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。