【Redis从入门到精通】第64篇:限流器——用Redis守护你的接口不被刷爆
2026/6/6 2:52:57 网站建设 项目流程

上一篇【第63篇】分布式锁——用Redis实现高可靠锁的正确姿势
下一篇【第65篇】缓存穿透/击穿/雪崩——三大缓存坑的解决方案


产品经理:“咱们的API被爬虫刷了,服务器快撑不住了!”
程序员:“加个限流吧,每秒最多100次请求。”
产品经理:“好的,但大V用户的请求不能被限。”
程序员:“那大V每秒1000次?”
产品经理:“不对,大V不限。”
程序员:“……那限流还有什么意义?”
产品经理:“限的是那些不重要的用户嘛。”
程序员:“好的,明白了,限流是给穷人用的。”

限流(Rate Limiting)是保护系统不被流量冲垮的第一道防线。无论你是做API网关、防止恶意爬虫、还是控制用户配额,都需要一个靠谱的限流方案。Redis凭借其原子操作和丰富数据结构,是实现限流器的绝佳工具。

一、为什么需要限流

没有限流的世界: 恶意用户 你的服务 ┌──────┐ ┌──────┐ │ 请求 │════════════════════>│ 💥 │ │ 请求 │════════════════════>│ 爆炸了 │ │ 请求 │════════════════════>│ │ │ 请求 │════════════════════>│ │ │ 请求 │════════════════════>│ │ │ ... │ │ │ └──────┘ └──────┘ 有限流的世界: 恶意用户 你的服务 ┌──────┐ ┌──────┐ │ 请求 │── ✓ ──────────────>│ 健康 │ │ 请求 │── ✓ ──────────────>│ 运行 │ │ 请求 │── ✗ 429 Too Many │ │ │ 请求 │── ✗ 429 Too Many │ │ │ 请求 │── ✗ 429 Too Many │ │ └──────┘ └──────┘ │ Redis限流器: 每秒最多通过100个请求 超出的直接拒绝

限流的核心目标:

  1. 防DDoS:恶意流量直接拦截在入口
  2. 保护下游:防止突发流量冲垮数据库等后端服务
  3. 公平使用:确保所有用户都能合理使用资源
  4. 成本控制:按配额收费的SaaS服务必须精确计费

二、四种限流算法

Redis可以实现多种限流算法,每种都有其适用场景。我们从简单到复杂逐一讲解。

2.1 固定窗口计数器(Fixed Window)

最简单的限流方案——在一个固定时间窗口内计数,超过阈值就拒绝。

# Redis实现:INCR + EXPIRE# key格式:rate_limit:{user_id}:{window_start}# 伪代码逻辑:GET rate_limit:user:1001:1716278400# 如果不存在:设置值为1,过期时间60秒# 如果存在且 < 100:INCR# 如果存在且 >= 100:拒绝

Lua脚本实现:

-- fixed_window_rate_limit.lualocalkey=KEYS[1]locallimit=tonumber(ARGV[1])localwindow=tonumber(ARGV[2])localcurrent=tonumber(redis.call("GET",key)or"0")ifcurrent+1>limitthenreturn0-- 超过限制,拒绝elseredis.call("INCRBY",key,1)ifcurrent==0thenredis.call("EXPIRE",key,window)endreturn1-- 允许通过end

Java调用:

publicbooleanisAllowed(StringuserId,intlimit,intwindowSeconds){longwindowStart=System.currentTimeMillis()/1000/windowSeconds*windowSeconds;Stringkey="rate_limit:"+userId+":"+windowStart;Objectresult=jedis.eval(fixedWindowScript,Collections.singletonList(key),Arrays.asList(String.valueOf(limit),String.valueOf(windowSeconds)));returnLong.valueOf(1L).equals(result);}

固定窗口的致命缺陷——窗口边界突发

每分钟最多100次请求的固定窗口限流: 时间轴(秒): 0─────────30─────────60─────────90─────────120 │ 窗口1 │ 窗口2 │ 窗口3 │ 窗口4 │ 正常情况:窗口1内100次 ✓ 边界突发: 窗口1最后30秒:100次请求 ← 窗口1用完了 窗口2开始30秒:100次请求 ← 新窗口,又来100次 ↑ 60秒内实际通过了200次! 限流形同虚设

2.2 滑动窗口计数器(Sliding Window)

滑动窗口解决了固定窗口的边界突发问题。核心思路是用**有序集合(ZSet)**记录每次请求的时间戳,通过计算窗口内的请求数量来限流。

