死锁几乎从不因为用了SELECT FOR UPDATE,而是多个事务对同一组资源加锁顺序不一致;例如事务A先锁order再锁user,事务B反向加锁,形成环路导致MySQL回滚其一并报错Deadlock found when trying to get lock。

为什么死锁几乎从不因为用了 SELECT FOR UPDATE
死锁根本不是锁太强,而是多个事务对同一组资源加锁顺序不一致。比如事务 A 先锁 order 再锁 user,事务 B 反过来——InnoDB 在加锁过程中就形成环路,MySQL 只能选一个回滚,报错 Deadlock found when trying to get lock。
这种问题和隔离级别、索引是否建好关系不大,真正要动的是代码里 SQL 的执行顺序。
- 所有涉及多表的
SELECT FOR UPDATE或UPDATE,必须按固定顺序访问表:要么严格按表名字母序(order_item→order→user),要么按业务主次(如订单流必须是order→order_item→payment) - 禁止用
if/else动态拼接 SQL 来决定先查哪张表。例如不能写if (type == 'refund') { SELECT FROM user FOR UPDATE; SELECT FROM order FOR UPDATE; } - 用
EXPLAIN FORMAT=TRADITIONAL确认每张表的SELECT FOR UPDATE都走了主键或唯一索引;否则可能锁整段索引区间,放大冲突面
SELECT FOR UPDATE 锁不住一行?多半是没走索引
锁行为完全取决于 WHERE 条件是否命中索引。没索引,InnoDB 就全表扫描,给每一行都加 X 锁——并发直接归零。
检查方法:在语句前加 EXPLAIN,重点看三处:
-
type字段不能是ALL或index;理想是const(主键等值)或ref(普通索引等值) -
key字段必须显示具体索引名,如PRIMARY或idx_order_no -
rows应接近预期行数;若显示几百上千,说明锁范围失控
常见踩坑点:
- 对字段用函数:
WHERE DATE(created_at) = '2024-01-01'→ 索引失效 - 隐式类型转换:
WHERE user_id = '123'(user_id是INT)→ 可能放弃索引 - 联合索引顺序错配:
WHERE status = 1对索引(user_id, status)无效
RR 隔离级别下,SELECT FOR UPDATE 会锁间隙,哪怕记录不存在
在默认 REPEATABLE READ 下,WHERE uid = ? 即使命中唯一索引但记录不存在,InnoDB 也会加“间隙锁”(Gap Lock),锁定该值可能插入的位置。多个事务同时执行相同语句,都会尝试锁定同一间隙,后续 INSERT 又需要在该间隙内加“插入意向锁”——两者冲突,形成死锁。
典型错误模式:SELECT * FROM orders WHERE order_no = 'ABC' FOR UPDATE → 记录不存在 → 多个并发请求都卡在间隙锁上 → 同时 INSERT → 死锁。
更健壮的做法是把冲突控制交给唯一约束本身:
-
order_no字段有UNIQUE约束 - 应用层先尝试
INSERT INTO orders (order_no, user_id) VALUES (?, ?) - 捕获 MySQL 错误码
1062(Duplicate entry),说明已存在,按需返回或重试 - 无需显式事务包裹整个流程;若需原子性,仅对
INSERT+ 后续更新加事务即可
事务没真正开启,SELECT FOR UPDATE 就等于没锁
MySQL 默认开启自动提交(autocommit=1),此时 SELECT FOR UPDATE 执行完就立刻提交、锁随即释放——你根本没机会用它保护后续操作。
必须显式启动事务:
- 执行
START TRANSACTION(推荐,语义更明确) - 紧接着执行
SELECT * FROM t WHERE id = 123 FOR UPDATE - 再执行
UPDATE或其他依赖锁的操作 - 最后必须
COMMIT或ROLLBACK;漏掉会导致锁长期悬挂,阻塞全表更新
验证是否生效:查 SELECT @@autocommit,返回 0 才安全;若为 1,仅靠 SET autocommit = 0 不够稳定,仍建议统一用 START TRANSACTION 包裹。
最容易被忽略的一点:锁住不该锁的间隙,会让无关 INSERT 被卡死。这不是语法问题,是隔离级别和索引设计共同作用的结果——得一起调,单改某一项往往白忙活。


















