ThinkPHP限流中间件应使用Redis显式连接+Lua令牌桶实现,key含用户标识与接口路径,通过路由配置动态读取策略,连接失败时降级放行并记录日志。

ThinkPHP 中间件里怎么接入 Redis 实现限流
直接用 Redis 手写令牌桶逻辑,比依赖扩展更可控、更轻量。中间件是唯一合理位置——在路由解析后、控制器执行前拦截,避免重复校验或漏判。
常见错误是把限流逻辑写进控制器方法里:既难复用,又绕过异常处理流程;或者用 Cache::get 读取文件缓存,扛不住并发,incr 操作直接丢数据。
- 必须用
Redis::connection()显式获取连接,别用Cache::store('redis'),后者不支持原子命令如eval和incr - 令牌桶的
key要包含用户标识(如$request->ip()或$auth->id)+ 接口路径($request->url(true)),否则所有人共享一个桶 - 用
Redis::eval()执行 Lua 脚本,保证「读桶余量→判断→扣减」三步原子性,避免并发时超发
令牌桶 Lua 脚本怎么写才不出错
TP 自带的 Redis::eval() 不自动序列化参数,Lua 脚本里所有传入值都是字符串,tonumber() 必须手动调,否则 if remaining > 0 永远为 false。
脚本要返回两个值:是否允许通过(1/0)、当前剩余令牌数(用于调试和响应头)。别只返回布尔值,排查时没法定位是漏刷还是桶空了。
立即学习“PHP免费学习笔记(深入)”;
return { tonumber(remaining) > 0 and 1 or 0, tonumber(remaining) }
-
KEYS[1]是桶 key,ARGV[1]是令牌总数,ARGV[2]是周期秒数,ARGV[3]是当前时间戳(time()) - 别在 Lua 里算“上次填充时间”,直接用
redis.call('ttl', KEYS[1])获取剩余过期时间,再反推已过去多久 - 首次访问时
redis.call('exists', KEYS[1]) == 0,就用redis.call('setex', KEYS[1], ARGV[2], ARGV[1])初始化满桶
如何给不同接口配不同限流策略
硬编码在中间件里?不行。得从路由或控制器注解里动态读配置,否则加个新接口就要改中间件代码。
推荐方案:在路由定义时用 option 注入限流参数,比如 ['rate_limit' => ['max' => 100, 'per' => 60]];中间件里用 $request->routeInfo()[2]['option']['rate_limit'] ?? null 拿到配置。
- 没配
rate_limit的接口默认不限流,别全局开关一刀切 -
per单位必须是秒,别用分钟或毫秒,Redis 过期时间只接受整数秒 - 同一接口对游客和登录用户要区分限流:游客按
$request->ip(),登录用户按$auth->id,两者 key 前缀不同
Redis 连接失败或超时怎么兜底
线上 Redis 挂了,限流中间件不能跟着挂——得降级为“记录日志 + 放行”,而不是抛 RedisException 导致整个接口 500。
TP 的 Redis::connect() 默认不抛异常,但 eval() 失败会 throw。必须用 try/catch 包住核心逻辑,并设默认放行标记。
- 捕获
RedisException和Throwable,记录Log::warning('rate limit redis unavailable') - 降级后仍要返回标准响应头,如
$response->header('X-RateLimit-Remaining', -1),让前端知道限流不可用 - 别在 catch 里重试 Redis,网络问题不是重试能解决的;更别 fallback 到 file cache,性能差且不一致
真正麻烦的是冷启动时的突发流量:桶初始是满的,但 Redis 连接池还没建好,第一次请求可能误判。所以初始化脚本里要预热一次 Redis::ping(),比等出错再处理强。



















