数据库 —— 数据库12种锁详解|8月更文挑战

470 阅读11分钟

这是我参与8月更文挑战的第26天,活动详情查看:8月更文挑战

从粒度上来说就是表锁、页锁、行锁。表锁有意向共享锁、意向排他锁、自增锁等。行锁是在引擎层由各个引擎自己实现的。但并不是所有的引擎都支持行锁,比如 MyISAM引擎就不支持行锁

按照锁的粒度进行分类,MySQL主要包含三种类型(级别)的锁定机制:

  • 全局锁:锁的是整个database。由MySQL的SQL layer层实现的
  • 表级锁:锁的是某个table。由MySQL的SQL layer层实现的
  • ⾏级锁:锁的是某⾏数据,也可能锁定⾏之间的间隙。由某些存储引擎实现,⽐如InnoDB

表级锁和行级锁的区别:

  • 表级锁:开销⼩,加锁快;不会出现死锁;锁定粒度⼤,发⽣锁冲突的概率最⾼,并发度最低
  • ⾏级锁:开销⼤,加锁慢;会出现死锁;锁定粒度最⼩,发⽣锁冲突的概率最低,并发度也最⾼

1 全局锁

锁的是整个database。由MySQL的SQL layer层实现的。

2 行级锁

InnoDB 事务中,行锁通过给索引上的索引项加锁来实现。即只有通过索引条件检索数据,InnoDB 才使用行级锁,否则将使用表锁。行级锁定同样分为两种类型:共享锁排他锁,以及加锁前需要先获得的 意向共享锁意向排他锁。行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。

在 InnoDB 存储引擎中,SELECT 操作的不可重复读问题通过 MVCC 得到了解决,而 UPDATE、DELETE 的不可重复读问题通过 Record Lock 解决,INSERT 的不可重复读问题是通过 Next-Key Lock(Record Lock + Gap Lock)解决的。

共享锁(Shared Locks)

共享锁又称为 S锁读锁。若事务T对数据对象A加上 S锁,则事务T 只能读A;其他事务只能再对A加S锁,而不能加X锁,直到T释放A上的S锁。这就保证了其他事务可以读A,但在T释放A上的S锁之前不能对A做任何修改。

  • select ... lock in share mode: 会加共享锁

排它锁(Exclusive Locks)

排它锁又称为 X锁写锁。若事务T对数据对象A加上X锁,则只允许T读取和修改A,其它任何事务都不能再对A加任何类型的锁,直到T释放A上的锁。它防止任何其它事务获取资源上的锁,直到在事务的末尾将资源上的原始锁释放为止。在更新操作(INSERT、UPDATE 或 DELETE)过程中始终应用排它锁。

注意:排他锁会阻止其它事务再对其锁定的数据加读或写的锁,但是不加锁的就没办法控制了。

  • insertupdatedeleteselect ... for update:会加排它锁

MVCC

MVCC主要是通过版本链ReadView来实现的。在Mysql的InnoDB引擎中,只有已提交读(READ COMMITTD)可重复读(REPEATABLE READ) 这两种隔离级别下的事务采用了MVCC机制。

  • 版本链

    在InnoDB引擎表中,它的每一行记录中有两个必要的隐藏列:

    • DATA_TRX_ID:表示插入或更新该行的最后一个事务的事务标识符,同样删除在内部被视为更新,在该更新中,行中的特殊位被设置为将其标记为已删除。行中会有一个特殊位置来标记删除。
    • DATA_ROLL_PTR:存储了一个指针,它指向这条记录的上一个版本的位置,通过它来获得上一个版本的记录信息。

    作用:解决了读和写的并发执行。

  • ReadView

    ReadView主要存放的是当前事务操作时,系统中仍然活跃着的事务(事务开启后,没有提交或回滚的事务)。

    • ReadView数据结构:ReadView是MySQL底层使用C++代码实现的一个结构体,主要的内部属性如下:

      • trx_ids:数组,存储的是创建readview时,活跃事务链表里所有的事务ID
      • low_limit_id:存储的是创建readview时,活跃事务链表里最大的事务ID
      • up_limit_id:存储的是创建readview时,活跃事务链表里最小的事务ID
      • creator_trx_id:当前readview所属事务的事务版本号
    • ReadView创建策略:对于读提交和可重复读事务隔离级别来说,ReadView创建策略是不同的,这样才能保证隔离性不同

      • 可重复读隔离级别:事务开启后,第一次查询的时候创建,之后一直不变,直到事务结束
      • 读提交隔离级别:事务开启后,每一次读取都重新创建

    也就是说已提交读隔离级别下的事务在每次查询的开始都会生成一个独立的ReadView,而可重复读隔离级别则在第一次读的时候生成一个ReadView,之后的读都复用之前的ReadView。

