2024 年我们一个 Go 服务在大促时被打爆,QPS 从 2w 飙到 12w,P99 延迟从 50ms 涨到 3s,Goroutine 数从 5000 涨到 80 万直接 OOM。复盘后发现:Goroutine 泄漏 + Channel 死锁 + HTTP Client 没限流。投了两周把这些坑全填了,本文复盘 Go 高并发场景下的 7 个真实事故 + 修法。
事故现场
服务:订单网关(Go 1.21 + Gin)
机器:16C32G Pod x 20
正常 QPS:2w/s,P99 50ms
大促峰值 QPS:12w/s
事故时间线:
14:00 大促开始,流量上涨
14:05 P99 从 50ms 涨到 200ms
14:10 Goroutine 数从 5000 涨到 5w
14:15 Goroutine 数 50w,内存 28GB,GC 频繁
14:20 Pod OOMKilled,k8s 重建
14:25 重建的 Pod 接到流量秒挂
14:30 整个集群雪崩,人工降级
最终:大促 30 分钟全站 503,损失千万
复盘:Goroutine 泄漏是元凶
坑 1:HTTP Client 没超时
// 错误:默认 http.Client 没超时
client := &http.Client{}
resp, err := client.Get("https://api.upstream.com/data")
// 上游慢 → 连接挂住 → Goroutine 阻塞
// 100 个 QPS × 30s 不返回 → 3000 Goroutine 堆积
// 雪崩开始
// 正确:必须设超时
client := &http.Client{
Timeout: 3 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 200,
MaxIdleConnsPerHost: 20,
MaxConnsPerHost: 50, // 限制单 host 连接数
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
// 关键:DialContext 也要超时
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
},
}
// 更细粒度:用 context 控制单次请求
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := client.Do(req)
坑 2:Goroutine 泄漏
// 错误:Goroutine 没有出口
func processOrder(orderID string) {
ch := make(chan Result)
go func() {
result := callUpstream(orderID)
ch <- result // 如果 callUpstream 永远不返回,goroutine 泄漏
}()
select {
case r := <-ch:
return r
case <-time.After(1 * time.Second):
return Result{Err: errors.New("timeout")}
}
// ↑ 函数返回了,但 goroutine 还在等 ch
// 如果 callUpstream 后来返回了,写 ch 阻塞(没人读)
// Goroutine 永久泄漏
}
// 正确:用 buffered channel + context
func processOrder(ctx context.Context, orderID string) Result {
ch := make(chan Result, 1) // buffered,即使无人读也能写
go func() {
result := callUpstream(ctx, orderID)
select {
case ch <- result:
case <-ctx.Done():
// 上层已退,goroutine 也退
}
}()
select {
case r := <-ch:
return r
case <-ctx.Done():
return Result{Err: ctx.Err()}
}
}
坑 3:Channel 死锁
// 错误:unbuffered channel + 单 goroutine
func aggregate(items []int) int {
ch := make(chan int)
for _, item := range items {
go func(i int) {
ch <- compute(i) // 第一个 goroutine 阻塞等读
}(item)
}
sum := 0
for range items {
sum += <-ch
}
return sum
}
// 问题:如果有 panic 或上层 timeout,后续 goroutine 阻塞泄漏
// 正确:buffered channel + errgroup
import "golang.org/x/sync/errgroup"
func aggregate(ctx context.Context, items []int) (int, error) {
g, ctx := errgroup.WithContext(ctx)
ch := make(chan int, len(items)) // buffer 足够大
for _, item := range items {
item := item
g.Go(func() error {
select {
case ch <- compute(item):
return nil
case <-ctx.Done():
return ctx.Err()
}
})
}
if err := g.Wait(); err != nil {
return 0, err
}
close(ch)
sum := 0
for v := range ch {
sum += v
}
return sum, nil
}
坑 4:无限制 Goroutine
// 错误:每个请求开 N 个 goroutine
func handler(w http.ResponseWriter, r *http.Request) {
items := getItems() // 1000 个
for _, item := range items {
go process(item) // 直接起 1000 goroutine
}
}
// QPS 1000 → 100w goroutine,直接 OOM
// 正确:用 semaphore / pool 限制并发
import "golang.org/x/sync/semaphore"
var sem = semaphore.NewWeighted(100) // 全局限并发 100
func handler(ctx context.Context) {
items := getItems()
var wg sync.WaitGroup
for _, item := range items {
if err := sem.Acquire(ctx, 1); err != nil {
break
}
wg.Add(1)
go func(it Item) {
defer wg.Done()
defer sem.Release(1)
process(it)
}(item)
}
wg.Wait()
}
// 或用第三方 ants pool
import "github.com/panjf2000/ants/v2"
pool, _ := ants.NewPool(1000)
defer pool.Release()
pool.Submit(func() { process(item) })
坑 5:sync.Pool 用错
// sync.Pool 是对象复用,但不是 LRU 缓存
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
// 错误用法:取出后不归还
func handler() {
buf := bufPool.Get().(*bytes.Buffer)
buf.WriteString(...)
// 忘了 Put,Pool 失去意义
}
// 正确
func handler() {
buf := bufPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset() // 清空内容
bufPool.Put(buf)
}()
buf.WriteString(...)
}
// 注意:sync.Pool 里对象会在 GC 时被清掉
// 适合短生命周期对象复用(如 buffer / decoder)
// 不适合做缓存(GC 后丢失)
坑 6:Map 并发读写
// 错误:多 goroutine 直接读写 map
var cache = map[string]int{}
func write(k string, v int) {
cache[k] = v // fatal error: concurrent map writes
}
// 修法 1:sync.RWMutex
var (
cache = map[string]int{}
mu sync.RWMutex
)
func read(k string) (int, bool) {
mu.RLock()
defer mu.RUnlock()
v, ok := cache[k]
return v, ok
}
func write(k string, v int) {
mu.Lock()
defer mu.Unlock()
cache[k] = v
}
// 修法 2:sync.Map(读多写少 + key 多变)
var cache sync.Map
cache.Store("k", 1)
v, ok := cache.Load("k")
// 修法 3:分片 map(高并发场景)
type ShardMap struct {
shards [256]struct {
sync.RWMutex
m map[string]int
}
}
func (s *ShardMap) Get(k string) (int, bool) {
shard := &s.shards[hash(k)%256]
shard.RLock()
defer shard.RUnlock()
v, ok := shard.m[k]
return v, ok
}
// 256 个分片,锁竞争降到 1/256
坑 7:Context 泄漏
// 错误:WithCancel 没 cancel
func getData(ctx context.Context) {
childCtx, _ := context.WithCancel(ctx) // 忽略 cancel
callA(childCtx)
callB(childCtx)
// ctx 一直挂着资源,GC 不掉
}
// 正确:defer cancel
func getData(ctx context.Context) {
childCtx, cancel := context.WithCancel(ctx)
defer cancel() // 必须 defer
callA(childCtx)
callB(childCtx)
}
// 错误:context 跨 goroutine 不传
func handler(r *http.Request) {
go background() // background 不带 ctx,无法被 cancel
}
// 正确
func handler(r *http.Request) {
ctx := r.Context()
go func() {
background(ctx) // 上层取消时,background 也能感知
}()
}
监控指标
// Goroutine 数监控
import "runtime"
var goroutineGauge = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "go_goroutines_count",
})
go func() {
for range time.Tick(10 * time.Second) {
goroutineGauge.Set(float64(runtime.NumGoroutine()))
}
}()
// 告警规则
// go_goroutines_count > 10000 持续 1min → P2
// go_goroutines_count 增长 > 100/s → P1(可能泄漏)
// pprof 集成(必装)
import _ "net/http/pprof"
go func() {
http.ListenAndServe(":6060", nil)
}()
// 排查 goroutine 泄漏
// $ go tool pprof http://localhost:6060/debug/pprof/goroutine
// (pprof) top
// (pprof) list
// 看哪个 goroutine 堆得最多
// 排查内存
// $ go tool pprof http://localhost:6060/debug/pprof/heap
// (pprof) top
// (pprof) png > heap.png
压测对比
压测工具:wrk2 -t 20 -c 1000 -d 60s -R 100000
机器:16C32G x 20 Pod
场景 优化前 优化后
====================================================
QPS 35k(雪崩) 115k
P50 延迟 120ms 35ms
P99 延迟 3s 80ms
Goroutine 数 50w+OOM 8000(稳)
内存 OOM 6GB
GC pause p99 150ms 5ms
错误率 35% 0.02%
CPU 满载 65%
核心 Pattern:超时 + 取消 + 限流
// 高并发安全模版
import (
"context"
"golang.org/x/sync/errgroup"
"golang.org/x/sync/semaphore"
)
type SafeCaller struct {
client *http.Client
sem *semaphore.Weighted
timeout time.Duration
}
func NewSafeCaller() *SafeCaller {
return &SafeCaller{
client: &http.Client{
Timeout: 3 * time.Second,
Transport: &http.Transport{
MaxConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
},
},
sem: semaphore.NewWeighted(500),
timeout: 1 * time.Second,
}
}
func (c *SafeCaller) Call(ctx context.Context, url string) ([]byte, error) {
// 1. 限流
if err := c.sem.Acquire(ctx, 1); err != nil {
return nil, fmt.Errorf("acquire semaphore: %w", err)
}
defer c.sem.Release(1)
// 2. 超时
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
// 3. 取消传递
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
限流降级
// 中间件:全局限流
import "golang.org/x/time/rate"
var limiter = rate.NewLimiter(50000, 5000) // QPS 5w,burst 5000
func RateLimitMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
w.WriteHeader(http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
// 熔断器(github.com/sony/gobreaker)
import "github.com/sony/gobreaker"
cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
Name: "upstream",
MaxRequests: 5,
Timeout: 10 * time.Second,
ReadyToTrip: func(c gobreaker.Counts) bool {
return c.ConsecutiveFailures > 5
},
})
result, err := cb.Execute(func() (any, error) {
return callUpstream(ctx)
})
避坑清单
- HTTP Client 必须设 Timeout,默认零是无限等
- 所有 Goroutine 必须有出口(ctx.Done 或 buffered channel)
- Channel 用 buffered + select + ctx 三件套
- 用 errgroup / semaphore / pool 限制 Goroutine 数
- sync.Pool 取出必 Put,且 Reset 清状态
- map 并发读写一律加锁 / 用 sync.Map / 分片
- context.WithCancel 必须 defer cancel,不要丢 cancel
- Goroutine 数监控 + pprof 永久开
- 限流(限流器/sem)+ 熔断(gobreaker)双保险
- 压测前后对比 Goroutine 数趋势,无泄漏才算稳
总结
Go 的高并发 = 简单 + 危险。Goroutine 起手就 go 一句,但泄漏起来悄无声息。这次大促事故让我深刻理解:Go 高并发的关键不是写得快,是写得稳 — 每个 goroutine 都要有出口,每个 channel 都要有边界,每个 HTTP Client 都要有超时。这一套下来 P99 从 3s 降到 80ms,雪崩再没发生。最大的认知改变:Go 让"开 goroutine"看起来零成本,但一旦泄漏代价极大。生产代码里默认 goroutine 都要在 errgroup / pool 里跑,不要裸 go,这是高并发服务的纪律。pprof 是 Go 程序员的命脉,熟练用 goroutine / heap / mutex profile 三种 pprof 才能在事故现场拿到证据。
—— 别看了 · 2026