用 Redis 实现接口限流:令牌桶的落地写法

限流是后端最常用的自我保护手段。固定窗口实现简单但有临界突刺,滑动窗口更平滑但开销大。折中方案是令牌桶:既允许合理突发,又能扛住持续高压。 为什么不用固定窗口 固定窗口(每秒计数)的问题在边界:第 0.9 秒来 100 个请求,第 1.1

限流是后端最常用的自我保护手段。固定窗口实现简单但有临界突刺,滑动窗口更平滑但开销大。折中方案是令牌桶:既允许合理突发,又能扛住持续高压。

为什么不用固定窗口

固定窗口(每秒计数)的问题在边界:第 0.9 秒来 100 个请求,第 1.1 秒又来 100 个,两秒内实际放了 200 个,远超阈值。攻击者专门挑边界打。

Lua 脚本保证原子性

限流的核心操作是”取令牌 + 回填时间”,必须原子执行,否则并发下会超发。用 Redis + Lua:

-- KEYS[1]=限流键  ARGV[1]=容量 ARGV[2]=每秒速率 ARGV[3]=本次消耗
local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(bucket[1]) or tonumber(ARGV[1])
local ts = tonumber(bucket[2]) or 0
local now = tonumber(redis.call('TIME')[1])

-- 按时间差补充令牌
tokens = math.min(tonumber(ARGV[1]),
                  tokens + (now - ts) * tonumber(ARGV[2]))

if tokens < tonumber(ARGV[3]) then
    return 0            -- 拒绝
end
tokens = tokens - tonumber(ARGV[3])
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], 3600)
return 1

TIME 要用 Redis 服务器时间,不能用客户端本地时间——多台机器时钟漂移会让限流彻底失真。

Go 侧调用

allowed, err := redis.Eval(ctx, luaScript,
    []string{"rl:" + userID},
    capacity, ratePerSec, cost).Int()
if err != nil || allowed == 0 {
    return http.StatusTooManyRequests
}

几个实践要点

  • key 的粒度:按用户 ID 限流比按 IP 更合理,能防同网段NAT下的误伤
  • cost 可变:导出类接口设 cost=5,普通查询设 cost=1,比统一阈值更灵活
  • Redis 挂了怎么办:Eval 报错时应放行还是拒绝?倾向放行并打点告警——限流是保护手段,不该成为单点
  • 必须 EXPIRE:否则 key 永久驻留,内存会被慢慢吃光

返回值可以更有用

被拒时把”还需等多久”算出来,放进 Retry-After 响应头,客户端就知道该退避而不是疯狂重试:

local wait = (tonumber(ARGV[3]) - tokens) / tonumber(ARGV[2])
return {0, math.ceil(wait * 1000)}   -- 毫秒

限流的目的是让系统活着,不是把用户挡跑。给个明确的等待时间,体验差别很大。