
前言接口被刷的典型症状很好认某个时间点开始 QPS 从 20 冲到几千数据库连接池被打满too many connections开始刷屏日志里同一个 IP 或同一个access_token每秒出现几百次参数高度雷同。更麻烦的是慢刷——攻击者把频率压到每分钟几次但持续几十小时把短信、验证码、导出报表这类有成本的接口当成免费资源慢慢薅风控图上完全看不出来。PHP 本身不提供任何限流能力它是每个请求一个进程的模型进程之间不共享内存所以限流状态必须放在进程之外——Redis 是最常见的选择Nginx 层则负责拦掉最粗暴的那一拨。本文讲清楚三件事限流算法怎么选Redis 侧的原子性怎么保证以及防刷为什么不能只靠 QPS 限流。关于版本PHP 8.5 已于 2025 年 11 月 20 日发布它带来的是语言层便利管道操作符|、array_first()/array_last()等并没有新增任何限流机制。本文示例代码标注了各自需要的最低版本限流方案本身在 PHP 8.0 上一样能跑。一、三种限流算法的取舍算法实现方式优点缺点适用场景固定窗口Fixed WindowINCR keyEXPIRE实现最简单、内存极小窗口边界处可放行 2 倍流量粗粒度总闸滑动窗口Sliding WindowZSET 存时间戳按分数清理边界平滑、精度可控每次请求多几条命令内存与 QPS 成正比面向用户的 API 限流令牌桶Token Bucket哈希存令牌数与上次时间允许突发burst、长期速率恒定需要浮点运算逻辑略复杂允许突发的高频接口漏桶Leaky Bucket队列式匀速放行出口速率绝对平滑不允许突发下游很脆弱时保护后端选型上有一条经验对外网关用固定窗口做粗筛业务 API 用滑动窗口做精筛允许突发的场景比如客户端一次批量提交用令牌桶。固定窗口的边界双倍问题不是理论风险攻击者只要在窗口切换的那一瞬间集中发请求就能用 60 次/分钟的配置打出 120 次。Nginx 自带的limit_req是漏桶实现配置如下可以挡住绝大部分脚本小子# nginx.conf 的 http 块 limit_req_zone $binary_remote_addr zoneapi_zone:10m rate20r/s; limit_conn_zone $binary_remote_addr zoneconn_zone:10m; server { listen 80; server_name api.example.com; location /api/ { limit_req zoneapi_zone burst40 nodelay; limit_req_status 429; limit_conn conn_zone 20; limit_conn_status 429; # 慢速攻击限制读请求头与请求体的时间 client_header_timeout 10s; client_body_timeout 10s; fastcgi_pass 127.0.0.1:9000; include fastcgi_params; } }rate20r/s表示令牌补充速率burst40是桶容量nodelay决定桶里的令牌是否可以一次性用掉——不加nodelay时超出速率的请求会被排队延迟处理这在长连接场景下会拖高响应时间。二、用 Lua 脚本保证 Redis 侧是原子操作限流最容易写错的地方是读-判断-写三步分开执行。三个 PHP-FPM 工作进程同时读到count 59然后都判断小于 60放行结果一次放行三个。解决方案是把整个逻辑塞进 Lua 脚本Redis 单线程执行脚本期间不会被打断。滑动窗口的 Lua 脚本注意时间取自 Redis 服务端避免各台 Web 机时钟不同步-- sliding_window.lua -- KEYS[1] 限流键 -- ARGV[1] 窗口大小毫秒 ARGV[2] 窗口内上限 ARGV[3] 本次请求的唯一成员 local key KEYS[1] local window tonumber(ARGV[1]) local limit tonumber(ARGV[2]) local member ARGV[3] -- 用 Redis 自己的时钟避免多台 Web 机时间不一致 local t redis.call(TIME) local ms t[1] * 1000 math.floor(t[2] / 1000) -- 清除窗口外的旧记录 redis.call(ZREMRANGEBYSCORE, key, 0, ms - window) local count redis.call(ZCARD, key) if count limit then redis.call(ZADD, key, ms, member) redis.call(PEXPIRE, key, window) return {1, limit - count - 1, 0} end -- 被拒绝时算出还要等多久 local oldest redis.call(ZRANGE, key, 0, 0, WITHSCORES) local retry math.ceil((tonumber(oldest[2]) window - ms) / 1000) return {0, 0, retry}令牌桶版本适合允许瞬时突发的接口-- token_bucket.lua -- KEYS[1] 桶键 -- ARGV[1] 桶容量 ARGV[2] 每秒补充令牌数 ARGV[3] 本次消耗令牌数 local key KEYS[1] local capacity tonumber(ARGV[1]) local rate tonumber(ARGV[2]) local need tonumber(ARGV[3]) local t redis.call(TIME) local now t[1] t[2] / 1000000 local data redis.call(HMGET, key, tokens, ts) local tokens tonumber(data[1]) local ts tonumber(data[2]) if tokens nil then tokens capacity end if ts nil then ts now end -- 按流逝的时间补令牌但不超过桶容量 tokens math.min(capacity, tokens (now - ts) * rate) local allowed 0 if tokens need then tokens tokens - need allowed 1 end redis.call(HSET, key, tokens, tokens, ts, now) redis.call(EXPIRE, key, math.ceil(capacity / rate) 60) return {allowed, math.floor(tokens)}三、PHP 侧的封装多维度、带响应头限流不能只看 IP。家用宽带后面可能坐着成千上万个用户只按 IP 限会误伤而只按用户限未登录的接口就等于不设防。正确做法是同时挂多个维度任一维度超限即拒绝。?php // rate_limiter.php —— 需 PHP 8.1readonly 属性与 phpredis 扩展 declare(strict_types1); final class RateLimitResult { public function __construct( public readonly bool $allowed, public readonly int $remaining, public readonly int $retryAfter, public readonly string $dimension , ) {} } final class SlidingWindowLimiter { private const LUA LUA local key KEYS[1] local window tonumber(ARGV[1]) local limit tonumber(ARGV[2]) local member ARGV[3] local t redis.call(TIME) local ms t[1] * 1000 math.floor(t[2] / 1000) redis.call(ZREMRANGEBYSCORE, key, 0, ms - window) local count redis.call(ZCARD, key) if count limit then redis.call(ZADD, key, ms, member) redis.call(PEXPIRE, key, window) return {1, limit - count - 1, 0} end local oldest redis.call(ZRANGE, key, 0, 0, WITHSCORES) local retry math.ceil((tonumber(oldest[2]) window - ms) / 1000) return {0, 0, retry} LUA; /** var arraystring, array{int, int} 维度 [次数, 窗口毫秒] */ private array $rules; public function __construct( private \Redis $redis, array $rules [], ) { $this-rules $rules ?: [ ip [120, 60_000], // 单 IP 每分钟 120 次 token [600, 60_000], // 单 token 每分钟 600 次 route [2000, 60_000], // 单接口每分钟 2000 次总闸 ]; } public function check(string $route, string $ip, ?string $token null): RateLimitResult { $identities [ip $ip, route $route]; if ($token ! null $token ! ) { $identities[token] hash(sha256, $token); } foreach ($this-rules as $dimension [$limit, $window]) { if (!isset($identities[$dimension])) { continue; } // 花括号让同一个限流键的所有操作落在同一个 Redis 槽位集群模式必需 $key sprintf(rl:{%s:%s}, $dimension, $identities[$dimension]); $member sprintf(%d-%s, (int) (microtime(true) * 1000), bin2hex(random_bytes(4))); /** var arrayint, int $res */ $res $this-redis-eval(self::LUA, [$key, $window, $limit, $member], 1); if ((int) $res[0] 0) { return new RateLimitResult(false, 0, (int) $res[2], $dimension); } } return new RateLimitResult(true, (int) $res[1], 0, token); } }接进框架前的入口脚本把结果写进响应头方便前端和网关排障?php // api.php —— 需 PHP 8.1 declare(strict_types1); $redis new \Redis(); $redis-connect(127.0.0.1, 6379, 1.0); $redis-auth($_ENV[REDIS_PASSWORD] ?? ); $redis-select(1); $limiter new SlidingWindowLimiter($redis); $route $_SERVER[REQUEST_URI] ?? /; $ip $_SERVER[REMOTE_ADDR] ?? 0.0.0.0; $token $_SERVER[HTTP_X_API_TOKEN] ?? null; $result $limiter-check($route, $ip, $token); header(X-RateLimit-Limit: 120); header(X-RateLimit-Remaining: . max(0, $result-remaining)); if (!$result-allowed) { header(Retry-After: . $result-retryAfter); http_response_code(429); echo json_encode( [code 429, msg 请求过于频繁请稍后重试, dim $result-dimension], JSON_UNESCAPED_UNICODE ); return; } // ... 正常业务 echo json_encode([code 0, msg ok], JSON_UNESCAPED_UNICODE);PHP 8.5 的语言特性可以让取身份 → 清洗 → 拼键这一段更好读但请记住这只是语法糖?php // 需 PHP 8.5 $identity $_SERVER | (fn (array $s): string $s[HTTP_X_API_TOKEN] ?? $s[REMOTE_ADDR] ?? unknown) | (fn (string $v): string preg_replace(/[^A-Za-z0-9_.:-]/, , $v));四、防刷QPS 限流只是第一层真正做防刷要区分高频攻击和低成本接口被薅两种形态后者 QPS 限流基本无效。刷法特征对策单 IP 高频同 IP 每秒数百次Nginxlimit_req Redis 滑动窗口分布式低频大量 IP各刷几次按 token/账号维度限流 设备指纹短信/邮件接口被薅请求量不高但成本高按手机号维度限流 图形验证码 每日配额重放攻击同一请求重复提交时间戳 nonce 去重 HMAC 签名校验慢速攻击建立连接后极慢地发数据Nginxclient_body_timeout、limit_conn竞态刷券并发抢同一资源分布式锁 数据库唯一索引兜底两个实现要点一是按成本扣令牌。导出 10 万行数据和查询一条记录代价差几个数量级用同一个 limit 是浪费。可以在令牌桶脚本的ARGV[3]里传本次消耗令牌数昂贵接口传 20普通查询传 1。二是封禁要有梯度。连续触发限流就升级为封禁且封禁时长递增?php // 需 PHP 8.1触发次数越多封禁越久 function banSeconds(\Redis $redis, string $ip): int { $key ban:cnt: . $ip; $count (int) $redis-incr($key); $redis-expire($key, 3600); // 计数窗口 1 小时 return min(86400, 2 ** min($count, 10)); // 2、4、8……最多 1 天 }常见坑点❌ 直接用$_SERVER[HTTP_X_FORWARDED_FOR]当客户端 IP 做限流✅ 上游有反向代理时该头部可被客户端伪造应只信任最后一跳代理取REMOTE_ADDR或按可信代理列表从X-Forwarded-For尾部往前取第一跳❌ 用INCREXPIRE两条命令实现固定窗口✅ 两条命令之间进程崩溃会让键永不过期计数只增不减该用户被永久封禁。改用 Lua 脚本或SET key 1 EX 60 NXINCR的组合❌ 在 Redis 集群模式下用普通 key 名✅eval脚本里所有 key 必须在同一个槽位用{}哈希标签如rl:{ip:1.2.3.4}❌ 用 PHP 的time()当滑动窗口的时间基准✅ 多台 Web 机时钟不同步会让窗口忽宽忽窄改在 Lua 里用redis.call(TIME)❌ 限流只做if ($count $limit) die(too many)不告诉客户端原因✅ 返回 429 状态码 Retry-AfterX-RateLimit-*响应头否则客户端会立即重试反而放大流量❌ 用REMOTE_ADDR单维度限流就上线✅ NAT 出口、CDN 回源、移动网络会让成千上万真实用户共享一个 IP必须叠加 token/账号维度并对白名单放行❌ 被拒绝的请求不做日志✅ 限流命中率是风控的核心指标要按维度打点谁、哪个接口、哪条规则否则永远分不清是攻击还是自己压测❌ 把验证码当唯一防刷手段接口本身不限流✅ 验证码有被绕过和被打码平台破解的成本上限接口层限流必须独立存在两者是叠加关系总结关注点做法关键约束粗筛Nginxlimit_reqlimit_conn放在最外层省掉无效的 PHP 请求精筛Redis Lua 滑动窗口读判写必须在同一段脚本里完成允许突发令牌桶按成本扣令牌令牌数用浮点补桶容量控制突发上限时间基准RedisTIME不依赖各台机器的本地时钟集群兼容{}哈希标签一次脚本只操作同一槽位的 key防刷纵深多维度 签名防重放 验证码 梯度封禁QPS 限流只是其中一层限流的本质是用一个可接受的误伤率换取后端可用性所以真正要调的不是代码而是参数先统计线上真实的 P95 调用频率再把阈值设在其上一点然后观察一周的误伤工单数量。至于 PHP 版本8.5 的管道操作符和array_first()能让限流代码更短但限流能不能挡住攻击取决于放在哪一层、状态是否原子、维度是否够细这些和语言版本无关。