InnoDB 中的锁机制以及多版本并发控制(MVCC)

601 阅读8分钟

  当多个用户并发的存取数据时,在数据库中就可能出现多个事务同时操作同一条记录的情况。若对并发操作不加控制,就可能读取和存储不正确的数据,破坏数据的一致性。

  InnoDB 中实现了标准的行级锁,按照兼容性可以分为共享锁(S 锁)和排他锁(X 锁):

  • 共享锁允许获得该锁的事务对数据进行读操作
  • 排它锁允许获得该锁的事务对数据进行写操作(update、delete)

⒈ 意向锁

  InnoDB 同时支持多种粒度的锁,例如 InnoDB 可以允许表锁和行锁共存。为了使这种机制变得切实可行,InnoDB 引入了意向锁。意向锁是表锁,它预示事务即将向表中的记录申请 S 锁或 X 锁。

  意向锁分两种类型:

  • 意向共享锁(IS):预示事务即将在记录上设置 S 锁
  • 意向排他锁(IX):预示事务即将在记录上设置 X 锁

  事务在获取 S 锁之前,必须先获取相应表的 IS 锁或 IX 锁;事务在获取 X 锁之前,必须先获取相应表的 IX 锁。

  锁的兼容性:

兼容性X 锁IX 锁S 锁IS 锁
X 锁冲突冲突冲突冲突
IX 锁冲突兼容冲突兼容
S 锁冲突冲突兼容兼容
IS 锁冲突兼容兼容兼容

  意向锁的主要目的是为了表明某个事务正在锁定某些记录或即将锁定某些记录。所以,除对全表数据的操作请求之外,意向锁不会阻塞其他操作。

⒉ 锁的几种算法

⓵ 记录锁(record lock)

  记录锁是加在索引上的。如果一张表在创建是没有指定索引,InnoDB 会自动创建一个隐藏的聚簇索引,记录锁会加在这个隐藏的聚簇索引上。

  如果在建表时除了主键,没有指定别的索引,而 where 条件中又没有使用主键列,那么 InnoDB 为了防止幻读,会扫描全表,并锁定扫描时遇到的所有记录。

  可以看出,由于 account_id 没有索引,所以在执行 update 操作时进行了全表扫描,所有的记录都被 session 1 中的事务锁定。在 session 1 的事务提交/回滚之前,session 2 和 session 3 中的事务只能等待。

⓶ 间隙锁(gap lock)

  间隙锁是加在索引之间的间隙上的,防止其他事务在这些间隙中插入新值,以保证 InnoDB 的可重复读。这些间隙可能跨越单个索引值、多个索引值,甚至可能为空。由于间隙锁只是为了防止其他事务向这些间隙中插入新值,所以同一间隙上可以同时存在不同事务的间隙锁,并且 S 类型的间隙锁和 X 类型的间隙锁并没有什么不同。

  当使用唯一索引进行等值查询时,间隙锁不会用到。唯一索引是一个组合索引,并且查询条件中并没有包含组成唯一索引的全部列,那么此时还是会用到间隙锁。

  在上例中,如果在 account_id 列加上索引,然后执行同样的操作。此时,在索引值 1 和 5 之间会加上间隙锁以防止幻读,从而保证 InnoDB 的可重复读。

⓷ Next-Key 锁

  Next-Key 是一个记录锁以及该记录锁所在的索引之前的间隙上的间隙锁的合集,其目的也是为了防止幻读。

  仍然使用上例,分别执行以下 SQL:

# session 1
begin;
update list_of_things set modified = now() where account_id = 5;

# session 2
begin;
insert into list_of_things values (0, 3, now(), 'account 3\'s thing');

# session 3
begin;
insert into list_of_things values (0, 6, now(), 'account 6\'s thing');

# session 4
begin;
insert into list_of_things values (0, 10, now(), 'account 10\'s thing');

  依次执行以上 4 个事务,session 1 中的 update 可以顺利执行。为了防止出现幻读,InnoDB 会在 [5,9) 之间加间隙锁,在 (1,5] 之间加 Next-Key 锁,所以 session 2 和 session 3 的 SQL 语句会被阻塞,但 session 4 的 SQL 可以正常执行,因为 10 > 9,所以 session 4 中的 SQL 可以正常的插入。

