Skip to content

高并发手段:减少重复工作,并给共享资源设上限 ​

接口慢了,第一反应常是多开 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把等待和驱动支持的取消接入 contextcontext 是否传到真正的调用,驱动具体支持

池大小还要乘实例数。每个实例 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[排空后关闭完成信号]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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: 取消在执行前生效
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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 等短名称是局部说明,文末提供对应完整内联函数。

依据 ​

  1. Go Diagnostics:profile、trace 等诊断入口。
  2. Go:Managing connections:DB 池与参数;Go 1.27.2 database/sql 源码:DBStats、资源归还与 context 方法。
  3. Go 1.27.2 net/http Client、Transport:复用及响应体管理。
  4. RFC 9113 §5:HTTP/2 流与连接。
  5. Go 1.27.2 sync.Pool:对象可被移除、Get 无匹配保证及适用范围。
  6. Go 1.27.2 bytes.Buffer:Reset 保留存储、Bytes 的别名和有效期。
  7. x/sync v0.24.0 singleflight:Do、DoChan 的重复调用抑制;该库未用于本次实验。
  8. RFC 9110 §15.3.3:202 Accepted。
  9. AWS:Transactional outbox pattern:业务与事件双写、重复消息。
  10. PostgreSQL 18 Hot Standby:只读查询和回放冲突。
  11. AWS Builders' Library:Caching challenges and strategies:缓存陈旧、回源和缓存故障的设计问题。
  12. PostgreSQL 18 Materialized Views:物化结果与刷新。
  13. Go Memory Model:同步与数据竞争;Go 1.27.2 sync/atomic 文档:原子操作顺序。
  14. 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.0

main.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)
	}
}

保留各书来源与许可 · 示例运行条件以正文为准