MySQL那些“锁”事,你听烦了吗?

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