概叙
科普文:软件架构数据库系列之【MySQL存储引擎InnoDB】-CSDN博客
前面说Innodb时,只是提到了MVCC,今天我们再详细说说MVCC。
说MVCC前,我们先大概了解一下这几个概念。
事务(Transaction)
事务(Transaction)在计算机术语中指的是访问并可能更新数据库中各种数据项的一个程序执行单元。
事务通常由高级数据库操纵语言或编程语言(如SQL、C++或Java)书写的用户程序的执行所引起,并用形如begin transaction
和end transaction
的语句(或函数调用)来界定。
事务由事务开始(begin transaction
)和事务结束(end transaction
)之间执行的全体操作组成。
事务是恢复和并发控制的基本单位,应该具有四个基本属性,通常称为ACID特性,即原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。
- 原子性确保事务是一个不可分割的工作单位,事务中的操作要么全部完成,要么全部不完成。
- 一致性保证事务必须使数据库从一个一致的状态变换到另一个一致的状态。
- 隔离性规定了多个事务并发执行时,一个事务的执行不应影响其他事务。
- 持久性意味着一旦事务完成,无论系统发生什么故障,其修改的数据都应保持一致状态。
事务隔离级别
事务隔离级别是数据库系统中用于控制并发事务之间相互影响的一种机制,以确保数据的一致性和可靠性。
SQL标准定义了四种事务隔离级别,分别是:读未提交(Read Uncommitted)、读已提交(Read Committed)、可重复读(Repeatable Read)和串行化(Serializable)。
这些级别通过控制事务对数据的访问和修改,来避免并发访问时可能出现的几种问题:脏读、不可重复读和幻读。
- 读未提交(Read Uncommitted):这是最低的事务隔离级别,允许一个事务读取另一个事务未提交的数据。这种隔离级别下,并发性最高,但由于可能读取到未提交的数据,可能导致脏读问题。脏读是指一个事务读取到了另一个事务未提交的数据,如果另一个事务后来回滚,那么之前读取的数据就是无效的。
- 读已提交(Read Committed): 这是Oracle的默认隔离级别。在这个隔离级别下,一个事务只能读取已经提交的数据,避免了脏读问题。但是,由于其他事务可能在此期间修改数据并提交,同一个事务在不同时间读取同一数据可能会得到不同的结果,导致不可重复读问题。
- 可重复读(Repeatable Read):这是MySQL的默认隔离级别。在这个级别下,事务在执行期间多次读取同一数据总是得到相同的结果,避免了不可重复读问题。然而,它仍然允许其他事务插入新数据,可能导致幻读问题。
- 串行化(Serializable):这是最高的事务隔离级别,通过强制事务串行执行来避免脏读、不可重复读和幻读问题。但这种级别的并发性能最低,因为每个事务都需要等待前一个事务完成。
以上四种隔离级别,由上往下隔离强度越来越大,但是执行效率会随之降低。在设置隔离级别时候,需要在隔离级别
和执行效率
两者做平衡取舍。
并发事务带来的问题
先了解几个基本概念:
脏读:事务A
修改数据,事务B
读取了数据后事务A
报错回滚,修改的数据没有提交到数据库中,此时事务B
读取修改的数据就是一个脏读,也就是一个事务读取到另一个事务未提交的数据就是脏读。
不可重复读:事务A
在同一个事务上多次读取同一个数据,在事务A
还没有结束时,事务B
修改了该数据,由于事务B
的修改,导致事务A
两次读取的数据不一致,就出现了不可以重复读的现象。
幻读:事务A
根据条件查询得到N
条数据,但此时事务B
更改或者增加了M
条符合事务A
查询的条件的数据。这样当事务A
再次查询的时候发现会有N + M
条数据,产生了幻读。
几种隔离级别可能会有脏读
、不可重复读
或者幻读
的问题,它们之间的关系如下:
-
读未提交
:可能会出现脏读
、不可重复读
、幻读
,读取未提交事务的数据,数据撤回了,就是一种脏读
。如果其他事务修改同一个数据,事务读取的数据也是不同的,所以也存在不可重复读
。同时也能读取到其他事务添加的数据,所以也存在幻读
。 -
读已提交
:该隔离级别只能读取到其他事务提交后的数据,所以不存在脏读
。但是在第一次读取数据后,其他事务修改后数据并提交事务,此时事务读取到数据就和第一次读到的数据不一致了,也就存在不可重复读
。同时其他事务可以添加多条数据,也存在幻读
。 -
可重复度读
:表示整个事务看到的事务和开启后的事务能看到的数据是一致的,既然数据是一致的,所以不存在不可重复读
。而且不会读取其他事务修改的数据,也就是不存在脏读
。而对同一个批
数据,可能会存在添加的情况,所以可能会存在幻读的情况。 -
窜行化
:当发生读写锁冲突时,后面的事务要等前面的事务执行完毕之后再执行,所以一定是先读或者先写的执行完毕之后再执行后读或者写,读写按照顺序依次进行,所以不存在脏读
、不存在不可重复读
、也不存在幻读
。
多版本并发控制 MVCC: Multi-Version Concurrency Control
MySQL的大多数事务型存储引擎使用的都不是简单的行级锁机制。它们会将行级锁和可以提高并发性能的多版本并发控制(MVCC)技术结合使用。
不仅是MySQL,包括Oracle、PostgreSQL以及其他一些数据库系统也都使用了MVCC,但各自的实现机制不尽相同,因为MVCC如何工作没有统一的标准。
可以认为MVCC是行级锁的一个变种,但是它在很多情况下避免了加锁操作,因此开销更低。根据其实现方式,不仅实现了非阻塞的读操作,写操作也只锁定必要的行。
MVCC的工作原理是使用数据在某个时间点的快照来实现的。这意味着,无论事务运行多长时间,都可以看到数据的一致视图,也意味着不同的事务可以在同一时间看到同一张表中的不同数据。每个存储引擎实现MVCC的方式都不同。其中一些变体包括乐观并发控制和悲观并发控制。我们可以通过图3所示的序列图解释InnoDB的行为,以此来展示MVCC的一种实现方式。
InnoDB通过为每个事务在启动时分配一个事务ID来实现MVCC。该ID在事务首次读取任何数据时分配。在该事务中修改记录时,将向Undo日志写入一条说明如何恢复该更改的Undo记录,并且事务的回滚指针指向该Undo日志记录。这就是事务如何在需要时执行回滚的方法。当不同的会话读取聚簇主键索引记录时,InnoDB会将该记录的事务ID与该会话的读取视图进行比较。如果当前状态下的记录不应可见(更改它的事务尚未提交),那么Undo日志记录将被跟踪并应用,直到会话达到一个符合可见条件的事务ID。这个过程可以一直循环到完全删除这一行的Undo记录,然后向读取视图发出这一行不存在的信号。
事务中的记录可以通过在记录的“info flags”中设置“deleted”位来删除。这在Undo日志中也被作为“删除标记”进行跟踪。值得注意的是,所有Undo日志写入也都会写入Redo日志,因为Undo日志写入是服务器崩溃恢复过程的一部分,并且是事务性的。这些Redo日志和Undo日志的大小也是高并发事务工作机制中的重要影响因素。
在记录中保留这些额外信息带来的结果是,大多数读取查询都不再需要获取锁。它们只是尽可能快地读取数据,确保仅查询符合条件的行即可。缺点是存储引擎必须在每一行中存储更多的数据,在检查行时需要做更多的工作,并处理一些额外的内部操作。
Innodb MVCC原理
MVCC
是通过保存数据在某个时间点的快照来实现的。
MySQL中MVCC主要是通过行记录中的隐藏字段(隐藏主键 row_id、事务ID trx_id、回滚指针 roll_pointer)、undo log(版本链)、ReadView(一致性读视图)来实现的。
InnoDB
实现的MVCC
,是通过在每行记录后面保存两个隐藏列来实现,一个是保存行的创建时间,另一个是保存行的过期时间。当然存储的不是时间,而是系统版本号
。每开启一个新的事务,系统版本号先自动递增,该系统版本号会作为事务的版本号,用来和查询到的每行记录的版本号做比较。比如在可重复读
隔离级别下,MVCC
是如何操作的:
- SELECT
InnoDB
会根据以下两个条件检查每行记录:InnoDB
只查找版本号早于当前事务的数据行(系统版本号小于或者等于事务的系统版本号),这样可以确保事务读取的行,要么是在事务开始前就存在,要么是事务自身插入或者更新过。- 行的删除版本要么未定义,要么大于当前事务版本号。这可以确保事务读取到的行,在事务开始之前未被删除。
- 只有符合上述两个条件的记录,才能返回作为查询的结果。
- INSERT
InnoDB
为新插入的每一行保存当前系统版本号作为行版本号。
- DELETE
InnoDB
为删除的每一行保存当前系统版本号作为行删除的标识。
- UPDATE
InnoDB
为插入一行新记录,保存当前系统版本号作为行版本号,同时保存当前系统版本号到原来的行作为行删除标识。
保存着两个额外的系统版本号,大多数读操作都可以不用加锁
。这样设计是的读数据的操作很简单,性能很好,并且也能保证只会读取到符合标准的行。不足之处是每行记录都需要额外的存储空间,需要做更多的行检查工作,以及一些额外的维护工作。
MVCC
只在读已提交
和可重复读
两个隔离级别下生效。其他两个隔离级别下MVCC都不能生效,因为读未提交
总是读取到最新的数据行,无需记录当前事务版本号。而串行化
会对所有的读写都会进行加锁,先读、先写
的先执行,后读
、后写
的后执行。也不需要记录记录版本号精心比对。
InnoDB
的行数据有多个版本,每个数据版本都有自己的row trx_id
,每个事务或者语句都有自己的一致性视图。查询语句是一致性读,一致性读会根据row trx_id
和一致性视图确定数据版本的可见性。
- 可重复读,只查询在事务启动前就提交完成的数据。
- 读已提交,只查询语句启动前其他事务提交的数据。
MySQL
采用了MVVC
(多版本并发控制)解决读已提交
、可重复读
隔离问题。
- 执行一条
SQL
语句,都会保存两个隐藏的列。一个是保存创建时间,一个保存过期时间,储存的系统版本号
。 - 每次开启一个事务都会系统会递增一个
系统版本号
,作为事务的版本号。select
,查询早于当前事务的数据。insert
添加版本号。delete
为删除的行把版本号作为删除标识。update
,先插入一条数据,保存当前系统版本号。同时保存原来的行作为行删除标志。
1、隐藏字段
MySQL中,在每一行记录中除了自定义的字段,还有一些隐藏字段:
row_id
:当数据库表没定义主键时,InnoDB会以row_id为主键生成一个聚集索引。
trx_id
:事务ID记录了新增/最近修改这条记录的事务id,事务id是自增的。
roll_pointer
:回滚指针指向当前记录的上一个版本(在 undo log 中)。
2、版本链
简单提下 redo log 和 undo log。在修改数据的时候,会向 redo log 中记录修改的页内容(为了在数据库宕机重启后恢复对数据库的操作),也会向 undo log 记录数据原来的快照(用于回滚事务)。undo log有两个作用,除了用于回滚事务,还用于实现MVCC。
3、ReadView
在上面的例子中,多个事务对 id=1 的数据修改后,这行记录除了最新的数据,在 undo log 中还有多个版本的快照。那其他事务查询时能查到最新版本的数据吗?如果不能,能读到哪个版本的快照呢?这就要由ReadView来决定了。
ReadView 就是MVCC在对数据进行快照读时,会产生的一个”读视图“(翻译过来就是ReadView~哈哈哈)。
ReadView中有4个比较重要的变量(具体这几个变量名是啥我也不知道,不过不要在意这些细节,这里就随便定义一下……):
m_ids
:活跃事务id列表,当前系统中所有活跃的(也就是没提交的)事务的事务id列表。
min_trx_id
:m_ids 中最小的事务id。
max_trx_id
:生成 ReadView 时,系统应该分配给下一个事务的id(注意不是 m_ids 中最大的事务id),也就是m_ids 中的最大事务id + 1 。
creator_trx_id
:生成该 ReadView 的事务的事务id。
某个事务进行快照读时可以读到哪个版本的数据,ReadView 有一套规则:
- (1)当【版本链中记录的 trx_id 等于当前事务id(trx_id = creator_trx_id)】时,说明版本链中的这个版本是当前事务修改的,所以该快照记录对当前事务可见。
- (2)当【版本链中记录的 trx_id 小于活跃事务的最小id(trx_id < min_trx_id)】时,说明版本链中的这条记录已经提交了,所以该快照记录对当前事务可见。
- (3)当【版本链中记录的 trx_id 大于下一个要分配的事务id(trx_id > max_trx_id)】时,该快照记录对当前事务不可见。
- (4)当【版本链中记录的 trx_id 大于等于最小活跃事务id】且【版本链中记录的trx_id小于下一个要分配的事务id】(min_trx_id<= trx_id < max_trx_id)时,如果版本链中记录的 trx_id 在活跃事务id列表 m_ids 中,说明生成 ReadView 时,修改记录的事务还没提交,所以该快照记录对当前事务不可见;否则该快照记录对当前事务可见。
当事务对 id=1 的记录进行快照读时select * from myuser where id=1
,在版本链的快照中,从最新的一条记录开始,依次判断这4个条件,直到某一版本的快照对当前事务可见,否则继续比较上一个版本的记录。
MVCC主要是用来解决RU隔离级别下的脏读和RC隔离级别下的不可重复读的问题,所以MVCC只在RC(解决脏读)和RR(解决不可重复读)隔离级别下生效,也就是MySQL只会在RC和RR隔离级别下的快照读时才会生成ReadView。
区别就是,
- 在RC隔离级别下,每一次快照读都会生成一个最新的ReadView;
- 在RR隔离级别下,只有事务中第一次快照读会生成ReadView,之后的快照读都使用第一次生成的ReadView。
还是有点抽象?那就手动来亲自验证一下,之后就会清晰很多。(如果想要真正理解上面的算法,建议最好找个例子,亲自验证一下)
验证Innodb的MVCC特性
验证前的环境准备,起了两个mysqld服务,3306对应RR隔离级别,3307对应RC隔离级别。
3306-RR
3307-RC
建innodb表
mysql> show create table myuser\G
*************************** 1. row ***************************
Table: myuser
Create Table: CREATE TABLE `myuser` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`uname` varchar(64) NOT NULL COMMENT '姓名',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户表'
1 row in set (0.00 sec)
mysql>
mysql> insert into myuser(id,uname)values(1,"zhouxx");
Query OK, 1 row affected (0.02 sec)
mysql>
事务隔离级别RC验证MVCC
换一下更直接看
在t1时刻,开启了三个事务,假设事务编号分别也是:21,22,23;
t2时刻
在t2时刻,产生三个ReadView及其版本链如下
事务1-t2-ReadView1
- trx_list:21,22,23
- mid_trx_id:21
- max_trx_id:24
- creator_trx_id:21
- trx_id:20
- rollpointer:
事务2-t2-ReadView2
- trx_list:21,22,23
- mid_trx_id:21
- max_trx_id:24
- creator_trx_id:22
- trx_id:20
- rollpointer:
事务3-t2-ReadView3
- trx_list:21,22,23
- mid_trx_id:21
- max_trx_id:24
- creator_trx_id:23
- trx_id:20
- rollpointer:
t2时刻,没有修改表中数据,所以此时刻,读取的数据都是同一个快照,读取的内容也是一样的。因为此时参考的是ReadView规则第二条“(2)当【版本链中记录的 trx_id 小于活跃事务的最小id(trx_id < min_trx_id)】时,说明版本链中的这条记录已经提交了,所以该快照记录对当前事务可见。”
t3时刻和t4时刻
t3时刻,事务2修改了数据,未提交,t4时刻产生三个ReadView及其版本链如下
事务1-t4-ReadView1
- trx_list:21,22,23,24,25
- mid_trx_id:21
- max_trx_id:26
- creator_trx_id:21
- trx_id:20
- rollpointer:(trx_id=25 ->trx_id=24 -> trx_id=20)
事务2-t4-ReadView2
- trx_list:21,22,23,24,25
- mid_trx_id:21
- max_trx_id:26
- creator_trx_id:24
- trx_id:24
- rollpointer:(trx_id=25 ->trx_id=24 -> trx_id=20)
事务3-t4-ReadView3(当前会话被阻塞、无法进行查询操作)
- trx_list:21,22,23,24,25
- mid_trx_id:21
- max_trx_id:26
- creator_trx_id:25
- trx_id:25
- rollpointer:(trx_id=25 ->trx_id=24 -> trx_id=20)
当前时间点,版本链中有三个快照(trx_id=25 ->trx_id=24 -> trx_id=20),从版本链中的快照中,从最新的开始,依次判断:
对于trx_id=24的快照,根据算法的第(1)条规则“(1)当【版本链中记录的 trx_id 等于当前事务id(trx_id = creator_trx_id)】时,说明版本链中的这个版本是当前事务修改的,所以该快照记录对当前事务可见。”,trx_id=24的快照对当前事务不可见。这也就验证了在RC隔离级别下,事务24修改但未提交的数据对于事务21应该不可见。
对于trx_id=21的快照,因为 trx_id(20)< min_trx_id(21),符合算法的第(2)条规则,所以trx_id=20的这个快照对当前事务可见。
t6时刻
t6时刻,事务2中的事务提交、事务3中被阻塞的update语句执行成功。产生的readView如下:
事务1-t4-ReadView1
- trx_list:21,25
- mid_trx_id:21
- max_trx_id:26
- creator_trx_id:21
- trx_id:20
- rollpointer:(trx_id=25 ->trx_id=24 -> trx_id=20)
事务2-t4-ReadView2
- trx_list:21,25
- mid_trx_id:21
- max_trx_id:26
- creator_trx_id:24
- trx_id:24
- rollpointer:(trx_id=25 ->trx_id=24 -> trx_id=20)
事务3-t4-ReadView3(当前会话阻塞结束)
- trx_list:21,25
- mid_trx_id:21
- max_trx_id:26
- creator_trx_id:25
- trx_id:25
- rollpointer:(trx_id=25 ->trx_id=24 -> trx_id=20)
t7时刻
t7时刻是三个select查询语句,未作修改操作,此时的Readview和t6时刻一样
当前时间点,版本链中有三个快照(trx_id=25 ->trx_id=24 -> trx_id=20),从版本链中的快照中,从最新的开始,依次判断:
对于trx_id=25的快照,因为 max_trx_id(26)>trx_id(25)>=min_trx_id(21),符合算法的第(4)条规则"(4)当【版本链中记录的 trx_id 大于等于最小活跃事务id】且【版本链中记录的trx_id小于下一个要分配的事务id】(min_trx_id<= trx_id < max_trx_id)时,如果版本链中记录的 trx_id 在活跃事务id列表 m_ids 中,说明生成 ReadView 时,修改记录的事务还没提交,所以该快照记录对当前事务不可见;否则该快照记录对当前事务可见。",所以trx_id=24的这个快照对当前事务不可见。
对于trx_id=21和trx_id=24的快照,trx_id=24对其均可见。
t9时刻
事务3中事务提交,此时只剩下事务1一个事务未提交。产生的readView如下:
事务1-t4-ReadView1
- trx_list:21
- mid_trx_id:21
- max_trx_id:26
- creator_trx_id:21
- trx_id:20
- rollpointer:(trx_id=25 ->trx_id=24 -> trx_id=20)
事务2-t4-ReadView2
- trx_list:21
- mid_trx_id:21
- max_trx_id:26
- creator_trx_id:24
- trx_id:24
- rollpointer:(trx_id=25 ->trx_id=24 -> trx_id=20)
事务3-t4-ReadView3(当前会话阻塞结束)
- trx_list:21
- mid_trx_id:21
- max_trx_id:26
- creator_trx_id:25
- trx_id:25
- rollpointer:(trx_id=25 ->trx_id=24 -> trx_id=20)
事务隔离级别RR验证MVCC
有兴趣的可以对着来操作一遍
小结
对于MySQL的个隔离级别:建议在RC和RR两个隔离级别中选一种,
如果能接受幻读,需要并发高点,就可以配置成RC:
如果不能接受幻读的情况,就设置成RR隔离级别。