多版本并发控制(MVCC)
前置知识:事务
并发问题
脏读
事务A中读取到事务B中未提交的数据。具体来说,在t1时刻,事务A中读取到事务B中未提交的数据,并将该数据用于计算得到一个结果值;而在t2时刻,事务B执行回滚,那么事务A中得到的结果值就没有任何意义,这种现象就称为脏读。

# 脏读问题演示
# 事务1: 设置全局的隔离级别为读未提交, 设置完成后重新登录MySQL
SET GLOBAL TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
# 检查隔离级别
SELECT @@TRANSACTION_ISOLATION;
# 事务1: 修改一条数据
BEGIN;
UPDATE `user` SET name = 'root' WHERE id = 1;
# 事务2: 查看数据
BEGIN;
SELECT * FROM `user` WHERE id = 1;
# 此时能够观测到事务2中读取到事务1中的修改, 但是事务1并未提交
# 将隔离级别改回默认级别, 重启控制台
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;
# 检查隔离级别
SELECT @@TRANSACTION_ISOLATION;
不可重复读
事务A在一次事务的执行过程中,两次读取结果不相同。具体来说,在t1时刻,事务A读取数据x1;在t2时刻,事务B将数据修改为x2并提交事务;在t3时刻,事务A再次读取数据得到x2;站在事务A的角度,可能在t1时刻到t3时刻并没有对数据进行修改,却得到两个不同的值(都是正确值),这种现象称为不可重复读。
幻读
解决了不可重复读的问题,即在一次事务中,可以保证对数据的两次读取的结果是相同的。但是会出现幻读的问题。幻读问题是指事务A在查询时明明没有查到该数据,但是却无法插入成功。具体来说,在t1时刻,事务A查询是否有id=1的数据,发现没有;在t2时刻,事务B插入一条id=1的数据并提交;在t3时刻,事务A再次查询是否有id=1的数据,还是发现没有(因为可重复读),因此事务A打算插入一条id=1的数据,但是此时插入失败并报错,这时事务A就会纳闷,明明查询id=1的结果是没有,但是插入又报错说id=1的数据已经存在,这种现象称为幻读。
# 事务1: 查看数据
BEGIN;
SELECT * FROM `user`;
# 事务2: 查看数据, 新增数据, 并提交事务
BEGIN;
SELECT * FROM `user`;
# 注: insert操作会加锁, 如果在事务1未提交前, 事务2也执行了insert操作, 那么事务2会被阻塞
INSERT INTO `user`(id, name) VALUES(7, 'why2');
COMMIT;
# 事务1: 再次查看数据
# 在RR级别下, 由于可重复读机制, 因此事务1看不到事务2提交的数据, 但是插入失败
# 在RC级别下, 可以看到新插入的数据, 因此行数更多(幻读)
SELECT * FROM `user`;
# 事务1: 尝试插入相同的数据, 此时报错(这里演示RR级别下的错误, RC级别下能看到有id=7的数据)
INSERT INTO `user`(id, name) VALUES(7, 'why1');
# 错误: ERROR 1062 (23000): Duplicate entry '7' for key 'user.PRIMARY'
隔离级别
三个问题,对应四种方案,分别是解决0个问题(读未提交)、解决1个问题(读已提交)、解决2个问题(可重复读)、解决3个问题(串行化)。
串行是一切并行问题的终点,没有并行就不会有并行导致的伴生问题。
Oracle 数据库的默认隔离级别是 RC,MySQL 数据库的默认隔离级别是 RR。
隔离级别之间的关系
可重复读并不是建立在读已提交的基础上,相反,这些隔离级别是互斥的。想要实现可重复读,那么必然会丢弃读已提交的性质。想要实现读已提交的数据,那么就没办法实现可重复读的性质。
操作指令
# 查看内置的变量值
SELECT @@autocommit;
# 开启事务: 方式一
SET @@autocommit=0;
# 开启事务: 方式二
BEGIN;
# 提交事务
COMMIT;
# 回滚事务
ROLLBACK;
# 查看事务的隔离级别
SELECT @@TRANSACTION_ISOLATION;
# 设置事务的隔离级别
# 需要退出当前会话窗口
SET [SESSION|GLOBAL] TRANSACTION ISOLATION LEVEL {READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE}
MVCC 是什么
并发可能遇到三种场景:读-读、读-写、写-写
对于读-读场景,不存在并发问题;而对于后面两种场景,则存在并发问题,这些并发问题都可以通过加锁来解决。但是对于读-写冲突的场景,存在着比加锁性能更高的解决方案,即 MVCC(多版本并发控制)。因此,MVCC 是针对并发场景下的读写冲突问题,对于读操作不采用加锁的一种优化方案。对于写操作还是需要采用加锁。
MVCC 的实现原理
MySQL 中的 MVCC 是通过 4个隐藏字段、undo 日志和 read view 的共同作用实现。在读操作时,生成一个read view
隐藏字段
- db_row_id:自增的隐藏主键,如果没有表结构没有指定主键,则会生成该字段
- db_trx_id:最近修改的事务id,记录该条记录最近一次被哪个事务修改
- db_roll_ptr:上一条记录的地址,本质上是next指针
- deleted_bit:逻辑删除标志位
undo log
undo log 用于记录数据被修改前的信息,用于事务回滚或MVCC(多版本并发控制)。
与 redo log 记录实际执行的物理操作不同,undo log 并不是记录实际执行的数据操作,甚至是记录与当前操作相反的操作(逆操作),例如当前实际执行的是delete 操作,那 undo log 中需要对该操作进行还原,因此记录 insert 操作。
-
insert undo log:保存插入记录的主键值,回滚时只需要删除该主键对应的记录即可
-
update undo log:保存记录的旧值,回滚时将记录更新回旧值
-
delete undo log:理论上将记录的旧值保存下来,回滚的时候重新插入记录即可(主键变化怎么办呢?)。但实际为了效率并没有这么处理。
设置删除标志位(隐藏字段),InnoDB 引擎通过专门的 purge 线程来清理这些删除标志位被设置true的记录。这种情况下如何实现回滚呢?
undo log 在事务提交之后,并不会立即删除,因为这些日志还可能用于 MVCC。undo log 采用 segment(段)的方式进行管理和记录,存放在 rollback segment (回滚段)中,内部包含 1024 个 rollback segment。这里是什么的内部?
简洁点来说,undo log 日志应该体现 insert、update、delete 操作,但是 insert 插入的是新数据,那么该行数据对应的 undo log 版本链就只有一个结点,因此改行数据没有 undo log。
read view(读视图)
ReadView的生成原理(快照读的原理)
版本链数据访问规则:
快照读(snapshot read):
- 在 RC(read committed)级别下,每次 snapshot read 都会生成 read view
- 在 RR(repeatable read)级别下,事务内的第一次snapshot read会生成read view,后面的snapshot read都会读取第一次read view中的数据,从而实现repeatable read。
当前读(current read):加锁读取当前数据库中保存的数据。