《后端架构设计·高性能与高可用》5.1 连接池、线程池与协程池

在 arch-lab 订单服务上实现有界工作池与连接池租约模型:队列上限、等待超时、三种拒绝策略、在途与排队指标、泄漏检测;用 1200 个任务真实对比 1/4/16/64 个 worker 的吞吐与排队,并给出 Java 21 线程池对照、本机命令与原始输出。

池化是把“无限并发”换成“可解释的排队”

arch-lab 的下单服务同时面对三种资源:到库存与支付服务的连接、执行下单编排的工作线程,以及每个请求派生出的 goroutine。三者都能“池化”,但池的语义完全不同:连接池限制的是对下游的并发连接数,工作池限制的是本进程同时执行的任务数,goroutine 池限制的是任务排队的位置与数量。把三者混成一个“并发数”配置,故障时就无法回答到底是连接不够、CPU 打满,还是队列在堆积。

本站 性能与可伸缩性 讨论过容量与并发的总体关系,本节不再重复,只把 arch-lab 里最小可运行的两类池写出来:一个有界工作池,一个带租约的连接池,并用真实测试回答池耗尽、等待超时、归还和泄漏这四个问题。

有界工作池:队列、等待、拒绝是三个旋钮

工作池的关键不是“开 N 个 goroutine”,而是明确队列满时发生什么。Pool 把 Workers、QueueSize、WaitTimeout 和 Policy 分开:Block 等到有空位或调用方取消,RejectOnFull 立即返回,RejectOnTimeout 只等固定窗口。Submit 先做非阻塞入队,失败后才进入策略分支,因此正常路径没有额外定时器。指标也分成两组:InFlight 是已经开始执行的任务数,Queued 是 len(tasks) 的真实队列长度。前者高说明下游慢,后者高说明到达速度超过消费速度;只看其中一个,都会把“排队”误判成“执行慢”。

// file: internal/platform/pool/bound.go
// tier: L1
// verify: go test ./internal/platform/pool/
package pool

import (
	"context"
	"errors"
	"sync"
	"sync/atomic"
	"time"
)

var (
	ErrQueueFull     = errors.New("pool: queue full")
	ErrWaitTimeout   = errors.New("pool: wait timeout")
	ErrPoolExhausted = errors.New("pool: connection wait timeout")
	ErrLeaseReleased = errors.New("pool: lease already released")
)

type Policy int

const (
	Block Policy = iota // wait for room or caller cancellation
	RejectOnFull
	RejectOnTimeout
)

type Config struct {
	Workers, QueueSize int
	WaitTimeout        time.Duration
	Policy             Policy
}

type Snapshot struct {
	Submitted, Completed, Rejected, WaitTimeouts int64
	InFlight, Queued                             int64
}

// Pool keeps queue depth and in-flight count as separate limits: a full queue
// means work has not started, high in-flight means the downstream is the
// bottleneck. Neither number alone explains saturation.
type Pool struct {
	cfg                                         Config
	tasks                                       chan func()
	wg                                          sync.WaitGroup
	closed                                      atomic.Bool
	submitted, completed, rejected, waitTimeout atomic.Int64
	inFlight                                    atomic.Int64
}

func New(cfg Config) *Pool {
	if cfg.Workers < 1 {
		cfg.Workers = 1
	}
	p := &Pool{cfg: cfg, tasks: make(chan func(), cfg.QueueSize)}
	p.wg.Add(cfg.Workers)
	for i := 0; i < cfg.Workers; i++ {
		go p.worker()
	}
	return p
}

func (p *Pool) worker() {
	defer p.wg.Done()
	for task := range p.tasks {
		p.inFlight.Add(1)
		task()
		p.inFlight.Add(-1)
		p.completed.Add(1)
	}
}

// Submit never invents capacity by spawning a goroutine per task: the bounded
// queue is the only buffer, and the policy decides what a full queue means.
func (p *Pool) Submit(ctx context.Context, task func()) error {
	p.submitted.Add(1)
	select {
	case p.tasks <- task:
		return nil
	default:
	}
	if p.cfg.Policy == RejectOnFull {
		p.rejected.Add(1)
		return ErrQueueFull
	}
	var timeout <-chan time.Time
	if p.cfg.Policy == RejectOnTimeout {
		timer := time.NewTimer(p.cfg.WaitTimeout)
		defer timer.Stop()
		timeout = timer.C
	}
	select {
	case p.tasks <- task:
		return nil
	case <-timeout:
		p.rejected.Add(1)
		p.waitTimeout.Add(1)
		return ErrWaitTimeout
	case <-ctx.Done():
		p.rejected.Add(1)
		return ctx.Err()
	}
}

