MySQL锁介绍
根据加锁的范围,MySQL 里面的锁大致可以分成全局锁、表级锁、行锁三类。
图片
全局锁
顾名思义,全局锁就是对整个数据库实例加锁。MySQL 提供了一个加全局读锁的方法,命令是 Flush tables with read lock (FTWRL)。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。全局锁的典型使用场景是,做全库逻辑备份。也就是把整库每个表都 select 出来存成文本。
官方自带的逻辑备份工具是 mysqldump。当 mysqldump 使用参数–single-transaction 的时候,导数据之前就会启动一个事务,来确保拿到一致性视图。而由于 MVCC 的支持,这个过程中数据是可以正常更新的。
一致性读是好,但前提是引擎要支持这个隔离级别。比如,对于 MyISAM 这种不支持事务的引擎,如果备份过程中有更新,总是只能取到最新的数据,那么就破坏了备份的一致性。这时,我们就需要使用 FTWRL 命令了。
表锁
元数据锁
元数据锁,即MDL全称为mysql metadata lock,当表有活动事务的时候,不可以对元数据进行写入操作。所以说MDL作用是维护表元数据的数据一致性
MDL的作用是确保并发事务之间对数据库对象的操作不会互相冲突或产生不一致的结果。当一个事务对某个数据库对象执行了锁定操作时,其他事务对同一对象的锁请求会被阻塞,直到持有锁的事务释放锁。
以下是MDL的一些特点和使用场景:
- 1. 读-写冲突:MDL具有读-写冲突,即一个事务持有写锁时会阻塞其他事务的读和写操作。这确保了在写操作进行期间,其他事务无法读取或修改受锁保护的对象。
- 2. 写-写冲突:MDL还存在写-写冲突,即一个事务持有写锁时会阻塞其他事务的写操作。这保证了同一时间只能有一个事务对一个对象进行写操作,避免了并发写操作引起的数据不一致问题。
- 3. 共享读锁:MDL允许多个事务同时获取读锁,因为读操作之间不会相互冲突。
- 4. 锁的级别:MDL的锁级别是语句级别的,而不是表级别或行级别的。这意味着对于同一表的不同语句,可以同时持有读锁和写锁,因为它们不会互相冲突。
自增锁Auto-inc Locks
是特殊的表级别锁,专门针对事务插入AUTO_INCREMENT类型的列。
原理:每张表自增长值并不保存在磁盘上进行持久化,而是每次InnoDB存储引擎启动时,执行以下操作:
SELECT MAX(auto_inc_col) FROM T FOR UPDATE;
之后得到的值会用变量auto_inc_lock作缓存,插入操作会根据这个自增长值加1赋予自增长列。因为每张表只有同一时刻只能有一个自增锁,可以避免同一表锁对象在各个事务中不断地被申请。
为了提高插入的性能,在MySQL5.1版本之后,对于普通的insert语句,自增锁每次申请完马上释放,不是在一个事务完成后才释放,以便允许别的事务再次申请。
举个例子:假设Test表有主键id和唯一索引列a,有两个并行事务A和B,为了避免两个事务申请到相同的主键id,必须要加自增锁顺序申请
事务A | 事务B |
begin; insert into Test values(null,1); | |
begin; insert into Test values(null,2); commit; //插入的行(2,2) | |
commit; |
事务A申请到主键id=1之后释放自增锁,没有等事务A提交之后释放,所以事务B可以插入不被阻塞。
什么情况自增主键不是连续的呢?
- • 事务回滚,如果在事务中插入了带有自增主键的记录,但该事务最终被回滚(rollback),则该自增值将被释放,不会被后续事务使用。这可能导致自增主键出现间隔或不连续的情况。
- • 手动插入了自增主键的值,而不是使用系统自动生成的自增值,可能会导致自增主键的连续性中断。例如,使用INSERT语句指定了特定的自增主键值。
- • 特殊的批量插入语句insert...select。
表级共享与排他锁
- • 表级共享锁,又称为表共享读锁,既在表的层级上对数据加以共享锁,实现读读共享
- • 表级排他锁,又称为表独占写锁,既在表的层级上对数据加以排他锁,实现读写互斥,写写互斥
表级意向锁
表级意向锁(Table-level Intention Lock)是MySQL中一种用于管理表级锁的机制。它是一种轻量级的锁,用于指示事务对表的意向操作,即事务打算在表级别上执行读操作或写操作。
表级意向锁分为两种类型:
- 1. 意向共享锁(Intention Shared Lock,IS):事务打算在表级别上执行读操作时,会申请意向共享锁。意向共享锁不会阻止其他事务获取表级共享锁或意向共享锁,但会阻止事务获取表级排他锁。
- 2. 意向排他锁(Intention Exclusive Lock,IX):事务打算在表级别上执行写操作时,会申请意向排他锁。意向排他锁会阻止其他事务获取表级共享锁、意向共享锁或意向排他锁。
表级意向锁的作用是协调并发事务对表的锁定操作,以确保数据一致性和避免死锁。事务在对表进行锁定操作之前,首先获取意向锁,并根据需要再获取具体的行级锁。它们存在的目的是帮助其他事务确定是否可以安全地获取表级共享锁或排他锁,以避免冲突和死锁的发生。
行锁
行级共享与排他锁
因为InnoDB支持表锁和行锁。所以在数据库层次结构的表级和行级,都可以对数据进行锁定。
- • 行级共享锁,行级共享锁既在行的层级上,对数据加以共享锁,实现对该行数据的读读共享
- • 行级排他锁,行级排他锁既在行的层级上,对数据加以排他锁,实现对该行数据的读写互斥,写写互斥
显式地加共享锁或排他锁?
- • select * from table lock in share mode 为table的所有数据加上共享锁,既表级共享锁
- • select * from table for update 为table的所有数据加上排他锁,既表级排他锁
- • select * from table where id = 1 for update 为table中id为1的那行数据加上排他锁,既行级排他锁
- • select * from table where id = 1 lock in share mode为table中id为1的那行数据加上共享锁,既行级共享锁
以上加的是行锁的前提是,id为主键且在查询命中,否则行锁会升级为表锁。共享锁之间兼容,排它锁与任何锁都不兼容
自增锁、意向锁和行级锁的兼容性
自增锁、意向锁和行级锁的兼容性如下:
AI | IS | IX | S | X | |
AI | 不兼容 | 兼容 | 兼容 | 不兼容 | 不兼容 |
IS | 兼容 | 兼容 | 兼容 | 兼容 | 不兼容 |
IX | 兼容 | 兼容 | 兼容 | 不兼容 | 不兼容 |
S | 不兼容 | 兼容 | 不兼容 | 兼容 | 不兼容 |
X | 不兼容 | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
意向锁是一个比较弱的锁,所以意向锁之间互不排斥
InnoDB锁算法
记录锁Record Locks
单个行记录上的锁,用来封锁索引记录。
如:假设Test表有主键id和唯一索引列a,已经有了(1,1)这条记录,执行
select * from Test where id=1 for update;
会在id=1的索引记录上加锁,以阻止其他事物插入更新、删除id=1这一行。
间隙锁Gap Locks
间隙锁(Gap Lock),它会封锁索引记录中的“缝隙”,不让其他事务在“缝隙”中插入数据。它锁定的是一个不包含索引本身的开区间范围 (index1,index2)。间隙锁是封锁索引记录之间的间隙,或者封锁第一条索引记录之前的范围,又或者最后一条索引记录之后的范围.锁定一个范围,但不包含记录本身。
封锁索引记录中的间隙,确保索引记录的间隙不变。间隙锁是针对事务隔离级别为可重复读(RR)或以上级别而已的,如果隔离级别降级为读提交(RC),间隙锁会自动失效。
MySQL事务实现原理中我们了解到幻读是指一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没看到的行,具体例子如下:
CREATE TABLE `t1` (
`id` int(11) NOT NULL,
`a` int(11) DEFAULT NULL,
`b` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `a` (`a`)
) ENGINE=InnoDB;
insert into t values(0,0,5),(5,5,5),(10,10,5);
如果只在 id=5 这一行加锁,而其他行的不加锁的话,就会发生以下情况
事务A | 事务B | 事务C |
begin; select * from t1 where a=5 for update; /Q1/result:(5,5,5) | ||
update t1 set a=5 where id=0; | ||
select * from t1 where a=5 for update; /Q2/result:(0,5,5)(5,5,5) | ||
inset into t1 values(1,5,5); | ||
select * from t1 where a=5 for update; /Q3/result:(0,5,5)(1,5,5)(5,5,5) | ||
commit; |
Q3读到了id=1这一行,就叫“幻读”。
如何解决幻读?
比如继续使用上面例子的表,执行select * from t1 where b=5 for update时候,由于b没有索引,就会在插入3个记录锁,和4个间隙锁,这样就确保了无法再插入新的记录,以此防止幻读的发生,如下:
(-∞,0),(0,5),(5,10),(10,+∞)
间隙锁在往间隙中插入一个记录才会冲突,间隙锁之间不存在冲突关系。
临键锁Next-key Locks
临键锁是记录锁与间隙锁的组合。
为了避免幻读,当InnoDB扫描索引记录的时候,会首先对索引记录加上行锁(Record Lock),再对索引记录两边的间隙加上间隙锁(Gap Lock)
innodb只有在RR隔离级别下、并且参数innodb_locks_unsafe_for_binlog关闭下,才有通过next-key locks来避免幻读。
如果是RC隔离级别,间隙锁就会失效,只剩下行锁部分,而且对于语句执行过程也有优化,使得锁的范围也会更小,时间更短,不容易死锁。
插入意向锁Insert Intention Locks
是间隙锁的一种,专门针对insert操作。同一个索引,同一个范围区间插入记录,插入的位置不冲突,不会阻塞彼此,可以提高插入并发。
由于插入意向锁和其他的临建锁/间隙锁本身会冲突,下面的两个事务会冲突:
图片
插入意向锁经常和间隙锁引发死锁问题,死锁是指两个或者多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环的现象。
简单模拟一个死锁的场景:
事务A | 事务B |
begin; | |
update t1 set a=a+1 where id=1; | begin; |
update t1 set a=a+1 where id=2; | |
update t1 set a=a+1 where id=2; | |
update t1 set a=a+1 where id=1; |
事务A在等待事务B释放id=2的行锁,事务B在等待A释放id=1的行锁,事务A和事务B互相等待对方释放资源,于是进入了死锁状态。
插入不会主动加显示的X Record锁,只有检测到Key冲突的时候才会把隐式锁转为显式锁。
隐式锁你可以理解为乐观锁,也就是正常来说不加锁或共享锁,但是遇到冲突则加锁或升级为排它锁。显式锁,那就是真的锁上了。
MySQL加锁规则
林晓斌总结MySQL加锁规则:包含了两个“原则”、两个“优化”和一个“bug”。
• 原则 1:加锁的基本单位是 next-key lock。next-key lock 是前开后闭区间。
• 原则 2:查找过程中访问到的对象才会加锁。
• 优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。
• 优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。
• 一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。
下面我们通过例子看一下这些规则:我们建个表,插入一些初始化数据:
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(0,0,0),(5,5,5),(10,10,10),(15,15,15),(20,20,20),(25,25,25);
等值查询间隙锁
由于表 t 中没有 id=7 的记录,所以用我们上面提到的加锁规则判断一下的话:
1. 根据原则 1,加锁单位是 next-key lock,session A 加锁范围就是 (5,10];
2. 同时根据优化 2,这是一个等值查询 (id=7),而 id=10 不满足查询条件,next-key lock 退化成间隙锁,因此最终加锁的范围是 (5,10)。
所以,session B 要往这个间隙里面插入 id=8 的记录会被锁住,但是 session C 修改 id=10 这行是可以的。
非唯一索引等值锁
image.png
这里 session A 要给索引 c 上 c=5 的这一行加上读锁。
1. 根据原则 1,加锁单位是 next-key lock,因此会给 (0,5]加上 next-key lock。要注意 c 是普通索引,因此仅访问 c=5 这一条记录是不能马上停下来的,需要向右遍历,查到 c=10 才放弃。
2. 根据原则 2,访问到的都要加锁,因此要给 (5,10]加 next-key lock。
3. 但是同时这个符合优化 2:等值判断,向右遍历,最后一个值不满足 c=5 这个等值条件,因此退化成间隙锁 (5,10)。
4. 根据原则 2 ,只有访问到的对象才会加锁,这个查询使用覆盖索引,并不需要访问主键索引,所以主键索引上没有加任何锁,这就是为什么 session B 的 update 语句可以执行完成。
但 session C 要插入一个 (7,7,7) 的记录,就会被 session A 的间隙锁 (5,10) 锁住。需要注意,在这个例子中,lock in share mode 只锁覆盖索引,但是如果是 for update 就不一样了。执行 for update 时,系统会认为你接下来要更新数据,因此会顺便给主键索引上满足条件的行加上行锁。
主键索引范围锁
举例之前,我们先思考一下这个问题:对于我们这个表 t,下面这两条查询语句,加锁范围相同吗?
select * from t where id=10 for update;
select * from t where id>=10 and id<11 for update;
你可能会想,id 定义为 int 类型,这两个语句就是等价的吧?其实,它们并不完全等价。在逻辑上,这两条查语句肯定是等价的,但是它们的加锁规则不太一样。现在,我们就让 session A 执行第二个查询语句,来看看加锁效果。
图片
现在我们就用前面提到的加锁规则,来分析一下 session A 会加什么锁呢?
1. 开始执行的时候,要找到第一个 id=10 的行,因此本该是 next-key lock(5,10]。根据优化 1, 主键 id 上的等值条件,退化成行锁,只加了 id=10 这一行的行锁。
2. 范围查找就往后继续找,找到 id=15 这一行停下来,因此需要加 next-key lock(10,15]。
所以,session A 这时候锁的范围就是主键索引上,行锁 id=10 和 next-key lock(10,15]。
这里需要注意一点,首次 session A 定位查找 id=10 的行的时候,是当做等值查询来判断的,而向右扫描到 id=15 的时候,用的是范围查询判断。
非唯一索引范围锁
需要注意的是,与主键范围锁不同的是,下面查询语句的 where 部分用的是字段 c。
图片
这次 session A 用字段 c 来判断,加锁规则不同是:在第一次用 c=10 定位记录的时候,索引 c 上加了 (5,10]这个 next-key lock 后,由于索引 c 是非唯一索引,没有优化规则,也就是说不会蜕变为行锁,因此最终 sesion A 加的锁是,索引 c 上的 (5,10] 和 (10,15] 这两个 next-key lock。
所以从结果上来看,sesson B 要插入(8,8,8) 的这个 insert 语句时就被堵住了。这里需要扫描到 c=15 才停止扫描,是合理的,因为 InnoDB 要扫到 c=15,才知道不需要继续往后找了。
唯一索引范围锁 bug
接下来再看一个关于加锁规则中 bug 的案例
图片
session A 是一个范围查询,按照原则 1 的话,应该是索引 id 上只加 (10,15]这个 next-key lock,并且因为 id 是唯一键,所以循环判断到 id=15 这一行就应该停止了。但是实际上,InnoDB 会往前扫描到第一个不满足条件的行为止,也就是 id=20。而且由于这是个范围扫描,因此索引 id 上的 (15,20]这个 next-key lock 也会被锁上。所以我们看到了,session B 要更新 id=20 这一行,是会被锁住的。同样地,session C 要插入 id=16 的一行,也会被锁住。照理说,这里锁住 id=20 这一行的行为,其实是没有必要的。因为扫描到 id=15,就可以确定不用往后再找了。但实际上还是这么做了,因此认为这是个 bug。
非唯一索引上存在"等值"的例子
插入一条数据
insert into t values(30,10,30);
新插入的这一行 c=10,也就是说现在表里有两个 c=10 的行。那么,这时候索引 c 上的间隙是什么状态了呢?由于非唯一索引上包含主键的值,所以是不可能存在“相同”的两行的。
图片
可以看到,虽然有两个 c=10,但是它们的主键值 id 是不同的(分别是 10 和 30),因此这两个 c=10 的记录之间,也是有间隙的。
图中索引 c 上的主键 id。为了跟间隙锁的开区间形式进行区别,用 (c=10,id=30) 这样的形式,来表示索引上的一行。
图片
这时,session A 在遍历的时候,先访问第一个 c=10 的记录。
- 1. 同样地,根据原则 1,这里加的是 (c=5,id=5) 到 (c=10,id=10) 这个 next-key lock。然后,session A 向右查找,直到碰到 (c=15,id=15) 这一行,循环才结束。
- 2. 根据优化 2,这是一个等值查询,向右查找到了不满足条件的行,所以会退化成 (c=10,id=10) 到 (c=15,id=15) 的间隙锁。
也就是说,这个 delete 语句在索引 c 上的加锁范围,就是下图中蓝色区域覆盖的部分。
图片
limit 语句加锁
这个例子里,session A 的 delete 语句加了 limit 2。你知道表 t 里 c=10 的记录其实只有两条,因此加不加 limit 2,删除的效果都是一样的,但是加锁的效果却不同。可以看到,session B 的 insert 语句执行通过了,跟案例六的结果不同。这是因为,案例七里的 delete 语句明确加了 limit 2 的限制,因此在遍历到 (c=10, id=30) 这一行之后,满足条件的语句已经有两条,循环就结束了。因此,索引 c 上的加锁范围就变成了从(c=5,id=5) 到(c=10,id=30) 这个前开后闭区间,如下图所示:
图片
可以看到,(c=10,id=30)之后的这个间隙并没有在加锁范围里,因此 insert 语句插入 c=12 是可以执行成功的。这个例子对我们实践的指导意义就是,在删除数据的时候尽量加 limit。这样不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围。
一个死锁的例子
next-key lock 实际上是间隙锁和行锁加起来的结果。
图片
现在,我们按时间顺序来分析一下为什么是这样的结果。
1. session A 启动事务后执行查询语句加 lock in share mode,在索引 c 上加了 next-key lock(5,10] 和间隙锁 (10,15);
2. session B 的 update 语句也要再索引 c 上加 next-key lock(5,10] ,进入锁等待;
3. 然后 session A 要再插入 (8,8,8) 这一行,被 session B 的间隙锁锁住。由于出现了死锁,InnoDB 让 session B 回滚。
你可能会问,session B 的 next-key lock 不是还没申请成功吗?其实是这样的,session B 的“加 next-key lock(5,10] ”操作,实际上分成了两步,先是加 (5,10) 的间隙锁,加锁成功;然后加 c=10 的行锁,这时候才被锁住的。
也就是说,我们在分析加锁规则的时候可以用 next-key lock 来分析。但是要知道,具体执行的时候,是要分成间隙锁和行锁两段来执行的。
避免死锁有哪些方法?
• 以固定的顺序访问表和行。
• 大事务拆小。大事务更容易发生死锁,如果业务允许,将大事务拆小。
• 在同一个事务中,尽可能做到一次锁定所需要的所有资源,减少死锁概率。
• 降低隔离级别。如果业务允许,将隔离级别调低也是较好的选择,比如将隔离级别从RR调整为RC,可以避免掉很多因为gap锁造成的死锁。
• 为表添加合理的索引。