【MySQL】第6节|深入理解Mysql事务隔离级别与锁机制
事务及其ACID属性
ACID
- 原子性(Atomicity) :对数据的修改,要么全都执行,要么全都不执行。
- 一致性(Consistent) :其他3个特性为实现一致性服务,数据完整可靠,逻辑不会对不上。
- 隔离性(Isolation) :事务独立执行空间,相互不影响。
- 持久性(Durable) :事务如果成功提交,必须确保能持久化。
并发事务的问题
更新丢失或脏写:最后的更新覆盖了由其他事务所做的更新。
脏读:事务A读取到了事务B已经修改但尚未提交的数据。
不可重复读:一个事务内相同语句执行多次结果不一致。
幻读:事务A读取到了事务B提交的新增数据。
事务隔离级别
隔离级别 | 脏读 | 不可重复读 | 幻读 |
读未提交 | 可能 | 可能 | 可能 |
读已提交 | 不可能 | 可能 | 可能 |
可重复读 | 不可能 | 不可能 | 可能 |
可串行化 | 不可能 | 不可能 | 不可能 |
锁机制
锁的分类
从性能分:乐观锁,悲观锁
操作粒度:表锁,行锁
操作类型:
读锁(共享锁,S锁),select * from T where id=1
lock in share mode
写锁(排它锁,X锁),select * from T where id=1
for update
意向锁:mysql内部用,用于标记表没有没行锁,从而判断能不能加表锁,提高加锁效率,因为不用每次逐行判断有没有行锁
表锁
每次操作锁住整张表。开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低;一般用在整表数据迁移的场景。
行锁
每次操作锁住一行数据。开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度最高。需要InnoDB支持行锁和事务。
间隙锁(Gap Lock)
在 MySQL 中,间隙锁(Gap Lock)是 InnoDB 存储引擎在 可重复读(REPEATABLE READ) 隔离级别下为解决 幻读问题 而引入的一种锁机制。当满足以下条件时,间隙锁会被触发:
一、触发间隙锁的必要条件
- 隔离级别为 REPEATABLE READ(默认)
间隙锁仅在该隔离级别下生效。若隔离级别为 读已提交(READ COMMITTED),间隙锁会被禁用,转而使用 记录锁(Record Lock)。
- 使用索引进行查询
间隙锁必须通过索引条件触发。若查询未使用索引(如全表扫描),InnoDB 会锁定整个表,而非仅间隙。
- 当前读(显式锁或写操作)
间隙锁仅在 当前读(如 SELECT ... FOR UPDATE
、UPDATE
、DELETE
)时触发。普通的 SELECT
(快照读)不会触发间隙锁。
二、常见触发场景
1. 范围查询(Range Query)
-- 假设索引为 idx_age (age),且存在记录 age=20, 25, 30
SELECT * FROM users WHERE age BETWEEN 20 AND 30 FOR UPDATE;-- 触发间隙锁的范围:
-- (负无穷, 20], (20, 25], (25, 30], (30, 正无穷)
- 作用:阻止其他事务在该范围内插入新记录(如
age=23
),避免幻读。
2. 唯一索引的等值查询(不存在的记录)
-- 假设唯一索引为 idx_id (id),且不存在 id=100 的记录
SELECT * FROM users WHERE id = 100 FOR UPDATE;-- 触发间隙锁:
-- 若表中存在 id=99 和 id=101,则锁定 (99, 101)
- 作用:防止其他事务插入
id=100
的记录,保证唯一性。
3. 非唯一索引的等值查询
-- 假设非唯一索引为 idx_name (name),存在多条 name='Alice' 的记录
SELECT * FROM users WHERE name = 'Alice' FOR UPDATE;-- 触发间隙锁:
-- 锁定所有 name='Alice' 记录的前后间隙
- 作用:阻止插入
name='Alice'
的新记录,避免幻读。
4. 插入意向锁(Insert Intention Lock)冲突
当多个事务尝试在同一间隙插入不同值时,会触发插入意向锁,本质是间隙锁的一种:
-- 事务 T1
INSERT INTO users (age) VALUES (23); -- 尝试插入间隙 (20, 25)-- 事务 T2
INSERT INTO users (age) VALUES (24); -- 尝试插入同一间隙
- 结果:T2 需等待 T1 提交或回滚,否则会被阻塞。
三、间隙锁的危害与优化
1. 可能导致的问题
- 死锁:多个事务在同一间隙交叉加锁时可能导致死锁。
- 性能下降:锁定范围过大,影响并发插入性能。
2. 优化建议
- 降低隔离级别:使用
READ COMMITTED
隔离级别,禁用间隙锁。 - 优化查询条件:避免范围查询,精确匹配记录。
- 添加索引:确保查询条件使用索引,减少锁的范围。
四、验证间隙锁的存在
通过 SHOW ENGINE INNODB STATUS
查看当前锁信息:
SHOW ENGINE INNODB STATUS\G;-- 输出中可能看到类似信息:
RECORD LOCKS space id 10 page no 5 n bits 80 index PRIMARY of table `test`.`users`
trx id 12345 lock_mode X locks gap before rec
总结
间隙锁主要在 可重复读隔离级别下的当前读操作中 触发,目的是防止幻读。常见于 范围查询、唯一索引的等值查询(查不存在的记录)、非唯一索引的等值查询 等场景。合理控制隔离级别和查询条件,可减少间隙锁带来的负面影响。
临键锁(Next-key Locks)
在 MySQL 的 InnoDB 存储引擎中,临键锁(Next-Key Locks) 是一种组合锁,它结合了 记录锁(Record Lock) 和 间隙锁(Gap Lock) 的功能,用于在 可重复读(REPEATABLE READ)隔离级别 下解决幻读问题。临键锁的核心作用是锁定一个 左闭右开区间(即前一个记录的间隙到当前记录之间的范围),确保在该范围内的数据不会被其他事务插入、修改或删除,从而避免幻读。
一、临键锁的本质:记录锁 + 间隙锁
- 记录锁(Record Lock):锁定索引中的单个记录(如
id=10
)。 - 间隙锁(Gap Lock):锁定索引记录之间的间隙(如
(10, 20)
)。 - 临键锁(Next-Key Lock):两者的组合,锁定 前一个间隙到当前记录的左闭右开区间(如
(5, 10]
,假设前一个记录是id=5
)。
二、临键锁的触发条件
- 隔离级别为可重复读(REPEATABLE READ,默认)
临键锁仅在该隔离级别下生效,其他隔离级别(如读已提交)会禁用间隙锁,仅使用记录锁。
- 通过索引访问数据
必须通过索引条件查询(如 WHERE id=10
或 WHERE age>20
),若未使用索引(全表扫描),会退化为表锁。
- 当前读操作(加锁操作)
如 SELECT ... FOR UPDATE
、UPDATE
、DELETE
等会触发当前读,普通 SELECT
(快照读)不会触发临键锁。
三、临键锁的锁定范围(示例)
假设表中有索引字段 id
,值为 10、15、20、25
,则索引间隙为:
(-∞, 10)、(10, 15)、(15, 20)、(20, 25)、(25, +∞)
。
场景 1:等值查询(存在记录)
SELECT * FROM table WHERE id = 15 FOR UPDATE;
- 锁定范围:临键锁会锁定
(10, 15]
区间(前一个间隙(10,15)
+ 记录id=15
)。- 阻止其他事务插入
(10,15)
间隙的数据(如12、13
)。 - 阻止修改或删除
id=15
的记录。
- 阻止其他事务插入
场景 2:等值查询(不存在记录)
SELECT * FROM table WHERE id = 12 FOR UPDATE;
- 锁定范围:若
id=12
不存在,但相邻记录为id=10
和id=15
,则锁定(10, 15)
间隙(纯间隙锁)。- 阻止其他事务插入
(10,15)
区间的数据(如12、13
)。
- 阻止其他事务插入
场景 3:范围查询
SELECT * FROM table WHERE id BETWEEN 10 AND 20 FOR UPDATE;
- 锁定范围:
- 记录锁:
id=10、15、20
。 - 间隙锁:
(-∞,10)
、(10,15)
、(15,20)
、(20,25)
。 - 组合为临键锁:
(-∞,10]
、(10,15]
、(15,20]
、(20,25)
(注意最后一个间隙为纯间隙锁,因为25
不在范围内)。 - 阻止插入
(-∞,10)
、(10,15)
、(15,20)
区间的数据,以及修改id=10、15、20
的记录。
- 记录锁:
四、临键锁与幻读的关系
- 幻读的定义:事务 T1 读取某个范围的数据后,事务 T2 在该范围内插入新数据,T1 再次读取时发现新增数据,如同“幻觉”。
- 临键锁的作用:通过锁定记录及其间隙,阻止其他事务在范围内插入数据,从而避免幻读。
五、临键锁的优化与注意事项
1. 可能的问题
- 锁范围过大:大范围查询可能锁定大量间隙,导致并发性能下降。
- 死锁风险:多个事务交叉锁定相邻间隙时可能引发死锁。
2. 优化方法
- 降低隔离级别:改用
READ COMMITTED
,此时 InnoDB 会禁用间隙锁,仅使用记录锁(需配合innodb_locks_unsafe_for_binlog=1
参数)。 - 缩小查询范围:避免不必要的范围查询,尽量使用精确条件(如
id=10
而非id>5
)。 - 合理设计索引:确保查询条件使用索引,避免全表扫描导致表锁。
六、如何验证临键锁的存在?
通过 SHOW ENGINE INNODB STATUS
命令查看锁信息,临键锁会显示为 lock_mode X next-key
:
SHOW ENGINE INNODB STATUS\G;-- 输出示例:
RECORD LOCKS space id 123 page no 4 n bits 72 index idx_id of table `test`.`t`
trx id 10001 lock_mode X next-key lock on id in (10,15]
总结
- 临键锁 = 记录锁 + 间隙锁,锁定左闭右开区间,防止幻读。
- 仅在 可重复读隔离级别 和 当前读操作 中生效,依赖索引触发。
- 优化时需平衡隔离级别、查询条件和索引设计,避免锁范围过大影响性能。
死锁
在 MySQL 的 InnoDB 存储引擎中,死锁通常发生在多个事务互相等待对方释放锁的场景下。以下是一个典型的 基于临键锁(Next-Key Locks) 的死锁案例,涉及索引范围查询和事务交叉锁定:
场景背景
表结构:
CREATE TABLE `order` (`id` INT PRIMARY KEY AUTO_INCREMENT,`amount` DECIMAL(10,2),INDEX `idx_amount` (`amount`)
);-- 表中已有数据(按 amount 排序):
-- amount: 100, 200, 300
死锁过程演示
事务 T1(用户 A 操作)
- 步骤 1:查询并锁定
amount > 150
的记录(范围查询,触发临键锁)
BEGIN;
SELECT * FROM `order` WHERE amount > 150 FOR UPDATE;
-
- 锁定范围:
- 索引
idx_amount
中,amount=200
和300
被记录锁锁定。 - 间隙锁锁定区间:
(100, 200)
、(200, 300)
、(300, +∞)
。 - 临键锁组合为:
(100, 200]
、(200, 300]
、(300, +∞)
(左闭右开)。
- 索引
- 锁定范围:
- 步骤 2:尝试更新
amount=100
的记录(需锁定(−∞, 100]
区间)
UPDATE `order` SET amount = 101 WHERE amount = 100;
-
- 等待原因:
amount=100
对应的临键锁为(−∞, 100]
,但该区间已被事务 T2 锁定,T1 进入阻塞。
- 等待原因:
事务 T2(用户 B 操作)
- 步骤 1:查询并锁定
amount < 250
的记录(范围查询,触发临键锁)
BEGIN;
SELECT * FROM `order` WHERE amount < 250 FOR UPDATE;
-
- 锁定范围:
- 索引
idx_amount
中,amount=100
和200
被记录锁锁定。 - 间隙锁锁定区间:
(−∞, 100)
、(100, 200)
、(200, 300)
(因250
介于200
和300
之间)。 - 临键锁组合为:
(−∞, 100]
、(100, 200]
、(200, 250)
(左闭右开,最后一个间隙为纯间隙锁)。
- 索引
- 锁定范围:
- 步骤 2:尝试更新
amount=300
的记录(需锁定(250, 300]
区间)
UPDATE `order` SET amount = 301 WHERE amount = 300;
-
- 等待原因:
amount=300
对应的临键锁为(250, 300]
,但该区间已被事务 T1 锁定,T2 进入阻塞。
- 等待原因:
死锁形成
- T1 持有锁:
(100, 200]
、(200, 300]
、(300, +∞)
,等待(−∞, 100]
。 - T2 持有锁:
(−∞, 100]
、(100, 200]
,等待(250, 300]
(属于 T1 锁定的(200, 300]
区间的一部分)。 - 循环等待:T1 等待 T2 释放
(−∞, 100]
,T2 等待 T1 释放(250, 300]
,形成死锁。
死锁日志验证
通过 SHOW ENGINE INNODB STATUS
查看死锁日志,会显示类似以下内容:
------------------------
LATEST DEADLOCK DETECTION
------------------------
2025-05-23 14:30:00
*** (1) TRANSACTION:
TRANSACTION 10001, ACTIVE 60 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s), 1 next-key lock(s)
MySQL thread id 10, OS thread handle 12345, query id 1000 user@localhost updating
UPDATE `order` SET amount = 101 WHERE amount = 100
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 124 page no 3 n bits 80 index idx_amount of table `test`.`order`
lock_mode X next-key lock waiting on id in (-∞,100]
*** (2) TRANSACTION:
TRANSACTION 10002, ACTIVE 60 sec inserting
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s), 1 next-key lock(s)
MySQL thread id 11, OS thread handle 12346, query id 1001 user@localhost updating
UPDATE `order` SET amount = 301 WHERE amount = 300
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 124 page no 5 n bits 72 index idx_amount of table `test`.`order`
lock_mode X next-key lock waiting on id in (250,300]
*** WE ROLL BACK TRANSACTION (2)
- InnoDB 会自动检测死锁并回滚其中一个事务(通常是成本较低的事务)以释放锁。
死锁的常见原因与避免方法
原因总结
- 临键锁的范围锁定:范围查询导致锁定大量间隙,交叉请求时易引发循环等待。
- 事务顺序不一致:不同事务以相反顺序请求锁(如 T1 先锁 A 再锁 B,T2 先锁 B 再锁 A)。
- 索引缺失:全表扫描导致表锁,扩大锁定范围。
避免方法
- 按固定顺序访问资源:确保所有事务以相同顺序操作数据(如按主键升序锁定)。
- 缩小事务范围:减少事务持锁时间,避免在事务中执行无关操作。
- 降低隔离级别:改用
READ COMMITTED
(需配合参数禁用间隙锁)。 - 优化索引:确保查询使用索引,避免全表扫描和大范围锁。
- 设置死锁超时:通过
innodb_lock_wait_timeout
参数调整死锁检测超时时间(默认 50 秒)。
总结
上述场景中,两个事务因范围查询触发临键锁,交叉锁定对方所需的间隙和记录,导致死锁。理解临键锁的锁定范围和事务执行顺序是避免死锁的关键,合理设计索引和事务逻辑可有效降低死锁风险。
锁优化
- 尽可能让所有数据检索都通过索引来完成,避免无索引行锁升级为表锁
- 合理设计索引,尽量缩小锁的范围
- 尽可能减少检索条件范围,避免间隙锁
- 尽量控制事务大小,减少锁定资源量和时间长度,涉及事务加锁的sql尽量放在事务最后执行
- 尽可能低级别事务隔离
系统表
-- 查看事务
select * from INFORMATION_SCHEMA.INNODB_TRX;
-- 查看锁
select * from INFORMATION_SCHEMA.INNODB_LOCKS;
-- 查看锁等待
select * from INFORMATION_SCHEMA.INNODB_LOCK_WAITS;-- 释放锁,trx_mysql_thread_id可以从INNODB_TRX表里查看到
kill trx_mysql_thread_id-- 查看锁等待详细信息
show engine innodb status\G;