必须用lua脚本实现滑动窗口限流,因incr+expire非原子操作会导致高并发下竞态失效;脚本需原子执行清理、统计、判断、写入四步,并正确设置过期防止内存泄漏。

直接用 INCR + EXPIRE 两步走做滑动窗口,必然出错——这不是性能问题,是逻辑缺陷。必须用 Lua 脚本把「清理过期数据 → 统计数量 → 判断是否超限 → 写入新时间戳」这四步锁死为原子操作。
为什么不能用 PHP 自增 + 过期分两步
高并发下,两个请求几乎同时进来:第一个刚 INCR 完但还没 EXPIRE,第二个就查到旧值并误判为“未超限”,结果双双放行。固定窗口还能靠窗口重置勉强兜底,滑动窗口依赖精确时间范围,这种竞态会导致限流完全失效。
- Redis 的
INCR和EXPIRE不是原子的,中间存在时间窗口 - PHP 层无法保证多进程/多协程间对同一 key 的操作顺序
- 哪怕加了
mutex锁,也会严重拖慢吞吐,失去 Redis 限流的意义
滑动窗口 Lua 脚本关键参数和行为
脚本接收 KEYS[1](限流 key)、ARGV[1](当前毫秒时间戳)、ARGV[2](窗口毫秒长度)、ARGV[3](最大请求数)四个参数,核心逻辑是:
-
ZREMRANGEBYSCORE清理所有早于ARGV[1] - ARGV[2]的记录 -
ZCARD获取当前窗口内剩余请求数 - 未超限时,用
ZADD插入ARGV[1]作为 score、一个唯一标识(如microtime(true).rand(1000,9999))作为 member - 必须调用
EXPIRE设置 key 过期时间为ceil(ARGV[2]/1000) + 1秒,防止 zset 残留导致内存泄漏
示例脚本片段:
local window_start = tonumber(ARGV[1]) - tonumber(ARGV[2])
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, window_start)
local current_count = redis.call('ZCARD', KEYS[1])
if current_count <h3>IP 限流时真实客户端识别容易翻车</h3><p>直接读 <code>$request->client->host</code> 或 <code>$_SERVER['REMOTE_ADDR']</code>,在 Nginx、Cloudflare、SLB 后面拿到的几乎肯定是代理 IP,不是用户真实出口 IP。</p>
- 必须检查
X-Forwarded-For或X-Real-IP,但仅信任你可控的上游代理头(比如只认 Nginx 配置里明确 set 的那个) - 更稳妥的做法是优先按
user_id限流,登录态缺失时再 fallback 到 IP,并对 IP 做可信链校验 - 如果业务允许,用设备指纹(如 UA + IP + TLS 指纹哈希)替代纯 IP,能显著降低误杀率
PHP 调用 EVAL 执行限流脚本的实操要点
别封装过度,直接用 Redis::eval() 最稳。注意三点:
- 脚本内容建议放在单独文件(如
resources/lua/sliding_window.lua),用file_get_contents()加载后传入,避免字符串拼接出错 - key 名要带业务上下文,比如
"api:login:ip:".$realIp或"api:sms:phone:".$phone,避免不同接口共用 key 导致误限 - 返回值是整数:1 表示放行,0 表示被限,不要当成布尔值直接 if 判断(Lua 返回 nil 或 number,PHP 可能类型转换异常)
调用示例:
$lua = file_get_contents(__DIR__.'/../lua/sliding_window.lua'); $result = $redis->eval($lua, [$key], 3, $nowMs, $windowMs, $limit);
滑动窗口真正难的不是写脚本,而是 key 的粒度设计和客户端识别链路的可靠性——同一个用户换网络、开多个标签页、走不同 CDN 节点,都可能被算作不同请求源。上线前务必用真实代理链路压测,别只在本地 curl 127.0.0.1 验证。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











