ARTICLE DETAIL

资讯详情

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

PHP限流全解析:从算法原理到Redis与分布式实践

PHP限流全解析:从算法原理到Redis与分布式实践 做PHP这些年限流这个事我感触挺深大多数项目用到它的时候都是已经被打疼了才想起来补。我最早接触限流是某个活动页上线当天被脚本刷到CPU跑满数据库连接池直接打穿那晚整个团队都在查日志重启服务。后来我把限流的各种思路系统捋了一遍才发现这东西看着简单落地时全是细节。这篇就把我实战中验证过的PHP限流思路完整拆开讲从算法原理到Redis落地再到分布式场景的坑给做后端、写接口的一线PHP开发一个可以直接参考的方案。先明确这篇适合谁项目里接口被刷过、担心瞬时流量打崩服务、或者正在做秒杀/登录/第三方API配额相关功能的人。我会尽量少讲空洞理论多用能立刻抄走的代码和配置说明。1. 限流到底在保护什么先定位你的真实瓶颈很多教程上来就甩算法但我觉得第一步应该是搞清楚你限流的目的是什么。限流不是“不让用户用”而是“在流量超过系统承载上限时牺牲一部分请求来保住整体可用性”。这个认知如果没建立后面所有方案都会走偏。1.1 四个最常见的PHP限流场景防接口刷请求验证码接口、短信接口、登录接口很容易被脚本或羊毛党盯上。这类场景的QPS不一定多高但频率很密重点要限制“单个用户/IP”的操作次数。保护后端资源数据库连接池、下游RPC服务、文件存储服务都有承载上限。比如一个导出接口如果所有用户同时点导出数据库IO会瞬间被打满。这类场景要做的是全局QPS限制或者并发数限制。应对业务高峰秒杀、抢购、抽奖这类活动流量有极大瞬时峰值预估可能超过系统上限。这时候限流配合缓存、降级一起用。第三方API配额管理你调用微信接口、支付接口、地图API对方给你配额超了就报错。这边需要做客户端限流确保请求速率小于对方限制。1.2 限流的粒度设计和阈值从哪来限流的粒度决定了效果和误伤程度。我在项目里最常见的三种粒度是用户维度按 user_id 或手机号做key适合登录、下单、支付这类业务。IP维度按客户端IP做key适合防爬虫、防刷验证码。接口维度按接口路径做key适合全局容量保护。粒度不是越细越好。太细了单个用户限不了总量机器照常打满全局太粗了一个用户异常会把所有用户都拖下水。真实项目里常用组合比如登录接口就同时做了“IP维度每分钟50次”和“用户维度每分钟10次”两层限制。阈值怎么定不要拍脑袋。我一般会先拿压测工具wrk、ab、JMeter测出接口的QPS上限然后取上限的60%~70%作为限流阈值。比如压测发现登录接口单实例最多扛1800 QPS那单机限流就设在1200 QPS左右留出缓冲。如果是多实例部署还要除以实例数或者改用集中式计数。1.3 限流、缓存、降级三者的关系很多同学把限流、缓存、降级分开看实际上它们是一个保护体系。缓存负责把热点数据挡在数据库前面让大多数请求不落库限流负责在请求量超出系统处理能力时拒绝一部分流量降级负责在依赖的下游故障时启用备用逻辑。举个实际例子商品详情页先用Redis缓存扛住99%的流量缓存失效回源数量有限如果回源并发过高接口层的限流开始生效直接返回“服务繁忙请稍后重试”避免数据库被打挂极端情况下Redis也挂了降级到本地文件缓存。三者配合才能形成完整防线。限流解决的是“量太大”的问题熔断解决的是“下游坏了”的问题这两者经常被混为一谈后面第5章我会详细展开。2. 四种经典限流算法逐一过一遍PHP实现限流算法没有绝对的好坏只有适不适合场景。我从最简单到最实用给你理一遍每个算法都附PHP可跑的代码思路。2.1 固定窗口计数器最直白的方案留意临界问题固定窗口计数器是最容易理解的方案把时间切成固定大小的窗口比如1分钟每来一个请求计数加一超过阈值就拒绝窗口结束清零。用Redis实现非常简洁$redis new Redis(); $redis-connect(127.0.0.1, 6379); $key rate_limit:api:user: . $userId; $limit 100; // 每分钟最多100次 $window 60; // 窗口大小单位秒 $count $redis-incr($key); if ($count 1) { $redis-expire($key, $window); } if ($count $limit) { http_response_code(429); exit(json_encode([code 429, msg 请求过于频繁])); }这里有个关键细节incr返回1说明是第一个请求必须同时设置过期时间。如果先incr再单独expire中间这几十微秒不可控但只要不是网络极端情况问题不大。更稳的写法是用Lua脚本把incr和expire合并成原子操作后面第3章会给出脚本。固定窗口最大的坑是临界突刺假设阈值是100次/分钟用户在59分59秒发了100个请求在60分00秒又能立刻发100个。这意味着一秒内可能实际打入200个请求窗口边界上流量可能直接翻倍。有些业务场景能接受有些不能。如果接口后面连着写数据库的操作这种突刺可能直接打垮数据库。这也是为什么会有滑动窗口算法。2.2 滑动窗口用ZSET消除临界毛刺滑动窗口的思想是记录每个请求的时间戳统计当前窗口内比如最近60秒的请求总数。每个请求都有自己的时间戳窗口是平滑移动的所以不会出现固定窗口那种边界突刺。用Redis ZSET实现$key sliding:api: . $userId; $now microtime(true); // 注意用浮点秒保留毫秒精度 $window 60; $limit 100; // Lua脚本保证原子性 $script LUA local key KEYS[1] local now tonumber(ARGV[1]) local window tonumber(ARGV[2]) local limit tonumber(ARGV[3]) -- 移除窗口外的旧记录 redis.call(ZREMRANGEBYSCORE, key, 0, now - window) -- 加入当前请求score和时间戳基本对齐 redis.call(ZADD, key, now, now .. : .. math.random(1000000)) local count redis.call(ZCARD, key) -- 设置过期时间避免key永久堆积 redis.call(EXPIRE, key, window 1) return count limit and 1 or 0 LUA; $allowed $redis-eval($script, [$key, $now, $window, $limit], 1);ZSET的member用“时间戳:随机数”是为了防止同一毫秒内多个请求member冲突。ZREMRANGEBYSCORE负责清理过期数据ZCARD统计窗口内总数。滑动窗口的代价是每个请求都要存一条ZSET记录内存开销比固定窗口大。如果一个高QPS接口用这个方案几分钟就能攒下几十万条记录。虽然每次请求都会清理过期记录但瞬时大量请求时Redis的内存和CPU都会受影响。我一般会在过期时间上做手脚EXPIRE设置成 window 一个小随机值避免所有key在同一时刻集体过期造成Redis热key压力。适用场景对流量准确性要求高、QPS本身不算极高的接口比如支付回调、交易接口。2.3 漏桶算法匀速消费的“排队器”漏桶算法的思路非常像水管里的漏桶请求像水一样倒入桶里桶底有个小孔匀速漏水即处理请求桶满了就溢出拒绝请求。这个算法的核心是恒定速率的处理能力不管上游怎么突发下游收到的流量永远匀速。PHP结合Redis List实现$bucketKey leaky_bucket: . $userId; $capacity 100; // 桶容量最多允许积压100个请求 $rate 5; // 每秒处理5个请求 // 入桶时判断桶是否已满 $len $redis-lLen($bucketKey); if ($len $capacity) { // 桶满直接拒绝返回429 http_response_code(429); exit; } $redis-rPush($bucketKey, $requestId); $redis-expire($bucketKey, $capacity / $rate 1); // 消费端可以放在后台定时任务或者请求内取 $requestId $redis-lPop($bucketKey); if ($requestId) { // 处理请求业务 handleRequest($requestId); }漏桶的典型坑是入桶和出桶的速率如果不匹配队列会越积越长。PHP没有常驻内存的后台进程除非你用Swoole所以“匀速消费”通常要么依赖计划任务要么在每次请求时尝试取出一定数量的积压请求。Swoole场景下可以用Timer定时器稳定出桶传统PHP-FPM场景下我更推荐用消息队列替代比如Redis Stream或者RabbitMQ天然就是漏桶的行为。适用场景需要绝对稳定的下游速率比如通知推送、对账批处理、第三方API调用。2.4 令牌桶算法最接近线上生产需求的方案令牌桶是生产环境用得最多的算法也是很多网关如OpenResty、Sentinel默认的方案。它的逻辑是系统以固定速率往桶里放令牌桶有容量上限。请求要消耗一个令牌才能通过。桶满时令牌不再增加桶里攒下的令牌允许一段时间内的突发流量。令牌桶和漏桶的本质区别漏桶限制的是流出速度把突发全部削平令牌桶限制的是平均速率但允许一定程度的突发只要桶里还有令牌。举个例子速率是每秒10个令牌桶容量是50那么连续50个请求可以在短时间内一次性通过之后速率会被拉回每秒10个。对于秒杀、点赞这种允许短时间内冲一下的业务令牌桶远比漏桶合适。令牌桶在PHP里可以直接用Redis Lua脚本实现代码放下面第3章这里先不重复。2.5 算法选型对照表我整理一个对比方便你按场景快速选型算法是否有突发内存开销实现复杂度适合场景固定窗口窗口边界有突刺极低最低登录防刷、验证码滑动窗口基本平滑较高中交易接口、精确控量漏桶完全平滑低中推送、对账、第三方API配额令牌桶允许一定突发低较高秒杀、网关、普通后端API选择的标准不是“哪个最优”而是“哪个更匹配你的业务失血点”。怕边界流量就打滑动窗口怕突发流量打爆下游就上漏桶想兼顾平均速率和突发能力就上令牌桶。3. 基于Redis的PHP限流落地实践算法有了接下来说真正落地要处理的问题。我假设你的项目已经用了Redis这里所有方案都以Redis为准因为PHP的共享内存方案APCu、共享文件在多实例下都会失效原因在第4章展开。3.1 phpredis与predis怎么选PHP操作Redis有两大选择phpredis扩展和predis纯PHP库。对比项phpredis扩展predis运行方式C扩展编译进PHP纯PHPComposer安装性能高函数直接调用较低多一层PHP封装安装成本需要编译扩展composer require 即可Lua支持原生支持支持长连接支持 pconnect支持我的建议能用phpredis就用phpredis。限流代码在请求路径上每个请求都会操作Redis性能差一个量级在高并发下就是天壤之别。如果项目环境不方便装扩展比如虚拟主机predis也能用但要在前面加一层APCu本地缓存减少Redis请求次数。3.2 固定窗口与滑动窗口的PHP代码实战固定窗口的完整写法注意并发环境下仍然有极小概率的过期时间覆盖问题所以我在线上一般直接用Lua-- KEYS[1]: 限流key -- ARGV[1]: 阈值 -- ARGV[2]: 窗口大小秒 local current redis.call(INCR, KEYS[1]) if current 1 then redis.call(EXPIRE, KEYS[1], tonumber(ARGV[2])) end if current tonumber(ARGV[1]) then return 0 end return 1PHP调用$rateLimitScript LUA local current redis.call(INCR, KEYS[1]) if current 1 then redis.call(EXPIRE, KEYS[1], tonumber(ARGV[2])) end if current tonumber(ARGV[1]) then return 0 end return 1 LUA; $allowed $redis-eval($rateLimitScript, [$key, $limit, $window], 1); if (!$allowed) { http_response_code(429); exit(json_encode([msg 请求过于频繁])); }滑动窗口的Lua脚本在前面2.2节已经给了线上版本还要加一个小细节ZSET的member如果直接拼接时间戳和随机数可以保证唯一性但要注意Redis高版本对member长度的限制建议控制长度不超过128字节。一般时间戳加随机数完全够。3.3 令牌桶Lua脚本的完整实现令牌桶在Redis里需要两个key一个存当前令牌数一个存上次补充时间。-- KEYS[1]: 令牌桶key -- KEYS[2]: 上次补充时间key -- ARGV[1]: 桶容量 -- ARGV[2]: 每秒补充速率 -- ARGV[3]: 当前时间戳秒 -- ARGV[4]: 本次请求消耗令牌数默认1 local tokens_key KEYS[1] local timestamp_key KEYS[2] local capacity tonumber(ARGV[1]) local rate tonumber(ARGV[2]) local now tonumber(ARGV[3]) local requested tonumber(ARGV[4]) or 1 local tokens tonumber(redis.call(GET, tokens_key) or capacity) local last_refill tonumber(redis.call(GET, timestamp_key) or now) -- 计算这段时间补充的令牌数 local delta math.floor((now - last_refill) * rate) if delta 0 then tokens math.min(capacity, tokens delta) redis.call(SET, timestamp_key, now) end if tokens requested then redis.call(SET, tokens_key, tokens - requested) redis.call(EXPIRE, tokens_key, 60) redis.call(EXPIRE, timestamp_key, 60) return 1 else -- 令牌不足把补充后的令牌数写回去避免丢失 redis.call(SET, tokens_key, tokens) redis.call(EXPIRE, tokens_key, 60) redis.call(EXPIRE, timestamp_key, 60) return 0 endPHP调用$tokenBucketScript file_get_contents(token_bucket.lua); $result $redis-eval($tokenBucketScript, [ $tokensKey, $timestampKey, $capacity, // 100 $rate, // 10 time(), // 当前秒级时间戳 1 // 消耗1个令牌 ], 2); if ($result ! 1) { http_response_code(429); exit(系统繁忙请稍后再试); }这个脚本会把两个key都设置60秒过期主要防止长期不用的key堆积在Redis。需要注意如果业务量很小两个key被过期清理后重新初始化时令牌会回到满桶状态这是允许的——长期没访问的接口首次放满令牌不会造成伤害因为后面速率会迅速拉回正常水平。另外说明一下为什么用“秒”而不是“毫秒”做时间单位毫秒精度下math.floor((now - last_refill) * rate)在rate小于1时会出现频繁为0的情况导致令牌补充被截断。用秒做单位速率至少支持到每秒1个令牌满足大部分接口场景。如果你需要更细粒度的速率可以改成毫秒并把rate换成“每毫秒多少令牌”。这个细节在线上踩坑时值得注意。4. 分布式场景下的限流设计与踩坑记录单机限流很简单但线上系统几乎都是多实例部署。这一章聊聊分布式场景下我们发现的一些关键问题和解决思路。4.1 多实例部署为什么不能用本地计数有个反直觉的事实用PHP自带的文件计数或者APCu做限流在多实例部署时会“必然失效”。原因很简单Nginx负载均衡会把请求分发到不同的PHP-FPM进程假如有3台服务器每台各自计数100次/分钟那么用户实际可以打300次/分钟。你以为限了流实际上等于没限。这时候必须用集中式计数器Redis就是最常见的载体。只要所有PHP实例读写同一个Redis key计数就是全局统一的。如果你的项目用了Redis Cluster要注意key的哈希槽问题同一业务限流相关的key最好用hash tag例如{rate_limit:login}:user:123把多个key固定到同一个槽位否则Lua脚本操作多个key时会报CROSSSLOT错误。4.2 限流key的粒度设计要避开哪些坑设计限流key时常见的坑我列一下key粒度过粗所有接口共用一个key某个接口被刷整个服务所有接口全挂。我见过有项目把所有接口都塞进一个key结果一个爬虫脚本就把全部业务禁掉了。key粒度过细每个用户一个key无法限制全局总流量。攻击者只要变换账号就能绕过限流。线上正确做法是双层key全局key 用户key同时限制。key中包含可变参数有人把请求参数拼进key比如rate:{userId}:{goodsId}攻击者只要变换参数就能绕过限制。key没有设置过期时间固定窗口的key如果expire没设置对Redis内存会被塞满。我建议的key命名规范rate_limit:{业务场景}:{维度}:{标识}例如rate_limit:login:ip:192.168.1.1 rate_limit:login:user:10086 rate_limit:api:global:/api/v1/order每个key都必须设置TTLTTL长度至少等于窗口大小。对于长时间不活跃的用户key可以用EXPIRE兜底清理。4.3 限流与熔断降级的协作Redis挂了怎么办我先说你最不想遇到的情况限流依赖RedisRedis一挂Redis请求本身超时会导致服务响应变慢甚至堆积。这时候限流反而成了拖垮系统的帮凶。我现在的做法是分三层兜底第一层Redis集群高可用主从哨兵或者Cluster从根源上降低挂的概率。第二层Redis客户端设置短超时connectTimeout和readTimeout都设成300ms以内超时后走降级逻辑不能因为Redis慢让主业务卡死。第三层本地兜底限流。用APCu做一层极简的本地固定窗口限流阈值设置为主限流的1.5倍。Redis不可用时本地限流仍然能挡住绝大部分流量。if (!$redis-ping()) { // Redis挂了走APCu本地限流 $localKey apcu_limit: . $key; $count apcu_inc($localKey); if ($count 1) { apcu_store($localKey, $count, $window); } if ($count $localLimit) { http_response_code(429); exit(请求过于频繁); } }这套设计能保证Redis故障期间系统不会直接裸奔。但也要注意APCu是单机内的多实例共享不足的问题在这里天然存在所以本地限流只能做兜底不能替代Redis主限流。4.4 压测时发现限流失效的排查清单有时候代码写好了压测一跑发现限流根本没生效。根据我的经验90%的情况出在以下几个地方现象可能原因排查方式限流失效QPS一路飙高限流key未生效可能拼错了维度在限流代码里加日志打印key和incr返回值部分请求绕过限流Nginx层有缓存请求没打到PHP查看Access Log中实际进入PHP的请求量Redis QPS突然暴涨限流逻辑每请求都调Redis本身成为瓶颈用Redis的MONITOR命令看请求key的分布返回429但业务还在往下执行限流判断和业务逻辑代码顺序问题检查代码里是否在限流判断前就有其他逻辑限流时好时坏Redis连接超时导致eval失败捕获Redis异常看异常日志压测时最容易被忽略的是压测工具的并发连接数太高Redis本身的QPS成了瓶颈。这时候限流代码每次请求都要round-trip一次Redis一万并发就是一万次Redis请求Redis不挂才怪。改进方案是配合第5.2节说的本地预过滤。5. 真实项目中我踩过的限流坑前面思路和代码都给了最后分享几个我在真实项目里反复踩过的坑。这些细节不踩一次很难发现文档里基本不会写。5.1 误伤正常用户的三宗罪限流做太狠误伤正常用户这个问题比限流失效还闹心因为流量损失不可见。第一个坑是阈值拍脑袋。把登录接口限成每分钟5次结果用户手机网络抖动重试几次就被锁了。合理做法是参考日常峰值和压测数据。我习惯在监控里统计一周的请求量分布取P95或P99值再乘以1.5~2作为阈值。第二个坑是限流维度太粗。比如全局限流1000 QPS某个秒杀活动正常流量就有2000正常用户全部被拒。这种情况应该按业务线分流秒杀接口单独一套阈值。第三个坑是没有白名单机制。内部监控系统的探活请求、搜索引擎爬虫、合作方回调这些根本不该有限流限制。我现在的做法是在限流过滤器里加一段白名单判断检测到指定来源比如IP白名单、请求头里的特殊标识直接放行。5.2 Redis连接风暴与key堆积问题前面提到高性能接口用滑动窗口会有内存问题这里说一个更深层的坑Redis key堆积。固定窗口方案如果每次请求都用不同的用户ID做key一天下来Redis里可能多几百万个key。虽然每个key都有TTL但大量key同时存在时Redis的keys命令会命中慢查询内存碎片率也会升高。我的做法是定期清理。写一个计划任务每天扫描rate_limit:*前缀的key统计数量和内存占用。超过阈值就用SCAN命令分批清理。另外一个改进是尽量复用key在key上叠加时间分片比如rate_limit:api:user:10086:20240615这样每个key的生命周期最多一天内存不会无限增长。5.3 聚焦业务场景登录防爆破、秒杀、导出任务把上述思路落到具体业务上我分享三个场景的配置参考登录防爆破固定窗口就够不用上令牌桶。IP维度每分钟20次账号维度防止撞库每15分钟5次验证码维度每小时10次。登录失败次数要按“失败次数”计数连续失败5次强制等待30分钟。秒杀接口令牌桶主限流capacity设成压测峰值的1.5倍rate设成后端实际处理能力的80%。秒杀请求本身不落数据库先打到Redis队列限流逻辑放在队列入口。批量导出任务用漏桶或信号量控制并发数比如同一用户同时最多2个导出任务超过就排队或报错。这种场景更看重并发数而非QPS可以配合Redis的SETNX加锁实现并发控制。5.4 最后分享一个经验限流阈值从哪来我自己的经验是限流阈值不要太“完美主义”。一开始宁可给宽松一点上线后靠监控数据慢慢收紧。比如先按压测上限的70%设置跑一周看监控里的以下几项指标429响应占比、Redis内存、数据库QPS、核心接口RT。如果429占比低于1%可以逐步下调阈值如果RT明显波动说明阈值还是太高。先上线、再调优这套思路比理想化的一次到位靠谱得多。限流是为了让系统活着而不是为了拒客。还有一个实用技巧限流响应不要只丢一个429建议在响应头里带上X-Rate-Limit-Remaining剩余可请求次数、X-Rate-Limit-Reset重置时间。客户端可以根据这些字段做本地退避减少对服务端的无效重试。给APP端用的时候这个体验差异非常明显。最后补充一个我反复提的细节限流代码一定要放在业务代码最前面越早拦截越好。最理想的位置是在Nginx层OpenResty的limit_req但那需要运维配合改配置退而求其次是在PHP框架的中间件或入口脚本里做总之不要等请求已经走了好几百行代码才想起来限流。
返回列表