10万并发来袭:Go凭什么能扛住?我写了5年代码才敢说这3句话

2026-03-29 9 0

10万并发来袭:Go凭什么能扛住?我写了5年代码才敢说这3句话

凌晨三点,你的手机响了。生产告警:OOM Kill。

你揉着眼睛打开DashBoard,发现罪魁祸首是一个看似无害的接口——它负责处理用户导入的Excel文件,并发一上来, goroutine 直接爆了。

这时候你可能会想:Go不是号称轻量级并发吗?goroutine 不是才几KB吗?是的,但"便宜"不等于"免费"。当你的系统面临真实洪流时,不懂限流的后端工程师,迟早会被限流教做人

今天聊三句话,每句都是我用线上故障换来的。


第一句话:Go的并发很便宜,但你不能无限挥霍

goroutine 确实便宜,初始栈只有 2KB,动态增长到 1GB 都没问题。但这不意味着你可以肆无忌惮地 spawn goroutine。

我见过最离谱的代码是这样的:

func ProcessUsers(userIDs []int64) {
    for _, id := range userIDs {
        go processOne(id) // 10万个用户 = 10万个goroutine
    }
}

这段代码跑在测试环境美滋滋,一上生产就是灾难。10万并发进来,内存瞬间爆炸,GC 开始疯狂标记,整个服务卡成PPT。

限流不是限制你的能力,而是保护你的系统不被自己的"能力"杀死。


第二句话:信号量是最被低估的限流工具

很多人一提到限流就想到令牌桶、漏桶。但其实 golang.org/x/sync/semaphore 是最直观、最容易理解的并发控制工具。

它的本质就是一个带权重的信号量,帮你控制同时运行的任务数:

package main

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "time"

    "golang.org/x/sync/semaphore"
)

// 最多同时处理100个请求
var sem = semaphore.NewWeighted(100)

func processWithSemaphore(ctx context.Context, id int) error {
    // 尝试获取1个许可证,如果满了就阻塞等待
    if err := sem.Acquire(ctx, 1); err != nil {
        return err
    }
    defer sem.Release(1)

    // 模拟实际工作
    time.Sleep(100 * time.Millisecond)
    fmt.Printf("处理完成: %d\n", id)
    return nil
}

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    id := 1

    // 带超时的获取,避免无限阻塞
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    if err := processWithSemaphore(ctx, id); err != nil {
        http.Error(w, "服务繁忙,请稍后重试", http.StatusServiceUnavailable)
        return
    }

    w.Write([]byte("ok"))
}

func main() {
    log.Println("服务启动: :8080")
    log.Fatalln(http.ListenAndServe(":8080", nil))
}

这段代码的核心逻辑:sem.Acquire 会阻塞,直到有空闲位置,最多等5秒。超时就返回错误,接口直接返回503。

有人会问:这和 sync.WaitGroup 有什么区别?区别大了:WaitGroup 只能控制"等全部完成",而信号量可以控制"同时进行多少个"。一个是计数器,一个是门槛。


第三句话:令牌桶才是生产级限流的标准答案

信号量解决了"同时多少个"的问题,但没有解决"每秒钟多少个"的问题。

现实场景更常见的是:我想限制QPS为5000,而不是同时5000个请求都在跑。这时候你需要令牌桶算法。

经典的实现有两种:

方案A:手写一个(适合学习)

package limiter

import (
    "sync"
    "time"
)

type TokenBucket struct {
    rate       int64          // 每秒产生的令牌数
    capacity   int64          // 桶的容量
    tokens     int64          // 当前令牌数
    lastUpdate time.Time      // 上次更新时间
    mu         sync.Mutex
}

func NewTokenBucket(rate, capacity int64) *TokenBucket {
    return &TokenBucket{
        rate:       rate,
        capacity:   capacity,
        tokens:     capacity,
        lastUpdate: time.Now(),
    }
}

