池化是把“无限并发”换成“可解释的排队”
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 | 吞吐 | 平均排队 | 观察 |
|---|---|---|---|
| 1 | 421/s | 231ms | 队列长期满,吞吐由单 worker 决定 |
| 4 | 1690/s | 58ms | 接近线性扩展,排队仍高 |
| 16 | 6825/s | 14ms | 单位时间产出继续上升 |
| 64 | 24729/s | 3.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 舱壁隔离与资源配额 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。