func (p *Pool) Stats() Snapshot {
	return Snapshot{
		Submitted: p.submitted.Load(), Completed: p.completed.Load(),
		Rejected: p.rejected.Load(), WaitTimeouts: p.waitTimeout.Load(),
		InFlight: p.inFlight.Load(), Queued: int64(len(p.tasks)),
	}
}

// Close drains accepted tasks. Callers must stop submitting first.
func (p *Pool) Close() {
	if p.closed.CompareAndSwap(false, true) {
		close(p.tasks)
	}
	p.wg.Wait()
}

type Conn struct{ ID int }

type Lease struct {
	Conn     *Conn
	pool     *ConnPool
	acquired time.Time
	released atomic.Bool
}

// ConnPool separates maxOpen (hard cap on real connections) from maxIdle
// (connections kept warm). The lease is the accounting unit: every Acquire must
// be paired with Release, and leaked leases stay visible.
type ConnPool struct {
	mu       sync.Mutex
	maxOpen  int
	maxIdle  int
	nextID   int
	open     int
	idle     []*Conn
	leases   map[int]*Lease
	notify   chan struct{}
	timedOut atomic.Int64
}

func NewConnPool(maxOpen, maxIdle int) *ConnPool {
	if maxIdle > maxOpen {
		maxIdle = maxOpen
	}
	return &ConnPool{maxOpen: maxOpen, maxIdle: maxIdle,
		leases: make(map[int]*Lease), notify: make(chan struct{}, maxOpen)}
}

// Acquire waits on one notification channel instead of one channel per waiter.
// The caller's deadline, not a pool-level knob, bounds the wait.
func (p *ConnPool) Acquire(ctx context.Context) (*Lease, error) {
	for {
		p.mu.Lock()
		if n := len(p.idle); n > 0 {
			c := p.idle[n-1]
			p.idle = p.idle[:n-1]
			l := p.leaseLocked(c)
			p.mu.Unlock()
			return l, nil
		}
		if p.open < p.maxOpen {
			c := &Conn{ID: p.nextID}
			p.nextID++
			p.open++
			l := p.leaseLocked(c)
			p.mu.Unlock()
			return l, nil
		}
		p.mu.Unlock()
		select {
		case <-ctx.Done():
			p.timedOut.Add(1)
			signal(p.notify)
			return nil, ErrPoolExhausted
		case <-p.notify:
		}
	}
}

func (p *ConnPool) leaseLocked(c *Conn) *Lease {
	l := &Lease{Conn: c, pool: p, acquired: time.Now()}
	p.leases[c.ID] = l
	return l
}

func (l *Lease) Release() error {
	if l.released.Swap(true) {
		return ErrLeaseReleased
	}
	p := l.pool
	p.mu.Lock()
	delete(p.leases, l.Conn.ID)
	if len(p.idle) < p.maxIdle {
		p.idle = append(p.idle, l.Conn)
	} else {
		p.open--
	}
	p.mu.Unlock()
	signal(p.notify)
	return nil
}

func signal(ch chan struct{}) {
	select {
	case ch <- struct{}{}:
	default:
	}
}

// Leaked feeds a lease-age alarm. It must never reap a lease still in use.
func (p *ConnPool) Leaked(age time.Duration) []int {
	p.mu.Lock()
	defer p.mu.Unlock()
	now := time.Now()
	var ids []int
	for id, l := range p.leases {
		if now.Sub(l.acquired) >= age {
			ids = append(ids, id)
		}
	}
	return ids
}

func (p *ConnPool) Stats() (open, idle, inUse int, timedOut int64) {
	p.mu.Lock()
	defer p.mu.Unlock()
	return p.open, len(p.idle), len(p.leases), p.timedOut.Load()
}

连接池:租约而不是连接数

ConnPool 把 maxOpen 和 maxIdle 分开:前者是真实连接硬上限,后者是保活数量。真正被记账的对象是 Lease,不是 Conn:Acquire 成功后必须 Release,否则 Leaked(age) 会把超期未归还的租约暴露出来。泄漏检测只报警、不强收,因为正在使用的连接被强行回收会制造更难查的错误。等待者共享一个容量为 1 的 notify 边沿,而不是每个等待者一个 channel;释放时发一次信号,被唤醒者重新竞争空闲连接。等待上限由调用方 context 决定,池本身不再提供一个容易与上游预算冲突的独立超时。

