不能直接用rate_limit类Composer包做分布式限流,因其默认仅支持单机内存或简单Redis计数器,未解决时钟漂移、网络分区和原子性竞争问题,实测K8s多实例下漏放率超30%;真正可用方案须封装Redis::eval()执行Lua原子脚本,如滑动窗口需zremrangebyscore+zcard+zadd全在一次eval中完成。

为什么不能直接用 rate_limit 这类 Composer 包做分布式限流
大多数标榜“分布式限流”的 Composer 库(比如 php-rate-limiter、limitter)默认只支持单机内存或 Redis 的简单计数器模式,没处理好时钟漂移、网络分区、原子性竞争这三类问题。你在 Kubernetes 里扩到 5 个 PHP 实例,用它们的 tryAcquire() 方法,实测漏放率可能超 30%——不是代码写错了,是它们压根没实现 Redis + Lua 原子脚本或 Redlock 协议降级逻辑。
必须自己封装 Redis::eval() 而不是依赖 incr + expire
用两条独立 Redis 命令模拟滑动窗口,必然在并发下出错:incr 返回 100,紧接着 expire 失败,窗口就永远卡死。真正可用的方案是把整个判断逻辑写进 Lua 脚本,由 Redis 原子执行。
-
KEYS[1]固定为限流器唯一标识(如"rate:api:/order:127.0.0.1"),避免 key 冲突 -
ARGV[1]是窗口大小(秒),ARGV[2]是最大请求数,ARGV[3]是当前时间戳(PHP 传入,别用redis.time()) - 脚本里用
redis.call("zremrangebyscore", ...)清过期时间戳,再zcard判数量,最后zadd插入新时间戳 —— 全部在一次 eval 中完成
示例片段:
local key = KEYS[1]
local window = tonumber(ARGV[1])
local max = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
redis.call("zremrangebyscore", key, 0, now - window)
if redis.call("zcard", key) < max then
redis.call("zadd", key, now, now)
redis.call("expire", key, window + 1)
return 1
else
return 0
end平滑整形必须放弃令牌桶的“预分配”思路
PHP-FPM 模型下,每个请求是独立进程,没法像 Java 那样用 ScheduledExecutorService 定期向桶里加令牌。硬套令牌桶会导致大量请求在桶空时被拒绝,流量呈锯齿状。
立即学习“PHP免费学习笔记(深入)”;
- 改用“漏桶 + 动态延迟”:每次请求计算
last_leak_time和应流出量,不足则 sleep 补齐 - sleep 时间必须用
usleep()而非sleep(),否则最小粒度是 1 秒,完全失真 - 延迟值不能直接返回给客户端(违反 HTTP 协议),得在 PHP 层阻塞住,但要设
max_execution_time防卡死
关键逻辑:
$now = microtime(true);
$leaked = ($now - $lastLeakTime) * $rate; // rate 单位:req/sec
if ($leaked < 1.0) {
usleep((1.0 - $leaked) * 1_000_000);
}本地缓存和分布式协调的边界必须手动切分
别指望任何库自动帮你做“本地窗口 + 远程兜底”。PHP 进程间不共享内存,apcu_store() 只对单个 worker 有效。你得明确哪些数据允许误差(如每秒请求数统计),哪些必须强一致(如黑名单 IP)。
- 高频白名单校验走
apcu_fetch(),失效时间设 10 秒,容忍短暂不一致 - 黑名单或配额变更必须同步写 Redis,并用
PUB/SUB通知其他实例清 APCu 缓存(否则一个实例更新后,其他实例还在用旧规则) - 不要用
file_put_contents()做本地配额文件——NFS 或容器挂载下会锁冲突
真正难的不是写代码,是想清楚哪条路径能接受延迟、哪条必须零误差。比如用户余额扣减前的限流,必须走 Redis 原子脚本;而后台任务队列的速率控制,用 APCu + 定期刷新就够了。



















