漏桶限流不能直接用INCR+EXPIRE,因为它需模拟“匀速漏水”行为,而INCR仅计数、EXPIRE仅设过期,无法动态计算并扣除已漏水量;必须通过Lua脚本原子执行“按时间差计算漏量→更新水量→判断是否放行”三步,否则退化为简单计数器或滑动窗口。

漏桶限流为什么不能直接用 INCR + EXPIRE
因为漏桶需要“匀速漏水”行为,而 Redis 原生命令无法自动模拟水滴漏出过程。常见错误是只记录请求次数、设置固定过期时间,结果变成简单计数器(类似令牌桶的简化版),不满足漏桶“恒定速率释放”的定义——它要求:即使突发流量打满桶,后续请求也必须按固定间隔被允许,不能靠重置时间窗口来“跳过等待”。
ThinkPHP 中若直接在中间件里写 $redis->incr($key) 再判断是否超限,没处理“漏出”逻辑,就等于把漏桶写成了滑动窗口。
- 漏桶核心是维护一个「当前水量」和「上次漏水时间戳」
- 每次请求来时,先按时间差计算该漏掉多少水(向下取整),再更新水量
- Redis 里必须用
EVAL原子执行计算+更新,否则并发下水量会算错 - ThinkPHP 的
Cache::store('redis')->handler()可拿到原生 Redis 实例,但要注意连接是否支持 Lua
用 Lua 脚本实现原子化漏桶逻辑
把“计算漏水量、更新水量、判断是否放行”三步压进一个 EVAL,避免 PHP 层读-改-写导致竞态。以下脚本接受四个参数:key(桶标识)、capacity(桶容量)、rate(每秒漏出量)、now(毫秒时间戳):
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
<p>local data = redis.call('HMGET', key, 'water', 'last_leak_ts')
local water = tonumber(data[1]) or 0
local last_leak_ts = tonumber(data[2]) or now</p><p>-- 计算应漏掉的水量(单位:滴,取整)
local passed_ms = now - last_leak_ts
local leak = math.floor(passed_ms * rate / 1000)
if leak > 0 then
water = math.max(0, water - leak)
redis.call('HSET', key, 'water', water, 'last_leak_ts', now)
end</p><p>-- 尝试加一滴水
if water + 1 </p>
在 ThinkPHP 中调用方式:
$redis = Cache::store('redis')->handler();$result = $redis->eval($luaScript, [$key], [$capacity, $rate, $nowMs]);
-
$key建议包含用户 ID 或接口路径,例如'leak:api:/v1/order:create:uid_123' -
$rate是浮点数,比如每秒 2 次就传2.0;注意 Lua 的math.floor对负数截断,所以先确保leak >= 0 - 脚本返回
1表示通过,0表示被限流,别直接当成布尔用,要显式判断
ThinkPHP 中间件里怎么安全集成
不要在每次请求都 new 一个 Redis 连接,复用 Cache::store('redis') 的连接池实例。关键点是异常兜底和降级:
- 捕获
RedisException,记录告警但不阻断请求(避免 Redis 故障导致全站 500) - 对非关键接口,可配置开关
'leak_enabled' => env('LEAK_ENABLE', true),方便紧急关闭 - 限流拒绝时,返回标准格式:
['code' => 429, 'msg' => 'Too Many Requests'],HTTP 状态码设为429 - 别把
$nowMs写成time() * 1000,要用microtime(true) * 1000保证毫秒精度,否则漏算误差大
示例中间件片段:
public function handle($request, Closure $next)
{
if (!config('rate.leak_enabled')) {
return $next($request);
}
<pre class="brush:php;toolbar:false;">$key = 'leak:api:' . $request->url() . ':uid_' . ($request->uid ?? 'guest');
$capacity = config('rate.capacity', 10);
$rate = (float) config('rate.rate_per_sec', 5.0);
$redis = Cache::store('redis')->handler();
$nowMs = (int)(microtime(true) * 1000);
try {
$result = $redis->eval($this->luaScript, [$key], [$capacity, $rate, $nowMs]);
if ($result == 0) {
return json(['code' => 429, 'msg' => 'Too Many Requests'])->code(429);
}
} catch (\RedisException $e) {
Log::warning('LeakBucket Redis error', ['key' => $key, 'err' => $e->getMessage()]);
// 降级:不校验,直接放行
}
return $next($request);}
实际压测时容易忽略的三个细节
漏桶在真实流量下表现和理论有偏差,主要卡在时间精度、网络延迟、以及桶状态持久化上:
- Redis 服务器和应用服务器时钟不同步超过 100ms,会导致
last_leak_ts计算偏移,建议用ntpdate或chrony同步,不要依赖各自系统时间 - Lua 脚本里用
now入参而非redis.call('TIME'),因为后者返回的是 Redis 服务端时间,而漏桶逻辑应以应用服务器为准(请求发起时刻) - 如果 Redis 挂了或超时,中间件里虽然降级放行,但可能造成短时放大效应——此时应配合 Sentinel 或本地内存计数(如
apcu_inc)做二级限流,而不是完全裸奔
漏桶真正的复杂点不在代码,而在如何让「漏」这个动作在分布式环境下保持单调递减且不跳跃。一旦用了 HSET 存两个字段,就必须确保所有读写都走同一段 Lua,任何绕过脚本的直连操作都会破坏状态一致性。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