InnoDB 不会记录已经扫描过得行
InnoDB 中的索引是按顺序排列的
在辅助索引中,叶子节点存储的是该索引对应的主键的值

   基于以上三点,由于 account_id 列上的索引只是普通索引,所以可以同时存在多条 account_id = 5 的记录。同时,在现有的 account_id =5 的记录之前或之后还可能插入新的 account_id = 5 的记录。为了防止出现幻读,InnoDB 会将 account_id =5 的记录以及之前和之后的间隙都加锁。account_id = 5 的记录之后的间隙加间隙锁,account_id = 5 的记录以及之前的间隙加 Next-Key 锁。

⓸ 插入意向锁

  插入意向锁是由 insert 语句设置在当前插入行索引之前的间隙锁。其目的是当有多个事务在同一索引间隙之间执行插入操作时,如果这些插入操作在不同的位置进行,则事务之间不需要互相等待。例如,两个事务分别在索引 4 和 7 之间插入 5 和 6,则这两个事务不需要互相等待。

⓹ 自增锁(auto-inc lock)

  自增锁是一种特殊的表级锁,当事务向数据表中插入带有自增(auto_increment)属性的列时,其他事务必须等待,以保证插入的自增列的值连续。

⒊ MVCC

  MySQL 默认采用的是悲观锁的机制,这种机制虽然保证了数据的一致性,但同时也降低了并发度。InnoDB 为了增加并发度,提高数据库的性能,引入了多版本并发控制机制(MVCC)。

乐观锁:默认不会有并发操作,每次读取数据都不加锁,只有在更新数据时才会判断该数据是否被其他线程做过修改(适用于读操作比较多的场景,可以提高吞吐量)

悲观锁:每次在获取数据时都会默认数据会被其他线程修改,所以都会加锁。这样,如果其他线程想要对相同的数据进行操作,就会被阻塞,直到锁被释放

⓵ MVCC 机制

  为了支持事务特性,InnoDB 会存储一些已经被修改的记录的旧版本相关的信息。这些信息存储在 undo tablespacerollback segment 当中。InnoDB 可以使用这些信息来进行事务的回滚操作,同时还用这些信息来保证一致性的读。

  InnoDB 底层会为每一条记录额外的增加三个字段:

  • DB_TRX_ID :对当前记录进行最新的写操作的事务的 ID
  • DB_ROLL_PTR :回滚指针,指向 rollback segment 中的 undo log
  • DB_ROW_ID :单调自增的记录 ID

  当有记录被修改时,InnoDB 会首先将修改之前的数据写入 undo log 中,然后再对记录进行更新操作。此时,记录中的 DB_TRX_ID 即为进行本次操作的事务的 ID,而 DB_ROLL_PTR 则指向刚刚生成的 undo log。在 rollback segment 中,这些 undo log 会组成一个链表的结构,来保证一致性的读。

  现实中,InnoDB 的 undo log 不一定会保存这么完整。insert 操作的 undo log 只有在执行 insert 操作的事务需要回滚时才需要用到,一旦事务提交完成,则 insert 操作的 undo log 就会被丢弃。update 操作的 undo log 的丢弃会相对复杂一些,因为 update 操作的 undo log 不仅会被用于事务的回滚,还会被用来保证一致性的读。所以,只有当这些读和写的事务全部完成以后,update 的 undo log 才可以被丢弃。

CREATE TABLE `list_of_things` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `first_name` char(16) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '',
  `last_name` char(16) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '',
  `account_id` int(11) NOT NULL DEFAULT '0',
  `email` char(32) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '',
  `things` char(64) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '',
  PRIMARY KEY (`id`),
  KEY `idx_name` (`first_name`,`last_name`),
  KEY `idx_account_id` (`account_id`),
  KEY `idx_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci

开启一个事务,执行读操作

开启另一个事务,执行更新操作

⓶ MVCC 与长事务

  为了保证一致性的读,同时支持事务的回滚,长事务会生成很多个 undo log,形成很长的 undo log 链表,尤其在写操作比较频繁的时候。这样就会拖慢事务本身执行的速度,同时,如果还有其他线程对这些记录执行读操作,那么这些查询也会变得非常慢。

  长事务会生成很多 undo log,在事务执行完之前或者需要用这些 undo log 来保证一致性读的读操作执行完之前,这些 undo log 无法被丢弃和清理而占用大量空间。

⓷ MVCC 中的删除

  删除会被当作更新操作对待。主索引(聚簇索引)中数据的更新会立即生效,更新前的旧数据会被写入 undo log。但辅助索引中数据的更新只是把旧的记录标记为 删除,同时写入一条新的记录,被标记为删除的记录最终会被清理。