行锁实现算法

  • Record Lock(记录锁)
  • Gap Lock(间隙锁)
  • Next-Key Lock(间隙锁)

2.1 Record Lock(记录锁)

记录锁就是为某行记录加锁,它封锁该行的索引记录:

-- id 列为主键列或唯一索引列
SELECT * FROM t_user WHERE id = 1 FOR UPDATE;

id 为 1 的记录行会被锁住。需要注意:

  • id 列必须为唯一索引列主键列,否则上述语句加的锁就会变成临键锁
  • 同时查询语句必须为精准匹配=),不能为 ><like等,否则也会退化成临键锁

也可以在通过 主键索引唯一索引 对数据行进行 UPDATE 操作时,也会对该行数据加记录锁

-- id 列为主键列或唯一索引列
UPDATE t_user SET age = 50 WHERE id = 1;

2.2 Gap Lock(间隙锁)

间隙锁基于非唯一索引,它锁定一段范围内的索引记录间隙锁基于下面将会提到的Next-Key Locking 算法,请务必牢记:使用间隙锁锁住的是一个区间,而不仅仅是这个区间中的每一条数据

SELECT * FROM t_user WHERE id BETWEN 1 AND 10 FOR UPDATE;
-- 或
SELECT * FROM t_user WHERE id > 1 AND id < 10 FOR UPDATE;

即所有在(1,10)区间内的记录行都会被锁住,所有id 为 2、3、4、5、6、7、8、9 的数据行的插入会被阻塞,但是 1 和 10 两条记录行并不会被锁住。除了手动加锁外,在执行完某些 SQL后,InnoDB也会自动加间隙锁

幻读原因:因为行锁只能锁住行,但新插入记录这个动作,要更新的是记录之间的“间隙”。所以加入间隙锁来解决幻读。

2.3 Next-Key Lock(临键锁)

临键锁是一种特殊的间隙锁,也可以理解为一种特殊的算法。通过临建锁可以解决幻读的问题。每个数据行上的非唯一索引列上都会存在一把临键锁,当某个事务持有该数据行的临键锁时,会锁住一段左开右闭区间的数据。需要强调的一点是,InnoDB行级锁是基于索引实现的,临键锁只与非唯一索引列有关,在唯一索引列(包括主键列)上不存在临键锁

比如:表信息 t_user(id PK, age KEY, name)

Next-Key-Locks.jpg

该表中 age 列潜在的临键锁有:

Next-Key-Locks-临键锁.jpg

事务 A 中执行如下命令:

-- 根据非唯一索引列 UPDATE 某条记录
UPDATE t_user SET name = Vladimir WHERE age = 24;
-- 或根据非唯一索引列 锁住某条记录
SELECT * FROM t_user WHERE age = 24 FOR UPDATE;

不管执行了上述 SQL 中的哪一句,之后如果在事务 B 中执行以下命令,则该命令会被阻塞:

INSERT INTO t_user VALUES(100, 26, 'tian');

很明显,事务 A 在对 age 为 24 的列进行 UPDATE 操作的同时,也获取了 (24, 32] 这个区间内的临键锁。

不仅如此,在执行以下 SQL 时,也会陷入阻塞等待:

INSERT INTO table VALUES(100, 30, 'zhang');

那最终我们就可以得知,在根据非唯一索引 对记录行进行 UPDATE \ FOR UPDATE \ LOCK IN SHARE MODE 操作时,InnoDB 会获取该记录行的 临键锁 ,并同时获取该记录行下一个区间的间隙锁

事务 A在执行了上述的 SQL 后,最终被锁住的记录区间为 (10, 32)

3 表级锁

MySQL 里面表级别的锁有这几种:

  • 表锁
  • 元数据锁(MDL)
  • 意向锁
  • AUTO-INC 锁

3.1 表锁

  • 表锁会限制别的线程的读写外
  • 表锁也会限制本线程接下来的读写操作

如果我们想对学生表(t_student)加表锁,可以使用下面的命令:

-- 表级别的共享锁,也就是读锁
lock tables t_student read;
-- 表级别的独占锁,也就是写锁
lock tables t_stuent wirte;

不过尽量避免在使用 InnoDB 引擎的表使用表锁,因为表锁的颗粒度太大,会影响并发性能,InnoDB 的优势在于实现了颗粒度更细的行级锁。要释放表锁,可以使用下面这条命令,会释放当前会话的所有表锁:

unlock tables

3.2 元数据锁(MDL)

