必须用毫秒级整数时间戳;因time()秒级精度导致ZSet排序错乱、ZCOUNT失真,且PHP-FPM中其取值非处理时刻而存几十毫秒误差,故需microtime(true)转毫秒整数并原子执行ZADD与ZREMRANGEBYSCORE。

滑动窗口限流必须用毫秒级时间戳,且必须是整数;用 time() 秒级精度会导致 ZSet 排序错乱、ZCOUNT 统计偏差,高并发下直接失效。
为什么不能用 time() 做滑动窗口的时间戳
秒级时间戳在 1 秒内所有请求 score 相同,ZSet 无法区分先后顺序,ZREMRANGEBYSCORE 可能误删或漏删,ZCOUNT 返回的请求数会严重失真。更关键的是:PHP-FPM 下 time() 取的是请求进入队列的时间,不是真正处理时刻,误差常达几十毫秒——对 60 秒窗口虽影响小,但对 5 秒、10 秒级精细限流就是致命偏差。
- 必须改用
microtime(true)获取浮点秒,再转为毫秒整数:(int)(microtime(true) * 1000) - score 必须是整数,否则 Redis ZSet 内部排序异常,
ZRANGEBYSCORE和ZCOUNT行为不可预测 - 不要四舍五入或取整到秒,向下取整(
floor())也不够,必须保留毫秒粒度
ZADD 和 ZREMRANGEBYSCORE 必须原子执行
PHP 客户端分两步调用(先删旧、再统计、再加新)在并发场景下会出竞态:两个请求几乎同时进来,都查到当前请求数
- Redis 服务端执行 Lua 时是单线程原子的,避免多命令间被其他客户端插入
- 脚本中
redis.call('zremrangebyscore', KEYS[1], 0, ARGV[1])必须放在zcount前,且用同一ARGV[1](即$now_ms - $window_ms) - 别依赖 PHP 层的
expire()做兜底——ZSet 过期只对 key 生效,不自动清理内部成员,仍会拖慢后续ZCOUNT
时间戳精度与业务窗口长度要匹配
窗口越短,时间戳精度要求越高。比如限流策略是「每 5 秒最多 20 次」,那毫秒级就足够;但若要做到「每 200 毫秒最多 1 次」,就得考虑 Redis 网络往返延迟是否已超过窗口本身——这时应换用 Nginx 的 limit_req 或本地内存缓存 + 时间轮,而不是硬扛 ZSet。
立即学习“PHP免费学习笔记(深入)”;
- 常见窗口长度(60s / 30s / 10s)配毫秒时间戳完全可行,也是当前事实标准
- 小于 1 秒的窗口慎用 ZSet,因
ZCOUNT在成员量大时有 O(log N + M) 复杂度,M 是范围内元素数 - 跨机房部署时,务必校准各节点与 Redis 服务器的系统时钟,用
ntpdate或chrony,不能依赖各自microtime()
最易被忽略的一点:ZSet 的 score 是 double 类型,但 PHP 传入非整数(如 microtime(true) * 1000 不强转)会让 Redis 解析出近似值,多个请求可能被映射到同一个 score,导致统计塌缩。上线前务必用 zrange key 0 -1 withscores 抽样检查 score 分布是否均匀离散。



















