1. 前言
数据库隔离级别以及Mysql实操 一文中,我描述了为了解决并发事务间的冲突,实现事务的隔离性,SQL标椎定义了四种隔离级别,今天就通过这篇文章来看下SQL标准中每种隔离级别的实现原理以及InnoDB引擎又是如何实现的。
2. 标准SQL事务隔离级别实现原理
解决并发问题最直觉的方法就是加锁了,而标准SQL事务隔离级别的实现就是依赖于锁的。
隔离级别 | 实现 |
---|---|
未提交读 | 事务对当前读取到的数据不加锁;事务在更新的瞬间对其加行级共享锁(读锁),直到事务结束才释放。 更新时加共享锁,会阻塞其他事务的更新,但是不会阻塞读。 由于在更新时没有加排他锁(写锁)并且其他事务读的时候也没有尝试加锁,导致其他事务是可以读到修改的,即脏读。 |
提交读 | 事务对当前读到的数据加行级共享锁,一旦读完该行就释放锁;事务在更新的瞬间对其加行级排他锁(写锁),直到事务结束才释放。 由于更新时加了排他锁,所以当前事务提交前,其他事务是读不到修改的,这就解决了脏读。 由于读完数据后就释放了锁,所以之后另外一个事务还能修改该行,修改后再读到就是修改之后的数据,这就造成一个事务内读取两次读到的数据是不同的了,即不可重复读。 |
可重复读 | 事务开始读取时,对其加行级共享锁,事务结束后才释放;事务在更新的瞬间对其加行级排他锁(写锁),直到事务结束才释放。 由于直到事务结束后才释放读锁,所以在事务结束前,其他事务无法修改该行,所以一个事务多次读取到的数据肯定是相同的,就不会存在不可重复读的问题了。 但是这个隔离级别下,由于只能锁住已存在的行,对insert进来的新数据,还是能读到的,即幻读。 |
串行化 | 事务在读取时,加表级共享锁,事务结束后才释放;事务在修改数据时,加表级排他锁。 这个级别下由于加了表锁,所以事务提交前就写不进来新数据,就不存在幻读的问题了。 |
3. MVCC(Multi-Version Concurrency Control)
通过锁虽然能实现事务间的隔离,但是开销还是太大了,系统性能肯定是扛不起高并发的,为了优化这个问题,尽量避免使用锁,提出了MVCC方式来解决事务并发问题。
3.1 InnoDB的MVCC实现
MVCC在InnoDB中是通过两个隐式字段
、undo log
、Read View
实现的。
3.1.1 隐式字段
InnoDB会在每一行加上两个隐式字段:
DB_TRX_ID
: 6bytes,最近修改事务的ID,记录这行记录最后一次修改的事务的IDDB_ROLL_PTR
: 7bytes,回滚指针,指向这条记录的上一个版本(存储于rollback segment中)
实际上还有两个字段,但是与MVCC无关。
DB_ROW_ID
: 隐藏的自增ID(隐藏主键),如果没有主键,则InnoDB会自动以DB_ROW_ID
产生一个聚簇索引- 一个隐藏的删除flag字段
3.1.2 undo log
undo log分为两种:
- insert undo log: 事务在insert时产生,事务提交后可以立即丢弃
- update undo log:事务在update/delete时产生,不仅在回滚时需要,快照读时也需要,不能随便删除,只有在快照读或者事务不涉及的时候才由purge线程去清除。
purge:为了实现MVCC,删除只是设置下记录的deleted_bit,并不真正删除,InnoDB 有专门的purge线程来回收标记删除的记录,为了不影响MVCC的工作,purge线程也维护一个自己的read view,如果某个记录的DB_TRX_ID相对于purge线程read view可见,那么这条记录就能被安全的删除。
执行流程如下:
1> 比如数据库中当前有一条记录:
name | age | DB_ROW_ID | DB_TRX_ID | DB_ROLL_PTR |
---|---|---|---|---|
n1 | 11 | 1 | 1 | null |
2> 新来一个事务 2修改了记录:update name=n2 where age = 11
,流程如下:
- 事务1修改改行记录时,InnoDB先对改行加排他锁
- 把当前记录拷贝到undo log中,作为旧记录
- 拷贝完了后修改name为n2,并且修改记录的DB_TRX_ID为当前事务的id,即:2。DR_ROLL_ID指向undo log中的旧记录,即它的上一个版本
- 事务提交后,释放锁
3> 又来一个事务 3修改记录:update name=n3 where age=11
,流程如下:
- 事务1修改改行记录时,InnoDB先对改行加排他锁
- 把当前记录拷贝到undo log中,作为旧记录,由于该行记录已经有undo log了,那么最新的旧记录作为链表头,插在undo log最前面
- 拷贝完了后修改name为n3,并且修改记录的DB_TRX_ID为当前事务的id,即:3。DR_ROLL_ID指向undo log中的旧记录,即它的上一个版本
- 事务提交后,释放锁
3.1.3 ReadView 读视图
ReadView中有四个比较重要的内容:
- creator_trx_id: 表示生成该ReadView的事务ID。 (只有在执行insert、update、delete时才会分配事务ID,在一个只读的事务中事务id默认为0)
- m_ids: 在生成ReadView时所有活跃的事务id集合,活跃事务是指开启还未提交的事务。
- min_trx_id: m_ids最小值。
- max_trx_id: 生成ReadView时系统应该分配的下一个事务ID,并非m_ids最大值。
有了这个ReadView,就可以这样判断一条记录是否对该事务可见:
- 如果被访问版本的trx_id等于creator_trx_id,说明生成该版本的事务就是当前事务,所以可见
- 如果被访问版本的trx_id小于min_trx_id,说明生成该版本的事务在当前事务生成ReadView前已提交,所以该版本可见
- 如果被访问版本的trx_id大于等于max_trx_id,表示生成该版本的事务在当前事务生成ReadView之后才开启,所以不可见
- 如果被访问版本trx_id在min_trx_id与max_trx_id之间,则判断是否在m_ids之中,如果在,说明创建ReadView时生成该版本的事务还活跃,所以不可见;如果不在m_ids中,则说明事务已提交所以可见。
如果某个版本的记录不可见就顺着版本链寻找下一个版本,依次判断是否可见,直到遍历到最后。
3.1.4 MVCC的实现
现在我们已经了解了undo log与ReadView,那么就来看下MVCC到底是如何实操的。
我们假设当前数据结构如下:
假设 事务20 与 事务30 并发执行,那么对于事务20,它的ReadView中m_ids=[20,30],min_trx_id=20,max_trx_id=31,creator_trx_id=20,对于事务30,它的ReadView 中m_ids=[20,30],min_trx_id=20,max_trx_id=31,creator_trx_id=30
如果此时 事务20 去读取数据,当前版本链中,数据最新版本的DB_TRX_ID为10,它小于 事务20 ReadView的min_trx_id,所以这个版本对 事务20 是可见的。
接着 事务30 修改了这行记录,数据结构就变成了下面这样:
这时 事务 20 再去读这行记录,当前版本链中,数据最新版本的DB_TRX_ID为30,30在 事务20 的m_ids中,所以这个版本数据对 事务20 不可见,继续顺着版本链读上一个版本,上一个版本DB_TRX_ID为10,可见,所以 事务20 就读到了 上一个版本的数据。
4. 几个概念
在了解InnoDB四种隔离级别的实现之前,我们先明确几个概念
4.1 锁定读和一致性非锁定读
- 锁定读:在一个事务中主动给读加锁,eg. select … for update(排他锁)、select … lock in share mode(共享锁)
- 一致性非锁定度:InnoDB通过MVCC向事务提供数据库某个时间点的快照,查询时只能查到当前事务开始前提交的修改,查不到该事务开始之后的修改。就是说事务开始后,事务看到的数据就是事务开始时的数据,后续其他事务的修改在当前事务不可见。
一致性非锁定读是InnoDB在RC和RR两个级别处理SELECT的默认模式,这个过程不用加锁,所以其他事务可以并发修改和读取。
4.2 当前读和快照读
- 当前读:像update、delete、insert、select … for update、select … lock in share mode,读到的都是当前版本数据,读取时要保证其他并发事务不能修改当前记录,还要加锁
- 快照读:读到的是快照版本,不加锁的select就是快照读,不加锁。前提是隔离级别不是未提交读和串行化,因为未提交读所有读都是当前读,串行化会对表加锁。
4.3 隐式锁定与显示锁定
隐式锁定 InnoDB在事务执行过程中采用两阶段锁协议,InnoDB根据隔离级别在需要的时候自动加锁,直到事务提交或回滚之后才释放锁,所有的锁都在同一时刻释放。
显示锁定 通过特定的语句显式锁定:
代码语言:javascript复制select ... for update
select ... lock in share mode
5. InnoDB隔离级别实现
InnoDB中,RC与RR两个隔离级别生成ReadView时机是不同的 * RC - 每次读取记录前都生成一个ReadView,而这就导致不可重复读问题 * RR - 在第一次读取时生成一个ReadView,这就解决了可重复读问题
事务隔离级别 | 实现 |
---|---|
未提交读 | 事务对读都不加锁,都是当前读; 事务在更新的瞬间对其加行级共享锁(读锁),直到事务结束才释放。 |
提交读 | 事务对读不加锁,都是快照读;事务在更新的瞬间对其加行级排他锁(写锁),直到事务结束才释放。 |
可重复读 | 事务读不加锁,都是快照读;事务在更新时,加Next-Key Lock直到事务结束才释放 |
串行化读 | 事务在读取时,加表级共享锁,直到事务结束才释放,都是当前读;写入时加表级排他锁,直到事务结束才释放 |
我们再思考两个问题:
5.1 RC级别就是快照读了,那还存在不可重复读的问题吗?
答案是仍然存在,原因是InnoDB在这个级别每次读取记录前都生成一个ReadView。
5.2 很多文章提到InnoDB在RR级别就通过MVCC解决了幻读问题,真的吗?
我们先运行一个例子:
事务A | 事务B |
---|---|
begin; | |
select * from users; Empty set (0.00 sec) | |
begin; | |
insert into users(name,age) values('n1', 1); | |
commit; | |
select * from users; Empty set (0.00 sec) |
OK,看起来是解决了,这个例子中事务B的ID>=事务A的ReadView的max_trx_id,所以事务B写入的数据对事务A是不可见的。
不过先别着急下结论,再看下下面的这个例子:
事务A | 事务B |
---|---|
begin; | |
select * from users; Empty set (0.00 sec) | |
begin; | |
insert into users(name,age) values(‘n1’, 1); | |
commit; | |
update users set name=‘n2’ where id=1; | |
select * from users; —- —— —— | id | name | age | —- —— —— | 1 | n2 | 1 | —- —— —— 1 row in set (0.00 sec) |
这个例子中第二次查询给查出来了,原因在于update是当前读,执行update后生成了一个新的快照,而这个快照对事务A是可见的,所以给查出来了。
如果想第二次select查询结果跟第一次一致,还依赖间隙锁(Gap Lock),事务A的第一个
代码语言:javascript复制select * from users;
要显式加锁,即:
代码语言:javascript复制select * from users lock in share mode;
这样事务B在执行insert语句时会被阻塞住直到事务A提交。
那么什么是间隙锁呢?
5.3 Gap Lock
举个例子,age字段有普通索引,对于如下sql:
代码语言:javascript复制update users set name='n3' where age = 30;
不止会锁住30这一行记录,而且还会锁住两侧的区间(10,30]和(30,positive infinity)
( 表示包括这个, [ 表示不包括这个,间隙锁遵循前开后闭原则,就是说update … age=10,insert age=30的话是不会撞到锁的。
注意,如果age没有索引,那么会给所有行上一个Gap Lock!但是如果age为唯一索引,就只锁一行了。
5.4 Next-Key Lock
Record Lock与Gap Lock的结合,既锁住行也锁住索引之间的间隙。
参考资料
- https://dev.mysql.com/doc/refman/8.0/en/innodb-multi-versioning.html
- https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html
- https://segmentfault.com/a/1190000025156465
- https://tech.meituan.com/2014/08/20/innodb-lock.html
- https://blog.csdn.net/qq_43827142/article/details/109292564