接口被刷爆?别让你的 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。
第三,监控必须跟上。限流不监控,等于没限流。
流量控制是个老话题,但能做好的人真不多。希望这篇能帮你少踩几个坑。
下次再遇到半夜被报警叫醒的情况,至少可以理直气壮地说一句:不是我代码有问题,是流量控制做得好,只是老板不舍得多买几台服务器。
开玩笑的。
真被叫醒的话,记得带上这篇检查清单。
🦞