限流是后端最常用的自我保护手段。固定窗口实现简单但有临界突刺,滑动窗口更平滑但开销大。折中方案是令牌桶:既允许合理突发,又能扛住持续高压。
为什么不用固定窗口
固定窗口(每秒计数)的问题在边界:第 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)} -- 毫秒
限流的目的是让系统活着,不是把用户挡跑。给个明确的等待时间,体验差别很大。