多表关联本身不锁表也不死锁,真正问题在于执行路径、索引缺失和事务设计;驱动表选择错误、连接列无索引或where条件未下推会导致全表扫描与大规模加锁。

多表关联本身不锁表也不死锁,真正出问题的是关联时的执行路径、索引缺失和事务设计。只要驱动表选错、连接列没索引、或 WHERE 条件没下推,千万行扫描一开,锁就漫天飞。
UPDATE JOIN 必须让小表当驱动表
MySQL 默认用嵌套循环(NLJ),驱动表决定扫描起点和锁范围。大表当驱动表 = 全表扫 + 锁所有中间匹配行。
- 错误写法:
UPDATE orders o JOIN users u ON o.user_id = u.id SET o.status = 'done' WHERE u.reg_date > '2025-01-01'——orders是大表,先全扫它,再过滤,锁住几十万无关订单 - 正确写法:
UPDATE orders o JOIN (SELECT id FROM users WHERE reg_date > '2025-01-01') u ON o.user_id = u.id SET o.status = 'done'—— 子查询先走索引收敛到几百个id,JOIN 只触达目标订单 - 验证方式:对子查询单独跑
EXPLAIN,确认type是range或ref,rows在千级以内
JOIN 条件字段必须有匹配索引,且类型严格一致
隐式类型转换或函数包裹会让索引完全失效,触发全表扫描,锁范围爆炸式扩大。
- 危险操作:
ON o.user_id = u.id但u.id是BIGINT,而o.user_id是INT;或写成WHERE u.name = 'alice'但name是varchar(255)且没索引 - 复合索引要覆盖驱动表的过滤+连接字段,例如驱动表是
users,条件是WHERE reg_date > ? AND status = ?,JOIN 用id,那就建INDEX idx_reg_status_id (reg_date, status, id) - 避免在 JOIN 字段上做运算:
ON DATE(o.created_at) = DATE(u.signup_at)→ 改成ON o.created_at >= u.signup_at AND o.created_at
高并发下 UPDATE JOIN 必须分批 + 限流
单条语句更新 50 万行,事务日志暴涨、锁持有时间超 30 秒,其他业务直接卡死。
- 每次只更新 5000 行,用主键或时间字段做游标:
UPDATE orders o JOIN (...) u ON o.user_id = u.id SET o.status = 'done' WHERE o.id > 1000000 LIMIT 5000 - 应用层加指数退避重试:捕获
Deadlock found when trying to get lock后sleep(100ms × 2^retry_count),最多重试 3 次 - 禁止在事务里调用 HTTP、发邮件、生成文件——这些操作会让锁持有时间从毫秒级拉长到秒级,死锁概率直线上升
READ COMMITTED 隔离级别能减死锁,但不是万能解药
它能去掉间隙锁(Gap Lock),降低因插入冲突导致的死锁,但两个事务同时 UPDATE 同一行,依然会锁等待甚至超时。
- 启用方式:
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;ORM 如 Spring 要显式写@Transactional(isolation = Isolation.READ_COMMITTED) - 前提是你能接受「不可重复读」:同一事务中两次
SELECT可能返回不同结果(比如订单状态被别人改了) - 仍需配合索引和短事务——RC 下没 Gap Lock,但行锁冲突照旧,锁顺序混乱照样死锁
最容易被忽略的是:死锁日志里反复出现的表,往往不是业务主表,而是日志、统计或中间状态表。它们常被遗忘建索引,又高频被 JOIN,成了隐形锁热点。










