InnoDB解决幻读的方案
🔓

InnoDB解决幻读的方案

Created
Jun 24, 2021 02:23 AM
status
reproduced

LBCC&MVCC

InnoDB默认的事务隔离级别是repeatable read,它为了解决该隔离级别下的幻读的并发问题,提出了LBCCMVCC两种方案。其中LBCC解决的是当前读情况下的幻读,MVCC解决的是普通读(快照读)的幻读。

LBCC

LBCCLock-Based Concurrent Control的简称,意思是基于锁的并发控制。在InnoDB中按锁的模式来分的话可以分为
  • 共享锁(S)
  • 排它锁(X)
  • 意向锁

共享锁(行锁) Shared Locks

又名读锁,对某一资源加共享锁,自身可以读该资源,其他人也可以读该资源(也可以再继续加共享锁,即共享锁可以多个共存),但是无法修改。
--加锁--
#after mysql 8.0
select *from table_name for share; 
#before mysql 8.0
select *from table_name lock in share mode; 
--释放锁--
Commit;
Rollback;

排它锁(行锁) Exclusive Locks

对某一资源加排它锁,自身可以进行增删改查,其他人无法进行任何操作 ⚠️排它锁不与其他锁共存
--加锁--
#自动:DML语句默认加排它锁
#手动
	select * from user where id = 1 for update
--释放锁--
Commit;
Rollback;

意向锁(表锁) Intention Locks

  • 意向共享锁 加共享锁的前提
  • 意向排它锁 加排它锁的前提
是为了告知是否有行锁存在

