TP8.0中Redis防超卖应优先用DECRBY原子预扣减+数据库乐观锁兜底;若用分布式锁,必须用SET命令原子设锁(带client_id和EX)、Lua脚本安全解锁、自动续期,禁用SETNX+EX等非原子操作。

直接说结论:TP8.0 里用 Redis 实现防超卖,别手写锁逻辑,优先走 DECRBY 原子预扣减 + 数据库乐观锁兜底;真要上分布式锁,必须带 client_id 校验和自动续期,否则锁可能被误删或提前过期。
为什么不能只用 SETNX + EX 模拟锁
很多人照着老教程写 SETNX 加锁、再 EXPIRE 设过期时间,这在 TP8.0 高并发下会出问题:两步操作非原子,中间若进程崩溃,锁就永远卡住;或者锁过期了但业务还没跑完,别的请求进来又拿到锁,导致库存被重复扣减。
正确做法是用 SET 命令一次性完成:key、value(唯一 client_id)、['nx' => true, 'ex' => 30]。TP8.0 的 Redis::set() 支持这个参数组合,它等价于 Redis 原生命令 SET key value NX EX 30,天然规避“设锁成功但设不过期”的竞态。
- client_id 必须全局唯一(比如用
uniqid('', true)或 UUID),不能写死字符串 - 过期时间别设太短(低于业务执行时间),也别太长(影响故障恢复速度),30 秒是常见起点
- 如果 config/cache.php 里 Redis 连接没配对,
Redis::set()会静默失败,日志里都看不到报错
解锁必须用 Lua 脚本校验 value
解锁不是简单 DEL lock:key 就完事。A 请求的锁过期后被 B 拿走,A 还在执行 DEL,就把 B 的锁删了——这就是典型的“误删”。TP8.0 中必须用 Lua 脚本保证“判断 + 删除”原子性:
立即学习“PHP免费学习笔记(深入)”;
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
调用时传入两个参数:['lock:goods:123'] 和当初 set 时用的 client_id 字符串。TP8.0 的 Redis::eval() 可以直接执行这段脚本。
- 别用
GET + DEL两步判断,网络往返期间就可能被抢占 - 脚本里不能用
redis.call('exists', ...)替代get判断,因为锁可能被其他客户端更新过 value - 如果
eval()返回 0,说明不是自己加的锁,不该删——这时候该抛异常还是重试,得看业务容忍度
比锁更稳的方案:DECRBY 直接原子预扣减
在秒杀类场景中,95% 的超卖其实发生在“查库存 → 扣库存”之间。与其费劲加锁,不如让 Redis 直接干掉这个窗口:redis()->decrBy('stock:1001', $buyCount)。
这个命令返回扣减后的值,只要检查它是否
- 必须前置使用:先
DECRBY,再查 DB,顺序反了就没意义 - 如果 DB 更新失败(比如 version 不匹配),一定要在
catch或finally里调用redis()->incrBy('stock:1001', $buyCount)补回,否则库存永久丢失 - 不要依赖
DECRBY返回值做业务状态判断(比如“扣减成功”),它只代表 Redis 层动作,DB 层仍可能失败
自动续期不是可选项,是必选项
锁持有时间超过业务实际耗时,就会提前释放。比如你设了 30 秒过期,但支付回调处理花了 45 秒,中间 15 秒锁就空了。TP8.0 没内置看门狗,得自己实现一个后台任务定期 EXPIRE 延长锁 TTL,或者用 think-redis-lock 这类扩展。
手动续期要注意:只给“自己加的锁”续期,得用 client_id 查一遍当前 value 是否匹配,否则又会误操作别人锁。
- 续期频率建议设为锁过期时间的 1/3(比如 30 秒锁,每 10 秒 check 一次)
- 别在锁即将过期前 1 秒才续,网络抖动可能导致续期请求迟到
- 如果业务流程里有 IO 等待(如调第三方接口),锁生命周期很可能覆盖不到全程,这时更适合拆成多段锁或换用消息队列削峰


















