select ... for update 是否生效取决于where条件是否命中索引,否则会退化为表锁;需用explain验证、开启查询日志确认sql含for update、确保字段有b+树索引,并避免like模糊查询或函数导致索引失效。
查 select ... for update 是否真生效
很多 laravel 开发者以为加了 db::transaction() 和 ->lockforupdate() 就万事大吉,但实际没锁住表或行——尤其在非主键/无索引字段上查询时,innodb 会退化为表锁甚至忽略锁。必须确认执行计划是否命中索引。
实操建议:
- 在 MySQL 中用 EXPLAIN SELECT * FROM orders WHERE user_id = 123 FOR UPDATE 查看 key 字段是否非 NULL;
- Laravel 中开启查询日志(DB::enableQueryLog())后检查生成的 SQL 是否含 FOR UPDATE;
- 确保 WHERE 条件字段有 B+ 树索引,否则 lockForUpdate() 可能锁整张表,拖垮并发;
- 避免在锁查询中用 LIKE '%xxx'、函数包裹字段(如 WHERE DATE(created_at) = '2024-01-01'),这些都会让索引失效。
监控 innodb_row_lock_waits 和等待时长
MySQL 自带指标能直接反映锁争用强度。如果 SHOW STATUS LIKE 'innodb_row_lock_waits' 持续上涨,说明事务在排队等锁,不是代码逻辑问题就是锁粒度太粗。
实操建议:
- 定期采集 innodb_row_lock_time_avg(单位毫秒),超过 50ms 就该警惕;
- 在 Laravel 的 DB 监听器里记录事务耗时,对 >200ms 的事务 dump 出锁相关 SQL 和堆栈;
- 不要只看慢查询日志——锁等待不一定会进慢日志,它可能卡在 UPDATE 前的锁获取阶段;
- 注意 information_schema.INNODB_TRX 表里的 TRX_WAITING_LOCK_ID,可关联 INNODB_LOCK_WAITS 查谁在等谁。
Laravel 中 updateOrCreate() 和 upsert() 的锁陷阱
这两个方法看着原子,其实底层是「先查再插/更」,中间没有锁保护。高并发下极易出现双写或覆盖,导致扣款多扣、少扣、漏扣。
实操建议:
- 绝对不要用 Model::updateOrCreate(['order_no' => $no], ['status' => 'paid']) 做资金变更;
- 替代方案:用原生 SQL + DB::statement() 执行带 FOR UPDATE 的子查询更新,例如 UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?;
- Laravel 10+ 的 upsert() 默认不加锁,且冲突时用 REPLACE 或 IGNORE,无法保证余额校验逻辑;
- 如果必须用 Eloquent,至少包在事务里,并在 where() 后显式调用 lockForUpdate() 再 firstOrFail()。
测试环境复现超卖不能只靠 Artisan::call('tinker')
本地敲几条命令根本压不出问题。真实超卖发生在毫秒级时间窗口,单线程模拟毫无意义。
实操建议:
- 用 ab -n 100 -c 20 http://test.dev/api/pay 或 hey -n 100 -c 20 http://test.dev/api/pay 发起并发请求;
- 在被测接口里加日志:记录进入事务前的余额、锁查询后的余额、更新后的余额,三者必须严格递减;
- 检查数据库是否启用了 READ-COMMITTED 隔离级别(Laravel 默认是它),REPEATABLE-READ 下间隙锁可能引发意外阻塞;
- 测试时关掉 Query Log 和 Telescope,它们本身会引入毫秒级延迟,掩盖真实锁等待。
真正难处理的是复合业务锁:比如「扣库存 + 扣余额 + 生成订单」需要跨三张表加锁,顺序不对就会死锁。这时候得靠 SHOW ENGINE INNODB STATUS 抓现场,而不是靠猜。











