Go 项目实战:2 万条定时导出把内存打爆,errgroup+信号量 40 行改完,P99 从 12s 压到 900ms
发布时间:2026/10/29 2:17:54 锦皓数字建站

你好我是利威尔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}平峰期这代码跑得挺欢——日订单几千条几千个 goroutineGo 运行时扛得住。但大促后 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/heapHeapAlloc2 分钟涨了 3.4GBgoroutine 数峰值 21473MySQL 活跃连接打到max_connections。三个数指向同一个结论同时干活的东西太多了得有个上限。面试追问 Q为什么不用sync.WaitGroup加计数当并发控制AWaitGroup 只负责等大家干完不管同时几个在干。要自己数并发还得配 channel 当令牌那就是重新发明信号量。标准库里有现成的别自己造。面试追问 Qgoroutine 数量有上限吗2 万个算多吗A单个 goroutine 栈初始 2KBGo 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一行搞定并发上限importgolang.org/x/sync/errgroupg: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 兜住转成 errorWithContextrecover姿势一等你自己加 defer recover可测试SetLimit是显式参数压测时直接调不用改代码结构。面试追问 QSetLimit(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(contextfmtsyncgolang.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{returnExportPool{limit:limit,render:render}}// ExportAll 并发受控地渲染全部订单任一失败即取消其余任务并返回第一个错误func(p*ExportPool)ExportAll(ctx context.Context,orders[]Order)([][]byte,error){p.resultsmake([][]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{// 关键点 1ctx 取消时未启动的任务直接退出不白干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;i20000;i{ordersappend(orders,Order{ID:int64(i1),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 返回结果再合并Ag.Go的闭包不返回值返回的是 error结果要么走 channel 汇总要么写预分配槽位。2 万个元素走 channel 汇总channel 本身就是热点预分配 slice 每槽位唯一写者锁只保护下标边界实际竞争接近于零。数据量大时这个差别能进 pprof。面试追问 Q如果单条render内部还有慢查询一条 500msSetLimit(100)还够吗A这是吞吐账100 并发 × (1/0.5s) 200 条/s2 万条要 100 秒。要么调大并发受 DB 连接池限制要么批量查把 100 条订单的查询合并成WHERE id IN (...)一次捞回慢查询从 2 万次变 200 次。后者的收益通常大一个数量级——先合并 IO再调并发。五、第四刀分批与流式把内存曲线拉平并发上限解决同时多少人但 2 万条订单全渲染完再统一写文件results数组照样吃 2 万份 buffer。所以再加一刀——分批消费渲染完一批写一批// 分批消费每 500 条一批渲染完立即落盘/推送results 不整体驻留内存constbatchSize500func(p*ExportPool)ExportStream(ctx context.Context,orders[]Order,sinkfunc(batch[][]byte)error)error{g,ctx:errgroup.WithContext(ctx)g.SetLimit(p.limit)forstart:0;startlen(orders);startbatchSize{end:startbatchSizeifendlen(orders){endlen(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 片段约 2KB500 条约 1MB一批的常驻内存可以忽略如果单条是 1MB 的图片500 批就是 500MB得缩到 50-100。批大小 × 单条大小 × 在途批数 ≈ 你的内存预算这个公式比任何经验值都靠谱。面试追问 Q流式之后失败重试的语义变了吗A变了要显式设计。整体失败时“重试哪些从全部变成失败的批 批内失败的条”。所以sink和render的错误都要带订单号上面代码里fmt.Errorf(render order %d: %w, ...)就是为这个重试接口按订单号集合走别按第几批走——批次是内存概念订单号才是业务概念。六、改造效果三个数字同一个任务、同一批 2 万条数据改造前后监控出处Grafana大促后周一 05:00 场指标改造前改造后峰值内存4.1GBOOM ×3612MB平稳P99 耗时12.4s890msMySQL 活跃连接峰值20480打满97内存降 85%P99 降 93%DB 连接从打满回到不慌。代码净增约 40 行ExportPool一个结构体没有引入任何新依赖。面试追问 QP99 降 93% 只是因为并发上限吗A不止是三把刀叠乘并发上限让 DB 不再排队慢查询从排队变直达、独立槽位干掉了 buffer 锁竞争原来 2 万个 goroutine 抢一把锁、分批让 GC 压力骤降对象存活时间从整个任务周期缩到一个批次周期。只上第一把刀P99 大约降到 3s 量级三把齐上才是 890ms。并发问题从来不是单点优化。七、 本章面试要点go func()是并发放任不是并发控制并发上限是显式设计errgroup.SetLimit是 Go 1.25 的标准答案。定位内存飙高三件套HeapAlloc曲线、goroutine 数、下游连接池占用——三个一起看别只看内存。独立槽位 轻锁 共享 buffer 重锁结果合并优先预分配 slice别用 channel 当热点。errgroup.WithContext管错误传播和取消传播任一失败不陪葬整批错误信息必须带业务主键订单号。大任务用分批流式批大小 × 单条大小 × 在途批数 ≈ 内存预算。先合并 IOIN查询批量捞再调并发收益通常大一个数量级。升配是止痛药数据量涨 N 倍内存涨 N 倍的方案没有第七次大促。写在最后留一个具体问题你的 Go 服务里现在能找到同时最多有多少个 goroutine 在跑同一段逻辑的那个数字吗如果找不到runtime.NumGoroutine()加一条监控曲线跑一天峰值贴出来——欢迎评论区贴你的数字我挑几个有代表性的拆解。如果这篇帮你把并发失控怎么收理顺了建议收藏——第四节的ExportPool和第五节的ExportStream下次直接抄比出故障时现翻 errgroup 文档快得多。下篇预告「项目实战」系列第三篇——Go 定时任务的正确姿势从cron 起了 3 个实例同一个任务跑了 3 遍讲起拆分布式锁选型etcd/Redis/MySQL 各自踩什么坑、任务分片与失败重试的幂等设计。作者利威尔xu | CSDN 专栏《项目实战》
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。