Go 高并发 7 大坑实录:Goroutine 泄漏 50w 雪崩复盘

Go 订单网关大促雪崩复盘:QPS 12w 时 Goroutine 飙到 50w 直接 OOM。复盘 7 个高并发坑:HTTP Client 超时 + Goroutine 泄漏 + Channel 死锁 + sync.Pool 误用 + map 并发 + Context 泄漏 + 限流熔断。优化后 P99 从 3s 降到 80ms。

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)
})

避坑清单

  1. HTTP Client 必须设 Timeout,默认零是无限等
  2. 所有 Goroutine 必须有出口(ctx.Done 或 buffered channel)
  3. Channel 用 buffered + select + ctx 三件套
  4. 用 errgroup / semaphore / pool 限制 Goroutine 数
  5. sync.Pool 取出必 Put,且 Reset 清状态
  6. map 并发读写一律加锁 / 用 sync.Map / 分片
  7. context.WithCancel 必须 defer cancel,不要丢 cancel
  8. Goroutine 数监控 + pprof 永久开
  9. 限流(限流器/sem)+ 熔断(gobreaker)双保险
  10. 压测前后对比 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

微信小程序性能优化实录:启动 8s→1.2s 评分 78→96

2026-5-19 12:40:43

技术教程

PostgreSQL 2TB 慢查询治理:从 30s 到 800ms 全实录

2026-5-19 12:47:04

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索