事务隔离级别
我们知道,MySQL是一个客户端/服务端架构的软件。对于同一个服务器来说,可以有多个客户端与之连接。每个客户端与服务端建立连接之后,就形成了一个会话。每个客户端都可以在自己的会话中向服务器发送请求语句,一个请求语句可能是某个事务的一部分。服务器可以同时处理来自多个客户端的多个事务。
一个事务就对应着现实世界的一次状态转换。事务执行之后必须保证数据符合现实世界的所有规则,这就是我们强调的一致性。数据库管理系统提供的一系列约束,比方说主键、唯一索引、外键、声明某个列不允许插入NULL值等可以帮助我们解决一部分一致性需求。但是这对于现实世界的所有规则来说,无异于杯水车薪,更多的一致性需求需要我们程序员人为地保证。数据库管理系统通过redo日志、undo日志这些手段来保证事务的原子性。程序员只要将现实世界的状态转换所对应的数据库操作都写到一个事务中,那么该事务执行完成后,必然从一个一致性状态转移到下一个一致性状态。(原子性保证即使事务执行失败,也只会返回到最初的一致性状态)。我们在18章中举了一个转账的例子。狗哥向猫爷转账5元钱就是现实世界的一次状态转换,当时我们粗略地将这次状态转换对应到下面这几个操作。
1、读取狗哥账户的余额到变量A中;简写为read(A)。大家可以把这个过程对应到一条SELECT语句,将读取到的结果存储到变量A中。
2、将狗哥账户的余额减去转账金额;简写为A = A - 5。大家可以把这个过程理解为在我们的用户程序中将变量A的值减5。
3、将狗哥账户修改过的余额写到磁盘中;简写为write(A)。大家可以把这个过程对应到一条update语句。
4、读取猫爷账户的余额到变量B;简写为read(B)。大家可以把这个过程对应到一条SELECT语句,将读取到的结果存储到变量B。
5、将猫爷账户的余额加上转账金额;简写为B = B + 5。大家可以把这个过程理解为在我们的用户程序中将变量B的值加5。
6、将猫爷账户修改过的余额写到磁盘中;简写为write(B)。大家可以把这个过程对应到一条UPDATE语句。
由于我们已经介绍过redo日志了,所以其实write(A)、write(B)操作没必要一定要将修改过的余额写到磁盘中。写到内存中的页面就可以了。
在这个转账事务中,我们必须保证参与转账的账户的总余额保持不变,这也就是这个转账事务的一致性需求。程序员只要把上述步骤都放在一个事务中执行,在事务的原子性的保护下,这些操作执行完肯定是能满足一致性需求。
如果事务是以单个的形式一个接一个地执行,那么在一个事务开始时,面对的就是上一个事务执行结束后留下的一致性状态,它执行之后又会产生下一个一致性状态。在多个事务并发执行时,情况就变得比较复杂了。如果这些并发执行的事务不会访问相同的数据,比方说在狗哥给猫爷转账的事务和张三给李四转账的事务并发执行时,由于这两个事务并不会访问相同的账户,所以它们并发执行不会带来什么一致性问题。也就是说最终的一致性需求是可以保证的。但是如果并发执行的事务会访问相同的数据,就可能导致不能满足一致性需求。我们也举了一个例子。狗哥一开始有11元,猫爷有2元,他们的账户总余额为13元。狗哥向猫爷同时进行两次转账,这两次转账对应的事务分别命名为T1和T2中的各个步骤的执行顺序如下图所示,那么就会引发一致性问题。
如果按照上图的执行顺序来进行两次转账,最终狗哥的账户里还剩6元钱,相当于只扣了5元钱,但是猫爷的账户里却成了12元钱,相当于多了10元钱。它们的账户总余额变为了18元。这显然违背了参与转账的账户的总余额保持不变的一致性需求。
这就要求我们使用某种手段强制让这些事务按照顺序一个一个单独地执行,或者最终执行结果和单独执行一样。也就是说我们希望让这些事务隔离地执行。互不干涉。这也就是事务的隔离性。
实现这个隔离性的最粗暴的方式就是在系统中的同一时刻最多只允许一个事务运行。其他的事务只能在该事务执行完之后,才可以运行。我们把这种多个事务的执行方式称为串行执行。但是串行执行太严格了,会严重降低系统吞吐量和资源利用率,会增加事务的等待时间。这样不太好。并发事务之所以可能影响一致性,是因为它们在执行过程中可能访问相同的数据。我们可以更人性化一点,比方说在某个事务访问某个数据时,对要求其他视图访问相同数据的事务进行限制,让他们进行排队,当该事务提交之后,其他事物才能继续访问这个数据,这样就可以让并发执行的事务的执行结果一样,我们把这种多个事务的执行方式称为可串行化执行。
两个并发的事务在执行过程中访问相同数据的情况有读-读情况(也就是两个事务对该数据都进行读操作)、读-写情况(也就是一个事务对该数据进行读操作,另一个事务对该数据进行写操作)、写-读情况(也就是一个事务对该数据进行写操作,另一个事务对该数据进行读操作)、写-写情况(也就是两个事务对该数据都进行写操作)。如果读-读操作的话,由于单纯的读操作并不会影响数据状态,所以读-读操作并不会带来一致性问题。只有在至少一个事务对数据进行写操作时(也就是读-写情况、写-读情况和写-写情况),才可能带来一致性问题。所以我们在实现多个事务的可串行化执行的时候,仅需要在多个事务对相同数据访问的是读-写情况、写-读情况、写-写情况时,对其进行排队即可。
事务并发执行时遇到的一些一致性问题
-
脏写(Dirty Write):如果一个事务修改了另一个未提交事务修改过的数据,就意味着发生了脏写现象。
如果一个事务修改了另一个未提交事务修改过的数据,就意味着发生了脏写现象。我们可以把脏写现象简称为P0。假设现在事务T1和T2并发执行,它们都要访问数据项X(这里可以将数据项X当作一条记录的某个字段)。那么P0对应的操作执行序列如下所示:
P0:w1[x]…w2[x]…((c1 or a1) and (c2 or a2) in any order)
其中w1[x]表示事务T1修改了数据项X的值,w2[x]表示事务T2修改了数据项x的值,c1表示事务T1的提交(commit),a1表示事务T1的中止(Abort),c2表示事务T2的提交,a2表示事务T2的中止,。。。表示其他的一些操作。从P0的操作执行序列中可以看出,事务T2修改了未提交事务T1修改过的数据,所以发生了脏写现象。
脏写现象可能引发一致性问题。比方说事务T1和T2要修改x和y这两个数据项(修改不同的数据项就相当于修改不同记录的字段),我们的一致性需求就是让x的值和y的值始终相同。现在并发执行事务T1和T2,它们的操作执行序列如下所示:
w1[x=1]w2[x=2]w2[y=2]c2w1[y=1]c1
很显然事务T2修改了尚未提交的事务T1的数据项x,此时发生了脏写现象。如果我们允许脏写现象的发生,那么在T1和T2全部提交之后,x的值是2,而y的值却是1,不符合x的值和y的值始终相同的一致性需求。
另外,脏写现象也可能破坏原子性和持久性。比方说有x和y这两个数据项,它们初始的值都是0,两个并发执行的事务T1和T2有下面的操作执行序列:
w1[x=2]w2[x=3]w2[y=3]c2a1
也就是T1先修改了数据项x,然后T2修改了数据项x和数据项y,然后T2提交,最后T1中止。现在的问题是T1中止时,需要将它对数据库所做的修改回滚到该事务开启时的样子,也就是将数据项x的值修改为0。但是此时T2已经修改过数据项x并且提交了,如果要将T1回滚的话,相当于要对T2对数据库所做的修改进行部分回滚(部分回滚是指T2只回滚对x做的修改,而不回滚对y所做的修改),这就影响了事务的原子性。如果要将T2对数据库所做的修改全部回滚的话,那么明明T2已经提交了,它对数据库所做的修改应该具有持久性,怎么能让一个未提交的事务将T2的持久性破坏掉呢? -
脏读(Dirty Read):如果一个事务读到了另一个未提交的事务修改过的数据,就意味着发成了脏读现象。
我们可以把脏读现象简称为P1.假设现在事务T1和T2并发执行,它们都要访问数据项x。那么P1对应的操作执行序列如下所示:
P1:w1[x]…r2[x]…((c1 or a1) and (c2 or a2) in any order)
脏读现象也可能引发一致性问题。比方说事务T1和T2中要访问x和y这两个数据项,我们的一致性需求就是让x的值和y的值始终相同,x和y的初始值都是0。现在并发执行事务T1和T2,它们的操作执行序列如下所示:
w1[x=1]r2[x=1]r2[y=0]c2w1[y=1]c1
很显然T2是一个只读事务,依次读取x和y的值。可能由于T2读取的数据项x是未提交事务T1修改过的值,所以导致最后读取x的值为1,y的值为0。虽然最终数据库状态还是一致的(最终变为了x=1,y=1),但是T2却得到了一个不一致的状态。数据库的不一致状态是不应该暴露给用户的。
P1代表的事务的操作执行序列其实是一种脏读的广义解释,针对脏读还有一种严格解释。为了与广义解释进行区分,我们把脏读的严格解释称为A1,A1对应的操作执行序列如下所示:
A1:w1[x]…r2[x]…(a1 and c2 in any order)
也就是T1先修改了数据项x的值,然后T2又读取到了未提交的事务T1针对数据项x修改后的值,之后T1中止而T2提交。这就意味着T2读到了一个根本不存在的值,这也是脏读的严格解释。很显然脏读的广义解释是覆盖严格解释包含的范围的。 -
不可重复读(Non-Repeatable Read):如果一个事务修改了另一个未提交事务读取的数据,就意味着发生了不可重复读现象。
我们可以把不可重复读现象简称为P2。假设现在事务T1和T2并发执行,它们都要访问数据项x。那么P2对应的操作执行序列如下所示:
P2:r1[x]…w2[x]…((c1 or a1) and (c2 or a2) in any order)
不可重复读现象也可能引发一致性问题。比方说事务T1和T2中要访问x和y这两个数据项,我们的一致性需求就是让x的值和y的值始终相同,x和y的初始值都是0。现在并发执行事务T1和T2,它们的操作执行序列如下所示:
r1[x=0]w2[x=1]w2[y=1]c2r1[y=1]c1
很显然T1是一个只读事务,依次读取x和y的值。可是由于T1在读取数据项x后,T2接着修改了数据项x和y的值,并且提交了,之后T1再读取数据项y。这个过程中虽未发生脏写和脏读(因为T1读取y的值时,T2已经提交),但最终T1得到的x的值为0,y的值为1。很显然这不是一个不一致的状态,这种不一致的状态是不应该暴露给用户的。
P2代表的事务的操作执行序列其实是一种不可重复读的广义解释,针对不可重复读还有一种严格解释。为了与广义解释进行区分,我们把不可重复读称为A2,A2对应的操作执行序列如下所示:
A2:r1[x]…w2[x]…c2…r1[x]…c1
也就是T1先读取了数据项x的值,然后T2又修改了未提交事务T1读取的数据项x的值。之后T2提交,然后T1再次读取到数据项x的值时会得到与第一次读取不同的值。这也是不可重复读的严格解释。很显然不可重复读的广义解释是覆盖严格解释包含的范围的。 -
幻读(Phantom):如果一个事务先根据某些搜索条件查询出一条记录,在该事务未提交时,另一个事务写入一些符合那些搜索条件的记录(这里的写入可以指INSERT、DELETE、UPDATE操作)。就意味着发生了幻读现象。
我们可以把幻读现象简称为P3.假设现在事务T1和T2并发执行,那么P3对应的操作执行序列如下所示:
P3:r1[P]…w2[y in P]…((c1 or a1) and (c2 or a2) any order)
其中r1[P]表示T1读取一些符合搜索条件P的记录,w2[y in P]表示T2写入一些符合搜索条件P的记录。
幻读现象也可能引发一致性问题。比方说现在符合搜索条件P的记录条数有3条。我们有一个数据项z专门表示符合搜索条件P的记录条数,它的初始值当然也是3。我们一致性的需求就是让z表示符合搜索条件P的记录数。现在执行事务T1和T2,它们的操作执行 序列如下所示:
r1[P]w2[insert y to P]r2[z=3]w2[z=4]c2r1[z=4]c1
T1先读取符合搜索条件P的记录,然后T2插入一条符合搜索条件P的记录,并且更新数据项z的值为4。然后T2提交,之后T1再读取数据项Z。z的值变为了4,这与T1之前实际读取出的符合搜索条件P的记录条数不合,不符合一致性需求。
P3代表的事务的操作执行序列其实是一种幻读的广义解释,针对幻读还有一种严格解释。为了与广义解释进行区分,我们把幻读的严格解释称为A3,A3对应的操作执行序列如下所示:
A3:r1[P]…w2[y in P]…c2…r1[p]…c1
也就是T1先读取符合搜索条件P的记录,然后T2写入了符合搜索条件P的记录。之后T1再读取符合搜索条件P的记录时,会发现两次读取的记录是不一样的。
由于SQL标准中对并发事务执行过程中可能产生一致性问题的各种现象描述不清晰,所以我们这里采用了论文A Critique of ANSI SQL Isolation Levels中关于脏写、脏读、不可重复读、幻读的定义。另外,SQL标准中针对幻读的描述,只认为在T2插入符合搜索条件P的记录时才会引起幻读现象,而A Critique of ANSI SQL Isolation Levels论文中却强调了T2进行INSERT、DELETE、UPDATE操作时均可引起幻读现象。这里需要注意的一点是,上面关于脏写、脏读、不可重复读、幻读的讨论均属于理论范畴,不涉及具体数据库。对于MySQL来说,幻读强调的就是一个事务在按照某个相同的搜索条件多次读取记录时,在后读取时读到了之前没有读到的记录。这个后读取到的之前没有读到的记录可以是由别的事务执行INSERT语句插入的,也可能是别的事务执行了更新记录键值的UPDATE语句而插入的。这些之前读取时不存在的记录也可以被称为幻影记录。假设T1先根据搜索条件P读取了一些记录,接着T2删除了一些符合搜索条件P的记录后提交,如果T1再读取符合搜索条件的记录时获得了不同的结果集,我们就可以把这种现象认为是结果集中的每一条记录分别发生了不可重复读现象。
SQL标准中的4种隔离级别
我们按照可能导致一致性问题的严重性给这些现象排一下序:
脏写>脏读>不可重复读>幻读
在SQL标准中设立了4个隔离级别。
- READ UNCOMMITTED:未提交读 可能发生脏读,不可重复读和幻读现象
- READ COMMITTED:已提交读 可能发生不可重复读和幻读现象,但是不可能发生脏读现象
- REPEATABLE READ:可重复读 可能发生幻读现象,但是不可能发生脏读和不可重复读的现象
- SERIALIZABLE:可串行化 上述各种现象都不会发生
MySQL中支持的4中隔离级别
MySQL的默认隔离级别为REPEATABLE READ。
MVCC原理
版本链
我们在前面说过,对于使用InnoDB存储引擎的表来说,它的聚簇索引记录中都包含下面这两个必要的隐藏列(row_id并不是必要的:在创建表中有主键时,或者有不允许为NULL的UNIQUE键时,都不会包含row_id列)。
- trx_id:一个事务每次对某条聚簇索引记录进行改动时,都会把该事务的事务id复制给trx_id隐藏列。
- roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志中。这个隐藏列相当于一个指针,可以通过它找到该记录修改前的信息。
实际上insert undo日志只在事务回滚时发生作用。当事务提交后,该类型的undo日志就没用了,它占用的Undo Log Segment也会被系统回收(也就是该undo日志占用的undo页面链表要么被重用,要么被释放)。虽然真正的insert undo日志占用的存储空间被回收了,但是roll_pointer的值并不会被清除。roll_pointer属性占用7字节,第一个比特就标记着它执行的undo日志的类型。如果该比特的值为1,就表示它指向的undo日志属于TRX_UNDO_INSERT大类,也就是该undo日志为insert undo日志。
假设之后两个事务id分别为100、200的事务对这条记录进行UPDATE操作,操作流程如下所示:
是否可以在两个事务中交叉更新同一条记录呢?不可以,这不就是一个事务修改了另一个未提交事务修改过的数据,沦为脏写了么。InnoDB使用锁来保证出现脏写现象。也就是在第一个事务更新某条记录前,就会给这条记录加锁;另一个事务再次更新该记录时,就需要等待第一个事务的提交,把锁释放之后才可以继续更新。
每对记录进行一次改动,都会记录一条undo日志。每条undo日志也都有一个roll_pointer属性(insert操作对应的undo日志没有该属性,因为insert操作的记录并没有更早的版本),通过这个属性可以将这些undo日志串成一个链表。
在每次更新该记录后,都会将旧值放到一条undo日志中(就算是该记录的一个旧版本)。随着更新次数的增多,所有的版本都会被roll_pointer属性连接成一个链表,这个链表称为版本链。版本链的头节点就是当前记录的最新值。另外,每个版本中还包含生成该版本时对应的事务id。这个信息很重要。我们之后会利用这个记录的版本链来控制并发事务访问相同记录时的行为,我们把这种机制称之为多版本并发控制(Multi-Version Concurrency Control,MVCC)。
我们知道,在UPDATE操作产生的undo日志中,只会记录一些索引列以及被更新的列的信息,并不会记录所有列的信息,我们在上图中展示的undo日志中,之所以将每一条记录的全部列的信息都画出来是为了方便理解。本身是没有记录country列的信息的,那么我们怎么知道该版本中的country列的值是多少呢?没有更新该列说明该列和上一个版本中的值相同。如果上一个版本的undo日志也没有记录该列的值,那么就和上上个版本中该列的值相同,当然,如果各个版本的undo日志都没有记录该列的值,说明该列从未被更新过,那么trx_id为80的那个版本的country列的值就和数据页中的聚簇索引记录的country列的值相同。
ReadView
对于使用READ UNCOMMITTED隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了;对于使用SERIALIZABLE隔离级别的事务来说,InnoDB规定使用加锁的方式来访问记录;对于READ COMMITTED和REPEATABLE READ隔离级别的事务来说,都必须保证读到已经提交的事务修改过的记录。也就是说加入一个事务已经修改了记录但是尚未提交,则不能直接读取最新版本的记录。核心问题就是:需要判断版本链中的哪个版本是当前事务可见的。为此,InnoDB提出了Read View的概念。这个Read View中主要包含4个比较重要的内容。
- m_ids:在生成Read View时,当前系统中活跃的读写事务的事务id列表。
- min_trx_id:在生成ReadView时,当前系统中活跃的读写事务中最小的事务id;也就是m_ids中的最小值。
- max_trx_id:生成ReadView时,系统应该分配给下一个事务的事务id值。
注意max_trx_id并不是m_ids中的最大值。事务id是递增分配的。比如现在有事务id分别为1,2,3的这3个事务,之后事务id为3的事务提交了,那么一个新的读事务在生成ReadView时,m_ids就包括1和2,min_trx_id的值就是1,max_trx_id的值就是4。
- creator_trx_id:生成该ReadView的事务的事务id。
有了这个ReadView后,在访问某条记录时,只需要按照下面的步骤来判断某个版本是否可见。
- 如果被访问的版本的trx_id属性值与ReadView中的createor_trx_id值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
- 如果被访问的版本的trx_id属性值小于ReadView中的min_trx_id值,表明生成该版本的事务在当前事务生成的ReadView前已经提交了,所以该版本可以被当前事务访问。
- 如果被访问的版本的trx_id属性值大于或等于ReadView中的max_trx_id值,表明生成该版本的事务在当前事务生成的ReadView后才开启,所以该版本不可以被当前事务访问。
- 如果被访问的版本的trx_id属性值在ReadView的min_trx_id和max_trx_id之间,则需要判断trx_id属性值是否在m_ids列表中。如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以访问。
如果某个版本的数据对当前事务不可见,那就顺着版本链找到下一个版本的数据,并继续执行上面的步骤来判断记录的可见性;依次类推,直到版本链中的最后一个版本。如果记录的最后一个版本也不可见,就意味着该条记录对当前事务完全不可见,查询结果就不包含该记录。
在MySQL中,READ COMMITTED与REPEATABLE READ隔离级别之间一个非常大的区别就是他们生成的ReadView的时机不同。
1、READ COMMITTED每次读取数据前都生成一个ReadView
比如,现在系统中有两个事务ID分别为100、200的事务正在执行:
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
再次强调,在事务执行过程中,只有在第一次真正修改记录时,才会分配唯一的事务id,而且这个事务id是递增的。所以我们刚才在Transaction 200中更新一些别的表的记录,目的是为它分配事务ID。
此时,表hero中number为1的记录对应的版本链表如图所示:
假设现在有一个使用READ COMMITTED隔离级别的新事务开始执行(注意是新事务,不是事务id为100、200的那两个事务):
BEGIN;
SELECT * FROM hero WHERE number = 1;
这个SELECT的执行过程如下:
步骤1:在执行SELECT语句时生成了一个ReadView。ReadView的m_ids列表的内容就是[100,200],min_trx_id为100,max_trx_id为201,creator_trx_id为0。
步骤2:然后从版本链中挑选可见的记录。从图中可以看出,最新版本的name列的内容是’张飞’,该版本的trx_id值为100,在m_ids列表内,因此不符合可见性要求;根据roll_pointer跳到下一个版本。
步骤3:下一个版本的name列的内容是’关羽’,该版本的trx_id值也为100,也在m_ids列表内,因此不符合要求,继续跳到下一个版本。
步骤4:下一个版本的name列的内容是‘刘备’,该版本的trx_id值为80,小于ReadView中的min_trx_id值为100,所以这个版本是符合要求的;最后返回给用户的版本就是这条name列为’刘备’的记录。
之后,我们把事务id为100的事务进行提交,如下所示
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
COMMIT;
然后再到事务id为200的事务中更新表hero中的number为1的记录:
BEGIN;
UPDATE hero SET name = '赵云' WHERE number = 1;
UPDATE hero SET name = '诸葛亮' WHERE number = 1;
此时,表hero中number为1的记录的版本链如下所示:
然后再到刚才使用READ COMMITTED隔离级别的事务中执行SELECT2,继续查找这个number为1的记录,如下:
BEGIN;
# SELECT1: Transaction 100 200均未提交
SELECT * FROM hero WHERE number = 1;
# SELECT2: Transaction 100提交,Transaction 200未提交
SELECT * FROM hero WHERE number = 1;
这个SELECT2的执行过程如下:
步骤1:在执行SELECT语句时又会单独生成一个ReadView。该ReadView的m_ids列表的内容就是[200](事务id为100的那个事务已经提交了,所以再次生成READVIEW时就没有它了),min_trx_id为200,max_trx_id为201,creator_trx_id为0。
步骤2:从版本链中挑选可见的记录。从图中可以看出,最新版本的name列的内容是诸葛亮,该版本的trx_id值为200,在m_ids列表内,因此不符合可见性要求;根据roll_pointer跳到下一个版本。
步骤3:下一个版本的name列的内容是赵云,该版本的trx_id值为200,也在m_ids列表内,因此也不符合要求;继续跳到下一个版本。
步骤4:下一个版本的name列的内容是张飞,该版本的trx_id值为100,小于ReadView中的min_trx_id值200,所以这个版本是符合要求的;最后返回给用户的版本就是这条name列为张飞的记录。
依次类推,如果之后事务id为200的记录也提交了,再次使用READ COMMITTED隔离级别的事务中查询表hero中的number为1的记录时,得到的结果就是诸葛亮了。具体流程在这里就不分析了。总结一下就是:使用READ COMMITTED隔离级别的事务在每次查询开始时都会生成一个独立的ReadView。
2、REPEATABLE READ-在第一次读取数据时生成一个ReadView
对于使用REPATABLE READ隔离级别的事务来说,只会在第一次执行查询语句时生成一个ReadView,之后的查询就不会重复生成ReadView了。
比如,现在系统中有两个事务id分别为100,200的事务正在执行。
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
此时,表hero中number为1的记录的版本链表如下所示:
假设现在有一个使用REPETABLE READ隔离级别的新事务开始执行:
BEGIN;
# SELECT1:Transaction 100 200未提交
SELECT * FROM hero WHERE number = 1;
这个SELECT1执行过程如下:
步骤1:在执行SELECT语句时生成了一个ReadView。ReadView的m_ids列表的内容就是[100,200],min_trx_id为100,max_trx_id为201,creator_trx_id为0。
步骤2:然后从版本链中挑选可见的记录。从图中可以看出,最新版本的name列的内容是’张飞’,该版本的trx_id值为100,在m_ids列表内,因此不符合可见性要求;根据roll_pointer跳到下一个版本。
步骤3:下一个版本的name列的内容是’关羽’,该版本的trx_id值也为100,也在m_ids列表内,因此不符合要求,继续跳到下一个版本。
步骤4:下一个版本的name列的内容是‘刘备’,该版本的trx_id值为80,小于ReadView中的min_trx_id值为100,所以这个版本是符合要求的;最后返回给用户的版本就是这条name列为’刘备’的记录。
之后,我们把事务id为100的事务进行提交。如下所示:
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
COMMIT;
然后再到事务id为200的事务中更新表hero中的number为1的记录;
BEGIN;
UPDATE hero SET name = '赵云' WHERE number = 1;
UPDATE hero SET name = '诸葛亮' WHERE number = 1;
此时,表hero中number为1的记录的版本链如下所示:
然后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个number为1的记录,如下:
BEGIN;
# SELECT1: Transaction 100 200均未提交
SELECT * FROM hero WHERE number = 1;
# SELECT2: Transaction 100提交,Transaction 200未提交
SELECT * FROM hero WHERE number = 1;
这个SELECT2的执行过程如下。
步骤1:因为当前事务的隔离级别为REPEATABLE READ,而之前在执行SELECT1时已经生成过ReadView了,所以此时直接复用之前的ReadView。之前的ReadView的m_ids列表的内容就是[100,200],min_trx_id为100,max_trx_id为201,creator_trx_id为0.
步骤2:然后从版本链中挑选可见的记录。从图中可以看出,最新版本的name列的内容是诸葛亮,该版本的trx_id值为200,在m_ids列表内,因此不符合可见性要求;根据roll_pointer跳到下一个版本。
步骤3:下一个版本的name列的内容是赵云,该版本的trx_id值为200,也在m_ids列表内,因此也不符合要求;继续跳到下一个版本。
步骤4:下一个版本的name列的内容是张飞,该版本的trx_id值为100,而m_ids列表中是包含值为100的事务id的,因此该版本也不符合要求。同理,下一个name列的内容是关羽的版本也不符合要求,继续跳到下一个版本。
步骤5,:下一个版本的name列的内容是刘备,该版本的trx_id值为80,小于ReadView中的min_trx_id值100,所以这个版本是符合要求的;最后返回给用户的版本就是这条name列为刘备的记录。
也就是说在REPEATABLE READ隔离级别下,事务的两次查询得到的结果是一样的,记录的name列值都是刘备。这就是可重复读的含义。如果我们之后再把事务id为200的记录进行提交,然后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个number为1的记录,得到的结果还是刘备。
另外,我们在前面提到过一个WITH CONSISTENTSNAPSHOT的修饰符。在隔离级别是REPEATABLE READ时,如果使用START TRANSACTION WITH CONSISTENTSNAPSHOT语句开启事务,会在执行该语句后立即生成一个Read View,而不是在执行第一条SELECT语句时才生成。
二级索引与MVCC
我们知道,只有在聚簇索引记录中才有trx_id和roll_pointer隐藏列。如果某个查询语句是使用二级索引来执行查询的,该如何判断可见性?比如下面这个事务:
BEGIN;
SELECT name FROM hero WHERE name = '刘备';
假设查询优化器决定先到二级索引idx_name中定位name值为刘备的二级索引记录,那么怎么知道这条二级索引记录对这个查询事务是否可见呢?判断可见性的过程大致分为下面两步。
- 步骤1:二级索引页面的Page Header部分有一个名为PAGE_MAX_TRX_ID的属性,每当对该页面中的记录执行增删改操作时,如果执行该操作的事务的事务id大于PAGE_MAX_TRX_ID属性值,就会把PAGE_MAX_TRX_ID属性设置为执行该操作的事务的事务id。这也就意味着PAGE_MAX_TRX_ID属性值代表着修改该二级索引页面的最大事务id是什么。当SELECT语句访问某个二级索引记录时,首先会看一下对应的ReadView的min_trx_id是否大于该页面的PAGE_MAX_TRX_ID属性值。如果是,说明该页面中的所有记录都对该ReadView可见;否则就得执行步骤2,在回表之后再判断可见性。
- 步骤2:利用二级索引记录中的主键值进行回表操作,得到对应的聚簇索引记录后再按照前面讲过的方式找到对该ReadView可见的第一个版本,然后判断该版本中相应的二级索引列的值是否与利用该二级索引查询时的值相同。如果是,就把这条记录发送给客户端,否则就跳过该记录。
另外,只有我们进行普通的SELECT查询时,MVCC才生效。
关于purge
大家有没有发现下面两件事。
- insert undo日志在事务提交之后就可以释放掉了,而update undo日志由于还需要支持MVCC,因此不能立即删除掉。
前文说过,一个事务写的一组undo日志中都有一个undo log Header部分,这个undo log header中有一个名为TRX_UNDO_HISTORY_NODE属性,表示一个名为history链表的节点。当一个事务提交之后,就会把这个事务执行过程中产生的这一组update undo日志插入到history链表的头部。
前文还说过,每个回滚段都对应一个名为Rollback Segment Header的页面。这个页面中有下面两个属性。
TRX_RSEG_HISTORY:表示history链表的基节点。
TRX_RSEG_HISTORY_SIZE:表示history链表占用的页面的数量。
也就是说每个回滚段都有一个history链表,一个事务在某个回滚段中写入一组update undo日志在该事务提交之后,就会加入到这个回滚段的history链表中。系统中可能存在很多回滚段,这也就意味着可能存在很多个history链表。
不过这些加入到history链表的update undo日志所占用的存储空间也没有被释放。 - 为了支持MVCC,delete mark操作仅仅是在记录上打一个删除标记,并没有真正将记录删除。
为了节约存储空间,我们应该在合适的时候把update undo日志以及仅仅被标记为删除的记录彻底删除掉,这个删除的操作就成为purge。不过问题在于:这个合适的时候到底是什么时候?
其实,只要我们能保证生成ReadView时某个事务已经提交,那么该ReadView肯定就不需要访问该事务运行过程中产生的undo日志了(因为该事务所改动的记录的最新版本均对该ReadView可见)。
InnoDB为此还做了两件事。 - 在一个事务提交时,会为这个事务生成一个名为事务no的值,该值用来表示事务提交的顺序,先提交的事务的事务no值小,后提交的事务的事务no值大。在一组undo日志中对应的undo log header部分有一个名为TRX_UNDO_TRX_NO属性。当事务提交时,就把该事务对应的事务no值填到这个属性中。因为事务no代表着各个事务提交的顺序,而history链表又是按照事务提交的顺序来排列各组undo日志的,所以history链表中的各组undo日志也是按照对应的事务no来排序的。
- 一个ReadView结构除了包含前面介绍过几个属性外,还会包含一个事务no的属性。在生成一个ReadView时,会把当前系统中最大的事务no值还大1的值赋值给这个属性。
InnoDB还把当前系统中所有的ReadView按照创建时间连成了一个链表。当执行purge操作时(这个purge操作是在专门的后台线程中执行的),就把系统中最早生成的ReadView给取出来。如果当前系统中不存在ReadView,就现场创建一个。然后从各个回滚段的History链表中取出事务no值较小的各组undo日志。如果一组undo日志的事务no值小于当前系统最早生成的ReadView的事务no属性值,就意味着该组undo日志没有用了,就会从history链表中移除,并且释放他们占用的存储空间。如果该组undo日志包含因delete mark操作而产生的undo日志,那么也需要将对应的标记为删除的记录给彻底删除。
这里需要注意的,当前系统中最早生成的ReadView决定了purge操作中可以清理哪些update undo日志以及打了删除标记的记录。如果某个事务使用REPEATABLE READ隔离级别,那么该事务会一直复用最初产生的ReadView。假如这个事务运行了很久,一直没有提交,那么最早生成的ReadView会一直不释放,系统中update log日志和打了删除标记的记录就会越来越多,表空间对应的文件也会越来越大,一条记录的版本链将会越来越长,从而影响系统性能。
什么是 MVCC ?
MVCC,全称 Multi-Version Concurrency Control ,即多版本并发控制。MVCC 是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问,在编程语言中实现事务内存。
MVCC 在 MySQL InnoDB 中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读
什么是当前读和快照读?
在学习 MVCC 多版本并发控制之前,我们必须先了解一下,什么是 MySQL InnoDB 下的当前读和快照读?
当前读
像 select lock in share mode (共享锁), select for update; update; insert; delete (排他锁)这些操作都是一种当前读,为什么叫当前读?就是它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁
快照读
像不加锁的 select 操作就是快照读,即不加锁的非阻塞读;快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读;之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制,即 MVCC ,可以认为 MVCC 是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销;既然是基于多版本,即快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本
说白了 MVCC 就是为了实现读-写冲突不加锁,而这个读指的就是快照读, 而非当前读,当前读实际上是一种加锁的操作,是悲观锁的实现
当前读,快照读和MVCC的关系
MVCC 多版本并发控制是 「维持一个数据的多个版本,使得读写操作没有冲突」 的概念,只是一个抽象概念,并非实现
因为 MVCC 只是一个抽象概念,要实现这么一个概念,MySQL 就需要提供具体的功能去实现它,「快照读就是 MySQL 实现 MVCC 理想模型的其中一个非阻塞读功能」。而相对而言,当前读就是悲观锁的具体功能实现
要说的再细致一些,快照读本身也是一个抽象概念,再深入研究。MVCC 模型在 MySQL 中的具体实现则是由 3 个隐式字段,undo 日志 , Read View 等去完成的,具体可以看下面的 MVCC 实现原理
MVCC 能解决什么问题,好处是?
数据库并发场景有三种,分别为:
读-读:不存在任何问题,也不需要并发控制
读-写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读
写-写:有线程安全问题,可能会存在更新丢失问题,比如第一类更新丢失,第二类更新丢失
MVCC 带来的好处是?
多版本并发控制(MVCC)是一种用来解决读-写冲突的无锁并发控制,也就是为事务分配单向增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照。 所以 MVCC 可以为数据库解决以下问题
在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能
同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题
小结一下咯
简而言之,MVCC 就是因为大佬们,不满意只让数据库采用悲观锁这样性能不佳的形式去解决读-写冲突问题,而提出的解决方案,所以在数据库中,因为有了 MVCC,所以我们可以形成两个组合:
MVCC + 悲观锁
MVCC解决读写冲突,悲观锁解决写写冲突
MVCC + 乐观锁
MVCC 解决读写冲突,乐观锁解决写写冲突
这种组合的方式就可以最大程度的提高数据库并发性能,并解决读写冲突,和写写冲突导致的问题
MVCC 的实现原理
MVCC 的目的就是多版本并发控制,在数据库中的实现,就是为了解决读写冲突,它的实现原理主要是依赖记录中的 3个隐式字段,undo日志 , Read View 来实现的。所以我们先来看看这个三个 point 的概念
隐式字段
每行记录除了我们自定义的字段外,还有数据库隐式定义的 DB_TRX_ID, DB_ROLL_PTR, DB_ROW_ID 等字段
DB_TRX_ID
6 byte,最近修改(修改/插入)事务 ID:记录创建这条记录/最后一次修改该记录的事务 ID
DB_ROLL_PTR
7 byte,回滚指针,指向这条记录的上一个版本(存储于 rollback segment 里)
DB_ROW_ID
6 byte,隐含的自增 ID(隐藏主键),如果数据表没有主键,InnoDB 会自动以DB_ROW_ID产生一个聚簇索引
实际还有一个删除 flag 隐藏字段, 既记录被更新或删除并不代表真的删除,而是删除 flag 变了
如上图,DB_ROW_ID 是数据库默认为该行记录生成的唯一隐式主键,DB_TRX_ID 是当前操作该记录的事务 ID ,而 DB_ROLL_PTR 是一个回滚指针,用于配合 undo日志,指向上一个旧版本
undo日志
undo log 主要分为两种:
insert undo log
代表事务在 insert 新记录时产生的 undo log, 只在事务回滚时需要,并且在事务提交后可以被立即丢弃
update undo log
事务在进行 update 或 delete 时产生的 undo log ; 不仅在事务回滚时需要,在快照读时也需要;所以不能随便删除,只有在快速读或事务回滚不涉及该日志时,对应的日志才会被 purge 线程统一清除
purge 从前面的分析可以看出,为了实现 InnoDB 的 MVCC 机制,更新或者删除操作都只是设置一下老记录的 deleted_bit ,并不真正将过时的记录删除。为了节省磁盘空间,InnoDB 有专门的 purge 线程来清理 deleted_bit 为 true 的记录。为了不影响 MVCC 的正常工作,purge 线程自己也维护了一个read view(这个 read view 相当于系统中最老活跃事务的 read view );如果某个记录的 deleted_bit 为 true ,并且 DB_TRX_ID 相对于 purge 线程的 read view 可见,那么这条记录一定是可以被安全清除的。
对 MVCC 有帮助的实质是 update undo log ,undo log 实际上就是存在 rollback segment 中旧记录链,它的执行流程如下:
一、 比如一个有个事务插入 persion 表插入了一条新记录,记录如下,name 为 Jerry , age 为 24 岁,隐式主键是 1,事务 ID 和回滚指针,我们假设为 NULL
二、 现在来了一个事务 1 对该记录的 name 做出了修改,改为 Tom
在事务 1 修改该行(记录)数据时,数据库会先对该行加排他锁
然后把该行数据拷贝到 undo log 中,作为旧记录,既在 undo log 中有当前行的拷贝副本
拷贝完毕后,修改该行name为Tom,并且修改隐藏字段的事务 ID 为当前事务 1 的 ID, 我们默认从 1 开始,之后递增,回滚指针指向拷贝到 undo log 的副本记录,既表示我的上一个版本就是它
事务提交后,释放锁
三、 又来了个事务 2 修改 person 表的同一个记录,将age修改为 30 岁
在事务2修改该行数据时,数据库也先为该行加锁
然后把该行数据拷贝到 undo log 中,作为旧记录,发现该行记录已经有 undo log 了,那么最新的旧数据作为链表的表头,插在该行记录的 undo log 最前面
修改该行 age 为 30 岁,并且修改隐藏字段的事务 ID 为当前事务 2 的 ID, 那就是 2 ,回滚指针指向刚刚拷贝到 undo log 的副本记录
事务提交,释放锁
从上面,我们就可以看出,不同事务或者相同事务的对同一记录的修改,会导致该记录的undo log成为一条记录版本线性表,既链表,undo log 的链首就是最新的旧记录,链尾就是最早的旧记录(当然就像之前说的该 undo log 的节点可能是会 purge 线程清除掉,向图中的第一条 insert undo log,其实在事务提交之后可能就被删除丢失了,不过这里为了演示,所以还放在这里)
Read View 读视图
什么是 Read View?
什么是 Read View,说白了 Read View 就是事务进行快照读操作的时候生产的读视图 (Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的 ID (当每个事务开启时,都会被分配一个 ID , 这个 ID 是递增的,所以最新的事务,ID 值越大)
所以我们知道 Read View 主要是用来做可见性判断的, 即当我们某个事务执行快照读的时候,对该记录创建一个 Read View 读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的undo log里面的某个版本的数据。
Read View遵循一个可见性算法,主要是将要被修改的数据的最新记录中的 DB_TRX_ID(即当前事务 ID )取出来,与系统当前其他活跃事务的 ID 去对比(由 Read View 维护),如果 DB_TRX_ID 跟 Read View 的属性做了某些比较,不符合可见性,那就通过 DB_ROLL_PTR 回滚指针去取出 Undo Log 中的 DB_TRX_ID 再比较,即遍历链表的 DB_TRX_ID(从链首到链尾,即从最近的一次修改查起),直到找到满足特定条件的 DB_TRX_ID , 那么这个 DB_TRX_ID 所在的旧记录就是当前事务能看见的最新老版本
那么这个判断条件是什么呢?
我们这里盗窃@呵呵一笑百媚生一张源码图,如上,它是一段 MySQL 判断可见性的一段源码,即 changes_visible 方法(不完全哈,但能看出大致逻辑),该方法展示了我们拿 DB_TRX_ID 去跟 Read View 某些属性进行怎么样的比较
在展示之前,我先简化一下 Read View,我们可以把 Read View 简单的理解成有三个全局属性
trx_list(名称我随意取的)
一个数值列表
用于维护 Read View 生成时刻系统 正活跃的事务 ID 列表
up_limit_id
lower water remark
是 trx_list 列表中事务 ID 最小的 ID
low_limit_id
hight water mark
ReadView 生成时刻系统尚未分配的下一个事务 ID ,也就是 目前已出现过的事务 ID 的最大值 + 1
为什么是 low_limit ? 因为它也是系统此刻可分配的事务 ID 的最小值
首先比较 DB_TRX_ID < up_limit_id , 如果小于,则当前事务能看到 DB_TRX_ID 所在的记录,如果大于等于进入下一个判断
接下来判断 DB_TRX_ID >= low_limit_id , 如果大于等于则代表 DB_TRX_ID 所在的记录在 Read View 生成后才出现的,那对当前事务肯定不可见,如果小于则进入下一个判断
判断 DB_TRX_ID 是否在活跃事务之中,trx_list.contains (DB_TRX_ID),如果在,则代表我 Read View 生成时刻,你这个事务还在活跃,还没有 Commit,你修改的数据,我当前事务也是看不见的;如果不在,则说明,你这个事务在 Read View 生成之前就已经 Commit 了,你修改的结果,我当前事务是能看见的
整体流程
我们在了解了 隐式字段,undo log, 以及 Read View 的概念之后,就可以来看看 MVCC 实现的整体流程是怎么样了
整体的流程是怎么样的呢?我们可以模拟一下
当事务 2 对某行数据执行了快照读,数据库为该行数据生成一个Read View读视图,假设当前事务 ID 为 2,此时还有事务1和事务3在活跃中,事务 4 在事务 2 快照读前一刻提交更新了,所以 Read View 记录了系统当前活跃事务 1,3 的 ID,维护在一个列表上,假设我们称为trx_list
Read View 不仅仅会通过一个列表 trx_list 来维护事务 2执行快照读那刻系统正活跃的事务 ID 列表,还会有两个属性 up_limit_id( trx_list 列表中事务 ID 最小的 ID ),low_limit_id ( 快照读时刻系统尚未分配的下一个事务 ID ,也就是目前已出现过的事务ID的最大值 + 1 资料传送门 | 呵呵一笑百媚生的回答 ) 。所以在这里例子中 up_limit_id 就是1,low_limit_id 就是 4 + 1 = 5,trx_list 集合的值是 1, 3,Read View 如下图
我们的例子中,只有事务 4 修改过该行记录,并在事务 2 执行快照读前,就提交了事务,所以当前该行当前数据的 undo log 如下图所示;我们的事务 2 在快照读该行记录的时候,就会拿该行记录的 DB_TRX_ID 去跟 up_limit_id , low_limit_id 和活跃事务 ID 列表( trx_list )进行比较,判断当前事务 2能看到该记录的版本是哪个。
所以先拿该记录 DB_TRX_ID 字段记录的事务 ID 4 去跟 Read View 的 up_limit_id 比较,看 4 是否小于 up_limit_id( 1 ),所以不符合条件,继续判断 4 是否大于等于 low_limit_id( 5 ),也不符合条件,最后判断 4 是否处于 trx_list 中的活跃事务, 最后发现事务 ID 为 4 的事务不在当前活跃事务列表中, 符合可见性条件,所以事务 4修改后提交的最新结果对事务 2 快照读时是可见的,所以事务 2 能读到的最新数据记录是事务4所提交的版本,而事务4提交的版本也是全局角度上最新的版本
也正是 Read View 生成时机的不同,从而造成 RC , RR 级别下快照读的结果的不同
MVCC 相关问题
RR 是如何在 RC 级的基础上解决不可重复读的?
当前读和快照读在 RR 级别下的区别:
在上表的顺序下,事务 B 的在事务 A 提交修改后的快照读是旧版本数据,而当前读是实时新数据 400
这里与上表的唯一区别仅仅是表 1的事务 B 在事务 A 修改金额前快照读过一次金额数据,而表 2的事务B在事务A修改金额前没有进行过快照读。
所以我们知道事务中快照读的结果是非常依赖该事务首次出现快照读的地方,即某个事务中首次出现快照读的地方非常关键,它有决定该事务后续快照读结果的能力
我们这里测试的是更新,同时删除和更新也是一样的,如果事务B的快照读是在事务A操作之后进行的,事务B的快照读也是能读取到最新的数据的
RC , RR 级别下的 InnoDB 快照读有什么不同?
正是 Read View 生成时机的不同,从而造成 RC , RR 级别下快照读的结果的不同
在 RR 级别下的某个事务的对某条记录的第一次快照读会创建一个快照及 Read View, 将当前系统活跃的其他事务记录起来,此后在调用快照读的时候,还是使用的是同一个 Read View,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个 Read View,所以对之后的修改不可见;
即 RR 级别下,快照读生成 Read View 时,Read View 会记录此时所有其他活动事务的快照,这些事务的修改对于当前事务都是不可见的。而早于Read View创建的事务所做的修改均是可见
而在 RC 级别下的,事务中,每次快照读都会新生成一个快照和 Read View , 这就是我们在 RC 级别下的事务中可以看到别的事务提交的更新的原因
总之在 RC 隔离级别下,是每个快照读都会生成并获取最新的 Read View;而在 RR 隔离级别下,则是同一个事务中的第一个快照读才会创建 Read View, 之后的快照读获取的都是同一个 Read View。
ReadView
说完了undo log我们再来看看ReadView。已提交读和可重复读的区别就在于它们生成ReadView的策略不同。
ReadView中主要就是有个列表来存储我们系统中当前活跃着的读写事务,也就是begin了还未提交的事务。通过这个列表来判断记录的某个版本是否对当前事务可见。其中最主要的与可见性相关的属性如下:
up_limit_id:当前已经提交的事务号 + 1,事务号 < up_limit_id ,对于当前Read View都是可见的。理解起来就是创建Read View视图的时候,之前已经提交的事务对于该事务肯定是可见的。
low_limit_id:当前最大的事务号 + 1,事务号 >= low_limit_id,对于当前Read View都是不可见的。理解起来就是在创建Read View视图之后创建的事务对于该事务肯定是不可见的。
trx_ids:为活跃事务id列表,即Read View初始化时当前未提交的事务列表。所以当进行RR读的时候,trx_ids中的事务对于本事务是不可见的(除了自身事务,自身事务对于表的修改对于自己当然是可见的)。理解起来就是创建RV时,将当前活跃事务ID记录下来,后续即使他们提交对于本事务也是不可见的。
用一张图更好的理解一下:
思考
一个较耗时操作可以使用内存替代,然后后台线程实现内存到磁盘的同步,然后需要一个更加低廉的操作来记录状态,最后刷新数据到磁盘后会更新这个状态值。