目录
前言
MVCC实现原理
UndoLog版本链
ReadView
MVCC是否可以解决不可重复读与幻读
隔离级别
READ UNCOMMITTED - 读未提交与脏读
READ COMMITTED - 读已提交与不可重复读
REPEATABLE READ - 可重复读与幻读
SERIALIZABLE - 串行化
小结
前言
为了提高数据库并发能力,首先应该想到的就是多线程,但是多线程带来的线程安全问题又不得不考虑。通常采用加锁解决,但这个锁的粒度和锁策略是至关重要的。MVCC全称MultiVersioned ConcurrencyControl(多版本并发控制),MVCC使用了锁、UndoLog以及ReadView配合来完成这件事情。
MVCC实现原理
UndoLog版本链
MVCC的实现是基于 Undo Log 版本链和 ReadView 来完成的,Undo Log做为回滚的基础,在执行Update或Delete操作时,会将每次操作的上一个版本记录在Undo Log中,每条Undo Log中都记录⼀个叫做 roll_pointer 的引用信息,通过 roll_pointer 就可以将某条数据对应的Undo Log组织成⼀个Undo链,在数据行的头部通过数据行中的 roll_pointer 与Undo Log中的第⼀条日志进行关联,这样就构成一条完整的数据版本链。
每一条被修改的记录都会有一条版本链,体现了这条记录的所有变更,当有事务对这条数据进行修改时,将修改后的数据链接到版本链接的头部。
ReadView
每条数据的版本链都构造好之后,在查询时具体选择哪个版本呢?这⾥就需要使用 ReadView 结构来实现了,所谓 ReadView 是⼀个内存结构,顾名思义是⼀个视图,在事务使用 select 查询数据时就会构造⼀个ReadView,里面记录了该版本链的一些统计值,这样在后续查询处理时就不用遍历所有版本链了,这些统计值具体包括:
- m_ids :当前所有活跃事务的集合(活跃事务:未提交的事务)
- m_low_limit_id :活跃事务集合中最小事务Id
- m_up_limit_id :下⼀个将被分配的事务Id,也就是 版本链头的事务Id + 1
- m_creator_trx_id :创建当前 ReadView 的事务Id
构造好 ReadView 之后需要根据一定的查询规则找到唯一的可用版本,会在UndoLog版本连中找到select查询具体记录版本的链头,从链头开始遍历所有版本,根据四步查找规则,判断每个版本是否符合要求,如果某个版本符合要求则返回该版本数据。
- 第一步:判断该版本是否为当前事务创建,若 m_creator_trx_id 等于该版本事务id,意味着读取自己修改的数据,可以直接访问,如果不等则到第二步
- 第⼆步:若该版本事务Id < m_up_limit_id (最⼩事务Id),意味着该版本在ReadView⽣成之前已经提交,可以直接访问,如果不是则到第三步
- 第三步:若该版本事务Id >= m_low_limit_id (最大事务Id),意味着该版本在ReadView生成之后才创建,所以肯定不能被当前事务访问,因为该条记录无法判断是否提交,所以无需第四步判断,直接遍历下一个版本,如果不是则到第四步
- 第四步:若该版本事务Id在 m_up_limit_id (最小事务Id)和 m_low_limit_id (最大事务Id)之间,同时该版本不在活跃事务列表中,意味着创建ReadView时该版本已经提交,可以直接访问,如果不是则遍历并判断下一个版本
这样从版本链头遍历判断到版本链尾,找到⾸个符合要求的版本即可,就可以实现查询到的结果都是已经提交事务的数据,那么就可以解决脏读问题。
MVCC是否可以解决不可重复读与幻读
- ⾸先幻读无法通过MVCC单独解决,InnoDB在可重复读隔离级别下使用临建锁,锁住某条记录以及该记录之前的间隙,可以解决大部分幻读问题,但无法从根本上解决幻读问题
- 对于不可重复读问题,在事务中的第⼀个查询时创建⼀个ReadView,后续查询都是⽤这个ReadView进行判断,所以每次的查询结果都是一样的,从而解决不可重复读问题,在REPEATABLE READ 可重复读,隔离级别下就采用的这种方式
- 如果事务每次查询都创建⼀个新的ReadView,这样就会出现不可重复读问题,因为不同的ReadView中参数可能不一致,那么在UndoLog中找到的数据就可能不一致,在 READ COMMITTED 读已提交的隔离级别下就是这种实现方式
这些机制加上锁就可以实现MySQL的四种隔离性
隔离级别
READ UNCOMMITTED - 读未提交与脏读
实现方式
- 读取时:不加任何锁,直接读取版本链中的最新版本,也就是当前读,可能会出现脏读,不可重复读、幻读问题
- 更新时:加共享行锁(S锁),事务结束时释放,在数据修改完成之前,其他事务不能修改当前数据,但可以被其他事务读取
存在问题
事务的 READ UNCOMMITTED 隔离级别不使用独占锁,所以并发性能很高,但是会出现大量的数据安全问题。
比如在事务A中执行了一条 INSERT 语句,在没有执行 COMMIT 的情况下,会在事务B中被读取到,此时如果事务A执行回滚操作,那么事务B中读取到事务A写入的数据将没有意义,这个现象叫做 "脏读"。
注意:
由于 READ UNCOMMITTED 读未提交会出现"脏读"现象,在正常的业务中出现这种问题会产生非常危重后果,所以正常情况下应该避免使用 READ UNCOMMITTED 读未提交这种的隔离级别。
READ COMMITTED - 读已提交与不可重复读
实现方式
- 读取时:不加锁,但使⽤快照读,即按照 MVCC 机制读取符合 ReadView 要求的版本数据,每次查询都会构造一个新的 ReadView ,可以解决脏读,但无法解决不可重复读和幻读问题
- 更新时:加独占行锁(X),事务结束时释放,数据在修改完毕之前,其他事务不能修改也不能读取这行数据
存在问题
为了解决脏读问题,可以把事务的隔离级别设置为 READ COMMITTED ,这时事务只能读到了其他事务提交之后的数据,但会出现不可重复读的问题,核心原因就是每次快照读都会构造新的ReadView。
比如事务A先对某条数据进行了查询,之后事务B对这条数据进行了修改,并且提交( COMMIT )事务,事务A再对这条数据进行查询时,得到了事务B修改之后的结果,这导致了事务A在同一个事务中以相同的条件查询得到了不同的值,这个现象要"不可重复读"。
REPEATABLE READ - 可重复读与幻读
实现方式
- 读取时:不加锁,也使⽤快照读,按照MVCC机制读取符合ReadView要求的版本数据,但无论事务中有几次查询,只会在⾸次查询时生成一个ReadView,可以解决脏读、不可重复读,配合Next-Key行锁可以解决一部分幻读问题
- 更新时:加Next-Key行锁,事务结束时释放,在一个范围内的数据修改完成之前,其他事务不能对这个范围内的数据进行修改、插入和删除操作,同时也不能被查询
存在问题
事务的 REPEATABLE READ 隔离级别是会出现幻读问题的,在 InnoDB 中使用了Next-Key行锁来解决大部分场景下的幻读问题,那么在不加 Next-Key 行锁的情况下会出现什么问题吗?
使用 Next-Key 锁,锁住的是当前索引记录以及索引记录前面的间隙,那么在不加 NextKey 锁的情况下,也就是只对当前修改行加了独占行锁(X),这时记录前的间隙没有被锁定,其他的事务就可以向这个间隙中插入记录,就会导致一个问题如下:
比如事务A查询了一个区间的记录得到结果集A,事务B向这个区间的间隙中写入了⼀条记录,事务A再查询这个区间的结果集时会查到事务B新写入的记录得到结果集B,两次查询的结果集不一致,这个现象就是"幻读“ 。
SERIALIZABLE - 串行化
实现方式
- 读取时:加共享表锁,读取版本链中的最新版本,事务结束时释放
- 更新时:加独占表锁,事务结束时释放,完全串行操作,可以解决所有事务问题
存在问题
所有的更新都是串行操作,效率极低
小结
当理解了不同隔离级别下实现原理与所存在的问题时,可以修改数据库隔离级别进行问题重现,理论与实操结合,相信你会有很大收获。
这里提供一些可能会用到的命令
# 开启事务
START TRANSACTION;
# 提交事务
commit;
# 回滚事务
rollback;
# 查看全局作用域隔离级别
SELECT @@GLOBAL.transaction_isolation;
# 查看会话作用域隔离级别
SELECT @@SESSION.transaction_isolation;
# 通过GLOBAL|SESSION分别指定不同作用域下隔离级别
SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL level|access_mode;
# 隔离级别level:
REPEATABLE READ # 可重复读
READ COMMITTED # 读已提交
READ UNCOMMITTED # 读未提交
SERIALIZABLE # 串⾏化
# 访问模式access_mode:
READ WRITE # 表⽰事务可以对数据进⾏读写
READ ONLY # 表⽰事务是只读,不能对数据进⾏读写