ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis限流策略全解析:固定窗口、滑动窗口与令牌桶实践

Redis限流策略全解析:固定窗口、滑动窗口与令牌桶实践 总有那么几个瞬间你会觉得自己的服务像春运期间的检票口搞活动时流量突然打进来、推广消息刚放出去、热搜某天夜里上了榜QPS从几百飙到几万数据库连接被打满下游接口超时监控看板全飘红。这时候大家第一个想到的往往是Redis因为又快到限流在技术上只要数数就行而Redis天生就是干这事的内存操作、单线程模型带来的原子计数、自带的过期机制以及最关键的——它是所有应用实例共享的不用你去搞每台机器各自的本地计数。这篇我把自己在不同项目里反复用的几种Redis限流策略连同用完才知道的坑一起整理出来希望能帮你们少走点弯路。无论你是刚接触这个概念还是已经在生产环境里写过限流代码应该都能从里面找到点有用的东西。1. 限流为什么绕不开Redis从一次流量尖峰说起先讲个真实的场景。前两年我维护过一个面向C端的小程序接口平时QPS也就两三百结果有一次平台突然上了个活动入口流量在十秒内翻了二十倍。当时我们的第一反应不是扩容而是先保命——把过载请求挡在前面别让它们全部涌到数据库。当时给接口做的方案就是用Redis计数限流。为什么选Redis而不是用应用进程里现成的限流器不仅仅是快不快的问题。你自己想想服务部署了八个节点如果每个节点各自数各自的那限流的阈值就变成了八倍完全失去意义。限流必须是全局视角那就要有一个所有节点都能访问的公共计数器。Redis天然就是干这个的。另外Redis在应对这种场景时有几个特性非常难得单线程模型对同一个key执行INCRRedis内部是串行处理的不会出现多个进程同时读到一个旧值、然后各自加一的竞态问题。内存读写极快一个请求进来做一次INCR加一次判断整体耗时通常不到一毫秒对主业务几乎没有感知。自带TTL限流计数自动过期省得你写一堆定时清理的脏活。数据类型丰富除了最基本的计数还能配合ZSET实现更精细的滑动窗口配合Lua实现令牌桶后面细说。所以说到底限流这件事要解决的是在一段时间内最多放过去多少请求而Redis几乎搭好了所有积木你要做的只是选择一种策略并且把边界条件想清楚。接下来我把几种主流策略逐个拆开讲。2. 固定窗口计数先会走再想跑2.1 INCREXPIRE的基础实现最纯朴的限流就是固定窗口计数。思路上去不复杂给每个业务或者每个用户建一个key比如rate:user:123窗口是一分钟允许一百次请求。每来一次请求就执行INCR如果增加后的值小于等于一百就放行大于一百就拒绝如果这是第一次请求顺手加一个EXPIRE六十秒后自动清零。很多人用RedisDesktopManager看数据时最容易发现的怪现象就是窗口的key一会儿消失一会儿重建其实那就是EXPIRE在生效。用Python写可能是这个样子import redis r redis.Redis(host127.0.0.1, port6379, db0) def fixed_window_limit(key: str, max_requests: int, window_sec: int) - bool: current r.get(key) if current is not None and int(current) max_requests: return False count r.incr(key) if count 1: r.expire(key, window_sec) return True但这里有一个一眼看不出问题的BugGET和INCR不是原子的。在并发高的场景下两个请求可能同时读到同一个旧值同时判断还没超限然后都放行。那数量就不准了。生产环境里肯定不能这么写要写成Lua脚本放在Redis端原子执行。下面这段是真正能上线的固定窗口限流脚本-- KEYS[1]: 限流使用的key -- ARGV[1]: 窗口大小单位秒 -- ARGV[2]: 窗口内允许的最大请求数 local current redis.call(GET, KEYS[1]) if current and tonumber(current) tonumber(ARGV[2]) then return 0 end local count redis.call(INCR, KEYS[1]) if count 1 then redis.call(EXPIRE, KEYS[1], ARGV[1]) end return 1调用的时候拿返回值判断1是放行0是拒绝。这样整个读旧值、判断、自增、设置过期是一个原子动作不会在并发下漏算。2.2 网上说的INCR不准到底是怎么回事有一阵子我注意到网上不少人搜redis incr不准。这事我起初也很困惑因为Redis单线程对同一个key的INCR理论上不可能是不太准确的。后来我排查了好几个项目发现大家说的不准往往是下面几种情况第一固定窗口在边界处天然产生两倍流量。比如一分钟限一百次用户在第59秒请求了第100次放行到第60秒窗口重置再请求第100次又放行。如果把这两秒合并起来看实际放行了200次。严格来说这不是INCR的锅而是算法本身的临界问题。第二INCR之后忘了处理EXPIRE的语义。当执行INCR时当前值已经存在只是如果在原窗口内就不应该再刷EXPIRE。如果错误地每次都执行EXPIRE那一个持续高流的请求就会让key一直延期窗口永远不结束看起来就像计数越积越怪。第三多个限流维度共用了同一个key。你可能给每用户每分钟100次和全接口每分钟1000次分别建了key但配置错了前缀结果用户维度的计数被其他用户或全局限流累加看起来数据就像发了疯。所以碰到计数不准先别急着怀疑Redis把窗口边界、EXPIRE策略、key隔离这三个地方检查一遍大概率能定位问题。2.3 固定窗口的临界击穿问题前面提到了边界处的两倍流量这在某些场景是很致命的。假如你限的是每分钟10笔下单请求窗口切换的那一瞬间理论上用户可以连续下20单直接触发超卖或库存问题。凡是涉及资金、库存、订单这类有强约束的业务固定窗口都显得太粗了。解决办法有两条路一是把窗口颗粒度缩小比如改成每十秒一个窗口但这只是把问题从60秒压缩到10秒没有根除二是直接上滑动窗口通过更细的时间切片把边界抹平。于是就有了下一节要说的方案。3. 滑动窗口把时间切成细粒度切片来解决边界问题3.1 ZSET的实现原理滑动窗口的思路是不要只记这一分钟一共来了多少请求而是把每个请求的时间点都记下来查询时只统计当前时间往回一个窗口内有多少请求。这个思路在Redis里刚好可以用ZSET实现拿时间戳当score拿请求唯一标识或者直接用时间戳加序号当member。每次来请求先把这个key里所有早于当前时间减窗口长度的成员删掉再统计剩余数量如果没超限就ZADD一个新的成员进去然后放行。正常来说用Python调redis-py这套逻辑写成管道的伪代码是import time import uuid import redis r redis.Redis(host127.0.0.1, port6379, db0) def sliding_window_limit(key: str, max_requests: int, window_sec: int) - bool: now time.time() member str(uuid.uuid4()) # 尽量保证唯一 pipe r.pipeline(transactionTrue) pipe.zremrangebyscore(key, 0, now - window_sec) # 清理过期的请求记录 pipe.zadd(key, {member: now}) pipe.zcard(key) # 统计窗口内请求数 pipe.expire(key, window_sec) _, _, count, _ pipe.execute() return count max_requests但这种管道方式虽然不是纯原子的要知道Pipeline只是把命令打包发送没有真正的条件判断快照。如果要保证严格正确还得交给Lua。下面这段是我在自己的开源项目里用了很久的滑动窗口Lua脚本-- KEYS[1]: 限流key -- ARGV[1]: 窗口大小单位毫秒 -- ARGV[2]: 窗口内最大请求数 -- ARGV[3]: 当前时间戳毫秒 local window tonumber(ARGV[1]) local limit tonumber(ARGV[2]) local now tonumber(ARGV[3]) redis.call(ZREMRANGEBYSCORE, KEYS[1], 0, now - window) local count redis.call(ZCARD, KEYS[1]) if count limit then return 0 end redis.call(ZADD, KEYS[1], now, now .. - .. math.random(1000000)) redis.call(PEXPIRE, KEYS[1], window) return 13.2 内存和性能的取舍滑动窗口比固定窗口准但代价也很明显每一个请求都要在ZSET里留一个member如果窗口是一分钟、限流一万次那这个key里就可能躺着几千上百个member。高流量下如果你的维度很粗比如全局接口限流内存压力会立刻显现。我在生产里处理过几次的优化方案有两个一个叫时间分桶压缩。为了不每一个请求都放进ZSET既然精度要求是一个窗口内控制到秒级聚合就够那可以以秒为单位把同一秒内的请求数记为同一个member的score后面再对该member的score做累减。这样窗口内的member数量最多是窗口长度那么多流量再大也就压到几百个。另一个叫只保留边界时间点。滑动窗口其实可以拆成当前窗口已统计的计数上一完整窗口的残片计数的加权形式。比如记录每分钟的计数查询时通过上一分钟的计数乘以已过时间比例来估算。不过估算终究有偏差如果业务对精度要求高还是老老实实用ZSET或者下面说的令牌桶。3.3 什么时候不值得用滑动窗口说实话滑动窗口在很多业务里是杀鸡用牛刀。如果限流的目的只是保护后端不被打垮而不是为用户提供精确的公平配额那固定窗口加一个冷却机制比如超限后强制等待若干秒已经足够。滑动窗口适合的场景是那些限流结果会被用户直接感知、并且用户会盯着数字卡点刷的地方比如签到、抽奖次数、短信发送频率。这时候窗口边界出现双倍放行是需要避免的。4. 令牌桶与漏桶平滑流量还是允许突发如果你觉得固定窗口和滑动窗口都太生硬只知道放或不放那我们应该聊聊令牌桶和漏桶。这两种算法核心目的只有一个让流量别那么极端。4.1 令牌桶怎么在Redis里落地令牌桶的思想是系统按固定速率往一个桶里放令牌桶的容量有上限每个请求必须从桶里取一个令牌才能过去。没有令牌就拒绝或者排队。它的好处是允许一定程度的突发流量——桶里攒了令牌短时间你能一次取很多个但长期平均速率是有限的。Redis里实现令牌桶并不真的需要后台任务去持续补充令牌完全可以在请求进来时按需计算。你只需要记住上次取令牌的时间然后算出这段时间内新增了多少令牌。这个计算在Lua里很自然-- KEYS[1]: 令牌桶key -- ARGV[1]: 桶容量 -- ARGV[2]: 每秒补充速率 -- ARGV[3]: 当前时间戳秒小数也行 -- ARGV[4]: 本次请求消耗的令牌数通常是1 local capacity tonumber(ARGV[1]) local rate tonumber(ARGV[2]) local now tonumber(ARGV[3]) local need tonumber(ARGV[4]) local meta redis.call(HMGET, KEYS[1], tokens, last_time) local tokens tonumber(meta[1]) local last_time tonumber(meta[2]) if tokens nil then tokens capacity last_time now else local elapsed math.max(0, now - last_time) tokens math.min(capacity, tokens elapsed * rate) end if tokens need then tokens tokens - need redis.call(HMSET, KEYS[1], tokens, tokens, last_time, now) return 1 else redis.call(HMSET, KEYS[1], tokens, tokens, last_time, now) return 0 end往这个方法里传时间戳的时候注意应用端和Redis服务器时钟尽量保持一致。如果你用Java、Python这种产生时间戳比较方便的语言直接从应用传当前毫秒数进去比在Lua里调用TIME命令更高效也避免了TIME命令的额外开销。4.2 漏桶下游只能吃一口一口喂的请求漏桶和令牌桶的区别在于令牌桶允许桶里有积攒的令牌也就是允许瞬时突刺而漏桶是无论上游来多猛我自岿然不动地按固定速率漏出去。漏桶更适合做平滑出口比如你的服务要调一个很弱的下游接口它每秒只扛得住20个请求那漏桶就是最适合的。在Redis里写漏桶通常有两种姿势。一种是用List当队列请求来了先LPUSH进去然后由一个消费逻辑按固定速率RPOP去处理。另一种是懒计算记录桶里当前的积压量和上次漏出的时间请求来时先算出这段时间漏掉了多少再将新请求量加入如果积压超出桶深就拒绝。第二种不依赖后台任务代码风格和令牌桶很像只不过是把补充换成了漏出。4.3 三种限流算法的选型对比这里可以给个小总结避免大家在选型时反复纠结策略核心特点适用场景注意点固定窗口计数简单、内存占用小、Redis原生配合好非关键路径、粗粒度保护、每个维度都能建key窗口边界会出现双倍流量滑动窗口ZSET精确到秒甚至毫秒、无边界问题用户可感知的配额、抽奖、短信验证码内存开销随QPS增长需时间分桶优化令牌桶允许有限突发、长期匀速需要同时支持短时爆发和整体平均限制的网关层需维护令牌余额和上次时间漏桶出口绝对平滑下游能力极弱、对调用频率有硬性要求容易误伤正常突发流量只有一个要素被忽略的时候很容易误判限流的度量口径是按请求数还是按并发数令牌桶和漏桶本质上都在限制速率如果你只需要限制同时处理的并发数应该用计数信号量而不是流速算法。这两个概念在面试里常被放到一起聊但在实战里搞混了会把架构带歪。5. Lua脚本不是可选项原子性才是限流落地的底线5.1 为什么要强调Lua我刚写限流那会儿一直觉得用Redis命令组合就能搞定先GET判断再INCR拼一个Pipeline不就好了后来真正压测时才发现并发一上来放行的请求数能比配置的阈值高出不少。原因前面也提过检查、自增是两个独立步骤进程切换或并发交错时多个请求都能读到同一个老计数然后一起放行。限流的本质是读-判断-写这个动作必须原子完成。在Redis里最正统的原子手段就是Lua脚本。Redis从2.6版本开始支持EVAL脚本执行期间Redis不会穿插其他命令别的客户端的命令会排队等脚本跑完。所以只要你的限流逻辑在脚本里写完了读旧值、判断、写新值并发压进来也没办法插队。5.2 一段完整的滑动窗口限流Lua脚本示例我在生产环境用的滑动窗口限流脚本是这个样子。为了控制ZSET的体积我用秒级时间戳作为member、同一秒内的请求累加到同一个member的分桶思路把内存消耗降了一个量级-- KEYS[1]: 限流维度key -- ARGV[1]: 窗口大小单位秒 -- ARGV[2]: 窗口内最大请求数 -- ARGV[3]: 当前时间戳精确到秒 local window tonumber(ARGV[1]) local limit tonumber(ARGV[2]) local current_time tonumber(ARGV[3]) redis.call(ZREMRANGEBYSCORE, KEYS[1], 0, current_time - window) local count 0 local all redis.call(ZRANGE, KEYS[1], 0, -1, WITHSCORES) for i 1, #all, 2 do count count 1 end if count limit then return 0 end -- 以当前秒为member对同一秒内的计数做累加 local member tostring(current_time) local added redis.call(ZINCRBY, KEYS[1], 1, member) redis.call(EXPIRE, KEYS[1], window 1) return 1这里需要注意两个细节。一是清理过期成员和统计之间在单个Lua脚本里执行天然是原子的不会出现清到一半被新请求插入的中间状态二是我给EXPIRE加了个1秒冗余防止窗口边界处的key过早失效。5.3 EVALSHA和脚本缓存那些事上线久了你会发现每次请求都发一串完整的Lua脚本体虽然功能没问题但对带宽和CPU有点浪费。官方推荐的做法是使用EVALSHA也就是先把脚本通过SCRIPT LOAD加载到Redis里得到一个SHA值之后每次调用只传SHA。不过这里面有个经典坑如果Redis重启脚本缓存会清空你的SHA就失效了调用会报错。线上一般做法是启动时统一加载一次加载失败自动落回EVAL。还要注意Redis主从环境如果发生故障转移新主节点可能没有旧的脚本缓存客户端要有重试机制。主从这块我再多说一句限流通常依赖写主库如果主节点宕机在高可用切换完成之前限流可能短暂失效。对于核心支付链路我一般会再叠加一层网关限流兜底不把所有鸡蛋放在一个Redis实例里。6. 限流参数怎么定我踩过的阈值和窗口坑6.1 阈值不是拍脑袋要有压测数据支撑新人在配置限流时常犯的毛病就是把阈值定得很随意。比如这里限100吧——那100是从哪来的如果后端接口本身只能扛住每秒50个请求你限100就起不到保护作用如果数据库每秒能扛300你限50又会误伤业务。我在实际项目中通常按下面这种方式定参数先压测出核心接口的最大安全QPS注意是P99延迟还不恶化的那个值而不是极限压垮值。留出40%到50%的余量。比如压测安全QPS是500限流阈值就设在250到300。窗口长度按照业务习惯来定。Checkout下单这类用户高频操作可以用1秒窗口加每秒限额抽奖活动可能按分钟累计限额。分布式场景下注意不同实例的时钟误差如果是多机房部署记得统一通过Redis服务器时间或NTP校准。至于为什么留一半余量因为限流本身也有抖动。Redis在故障切换、网络微抖动、大key扫描时都可能出现几十毫秒的响应延迟若你的阈值贴着系统极限一个小抖动就会让流量瞬间冲破保护线。6.2 降级策略Redis挂了缓存穿透也跟着来了提到Redis故障就绕不开缓存穿透这个话题。很多团队把Redis同时用在缓存和限流两件事上一旦Redis挂了缓存穿透和限流失效会同时发生那可真叫雪上加霜。我自己的处理原则是限流器必须能快速降级。当Redis不可用时按风险等级选择以下方式之一安全模式推荐直接拒绝非核心链路的请求保护后端。本地降级在应用内存里开启一个简化版限流器比如Guava RateLimiter每个节点各自限一份。虽然多节点叠加时总量控制不精确但总比完全没有保护强。放行模式只对非常核心且后端有足够冗余的接口才用一般不做这个选择。很多生产事故的根源是Redis连接超时后应用端不断重试导致Redis虽然没有完全挂但连接线程被打满Redis彻底无响应。限流代码里一定要给Redis操作设置超时时间超时就走降级分支别把资源全耗在等待上。6.3 和分布式锁纠缠的那些事最后我想泼一盆冷水限流和分布式锁是两码事但我在项目中见过不少把它们混在一起用的。Redis分布式锁解决的是多实例互斥访问共享资源的问题限流解决的是单位时间内允许多少请求的问题。两者可以共存但千万别用分布式锁去实现限流也不要指望限流能替代锁。举个典型场景一个扣减库存接口既需要保证同一用户同一商品同时只能有一个请求在处理这是锁的职责又需要防止某用户短时间刷千百次请求这是限流的职责。这种情况下我会在接口最外层做限流把明显超量的请求挡掉然后进入业务后再对真正要操作共享库存的代码加锁。顺序不要颠倒因为限流比加锁成本低得多而且不会阻塞其他合法请求。还有一点很多人喜欢用Redis的SET NX PX做分布式锁搜索redis分布式锁时看到各种复杂实现。如果你的限流逻辑本身用的是同一套Redis集群那么在锁和限流共用key时一定要小心避免一个业务的锁key和另一个业务的限流key设计得一样那样真的会出怪事。我见过因为key前缀没隔离开限流器把锁给限住了下游拿不到锁直接超时。解决方式也很简单给限流相关的key统一加一个固定的前缀比如rate_limit:让它们和缓存、锁的key命名空间彻底分开。最后的几条经验从固定窗口到滑动窗口再到令牌桶和漏桶每种Redis限流策略都有自己的适用场景没有谁是万能的。我个人的体会是先把最简单的INCREXPIRE固定窗口玩明白理解边界问题和原子性问题线上真正需要平滑流量时再上令牌桶需要严格精确配额时再上滑动窗口。千万别一开始就上一个重度方案否则后续的调优和维护成本都是成倍增长的。另外我想再分享一个小技巧无论你用了哪种策略上线之前一定要做一次窗口边界压测专门盯着临界点打流量。很多时候限流失效并不是因为代码写得不对而是边界条件没有覆盖到比如秒级跳变、毫秒精度、EXPIRE提前失效。把这些边界实测一遍限流器才能真正让你睡得着觉。
返回列表