thinkphp 乐观锁必须手动构造带版本校验的原子 update 语句,唯一可靠路径是显式使用 where('version', $expectedversion) 配合 update;save() 本身不校验前置状态,lock(true) 属悲观锁且与乐观锁互斥,事务不增强版本校验能力,version 字段比 updated_at 更可靠。

ThinkPHP 本身没有开箱即用的乐观锁支持,所谓“乐观锁”必须靠你手动构造带版本校验的原子 UPDATE 语句,否则就是假锁。
为什么 save() + where('version', $old) 是唯一靠谱路径
TP6 的 save() 方法本身不校验前置状态,它只是把数组拼成 SET 子句;如果你先 find() 拿到 version=5,再 save(['version' => 6, 'stock' => 99]),却没在 where 条件里带上 version = 5,那这次更新就完全绕过了乐观锁逻辑——别人可能已经把 version 改成 6 甚至 7 了。
真正起作用的是 WHERE 条件里的版本比对,不是 save 行为本身。所以必须显式写:
$res = Db::name('goods')
->where('id', 123)
->where('version', $expectedVersion)
->update([
'stock' => Db::raw('stock - 1'),
'version' => Db::raw('version + 1'),
'updated_at' => time()
]);
-
$res === 1:成功扣减,版本已升 -
$res === 0:WHERE 不匹配(别人抢先改了),你要重试或报错 -
$res === false:SQL 执行失败,比如字段不存在或权限不足
别信 lock(true) 在乐观锁场景下的作用
lock(true) 是悲观锁接口,底层生成 SELECT ... FOR UPDATE,它和乐观锁是两套互斥机制。你在事务里混用 lock(true) 和版本号 update,不仅没叠加效果,反而容易引发死锁或掩盖并发问题——比如你用 lock(true) 锁住一行,然后做非原子的 find()+save(),锁只在 select 那一刻有效,后续 update 仍可能覆盖。
更危险的是:有人误以为加了 lock(true) 就等于启用了乐观锁,结果代码里根本没写 version 校验,上线后库存超卖才发现不对劲。
事务里用乐观锁,startTrans() 只是锦上添花
乐观锁的本质是单条 UPDATE 语句的原子性,它不依赖事务也能生效。加 Db::startTrans() 的唯一合理理由,是你后续还要更新订单、日志等关联表,需要保证它们和库存扣减一起成功或一起失败。
但要注意:startTrans() 不会增强乐观锁的校验能力。如果你在事务里执行了两次乐观扣减(比如两个商品),而第二次的 where('version', $v) 用的是第一次查询的老版本号,那第二次依然会失败——事务不会帮你自动刷新版本值。
常见疏漏:
- 在一个事务中多次复用同一个
$expectedVersion变量 - 扣减前没重新查最新 version,直接拿缓存或 session 里的旧值
- 没检查
update()返回值,把$res === 0当成成功处理
版本字段选 version 还是 updated_at?
用 version 字段更可靠。因为 updated_at 在高并发下可能重复(MySQL timestamp 精度只有秒级,或 PHP time() 调用间隔太短),导致两个请求读到相同时间戳,都满足 WHERE updated_at = '2026-04-23 23:40:00',最终只有一个能成功,另一个被丢弃——这不是你想要的“冲突发现”,而是“随机丢弃”。
version 是纯整数自增,无精度问题,且语义清晰:每次修改必+1。建表时记得给它加索引,否则 WHERE version = ? 会走全表扫描。
另外,别用 find()->save() 包装版本更新逻辑,哪怕你封装成模型方法——只要内部拆成了两步 SQL,就不是乐观锁。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











