前言
做了 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 三条红线
- 能不用就不多用:channel 适合信号传递和任务分发,不适合做大数据搬运(拷贝开销 + 调度延迟)
- close 由发送方做,且一个 channel 只 close 一次;用
sync.Once保护 - 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 | 推送服务内存日涨 2G | channel 发送阻塞 | select+ctx 出口 |
| 2 | 定时任务偶发全停 | waitgroup 用错 | 重构为 errgroup |
| 3 | 网关 P99 抖动 | slice 无限 append | 预分配容量 |
| 4 | 配置热更新丢数据 | map 并发写 | RWMutex |
| 5 | 连接池打满 | 泄漏的 body 未 close | defer 一把梭 |
下篇预告
连接池参数怎么定、超时重试怎么设计才不会雪崩、GC 调优的三个参数。更新会在评论区置顶。
这些内容已整理成持续更新的完整版(含复现代码和修复模板),放在我的爱发电主页,按需自取,不强制。有问题评论区聊,工作日 24h 内回。