导读:读完本文你能拿到三样东西:①四种常见限流算法的适用场景对照表;②基于 Redis + Lua 的原子性限流脚本(可直接抄走);③一套从单机到分布式的限流方案演进清单。适合后端、中间件开发者参考,也适合排查线上接口被突增流量打满的场景。
限流不是为了"不让用户用",而是把超过系统承载能力的流量挡在入口之外,保护数据库、下游服务和第三方依赖不被拖垮。它和熔断、降级配合使用:限流管"进不来",熔断管"我不调",降级管"给兜底"。
判断要不要限流的三个信号:
注意:限流是"丢请求"的策略,任何限流都会牺牲部分请求,所以阈值要按"上游能容忍的失败率 × 单机能力"来定,而不是拍脑袋。
以固定时间窗口(如 1 秒)为单位计数,窗口内超过阈值就拒绝。实现最简单,但存在临界突刺:窗口边界处各计一半,实际 2 倍流量可能在同一瞬间通过。
把窗口细分为若干小格(如 1 秒窗口切成 10 个 100ms 小格),每次判断时把整个窗口内的小格计数求和。平滑了临界突刺,Redis 实现时用 ZSET 记录每个请求的时间戳即可,但内存占用随流量线性增长。
请求先进桶,桶按固定速率出水。优点是输出速率恒定、对下游最友好;缺点是无法应对突发流量——即使系统有富余能力,也只能匀速处理。
以固定速率往桶里放令牌,请求取到令牌才能通过,桶容量允许一定量的突发。这是生产中使用最广的方案:既能限平均速率,又能容忍短期突发。
算法 | 允许突发 | 实现成本 | 内存/存储 | 典型场景 |
|---|---|---|---|---|
固定窗口 | 边界突发 | 极低 | 极低 | 简单计数、防刷 |
滑动窗口 | 平滑 | 中 | 随粒度增长 | 精确限流 |
漏桶 | 不支持 | 低 | 低 | 保护下游匀速消费 |
令牌桶 | 支持 | 中 | 低 | 通用接口限流 |
建议:第一版用固定窗口即可上线;有突发流量且希望平滑时换令牌桶;需要精确到毫秒级控制时用滑动窗口。
限流值不是拍脑袋定的,推荐一条计算链:
常见错误:不压测就写死"100 QPS",结果流量一涨就误伤正常用户,或者系统被打穿限流形同虚设。阈值必须能动态调整,并配合监控验证。
生产环境多实例部署时,单机内存计数会互相独立,必须用 Redis 做集中计数。为了防止"先判断后扣减"之间的竞态,整个限流逻辑必须放进一个 Lua 脚本原子执行。
-- KEYS[1] = 限流 key,如 rate:api:order:20260911
-- ARGV[1] = 窗口秒数,ARGV[2] = 窗口内最大请求数
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local current = tonumber(redis.call('GET', key) or '0')
if current >= limit then
return 0
end
redis.call('INCR', key)
if current == 0 then
redis.call('EXPIRE', key, window)
end
return 1窗口用自然时间对齐:key 里带上时间戳,如 rate:api:order:20260911,每小时一个 key,天然避免跨窗口残留。
-- KEYS[1] = 令牌桶 key(存 JSON:capacity/tokens/last_refill)
-- ARGV[1] = 容量,ARGV[2] = 每秒补充速率,ARGV[3] = 本次请求数
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local need = tonumber(ARGV[3])
local now = tonumber(redis.call('TIME')[1])
local data = redis.call('GET', key)
local tokens, last = capacity, now
if data then
local t = cjson.decode(data)
tokens = t.tokens
last = t.last
end
tokens = math.min(capacity, tokens + (now - last) * rate)
if tokens < need then
return 0
end
tokens = tokens - need
redis.call('SET', key, cjson.encode({tokens = tokens, last = now}), 'EX', 3600)
return 1说明:
redis.call('TIME')取的是 Redis 服务器时间,多实例时钟一致,不依赖应用服务器时钟。
public boolean tryAcquire(String key, int capacity, int rate) {
String lua = loadScript("token_bucket.lua");
Long result = redisTemplate.execute(
new DefaultRedisScript<>(lua, Long.class),
List.of(key),
String.valueOf(capacity),
String.valueOf(rate),
"1"
);
return result != null && result == 1L;
}说明:Lua 脚本加载后可常驻,避免每次请求都发送脚本内容;Go、Python 等其他语言通过各自 Redis 客户端的
EVAL/SCRIPT LOAD支持同样的原子语义,实现思路完全一致。
TIME 而不是应用本地时间。限流不是"挡住就完事",后面还得有配套:
429 Too Many Requests 并带 Retry-After 头,客户端据此退避,而不是一律 500 让监控误报;一个容易忽略的点:限流、熔断、降级要联动。限流只挡入口流量,如果依赖的下游已经故障,还要靠熔断快速失败,否则限流放进来的请求依然会拖垮整个调用链。
EVAL 在测试环境验证过限流是接口稳定性的第一道闸门,选型不必复杂:没有突发需求用固定窗口,有突发需求用令牌桶,要求精确平滑用滑动窗口。实现上记住一条铁律——判断和扣减必须原子,生产环境用 Redis + Lua 是成本最低的可靠方案。功能与实现细节以官方文档与实时版本为准。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。