你好,我是利威尔xu。
这是「项目实战」系列的第二篇。上一篇我们聊了 Go 项目骨架 25 个目录只用 9 个,讲的是"代码放哪";这一篇换一个更疼的话题——并发怎么收:一个每天跑一次的定时导出任务,怎么把 4GB 内存的服务打到 OOM 重启,又怎么用 40 行改动把 P99 从 12s 压到 900ms。
点名一下受众:写过for循环起 goroutine、见过"意料之外"的内存飙高、却从没压测过后端服务的 Go 后端同学。这篇给你一套能直接抄走的并发控制范式,和一次完整故障的定位过程。
建议先收藏——第四节是完整可运行的代码(Go 1.25+,零第三方依赖),下次你的服务被并发打爆,把里面的ExportPool结构体直接搬走,比临时go func()撒一发强得多。(新读者可以先看置顶的学习路线再决定从哪篇入坑。)
文章目录
- 一、开场:一次周五晚上的 OOM
- 先建一个直觉比喻:食堂打饭
- 二、第一刀:为什么先杀 goroutine 数量,而不是先加内存
- 三、第二刀:信号量的两种姿势,选哪个
- 四、第三刀:完整可运行代码(Go 1.25+,零第三方依赖)
- 五、第四刀:分批与流式,把内存曲线拉平
- 六、改造效果:三个数字
- 七、💡 本章面试要点
- 写在最后
一、开场:一次周五晚上的 OOM
先讲一个我身边真实发生过的故障(细节做了脱敏)。一个数据报表服务,每天凌晨跑一次定时任务:把前一天的全部订单导出成 Excel,推送到运营后台。平时日订单量几千条,一直相安无事;直到某个大促后的周一早上,运维在群里甩了一张监控图——服务内存从 600MB 飙到 4GB,容器被 OOM Kill,连续重启了 3 次,导出任务一条都没跑完。
第一反应是"订单量涨了",一看:凌晨峰值 5 分钟涌进来 2 万多条,是平峰的 7 倍。代码长这样,你肯定眼熟:
// 出事前的代码:图省事,一条订单一个 goroutinefunc(s*ExportService)ExportAll(ctx context.Context,orders[]Order)error{varwg sync.WaitGroupfor_,o:=rangeorders{wg.Add(1)gofunc(o Order){deferwg.Done()s.renderOne(ctx,o)// 每条订单:查库、拼模板、写共享 buffer}(o)}wg.Wait()returnnil}平峰期这代码跑得挺欢——日订单几千条,几千个 goroutine,Go 运行时扛得住。但大促后 2 万条同时涌进来,同时 2 万个 goroutine 在查库、拼模板、往同一个共享 buffer 里写,内存直接被打穿。
这个案例是本文的主线。它说明一件事:go func()不是并发控制,是并发放任——你把"同时跑多少个"这个最重要的决定,交给了调用方的数据量。
先建一个直觉比喻:食堂打饭
后面全是术语,先用一个比喻把地图立起来。把并发任务想象成食堂打饭:
- goroutine= 排队打饭的人,来多少人都能排,队伍可以无限长;
- 共享 buffer= 唯一的打饭窗口,人再多,窗口只有一个,全堵在窗口前;
- 内存= 食堂面积,人多了转个身都困难,最后踩踏;
- 信号量(semaphore)= 门口保安:“里面同时最多 100 人,出来一个放一个进去”;
- errgroup= 值班经理:盯着哪个窗口出餐出错,出错就吹哨收工,不让整食堂陪葬。
今天要讲的,就是怎么给"无限排队的食堂"配上保安和值班经理。管理学的一句话:队伍可以长,但同时干活的人必须有个上限。
二、第一刀:为什么先杀 goroutine 数量,而不是先加内存
故障定位的第一步,运维同学提议"内存升到 8GB"。我拦住了——升配是止痛药,不是手术。理由很直接:
- 数据量涨到 7 倍,内存就得涨 7 倍,明年呢?后年呢?
- 就算 8GB 能扛住,2 万个 goroutine 同时打 MySQL,连接池先崩,故障换了个形态而已;
- 共享 buffer 的写冲突是数据竞争,内存再大也治不了。
先看的三个数(都是runtime.MemStats和 pprof 直接读的,出处:服务/debug/pprof/heap):HeapAlloc2 分钟涨了 3.4GB;goroutine 数峰值 21473;MySQL 活跃连接打到max_connections。三个数指向同一个结论:同时干活的东西太多了,得有个上限。
面试追问 Q:为什么不用sync.WaitGroup加计数当并发控制?
A:WaitGroup 只负责"等大家干完",不管"同时几个在干"。要自己数并发,还得配 channel 当令牌,那就是重新发明信号量。标准库里有现成的,别自己造。
面试追问 Q:goroutine 数量有上限吗?2 万个算多吗?
A:单个 goroutine 栈初始 2KB(Go 1.19+ 起动态增长),2 万个理论上只占 40MB 栈内存——光看栈根本不多。问题是每个 goroutine 干的事:查库占连接、拼模板占堆内存、写 buffer 占锁。goroutine 数是表面现象,每个 goroutine 背后的资源才是真成本。
三、第二刀:信号量的两种姿势,选哪个
给并发装"保安",Go 里有两条路:
姿势一:channel 令牌桶——经典写法,缓冲 channel 当令牌池:
// 经典令牌桶:buffered channel 当信号量sem:=make(chanstruct{},100)// 最多 100 个并发for_,o:=rangeorders{sem<-struct{}{}// 拿令牌,满了就阻塞在这gofunc(o Order){deferfunc(){<-sem}()// 还令牌s.renderOne(ctx,o)}(o)}姿势二:golang.org/x/sync/errgroup的SetLimit——Go 1.25 时代的标准答案,errgroup.Group原生支持并发上限和错误传播:
// errgroup + SetLimit:一行搞定并发上限import"golang.org/x/sync/errgroup"g:=new(errgroup.Group)g.SetLimit(100)// 同时最多 100 个for_,o:=rangeorders{g.Go(func()error{returns.renderOne(ctx,o)})}iferr:=g.Wait();err!=nil{returnerr// 任一失败,Wait 返回第一个错误}我选姿势二,三个理由:
- 错误传播是内置的:姿势一要自己收集 error,稍不注意就写成"出错但 wg.Wait() 照常返回 nil",故障静默;
- panic 不会炸进程:
g.Go的 goroutine panic 会被 errgroup 兜住转成 error(WithContext+recover),姿势一等你自己加 defer recover; - 可测试:
SetLimit是显式参数,压测时直接调,不用改代码结构。
面试追问 Q:SetLimit(100)是"最多 100 个在跑"还是"最多 100 个在排队"?
A:最多 100 个同时在跑。超出的g.Go调用会阻塞在提交处(Go 1.21+ 语义,旧版 x/sync 是 panic/无限增长,版本差异要注意),g.Wait()等全部结束。排队的是调用方这个 for 循环,队列长度取决于数据量。
面试追问 Q:并发上限 100 这个数怎么定?
A:压测定,不拍脑袋。压测看三个指标的交叉点:内存平稳、MySQL 连接池占用 <70%、P99 不再随并发数下降。我们的导出任务 100 是这么测出来的——换成 CPU 密集型任务,这个数应该贴近GOMAXPROCS,理由见下一篇 GMP 系列(Go GMP 调度器的 10 个高频追问)。
四、第三刀:完整可运行代码(Go 1.25+,零第三方依赖)
光有并发上限只解决了一半——共享 buffer 的写冲突还在。下面是修完之后的完整实现,ExportPool可以直接搬进你的项目:
// export_pool.go —— 并发受控的批量导出池(Go 1.25+,零第三方依赖)// 用法见文末 main 示例;核心思想:errgroup 控并发 + 每 goroutine 独立 buffer,杜绝共享写。packagemainimport("context""fmt""sync""golang.org/x/sync/errgroup")// Order 一条待导出的订单(示例结构)typeOrderstruct{IDint64Amountint64Statusstring}// ExportPool 并发受控的导出池typeExportPoolstruct{limitint// 并发上限renderfunc(ctx context.Context,o Order)([]byte,error)// 单条渲染逻辑(注入,方便测试)results[][]byte// 每 goroutine 独立写自己的槽位,无共享冲突mu sync.Mutex// 只保护 results 下标分配,不保护渲染过程}// NewExportPool 创建导出池;render 为单条订单的渲染函数(查库/拼模板都在这里)funcNewExportPool(limitint,renderfunc(context.Context,Order)([]byte,error))*ExportPool{return&ExportPool{limit:limit,render:render}}// ExportAll 并发受控地渲染全部订单;任一失败即取消其余任务并返回第一个错误func(p*ExportPool)ExportAll(ctx context.Context,orders[]Order)([][]byte,error){p.results=make([][]byte,len(orders))g,ctx:=errgroup.WithContext(ctx)g.SetLimit(p.limit)fori,o:=rangeorders{i,o:=i,o// Go 1.22+ 循环变量语义已修,这行仅为兼容旧代码习惯g.Go(func()error{// 关键点 1:ctx 取消时未启动的任务直接退出,不白干iferr:=ctx.Err();err!=nil{returnerr}// 关键点 2:渲染结果写进自己的槽位,多个 goroutine 零共享b,err:=p.render(ctx,o)iferr!=nil{returnfmt.Errorf("render order %d: %w",o.ID,err)}p.mu.Lock()p.results[i]=b// 每槽位唯一写者,锁竞争可忽略p.mu.Unlock()returnnil})}// 关键点 3:任一失败,WithContext 取消其余任务;Wait 返回第一个错误iferr:=g.Wait();err!=nil{returnnil,err}returnp.results,nil}funcmain(){ctx:=context.Background()orders:=make([]Order,0,20000)fori:=0;i<20000;i++{orders=append(orders,Order{ID:int64(i+1),Amount:int64(i*100),Status:"PAID"})}pool:=NewExportPool(100,func(ctx context.Context,o Order)([]byte,error){// 生产里这里是查库 + 拼模板;示例直接造一段字节return[]byte(fmt.Sprintf("订单 %d | 金额 %d | 状态 %s\n",o.ID,o.Amount,o.Status)),nil})results,err:=pool.ExportAll(ctx,orders)iferr!=nil{fmt.Println("导出失败:",err)return}fmt.Printf("导出完成: %d 条, 首条=%s\n",len(results),results[0])}三个"关键点"就是这次故障的三把刀,建议对照着抄:ctx 取消检查(上游超时/取消时别再白干)、独立槽位(干掉共享 buffer 竞争)、WithContext 错误传播(一个坏订单不再陪葬整批)。
面试追问 Q:为什么用"独立槽位 + 轻锁",不直接每个 goroutine 返回结果再合并?
A:g.Go的闭包不返回值(返回的是 error),结果要么走 channel 汇总,要么写预分配槽位。2 万个元素走 channel 汇总,channel 本身就是热点;预分配 slice + 每槽位唯一写者,锁只保护下标边界,实际竞争接近于零。数据量大时这个差别能进 pprof。
面试追问 Q:如果单条render内部还有慢查询(一条 500ms),SetLimit(100)还够吗?
A:这是吞吐账:100 并发 × (1/0.5s) = 200 条/s,2 万条要 100 秒。要么调大并发(受 DB 连接池限制),要么批量查:把 100 条订单的查询合并成WHERE id IN (...)一次捞回,慢查询从 2 万次变 200 次。后者的收益通常大一个数量级——先合并 IO,再调并发。
五、第四刀:分批与流式,把内存曲线拉平
并发上限解决"同时多少人",但 2 万条订单全渲染完再统一写文件,results数组照样吃 2 万份 buffer。所以再加一刀——分批消费,渲染完一批写一批:
// 分批消费:每 500 条一批,渲染完立即落盘/推送,results 不整体驻留内存constbatchSize=500func(p*ExportPool)ExportStream(ctx context.Context,orders[]Order,sinkfunc(batch[][]byte)error)error{g,ctx:=errgroup.WithContext(ctx)g.SetLimit(p.limit)forstart:=0;start<len(orders);start+=batchSize{end:=start+batchSizeifend>len(orders){end=len(orders)}batch:=orders[start:end]// 每批一个独立结果槽,批次之间顺序执行 sinkg.Go(func()error{results:=make([][]byte,len(batch))varinner errgroup.Group inner.SetLimit(p.limit)forj,o:=rangebatch{j,o:=j,o inner.Go(func()error{b,err:=p.render(ctx,o)iferr!=nil{returnerr}results[j]=breturnnil})}iferr:=inner.Wait();err!=nil{returnerr}returnsink(results)// 一批渲染完立即消费:写文件 / 推送 / 发消息})}returng.Wait()}改造后的内存曲线:从"锯齿状冲到 4GB"变成"平稳的 600MB 平台"——因为任何时刻驻留的只有"在途的几批 × 每批 500 条",跟总量 2 万还是 20 万没关系。
面试追问 Q:分批大小 500 怎么定?
A:看单条渲染结果的大小。我们单条 Excel 片段约 2KB,500 条约 1MB,一批的常驻内存可以忽略;如果单条是 1MB 的图片,500 批就是 500MB,得缩到 50-100。批大小 × 单条大小 × 在途批数 ≈ 你的内存预算,这个公式比任何"经验值"都靠谱。
面试追问 Q:流式之后,失败重试的语义变了吗?
A:变了,要显式设计。整体失败时,“重试哪些"从"全部"变成"失败的批 + 批内失败的条”。所以sink和render的错误都要带订单号(上面代码里fmt.Errorf("render order %d: %w", ...)就是为这个),重试接口按订单号集合走,别按"第几批"走——批次是内存概念,订单号才是业务概念。
六、改造效果:三个数字
同一个任务、同一批 2 万条数据,改造前后(监控出处:Grafana,大促后周一 05:00 场):
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 峰值内存 | 4.1GB(OOM ×3) | 612MB(平稳) |
| P99 耗时 | 12.4s | 890ms |
| MySQL 活跃连接峰值 | 20480(打满) | 97 |
内存降 85%,P99 降 93%,DB 连接从"打满"回到"不慌"。代码净增约 40 行(ExportPool一个结构体),没有引入任何新依赖。
面试追问 Q:P99 降 93% 只是因为并发上限吗?
A:不止,是三把刀叠乘:并发上限让 DB 不再排队(慢查询从排队变直达)、独立槽位干掉了 buffer 锁竞争(原来 2 万个 goroutine 抢一把锁)、分批让 GC 压力骤降(对象存活时间从"整个任务周期"缩到"一个批次周期")。只上第一把刀,P99 大约降到 3s 量级;三把齐上才是 890ms。并发问题从来不是单点优化。
七、💡 本章面试要点
go func()是并发放任不是并发控制;并发上限是显式设计,errgroup.SetLimit是 Go 1.25 的标准答案。- 定位内存飙高三件套:
HeapAlloc曲线、goroutine 数、下游连接池占用——三个一起看,别只看内存。 - 独立槽位 + 轻锁 > 共享 buffer + 重锁;结果合并优先预分配 slice,别用 channel 当热点。
errgroup.WithContext管错误传播和取消传播,任一失败不陪葬整批;错误信息必须带业务主键(订单号)。- 大任务用分批流式:批大小 × 单条大小 × 在途批数 ≈ 内存预算。
- 先合并 IO(
IN查询批量捞),再调并发;收益通常大一个数量级。 - 升配是止痛药:数据量涨 N 倍内存涨 N 倍的方案,没有第七次大促。
写在最后
留一个具体问题:你的 Go 服务里,现在能找到"同时最多有多少个 goroutine 在跑同一段逻辑"的那个数字吗?如果找不到,runtime.NumGoroutine()加一条监控曲线,跑一天峰值贴出来——欢迎评论区贴你的数字,我挑几个有代表性的拆解。
如果这篇帮你把"并发失控怎么收"理顺了,建议收藏——第四节的ExportPool和第五节的ExportStream下次直接抄,比出故障时现翻 errgroup 文档快得多。
下篇预告:「项目实战」系列第三篇——Go 定时任务的正确姿势:从"cron 起了 3 个实例,同一个任务跑了 3 遍"讲起,拆分布式锁选型(etcd/Redis/MySQL 各自踩什么坑)、任务分片与失败重试的幂等设计。
作者:利威尔xu | CSDN 专栏《项目实战》