限流逻辑必须放在中间件层,而非控制器;应使用Redis Lua脚本实现原子计数与过期设置,结合X-Real-IP或token解析的用户标识构造带命名空间的key,并统一返回429响应及Retry-After头。

限流逻辑该放在哪一层?别在控制器里手写计数
ThinkPHP 的 API 限流必须剥离业务逻辑,否则 index() 里塞 $redis->incr() 和 expire() 会污染可维护性,也绕过中间件的统一拦截能力。真实项目中,90% 的限流失效都源于把限流判断写在了控制器里——它无法覆盖跨方法、跨路由的请求聚合统计,更没法在权限校验前就拒绝恶意刷量。
正确做法是用中间件:ThinkPHP 6+ 支持全局/分组中间件,app/middleware/RateLimitMiddleware.php 是唯一该放限流的地方。它会在所有路由匹配后、控制器执行前触发,天然支持按 route、ip 或自定义键(如 token_user_id)做维度控制。
- 不要在
__invoke()外写静态计数器——PHP-FPM 进程间不共享内存,static $count = 0完全无效 - Redis 键名必须带命名空间和过期标识,例如
rate:api:v1.users.index:192.168.1.100,避免不同接口或 IP 的计数互相污染 - 务必在
catch块里也调用$response返回,否则异常时中间件静默失败,限流形同虚设
Redis 计数怎么防并发超卖?用 INCR + EXPIRE 原子组合不行
INCR 和 EXPIRE 分两步调用,在高并发下必然出现“计数已超但过期没设上”的窗口期。线上曾有接口被单 IP 每秒 200 次请求穿透,原因就是用了 $redis->incr($key); $redis->expire($key, 60)。
必须改用 Lua 脚本保证原子性。ThinkPHP 自带的 think\cache\driver\Redis 不直接暴露 eval,但可通过原生连接调用:
立即学习“PHP免费学习笔记(深入)”;
$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 分布不一致 - 首次
INCR返回 1 才设EXPIRE,避免重复设置过期时间干扰 TTL 计算 - 不要用
SETNX + INCR组合——它无法解决“多个请求同时发现 key 不存在、同时 setnx 成功”的竞态
如何按用户身份限流?别直接用 $_SERVER['REMOTE_ADDR']
API 接口的真实调用者常经过 Nginx、CDN 或云 WAF,$_SERVER['REMOTE_ADDR'] 拿到的是代理 IP,导致同一用户被分散计数,或整个 CDN 网段被误封。
应优先从请求头提取可信标识:X-Real-IP(Nginx 透传)、X-Forwarded-For(取第一个非私有地址)、或业务层的 Authorization token 解析出 user_id。ThinkPHP 中可在中间件里这样取:
$request = \think\Request::instance();
$clientIp = $request->header('X-Real-IP', $request->ip());
$userId = $request->header('Authorization') ? $this->parseTokenUserId($request->header('Authorization')) : null;
$key = $userId ? "rate:api:{$route}:user_{$userId}" : "rate:api:{$route}:ip_{$clientIp}";
- 永远校验
X-Real-IP是否在可信代理列表内,否则前端可伪造该 header 绕过限流 - 若用 token 限流,需确保
parseTokenUserId()不触发数据库查询——应从 JWT payload 直接解析,或查缓存 - 未登录用户仍需 fallback 到 IP 限流,但 key 中加
guest_前缀,避免和登录用户 key 冲突
限流响应码和消息怎么写才不翻车?429 不是万能的
返回 429 Too Many Requests 是标准做法,但很多团队忽略两点:前端是否真识别这个状态码、错误体格式是否和其它 API 一致。曾有 H5 页面收到 429 后走默认错误弹窗,显示“网络错误”,用户完全不知是被限流。
必须保持响应结构统一。ThinkPHP 默认 JSON 输出是 ['code'=>0,'msg'=>'ok','data'=>...],限流响应也得对齐:
return json([
'code' => 429,
'msg' => '请求过于频繁,请稍后再试',
'data' => [
'retry_after' => 60,
'limit' => 100,
'remaining' => 0
]
], 429);
- Header 中必须带
Retry-After: 60,这是 RFC 6585 明确要求的,部分 HTTP 客户端(如 axios)会自动读取并延迟重试 - 不要只返回纯文本或空 body——前端异常捕获逻辑可能依赖
data.code字段做分支处理 - 如果限流策略分等级(如免费用户 100 次/小时、VIP 1000 次/小时),
limit和remaining必须动态计算,不能硬编码
Redis 连接池配置、Lua 脚本超时、以及限流 key 的 TTL 精度(秒级 vs 毫秒级)才是真正压测时暴露问题的地方,这些细节比“怎么写中间件”更决定上线后的稳定性。


















