死锁重在预防而非修复:Yii 2中需设置innodb_lock_wait_timeout(建议5~10秒)、手动实现最多3次带50ms间隔的重试、forUpdate()必须在事务内使用、统一多表加锁顺序、乐观锁需配合条件更新,且须分析InnoDB死锁日志定位根因。

死锁不是“修”,而是“防”:Yii 2里一写就崩,往往因为没设超时
MySQL 报 ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction,说明死锁已被检测并强制回滚——你看到的不是故障现场,是 MySQL 已经帮你“截胡”了。真正的问题是:为什么每次高并发下单都触发它?核心原因常是事务没设 wait_timeout 或应用层没做重试。
- Yii 2 默认不设置锁等待超时,
PDO::ATTR_TIMEOUT不影响行锁等待,得靠 MySQL 层:innodb_lock_wait_timeout(默认 50 秒),线上建议调成5~10 - PHP 层必须自己加重试逻辑,不能依赖框架自动重试——
save()、updateAll()都不会自动捕获1213并重试 - 重试次数别超过 3 次,否则可能把雪崩变持续抖动;每次重试前用
usleep(50000)(50ms)错开竞争窗口
forUpdate() 写法不对,等于没锁:Yii 2 的悲观锁必须在事务里才生效
forUpdate() 看似简单,但 Yii 2 中它只是生成 SQL 的标记,**不开启事务,它根本不会加锁**。很多开发者写了 Product::find()->where(['id'=>1])->forUpdate()->one() 就直接改库存,结果还是超卖。
- 必须包裹在
$transaction = Yii::$app->db->beginTransaction()里,且forUpdate()调用要在beginTransaction()之后、commit()之前 - 不要在循环里反复
findOne()+forUpdate(),应先查 ID 列表,再一次性forUpdate()批量锁定(如SELECT * FROM product WHERE id IN (1,2,3) FOR UPDATE) - 避免在
forUpdate()后做耗时操作(如调外部 API、发邮件),否则锁持有时间拉长,死锁概率指数上升
访问顺序不统一,是死锁的隐形推手:多表更新必须按固定顺序加锁
两个事务,一个先锁商品再锁订单,另一个先锁订单再锁商品——只要并发一上来,死锁几乎必现。这不是概率问题,是设计缺陷。
- 所有涉及多表更新的业务(如扣库存+写订单+减余额),必须约定全局加锁顺序,例如:总是
product → order → user_account,绝不颠倒 - 在代码里用注释或常量明确标出顺序:
// LOCK ORDER: Product → Order → Wallet,比文档更可靠 - 如果某处需要反向操作(比如退款要先改订单状态再加库存),就拆成两个独立事务,中间不跨锁,用最终一致性兜底
乐观锁不是万能解药:version 字段只防“写冲突”,不防“读-改-写”竞态
很多人以为加了 optimisticLock() 就安全了,结果秒杀还是超卖。因为乐观锁只在 save() 时校验版本号,而“查库存→判断→扣减”这三步本身没原子性,中间可能被其他请求插队。
- 乐观锁适合修改冲突少、读远多于写的场景(如用户资料编辑),不适合库存类强竞争资源
- 若坚持用乐观锁控库存,必须配合数据库层面的条件更新:
UPDATE product SET stock = stock - 1, version = version + 1 WHERE id = 123 AND stock >= 1 AND version = 5 - Yii 2 的
updateCounters()可用于无版本号的轻量扣减,但它不抛异常,需手动检查返回值是否为 1(表示成功更新一行)
最易被忽略的一点:死锁日志不是看一次就完事。MySQL 的 SHOW ENGINE INNODB STATUS 输出里,LATEST DETECTED DEADLOCK 区域会精确列出每个事务持有哪些锁、等待什么锁——这才是定位循环等待链的唯一真实依据,别只盯着代码猜。



















