mysql原理--事务的隔离级别与 MVCC
1.事前准备为了故事的顺利发展我们需要创建一个表CREATE TABLEhero(number INT,nameVARCHAR(100),countryvarchar(100),PRIMARYKEY(number))EngineInnoDB CHARSETutf8;然后向这个表里插入一条数据INSERT INTO hero VALUES(1, 刘备, 蜀);现在表里的数据就是这样的2.事务隔离级别我们知道MySQL是一个 客户端服务器 架构的软件对于同一个服务器来说可以有若干个客户端与之连接每个客户端与服务器连接上之后就可以称之为一个会话Session。每个客户端都可以在自己的会话中向服务器发出请求语句一个请求语句可能是某个事务的一部分也就是对于服务器来说可能同时处理多个事务。在事务简介的章节中我们说过事务有一个称之为 隔离性 的特性理论上在某个事务对某个数据进行访问时其他事务应该进行排队当该事务提交之后其他事务才可以继续访问这个数据。但是这样子的话对性能影响太大我们既想保持事务的 隔离性 又想让服务器在处理访问同一数据的多个事务时性能尽量高些鱼和熊掌不可得兼舍一部分 隔离性 而取性能者也。2.1.事务并发执行遇到的问题怎么个舍弃法呢我们先得看一下访问相同数据的事务在不保证串行执行也就是执行完一个再执行另一个的情况下可能会出现哪些问题(1). 脏写Dirty Write如果一个事务修改了另一个未提交事务修改过的数据那就意味着发生了 脏写 示意图如下如上图Session A和Session B各开启了一个事务Session B中的事务先将number列为1的记录的name列更新为 ‘关羽’ 然后Session A中的事务接着又把这条number列为1的记录的name列更新为 ‘张飞’ 。如果之后Session B中的事务进行了回滚那么Session A中的更新也将不复存在这种现象就称之为 脏写 。这时Session A中的事务就很懵逼我明明把数据更新了最后也提交事务了怎么到最后说自己啥也没干呢脏写的本质是两个事务两个并发事务执行期间修改了同一份数据(2). 脏读Dirty Read如果一个事务读到了另一个未提交事务修改过的数据那就意味着发生了 脏读 示意图如下如上图Session A和Session B各开启了一个事务Session B中的事务先将number列为1的记录的name列更新为 ‘关羽’ 然后Session A中的事务再去查询这条number为1的记录如果读到列name的值为 ‘关羽’ 而Session B中的事务稍后进行了回滚那么Session A中的事务相当于读到了一个不存在的数据这种现象就称之为 脏读 。脏读的本质是对两个并发执行的事务某个事务读到了被另一个尚未提交的事务修改了的数据(3). 不可重复读Non-Repeatable Read如果一个事务只能读到另一个已经提交的事务修改过的数据并且其他事务每对该数据进行一次修改并提交后该事务都能查询得到最新值那就意味着发生了 不可重复读 示意图如下如上图我们在Session B中提交了几个隐式事务注意是隐式事务意味着语句结束事务就提交了这些事务都修改了number列为1的记录的列name的值每次事务提交之后如果Session A中的事务都可以查看到最新的值这种现象也被称之为 不可重复读 。不可重复读的本质是一个事务执行期间在自身未修改读取数据下两次读取同一数据读出的数据内容不一致(4). 幻读Phantom如果一个事务先根据某些条件查询出一些记录之后另一个事务又向表中插入了符合这些条件的记录原先的事务再次按照该条件查询时能把另一个事务插入的记录也读出来那就意味着发生了 幻读 示意图如下如上图Session A中的事务先根据条件number 0这个条件查询表hero得到了name列值为刘备的记录之后Session B中提交了一个隐式事务该事务向表hero中插入了一条新记录之后Session A中的事务再根据相同的条件number 0查询表hero得到的结果集中包含Session B中的事务新插入的那条记录这种现象也被称之为 幻读 。有的同学会有疑问那如果Session B中是删除了一些符合number 0的记录而不是插入新记录那Session A中之后再根据number 0的条件读取的记录变少了这种现象算不算 幻读 呢明确说一下这种现象不属于 幻读 幻读 强调的是一个事务按照某个相同条件多次读取记录时后读取时读到了之前没有读到的记录。那对于先前已经读到的记录之后又读取不到这种情况算啥呢其实这相当于对每一条记录都发生了不可重复读的现象。幻读只是重点强调了读取到了之前读取没有获取到的记录。幻读本质是一个事务在自身未修改某数据下前后两次读取后来读取时读到了新数据3.SQL标准中的四种隔离级别我们上边介绍了几种并发事务执行过程中可能遇到的一些问题这些问题也有轻重缓急之分我们给这些问题按照严重性来排一下序脏写 脏读 不可重复读 幻读。我们上边所说的舍弃一部分隔离性来换取一部分性能在这里就体现在设立一些隔离级别隔离级别越低越严重的问题就越可能发生。有一帮人并不是设计MySQL的大叔们制定了一个所谓的SQL标准 在标准中设立了4个 隔离级别 (1).READ UNCOMMITTED未提交读。(2).READ COMMITTED已提交读。(3).REPEATABLE READ可重复读。(4).SERIALIZABLE可串行化。SQL标准 中规定针对不同的隔离级别并发事务可以发生不同严重程度的问题具体情况如下也就是说(1).READ UNCOMMITTED隔离级别下可能发生 脏读 、 不可重复读 和 幻读 问题。(2).READ COMMITTED隔离级别下可能发生 不可重复读 和 幻读 问题但是不可以发生 脏读 问题。(3).REPEATABLE READ隔离级别下可能发生 幻读 问题但是不可以发生 脏读 和 不可重复读 的问题。(4).SERIALIZABLE隔离级别下各种问题都不可以发生。脏写 是怎么回事儿怎么里边都没写呢这是因为脏写这个问题太严重了不论是哪种隔离级别都不允许脏写的情况发生。4.MySQL中支持的四种隔离级别不同的数据库厂商对SQL标准 中规定的四种隔离级别支持不一样比方说Oracle就只支持READ COMMITTED和SERIALIZABLE隔离级别。本书中所讨论的MySQL虽然支持4种隔离级别但与SQL标准 中所规定的各级隔离级别允许发生的问题却有些出入MySQL在REPEATABLE READ隔离级别下是可以禁止幻读问题的发生的关于如何禁止我们之后会详细说明的。MySQL的默认隔离级别为REPEATABLE READ我们可以手动修改一下事务的隔离级别。4.1.如何设置事务的隔离级别我们可以通过下边的语句修改事务的隔离级别SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL level;其中的level可选值有4个level:{REPEATABLE READ|READ COMMITTED|READ UNCOMMITTED|SERIALIZABLE}设置事务的隔离级别的语句中在SET关键字后可以放置GLOBAL关键字、SESSION关键字或者什么都不放这样会对不同范围的事务产生不同的影响具体如下(1). 使用GLOBAL关键字在全局范围影响比方说这样SET GLOBAL TRANSACTION ISOLATION LEVEL SERIALIZABLE;则a. 只对执行完该语句之后产生的会话起作用。b. 当前已经存在的会话无效。(2). 使用SESSION关键字在会话范围影响比方说这样SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;则a. 对当前会话的所有后续的事务有效b. 该语句可以在已经开启的事务中间执行但不会影响当前正在执行的事务。c. 如果在事务之间执行则对后续的事务有效。(3). 上述两个关键字都不用只对执行语句后的下一个事务产生影响比方说这样SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;则a. 只对当前会话中下一个即将开启的事务有效。b. 下一个事务执行完后后续事务将恢复到之前的隔离级别。c. 该语句不能在已经开启的事务中间执行会报错的。如果我们在服务器启动时想改变事务的默认隔离级别可以修改启动参数transaction-isolation的值比方说我们在启动服务器时指定了--transaction-isolationSERIALIZABLE那么事务的默认隔离级别就从原来的REPEATABLE READ变成了SERIALIZABLE。想要查看当前会话默认的隔离级别可以通过查看系统变量transaction_isolation的值来确定或者使用更简便的写法5.MVCC原理5.1.版本链我们前边说过对于使用InnoDB存储引擎的表来说它的聚簇索引记录中都包含两个必要的隐藏列row_id并不是必要的我们创建的表中有主键或者非NULL的UNIQUE键时都不会包含row_id列(1).trx_id每次一个事务对某条聚簇索引记录进行改动时都会把该事务的事务id赋值给trx_id隐藏列。(2).roll_pointer每次对某条聚簇索引记录进行改动时都会把旧的版本写入到undo日志 中然后这个隐藏列就相当于一个指针可以通过它来找到该记录修改前的信息。比方说我们的表hero现在只包含一条记录假设插入该记录的 事务id为80那么此刻该条记录的示意图如下所示实际上insert undo只在事务回滚时起作用当事务提交后该类型的undo日志就没用了它占用的Undo Log Segment也会被系统回收也就是该undo日志占用的Undo页面链表要么被重用要么被释放。虽然真正的insert undo日志占用的存储空间被释放了但是roll_pointer的值并不会被清除roll_pointer属性占用7个字节第一个比特位就标记着它指向的undo日志的类型如果该比特位的值为1时就代表着它指向的undo日志类型为insert undo。所以我们之后在画图时都会把insert undo给去掉大家留意一下就好了。假设之后两个 事务id分别为100、200的事务对这条记录进行UPDATE操作操作流程如下能不能在两个事务中交叉更新同一条记录呢哈哈这不就是一个事务修改了另一个未提交事务修改过的数据沦为了脏写了么InnoDB使用锁来保证不会有脏写情况的发生也就是在第一个事务更新了某条记录后就会给这条记录加锁另一个事务再次更新时就需要等待第一个事务提交了把锁释放之后才可以继续更新。关于锁的更多细节我们后续的文章中再唠叨哈每次对记录进行改动都会记录一条undo日志 每条undo日志 也都有一个roll_pointer属性INSERT操作对应的undo日志 没有该属性因为该记录并没有更早的版本可以将这些undo日志 都连起来串成一个链表所以现在的情况就像下图一样对该记录每次更新后都会将旧值放到一条undo日志 中就算是该记录的一个旧版本随着更新次数的增多所有的版本都会被roll_pointer属性连接成一个链表我们把这个链表称之为 版本链 版本链的头节点就是当前记录最新的值。另外每个版本中还包含生成该版本时对应的 事务id这个信息很重要我们稍后就会用到。5.2.ReadView对于使用READ UNCOMMITTED隔离级别的事务来说由于可以读到未提交事务修改过的记录所以直接读取记录的最新版本就好了对于使用SERIALIZABLE隔离级别的事务来说设计InnoDB的大叔规定使用加锁的方式来访问记录加锁是啥我们后续文章中说哈对于使用READ COMMITTED和REPEATABLE READ隔离级别的事务来说都必须保证读到已经提交了的事务修改过的记录也就是说假如另一个事务已经修改了记录但是尚未提交是不能直接读取最新版本的记录的核心问题就是需要判断一下版本链中的哪个版本是当前事务可见的。为此设计InnoDB的大叔提出了一个ReadView的概念这个ReadView中主要包含4个比较重要的内容(1).m_ids表示在生成ReadView时当前系统中活跃的读写事务的 事务id列表。(2).min_trx_id表示在生成ReadView时当前系统中活跃的读写事务中最小的 事务id也就是m_ids中的最小值。(3).max_trx_id表示生成ReadView时系统中应该分配给下一个事务的id值。注意max_trx_id并不是m_ids中的最大值事务id是递增分配的。比方说现在有id为123这三个事务之后id为3的事务提交了。那么一个新的读事务在生成ReadView时m_ids就包括1和2min_trx_id的值就是1max_trx_id的值就是4。(4).creator_trx_id表示生成该ReadView的事务的 事务id。我们前边说过只有在对表中的记录做改动时执行INSERT、DELETE、UPDATE这些语句时才会为事务分配事务id否则在一个只读事务中的事务id值都默认为0。有了这个ReadView这样在访问某条记录时只需要按照下边的步骤判断记录的某个版本是否可见(1). 如果被访问版本的trx_id属性值与ReadView中的creator_trx_id值相同意味着当前事务在访问它自己修改过的记录所以该版本可以被当前事务访问。(2). 如果被访问版本的trx_id属性值小于ReadView中的min_trx_id值表明生成该版本的事务在当前事务生成ReadView前已经提交所以该版本可以被当前事务访问。(3). 如果被访问版本的trx_id属性值大于ReadView中的max_trx_id值表明生成该版本的事务在当前事务生成ReadView后才开启所以该版本不可以被当前事务访问。(4). 如果被访问版本的trx_id属性值在ReadView的min_trx_id和max_trx_id之间那就需要判断一下trx_id属性值是不是在m_ids列表中如果在说明创建ReadView时生成该版本的事务还是活跃的该版本不可以被访问如果不在说明创建ReadView时生成该版本的事务已经被提交该版本可以被访问。如果某个版本的数据对当前事务不可见的话那就顺着版本链找到下一个版本的数据继续按照上边的步骤判断可见性依此类推直到版本链中的最后一个版本。如果最后一个版本也不可见的话那么就意味着该条记录对该事务完全不可见查询结果就不包含该记录。在MySQL中READ COMMITTED和REPEATABLE READ隔离级别的的一个非常大的区别就是它们生成ReadView的时机不同。我们还是以表hero为例来假设现在表hero中只有一条由 事务id为80的事务插入的一条记录接下来看一下READ COMMITTED和REPEATABLE READ所谓的生成ReadView的时机不同到底不同在哪里。5.2.1.READ COMMITTED—— 每次读取数据前都生成一个ReadView比方说现在系统里有两个 事务id分别为100、200的事务在执行#Transaction100BEGIN;UPDATE hero SET name关羽WHERE number1;UPDATE hero SET name张飞WHERE number1;#Transaction200BEGIN;# 更新了一些别的表的记录再次强调一遍事务执行过程中只有在第一次真正修改记录时比如使用INSERT、DELETE、UPDATE语句才会被分配一个单独的事务id这个事务id是递增的。所以我们才在Transaction 200中更新一些别的表的记录目的是让它分配事务id。此刻表hero中number为1的记录得到的版本链表如下所示假设现在有一个使用READ COMMITTED隔离级别的事务开始执行# 使用READ COMMITTED隔离级别的事务 BEGIN;#SELECT1Transaction100、200未提交SELECT*FROM hero WHERE number1;# 得到的列name的值为刘备这个SELECT1的执行过程如下(1). 在执行SELECT语句时会先生成一个ReadViewReadView的m_ids列表的内容就是[100, 200]min_trx_id为100max_trx_id为201creator_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的事务提交一下就像这样#Transaction100BEGIN;UPDATE hero SET name关羽WHERE number1;UPDATE hero SET name张飞WHERE number1;COMMIT;然后再到 事务id为200的事务中更新一下表hero中number为1的记录#Transaction200BEGIN;# 更新了一些别的表的记录...UPDATE hero SET name赵云WHERE number1;UPDATE hero SET name诸葛亮WHERE number1;此刻表hero中number为1的记录的版本链就长这样然后再到刚才使用READ COMMITTED隔离级别的事务中继续查找这个number为1的记录如下# 使用READ COMMITTED隔离级别的事务 BEGIN;#SELECT1Transaction100、200均未提交SELECT*FROM hero WHERE number1;# 得到的列name的值为刘备#SELECT2Transaction100提交Transaction200未提交SELECT*FROM hero WHERE number1;# 得到的列name的值为张飞这个SELECT2的执行过程如下(1). 在执行SELECT语句时会又会单独生成一个ReadView该ReadView的m_ids列表的内容就是[200] 事务id为100的那个事务已经提交了所以再次生成快照时就没有它了min_trx_id为200max_trx_id为201creator_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。5.2.2.REPEATABLE READ—— 在第一次读取数据时生成一个ReadView对于使用REPEATABLE READ隔离级别的事务来说只会在第一次执行查询语句时生成一个ReadView之后的查询就不会重复生成了。我们还是用例子看一下是什么效果。比方说现在系统里有两个 事务id分别为100、200的事务在执行#Transaction100BEGIN;UPDATE hero SET name关羽WHERE number1;UPDATE hero SET name张飞WHERE number1;#Transaction200BEGIN;# 更新了一些别的表的记录...此刻表hero中number为1的记录得到的版本链表如下所示假设现在有一个使用REPEATABLE READ隔离级别的事务开始执行# 使用REPEATABLE READ隔离级别的事务 BEGIN;#SELECT1Transaction100、200未提交SELECT*FROM hero WHERE number1;# 得到的列name的值为刘备这个SELECT1的执行过程如下(1). 在执行SELECT语句时会先生成一个ReadViewReadView的m_ids列表的内容就是[100, 200]min_trx_id为100max_trx_id为201creator_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的事务提交一下就像这样#Transaction100BEGIN;UPDATE hero SET name关羽WHERE number1;UPDATE hero SET name张飞WHERE number1;COMMIT;然后再到 事务id为200的事务中更新一下表hero中number为1的记录#Transaction200BEGIN;# 更新了一些别的表的记录...UPDATE hero SET name赵云WHERE number1;UPDATE hero SET name诸葛亮WHERE number1;此刻表hero中number为1的记录的版本链就长这样然后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个number为1的记录如下# 使用REPEATABLE READ隔离级别的事务 BEGIN;#SELECT1Transaction100、200均未提交SELECT*FROM hero WHERE number1;# 得到的列name的值为刘备#SELECT2Transaction100提交Transaction200未提交SELECT*FROM hero WHERE number1;# 得到的列name的值仍为刘备这个SELECT2的执行过程如下(1). 因为当前事务的隔离级别为REPEATABLE READ而之前在执行SELECT1时已经生成过ReadView了所以此时直接复用之前的ReadView之前ReadView的m_ids列表的内容就是[100, 200]min_trx_id为100max_trx_id为201creator_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所以这个版本是符合要求的最后返回给用户的版本就是这条列值为刘备的记录。也就是说两次SELECT查询得到的结果是重复的记录的列值都是 ‘刘备’ 这就是 可重复读 的含义。如果我们之后再把 事务id为200的记录提交了然后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个number为1的记录得到的结果还是刘备具体执行过程大家可以自己分析一下。5.2.3.MVCC小结从上边的描述中我们可以看出来MVCCMulti-Version Concurrency Control多版本并发控制指的就是在使用READ COMMITTD、REPEATABLE READ这两种隔离级别的事务在执行普通的SEELCT操作时访问记录的版本链的过程这样子可以使不同事务的 读-写 、 写-读 操作并发执行从而提升系统性能。READ COMMITTD、REPEATABLE READ这两个隔离级别的一个很大不同就是生成ReadView的时机不同READ COMMITTD在每一次进行普通SELECT操作前都会生成一个ReadView而REPEATABLE READ只在第一次进行普通SELECT操作前生成一个ReadView之后的查询操作都重复使用这个ReadView就好了。我们之前说执行DELETE语句或者更新主键的UPDATE语句并不会立即把对应的记录完全从页面中删除而是执行一个所谓的delete mark操作相当于只是对记录打上了一个删除标志位这主要就是为MVCC服务的大家可以对比上边举的例子自己试想一下怎么使用。另外所谓的MVCC只是在我们进行普通的SEELCT查询时才生效截止到目前我们所见的所有SELECT语句都算是普通的查询至于啥是个不普通的查询我们稍后再说哈6.关于purge大家有没有发现两件事儿(1). 我们说insert undo在事务提交之后就可以被释放掉了而update undo由于还需要支持MVCC不能立即删除掉。(2). 为了支持MVCC对于delete mark操作来说仅仅是在记录上打一个删除标记并没有真正将它删除掉。随着系统的运行在确定系统中包含最早产生的那个ReadView的事务不会再访问某些update undo日志 以及被打了删除标记的记录后有一个后台运行的purge线程 会把它们真正的删除掉7.总结数据库服务端存在并发执行多个事务的情况事务的隔离性要求这些并发事务操作同一份数据时应满足串行化要求但完全串行化访问效率低所以实际应用中允许舍弃一部分隔离性来提升性能隔离性舍弃下可能产生四个经典现象脏写脏读不可重复读幻读脏写是两个并发事务同时写一份数据脏读是一个事务读到的数据是另一个尚未提交事务修改过的版本不可重复读是某个事务前后两次读取同一份数据时其他事务可能修改了数据导致前后两次读出内容不一致幻读是某个事务前后两次按同一条件执行读取时读出的新数据四种现象严重性排序为脏写 脏读 不可重复读 幻读数据库产品支持四类隔离级别设置脏写在任何级别下均禁止四类隔离级别下分别实现了三个都不禁止禁止脏读禁止脏读和不可重复读禁止脏读不可重复读幻读Mysql采用MVCC锁等技术来实现不能访问级别下的数据访问控制一个事务内两次查询记录变多属于幻读记录变少不算幻读。记录变少一般由于数据被删除或修改属于可重复读范畴。可重复读下通过MVCC首次普通查询生成ReadView可以解决。可重复读下通过间隙锁可以解决大部分幻读还有一些场景不能覆盖。完全覆盖需设置到可串行化。
