hyperf 中 lockforupdate() 必须在 db::transaction() 内使用,否则锁立即释放;where 条件需走索引,否则退化为表锁;查改必须在同事务内原子完成,不可依赖 model 实例延迟更新。

Hyperf 的 Model 直接调用 lockForUpdate() 无效,根本锁不住 —— 因为它没在事务里执行,查询一返回锁就释放了。
lockForUpdate() 必须和 DB::transaction() 配合使用
Hyperf 的 lockForUpdate() 是 Eloquent 风格的查询构造器方法,但它本身不开启事务,也不维持连接状态。单独写 Product::where('id', $id)->lockForUpdate()->first(),等同于先执行 SELECT ... FOR UPDATE,再立刻 COMMIT(或更糟:自动提交),锁在结果返回后立即失效。
- ✅ 正确姿势:
DB::transaction(function () use ($id) { return Product::where('id', $id)->lockForUpdate()->first(); }); - ❌ 错误姿势:
Product::find($id)->lockForUpdate()(find()已触发查询,lockForUpdate()完全无意义) - ⚠️ 注意:
first()、get()、value()等都会立即执行 SQL;锁必须在 query builder 阶段声明,不能“查完再加锁”
WHERE 条件不走索引 → 行锁变表锁
即使你写了 lockForUpdate() 并包在事务里,如果 WHERE 字段没建索引(比如用 status 或 name 查询但没索引),InnoDB 会退化为全表扫描 + 表级锁。这时并发请求不是排队等一行,而是互相阻塞整张表,极易引发死锁或超时。
- 验证方式:
EXPLAIN SELECT * FROM products WHERE id = 123 FOR UPDATE;,看key列是否非NULL,type是否为const或ref - 主键或唯一索引最安全;普通字段务必加索引,否则
lockForUpdate()就是假锁 - 避免隐式类型转换(如字符串 ID 用数字查询),会导致索引失效
Model 的 first() 返回后,事务还没结束?别信
很多人以为 “DB::transaction() 包着 first(),那锁就一直持有着”,其实不然:first() 执行完只是拿到了模型实例,后续对这个模型对象调用 save() 或 update() 是另一条独立 SQL,不带任何锁信息。如果中间有其他逻辑(比如 sleep、HTTP 调用、复杂计算),别的事务早就能进来读写同一行。
- 正确做法:在同一个事务块内完成「查 + 改」,例如:
DB::table('products')->where('id', $id)->lockForUpdate()->decrement('stock', $qty); - 不要依赖 Model 实例的延迟更新:
$product->stock -= $qty; $product->save();这两步之间存在竞态窗口 - 更稳的原子写法:
DB::update('UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?', [$qty, $id, $qty]);,失败直接返回 0 行影响
Hyperf 下 lockForUpdate 和 cache()->lock 混用容易漏环节
缓存锁(如 cache()->lock('stock_lock:'.$id)->block(10))解决的是进程/服务间协调问题,而 lockForUpdate() 解决的是单次数据库事务内的行一致性。两者不是替代关系,而是分层协作:
- 缓存锁 key 必须带业务标识,例如
'product_stock_lock:'.$id,不能写死成'stock_lock' - 缓存锁
ttl必须 > 事务最大耗时,否则锁提前过期,另一个请求进来又读到旧值,形成“读-改-写”覆盖 - 最简稳态组合:
cache()->lock(...)->block()入口拦截 → 进入事务 →lockForUpdate()查行 → 原子 update 或 CAS 校验 version
真正难的从来不是加锁动作本身,而是锁的边界是否清晰、生命周期是否可控、以及每一层抽象(缓存 / ORM / DB)是否清楚自己该管什么、不该管什么。











