直接用 rate_limit 类库在分布式场景下会丢精度,因其默认基于本地内存或单 redis key 计数,未解决跨节点时钟漂移与 incr+expire 非原子竞争,导致实际速率超阈值;需用 lua 封装原子操作并窗口对齐自然秒。

为什么直接用 rate_limit 类库在分布式场景下会丢精度?
因为多数 Composer 包(比如 php-rate-limiter 或 slim-rate-limit)默认基于本地内存或单 Redis key 做计数,没处理跨进程/跨节点的时钟漂移和原子性竞争。你看到的“100次/秒”在高并发下实际可能是 120+,尤其当请求分散到多个 PHP-FPM worker 或不同机器时,INCR + EXPIRE 非事务组合会被打穿。
真正可用的方案必须满足两点:一是用 Lua 脚本封装 INCR 和 EXPIRE 原子操作;二是限流窗口必须对齐自然秒(而非请求到达时刻),否则突发流量会错峰叠加、突破阈值。
- 别用
Redis::incr()后再expire()—— 中间可能失败,key 永久存在 - 避免用
microtime(true)算滑动窗口起始时间 —— 不同机器时间不同步会导致窗口错位 - 选支持
EVAL的客户端,如predis/predis或phpredis(启用scan模式时注意 pipeline 兼容性)
怎么写一个能跑在 Laravel/Swoole/Symfony 里的通用限流器?
核心是把限流逻辑抽成无框架依赖的类,只依赖 Predis\Client 或 Redis 扩展,用 key 模板 + rate + burst 三参数驱动。关键不是“实现算法”,而是让 key 能按需拼接(比如 "rate:api:v1:users:{uid}"),且支持预热(warmup)和降级(fallback)。
示例 Lua 脚本(存为 lua/rate_limit.lua):
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local burst = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
local window_start = now - (now % window)
<p>local current = redis.call('ZCOUNT', key, window_start, '+inf')
if current >= limit + burst then
return {0, current}
end</p><p>redis.call('ZADD', key, now, math.random(10000,99999))
redis.call('ZREMRANGEBYSCORE', key, 0, window_start - 1)
redis.call('EXPIRE', key, window * 2)
return {1, current + 1}</p>
PHP 调用时传入 [$key, $window, $limit, $burst, time()],返回数组第一个值为是否放行。
-
$window固定设为1(秒级)或60(分钟级),不要动态算 -
$burst是允许瞬时超出的额度,设为$limit * 2可缓冲短时毛刺,但别超过Redis内存承受上限 - 每次调用前检查
redis.ping(),失败时走 fallback(如返回503或跳过限流)
平滑突发流量的关键:漏桶 vs 令牌桶,PHP 里该选哪个?
PHP 进程模型决定了漏桶(leaky bucket)更稳——它靠定时任务“匀速漏出”,而令牌桶(token bucket)需要维护一个随时间递增的令牌数,PHP 没有常驻进程,每次请求都要重算时间差,误差大。所以实际部署中,**所有靠谱的 PHP 分布式限流器底层都是漏桶语义,只是用 Redis ZSET 模拟“水位”和“漏水口”**。
具体表现就是:你设了 100r/s,系统不会在每秒初一次性发 100 个令牌,而是把每个请求时间戳记进 ZSET,再用 ZCOUNT 统计过去 1 秒内有多少请求——本质是滑动窗口,但窗口起点强制对齐整秒,不随请求浮动。
- 别信文档里写的 “支持令牌桶” —— 查源码,如果没用
TIME或server.time做服务端时间锚点,就是假的 - 突发流量下,ZSET 的
ZREMRANGEBYSCORE性能比哈希表 + TTL 更可控,但要注意ZADD的NX参数在旧版 Redis 不可用,得靠 Lua 保证唯一性 - 如果业务要求“绝对公平”,比如支付接口,建议加一层本地缓存(APCu)做二次校验,避免 Redis 网络抖动导致误限
上线前必须验证的三个边界点
限流器上线不难,难的是它在真实链路里不出幺蛾子。这三个点不测,迟早出线上事故:
- 压测时故意 kill 掉一个 Redis 节点,看客户端是否自动 failover,且限流行为降级合理(比如切到宽松策略,而不是直接 500)
- 用
clock_gettime(CLOCK_MONOTONIC)对齐时间(PHP 8.1+ 可用hrtime(true)),对比time()在 NTP 调整时的跳变影响 —— 曾有团队因这个导致整分钟限流失效 - 检查日志里
rate_limit.exceeded是否带完整上下文(key、current、limit、burst),否则出问题时只能干瞪眼
真正的难点从来不在“怎么写”,而在“怎么让它在凌晨三点 CPU 突增、Redis cluster 网络分区、NTP 同步失败时,依然给出可预期的行为”。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











