
Go 生产服务避坑清单goroutine 泄漏、内存泄漏和死锁基础设施不需要漂亮话。Go 的并发模型简洁优雅但简洁不代表安全。过去一年我在生产环境排查了 20 起并发相关故障其中 goroutine 泄漏占 8 起、内存泄漏占 6 起、死锁占 4 起。这三个问题有一个共同特征服务不会立刻崩溃而是慢慢变慢、慢慢变大直到某天突然 OOM 或响应超时。这篇文章把三类问题的排查思路和典型模式列出来每个都附带代码示例和解决方案。一、背景Go 并发问题的隐蔽性Go 的 goroutine 很轻创建成本约 2KB 栈空间。这让人觉得goroutine 很便宜随便开。但 2KB × 100 万 2GBgoroutine 泄漏不报错、不崩溃只是内存慢慢涨。三类问题往往互为因果goroutine 泄漏会导致内存泄漏死锁的 goroutine 本身也是一种泄漏。二、goroutine 泄漏泄漏模式 1Channel 未关闭接收方永远等// 错误写法 func process(ctx context.Context, ch -chan int) { for val : range ch { // 处理 val } // 如果 ch 永远不关闭这个 goroutine 永远不会退出 } // 正确写法用 ctx 控制退出 func process(ctx context.Context, ch -chan int) { for { select { case val, ok : -ch: if !ok { return // channel 关闭正常退出 } // 处理 val case -ctx.Done(): return // 超时或取消强制退出 } } }排查方法用runtime.NumGoroutine()监控 goroutine 数量如果持续增长不回落就是泄漏。pprof 的 goroutine profile 能看到每个 goroutine 的创建点和阻塞位置。泄漏模式 2HTTP Client 连接未释放// 错误写法默认 Client 没有超时设置 resp, err : http.Get(https://example.com/api) if err ! nil { return err } // 忘记调用 resp.Body.Close() // TCP 连接永远不会释放底层 goroutine 持续存在 // 正确写法 client : http.Client{Timeout: 30 * time.Second} resp, err : client.Get(https://example.com/api) if err ! nil { return err } defer resp.Body.Close() // 必须关闭 _, _ io.Copy(io.Discard, resp.Body) // 先读完再关闭防止连接无法复用排查方法pprof goroutine profile 中看到大量net/http.(*persistConn).readLoop说明 HTTP 连接没关闭。泄漏模式 3WaitGroup 计数错误// 错误写法Add 和 Done 计数不匹配 var wg sync.WaitGroup for i : 0; i 10; i { go func() { // 忘记 wg.Add(1) doWork() wg.Done() // Done 比 Add 多触发 panic }() } wg.Wait() // 正确写法在启动 goroutine 之前 Add var wg sync.WaitGroup for i : 0; i 10; i { wg.Add(1) go func() { defer wg.Done() doWork() }() } wg.Wait()更危险的变体Add在 goroutine 内部调用主线程可能先执行到Wait此时计数还是 0Wait立刻返回goroutine 还在跑。排查方法wg.Add必须在go func()之前调用这是 Go 社区反复强调的规则但依然有人犯错。三、内存泄漏泄漏模式 4全局 Map 无界增长// 错误写法缓存没有淘汰机制 var cache make(map[string][]byte) func getCachedData(key string) []byte { if val, ok : cache[key]; ok { return val } val : fetchData(key) // 数据可能很大 cache[key] val // 永远不删除 return val } // 正确写法限制缓存大小 func getCachedData(key string) []byte { if len(cache) maxCacheSize { // 淘汰最老的或随机淘汰 evictOldest(cache) } // ... }排查方法pprof heap profile 看内存分配如果某个 map 的内存占比持续增长就是无界缓存。更好的方案是用sync.Map配合 TTL或者直接用成熟缓存库如bigcache、ristretto。泄漏模式 5闭包捕获大对象// 错误写法闭包捕获了整个 request 对象 func handler(req *http.Request) { bigData : parseRequest(req) // 100MB 数据 go func() { // 只用了 bigData 的一个字段但整个 bigData 都被闭包引用 process(bigData.ID) // goroutine 完成后 bigData 依然无法被 GC }() } // 正确写法只传递需要的值 func handler(req *http.Request) { bigData : parseRequest(req) id : bigData.ID // 只拷贝需要的值 go func() { process(id) // 闭包只引用 idbigData 可以被 GC }() // bigData 在这里就可以被回收了 }排查方法heap profile 中看到大量runtime.mspan或特定业务对象无法回收检查是否有闭包或全局变量引用。泄漏模式 6sync.Pool 滥用sync.Pool是临时对象缓存GC 时会清理。但如果 Pool 中的对象特别大比如 1MB 的 bufferGC 清理后又被重新创建实际上没有节省内存反而增加了 GC 压力。// 问题写法Pool 中存大对象 var bigBufPool sync.Pool{ New: func() interface{} { return make([]byte, 1024*1024) // 1MB buffer }, } // 建议Pool 只缓存小对象大对象用显式管理 // 或者 Pool 的对象大小不超过 4KB排查方法监控 GC 频率和停顿时间如果引入sync.Pool后 GC 停顿反而增加了说明 Pool 对象太大。四、死锁死锁模式 7互斥锁循环等待// 错误写法两个锁顺序不一致 func transfer(from, to *Account, amount int) { from.mu.Lock() to.mu.Lock() // 如果另一个 goroutine 同时做 transfer(to, from, amount) // 它先锁 to.mu再锁 from.mu // 两个 goroutine 互相等对方释放锁 - 死锁 from.balance - amount to.balance amount to.mu.Unlock() from.mu.Unlock() } // 正确写法统一锁顺序 func transfer(from, to *Account, amount int) { // 按账户 ID 排序确保所有 goroutine 以相同顺序加锁 first, second : orderAccounts(from, to) first.mu.Lock() second.mu.Lock() // ... second.mu.Unlock() first.mu.Unlock() }排查方法死锁时服务不崩溃但停止响应。pprof goroutine profile 会显示大量 goroutine 在semacquire状态等待锁。死锁模式 8Channel 双向阻塞// 错误写法无缓冲 channel 在同一 goroutine 收发 ch : make(chan int) // 无缓冲 ch - 1 // 阻塞因为没有人接收 val : -ch // 永远等不到因为上面已经阻塞了 // 正确写法用缓冲 channel 或在另一个 goroutine 接收 ch : make(chan int, 1) // 缓冲 1 ch - 1 // 不阻塞 val : -ch // 立刻读到更隐蔽的变体生产者和消费者的数量不匹配。10 个生产者往 channel 写但只有 1 个消费者读。channel 满了之后生产者阻塞消费者处理太慢最终所有 goroutine 都卡在 channel 操作上。排查方法goroutine profile 显示大量 goroutine 在chan send或chan receive阻塞。检查 channel 的缓冲大小和收发 goroutine 数量是否匹配。死锁模式 9Select 无 default 分支且所有 case 都阻塞// 错误写法所有 case 都可能永久阻塞 select { case val : -ch1: // ch1 永远不来数据 case val : -ch2: // ch2 永远不来数据 } // 正确写法加超时或 default select { case val : -ch1: process(val) case val : -ch2: process(val) case -time.After(5 * time.Second): return errors.New(timeout) }排查方法和模式 8 类似goroutine profile 中看到大量select阻塞。关键检查点是否有 case 永远不会被触发。五、统一的排查方法论核心工具go tool pprof的三个子命令命令用途关键指标goroutine看 goroutine 数量和阻塞位置阻塞在chan send/receive、semacquireheap看内存分配和未回收对象inuse_objects持续增长profile看 CPU 热点GC 占 CPU 比例过高说明内存压力大自动化监控把runtime.NumGoroutine()和debug.ReadMemStats()的关键指标接入 Prometheus设告警阈值goroutine 数 1000 且持续增长 → goroutine 泄漏告警heap inuse 配额的 80% → 内存泄漏告警服务 P99 延迟突然暴涨但 QPS 不变 → 死锁嫌疑六、总结Go 并发安全的五个原则每个 goroutine 都要有退出路径不是go func()就结束了要考虑它什么时候停。Channel 发送方负责关闭接收方用ctx.Done()兜底永远不要让接收方等一个不会关闭的 channel。全局缓存必须设上限无界 map 是内存泄漏的常见原因用成熟库比自己写淘汰逻辑安全。锁的顺序要全局统一死锁的根因几乎都是锁顺序不一致规范比排查更划算。pprof 是第一工具不要靠猜用数据说话。goroutine profile 和 heap profile 是排查的第一步。基础设施不需要漂亮话并发安全就是 Go 服务的生命线。