文章总结: 本文针对Go程序因无限制创建goroutine导致OOM的问题,提出使用协程池控制并发量的解决方案。通过代码示例展示如何实现具备超时控制、队列管理和panic恢复的协程池,并强调需根据任务类型配置worker数量、监控队列长度及制定满队列处理策略。 综合评分: 85 文章分类: 安全开发,解决方案,实战经验,应用安全,安全工具
Goroutine 太多导致 OOM?是时候给你的 Go 程序加上协程池了
原创
go go
Go语言教程
2026年6月28日 13:29 陕西
在小说阅读器读本章
去阅读
线上机器被 OOMKilled,第一眼别急着怀疑 Go 的 GC。
我一般先看两个东西:内存曲线是不是一路往上拱,goroutine 数是不是也跟着涨。要是这俩一起涨,业务代码里八成有这种东西:
for _, orderID := range orderIDs {
go func(id int64) {
err := syncOrderToWarehouse(ctx, id)
if err != nil {
log.Printf("sync order failed, order_id=%d err=%v", id, err)
}
}(orderID)
}
这段代码看着挺 Go。
来一个订单,起一个 goroutine;来一批订单,起一批 goroutine。开发环境跑 100 条没事,测试环境跑 1000 条也没事,到了线上导入几十万条订单,机器直接开始喘。
日志大概会长这样:
runtime: goroutine stack exceeds limit
k8s: container OOMKilled
goroutines=182394 heap=3.8GB next_gc=4.6GB
这时候再去调 GC 参数,基本属于绕远路。
goroutine 确实轻,但不是不要钱。每个 goroutine 都有栈,栈会扩容;阻塞在 HTTP、DB、Redis、MQ 上的 goroutine,也都要被 runtime 管着。数量一多,内存顶上去,调度也开始乱。更烦的是,下游接口本来就慢,你还一口气打过去几万并发,下游没扛住,你这边也跟着堆死。
我更愿意把这种逻辑改成协程池。不是为了显得高级,就是把“无限创建”改成“有上限排队”。
下面这段是我现场会写的版本,不追求框架感,够用、能停、能看队列压力。
package worker
import (
"context"
"errors"
"log"
"sync"
"time"
)
type OrderJob struct {
OrderID int64
Trace string
}
type Pool struct {
jobs chan OrderJob
wg sync.WaitGroup
}
func NewPool(workerNum int, queueSize int, handle func(context.Context, OrderJob) error) *Pool {
p := &Pool{
jobs: make(chan OrderJob, queueSize),
}
for i := 0; i < workerNum; i++ {
workerID := i + 1
p.wg.Add(1)
go func() {
defer p.wg.Done()
for job := range p.jobs {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
err := handle(ctx, job)
cancel()
if err != nil {
log.Printf(
"order sync failed worker=%d order_id=%d trace=%s err=%v",
workerID, job.OrderID, job.Trace, err,
)
}
}
}()
}
return p
}
func (p *Pool) Submit(ctx context.Context, job OrderJob) error {
select {
case p.jobs <- job:
return nil
case <-ctx.Done():
return errors.New("submit order job timeout")
}
}
func (p *Pool) Stop() {
close(p.jobs)
p.wg.Wait()
}
用的时候别一股脑塞。入口这里也要有超时,不然队列满了,请求线程全卡住,最后还是换个姿势把自己拖死。
func SyncOrders(ctx context.Context, ids []int64, trace string) {
pool := NewPool(32, 2000, func(ctx context.Context, job OrderJob) error {
return syncOrderToWarehouse(ctx, job.OrderID)
})
defer pool.Stop()
for _, id := range ids {
submitCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
err := pool.Submit(submitCtx, OrderJob{
OrderID: id,
Trace: trace,
})
cancel()
if err != nil {
log.Printf("drop order sync job order_id=%d trace=%s err=%v", id, trace, err)
continue
}
}
}
这地方我不太喜欢写成“有多少任务开多少 goroutine”。因为线上不是只跑你这一个功能。一个批量同步占满 CPU、内存、连接池,旁边的支付回调、库存扣减、订单查询全会受影响。
协程池真正挡住的是三件事。
第一,挡住 goroutine 数量。比如 workerNum 是 32,那同一时刻真正干活的就是 32 个,不会突然冒出几万个。
第二,挡住下游压力。DB 连接池就 50 个,你开 5000 个 goroutine 也没意义,最后都是排队抢连接。还不如在自己进程里老老实实排队。
第三,挡住内存峰值。任务先进 channel,队列满了就超时或者丢弃,至少系统还活着。系统活着,后面才有补偿、重试、人工处理的机会。
worker 数怎么配?
这个别背公式。CPU 密集型,别超过 CPU 核数太多;IO 密集型,可以放大一点,但要看下游连接池和接口耗时。比如同步订单要访问仓库接口,接口 p95 已经几百毫秒,那 worker 开太大,只会把对方打慢。
我一般还会加一条监控,至少把队列长度打出来:
func (p *Pool) QueueLen() int {
return len(p.jobs)
}
然后日志里定时打一行:
log.Printf("order_sync_pool queue_len=%d", pool.QueueLen())
别嫌土。真出问题的时候,这种日志比一堆漂亮封装管用。
协程池也不是银弹。
任务必须能接受排队,不能是用户请求里必须立刻完成的强一致动作。队列满了之后怎么处理,也要提前定:是返回繁忙,是落库补偿,还是直接丢弃低优先级任务。这个不定清楚,协程池只是把 OOM 改成了请求超时。
还有一点,panic 要兜住。线上 worker 直接 panic 掉,池子少一个工人,时间长了也会慢慢死。
defer func() {
if r := recover(); r != nil {
log.Printf("order sync worker panic worker=%d err=%v", workerID, r)
}
}()
我排这种问题,一般顺序很固定:先看 goroutine 数,再看堆内存,再看阻塞点,最后才看 GC。要是发现 goroutine 大量卡在 HTTP 请求、DB 查询、Redis 读写上,先别优化算法,先把并发上限收回来。
Go 给了我们很便宜的 goroutine,但便宜不是免费。
线上程序最怕的不是慢一点,是没有边界。协程池干的就是这件事:给并发画一条线,超过这条线就排队、超时、降级,别让它把整个进程拖进 OOM。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:Go语言教程 go go《Goroutine 太多导致 OOM?是时候给你的 Go 程序加上协程池了》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。








评论