滑动窗口 vs 固定窗口: 固定窗口(固定边界): │──── 窗口1 ────│──── 窗口2 ────│ 0 60 120 滑动窗口(随请求滑动): ┌── 当前时刻 ──┐ ←──── 60秒窗口 ──────────────→│ -60s now 窗口没有固定边界,以当前时刻为终点 往前推60秒,数这个范围内的请求数量

Redis ZSet实现:

-- sliding_window_rate_limit.lualocalkey=KEYS[1]localnow=tonumber(ARGV[1])localwindow=tonumber(ARGV[2])locallimit=tonumber(ARGV[3])-- 1. 移除窗口之外的旧记录redis.call("ZREMRANGEBYSCORE",key,0,now-window)-- 2. 统计窗口内的请求数量localcount=redis.call("ZCARD",key)-- 3. 判断是否超过限制ifcount>=limitthenreturn0-- 超过限制,拒绝else-- 4. 添加当前请求(用时间戳作为score,唯一ID作为member)redis.call("ZADD",key,now,now..":"..math.random(1000000))-- 5. 设置过期时间(窗口时间 + 1秒的buffer)redis.call("EXPIRE",key,window/1000+1)return1-- 允许通过end
ZSet 滑动窗口的内存结构: key: rate_limit:user:1001 ZSet (score = 请求时间戳): score member 1716278340000 → "1716278340000:384721" 1716278341000 → "1716278341000:928374" 1716278342000 → "1716278342000:102938" ... ... 1716278398000 → "1716278398000:746392" ← 窗口内 1716278399000 → "1716278399000:582741" ← 窗口内 1716278400000 → "1716278400000:123456" ← 当前请求(now) now - window 之前的记录被 ZREMRANGEBYSCORE 清理掉 ZCARD 统计剩余记录数量 = 窗口内的请求次数

2.3 令牌桶(Token Bucket)

令牌桶是业界最常用的限流算法之一。它以固定速率向桶中放入令牌,请求需要从桶中取令牌才能通过。

令牌桶工作原理: ┌───────────┐ 以固定速率(如每秒100个) │ 令牌生成 │ ═════════════════════╗ │ 器 │ "滴...滴...滴..." ║ └───────────┘ ▼ ┌──────────────┐ │ 令牌桶 │ │ 🪙🪙🪙🪙🪙 │ ← 容量上限(如200个) │ 🪙🪙🪙 │ │ │ └──────┬───────┘ │ ┌────────────────┼────────────────┐ │ │ │ ▼ ▼ ▼ 请求1 ✓ 请求2 ✓ 请求3 ✗(桶空了) (取走1个令牌) (取走1个令牌) (无令牌可用,拒绝) 特点: - 允许一定程度的突发(桶内有积攒的令牌) - 长期平均速率 = 令牌生成速率 - 突发上限 = 桶的容量

Redis + Lua 实现令牌桶:

-- token_bucket.lualocalkey=KEYS[1]localcapacity=tonumber(ARGV[1])-- 桶容量localrate=tonumber(ARGV[2])-- 每秒生成令牌数localnow=tonumber(ARGV[3])-- 获取当前桶状态localbucket=redis.call("HMGET",key,"tokens","last_time")localtokens=tonumber(bucket[1])locallast_time=tonumber(bucket[2])-- 如果桶不存在,初始化为满桶iftokens==nilthentokens=capacity last_time=nowend-- 计算时间差,补充令牌localelapsed=(now-last_time)/1000-- 转换为秒ifelapsed>0thentokens=math.min(capacity,tokens+elapsed*rate)end-- 尝试消费一个令牌iftokens>=1thentokens=tokens-1redis.call("HSET",key,"tokens",tokens,"last_time",now)redis.call("EXPIRE",key,capacity/rate+10)return1-- 允许通过elseredis.call("HSET",key,"tokens",tokens,"last_time",now)return0-- 拒绝end

2.4 漏桶(Leaky Bucket)

漏桶和令牌桶很像,但思路相反——请求像水一样倒入桶中,桶底以固定速率漏水(处理请求),桶满了就拒绝新请求。

漏桶工作原理: 请求1 ──┐ 请求2 ──┤ 请求3 ──┼──→ ┌──────────┐ ┌──────────┐ 请求4 ──┤ │ 桶 │ │ 固定速率 │ 请求5 ──┤ │ 💧💧💧💧 │ ═══>│ 漏出 │ ──→ 处理请求 请求6 ──┘ │ 💧💧💧💧 │ │ (匀速) │ └──────────┘ └──────────┘ 桶满时: 请求N ──→ ✗ 拒绝(桶已满,无法再接水) 特点: - 请求以固定速率被处理(匀速输出) - 不允许任何突发(即使桶是空的,也按固定速率漏出) - 适合对处理速率有严格要求(而非请求速率)的场景

