主题
高并发手段:减少重复工作,并给共享资源设上限
接口慢了,第一反应常是多开 goroutine。可如果请求都在等同一个数据库,多开 goroutine 只会让更多请求挤到数据库前。优化前要先分清:时间花在计算、分配、网络往返、锁竞争,还是等待资源上;随后才能决定该复用、合并、搬到后台,还是减少工作。
这篇文章把连接池、协程池、对象池、批处理、请求合并、异步化、读写分离、缓存、预计算和锁优化放在同一条请求路径上。它们解决的问题不同,也会把成本转移到不同地方。所有性能选择都需要目标负载验证,本文的本地实验只验证机制和关闭路径,不提供通用吞吐排名。
先用现象缩小范围
| 看到的证据 | 可以先考虑 | 同时检查的代价 |
|---|---|---|
| 建连、TLS 或认证反复发生 | 复用连接,调整池 | 对端连接上限、空闲失效、连接寿命 |
| goroutine 和排队内存随流量增加 | 有界工作池、限并发、背压 | 队列等待是否已超过请求剩余时间 |
| 相同临时对象频繁分配 | sync.Pool、复用缓冲 | 内存滞留、所有权与旧数据残留 |
| 很多小请求支付同样固定成本 | 批处理 | 凑批等待、批量失败、最大字节数 |
| 大量并发查询同一个键 | 合并在途请求、缓存 | 取消语义、键作用域、缓存失效 |
| 用户无需马上看到最终结果 | 持久队列异步处理 | 任务是否会丢、重复消费、排队时延 |
| 主库读负担很重 | 读副本、缓存、预计算 | 读旧数据、写后读语义、刷新成本 |
| profile 显示锁竞争集中 | 缩短临界区、分段锁、不可变快照 | 原子性范围、跨分片操作、复制成本 |
Go 官方诊断文档提供 CPU、内存、阻塞和执行跟踪入口,database/sql 又有 DB.Stats。先观察这些证据,比看到“高并发”就上对象池更可靠。优化前后要使用相同请求混合、数据规模和错误口径,否则快的可能只是少做了工作。[1][2]
三种池,复用的是三种东西
连接池:复用昂贵连接,也把容量变成排队点
*sql.DB 本身就是可被多个 goroutine 共享的数据库句柄和连接池,不需要每次请求都 sql.Open。池要回答最大连接数、空闲连接数、空闲时间和总寿命四个问题。[2]
database/sql 入口 | 影响 | 要看什么 |
|---|---|---|
SetMaxOpenConns | 约束打开的连接总量;达到上限时新操作等待 | InUse、WaitCount、WaitDuration 与数据库负载 |
SetMaxIdleConns | 保留多少空闲连接 | 突发时重新建连成本,空闲连接占用 |
SetConnMaxIdleTime | 空闲多久后可被关闭 | 空闲连接回收与下一次突发成本 |
SetConnMaxLifetime | 连接复用寿命 | 对端切换、代理连接寿命与重连节奏 |
QueryContext / ExecContext | 把等待和驱动支持的取消接入 context | context 是否传到真正的调用,驱动具体支持 |
池大小还要乘实例数。每个实例 50 条连接,扩到 20 个实例,配置层面的潜在总量就到了 1000;这不等于数据库能承受 1000 个活跃查询。连接多了以后,CPU、锁和 I/O 争用可能使每条查询更慢。官方文档也提醒,限制连接相当于引入一个信号量,持有连接再等待另一条连接可能形成死锁。[2]
Rows 使用后关闭,事务必须提交或回滚,专用 Conn 用完要 Close 归还。泄漏一个资源就可能长期占住池槽。HTTP 则应复用 Client/Transport,并正确关闭响应体;HTTP/2 还可能在一条连接上承载多个流,连接数不能直接当成请求并发数。[3][4]
协程池:限制执行者,还要限制等待者
Go 的 goroutine 不是操作系统线程。工作池的主要价值常是给数据库、CPU 密集任务或外部服务设置并发边界,而不是宣称“启动 goroutine 一定昂贵”。如果池只有固定 worker,但接收一个无限队列,内存和等待时间仍没有上限。
正在绘制图示…
查看图源代码
flowchart LR
A[提交方] --> B{队列有空位?}
B -- 否 --> C[拒绝并把背压交回提交方]
B -- 是 --> D[有界任务队列]
D --> E[固定数量 worker]
E --> F[检查任务是否已取消]
F --> G[执行业务并写入有界结果通道]
H[关闭提交入口] --> D
D --> I[排空后关闭完成信号]
文末池有 1 个 worker、2 个排队位置。测试先用 channel 精确地让第一个任务占住 worker,再提交后续任务,不靠 Sleep 猜测任务是否已经开始。
go
second, e2 := p.TrySubmit(ctx, fastJob)
third, e3 := p.TrySubmit(ctx, fastJob)
_, e4 := p.TrySubmit(ctx, fastJob)
fmt.Println("queued:", e2 == nil, e3 == nil,
"overflow rejected:", errors.Is(e4, ErrFull))实际输出:
text
queued: true true overflow rejected: true
results: <nil> <nil> <nil>
drained: true
submit after close: true“一个正在跑、两个在等”是明确的容量边界。第三个等待者被拒绝,提交方必须选择报错、降级或稍后重试,而不是在池外再建一个无界 goroutine 等着提交。结果通道容量为 1,所以调用方已经放弃接收时,worker 仍能报告结果并结束。
关闭时先禁止提交并关闭队列,再等 worker 排空。TrySubmit 与关闭共享一把读写锁,避免关闭 channel 的同时另一个 goroutine 往里发送。Shutdown 的 context 到期只表示调用方不再等,不能杀掉不合作的任务;负例测试验证了这点,随后显式释放任务,让所有 worker 退出。任务已经在队列中但开始前被取消时,worker 直接返回取消错误,不再执行它。
这个小池要求 Run 不 panic,也不提供优先级、公平性、持久化和自动重试。是否 recover 不能只为了“程序不崩”:恢复后仍要将失败传给调用者,并保证业务状态和资源得到处理。生产采用库时,要核对这些契约,而不是只看 Submit 方法。
对象池:降低分配压力,但不保存业务事实
sync.Pool 存放可临时复用的对象。Go 文档允许池内任意对象在任何时候被移除,Get 也不保证取回刚放入的那个对象。因此它适合临时缓冲,不适合连接配额、会话存储或必须执行 Close 的资源。[5]
go
b := reuse.Get().(*bytes.Buffer)
b.WriteString("tenant-a")
b.Reset()
reuse.Put(b)
b = reuse.Get().(*bytes.Buffer)
fmt.Println("reused buffer length:", b.Len())实际输出:
text
reused buffer length: 0零长度来自归还前 Reset,或从 New 得到新空缓冲;这条输出不能证明对象一定被复用。bytes.Buffer.Reset 保留底层存储供后续写入,所以一个偶发的巨型缓冲可能继续占内存。可以按容量上限决定是否归还;上限应根据实际分配分布测量。[5][6]
取出对象之后由当前使用者独占;Put 以后原引用就不应再读写。若把 Buffer.Bytes() 的切片返回给调用者,再把 Buffer 归还,后来的使用者可能改写那段结果。需要跨过所有权边界就复制。Reset 也不是安全擦除:旧字节可能仍留在容量内,涉及秘密时要使用明确的清除和生命周期策略,不能从 Len 为零推出数据已经消失。[6]
批处理和合并请求,减少的是不同的重复
批处理把多个不同操作放进一次往返,例如批量查 100 个 ID;合并请求把并发的同一个操作只执行一次,再把结果分享给等待者,例如 100 个请求同时加载同一个商品。二者可以组合,但不能互换。
| 维度 | 批处理 | 在途请求合并 |
|---|---|---|
| 输入关系 | 多个不同项可以放在一起 | 键相同、可共享同一次结果 |
| 节约对象 | 固定网络/协议/事务成本 | 重复计算或重复回源 |
| 新增等待 | 等数量或计时器触发 | 等最先执行的那次调用 |
| 失败粒度 | 整批失败或逐项失败,要看接口 | 同键等待者可能一起收到失败 |
| 关键上限 | 项数、总字节、最长等待时间 | 同时在途键数、等待者数、执行时间 |
文末计数分批模型把五项按每批两项分组,并处理了最后不足一批的尾巴:
go
fmt.Println("batches:", Batch([]int{1, 2, 3, 4, 5}, 2))text
batches: [[1 2] [3 4] [5]]这个函数只演示数量分组和尾批,不是实时批处理器。实时系统还要加最长等待:只等凑满 100 项,低流量时可能永远不发;只设 10 毫秒计时器,却允许每项数 MB,内存仍会失控。建议先达到“项数、字节数、等待时间”任一条件就发送;关闭时处理尾批,并明确取消中的项目如何返回。
批量重试也要小心。如果服务端已经处理前 80 项,第 81 项失败,整批重发需要逐项幂等或协议保证的原子批事务。不要从“只发了一次 HTTP 请求”推出“所有项一起成功或失败”。批处理增加了共享失败范围,吞吐收益应和尾延迟、内存及重做工作一起衡量。
Go 的 singleflight.Group.Do 按 key 合并同时在途的调用,重复调用会等待并共享结果;它不是持久缓存,调用完成后的下一次仍可以重新执行。DoChan 让等待方自己用 select 处理取消,但等待方离开不代表底层共享操作自动取消。[7]
合并键必须包括影响响应的租户、权限和参数。把所有用户的“profile”合成一个键会产生越权风险。也不要直接拿第一个调用者即将到期的 context 控制所有等待者,除非业务接受一起失败;可以为共享工作设置独立、有限的生命周期,同时允许各等待者独立离开。共享结果如果含可变切片或 map,还要约定只读或复制。
异步化把响应变短,也把责任延长
用户点“导出”时,可以立即返回任务 ID,后台生成文件。用户等待 HTTP 响应的时间缩短了,但总计算量没有因此减少,系统还新增了任务存储、排队、重试和结果查询。
正在绘制图示…
查看图源代码
stateDiagram-v2
[*] --> Accepted: 任务已持久接收
Accepted --> Running: worker 取得任务
Running --> Succeeded: 结果已保存
Running --> RetryWait: 可恢复失败且预算允许
RetryWait --> Accepted: 延迟到期
Running --> Failed: 确定失败或预算用尽
Accepted --> Canceled: 取消在执行前生效
HTTP 202 表示请求被接受处理,并不保证最终成功。响应应给出任务 ID、状态查询入口以及必要的过期规则。若业务承诺“任务不会因进程重启丢失”,就不能只 go func() 或写入内存 channel 后返回接受;必须先达到承诺所需的持久化点。[8]
持久队列也可能重复投递,worker 的副作用要幂等。数据库更新后还要发消息时,用事务 outbox 等方案处理双写窗口。任务必须有最大执行时间、重试预算、失败去向和积压告警;排队长度与最老任务年龄,比“HTTP 返回很快”更能暴露后台是否赶得上。[9]
读写分离、缓存和预计算,要把新鲜度说清楚
这三种办法都可能减少读请求对主写路径的压力,但产生旧数据的原因不同。
| 方法 | 读的是哪里 | 旧数据从哪里来 | 适合的前提与补救 |
|---|---|---|---|
| 读写分离 | 数据库只读副本 | 复制和回放滞后 | 能容忍陈旧读;关键写后读走主库或等待指定复制位置 |
| 缓存 | 预先保存的查询结果 | 失效事件延迟、TTL、回源更新窗口 | 明确键、TTL、回源上限和旧值语义 |
| 预计算 | 定时或增量生成的派生结果 | 刷新周期、事件积压、计算失败 | 查询模式相对稳定;能接受刷新成本和模型变更 |
PostgreSQL 的 hot standby 在恢复期间提供只读查询,查询还可能与日志回放发生冲突并被取消。复制读副本并不自动承诺“刚写完马上读到”,应用必须决定一致性要求。一个常见选择是支付完成页等关键写后读使用主库,普通报表允许有标明时间点的副本结果;具体能否满足要求仍取决于数据库复制配置。[10]
缓存命中让请求绕过昂贵回源,失效后却可能让许多请求同时回源,因此要配合在途合并、并发上限和过期策略。缓存键要覆盖权限与查询语义;错误数据缓存得再快也没有价值。TTL 表达容忍多旧,不是正确性的自动证明;需要强一致结果时必须采用相应协议,或不缓存该结果。[11]
预计算把查询时的成本挪到刷新时。排行榜、日报、聚合统计可以通过物化视图或独立汇总表实现。PostgreSQL 物化视图保存查询结果,刷新前数据可能不是最新;这样查得更直接,但刷新、存储和数据修正都有成本。[12]
按增量事件更新汇总时,还要处理重复、乱序、迟到和历史修正。最好保留能重新计算的事实数据与版本信息,使新算法可以重建一份结果再切换。不能只保留一个持续累加的计数,然后期待任何错误都能用下一条事件自动抵消。
从锁竞争走向分段锁和原子操作
普通 Mutex 往往是最易核对的起点。锁内尽量只做必须原子的状态变化,把慢 I/O、日志格式化和无关计算移到外面;但移出前要保证需要的数据已经复制或仍受保护。Go 内存模型要求对并发共享数据建立同步关系,删掉锁不会让竞态自动变成“无锁算法”。[13]
分段锁按 key 的哈希把数据分到多个分片,不同分片可以并行操作。它适合大量分散键,但一个热点键仍落在同一个分片;遍历全局快照、跨分片转账等操作也不再自然原子。若必须同时取得多把锁,应规定统一顺序,否则会引入死锁。
| 做法 | 适合的共享状态 | 主要限制 |
|---|---|---|
| Mutex / RWMutex | 需要维护多个字段的一组不变量 | 临界区和读写比例决定争用,RWMutex 不保证更快 |
| 分段锁 | 按键独立更新的数据 | 热点倾斜、跨分片原子性、锁顺序 |
atomic 计数或 CAS | 一个值的原子更新,或仔细设计的状态转换 | 重试冲突、缓存行争用、ABA 等算法问题 |
| 不可变快照 + 原子发布指针 | 读多写少配置、路由表 | 写入要构造新快照;发布后不能继续修改旧共享内容 |
Go 的 sync/atomic 提供顺序一致的原子操作,但“用了 CAS”不能直接证明整个算法 lock-free 或 wait-free;这两个术语描述进展保证,涉及完整算法和运行条件。CAS 循环在热点下可能反复失败,分段锁则增加额外对象与哈希成本。选择应由正确性论证和同负载测量决定。[13][14]
最后,把每种优化都接回资源账本:省了几次往返,减少多少分配,新增多少排队,谁负责释放,失败时会重复多少工作。能回答这些问题,才知道优化是否真正帮助了用户请求。
验证边界
本次使用 Go 1.27.2、Linux/amd64。工作池的满队列拒绝、已取消任务不执行、关闭与提交并发、关闭等待取消、尾批复制,以及空缓冲的生命周期都已运行;go test、go test -race、go vet 通过。没有数据库、消息队列或生产缓存服务,因此没有验证任何真实连接池容量、复制延迟和端到端吞吐。文中的 fastJob 等短名称是局部说明,文末提供对应完整内联函数。
依据
- Go Diagnostics:profile、trace 等诊断入口。
- Go:Managing connections:DB 池与参数;Go 1.27.2 database/sql 源码:DBStats、资源归还与 context 方法。
- Go 1.27.2 net/http Client、Transport:复用及响应体管理。
- RFC 9113 §5:HTTP/2 流与连接。
- Go 1.27.2 sync.Pool:对象可被移除、Get 无匹配保证及适用范围。
- Go 1.27.2 bytes.Buffer:Reset 保留存储、Bytes 的别名和有效期。
- x/sync v0.24.0 singleflight:Do、DoChan 的重复调用抑制;该库未用于本次实验。
- RFC 9110 §15.3.3:202 Accepted。
- AWS:Transactional outbox pattern:业务与事件双写、重复消息。
- PostgreSQL 18 Hot Standby:只读查询和回放冲突。
- AWS Builders' Library:Caching challenges and strategies:缓存陈旧、回源和缓存故障的设计问题。
- PostgreSQL 18 Materialized Views:物化结果与刷新。
- Go Memory Model:同步与数据竞争;Go 1.27.2 sync/atomic 文档:原子操作顺序。
- Herlihy 1991:Wait-Free Synchronization:进展保证术语和同步算法背景;不据此声称本文池或 Go 运行时具备 wait-free 保证。
完整可复现实验
标准库,无网络和外部依赖。测试利用 channel 控制顺序,不使用实际高负载。Shutdown 不取消正在运行的任务,因此调用方必须保证任务可结束;文末负例最终会释放阻塞任务。
sh
go run .
go test -v ./...
go test -race ./...
go vet ./...go.mod
go
module example.com/concurrency
go 1.27.0main.go
代码块 · 119 行 · 点击收起 / 展开
go
package main
import (
"bytes"
"context"
"errors"
"fmt"
"sync"
)
var ErrFull = errors.New("queue full")
var ErrClosed = errors.New("pool closed")
type Job struct {
Ctx context.Context
Run func(context.Context) error
Result chan error
}
type Pool struct {
mu sync.RWMutex
closed bool
jobs chan Job
done chan struct{}
}
func NewPool(workers, queue int) *Pool {
if workers < 1 || queue < 0 {
panic("invalid pool size")
}
p := &Pool{jobs: make(chan Job, queue), done: make(chan struct{})}
var wg sync.WaitGroup
for range workers {
wg.Go(func() {
for j := range p.jobs {
err := j.Ctx.Err()
if err == nil {
err = j.Run(j.Ctx)
}
j.Result <- err
close(j.Result)
}
})
}
go func() { wg.Wait(); close(p.done) }()
return p
}
func (p *Pool) TrySubmit(ctx context.Context, f func(context.Context) error) (<-chan error, error) {
p.mu.RLock()
defer p.mu.RUnlock()
if p.closed {
return nil, ErrClosed
}
if err := ctx.Err(); err != nil {
return nil, err
}
j := Job{ctx, f, make(chan error, 1)}
select {
case p.jobs <- j:
return j.Result, nil
default:
return nil, ErrFull
}
}
func (p *Pool) Shutdown(ctx context.Context) error {
p.mu.Lock()
if !p.closed {
p.closed = true
close(p.jobs)
}
p.mu.Unlock()
select {
case <-p.done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// Batch demonstrates count-based grouping and final flush, not a timed live batcher.
func Batch(xs []int, n int) [][]int {
if n < 1 {
panic("positive batch size required")
}
var out [][]int
for len(xs) > 0 {
k := min(n, len(xs))
out = append(out, append([]int(nil), xs[:k]...))
xs = xs[k:]
}
return out
}
func main() {
ctx := context.Background()
p := NewPool(1, 2)
started := make(chan struct{})
release := make(chan struct{})
first, _ := p.TrySubmit(ctx, func(context.Context) error { close(started); <-release; return nil })
<-started
second, e2 := p.TrySubmit(ctx, func(context.Context) error { return nil })
third, e3 := p.TrySubmit(ctx, func(context.Context) error { return nil })
_, e4 := p.TrySubmit(ctx, func(context.Context) error { return nil })
fmt.Println("queued:", e2 == nil, e3 == nil, "overflow rejected:", errors.Is(e4, ErrFull))
close(release)
fmt.Println("results:", <-first, <-second, <-third)
fmt.Println("drained:", p.Shutdown(ctx) == nil)
_, err := p.TrySubmit(ctx, func(context.Context) error { return nil })
fmt.Println("submit after close:", errors.Is(err, ErrClosed))
batches := Batch([]int{1, 2, 3, 4, 5}, 2)
fmt.Println("batches:", batches)
var reuse = sync.Pool{New: func() any { return new(bytes.Buffer) }}
b := reuse.Get().(*bytes.Buffer)
b.WriteString("tenant-a")
b.Reset()
reuse.Put(b)
b = reuse.Get().(*bytes.Buffer)
fmt.Println("reused buffer length:", b.Len())
b.Reset()
reuse.Put(b)
}main_test.go
代码块 · 66 行 · 点击收起 / 展开
go
package main
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestCanceledQueuedJobAndShutdown(t *testing.T) {
p := NewPool(1, 1)
start := make(chan struct{})
release := make(chan struct{})
first, err := p.TrySubmit(context.Background(), func(context.Context) error { close(start); <-release; return nil })
if err != nil {
t.Fatal(err)
}
<-start
ctx, cancel := context.WithCancel(context.Background())
var ran atomic.Bool
result, err := p.TrySubmit(ctx, func(context.Context) error { ran.Store(true); return nil })
if err != nil {
t.Fatal(err)
}
cancel()
short, stop := context.WithCancel(context.Background())
stop()
if err := p.Shutdown(short); !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
if _, err := p.TrySubmit(context.Background(), func(context.Context) error { return nil }); !errors.Is(err, ErrClosed) {
t.Fatal(err)
}
close(release)
<-first
if err := <-result; !errors.Is(err, context.Canceled) || ran.Load() {
t.Fatal(err, ran.Load())
}
deadline, done := context.WithTimeout(context.Background(), time.Second)
defer done()
if err := p.Shutdown(deadline); err != nil {
t.Fatal(err)
}
}
func TestConcurrentCloseSubmit(t *testing.T) {
p := NewPool(2, 4)
var wg sync.WaitGroup
for range 100 {
wg.Go(func() { _, _ = p.TrySubmit(context.Background(), func(context.Context) error { return nil }) })
}
wg.Go(func() { _ = p.Shutdown(context.Background()) })
wg.Wait()
if err := p.Shutdown(context.Background()); err != nil {
t.Fatal(err)
}
}
func TestBatchOwnsCopiesAndFlushesTail(t *testing.T) {
xs := []int{1, 2, 3}
got := Batch(xs, 2)
xs[0] = 99
if len(got) != 2 || len(got[1]) != 1 || got[0][0] != 1 {
t.Fatal(got)
}
}