func (tb *TokenBucket) Allow() bool {
    return tb.AllowN(1)
}

func (tb *TokenBucket) AllowN(n int64) bool {
    tb.mu.Lock()
    defer tb.mu.Unlock()

    now := time.Now()
    // 根据时间流逝补充令牌
    elapsed := now.Sub(tb.lastUpdate).Seconds()
    tb.tokens += int64(elapsed * float64(tb.rate))
    if tb.tokens > tb.capacity {
        tb.tokens = tb.capacity
    }
    tb.lastUpdate = now

    if tb.tokens >= n {
        tb.tokens -= n
        return true
    }
    return false
}

方案B:用现成库(适合生产)

package main

import (
    "fmt"
    "net/http"
    "sync"
    "time"

    "github.com/juju/ratelimit"
)

var (
    bucket   *ratelimit.Bucket
    once     sync.Once
)

func getBucket() *ratelimit.Bucket {
    once.Do(func() {
        // 每秒5000个令牌,桶容量10000
        bucket = ratelimit.NewBucketWithRate(5000, 10000)
    })
    return bucket
}

func handler(w http.ResponseWriter, r *http.Request) {
    // 尝试获取1个令牌,阻塞直到可用
    tb := getBucket()
    tb.Wait(1)

    // 业务逻辑
    fmt.Fprintf(w, "处理请求中...\n")
}

func main() {
    http.HandleFunc("/", handler)
    http.ListenAndServe(":8080", nil)
}

方案A让你理解原理,方案B让你快速上线。面试的时候手写算法,生产的时候用库。这不是双标,这是专业。


Bonus:滑动窗口限流,应对突发流量更优雅

令牌桶有个小问题:它允许突发流量一瞬间占满桶,这在某些场景下还是会打垮下游。

滑动窗口算法可以做到更平滑的限流:

package limiter

import (
    "sync"
    "time"
)

type SlideWindowLimiter struct {
    windowSize time.Duration
    maxReqs    int64
    requests   []time.Time
    mu         sync.Mutex
}

func NewSlideWindow(windowSize time.Duration, maxReqs int64) *SlideWindowLimiter {
    return &SlideWindowLimiter{
        windowSize: windowSize,
        maxReqs:    maxReqs,
        requests:   make([]time.Time, 0),
    }
}

func (sw *SlideWindowLimiter) Allow() bool {
    sw.mu.Lock()
    defer sw.mu.Unlock()

    now := time.Now()
    cutoff := now.Add(-sw.windowSize)

    // 清理窗口外的请求
    idx := 0
    for i, t := range sw.requests {
        if t.After(cutoff) {
            idx = i
            break
        }
    }
    sw.requests = sw.requests[idx:]

    if int64(len(sw.requests)) < sw.maxReqs {
        sw.requests = append(sw.requests, now)
        return true
    }
    return false
}

滑动窗口的核心思想:统计当前时间窗口内的请求数,超过阈值就拒绝。相比固定窗口(Redis SETNX方案),它的精度更高,不会在窗口边界出现流量突刺。


说点得罪人的话

很多人觉得限流是个小技巧,其实它体现的是你对系统容量有没有清醒的认知。

那些不做限流就上线的服务,要么是初创阶段赌运气,要么是根本不知道自己的系统在临界点会变成什么样子。

限流做好只是第一步:你还需要监控、告警、熔断、降级、扩容。这是一个体系,不是一个配置。

下次半夜被报警吵醒的时候,你至少可以先确认一件事:你的限流有没有生效。

如果没有,那这篇文章就值了。

相关文章

还在为部署AI工具熬夜?让专业的人来!🦞
为什么你的API总被人骂?10年踩坑总结
为什么你的API总被人骂?10年踩坑总结
我把AI调教了三个月,发现了一个反直觉的真相
那些年我们踩过的HTTP超时坑:一次线上事故的深度复盘
写接口这事,能别那么随意吗?后端API设计的七个血坑

发布评论