限流中间件需注册并显式绑定路由才能生效;键名应基于可信IP或用户ID构造;必须用Lua脚本原子操作计数与过期;异常时须显式返回429响应。

确认限流中间件是否真正生效
ThinkPHP 6.1 的限流逻辑不会自动运行,必须确保中间件已注册且路由明确绑定——闭包路由、资源路由未显式指定控制器方法时,throttle中间件完全不触发。
检查 app/middleware.php 中是否已启用全局中间件开关:'use_global_middleware' => true;若未开启,仅靠路由链式调用 middleware() 仍可能被跳过。
在 app/route/app.php 中,必须使用控制器方法绑定语法:Route::get('api/user', 'Api/UserController@index')->middleware('throttle:30,1');写成 Route::get('api/user', function(){}) 就等于没加限流。
构造带命名空间的唯一限流键
方法一:直接使用 IP + 路由规则拼接
立即学习“PHP免费学习笔记(深入)”;
$key = 'rate:' . $request->ip() . ':' . $request->rule(); 这种写法简单,但若 Nginx 未透传 X-Real-IP,$request->ip() 会恒为 127.0.0.1,所有请求共用一个计数器。
方法二:优先解析可信头并清洗 IP
在自定义中间件中先做校验:$realIp = $request->header('X-Real-IP', false); $ip = filter_var($realIp ?: $request->ip(), FILTER_VALIDATE_IP) ?: '0.0.0.0'; 再拼接 $key = 'rate:api:' . $request->rule() . ':' . $ip;不做过滤直接拼接 X-Forwarded-For 可能引入逗号分隔的脏数据,导致 Redis key 错误。
方法三:按登录用户限流(需 Token 解析)
从 Authorization 头提取 Bearer token,调用 JwtToken::parse($token)->getUserId() 获取 ID;若解析失败,降级为 IP;键名格式为 rate:user:{$userId}:{$route},避免未登录用户挤占已登录用户的额度。
用 Lua 脚本实现原子计数与过期设置
第一步:禁止使用两步操作 $redis->incr($key) → $redis->expire($key, 60)
高并发下存在“计数已超但过期未设”的竞态窗口,实测单 IP 每秒 150+ 请求即可穿透。
第二步:改用 eval 执行内联 Lua
$lua = "local current = redis.call('incr', KEYS[1]) if current == 1 then redis.call('expire', KEYS[1], ARGV[1]) end return current"; $count = $this->redis->eval($lua, [$key], 1, 60); 注意:KEYS[1] 必须是数组索引传入,不能字符串拼接,否则 Redis Cluster 下 key 分布异常。
第三步:判断是否超限并返回响应
if ($count > $maxAttempts) { $response = Response::create(['msg' => 'Too Many Requests'], 'json', 429); $response->header('Retry-After', $decaySeconds); return $response; }
异常路径必须显式返回响应
在中间件 __invoke() 方法的 try...catch 块中,catch 部分若只写 Log::error($e) 而不 return $response,请求将绕过限流继续进入控制器——这是线上最隐蔽的限流失效原因。
务必在 catch 最后一行加上 return $response,哪怕只是 Response::create('', 429);否则异常时中间件静默失败,限流形同虚设。



















