thinkphp中不存在真正的redis悲观锁,所谓“redis悲观锁”实为用setnx+过期时间模拟的分布式锁;redis乐观锁对应watch+multi/exec,但tp未封装,需手动调用;db::name('goods')->lock(true)是mysql层select for update,与redis无关,混用会导致库存不一致;正确做法是redis预扣减+mysql条件更新闭环校验,配合安全删锁lua脚本和定时对账机制。

ThinkPHP 里没有“Redis 悲观锁”这个东西——Redis 本身不提供传统数据库意义上的排它锁(如 SELECT FOR UPDATE),所谓“Redis 悲观锁”其实是用 SETNX + 过期时间模拟的互斥访问控制,本质是分布式锁;而 Redis 的乐观锁对应的是 WATCH + MULTI/EXEC 事务机制,但 TP 并未封装该模式,需手动调用原生命令。
为什么不能直接用 Db::name('goods')->lock(true) 配合 Redis?
这是最常见的误解源头。lock(true) 是 MySQL 层的 SELECT FOR UPDATE,只对当前数据库连接生效,和 Redis 完全无关。如果你在事务里先 lock(true) 查库存,再用 Redis 做预扣减,两者之间没有原子性保障:MySQL 锁住的行,Redis 可能还没操作;Redis 扣完,MySQL 事务又回滚了——库存就对不上。
- TP 的
lock(true)必须搭配Db::startTrans()使用,否则无效 - 它只作用于当前查询语句,且要求 WHERE 条件命中唯一索引(如主键),否则可能升级为表锁
- 一旦你引入 Redis,就必须放弃“单层锁”思路,改用分层校验:Redis 做快速拦截,MySQL 做最终落库一致性保证
redis()->setnx() 模拟悲观锁的正确写法
这不是真正意义上的悲观锁,而是用 Redis 实现的「抢占式互斥」,适用于秒杀下单前的瞬时防重(比如防止用户连点提交两次订单)。
- 键名必须带业务上下文,例如
lock:order:create:uid_123:goods_456,避免全局锁导致吞吐骤降 - 一定要设置过期时间,推荐用
SET lock_key 1 NX PX 5000形式(TP8 可用redis()->set('lock_key', 1, ['nx', 'px' => 5000])) - 获取锁失败不能直接
return,要返回明确错误码(如429 Too Many Requests),前端可做友好提示 - 锁释放必须用
DEL,但注意不能简单redis()->del($key)——万一锁过期被别人抢走,你删的是别人的锁。安全做法是 Lua 脚本比对 value 再删
示例片段:
$key = 'lock:order:' . $uid . ':' . $goodsId;
$value = uniqid('', true);
if (redis()->set($key, $value, ['nx', 'px' => 3000])) {
try {
// 执行下单逻辑(含 Redis 预扣、DB 乐观更新等)
} finally {
// 安全删锁
redis()->eval("if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", 1, $key, $value);
}
} else {
throw new Exception('操作过于频繁,请稍后再试');
}
Redis + MySQL 闭环乐观锁怎么落地
这才是 TP 高并发防超卖的主力方案:Redis 原子预扣减做第一道闸门,MySQL 带条件更新做第二道校验,失败后必须回滚 Redis。
- Redis 层用
decrBy('stock:123', $num),检查返回值是否>= 0;小于 0 立即拒绝,不进 DB - MySQL 层必须用原生条件更新:
UPDATE goods SET stock = stock - ? WHERE id = ? AND stock >= ?,不能用模型的save()或setDec() - 执行后必须判断
affectedRows() === 1,为 0 表示 DB 层校验失败(库存被其他请求扣完或 version 不匹配),此时要用incrBy('stock:123', $num)补回 Redis 库存 - 整个流程建议包在 DB 事务里,但 Redis 回滚不是事务一部分,必须显式编码处理
关键点在于:Redis 和 MySQL 的两次校验不是冗余,而是分工——Redis 抗量,MySQL 保底。漏掉任一环,都会在大流量下出现超卖。
别碰 WATCH/MULTI/EXEC 事务模式
TP 没有封装 Redis 事务,手动调用 watch() + multi() 在高并发下极易失败重试,且 WATCH 监控的 key 一旦被任意客户端修改,整个事务就废掉。实际压测中,失败率远高于直接用 DECRBY 或 Lua 脚本。
-
WATCH适合低频、强一致读写场景(如用户积分变更),不适合每秒上万次的库存扣减 - 如果真要用,必须把所有读+写逻辑写进一个 Lua 脚本里执行,靠 Redis 单线程保证原子性,而不是靠 WATCH 之后再发多个命令
- TP8 的
redis()->eval()支持传参,可以封装成通用库存扣减脚本,但要注意 Lua 中不能做网络请求或耗时计算
最易被忽略的是:Redis 和 MySQL 数据不一致时,没有对账机制。上线后必须加定时任务,每天比对 Redis key 和 DB stock 字段,发现偏差立刻告警人工介入。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