如果按照锁的算法来分的话又分为
  • 记录锁 (Record Locks
  • 间隙锁 (Gap Locks
  • 临键锁 (Next-key Locks
临键锁可以用来解决RR下的幻读问题。
notion image
我们将数据库中存储的每一行数据称为记录。则上图中1、5、9、11分别代表id为当前数的记录。对于键值在条件范围内但不存在的记录,叫做间隙(GAP)。则上图中的(-∞,1)、(1,5)...(11,+∞)为数据库中存在的间隙。而(-∞,1]、(1,5]...(11,+∞)我们称之为临键,即左开右闭的集合。

记录锁(Record Locks)

对表中的行记录加锁,叫做记录锁,简称行锁。可以使用sql语句select ... for update来开启锁,select语句必须为精准匹配(=),不能为范围匹配,且匹配列字段必须为唯一索引或者主键列。也可以通过对查询条件为主键索引或唯一索引的数据行进行UPDATE操作来添加记录锁。
★记录锁存在于包括主键索引在内的唯一索引中,锁定单条索引记录。”

间隙锁(GAP Locks)

对上面说到的间隙加锁即为间隙锁。间隙锁是对范围加锁,但不包括已存在的索引项。可以使用sql语句select ... for update来开启锁,select语句为范围查询,匹配列字段为索引项,且没有数据返回;或者select语句为等值查询,匹配字段为唯一索引,也没有数据返回。
间隙锁有一个比较致命的弱点,就是当锁定一个范围键值之后,即使某些不存在的键值也会被无辜的锁定,而造成在锁定的时候无法插入锁定键值范围内的任何数据。在某些场景下这可能会对性能造成很大的危害。以下是加锁之后,插入操作的例子:
select * from user where id > 15 for update;
--插入失败,因为id20大于15,不难理解--
insert into user values(20,'20');
--插入失败,原因是间隙锁锁的是记录间隙,而不是sql,也就是说`select`语句的锁范围是(11+∞),而13在这个区间中,所以也失败。--
insert into user values(13,'13');
★GAP Locks只存在于RR隔离级别下,它锁住的是间隙内的数据。加完锁之后,间隙中无法插入其他记录,并且锁的是记录间隙,而非sql语句。间隙锁之间都不存在冲突关系。”
打开间隙锁设置: 以通过命令show variables like 'innodb_locks_unsafe_for_binlog';来查看 innodb_locks_unsafe_for_binlog 是否禁用。innodb_locks_unsafe_for_binlog默认值为OFF,即启用间隙锁。因为此参数是只读模式,如果想要禁用间隙锁,需要修改 my.cnf(windows是my.ini) 重新启动才行。
#在 my.cnf 里面的[mysqld]添加
[mysqld]
innodb_locks_unsafe_for_binlog = 1

临键锁(Next-Key Locks)

当我们对上面的记录和间隙共同加锁时,添加的便是临键锁(左开右闭的集合加锁)。为了防止幻读,临键锁阻止特定条件的新记录的插入,因为插入时要获取插入意向锁,与已持有的临键锁冲突。可以使用sql语句select ... for update来开启锁,select语句为范围查询,匹配列字段为索引项,且有数据返回;或者select语句为等值查询,匹配列字段为索引项,不管有没有数据返回。
★插入意向锁并非意向锁,而是一种特殊的间隙锁。”

总结

  • 如果查询没有命中索引,则退化为表锁;
  • 如果等值查询唯一索引且命中唯一一条记录,则退化为行锁;
  • 如果等值查询唯一索引且没有命中记录,则退化为临近结点的间隙锁;
  • 如果等值查询非唯一索引且没有命中记录,退化为临近结点的间隙锁(包括结点也被锁定);如果命中记录,则锁定所有命中行的临键锁,并同时锁定最大记录行下一个区间的间隙锁。
  • 如果范围查询唯一索引或查询非唯一索引且命中记录,则锁定所有命中行的临键锁 ,并同时锁定最大记录行下一个区间的间隙锁。
  • 如果范围查询索引且没有命中记录,退化为临近结点的间隙锁(包括结点也被锁定)。

MVCC

LBCC是基于锁的并发控制,因为锁的粒度过大,会导致性能的下降,因此提出了比LBCC性能更优越的方法MVCCMVCCMulti-Version Concurremt Control的简称,意思是基于多版本的并发控制协议,通过版本号,避免同一数据在不同事务间的竞争,只存在于InnoDB引擎下。它主要是为了提高数据库的并发读写性能,不用加锁就能让多个事务并发读写。
MVCC的实现依赖于:
三个隐藏字段
Undo log
Read View
其核心思想就是:
  • 只能查找事务id小于等于当前事务ID的行;
  • 只能查找删除时间大于等于当前事务ID的行,或未删除的行。

当前读

当前读(Locking Read)也称锁定读,读取当前数据的最新版本,而且读取到这个数据之后会对这个数据加锁,防止别的事务更改即通过next-key锁(行锁+gap锁)来解决当前读的问题。在进行写操作的时候就需要进行“当前读”,读取数据记录的最新版本,包含以下SQL类型:select ... lock in share mode 、select ... for updateupdate 、delete 、insert

隐藏列

MySQL中会为每一行记录生成三个隐藏列。
  1. DB_TRX_ID:事务ID,是根据事务产生时间顺序自动递增的,是独一无二的。如果某个事务执行过程中对该记录执行了增、删、改操作,那么InnoDB存储引擎就会记录下该条事务的id。
  1. DB_ROLL_PTR:回滚指针,本质上就是一个指向记录对应的undo log的一个指针,大小为 7 个字节,InnoDB 便是通过这个指针找到之前版本的数据。该行记录上所有旧版本,在undo log中都通过链表的形式组织。
  1. DB_ROW_ID:行标识(隐藏单调自增 ID),如果表没有主键,InnoDB 会自动生成一个隐藏主键,大小为 6 字节。如果数据表没有设置主键,会以它产生聚簇索引。
  1. 实际还有一个删除flag隐藏字段,既记录被更新或删除并不代表真的删除,而是删除flag变了。

undo log

每当我们要对一条记录做改动时(这里的改动可以指INSERT、DELETE、UPDATE),都需要把回滚时所需的东西记录下来, 比如:
  • Insert undo log :插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉就好了。
  • Delete undo log:删除一条记录时,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中就好了。
  • Update undo log:修改一条记录时,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值就好了。
InnoDB把这些为了回滚而记录的这些东西称之为undo log。这里需要注意的一点是,由于查询操作(SELECT)并不会修改任何用户记录,所以在查询操作执行时,并不需要记录相应的undo log
每次对记录进行改动都会记录一条undo日志,每条undo日志也都有一个DB_ROLL_PTR属性,可以将这些undo日志都连起来,串成一个链表,形成版本链。版本链的头节点就是当前记录最新的值。
先插入一条记录,假设该记录的事务id为80,那么此刻该条记录的示意图如下所示
notion image
实际上insert undo只在事务回滚时起作用,当事务提交后,该类型的undo日志就没用了,它占用的Undo Log Segment也会被系统回收。
接着继续执行sql操作
notion image
其版本链如下
notion image
✨很多人以为undo log用于将数据库物理的恢复到执行语句或者事务之前的样子,其实并非如此,undo log是逻辑日志,只是将数据库逻辑的恢复到原来的样子。因为在多并发系统中,你把一个页中的数据物理的恢复到原来的样子,可能会影响其他的事务。”

快照读和Read View

在可重复读隔离级别下,我们可以把每一次普通的select查询(不加for update语句)当作一次快照读,而快照便是进行select的那一刻,生成的当前数据库系统中所有未提交的事务id数组(数组里最小的idmin_id)和已经创建的最大事务idmax_id)的集合,即我们所说的一致性视图readview。在进行快照读的过程中要根据一定的规则将版本链中每个版本的事务idreadview进行匹配查询我们需要的结果。
快照读是不会看到别的事务插入的数据的。因此,幻读在“当前读”下才会出现。快照读的实现是基于多版本并发控制,即MVCC,可以认为MVCC是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销;既然是基于多版本,即快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。MVCC只在 READ COMMITTED 和 REPEATABLE READ两个隔离级别下工作,其他两个隔离级别不和MVCC兼容。因为READ UNCOMMITTED总是读取最新的数据行,而不是符合当前事务版本的数据行,而SERIALIZABLE 则会对所有读取的行都加锁。事务的快照时间点(即下文中说到的Read View的生成时间)是以第一个select来确认的。所以即便事务先开始,但是select在后面的事务的update之类的语句后进行,那么它是可以获取前面的事务的对应的数据。
RC和RR隔离级别下的快照读和当前读: RC隔离级别下,快照读和当前读结果一样,都是读取已提交的最新; RR隔离级别下,当前读结果是其他事务已经提交的最新结果,快照读是读当前事务之前读到的结果。RR下创建快照读的时机决定了读到的版本。”
对于使用RC和RR隔离级别的事务来说,都必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的。核心问题就是:需要判断一下版本链中的哪个版本是当前事务可见的。为此,InnoDB提出了一个Read View的概念。
Read View就是事务进行快照读(普通select查询)操作的时候生产的一致性读视图,在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,它由执行查询时所有未提交的事务id数组(数组里最小的id为min_id)和已经创建的最大事务id(max_id)组成,查询的数据结果需要跟read view做对比从而得到快照结果。
notion image

快照规则

版本链比对规则:
  1. 如果落在绿色部分(trx_id<min_id),表示这个版本是已经提交的事务生成的,这个数据是可见的;
  1. 如果落在红色部分(trx_id>max_id),表示这个版本是由将来启动的事务生成的,是肯定不可见的;
  1. 如果落在黄色部分(min_id<=trx_id<=max_id),那就包含两种情况:
    1. 若row的trx_id在数组中,表示这个版本是由还没提交的事务生成的,不可见; 如果是自己的事务,则是可见的;
    2. 若row的trx_id不在数组中,表示这个版本是已经提交了的事务生成的,可见。

RR模式

首先我们要准备两张表,一张test和一张account表,然后我们以accountundo log来画版本链,准备数据和原始记录图如下
#test表中数据
id=1,c1='11';
id=5,c1='22';
#account表数据
id=1,name=‘lilei’;
notion image
如下图,我们将按照里面的顺序执行
notion image
当我们执行到第7行的select的语句时,会生成readview[100,200],300,版本链如图所示:
notion image
此时我们查询到的数据为lilei300。我们首先要拿最新版本的数据trx_id=300readview中匹配,落在黄色区间内,一看该数据已经提交了,所以是可见的。继续往下执行,当执行到第10行的select语句时,因为trx_id=100并未提交,所以版本链依然为readview[100,200],300,版本链如图所示:
notion image
此时我们查询到的数据为lilei300。我们按上边操作,从最新版本依次往下匹配,我们首先要拿最新版本的数据trx_id=100readview中匹配,落在黄色区间内,一看该数据在未提交的数组中,且不是自己的事务,所以是不可见的;然后我们选择前一个版本的数据,结果同上;继续向上找,当找到trx_id=300的数据时,会落在黄色区间,且是提交的,所以数据可见。继续往下执行,当执行到第13行的select语句时,此时尽管trx_id=100已经提交了,因为是InnoDB的RR模式,所以readview不会更改,仍为readview[100,200],300,版本链如图所示:
notion image
此时我们查询到的数据为lilei300。原因同上边的步骤,不再赘述。
💫当执行update语句时,都是先读后写的,而这个读,是当前读,只能读当前的值,跟readview查找时的快照读区分开。”

RC模式

刚才演示的是InnoDB下的RR模式,接下来我们简单说一下RC模式,上文中提到的RC模式的数据读都是读最新的即当前读,所以readview是实时生成的,执行语句如图所示:
notion image
当我们执行到第13行的select的语句时,会生成readview[200],300,版本链还和之前一样,此时我们查询到的数据为lilei2。原因和上边讲的RR模式下的比对规则相同。
此处演示的是update的情况,对于删除的情况可以认为是update的特殊情况,会将版本链上最新的数据复制一份,然后将trx_id改成删除操作的trx_id,同时在该条记录的头信息(record header)里的(deleted_flag)标记位上写上true,来表示当前记录已经被删除,在查询时按照上边的规则查到对应的记录,如果delete_flag标记位为true,意味着记录已被删除,则不返回数据。

undo log什么时候删除呢?

系统会判断,没有比这个undo log更早的read view的时候,undo log会被删除。所以这里也就是为什么我们建议你尽量不要使用长事务的原因。长事务意味着系统里面会存在很老的事务视图。由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。

总结

在 MySQL 中,
READ COMMITTEDREPEATABLE READ隔离级别的的一个非常大的区别就是它们生成 ReadView的时机不同。
READ COMMITTED中每次查询都会生成一个实时的ReadView,做到保证每次提交后的数据是处于当前的可见状态。 REPEATABLE READ中,在当前事务第一次查询时生成当前的ReadView,并且当前的ReadView会一直沿用到当前事务提交,以此来保证可重复读。

Loading Comments...