文章总结: 本文讨论了Go语言中goroutine数量激增导致数据库连接池超时的问题,并提出了一个简单的固定worker数量任务池解决方案。该方案通过固定worker数量、任务队列和非阻塞提交来限制并发,避免过载。文章强调了监控队列长度、拒绝次数和任务耗时的重要性,并提供了panic恢复机制。核心在于将过载变为可观察、可处理的结果,而非单纯复用goroutine。 综合评分: 87 文章分类: 实战经验
Go 里一个简单的 goroutine 资源池,到底解决了什么问题
原创
go go
Go语言教程
2026年7月22日 13:21 陕西
在小说阅读器读本章
去阅读
接口刚开始跑得挺稳,流量一上来,goroutine 数量直接从几百涨到几万。
日志里没有明显报错,CPU 也没打满,但数据库连接池开始超时:
sql: database is closed
context deadline exceeded
db pool wait_count=18432
goroutines=37641
这种问题我一般不会先去怀疑 SQL。goroutine 都堆成这样了,再快的 SQL 也架不住几万个任务同时往数据库连接池里挤。
代码通常长这样:
for _, orderID := range orderIDs {
go syncOrder(orderID)
}
写起来很舒服,每个任务扔一个 goroutine。数据量几十条时没问题,换成几万条订单,这段代码就开始不讲武德了。
goroutine 确实比操作系统线程轻,但“轻”不等于没有成本。它要占栈空间,要被调度,任务里还会继续占数据库连接、HTTP 连接、文件句柄。真正把服务拖垮的,很多时候不是 goroutine 自己,而是它后面挂着的那些资源。
这时候需要的不是更快地创建 goroutine,而是给并发踩一脚刹车。
我平时写这种简单任务池,不会一上来搞复杂的动态扩缩容。先把三件事做对:
固定 worker 数量,任务统一进入队列,程序退出时能把 worker 收干净。
代码可以压到下面这个程度:
package taskpool
import (
"context"
"errors"
"log"
"sync"
)
var ErrPoolBusy = errors.New("goroutine pool is busy")
type Job func(context.Context)
type Pool struct {
jobs chan Job
wg sync.WaitGroup
cancel context.CancelFunc
}
func New(parent context.Context, workers, queueSize int) *Pool {
ctx, cancel := context.WithCancel(parent)
p := &Pool{
jobs: make(chan Job, queueSize),
cancel: cancel,
}
for workerID := 0; workerID < workers; workerID++ {
p.wg.Add(1)
go p.runWorker(ctx, workerID)
}
return p
}
func (p *Pool) runWorker(ctx context.Context, workerID int) {
defer p.wg.Done()
for {
select {
case <-ctx.Done():
return
case job, ok := <-p.jobs:
if !ok {
return
}
func() {
defer func() {
if err := recover(); err != nil {
log.Printf("worker=%d panic=%v", workerID, err)
}
}()
job(ctx)
}()
}
}
}
func (p *Pool) Submit(job Job) error {
if job == nil {
return errors.New("job is nil")
}
select {
case p.jobs <- job:
return nil
default:
return ErrPoolBusy
}
}
func (p *Pool) Close() {
p.cancel()
p.wg.Wait()
}
这个池子没有多少花活。创建时启动固定数量的 worker,每个 worker 都阻塞在 jobs 通道上。任务来了就取一个执行,没有任务就等着,不会继续创建新的 goroutine。
这里我特意把 Submit 写成了非阻塞提交。
select {
case p.jobs <- job:
return nil
default:
return ErrPoolBusy
}
队列满了直接返回错误,不让调用方无限卡住。这个取舍不一定适合所有业务,但在线接口里,我更愿意明确拒绝,也不愿意把请求悄悄堆在内存里。
无限队列看起来不丢任务,实际上只是把故障往后拖。任务积压十几分钟后,数据可能早就过期了,内存倒是先吃满。
接到订单同步业务里,大概这样用:
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
pool := taskpool.New(ctx, 12, 200)
defer pool.Close()
for _, id := range pendingOrderIDs {
orderID := id
err := pool.Submit(func(ctx context.Context) {
if err := syncOrder(ctx, orderID); err != nil {
log.Printf("sync order failed, id=%d err=%v", orderID, err)
}
})
if errors.Is(err, taskpool.ErrPoolBusy) {
log.Printf("drop sync task, id=%d reason=pool_busy", orderID)
}
}
orderID := id 这一行别嫌多余。循环变量捕获问题虽然在新版 Go 中已经有所改善,但线上项目未必都统一版本,而且这种关键任务我不喜欢依赖别人记得版本差异。多写一行,代码意图也更清楚。
worker 数量也不能拍脑袋。
如果任务主要做计算,可以从 CPU 核数附近开始。如果任务大部分时间在等数据库或者远程接口,可以适当增加。但我更关心下游能扛多少。
数据库连接池最大只有 20 个连接,worker 开 200 个没有意义。最后还是 180 个 goroutine 堵在取连接上,监控里看着并发很高,实际全在排队。
任务池上线后,至少要盯两个值:
log.Printf(
"pool queue=%d capacity=%d goroutines=%d",
len(poolQueue),
cap(poolQueue),
runtime.NumGoroutine(),
)
实际项目里我会把队列长度、拒绝次数、任务耗时和执行失败数打到监控。只看 goroutine 数量不够,队列长期接近满载,说明 worker 太少、任务太慢,或者上游提交速度已经超过系统处理能力。
还有个容易被忽略的地方:任务里的 panic 必须兜住。
某个任务 panic 如果没人恢复,整个进程可能直接退出。资源池本来是想保护服务,结果一个脏数据把服务带走,那就有点难看了。所以 worker 执行任务时,至少做一层 recover,同时把任务标识和堆栈打出来。
这个简单资源池解决的并不是“复用 goroutine”这么表面的事情。
它真正限制的是系统同一时刻允许执行多少任务,并且把过载变成可观察、可处理的结果。队列满了就拒绝,任务慢了能从监控看到,服务关闭时 worker 也能退出。
至于动态扩容、任务优先级、超时回收,可以后面再加。一个连并发上限和退出流程都没处理好的池子,功能做得再全,我也不太敢往线上放。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:Go语言教程 go go《Go 里一个简单的 goroutine 资源池,到底解决了什么问题》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论