MySQL InnoDB SELECT FOR UPDATE 死锁原理分析与排查
在高并发业务中,多台服务器同时竞争 MySQL 数据资源时,常采用 SELECT ... FOR UPDATE
并发获取数据的风险与背景
当面临多台机器需要从同一张表中分批次提取数据进行处理的场景时,核心需求是保证数据的唯一分配,防止不同节点拿到重复数据。在 MySQL 的 Innodb 存储引擎支持行锁的前提下,直接采用 SELECT ... FOR UPDATE
该机制通过给查询结果集加上 X 锁(排他锁),确保其他事务无法修改这些行。但在实际应用中,简单的 SELECT ... FOR UPDATE LIMIT N
InnoDB 行锁与索引关联机制
理解死锁的关键在于明白 InnoDB 行锁的物理本质:It locks records in the index. 即锁的是索引项,而非单纯的数据页。
- 锁的类型取决于查询条件:若 WHERE 条件仅包含主键,则仅锁定主键索引;若包含普通索引(如状态字段),则可能锁定对应普通索引以及回表的主键索引(Next-Key Lock)。
- 锁的顺序:如果一个 SQL 语句涉及多个索引的访问,InnoDB 会按照内部发现的顺序进行加锁。
当两个事务分别以不同的条件扫描表,且这两个条件交集所在的行被对方持有锁时,就会形成经典的“等待环”,导致死锁。
死锁案例分析:条件判断导致的隐式阻塞
以下场景展示了为何“看似不冲突”的操作会导致死锁或长时间阻塞。假设有一张记录任务状态的表,包含 id (主键), getTime, IsSuccess (普通索引)。
场景一:Update 与 Select 交叉引发的互斥
前提:某行 ID=400000 的记录,原状态 IsSuccess=0。
事务 A:
SET autocommit=0;
BEGIN;
-- 选取未被处理的数据
SELECT * FROM table1
WHERE getTime < 1 AND IsSuccess=0
ORDER BY id ASC LIMIT 0,30 FOR UPDATE;
COMMIT;
事务 B:尝试更新这行原本未被选中的旧数据(此时尚未 Commit,该行仍可能被其他锁持有或处于中间状态)。
-- 将该行标记为已处理或清空状态
UPDATE table1 SET IsSuccess=0 WHERE id = 400000;
分析:
如果事务 A 先执行,它会对符合 IsSuccess=0 的索引区间加锁。若此时 Id=400000 的数据恰好位于待选区间边缘或在范围内,事务 A 锁住了这部分记录。事务 B 想要更新 Id=400000,但该行的主键索引和二级索引都可能已被事务 A 锁定(或者需要等待锁升级/验证),从而产生阻塞。
场景二:更新后未提交导致的 Select 阻塞(核心死锁点)
这是最隐蔽的死锁触发点。修改上述数据环境:先将 Id=400000 的 GetTime 改为满足条件的值,但先保留 IsSuccess=0。
步骤 1:事务 C(未提交)
SET autocommit=0;
BEGIN;
-- 将原本符合条件的数据标记为已完成
UPDATE ctripticketchangeresultdata SET issuccess=1 WHERE id = 400000;
-- 注意:此处故意不 COMMIT
步骤 2:事务 D(未提交)
SET autocommit=0;
BEGIN;
-- 尝试获取下一批数据
SELECT * FROM table1
WHERE getTime < 1 AND IsSuccess=0
ORDER BY id ASC LIMIT 0,30 FOR UPDATE;
死锁成因解析:
虽然事务 D 的查询条件要求 IsSuccess=0,而事务 C 已经将 Id=400000 改为了 1(不再符合条件)。按理说事务 D 应该跳过这行。
**但是**,InnoDB 在处理 FOR UPDATE 时,为了保证可重复读(RR)隔离级别下的可见性,必须确认该行的当前真实状态。
1. 事务 C 已经持有了 Id=400000 的主键锁以及对应的二级索引锁(用于查找该主键)。
2. 事务 D 在遍历时扫到了 Id=400000,发现其主键在 C 的事务中,因此事务 D **阻塞**,等待事务 C 提交或回滚,以确定这行到底能不能看。
3. 与此同时,如果还有另一个事务 E 尝试更新 Id=400000 以外的、但在事务 D 索引扫描范围内的其他行,可能会反过来持有事务 D 需要的锁,从而形成环路。
这种因为“范围查询中包含了一个被其他事务修改但未提交的数据”,导致后续查询不得不挂起等待确定性的行为,常被误认为是锁了整个段,实则是 Next-Key Lock 或 Gap Lock 配合MVCC一致性视图校验导致的阻塞。
通用结论与避坑指南
总结 InnoDB 在这种场景下的行为逻辑:
- 索引值的确定性优先:查询时只要触发了对某个索引区的扫描,如果遇到由其他事务持有的锁,无论该行是否最终匹配 WHERE 条件(因为可能在当前 Snapshot 下暂不可见,或需要 Rollback 后才能确定其值是否符合),都会发生阻塞。
- Order by 影响范围:排序意味着必须从头到尾扫描(除非走覆盖索引且完全命中),这会拉长持有锁的时间并增加捕获范围。
- Limit 偏移量的陷阱:
LIMIT offset, size中的offset代表扫描行数。前几行如果有行锁等待,整个语句就会被卡住。
优化建议:
在设计高并发抢单或分片任务时,尽量避免让 SELECT FOR UPDATE 涉及到复杂的状态列组合查询。可以引入专门的“队列表”,使用单条主键自增 ID 进行原子性的 UPDATE ... SET status='processing' WHERE status='free' LIMIT 1 方案来规避复杂的联合索引死锁。
死锁排查与缓解工具
当系统陷入僵死状态,查询大量超时或报错 Deadlock found when reading table lock 时,可以通过 Kill 掉特定的线程来恢复。但这只是治标,关键在于找到根因。
查看当前线程状态
登录 MySQL 控制台,运行以下命令列出所有连接:
SHOW PROCESSLIST;
重点关注 State 列为 updating, waiting for lock 或耗时 (Time) 极长的条目。例如:
+------+-------+---------------------+----------+---------+------+-----------+------------------+
| Id | User | Host | db | Command | Time | State | Info |
+------+-------+---------------------+----------+---------+------+-----------+------------------+
| 8352 | root | 192.168.x.x | mydb | Sleep | 1955 | | NULL |
| 8358 | root | 192.168.x.x | mydb | Query | 4156 | updating | UPDATE t_shop... |
+------+-------+---------------------+----------+---------+------+-----------+------------------+
在此例中,Id: 8358 的执行时间长达 4156 秒,显然是导致阻塞的源头。
终止线程
根据上述查到的 Id,使用 KILL 命令终止:
KILL 8358;
如果需要终止当前正在执行的 SQL 但不断开客户端连接,可以使用 KILL QUERY 8358;。通常管理员拥有 PROCESS 权限后可查看所有线程,只有 SUPER 或拥有相关权限的用户才能 kill 他人的线程。也可以通过命令行工具 mysqladmin -u root -p processlist 来辅助检查。
以上就是 MySQL SELECT FOR UPDATE 死锁过程分析的详细内容,更多关于 MySQL 数据库行锁调优的资料请关注本站其它相关文章!
免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。









