真正可靠的乐观锁是用whereraw将校验与更新合并为一条原子sql,配合事务中select for update及affectedrows判断;单表库存优先用stock字段自身校验,高并发下需叠加redis+lua二次拦截。

ThinkPHP6里用whereRaw写原子UPDATE才是真乐观锁
TP6的find() + save()组合不是乐观锁,是并发漏洞温床。它把“读-改-写”拆成三步,中间任何时刻都可能被其他请求覆盖。真正能落地的,只有把校验和更新压进一条SQL里,靠数据库返回affectedRows判断成败。
常见错误是写成:where('stock', '>=', $need)再update(['stock' => $newStock])——这仍是两步,WHERE条件拼的是PHP变量,不是数据库当前值。
- 必须用
Db::raw('stock - 1')这类表达式,让减法在数据库端执行 - WHERE条件要直接引用字段,比如
WHERE stock >= 1,不能传入PHP计算后的值 - 扣多件时,条件必须带数量:
WHERE id = 123 AND stock >= 5,否则只扣1件 - 执行后立刻检查
$res === 0,这是库存不足的正常业务流,不是异常
lock(true)必须套在事务里,否则等于没锁
有人以为->lock(true)加在find()后面就万事大吉,其实它依赖事务上下文。没开事务时调用,MySQL根本不会加SELECT FOR UPDATE,静默失效。
典型症状:本地测试没问题,一上生产就超卖。因为开发环境并发低,冲突少;生产环境多个请求同时查到同一行,都拿到相同库存值,然后都去update,最后一行被反复覆盖。
- 必须先
Db::startTrans(),再执行lock(true)查询 - 更新操作必须在同一个事务内完成,最后
Db::commit()释放锁 - 事务范围要窄,避免在事务里做HTTP请求、Redis操作等耗时动作
- 若用
Db::transaction()闭包写法,lock(true)要放在闭包内查询语句上
版本号字段version和库存字段stock校验,选哪个
纯库存扣减,直接比stock >= $num更轻量、少一次查询、逻辑清晰。加version适合多表联动、业务规则复杂的场景,比如扣库存+锁座位+生成订单号,需要保证整套操作不被中途覆盖。
但用version容易漏掉关键点:每次update都得同步version = version + 1,且WHERE必须带上AND version = {$oldVersion}。漏一个,乐观锁就形同虚设。
- 单表库存操作,优先用
stock字段自身做条件,如WHERE id = ? AND stock >= ? - 涉及状态流转(如订单从“待付款”变“已锁定”),再叠加
version字段双重保险 -
version字段类型必须是INT UNSIGNED,初始值为0,避免负数或溢出 - 重试逻辑要由应用层控制,TP6不提供自动重试机制
Redis+Lua做二次校验,不能只靠DB层
只靠MySQL乐观锁,在极端高并发下仍有风险:DB响应慢、网络抖动、主从延迟都可能导致校验滞后。Redis+Lua能把“读库存→判是否足够→扣减”三步压成原子操作,作为前置快速拦截。
注意:不能用GET + DECRBY分开调,两次网络往返之间就是并发窗口。必须用Lua脚本封装,例如:
if redis.call("GET", KEYS[1]) >= ARGV[1] then
return redis.call("DECRBY", KEYS[1], ARGV[1])
else
return -1
end
- Lua脚本里用
redis.call()保证原子性,不能拆成多个Redis命令 - DB和Redis库存要定期对账,防长期不一致
- 入队前必须用Redis校验,不能只在消费者端校验
- Redis key建议带业务维度,如
stock:goods_123:sku_456,避免单key热点
真正落地时,最易忽略的是affectedRows的判断逻辑——很多人只检查false,却把0当成成功,结果库存明明不够还继续走后续流程。这个分支必须显式处理,且不能抛异常,它是业务拒绝,不是系统错误。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











