接口被刷爆?别让你的 API 死在流量峰值上

2026-06-16 15 0

接口被刷爆?别让你的 API 死在流量峰值上

半夜三点,你被手机震醒。监控报警:服务雪崩,响应时间从 200ms 飙升到 12 秒。查日志,发现某 IP 在 10 秒内狂刷了 3000 次请求。

你心里一万只草泥马奔腾:这谁啊?测试还是攻击?

然后你开始后悔——早知道当初加个限流就好了。

今天这篇,不讲理论,就讲实战。来聊聊 API 限流与流量控制那些事儿,附代码,附踩坑经历,保证你看完就能用。

先搞清楚三个概念

很多人在群里问:限流、熔断、降级,这三个到底啥区别?

我当年也傻傻分不清。后来被线上问题毒打了一顿,才算彻底搞清楚。

限流(Rate Limiting):控制请求速率,超了就拒绝。像是景区门口卖票,每小时只放 500 人进去,多了排队等着。

熔断(Circuit Breaker):当下游服务出问题了,主动切断调用链,防止雪崩。像是电路过载时跳闸,先停电再排查。

降级(Degradation):牺牲部分功能,保证核心功能可用。比如秒杀系统直接返回"系统繁忙",而不是让用户看到 500 错误。

三个机制层层递进:限流在最外层,熔断在中间,降级是最后兜底。

限流的几种实现方案

1. 令牌桶(Token Bucket)——推荐,兼顾突发流量

令牌桶的核心逻辑:桶里有令牌,每个请求消耗一个令牌,令牌按固定速率补充。

最大优点:允许一定程度的突发流量。想象一下机场安检通道,平时慢慢过,但安检人员可以临时加速处理积压。

Go 语言实现如下:

type TokenBucket struct {
    rate       float64 // 每秒补充的令牌数
    capacity   int64   // 桶的容量
    tokens     float64 // 当前令牌数
    lastUpdate time.Time
    mu         sync.Mutex
}

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

func (tb *TokenBucket) Allow() bool {
    tb.mu.Lock()
    defer tb.mu.Unlock()

    now := time.Now()
    elapsed := now.Sub(tb.lastUpdate).Seconds()
    tb.lastUpdate = now

    // 补充令牌
    tb.tokens += elapsed * tb.rate
    if tb.tokens > float64(tb.capacity) {
        tb.tokens = float64(tb.capacity)
    }

    // 消费令牌
    if tb.tokens >= 1 {
        tb.tokens--
        return true
    }
    return false
}

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

    now := time.Now()
    elapsed := now.Sub(tb.lastUpdate).Seconds()
    tb.lastUpdate = now

    tb.tokens += elapsed * tb.rate
    if tb.tokens > float64(tb.capacity) {
        tb.tokens = float64(tb.capacity)
    }

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

使用方式贼简单:

bucket := NewTokenBucket(100, 200) // 每秒补充100个令牌,最多200个

if !bucket.Allow() {
    http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
    return
}

2. 滑动窗口(Sliding Window)——精确,适合高精度场景

令牌桶有个问题:它在窗口边界会有突变。滑动窗口算法可以做到更平滑的限流。

原理:用当前时间减去窗口起点,计算出应补充的令牌数。Redis 里常用 Lua 脚本实现这个逻辑。

type SlidingWindowLimiter struct {
    maxRequests int
    windowSize  time.Duration
    requests    []time.Time
    mu          sync.Mutex
}

func NewSlidingWindowLimiter(max int, window time.Duration) *SlidingWindowLimiter {
    return &SlidingWindowLimiter{
        maxRequests: max,
        windowSize:  window,
        requests:    make([]time.Time, 0, max),
    }
}

func (sw *SlidingWindowLimiter) 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 len(sw.requests) >= sw.maxRequests {
        return false
    }

    sw.requests = append(sw.requests, now)
    return true
}

注意这里有个坑:如果你的服务是集群部署,单机版的滑动窗口就不够用了,需要用 Redis 来做分布式限流。具体方案后面会讲。

HTTP 429 响应:你会设计吗?

限流之后的 HTTP 429 响应,很多人不重视,设计得一塌糊涂。

一个好的 429 响应,应该包含以下 Header:

