TP8限流必须用Lua+Redis原子脚本,因INCR+EXPIRE有窗口期风险;需手动构造安全key(替换特殊字符、游客fallback为IP)、规范中间件结构与Redis配置。

TP8 不能直接用 RateLimiter,必须手写 Lua + Redis 中间件;TP6 的 throttle 中间件在闭包路由、资源路由未绑定时根本不会触发——这不是配置问题,是设计限制。
为什么不能用 INCR + EXPIRE 组合
高并发下必然出现“计数超限但过期没设上”的窗口期。比如 100 个请求同时读到 tokens 为 1,全部通过判断后执行 DECR,结果 99 个被错误放行。这不是概率事件,压测必现。
必须用 Lua 脚本保证原子性,且注意:
-
argv全是字符串,tonumber(argv[1])必须显式转换,否则if remaining > 0永远为 false - 返回值必须是数组,例如
return {allowed, remaining},中间件才能拿到剩余令牌数用于调试或响应头 -
KEYS[1]传 key 名,不要拼接字符串,否则 Redis Cluster 下 key 分布不一致
TP8 如何构造安全的限流 key
TP8 不再自动拼接 $request->url(true) . $request->ip(),你得自己构造,常见坑:
立即学习“PHP免费学习笔记(深入)”;
- key 含斜杠、冒号(如
rate:api/v1/user/info:127.0.0.1)→ 某些 Redis 驱动(如 predis)解析失败,应统一替换:str_replace(['/', ':', '?'], '_', $key) - 按
$auth->id构造,但游客请求没登录 →$auth->id为空 → 所有游客共用一个桶 → 一人刷崩全站,必须 fallback:$userId ?: $request->ip() - 混入动态参数(如
rate:api/v1/user/123/info)→ 每个用户 ID 单独一个桶,但攻击者可批量构造不同 ID 绕过限流,应按接口维度聚合,再叠加用户/IP 二级维度
TP8 限流中间件完整结构(app/middleware/RateLimitMiddleware.php)
核心逻辑必须放在中间件层,不能塞进控制器——否则跨路由、跨方法无法聚合统计,权限校验前也拦不住恶意刷量。
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
关键点:
- IP 必须取
$request->header('X-Real-IP', $request->ip()),并确保 Nginx 已配proxy_set_header X-Real-IP $remote_addr; - key 命名空间建议:
rate:{$routeName}:{$ipOrUserId},其中$routeName用$request->rule() ?: $request->path()避免闭包路由为空 - 调用
Redis::eval()时,Lua 脚本里用redis.call('hmget', keys[1], 'last_fill_ms', 'tokens')实现漏斗算法,比简单计数更抗突发 - catch 块里必须显式返回
$response->withStatus(429)->header('Retry-After', 60),否则异常时中间件静默失效
示例 Lua 片段(用于 Redis::eval()):
local capacity = tonumber(argv[1])
local rate_per_sec = tonumber(argv[2])
local now_ms = tonumber(argv[3])
local state = redis.call('hmget', keys[1], 'last_fill_ms', 'tokens')
local last_fill_ms = tonumber(state[1]) or now_ms
local tokens = math.min(capacity, tonumber(state[2]) or capacity + (now_ms - last_fill_ms) * rate_per_sec / 1000)
if tokens < 1 then
return {0, 0}
end
redis.call('hset', keys[1], 'last_fill_ms', now_ms, 'tokens', tokens - 1)
return {1, tokens - 1}Redis 连接与驱动配置容易被忽略的点
TP8 默认不带 think-redis,需手动安装 ext-redis 并确认已启用;config/redis.php 中必须显式设置 'prefix' => 'tp8_rate_',否则多个项目共用同一 Redis 实例时 key 冲突。
更重要的是连接池配置:
- 若用 Swoole 或 RoadRunner,必须开启
'persistent' => true,否则每次请求新建连接,Redis 连接数暴涨 - 生产环境务必设
'timeout' => 1(秒),避免单个慢查询拖垮整个中间件链路 - 别用
cache.driver = file测试限流——文件锁会导致并发计数严重不准,线上必须切到redis驱动
复杂点在于:漏斗算法中 last_fill_ms 和 tokens 必须用 HSET 存储,不能拆成两个独立 key,否则无法保证原子读写;而 TP8 的 Redis facade 不暴露 pipeline,所以只能靠 Lua 把填充、判断、扣减全包进去——这正是你不能抄 TP6 示例代码的底层原因。


