测试:耗尽、等待、归还、泄漏

第一组测试把两个 worker 卡住、队列塞满,验证第四个任务被拒绝;再把唯一 worker 卡住,验证 30ms 等待窗口后返回 ErrWaitTimeout。第二组申请两个连接后申请第三个,验证 context 到期返回 ErrPoolExhausted;释放一个后检查 open/idle/inUse,再故意不归还第二个,确认 Leaked(5ms) 能找到它,最后验证归还后空闲连接被保留、超出 maxIdle 的连接被关闭。

// file: internal/platform/pool/pool_test.go
// tier: L1
// verify: go test ./internal/platform/pool/ -count=1 -v
package pool

import (
	"context"
	"errors"
	"testing"
	"time"
)

func TestRejectAndTimeout(t *testing.T) {
	ctx := context.Background()
	release, started := make(chan struct{}), make(chan struct{}, 2)
	p := New(Config{Workers: 2, QueueSize: 1, Policy: RejectOnFull})
	for i := 0; i < 2; i++ {
		if err := p.Submit(ctx, func() { started <- struct{}{}; <-release }); err != nil {
			t.Fatal(err)
		}
		<-started
	}
	if err := p.Submit(ctx, func() {}); err != nil {
		t.Fatal(err)
	}
	if err := p.Submit(ctx, func() {}); !errors.Is(err, ErrQueueFull) {
		t.Fatal(err)
	}
	close(release)
	p.Close()
	p = New(Config{Workers: 1, QueueSize: 0, WaitTimeout: 30 * time.Millisecond, Policy: RejectOnTimeout})
	block, entered := make(chan struct{}), make(chan struct{})
	p.Submit(ctx, func() { close(entered); <-block })
	<-entered
	begin := time.Now()
	err := p.Submit(ctx, func() {})
	waited := time.Since(begin)
	if !errors.Is(err, ErrWaitTimeout) || waited < 25*time.Millisecond {
		t.Fatalf("err=%v waited=%s", err, waited)
	}
	close(block)
	p.Close()
	t.Logf("queue_full_rejected=1 wait_timeout_waited=%s", waited.Round(time.Millisecond))
}

func TestConnPoolLease(t *testing.T) {
	ctx := context.Background()
	p := NewConnPool(2, 1)
	l1, _ := p.Acquire(ctx)
	l2, _ := p.Acquire(ctx)
	timed, cancel := context.WithTimeout(ctx, 30*time.Millisecond)
	defer cancel()
	if _, err := p.Acquire(timed); !errors.Is(err, ErrPoolExhausted) {
		t.Fatal(err)
	}
	_ = l1.Release()
	if open, idle, inUse, timedOut := p.Stats(); open != 2 || idle != 1 || inUse != 1 || timedOut != 1 {
		t.Fatalf("open=%d idle=%d inUse=%d timedOut=%d", open, idle, inUse, timedOut)
	}
	time.Sleep(10 * time.Millisecond)
	if leaked := p.Leaked(5 * time.Millisecond); len(leaked) != 1 || leaked[0] != l2.Conn.ID {
		t.Fatalf("leaked = %v", leaked)
	}
	_ = l2.Release()
	if open, idle, inUse, _ := p.Stats(); open != 1 || idle != 1 || inUse != 0 {
		t.Fatalf("open=%d idle=%d inUse=%d", open, idle, inUse)
	}
	t.Logf("timed_out=1 leaked=[%d] final_open=1 final_idle=1", l2.Conn.ID)
}

真实对照:1、4、16、64 个 worker

同一份代码在 macOS darwin/arm64、Go 1.26.0 上真实运行:1200 个任务,每个 sleep 2ms,32 个提交者,队列 64,Block 策略,单次运行。命令与原始输出在 .local/reports/be-arch/be-arch-perf-05-1-tests.txt;压测入口 TestPoolSizeSweep 与正文测试同包,正文为控制篇幅只保留池语义测试:

