直接用 incr+expire 会出问题,因为非原子操作导致高并发下计数错乱和过期时间被覆盖;必须用 Lua 脚本保证 incr 和 expire 的原子性,且仅在首次 incr 时设置过期时间。

为什么直接用 incr + expire 会出问题
因为这不是原子操作。高并发下,两个请求几乎同时执行 $redis->incr($key),都返回 1,接着都执行 $redis->expire($key, 60)——结果是过期时间被重复设置,但更致命的是:第一个请求 incr 后还没来得及 expire,第二个请求就已 incr 到 2,而此时 key 还没设过期,后续所有请求都会持续累加,直到 Redis 内存耗尽或手动清理。
必须用 Lua 脚本保证原子性
ThinkPHP 自带的 Redis 驱动不暴露 eval,但可通过原生连接调用。关键点不是“能不能写”,而是脚本逻辑是否防住窗口期:
-
local current = redis.call('incr', KEYS[1])—— 先自增 -
if current == 1 then redis.call('expire', KEYS[1], ARGV[1]) end—— 仅首次 incr 才设过期,避免 TTL 被反复覆盖 - 传参用
KEYS[1]和ARGV[1],不拼接字符串,兼容 Redis Cluster 分片
示例代码(在 ThinkPHP 控制器中):
$lua = "local current = redis.call('incr', KEYS[1]) if current == 1 then redis.call('expire', KEYS[1], ARGV[1]) end return current";
$count = $this->redis->eval($lua, [$key], 1, 60); // 第三个参数 1 表示 keys 数量,第四个是 ARGV[1]
计数器限流时怎么判断超限并阻塞
不能只靠 incr 返回值做判断,要结合当前时间窗口和阈值做闭环控制:
立即学习“PHP免费学习笔记(深入)”;
- 用时间戳构造 key,例如
rate:api:login:2026081713(精确到分钟),避免单 key 累积过大 - incr 后立刻检查
$count > 200,超限就拒绝,不要等下一次请求再清空 - 不要用
getset清零——它会丢失中间计数;改用定时任务按 key pattern 扫描清理过期窗口 - 若需“等待下一窗口”,客户端必须自己 sleep 或重试,Redis 层不负责调度
ThinkPHP 中 Redis 连接配置容易忽略的坑
很多线上故障不是逻辑错,而是驱动参数没调对:
-
timeout必须显式设为 3(秒),默认 0 是无限等待,一个慢命令拖垮整个连接池 -
persistent设为true,否则每请求新建 TCP 连接,QPS 上千时 handshake 成瓶颈 -
select显式指定 db,比如1,别和 session 共用db 0,防止误flushdb - 键名必须带业务前缀,如
counter:like:post_456,避免不同模块 key 冲突
真正难的不是写对一行 eval,而是确保每次 incr 的 key 在集群里落在同一 slot、过期时间不被覆盖、连接不因参数默认值而雪崩——这些细节漏掉一个,压测时就会突然崩掉。



