MDL 是为了保证当用户对表执行 CRUD 操作时,防止其他线程对这个表结构做了变更。不需要显示的使用 MDL,因为当我们对数据库表进行操作时,会自动给这个表加上 MDL:

  • 对一张表进行 CRUD 操作时,加的是 MDL 读锁

    当有线程在执行 select 语句( 加 MDL 读锁)的期间,如果有其他线程要更改该表的结构( 申请 MDL 写锁),那么将会被阻塞,直到执行完 select 语句( 释放 MDL 读锁)。

  • 对一张表做结构变更操作的时候,加的是 MDL 写锁

    当有线程对表结构进行变更( 加 MDL 写锁)的期间,如果有其他线程执行了 CRUD 操作( 申请 MDL 读锁),那么就会被阻塞,直到表结构变更完成( 释放 MDL 写锁)。

MDL 是在事务提交后才会释放,这意味着事务执行期间,MDL 是一直持有的。申请 MDL 锁的操作会形成一个队列,队列中写锁获取优先级高于读锁,一旦出现 MDL 写锁等待,会阻塞后续该表的所有 CRUD 操作。

3.3 意向锁

  • 在使用 InnoDB 引擎的表里对某些记录加上「共享锁」之前,需要先在表级别加上一个「意向共享锁」;
  • 在使用 InnoDB 引擎的表里对某些纪录加上「独占锁」之前,需要先在表级别加上一个「意向独占锁」;

也就是,当执行插入、更新、删除操作,需要先对表加上「意向共享锁」,然后对该记录加独占锁。而普通的 select 是不会加行级锁的,普通的 select 语句是利用 MVCC 实现一致性读,是无锁的。不过,select 也是可以对记录加共享锁和独占锁的,具体方式如下:

-- 先在表上加上意向共享锁,然后对读取的记录加独占锁
select ... lock in share mode;
-- 先表上加上意向独占锁,然后对读取的记录加独占锁
select ... for update;

意向共享锁和意向独占锁是表级锁,不会和行级的共享锁和独占锁发生冲突,而且意向锁之间也不会发生冲突,只会和共享表锁(lock tables … read)和独占表锁(lock tables … write)发生冲突。

表锁和行锁是满足读读共享、读写互斥、写写互斥的。如果没有「意向锁」,那么加「独占表锁」时,就需要遍历表里所有记录,查看是否有记录存在独占锁,这样效率会很慢。那么有了「意向锁」,由于在对记录加独占锁前,先会加上表级别的意向独占锁,那么在加「独占表锁」时,直接查该表是否有意向独占锁,如果有就意味着表里已经有记录被加了独占锁,这样就不用去遍历表里的记录。所以,意向锁的目的是为了快速判断表里是否有记录被加锁

3.4 AUTO-INC锁(自增长锁)

在为某个字段声明 AUTO_INCREMENT 属性时,之后可以在插入数据时,可以不指定该字段的值,数据库会自动给该字段赋值递增的值,这主要是通过 AUTO-INC 锁实现的。

AUTO-INC 锁是特殊的表锁机制,锁不是再一个事务提交后才释放,而是再执行完插入语句后就会立即释放在插入数据时,会加一个表级别的 AUTO-INC 锁,然后为被 AUTO_INCREMENT 修饰的字段赋值递增的值,等插入语句执行完成后,才会把 AUTO-INC 锁释放掉。

AUTO-INC 锁再对大量数据进行插入的时候,会影响插入性能,因为另一个事务中的插入会被阻塞。因此, 在 MySQL 5.1.22 版本开始,InnoDB 存储引擎提供了一种轻量级的锁来实现自增。一样也是在插入数据的时候,会为被 AUTO_INCREMENT 修饰的字段加上轻量级锁,然后给该字段赋值一个自增的值,就把这个轻量级锁释放了,而不需要等待整个插入语句执行完后才释放锁

InnoDB 存储引擎提供了个 innodb_autoinc_lock_mode 的系统变量,是用来控制选择用 AUTO-INC 锁,还是轻量级的锁。

  • 当 innodb_autoinc_lock_mode = 0,就采用 AUTO-INC 锁
  • 当 innodb_autoinc_lock_mode = 2,就采用轻量级锁
  • 当 innodb_autoinc_lock_mode = 1,这个是默认值,两种锁混着用,如果能够确定插入记录的数量就采用轻量级锁,不确定时就采用 AUTO-INC 锁

不过,当 innodb_autoinc_lock_mode = 2 是性能最高的方式,但是会带来一定的问题。因为并发插入的存在,在每次插入时,自增长的值可能不是连续的,这在有主从复制的场景中是不安全的