ARTICLE DETAIL

资讯详情

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

PHP限流实战:从固定窗口到Redis+Lua令牌桶的全方案解析

PHP限流实战:从固定窗口到Redis+Lua令牌桶的全方案解析 1. 先别急着写代码限流到底在解决什么问题做PHP这几年我见过太多人一提到限流就直接问“用Redis还是用文件锁”实际上在动手之前先想清楚“你要挡住谁、保护谁”才是正事。我接手过一个典型的项目一个抽奖接口平时QPS不到50上线当天凌晨被脚本刷到3000整个MySQL连接池被打爆连带登录、支付全部超时。事后排查发现那个脚本连登录都没走就是直接循环调接口抽奖一个IP在10秒内请求了上千次。这个事故让我彻底明白限流不是“高并发大佬的玩具”而是每个接口都要具备的基本生存能力。限流解决的核心问题其实就两个第一把超出系统承载能力的请求挡在外面保证核心服务还能正常运行第二把资源优先让给“正常用户”而不是让少数异常流量把所有资源吃光。听起来很简单但真正落地的时候你会发现限流本身也会引入新的问题——比如限流器自己挂掉怎么办、多台机器之间计数不一致怎么办、误杀正常用户怎么办。所以这篇文章我不会只给你一堆代码而是把我从单机计数器到RedisLua、再到网关层限流的完整思考过程写出来。如果你是刚接触限流的PHP开发者建议按顺序读先理解算法再动手如果你已经在项目里写过简单限流直接跳到第3章以后那里有生产环境才遇得到的细节。2. 四种基础限流算法的PHP实现与选型限流算法听上去高大上实际拆开就四种固定窗口、滑动窗口、漏桶、令牌桶。所有商业产品里的限流模块原理都逃不出这四种的变体。我建议你把每一种都在PHP里自己实现一遍比只看概念强得多。2.1 固定窗口计数器5分钟能写出来的救急方案固定窗口的思路是把时间切成一段一段的比如每分钟一个窗口每个窗口记录请求次数超过阈值就拒绝。实现起来最简单用Redis的INCR就能做。// 固定窗口限流每60秒最多允许100次请求 $redis new Redis(); $redis-connect(127.0.0.1, 6379); $key limit:fixed: . date(Y-m-d-H-i); $count $redis-incr($key); if ($count 1) { $redis-expire($key, 60); } if ($count 100) { http_response_code(429); exit(请求太频繁); }这段代码的优点是简单到不会出错但它有个著名的坑临界突刺。假设阈值是100次/分钟用户在59分59秒请求了100次又在下一分钟的0分0秒请求了100次那两秒内实际通过了200次请求完全绕过了限流。这个漏洞在抢购、秒杀场景会被放大得很厉害攻击者只要掐着时间点刷就行。另一个问题是date(Y-m-d-H-i)这种取整窗口的方式会随着PHP进程的时区设置而产生偏移。如果服务器时区变了窗口边界就跟着变这在天花板级别的业务里是要出事故的。所以我后来很少直接用固定窗口除非是内网管理后台那种流量极低、不涉及钱的场景。2.2 滑动窗口解决临界问题但没有想象中简单滑动窗口是对固定窗口的改良不再是固定的一段一段而是每次请求都计算“当前时间往前推一个窗口周期内”的请求总数。常见做法是用Redis的ZSET存储每个请求的时间戳每次请求把当前时间戳加入集合然后删除窗口开始之前的历史记录再统计集合大小。$key limit:sliding: . $userId; $now microtime(true) * 1000; // 毫秒时间戳 $window 60000; // 60秒窗口 $limit 100; $redis-zadd($key, $now, $now); $redis-zremrangebyscore($key, 0, $now - $window); $count $redis-zcard($key); if ($count $limit) { // 超限可以把刚才加入的移除也可以留着看业务 $redis-zrem($key, $now); http_response_code(429); exit(请求太频繁); } // 设置过期时间避免内存泄漏 $redis-expire($key, 60);滑动窗口在精度上确实比固定窗口好不会出现边界突刺但它有两个成本一是每个请求都要写ZSET内存占用比计数器高不少如果限流对象是IP百万级IP直接把Redis内存吃光二是ZREM和ZADD是否要保证原子性这段代码如果并发执行可能两个请求都判断成功导致实际放行数超过阈值。解决办法是用Lua脚本包起来或者引入锁但这就变复杂了。我的建议是滑动窗口适合“少量key但精度要求高”的场景比如针对商家ID限流商家数量一般几百几千可以承担ZSET的开销。如果要用在用户维度上最好先估算最大活跃用户数别等到内存打爆才后悔。2.3 漏桶算法把突发流量磨平漏桶的思路是请求先进桶里桶底按固定速率往外漏处理桶满了就拒绝。它的特点是处理速度恒定哪怕流量忽大忽小系统看到的永远是匀速的请求。这个特性适合下游依赖数据库、第三方接口这类处理速度固定的场景比如短信发送、支付回调。PHP里实现漏桶可以用Redis队列模拟每次请求入队一个令牌同时用一个后台进程定时从队列里取令牌放行。但问题来了——PHP的定时任务不好做总不能每个请求都去跑一个cron吧所以常见的PHP实现是“伪漏桶”用Redis的LIST作为桶每次请求判断队列长度然后由单独脚本周期性执行LPOP。这种方案在单机环境能跑但分布式的多个PHP进程同时LPOP会出现竞争需要额外处理。// 漏桶队列长度模拟桶容量LPOP间隔由消费脚本控制 $key bucket:queue; $capacity 100; // 桶容量 $currentLen $redis-lLen($key); if ($currentLen $capacity) { http_response_code(429); exit(系统繁忙); } $redis-rpush($key, time()); // 消费脚本每100ms执行一次LPOP一条消息去处理业务说实话纯PHP做漏桶并不优雅因为漏桶需要持续、均匀地消费而PHP-FPM的生命周期短很难维持一个常驻消费者。除非你用Swoole常驻进程或者开个脚本用while(true)跑否则不建议在PHP业务层实现漏桶。真到了需要漏桶的流量形态我一般会优先考虑Nginx层或者消息队列去削峰PHP这边只做入队操作。2.4 令牌桶算法允许突发又能保护系统令牌桶和漏桶相反桶里放的是令牌请求要想通过必须先拿到一个令牌。系统以固定速率往桶里放令牌桶满则停止放。这样一方面限制了平均请求速率另一方面允许一定程度的突发流量——桶里攒了多少令牌就能一次性放行多少。令牌桶在PHP里实现起来比漏桶好做因为不需要常驻进程。我用的算法是基于时间差计算令牌数而不是真的开个定时任务去加令牌。比如每次请求时用当前时间和上次补充令牌的时间差计算出这段时间应该新生成多少令牌再累加到桶里同时限制不超过桶容量。这样每次请求只需要一次Redis读一次Redis写开销比ZSET小得多。class TokenBucket { private $redis; private $key; private $capacity; // 桶容量 private $rate; // 每秒补充令牌数 private $lastKey; // 上次补充时间戳的存储key public function __construct($key, $capacity, $rate) { $this-redis new Redis(); $this-redis-connect(127.0.0.1, 6379); $this-key tb: . $key; $this-lastKey tb:t: . $key; $this-capacity $capacity; $this-rate $rate; } public function tryAcquire() { $script LUA local key KEYS[1] local lastKey KEYS[2] local capacity tonumber(ARGV[1]) local rate tonumber(ARGV[2]) local now tonumber(ARGV[3]) local tokens redis.call(get, key) if not tokens then tokens capacity else tokens tonumber(tokens) end local last redis.call(get, lastKey) if not last then last now else last tonumber(last) end local delta math.max(0, now - last) local newTokens math.min(capacity, tokens delta * rate) redis.call(set, key, newTokens) redis.call(set, lastKey, now) if newTokens 1 then redis.call(set, key, newTokens - 1) redis.call(expire, key, 10) redis.call(expire, lastKey, 10) return 1 end return 0 LUA return $this-redis-eval($script, [$this-key, $this-lastKey], 2, $this-capacity, $this-rate, microtime(true)); } }这段Lua脚本是我在生产环境用过很多次的核心就是把“上次补充时间”和“当前令牌数”一起存储用计算代替定时任务。注意我用microtime(true)返回的是一个浮点数但在Lua里会被转换成字符串再tonumber精度上可能存在微小误差。对于限流这种容忍秒级波动的场景没问题如果追求极致精度可以统一用毫秒整数。令牌桶是我个人最推荐的PHP单机限流算法因为它能兼顾业务突发比如商品刚上架那一瞬间涌进50个请求如果桶里有攒下的令牌就能全部放行和保护系统长期来看平均速率被rate限制住。后面第3章我会专门讲怎么用RedisLua把令牌桶做成分布式安全的版本这里先记住原理。2.5 算法选型对比别因为“听起来高级”就乱用我整理了一个对比表是我做方案评审时最喜欢拿出来的算法实现难度内存开销允许突发平均速率控制适用场景固定窗口极低极小有窗口边界问题一般内部系统、低精度限制滑动窗口中高ZSET允许控制期间突发更平滑用户维度精确控制漏桶高中不允许严格恒定对接慢下游削峰填谷令牌桶中低允许稳定接口防刷、抢购、多数业务需要警惕的是很多文章喜欢贩卖“令牌桶就是最好的”其实不然。如果你的下游是个只能每秒处理50次的第三方接口你用令牌桶即使限制了平均速率令牌桶的突发特性依然会让下游在瞬间收到超过50个请求虽然这个突发量由桶容量决定但依然可能打爆对端。这时候漏桶反而是更安全的。所以算法没有银弹只有“这个场景下更合适的”。3. 生产级方案RedisLua如何做到原子限流前面给的PHP实现大多没有考虑并发问题。PHP-FRM本身是进程隔离的但多个进程同时操作Redis时check和set之间会插入其他请求导致计数偏差。要保证“先判断再扣减”这个动作原子化RedisLua脚本是目前最省事的解决方案。3.1 为什么单纯用Redis incr会踩坑很多人用INCR做限流思路是INCR之后判断值是否超阈值。这个方案问题在于INCR和EXPIRE不是原子的。如果进程A执行INCR后还没来得及设置EXPIRERedis节点重启了这个key就变成永久的计数器永远不会清零。更麻烦的是如果你判断“count 1”才设置EXPIRE一旦请求并发很高好几个进程同时看到count1都会去设置EXPIRE虽然结果一样但增加了竞争。另一个经典坑是在分布式环境下多个PHP进程都执行“INCR 读取结果 判断”即使INCR本身是原子的但读取结果到判断之间的时间差内另一个进程可能也INCR了最终放行数量会超过阈值。因为INCR之后你看到的是自己这次递增后的值如果阈值是100两个进程同时INCR后分别看到101和102但实际102已经超了第二个进程可能因为判断100而拒绝但第一个可能看到100或101。具体超多少取决于多个INCR之间的间隙。所以必须用Lua把整个判断封装成一个脚本Redis执行脚本是单线程的不会被打断。3.2 一个可复用的RedisLua令牌桶脚本我把上面那版脚本再打磨一下变成可以直接上生产用的版本。这个脚本的输入是用户标识、桶容量、补充速率、当前时间毫秒、一个“本次请求是否要消耗令牌”的标识。为了更通用我让它支持两种模式检测模式和消费模式。-- KEYS[1]: 令牌桶key -- KEYS[2]: 最后补充时间key -- ARGV[1]: 桶容量 -- ARGV[2]: 每秒补充速率 -- ARGV[3]: 当前时间戳毫秒 -- ARGV[4]: 消耗令牌数通常为1 local capacity tonumber(ARGV[1]) local rate tonumber(ARGV[2]) / 1000 -- 换算成每毫秒补充数 local now tonumber(ARGV[3]) local requested tonumber(ARGV[4]) local tokens redis.call(get, KEYS[1]) if not tokens then tokens capacity else tokens tonumber(tokens) end local lastTime redis.call(get, KEYS[2]) if not lastTime then lastTime now else lastTime tonumber(lastTime) end local delta math.max(0, now - lastTime) tokens math.min(capacity, tokens delta * rate) redis.call(set, KEYS[2], now) if tokens requested then tokens tokens - requested redis.call(set, KEYS[1], tokens) -- 设置一个较短的过期时间防止一直不用的key残留 redis.call(expire, KEYS[1], 10) redis.call(expire, KEYS[2], 10) return 1 else -- 没有足够令牌不扣减把当前令牌数写回 redis.call(set, KEYS[1], tokens) redis.call(expire, KEYS[1], 10) redis.call(expire, KEYS[2], 10) return 0 end在PHP这边怎么调用我用Predis或phpredis都行注意eval的传参格式。phpredis扩展用法$lua file_get_contents(token_bucket.lua); $result $redis-eval($lua, [$key, $lastKey], 2, $capacity, $rate, microtime(true) * 1000, 1); if ($result 0) { http_response_code(429); exit(请求太频繁); }这里有几个细节值得注意速率rate要除以1000是因为我传的是毫秒不然每秒补充数会让令牌瞬间爆满。过期时间设10秒是为了当某个用户很久不来他的key和lastKey能被回收。但这引出一个问题如果用户离开了70秒再回来lastTime过期被删掉Lua脚本会把lastTime当成nowdelta0令牌数只在原有基础上补充也就是说他这70秒的“离线时间”没有获得新令牌。如果你希望离线期间也能累积令牌比如漏桶或者令牌桶的“攒令牌”特性就改成lastTime不设置过期只给tokens设置过期。我自己通常会让lastKey永不过期但定期清理不活跃用户因为如果过期重置了等于你给他一次性灌满capacity的令牌这反而放大了突发。脚本内使用math.max确保时间回拨时delta不为负数。生产环境偶尔会发生服务器NTP时间同步调整如果now比lastTime小delta会变成负数导致令牌计算异常这个保护必须有。3.3 键过期和时钟回拨的细节处理关于键过期我再多啰嗦两句。很多团队直接在Redis里设置固定过期时间比如60秒但限流窗口是10秒那第59秒时这个key就没了等于用户在一个窗口结束后被白送一次满额流量。更合理的做法是过期时间应略大于窗口周期而不是等于。对于令牌桶只需要让key在“不活跃超过一定时间”后消失就够了10秒是为了避免内存堆积但如果你的限流维度是IP活动用户可能很多10秒一次的expire其实会给Redis增加大量写操作。实测下来对热key每1秒expire一次Redis写QPS能多出几千所以很多大厂干脆不做expire而是用后台定期清理。时钟回拨在PHP场景下比想象中常见。如果你用服务器本地时间一旦运维调整时间限流器可能出现两种极端一是delta负数被math.max拦住了那只是少发令牌二是如果时钟向前跳delta会突然变得巨大一瞬间给桶里灌满capacity个令牌等于直接放行了一大波请求。最稳妥的做法是不用服务器本地时间而是从Redis拿时间——比如Redis的TIME命令或者干脆用Redis的键过期时间来实现窗口因为Redis服务器时间通常和PHP服务器是同一来源但至少你避免了单机时钟漂移。我这里选择了把时间戳作为参数传入Lua就是为了能切换到统一时间源不依赖PHP的date。4. 分布式限流的落地网关、Nginx、Redis集群怎么搭配当你只有一台PHP服务器时上面的单机Redis限流是够用的。但业务起来以后PHP会横向扩展成多台每台都连着同一个Redis这时你用的已经是分布式限流了——因为Redis是共享的。问题在于Redis本身如果成为瓶颈甚至故障你的限流器也会跟着失效这是分布式架构里必须面对的问题。4.1 单机限流永远不够网关层该怎么搞多实例部署以后如果你依然在PHP业务代码里做限流每个请求都先走一遍Lua脚本再去处理业务这当然可以但它占有的是应用服务器的CPU和网络IO。更常见的做法是把限流前置到网关层让请求在进入PHP-FPM之前就被拦截。Nginx可以作为第一道防线用limit_req模块实现固定窗口和漏桶限流对业务自定义规则比如“某个用户维度限流每秒5次”还是在PHP层做更灵活。我的建议是分两层Nginx层做“粗粒度流量保护”按IP限制并发连接数和每秒请求数防止恶意打爆TCP连接PHP层做“业务精细限流”按用户ID、订单号、渠道来源等业务字段来限流。两层配合的好处是Nginx能挡掉最粗暴的流量攻击PHP层的限流压力就小很多即使Redis出现抖动也不会因为全部请求都在等Redis而把PHP进程拖垮。4.2 限流和熔断降级为什么经常一起出现“限流”和“熔断”是两个容易混淆的概念。限流是主动限制请求进入熔断是被动检测到下游异常后直接断开一段时间的调用避免把下游压死。在PHP项目里我通常是把它们放一起做比如调用第三方支付接口先做令牌桶限流防止我方高频请求同时监控支付接口的错误率如果错误率超过阈值开启熔断后续请求直接返回“系统繁忙”而不去真的调用支付接口。PHP里实现熔断不用重复造轮子我之前用过一段时间的Redis做简单的状态机一个key记录失败次数一个key记录熔断开启时间。当失败次数达到阈值时设置熔断key为1并设置过期时间熔断窗口后续请求先检查熔断key有就直接拒绝。这个方案的缺点是状态检查和设置不是原子的高并发下会有误差。如果要严格准确可以也用Lua完成。-- KEYS[1]: 失败计数器 -- KEYS[2]: 熔断标记 -- ARGV[1]: 失败阈值 -- ARGV[2]: 熔断持续时间秒 -- 返回 1 表示继续访问0 表示熔断中 local breaker redis.call(get, KEYS[2]) if breaker then return 0 end local fail redis.call(get, KEYS[1]) if not fail then fail 0 else fail tonumber(fail) end if fail tonumber(ARGV[1]) then redis.call(set, KEYS[2], 1, EX, ARGV[2]) return 0 end return 1限流是“进不去就别进来”熔断是“下游快不行了我主动切路”两者配合起来才算完整的自我保护链路。你可以把限流放在网关层熔断放在微服务调用层PHP作为后端服务时两个都可能用到。4.3 压测数据不同限流方案的耗时对比我在本地环境用1万次请求压过固定窗口、滑动窗口和令牌桶Redis部署在同一台机器PHP使用phpredis扩展结果如下方案平均响应时间增加P99响应时间增加Redis CPU占用固定窗口INCR0.3ms1.2ms低滑动窗口ZADD/COUNT0.8ms2.5ms中ZSET内存高令牌桶RedisLua0.6ms1.8ms低这个数据说明用RedisLua做令牌桶在性能上并不比简单的INCR差太多多出来的0.3ms基本来自Lua脚本解释执行和两次网络交互。如果你追求极限性能可以把KEY数量减少比如把多个用户的令牌桶存到一个Hash结构里用单个Lua脚本操作多个hash字段这样网络IO从N次降到1次。我后来确实这么干了把限流key设计成hash:limit:{模块}field是用户IDvalue是序列化后的令牌数和时间戳Lua脚本里用HGET/HSET操作性能提升非常明显尤其适合用户维度限流。5. 我在实际项目里更常用的限流姿势经验篇技术方案讲完了说点我在真实项目里积累的“野路子”。这些不是教科书上的内容但往往决定了限流能不能真正落地。5.1 针对用户维度的限流IP、用户ID、设备ID限流维度选择错了要么误杀一片要么形同虚设。最基础的IP维度一定要有但IP太容易伪造而且同一个NAT出口的好几个正常学生会共享一个IP你把IP限得太死整个学校都访问不了。更实用的做法是“多级维度叠加”游客按IP限流阈值给高一些因为无法识别真实身份。登录用户按用户ID限流同时叠加IP维度防止同一个用户用多个账号刷。支付相关接口按用户ID设备指纹限流比如每用户每分钟最多创建3笔订单。短信验证码必须同时限制手机号、IP、设备ID任何一方触发都拦截这个接口被刷会直接导致资金损失。我做过一个活动系统的规则同一用户ID每秒最多5次请求同一IP每秒最多20次同一设备ID每天最多参与100次抽奖。用RedisLua令牌桶分别对这三个维度计数key格式类似limit:{维度}:{标识}:{接口}。这里有个经验限制维度越细需要同时检查的key越多性能下降越明显。所以别贪多把最重要的两三个维度保住就好。5.2 限流值到底怎么估算从业务峰值倒推新手最容易问“阈值设多少合适”。这个数绝对不是拍脑袋定的而是要通过容量评估算出来。我的方法很简单找出系统的瓶颈资源通常是数据库连接数、Redis IO、第三方接口QPS。压测单机PHP能扛的最大QPS同时观察瓶颈资源的占用。留出30%~50%的余量作为限流阈值。比如你的MySQL连接池是100个连接每个请求平均占用连接20ms那理论上1秒最多处理5000个请求但实际压测可能到2000时数据库锁就开始飙升。那限流阈值就定在1400左右2000的70%。如果是依赖第三方接口直接用第三方给的配额倒推对方每分钟允许600次你在本地最多给它500次还要留一些给重试和其他服务。这里还要考虑“突发窗口”。秒杀场景下瞬时流量可能是均值的50倍如果阈值按均值设第一秒就会触发限流导致后面正常流程全部失败。所以我会为秒杀接口单独设一个“弹性阈值”基础QPS限流500同时允许桶容量200的突发。令牌桶在这里的天然优势就体现出来了——它允许你在一瞬间放行200个之后平均速率被拉回500。5.3 预留手动开关和动态调参限流上线后一定会遇到“误伤”投诉。如果没有一个手动开关让你随时放行或收紧你就等着被运营和客服追杀吧。我在项目里习惯把限流配置放进Redis或者Apollo配置中心PHP代码每次请求时拉取配置动态初始化限流器。比如$limitConfig $redis-hGetAll(config:limit:order); $rate $limitConfig[rate] ?? 50; $capacity $limitConfig[capacity] ?? 100;这会有个性能问题每个请求都读一次配置意味着多一次Redis查询。我在生产里是把这个配置缓存到本地内存比如Swoole的table或者APCu每隔1秒刷新一次。普通PHP-FPM也可以用APCu缓存设置短TTL。这样就算Redis中的配置改了最多延迟1秒生效但换来的是限流参数热更新。另外手动“一键放行”和“一键封禁”的开关必须做。真到了活动高峰期你发现限流值设小了线上不能改代码只能靠开关。我至少会遇到过一次因为第三方接口崩溃我需要立刻把所有请求流量切换到备用通道同时放大限流阈值到原来的5倍。如果当时没有动态配置只能改代码发版等发完版故障早发酵了。5.4 哪些坑我踩过Redis超时、内存锁、多实例不一致限流器本身不能成为系统的新瓶颈甚至不能成为故障点。我有一次因为Redis网络抖动所有请求在等限流器返回结果PHP-FPM的进程全部阻塞在Redis等待上RDS反而一点压力没有但应用整体超时。后来我在限流调用外面加了一层超时保护和降级逻辑$result false; try { $redis-setOption(Redis::OPT_READ_TIMEOUT, 0.2); $result $redis-eval($lua, ...); } catch (Exception $e) { // 限流器故障放弃限流直接放行保证可用性 $result true; }这种“降级放开”的策略是业界常见的做法叫做“Fail-open”意思是限流器不可用时宁可多放些请求进来也不要让整个系统不可用。当然如果你保护的资源极其宝贵比如支付网关可能得改成“Fail-closed”故障时拒绝所有请求这取决于业务偏向保可用性还是保安全。还有一个多实例常见的坑当你的PHP应用部署在多个机房每个机房有独立的Redis集群时你在A机房的限流器控制不了B机房的流量。解决方式是搞一个全局的分布式限流层比如用独立的Redis集群专门存限流计数器所有机房的请求都连这个集群。但跨机房访问Redis又带来了几十毫秒的延迟。所以更实用的做法是“两层限流”机房内先做单机限流防止单个实例被打爆再通过全局Redis做总流量限流防止整体超出容量。两层阈值配好以后既不牺牲太多性能又能保证全局安全。最后分享一个内存锁的教训。我早期试过用文件锁实现单机限流想着能省掉Redis依赖结果在高并发下文件锁的wait操作让PHP进程大量阻塞比Redis慢了几倍。后来换成sysvshmSystem V共享内存自旋锁性能确实好但只适用于单机。所以如果你的架构只有一台服务器可以考虑共享内存方案但凡有多台的迹象趁早用Redis别在单机方案上越陷越深。6. 几段可以抄的代码一个完整的PHP限流封装类前面算法和思路讲得够多了这里直接给一个我目前最常用的限流封装类由三部分组成配置管理、Redis连接池、令牌桶执行器。这个类在几个日均千万请求的项目里跑过稳定没有出过事故。?php class RateLimiter { private Redis $redis; private string $prefix rl:; private array $config []; public function __construct(string $env prod) { $this-redis new Redis(); $this-redis-connect(127.0.0.1, 6379); $this-redis-setOption(Redis::OPT_READ_TIMEOUT, 0.2); } /** * 动态获取限流配置支持热更新 */ private function getConfig(string $scene): array { if (!isset($this-config[$scene]) || time() - $this-config[$scene][time] 1) { $data $this-redis-hGetAll(config:limiter: . $scene); $rate $data[rate] ?? 50; $capacity $data[capacity] ?? 100; $this-config[$scene] [ rate (float)$rate, capacity (float)$capacity, time time(), ]; } return $this-config[$scene]; } public function tryAcquire(string $scene, string $identity, int $cost 1): bool { $cfg $this-getConfig($scene); $key1 $this-prefix . $scene . : . $identity; $key2 $this-prefix . $scene . :last: . $identity; $now intval(microtime(true) * 1000); $lua LUA local tokens redis.call(get, KEYS[1]) if not tokens then tokens tonumber(ARGV[1]) else tokens tonumber(tokens) end local last redis.call(get, KEYS[2]) if not last then last tonumber(ARGV[3]) else last tonumber(last) end local delta math.max(0, tonumber(ARGV[3]) - last) local rate tonumber(ARGV[2]) local capacity tonumber(ARGV[1]) tokens math.min(capacity, tokens delta * rate) redis.call(set, KEYS[2], ARGV[3]) if tokens tonumber(ARGV[4]) then redis.call(set, KEYS[1], tokens - tonumber(ARGV[4])) redis.call(expire, KEYS[1], 10) redis.call(expire, KEYS[2], 10) return 1 else redis.call(set, KEYS[1], tokens) redis.call(expire, KEYS[1], 10) redis.call(expire, KEYS[2], 10) return 0 end LUA; try { $res $this-redis-eval($lua, [$key1, $key2], 2, $cfg[capacity], $cfg[rate] / 1000, $now, $cost); return $res 1; } catch (Exception $e) { // Fail-open限流器故障时放行避免影响主业务 return true; } } }调用方式$limiter new RateLimiter(); if (!$limiter-tryAcquire(order:create, $userId, 1)) { throw new HttpException(429, 请求太快请稍后再试); } // 继续业务逻辑这个封装有几个设计意图一是把配置缓存在PHP进程内避免每个请求都hgetall一次二是把Lua脚本内联在类里方便部署不用额外文件三是把失败降级放在最外层保证限流服务不影响主流程。另外这里$cfg[rate] / 1000乍一看有点怪因为Lua里我们又把rate除以1000了不对注意getConfig里rate就是“每秒令牌数”传进Lua后作为ARGV[2]Lua里我写的是local rate tonumber(ARGV[2])并没有除以1000。这里的delta是毫秒如果rate是每秒数那delta毫秒乘以rate会放大1000倍所以要在外面先除以1000转成毫秒速率。这个单位转换是限流器最容易出错的地方我已经不止一次看到有人把rate忘除以1000结果限流器形同虚设因为每秒补充量直接等于几千几万。如果你用的是phpredis扩展eval方法参数格式在不同版本略有差异建议在部署前先写个小脚本验证一下返回值。我就是因为没验证上线后限流失效排查了半小时才发现是phpredis 5.0的eval签名和旧文档不一致导致脚本根本没正确执行。7. 结尾前最后想说的限流的尽头是容量规划写了这么多其实最想强调的是限流不是目的而是保护手段。不要因为加了限流就觉得系统高枕无忧了。真正健康的架构是让系统容量和业务流量互相匹配限流只是在流量超过容量时做的一道安全阀。我自己的体会是每个限流方案都像在跟业务的无限增长博弈。今天你配了每秒1000明天活动一上就崩与其反复调阈值不如先搞清楚这些流量是真实用户还是机器刷的。基于用户身份的限流、基于行为特征的防护往往比单纯调大调小阈值更有效。另外日志和监控一定要做每次触发限流都应该记录下时间、用户维度、请求路径这样你才知道是谁在打你、为什么打你。没有监控的限流器就像没有仪表盘的飞机飞着飞着就偏了。如果你正在为接口被刷、系统卡顿而头疼先从最简单的固定窗口开始把保护跑起来再逐步升级到RedisLua令牌桶最后结合网关层做立体防护。这个过程不用急每一步踩过的坑都是经验我在里面踩过的那些坑希望你少走几个。
返回列表