尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Redis限流组件实战:算法、Lua脚本与分布式落地

Redis限流组件实战:算法、Lua脚本与分布式落地 做限流这件事很多人第一反应是上网关或者框架比如Sentinel、网关插件之类。但真到业务里你会发现很多时候你需要的只是一个轻量、可控、能按自己业务规则定制的限流能力这时候用Redis手写一套限流组件往往比整合一堆框架更顺手。这篇文章就从我实际开发Redis限流功能的经历出发把限流算法、Redis命令落地、分布式场景的坑、参数调优这些事一次性讲清楚。先说清楚这篇文章适用于谁后端开发要给自己服务加限流、接口要做防刷、促销活动要保护下游、或者面试前想彻底搞懂Redis限流原理的人。看完你能直接照着写出一套可上线的Redis限流组件也能搞明白为什么很多团队最终选了某一种方案而不是另一种。1. 为什么选Redis做限流让多实例共用一个闸门1.1 单机限流在多实例部署下等于没有限流很多服务刚开始是单机部署用Guava的RateLimiter或者ConcurrentHashMap记一下请求次数简单粗暴本地一锁就完事。但一旦服务横向扩容到三台、五台本地限流瞬间就失效了——每个实例各自记自己的计数每台机器都能放过自己阈值内的流量实际上放到网关的总流量变成了原来的N倍下游照样被冲垮。Redis限流的本质是把计数和判断这两个动作从应用进程里挪到一个所有实例都能访问的公共存储上。所有请求都去Redis里查同一个计数器谁放行、谁拒绝由这把公共的尺子说了算。这样无论是三台还是三十台实例对外表现就像一台机器在限流闸门是统一的。1.2 为什么不用网关限流而要在业务里做不否认成熟网关的限流能力强但网关限流的粒度通常是域名、路由、客户端IP它很难感知你业务里面的用户维度、订单维度、库存维度的精细规则。举个实际例子秒杀场景里你要限制每个用户每秒最多点5次购买按钮这个用户维度的信息在网关层根本拿不到只有在业务代码里才能从登录态、请求参数里取出来。另一个现实原因是很多内部系统的流量根本不过网关服务之间直接Feign调用或者通过消息队列异步触发这种场景你只能在自己这层做控制。用Redis在业务代码里做限流等于把一把可编程的闸门装到了每个需要保护的地方规则完全由业务自己定这是网关方案很难替代的灵活性。2. 四种限流算法的Redis落地姿势动手写代码之前先把算法这关过了。网上讲限流算法的文章很多但大多数停留在概念层面真正用Redis命令怎么实现、每个算法有什么坑很少有人说透。我按实际工程中常用的四种算法逐一拆。2.1 固定窗口计数INCR配合EXPIRE三行命令解决问题固定窗口的思路最简单把时间切成固定长度的窗口比如1秒、1分钟每个窗口内维护一个计数器超过阈值就拒绝。用Redis实现就是两个命令的事INCR rate:limit:user:1001:2025-01-01-12:30 EXPIRE rate:limit:user:1001:2025-01-01-12:30 60逻辑就是每次请求进来对当前的key做自增第一次自增的结果如果是1顺手设一个过期时间让这个计数器在窗口结束后自动消失。当自增结果超过阈值直接返回拒绝。这里的核心技巧是用过期时间代替定时清理。很多人会陷入一个误区专门起一个定时任务去清空计数器其实完全没必要Redis对key设了过期时间之后在到期的那一刻key自然失效内存也会被后续的惰性删除机制回收这样既不用额外写清理逻辑也不会因为某个窗口的计数器忘了删导致Redis内存一直涨。但这个方案有个著名的边缘问题临界突刺。假设限制每分钟100次请求用户在12:30:59这一秒打了100次又在12:31:00这一秒打了100次跨窗口交界处两秒内打进了200次服务器直接被干趴。固定窗口的容忍度比较差如果业务场景对突发流量完全无法接受就要上滑动窗口。2.2 滑动窗口用ZSET把每一秒都当作起点滑动窗口解决临界突刺的办法是不再以某个固定起点切窗口而是以当前这一时刻往前推一个窗口长度统计这个时间段内的请求数量。每个请求进来都记录一个带时间戳的成员统计的时候把窗口外的数据剔除剩下的就是有效计数。Redis里用ZSET实现非常自然ZADD rate:sliding:user:1001 1735709400 req_xxx_001 ZREMRANGEBYSCORE rate:sliding:user:1001 0 1735709340 ZCARD rate:sliding:user:1001每次请求先ZADD加一条记录score用当前秒级时间戳然后ZREMRANGEBYSCORE把窗口起点之前的旧记录全部删掉最后ZCARD数一下集合里还剩几条。如果剩余数量超过阈值就拒绝否则放行。这套方案里最需要留意的就是ZSET成员必须唯一。如果直接用请求时间戳当member同一个秒内来的两个请求会覆盖同一条记录计数直接少算。所以member里必须拼上请求ID、UUID这类唯一值我习惯拼请求ID随机数保证集合里每个成员独一无二。滑动窗口的最大缺点是内存消耗。每个放行的请求都要在ZSET里存一条如果限流阈值是每秒1万窗口60秒最高峰时集合里有60万条成员即使每条只占几十字节也是几十MB的量级。所以线上一般不会用纯精细的滑动窗口做高频接口而是把它用在用户维度的低频防刷场景比如单个用户每秒最多操作3次这种场景数据量完全可控。2.3 令牌桶应对突发流量的平衡派令牌桶的思路是系统以固定的速率往桶里放令牌桶有容量上限请求来了必须拿到令牌才能放行。它的好处是既能限制长期平均速率又能容忍一定程度的突发流量——因为桶里可以积攒最多容量上限的令牌短时间内的峰值只要不超过桶容量都可以被消化。Redis实现令牌桶的经典做法是用Lua脚本维护两个字段上次补充时间和当前令牌数。每次请求进来先根据距上次补充时间的时间差计算这段时间新产生了多少令牌加到当前令牌数里但最多加到桶容量然后尝试拿一个令牌。这是我实际在用的Lua脚本可以直接抄-- KEYS[1] 令牌桶key -- ARGV[1] 桶容量 capacity -- ARGV[2] 每秒补充速率 rate -- ARGV[3] 当前时间戳秒 -- ARGV[4] 本次请求要消耗的令牌数默认1 local capacity tonumber(ARGV[1]) local rate tonumber(ARGV[2]) local now tonumber(ARGV[3]) local requested tonumber(ARGV[4]) local lastRefillTime tonumber(redis.call(HGET, KEYS[1], lastRefillTime)) if lastRefillTime nil then lastRefillTime now redis.call(HSET, KEYS[1], lastRefillTime, now) redis.call(HSET, KEYS[1], tokens, capacity) end local tokens tonumber(redis.call(HGET, KEYS[1], tokens)) local elapsed math.max(0, now - lastRefillTime) local newTokens math.floor(elapsed * rate) tokens math.min(capacity, tokens newTokens) redis.call(HSET, KEYS[1], lastRefillTime, now) redis.call(HSET, KEYS[1], tokens, tokens - requested) if tokens requested then return 1 else return 0 end这段脚本每跑一次相当于完成了一次原子性的补充令牌扣减令牌操作。你可能会问为什么不在应用里计算完再更新Redis因为读取-计算-写回这3步在并发环境下必然出现竞态两个请求同时读到同样的令牌数可能都判定自己拿得到结果实际超发了。用Lua脚本让Redis把这3步当作一个整体执行才保证判断的准确性。2.4 漏桶削峰填谷的另一种思路漏桶和令牌桶最大的区别在于令牌桶允许突发漏桶完全禁止突发它把流量像水倒进一个有漏口的桶里一样无论上游来得多猛下游始终以固定速率处理。Redis实现漏桶其实不需要真的在Redis里维护队列一种偷懒但有效的做法是用令牌桶脚本的变体把桶容量设为1、补牌速率设为业务允许的处理速率这样任何瞬间的并发超过1的请求都会被拒掉相当于强制将一个请求一个请求地放给下游。不过老实说我在实际项目里真正用Redis做漏桶的场景很少。漏桶的意义在于让下游以恒定速率消费这通常是消息队列的职责——Kafka、RocketMQ天然就是漏桶模型你根本没必要自己用Redis造一个。所以我的建议是如果下游消费速率必须恒定优先考虑接MQ如果只是要保护接口不被打爆令牌桶就够用了。3. 手写一套可上线的Redis限流组件算法说完下面进入真正的开发环节。这一节我会从头到尾设计一个可以拿到生产环境用的限流组件包括Lua脚本的管理、Spring Boot集成方式、以及几个我在多次迭代里踩过的坑。3.1 为什么所有判断逻辑都塞进Lua脚本前面提过一嘴竞态问题这里展开讲透。如果不在Lua里做原子操作任何先查再写的逻辑在并发场景下都有问题。用RedisTemplate的普通API演示一下错误写法Long count redisTemplate.opsForValue().increment(key); if (count 1) { redisTemplate.expire(key, window, TimeUnit.SECONDS); } if (count limit) { // 拒绝 }这段代码看起来没问题INCR命令本身是原子的EXPIRE也是原子的但组合起来不是。极端情况下进程在INCR之后、EXPIRE之前宕机这个key就成了永不失效的计数器阈值会被这个孤儿key长期占据导致后续所有请求都被误伤。更严重的场景是多个请求同时INCR同一个key大家一起读到的值可能都小于等于limit顺手全放行。把整套逻辑写进Lua脚本之后Redis执行脚本是单线程的脚本里的命令之间不会被其他命令插队等于拿到了整个Redis实例粒度的互斥锁。这就是为什么生产级的限流组件一定走Lua。3.2 组件骨架设计把限流规则当成可配置参数我的组件设计分了4层每层职责单一方便后续扩展第一层注解/调用入口。对外暴露一个RateLimit注解里面可以声明key、阈值、窗口时间、算法类型。这样业务方使用成本最低方法上加一行注解就完事。第二层规则解析。把注解里的参数和一个限流资源标识拼接成最终要操作的Redis key。这里的关键点是key必须包含完整维度比如用户限流就用rl:user:{userId}接口限流就用rl:api:{接口路径}IP限流就用rl:ip:{ip}不能只用一个固定key否则所有用户共享一个计数器那就不叫用户维度限流了。第三层执行器。根据算法类型选择不同的Lua脚本把参数传进去拿到Redis返回的0或1。执行器这一层要内置一个脚本加载的缓存避免每次请求都重新加载Lua脚本。第四层降级处理。Redis万一挂了怎么办不能因为限流组件把主业务流程拖死我后面会专门讲降级策略。3.3 Spring Boot集成示例下面给出一个可以直接跑的Spring Boot样例我用的是Spring Data Redis的DefaultRedisScript把Lua脚本注册成Bean初始化时一次性加载避免每次调用都走EVAL的资源开销。Component public class RedisRateLimiter { private final RedisTemplateString, Object redisTemplate; private final DefaultRedisScriptLong tokenBucketScript; public RedisRateLimiter(RedisTemplateString, Object redisTemplate) { this.redisTemplate redisTemplate; this.tokenBucketScript new DefaultRedisScript(); this.tokenBucketScript.setScriptText(TOKEN_BUCKET_LUA); this.tokenBucketScript.setResultType(Long.class); } public boolean tryAcquire(String key, int capacity, int rate) { ListString keys Collections.singletonList(key); Long result redisTemplate.execute( tokenBucketScript, keys, String.valueOf(capacity), String.valueOf(rate), String.valueOf(System.currentTimeMillis() / 1000), 1 ); return result ! null result 1L; } }再配合一个注解切面业务方的用法就是RateLimit(key order:submit:#{userId}, capacity 10, rate 2) public Result submitOrder(Long userId) { // 业务逻辑 }#{userId}这种占位符是在切面里从SpEL表达式解析出来的这样同一个方法可以根据不同用户ID生成不同key天然实现用户维度限流。这里有个我特别想提醒的细节命令超时设置。Redis限流是同步调用一旦Redis响应慢你的业务线程就会卡在execute上。必须给RedisTemplate设置合理的超时时间比如连接超时200ms、读超时200ms宁可让这部分流量漏过去也不能让限流器变成业务的瓶颈点。这跟很多人的直觉相反——限流器本来是保护业务的结果自己先拖垮了业务这是线上最容易犯的错。4. 分布式环境与Redis自身的坑把组件跑通只是第一步线上环境会遇到一堆单机Demo里根本碰不到的问题。我按踩坑频率排序逐个说。4.1 多实例时间偏差与窗口边界固定窗口和令牌桶脚本都依赖时间戳。我的令牌桶脚本里时间戳是应用服务传进去的System.currentTimeMillis() / 1000不是Redis服务器的时间。这么设计是因为每个应用实例的时钟可能不完全一致但同一台实例内部的一致性足够好。如果你用TIME命令让Redis自己取时间反而会因为不同请求落在不同Redis节点上导致补牌基准时间错乱。不过多实例部署时A实例传进来的是10:00:01B实例传进来的是10:00:02这两秒的偏差对令牌桶影响不大因为补牌速率是按秒算的偏差一两秒最多影响几个令牌的积攒量。但如果你做的是固定窗口并且窗口只有1秒这种偏差就可能让不同实例的用户落在不同的窗口key上限流效果打折。所以窗口越小对多实例时钟一致性要求越高这是选算法时必须权衡的点。4.2 Redis故障限流器不能比业务先挂Redis挂了之后的策略业界主流有三种fail-open放行、fail-closed拒绝、fail-local本地限流兜底。我强烈建议用fail-open加一层本地限流兜底检测到Redis调用异常后放行当前请求不阻塞主流程同时在本地用ConcurrentHashMap启动一个简易的本地限流器限制住Redis不可用期间的极端流量避免彻底不设防本地限流器的阈值可以设成Redis阈值的1.5倍左右宁可放宽一点也不要误伤正常用户。我用AOP切面实现的降级逻辑大概是这样的tryAcquire方法内部捕获RedisConnectionFailureException、超时异常捕获后返回一个本地桶是否还有余量的判断结果。等Redis恢复自动切回远程限流。这套降级逻辑必须在开发阶段就把测试覆盖上否则线上Redis抖动的时候你连自己到底是放行还是拒绝的都不知道。4.3 性能与精度权衡Redis命令真的越快越好吗限流器处在请求链路的必经之路上每多一次Redis往返就多一份延迟。我这里给出几个经过实践检验的取舍建议关注点建议脚本调用方式脚本在初始化时LOAD一次之后用EVALSHA调用省去每次传输脚本的带宽Redis连接使用连接池初始大小和最大大小要按接口QPS预估连接饥饿会导致限流本身变慢网络延迟限流Redis和应用服务尽量同机房部署跨机房一次2msQPS上来后不容忽视高QPS接口对每秒上万次的接口可以改成每N次请求抽样检查一次用精度换性能命令合并同一请求内多个限流点可以用pipeline合并减少RTT第四点值得展开我之前有个接口QPS接近2万如果每个请求都做一次Redis Lua调用单是限流这一层就吃掉一大截Redis性能。后来改成采样限流——只在每5个请求里取1个做全量检查结果是限流精度从100%降到约80%但Redis的压力直接降到原来的1/5。精度没有完美但系统稳了这在流量峰值场景下是值得的。4.4 key的过期时间与内存回收问题ZSET滑动窗口的key如果不设过期时间用户维度限流的key会无限堆积内存迟早爆掉。有人会把过期时间设成窗口长度相等比如60秒窗口就给key设60秒过期。但这里有个细节窗口刚建立的那一秒后面59秒内不断有请求ZADDkey的过期时间是从创建那一刻算的不是从最后一次写入算的。如果窗口的滑动不断被新的请求推进旧的过期时间会提前把整个key干掉导致限流窗口提前重置。我惯用的做法是给滑动窗口key设置窗口长度 × 2的过期时间同时在每次ZADD之后顺手PEXPIRE续期一次确保key在窗口范围内永远存活窗口结束后最多再过窗口长度的时间自动被回收。虽然会多存一段时间的冗余数据但换来了逻辑的确定性。5. 限流参数怎么定以及和Sentinel的互补5.1 阈值不是拍脑袋定的限流的阈值到底设多少经常是开发群里争论半天的话题。我给出一套自己一直在用的标定方法先看下游承受能力找到你限流要保护的下游是谁数据库、第三方接口还是核心服务。压测得出下游的极限QPS这个值乘以0.7作为硬上限。再看业务容忍度比如登录接口用户多点几次问题不大可以宽松一点支付接口失败一次用户就流失要尽量少限流。所以同样的下游极限不同业务接口要乘不同的系数。最后看流量画像从监控里看正常高峰期的QPS是多少限流阈值建议设在正常峰值×1.2附近。设太低高峰期正常用户被误杀设太高保护意义就没了。这三个数取交集再结合线上压测验证一轮比拍脑袋靠谱得多。5.2 限流后的响应体设计限流拒绝不是简单返回一个503就完了。实际开发中我发现被限流的用户如果收到明确的提示和重试时间投诉率会大幅下降。所以我设计的限流响应里固定包含三个字段{ code: 429, message: 请求过于频繁请稍后重试, retryAfter: 5, requestId: a8f2... }retryAfter让客户端可以精确等待requestId方便用户在反馈问题时排查。如果你做了异步化的重试队列还可以在这个响应里带上已进入排队的标识用户体验会更好。5.3 有了Sentinel还需要自己写Redis限流吗最近不少人在问Sentinel限流配置和Nacos动态配置的事。我的看法是框架限流和Redis自研限流不是替代关系是互补关系。Sentinel这类框架擅长的是资源维度的统一治理配合Nacos把限流规则做成动态配置下发适合平台级、网关级的统一管控规则变更不用发版这一点确实方便。但Sentinel默认的限流数据是存储在应用内存里的它的集群限流方案要引入Token Server组件架构复杂度一下子上来了。而Redis限流天然就是集群维度的不需要额外组件规则虽然硬编码但业务维度可以做到非常细。我现在的项目是两条腿走路入口流量、核心RPC接口用Sentinel做粗粒度保护规则走Nacos动态调整涉及具体用户、具体订单的精细化限流用Redis自研组件。前者管服务别被打挂后者管单个用户别薅羊毛各管各的互不干扰。5.4 配套的四大监控指标限流组件上线后如果没有任何监控等于盲开。我一般会为每个限流点埋四类指标通过量passed每秒放行了多少请求用来评估流量是否接近阈值。拒绝量blocked每秒拒绝了多少请求这个指标突增可能意味着有人刷接口或者阈值设太低。降级触发次数fallbackRedis故障后走了本地限流的次数用于发现Redis抖动。限流耗时cost每次tryAcquire的Redis调用耗时P99超过50ms就该优化连接池或脚本了。这四类指标全部打到Prometheus上配合Grafana出面板。我见过很多团队限流组件写得挺漂亮但监控一个都没埋结果线上被限流的用户骂翻天开发还不知道发生了什么。监控不是可选项是限流组件的一部分。6. 最后再提几个实战细节文章写到这核心的限流开发内容已经齐了。最后分享几个我踩过多次坑之后沉淀下来的细节这些内容常规文档里不会写。**第一限流key的命名空间一定要带版本号。**比如rl:v2:user:{userId}因为限流规则升级后旧key的数据可能和新规则不兼容带上版本号可以自然隔离也方便出问题时灰度回滚。**第二Lua脚本里避免使用KEYS[1]以外的动态key。**Redis Cluster模式下Lua脚本要求所有key必须落在同一个slot里如果你在脚本里用redis.call(ZADD, prefix_ .. user_id, ...)动态拼key一旦user_id的hash不在同一个slot整个脚本会报错。我的做法是外层拼好完整的key只传一个KEYS进去。**第三压测环境和生产环境的限流参数要分开。**我就出过一次事故压测脚本写死了阈值是1000结果压测平台用的配置中心和线上是同一套压测当天把线上所有用户都限流了。后来所有限流参数都做成环境隔离压测环境单独一套配置这件事之后再没发生过。**第四限流拒绝也要记日志但必须采样。**被限流的请求日志量可能非常大全量打印会把日志系统打爆。我在切面里对拒绝请求做了1%的采样打印既能在排查问题时找到线索又不至于把磁盘搞满。Redis限流开发这件事说难不算难说简单也不简单真正决定成败的往往不是算法本身而是你对并发、故障、监控这些边缘问题的处理。希望这篇文章能帮你把每条路都走通少踩几个我当年踩过的坑。
返回列表