三、四种算法对比

特性固定窗口滑动窗口令牌桶漏桶
突发处理窗口边界可能2倍突发平滑,无边界突发允许突发(桶容量内)不允许突发
实现难度简单中等较复杂中等
精确度低(边界不精确)
内存占用极低(一个计数器)中等(ZSet存时间戳)低(Hash存两个值)
Redis操作INCR + EXPIREZADD + ZREM + ZCARDHGET + HSETLPUSH + LPOP
适用场景简单配额控制API精确限流允许突发的API限流流量整形(流量削峰)
典型代表Nginx limit_reqSentinel限流Google Guava RateLimiterAWS API Gateway

四、Redis Cell:专业限流模块

如果你不想自己写Lua脚本,Redis官方提供了一个专门的限流模块——Redis Cell,基于GCRA算法实现:

# 安装Redis Cell模块(需要在Redis配置中加载)# redis.conf:# loadmodule /path/to/libredis_cell.so# 使用CL.THROTTLE命令限流# CL.THROTTLE key max_burst tokens_per_period periodCL.THROTTLE user:1001153060# 返回:# 1) (integer) 0 ← 0=允许,1=拒绝# 2) (integer) 16 ← 限制总量# 3) (integer) 15 ← 剩余配额# 4) (integer) -1 ← 多少秒后重试(-1=无需重试)# 5) (integer) 2 ← 多少秒后配额恢复满
CL.THROTTLE 参数图解: CL.THROTTLE user:1001 15 30 60 │ │ │ │ │ │ │ └── 时间窗口:60秒 │ │ └────── 周期内允许的请求数:30 │ └────────── 突发容量:15 └─────────────── 限流对象的Key

五、实战:API网关用户级限流

让我们把上面的知识综合起来,实现一个完整的API网关限流方案:

限流架构: 客户端请求 → API网关 → Redis限流器 → 后端服务 │ ├── 检查用户级限流(令牌桶,每秒10次) │ ↓ 通过 ├── 检查IP级限流(滑动窗口,每分钟100次) │ ↓ 通过 ├── 检查接口级限流(固定窗口,每秒1000次) │ ↓ 通过 └── 转发到后端服务
publicclassApiRateLimiter{privatefinalJedisPooljedisPool;publicbooleancheckAccess(StringuserId,Stringip,StringapiPath){try(Jedisjedis=jedisPool.getResource()){// 1. 用户级限流:令牌桶,每秒10次if(!tokenBucketCheck(jedis,"rl:user:"+userId,10,10)){returnfalse;// HTTP 429}// 2. IP级限流:滑动窗口,每分钟100次if(!slidingWindowCheck(jedis,"rl:ip:"+ip,60000,100)){returnfalse;}// 3. 接口级限流:固定窗口,每秒1000次if(!fixedWindowCheck(jedis,"rl:api:"+apiPath,1,1000)){returnfalse;}returntrue;// 全部通过}}}

六、限流触发后的处理策略

被限流了怎么办?直接返回429是最简单的,但用户体验不好:

限流触发后的策略: ┌────────────────────────────────────────────────┐ │ 限流触发 │ └────────────┬───────────────────────────────────┘ │ ┌──────────┼──────────┐ │ │ │ ▼ ▼ ▼ 拒绝 排队 降级 (Reject) (Queue) (Fallback) 拒绝:返回429 Too Many Requests + Retry-After 头部 + 适合:公开API 排队:请求放入队列等待处理 + 使用Redis List或Stream + 适合:用户可感知进度的操作 降级:返回默认值或缓存数据 + 保证核心功能可用 + 适合:推荐/搜索等非核心功能
// 降级策略示例publicObjectapiWithRateLimit(StringuserId){if(!rateLimiter.checkAccess(userId,ip,"/api/search")){// 限流触发,返回缓存数据(降级)returncacheService.getFallbackResults(userId);}// 正常执行returnsearchService.search(userId);}

小结

Redis限流器的核心要点:

  1. 四种算法各有千秋:固定窗口最简单,滑动窗口最精确,令牌桶允许突发,漏桶匀速输出
  2. Lua脚本保原子性:所有限流判断+计数更新必须在一个Lua脚本中完成
  3. 多层限流:用户级 + IP级 + 接口级,分层防护
  4. 优雅降级:被限流不等于返回错误,可以排队或返回缓存数据
  5. 善用模块:Redis Cell模块可以省去自己写Lua脚本的工作

上一篇【第63篇】分布式锁——用Redis实现高可靠锁的正确姿势
下一篇【第65篇】缓存穿透/击穿/雪崩——三大缓存坑的解决方案


需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询