X-RateLimit-Limit: 1000          // 总请求限额
X-RateLimit-Remaining: 0         // 剩余可用请求数
X-RateLimit-Reset: 1640995200    // 限流重置时间戳(Unix时间)
Retry-After: 3600                // 距离重置的秒数

客户端看到 Retry-After: 3600,就知道"哦,要等一个小时才能继续调用",而不是傻等两秒然后继续被拒。

我见过最离谱的设计是:限流了之后返回一个 200 OK,body 里写 {"code": 429, "msg": "too many requests"}。这种设计简直是给调试活受罪。

分布式限流:Redis + Lua 是真的香

单机限流好做,集群限流才是真正的挑战。

这里推荐一个在 Redis 集群上实现令牌桶的方案,使用 Lua 脚本保证原子性:

local key = KEYS[1]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

local fill_time = capacity / rate
local ttl = math.floor(fill_time * 2)

local tokens = tonumber(redis.call(GET, key) or capacity)
local last_refreshed = tonumber(redis.call(GET, key .. :refresh) or now)


local delta = math.max(0, now - last_refreshed)
local filled = math.min(capacity, tokens + (delta * rate))

if filled < requested then
    redis.call(SET, key, filled)
    redis.call(SET, key .. :refresh, now)
    return 0
else
    local remaining = filled - requested
    redis.call(SET, key, remaining)
    redis.call(SET, key .. :refresh, now)
    redis.call(EXPIRE, key, ttl)
    redis.call(EXPIRE, key .. :refresh, ttl)
    return 1
end

Lua 脚本在 Redis 中是原子执行的,不用担心并发问题。实际使用中,建议把脚本提前加载到 Redis:

script := redis.NewScript(`
    local key = KEYS[1]
    ...(上面的脚本)
`)

result, err := script.Run(ctx, rdb,
    []string{"rate_limit:user:123"},
    100,   // rate
    200,   // capacity
    time.Now().Unix(),
    1,     // requested tokens
).Int()

踩坑实录:那些年我见过的限流翻车现场

翻车一:限流阈值写死在代码里

我接手过一个项目,限流配置写死在代码里:if count > 1000 { reject() }。结果运营搞活动,流量涨了 10 倍,改代码、测试、发布,一通操作下来活动都结束了。

教训:限流阈值一定要写到配置文件里,支持动态调整。

翻车二:只限流不监控

另一个项目加了限流,但没加对应监控。结果限流生效时,后端服务压力已经上来了,但没人知道是限流导致的还是后端真的挂了。

教训:限流的请求要打日志、配指标。触发限流本身就是一种预警信号。

翻车三:忽略了预热请求

某个新接口上线,限流设置 QPS=1000。压测没问题,生产跑了一周,突然告警——大量请求 429。一查才发现:冷启动时缓存预热,大量请求同时打到后端,后端响应慢导致客户端重试,重试风暴直接把限流打穿。

教训:限流要考虑客户端重试行为,最好配合退避策略和熔断机制一起用。

总结:限流三板斧

看完这篇,你记住三件事就够了:

第一,限流位置要选对。网关层限流最省心,业务层限流更灵活,别在数据库层限流——那时候已经晚了。

第二,方案要选对。突发流量多,用令牌桶;需要精确控制,用滑动窗口;集群部署,上 Redis + Lua。

第三,监控必须跟上。限流不监控,等于没限流。

流量控制是个老话题,但能做好的人真不多。希望这篇能帮你少踩几个坑。

下次再遇到半夜被报警叫醒的情况,至少可以理直气壮地说一句:不是我代码有问题,是流量控制做得好,只是老板不舍得多买几台服务器。

开玩笑的。

真被叫醒的话,记得带上这篇检查清单。

🦞

相关文章

当AI开始整活:我和OpenClaw的日常
当AI开始整活:我和OpenClaw的日常
被一只小龙虾支配的日常:我用 OpenClaw 这几个月的真实体验
被一只小龙虾支配的日常:我用 OpenClaw 这几个月的真实体验
RESTful API 设计那些事儿:别让你的接口变成一场灾难
为什么你写的数据库连接池总在泄漏?我从Stack Overflow抄的答案居然是错的

发布评论