$ go test ./internal/platform/pool/ -run TestPoolSizeSweep -count=1 -v
workers=1  elapsed=2.85s  throughput=421/s  avg_wait=231.278ms rejected=0
workers=4  elapsed=710ms  throughput=1690/s avg_wait=57.698ms  rejected=0
workers=16 elapsed=176ms  throughput=6825/s avg_wait=14.357ms  rejected=0
workers=64 elapsed=49ms   throughput=24729/s avg_wait=3.833ms  rejected=0
worker吞吐平均排队观察
1421/s231ms队列长期满,吞吐由单 worker 决定
41690/s58ms接近线性扩展,排队仍高
166825/s14ms单位时间产出继续上升
6424729/s3.8ms任务几乎不排队,但并行度已远超 10 核

这不是“64 最好”的结论。任务只是 sleep,不占 CPU,所以 64 个 worker 仍能扩展;真实下单要访问数据库和下游,worker 超过下游连接容量只会把排队搬到下游。池大小的上限应由 maxOpen、下游并发配额和 P99 预算共同决定,而不是压测里最大的那个数。

Java 21 对照用同样的 1200 任务、2ms sleep、32 提交者和 1/4/16/64 个平台线程,ThreadPoolExecutor 配无界队列,真实运行结果为 391/1593/6283/21999 每秒(.local/reports/be-arch/be-arch-perf-05-1-java-contrast.txt)。Java 的 Thread.sleep(2) 在本机实际约 2.5ms,绝对吞吐不能与 Go 逐项对齐;可比的结论是扩展倍率:Go 从 421 到 24729(59 倍),Java 从 391 到 21999(56 倍),两者接近;差异可能来自 sleep 粒度与平台线程调度开销,未单独测量。

Go 与 Java 的边界差异

Go 的 goroutine 由运行时调度,初始栈约 2KB,按需增长,创建 64 个甚至 64 万个的边际成本都很低;因此“协程池”在 Go 里通常不是为了省创建成本,而是为了限流和背压。把 go handle(req) 改成固定 worker 池,换来的不是性能,而是可拒绝、可观测、可关闭的边界。

Java 的平台线程是 1:1 映射到内核线程,本机 -XX:+PrintFlagsFinal 显示 ThreadStackSize=2048(KB,即 2MB 栈),线程数必须显式限制。Java 21 的虚拟线程把创建成本降下来,但 ThreadPoolExecutor 的队列与拒绝策略仍然是平台线程池的语义;虚拟线程更适合“每请求一线程”的阻塞式写法,不等于可以取消对下游并发的限制。两者的共同边界是:池只能限制本进程,不能限制下游。数据库连接池 100、下游配额 50 时,工作池开 200 只会让 100 个请求同时卡在网络上。

池多大才算够:退出条件

工作池的退出(缩容或关闭)应满足三条中的至少一条:队列平均占用连续 10 分钟低于 10%,说明预留过多;InFlight 已等于 Workers 且下游 P99 没有改善,说明瓶颈不在本进程;扩大池后拒绝率不降反升,说明下游已经饱和。连接池的退出条件是 idle 长期等于 maxIdle 且 Leaked 持续为 0,可以下调保活数;若 timedOut 持续大于 0 而数据库连接数未满,则应先查慢查询而不是加连接。

常见错误与边界

第一,把队列设成无界。无界队列把过载变成内存增长和更长的延迟,最终仍会超时,只是错误出现得更晚、更难定位。

第二,等待超时大于上游预算。上游只剩 100ms,池内却允许等 500ms,请求注定在下游返回前被上游取消,白白占用一个 worker。

第三,连接池只限制 maxOpen 不限 maxIdle。空闲连接长期占着数据库配额,扩缩容时新实例拿不到连接。

第四,泄漏检测直接回收租约。正在执行的查询会突然拿到已关闭的连接,错误从“泄漏”变成“随机失败”。第五,把 goroutine 池当成性能优化。Go 里创建 goroutine 很便宜,池的价值是限流、拒绝和可观测;没有这三个需求时,池只是多一层调度。

小结

本节把池化拆成两类可运行组件:有界工作池用队列上限、等待窗口和三种拒绝策略把过载变成显式错误;连接池用租约记账、maxOpen/maxIdle 分离和泄漏检测把连接生命周期变成可审计的对象。真实单次运行中,1200 个 2ms 任务在 1/4/16/64 个 worker 下吞吐为 421/1690/6825/24729 每秒,平均排队从 231ms 降到 3.8ms;Java 21 对照为 391/1593/6283/21999 每秒。下一节把这些池拆成按下游划分的舱壁与配额。

阅读导航:上一节:4.3 积压、顺序、死信与定时对账补偿 · 下一节:5.2 舱壁隔离与资源配额 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查