15 年后端老兵的 Go 高并发避坑清单(上):goroutine、channel 与 context 的 9 个真实事故

43 阅读3分钟

前言

做了 15 年后端,Go 服务写的越多,越觉得高并发问题翻来覆去就是那几类。这份清单里的每一条都来自真实生产事故,不是背书背出来的。上篇先讲 goroutine、channel、context 三件套,下篇讲连接池、超时与 GC。

一、goroutine 泄漏:90% 是 channel 阻塞

事故现场:某推送服务内存每天涨 2G,pprof 里几万个 goroutine 停在同一个 send 位置。

根因几乎都是一个模式:往无缓冲 channel 发数据,接收方因为超时/错误提前退出了,发送方永远阻塞。

// 错误:下游超时退出后,ch 没人收,goroutine 永久阻塞
func worker(ch chan Task) {
    for t := range tasks {
        go func(t Task) {
            result := process(t)
            ch <- result  // ← 这里卡死
        }(t)
    }
}

// 正确:select + context 双保险
func worker(ctx context.Context, ch chan Task) {
    for t := range tasks {
        go func(t Task) {
            result := process(t)
            select {
            case ch <- result:
            case <-ctx.Done():  // 出口必须有
            }
        }(t)
    }
}

排查口诀:curl /debug/pprof/goroutine?debug=1,看顶部堆栈聚集点,十秒定位。

二、channel 三条红线

  1. 能不用就不多用:channel 适合信号传递和任务分发,不适合做大数据搬运(拷贝开销 + 调度延迟)
  2. close 由发送方做,且一个 channel 只 close 一次;用 sync.Once 保护
  3. range 前想清楚 close 语义:接收方 for range ch 依赖 close 退出,发送方忘了 close 就是泄漏

三、context 传递规范

  • context 必须作为函数第一个参数,且中间层不得吞掉(哪怕自己不用也要透传)
  • 不要把 context 存进 struct——真实事故:struct 里存了请求级 ctx,对象进池子复用后,下个请求直接被上个请求的超时干掉
  • 超时要在最外层定:context.WithTimeout(ctx, 800*time.Millisecond) 放入口,内层用 ctx.Err() 判断。每层各定超时的结果就是时间预算永远对不上

四、map 并发写:报错是福气

fatal error: concurrent map writes 直接崩进程——但说真的,这是福气,崩了立刻知道。真正可怕的是并发读写交错表现出的偶发脏数据,那才是灾难。

规范就一条:可能被并发碰的 map,一律 sync.Map 或 sync.RWMutex 包一层。性能焦虑留给 benchmark,先保证正确。

五、slice 扩容:append 的隐藏成本

大 slice 的 append 扩容时是 O(n) 拷贝。高并发下一个 10 万元素的 slice 反复 append,GC 压力能吃掉半个核。

做法:能预估容量就 make([]T, 0, cap) 一次到位。我们有个服务就改了这一行,CPU 降了 18%。

六、真实事故速查表

#事故根因修复
1推送服务内存日涨 2Gchannel 发送阻塞select+ctx 出口
2定时任务偶发全停waitgroup 用错重构为 errgroup
3网关 P99 抖动slice 无限 append预分配容量
4配置热更新丢数据map 并发写RWMutex
5连接池打满泄漏的 body 未 closedefer 一把梭

下篇预告

连接池参数怎么定、超时重试怎么设计才不会雪崩、GC 调优的三个参数。更新会在评论区置顶。


这些内容已整理成持续更新的完整版(含复现代码和修复模板),放在我的爱发电主页,按需自取,不强制。有问题评论区聊,工作日 24h 内回。