小故事
网友大喝:标题竟敢胡言乱语?MySQL作为全世界最流行的数据库,30余年历史,怎么可能一无是处?
ChatGPT回答说:哈哈,你说得对!MySQL作为全世界最流行的数据库,确实有着令人难以忽视的历史和优势。就像一位老资格的数据库大佬一样,它在数据存储和管理方面功力深厚,让数据如鱼得水。但是,有时候也会有一些小插曲,就像人类一样,有时候也会犯点小错误。不过,这只能证明MySQL也是有人情味的,不是吗?毕竟,谁能一无是处呢?连MySQL都不行的话,那我们这些人类还有什么希望呢?嘿嘿!
书归正传,本文讨论MySQL的数据正确性的实现技术。在这一方面,MySQL的数据正确性实现技术,一无特色和亮点二有较多可改进之处,因此可以说:一无是处。
为什么说MySQL的数据正确性实现一无是处呢?
下面我们来慢慢聊聊。
首先,看看集人类知识大成的ChatGPT的认知能力。问了ChatGPT如下两个问题。
Q1(问ChatGPT):用300字总结一下MySQL是怎么用并发访问控制算法保障数据正确性的?
A1(ChatGPT回答):如下回答中,红色字体是作者所加
MySQL使用并发访问控制算法来保障数据的正确性(不完全正确)。并发访问控制是一种管理多个用户同时访问数据库的方法。MySQL使用了两种主要的并发控制算法:锁和多版本并发控制(MVCC)。
锁是MySQL最常用的并发控制算法之一。它通过在数据上设置锁来限制对数据的访问。当一个用户正在修改数据时,MySQL会为该数据加上写锁,这样其他用户就无法同时修改该数据。当一个用户正在读取数据时,MySQL会为该数据加上读锁(只有可串行化隔离级别下才加读锁),这样其他用户可以同时读取该数据,但不能修改它。通过锁的机制,MySQL确保了并发访问时数据的一致性和正确性。(该段没有区分隔离级别,不同隔离级别下,情况是不一样的,因此需要深入细节来分析问题。在并发访问控制方面,不深入细节是不能掌握好并发算法的。更加有问题的地方在于,不深入讨论数据异常是没有办法设计和把握好并发算法的,因为数据异常是问题之源,算法是针对问题而给出的解决方式,不知问题而得到的算法要么是错误的会以偏概全,要么是正确的到会笼统的“全面积覆盖”,而不能做到精细化管理和高性能;这两者,MySQL都存在
另一种并发控制算法是MVCCMVCC使用了版本控制的概念来管理并发访问。当一个用户修改数据时,MySQL会为该数据创建一个新的版本,并将该版本的信息保存在事务日志中。其他用户可以继续读取旧版本的数据,而不会受到正在修改的影响。当所有读取旧版本的用户完成后,MySQL会将新版本的数据应用到数据库中,从而保证数据的正确性。(这句话更是笼统,既没有表明MVCC的实质,更没有说出数据正确性是如何被保障的。当然,再问
MySQL还使用了其他一些技术来增强并发访问控制的效果,如行级锁、间隙锁和死锁检测等(没有说出这些技术和前述的封锁技术、MVCC技术之间的关系,认知不够详实)。这些算法和技术的综合应用,使得MySQL能够处理大量并发的读写操作,并保证数据的一致性和正确性。
ChatGPT很能说,比要求的300字超了不少,但很多说法模糊不清,不能深入实质。
Q2(问ChatGPT):用100字说一下MySQL确保数据一致性方面的优点和缺点
A2(ChatGPT回答):如下回答中,红色字体是作者所加
MySQL在确保数据一致性方面有以下优点:采用了并发访问控制算法,如锁和MVCC,能够处理大量并发操作;支持行级锁和间隙锁,提高了并发性能(这个逻辑其实很怪,似乎行级锁等是为提高性能的进一步的优化方法,本应该直接在被操作对象上加锁,理所当然的事情,被总结为这个样子而误导人;而行级锁之外的页级锁等是与存储结构相关的锁,非封锁算法要求的粒度);具备死锁检测机制,避免了死锁问题(死锁检测是TP型数据库系统的标配,没有做到的那是因为技术能力不佳;但可以做得更好,无死锁检测的算法理论层面更优)。
然而,MySQL也存在一些缺点:锁的粒度较大,可能导致并发度降低;MVCC会增加存储空间的消耗(与实现方式有关,要么耗费内存要么耗费物理存储);死锁检测可能会引起性能问题这个回答是非常正确的,MySQL在可串行化隔离级别做了大量的死锁检测的工作,会严重影响性能,从一致性八仙图中,大家可以发现MySQL在可串行化隔离级别下有大量的“红色的D)。因此,在高并发场景下,需要权衡数据一致性和性能需求(这个是个伪认知,鱼和熊掌可兼得,100%的数据的正确性和性能是可以同时得到的)。
从问题与回答中,可以看到两个方面:一是回答基本正确,但泛而无用;二是不专业,认知有限甚至存在错误。
目前阶段,专业的问题,还是不问ChatGPT为好;专业的回答,ChatGPT给不了。但其实这种认知,代表了多数人对MySQL的认知水准。我们且把这样的回答看做是平均水位(也许是高于数据库从业人员的平均认知的)。
而我们说,在数据的正确性实现技术方面,MySQL一无是处。这句话又怎么理解呢?
首先,数据库理论层面已知的数据异常问题,MySQL能保证正确性,只是可串行化下性能不佳。一致性八仙图中提出的比传统理论或ANSI-SQL标准多出来的20+个数据异常种类,MySQL也能消除。这是一个客观事实,单凭这点,能证明MySQL的正确性是有保障的
其次一致性八仙图,犹如一个放大镜,彰显了一个数据库系统的并发处理细节。这使得可以基于一致性八仙图这把标尺,来测量任何数据库系统,MySQL自然不例外。所以并发细节得到展示后,自然能够看出其并发算法的能力强弱。
MySQL最大的问题,在于可串行化隔离级别下,多数数据异常采用死锁检测的方式去解决,换句话说,多数数据异常采用了封锁的并发算法然后导致了死锁的发生以至于不得不用死锁检测的方式加以消除。这是性能问题的根源所在(注意非可串行化隔离级别下,异步进行死锁检测的线程依然在运行)。既然死锁检测影响性能,那么,是否存在一种算法,能不采取死锁检测就能消除任何数据异常?这是MySQL应该考虑和改进的。
在一致性八仙图中,与MySQL在并发访问控制算法实现能力最弱的、有的一拼的是SQL Server。
再次,MySQL在较弱隔离级别下,出现的“黄色A”较多,说明MySQL在较多的场景下出现数据不一致的情况是多于其他数据库的。这点明显和PostgreSQL有差距。
有同学会说,这个时候,应用开发采用“SELECT…FOR UPDATE”即可避免任何不一致性现象。是的,从能力上看,确实如此,但是正如本系列的第一篇《分布式数据库(1)——踢球时代》中给出的图4所述,用户主动加锁的行为会严重影响数据库的性能
用户主动加锁,性能严重下降,在实践中意味着数据库成本瞬时翻倍
此时此刻,用户主动加锁的观点持有者,不知是否会继续坚持自己的观点?如果这么做了,那么1台机器的成本能搞定的事情,会变成2台或更多。
数据库性能差则堆机器,一些互联网大厂、金融商用系统等数据库成本居高不下的问题,就是在这样的背景下搞出来的。
第四在用并发算法消除具体的数据异常的实现方面,相比其他数据库,MySQL/InnoDB依旧有着较大的不足
这一点需要看如下的细节。
题外话:
既然既谈正确性又谈性能,有的人此刻不禁要问:在一致性八仙图中,其他的、“红色的D”少于MySQL的数据库,是不是就优于MySQL呢?
这个问题需要一分为二的去看:
1. 其中一部分,性能强于MySQL的,自然是正常的,不用多说。
2. 性能弱于MySQL的,有一些国产的数据库,似乎并发能力比MySQL强、但事实上性能又弱于MySQL,这意味着存在一种可能——基于相同并发原理的但性能弱是因为其实现方面存在“更大”的问题?如“系统架构和/或编码”等方面。一些原创的国产数据库勇敢而艰难地摸索,在没有长期有效积累的情况下,其系统存在种种问题,对于数据库这种品类而言,几乎是必然的。数据库系统的研发,是需要积累底蕴的,技术需要长期积累不能一蹴而就的
MySQL是如何进行数据正确性保证的?
MySQL的数据正确性,是通过完整性约束(暂且不谈,后续另文讨论)和并发访问控制算法确保的。其中,并发算法的实现方式如下:

1. 首先,MySQL根据隔离级别确定如何施加锁

1.1 实现可串行化隔离级别时:为事务加读锁(通过函数ha_innobase::store_lock 施加LOCK_S,且该锁是属于事务级别的)。注意该锁会互斥其他任何锁(注册到锁表),但是系统级的死锁检测线程会根据锁表每隔1秒找出对应的偏序对来构造等待图,并进行死锁检测
非可串行化隔离级别,无读锁,所以在锁表中只能有写锁,不能形成RW、WR等类型的偏序对,所以非可串行化隔离级别下带有RW、WR等的数据异常也不会被死锁检测消除。
1.2 实现可重复读隔离级别,在innobase_start_trx_and_assign_read_view函数中采用同一份全局快照(Readview,读已提交级别不会采用同一份快照),相当于快照隔离级别;这也表明可串行化并不是以可重复读隔离级别/快照隔离级别为基础的,表明隔离级别的实现不是逐层叠加实现的;——这点有点背离ANSI-SQL标准对隔离级别定义的初衷,也说明MySQL的隔离级别和数据正确性的实现方式,有点混乱
1.3 实现读已提交隔离级别,确保不使用间隙锁;这样间隙锁只能对可串行化隔离级别和可重复读隔离级别发生作用,因此这两个级别能够消除幻读数据异常。
1.4 对于所有隔离级别,写操作都是要施加锁的,因此和隔离级别“无关”(并注册到锁表中)。
1.5 封锁的作用,不只是让锁起到用户线程互斥的作用,同时构建全局锁表,标识出线程之间的等待关系到全局锁表lock_sys中,这为构造等待图做好了准备工作——这点很好地体现了MySQL用等待图确保数据正确性的思想。
1.6 外部施加锁,如通过SELECT…FOR UPDATE/SHARE等方式施加互斥/读锁,这些锁信息也会通过ha_innobase::external_lock函数保存下来;由此构成加锁的一大来源(另外一大源头是:根据隔离级别加锁和用户通过SQL语句主动加锁)。——这点可很好地说明《第三代分布式数据库(1)--踢球时代》中图4展示的“用户在应用中主动加锁导致数据库性能下降严重”的原因:数据库自己施加的锁,数据库通常能比较及时地释放;而用户施加的锁,系统是无法以比较好的方式自动处理的,且其执行时延会很长,长锁会严重影响着并发访问的效率。

2. 其次,采用MVCC技术,建立Readview快照,实现读一致性:

2.1 根据隔离级别,确定是用同一份快照(可重复度隔离级别)还是使用不同的快照(读已提交隔离级别)。
2.2在非可串行化隔离级别下,读操作不加锁,因此锁表中不会有读操作存在,所有和读操作有关的数据异常,都不能通过死锁检测的方式被发现进而。
2.3 快照的建立是需要花费时间的,且根据快照读取数据遍历旧版本也是要花费时间,如果快照中活跃事务的个数很多,则遍历过程会很耗时,因此使用快照可能带来较大的副作用,需要慎重对比在可串行化隔离级别下的环检测方法的耗时。
2.4 函数trx_assign_read_view(trx)被多种情况调用,存在滥用的可能。快照是一个事务级的对象,应该在事务初始阶段设置好即对整个事务有效,不需要在多个地方被生成多次。——而读已提交隔离级别下,生成多次快照,则会延长单个事务处理的时长,导致并发效率降低,因此使得“弱隔离级别的性能反而低于了较强隔离级别的性能”,又一次未被了定义隔离级别的初衷

3. 第三,对于WW冲突关系,没有采用ANSI-SQL标准

MySQL/InnoDB没有直接禁止脏读的WW偏序对发生(即没有采用2PL算法的No_wait策略,而是采用了Wait_die策略),而是允许WW偏序对发生,此时,采用环检测的方式,构造等待图,消除诸如《据库管理系统中数据异常体系化定义与分类》中定义的Dirty Write、Lost Update、Full-write Skew等类型的数据异常。
3.1 可串行化隔离级别下,MySQL对于《数据库管理系统中数据异常体系化定义与分类》中给出的大部分数据异常,都是采用死锁检测的方法进行消除的,这给其带来了较大的性能损失;类似Lost Update、Lost Update Committed等异常,其实有更好的方法加以消除,但MySQL没有采用.
3.2 MySQL采用等待图消除如上描述的数据异常,等待图被系统进程检测后报告的信息是死锁被发现的相关信息;.
3.3 MySQL采用等待图实现死锁检测和消除诸如Full-write Skew、Lost Update等异常,作为消除所有数据异常的兜底手段;这表明任何数据异常都有可能被随时消除掉;而不同隔离级别下所用的技术手段如第一条所示,差异不大,未能有明显造成性能瓶颈的因素(如可串行化隔离级别加读锁能构造出RW冲突进而消除Write Skew环,可重复读隔离级别只是通过快照消除了一些诸如Read Skew异常的发生但其实把其转化为Read-write Skew异常而被环检测消除;且这两个级别都会采用间隙锁消除幻读异常),因此,请思考:既然这样,区分隔离级别换取性能优势的理念又如何得以体现呢?隔离级别的设置还有什么价值吗?(后续单独成文讨论)

第四,幻读等类型的数据异常采用间隙锁

幻读类数据异常,在InnoDB储存引擎下,是在B+Tree索引树上通过间隙锁来消除的;间隙锁是作为其他锁的附加的方式,在隔离级别下被施加;之后根据锁的类型封锁B+Tree的范围。
加锁部分,实现分为两个方面:一是加锁的时候,读已提交隔离级别不加间隙锁,这样就能允许并发的其他事务执行插入操作因而产生幻象现象,因为读已提交隔离级别是允许幻象异常存在的。如下代码(适配V5.7等版本; 之后的代码如V8.0.33有所变化,但逻辑没有改变),加锁的时候,根据隔离级别确定是否加间隙锁。
row_sel_get_clust_rec[1](...){... if (!node->read_view) {... if(srv_locks_unsafe_for_binlog ||trx->isolation_level <= TRX_ISO_READ_COMMITTED){ lock_type = LOCK_REC_NOT_GAP; //小于等于读已提交,则不加间隙锁,允许其他事务插入,因此可发生幻象 } else { lock_type = LOCK_ORDINARY; //大于读已提交,则加间隙锁,防止其他事务插入某个范围内的数据,避免幻象 }...}
SQL语句中如带有的谓词的范围比较大,可能使得该谓词包含了B+Tree索引树的较高层次,采用间隙锁对应谓词范围,则使得B+Tree索引树的并发度严重降低(这在物理的B+Tree索引树加锁带来严重的性能后果)。这是间隙锁带来的负面影响。
MySQL的实现技术细节

1. 加锁的方式

MySQL/InnoDB的记录锁的结构体为lock_rec_t,其描述了一个页面上哪些记录被加锁(注是用页面而不是元组上对应锁相关信息)。该结构体之后紧跟着一个位图,用于标记一个页面中有哪些记录被加锁。
1. 锁施加成功后,会存储在物理页面中(数据缓冲区中以page为单位的物理存储块);
2. 新的锁施加前,会从数据中读出已经施加锁的数据和锁施加者,调用add_to_waitq作为本事务的阻塞者(lock_prdt_insert_check_and_lock函数调用lock_prdt_other_has_conflicting函数);

2. 加锁的时机

根据SS2PL算法,加锁是发生在获取元组的逻辑处,即读或写元组。因此这是逻辑层面的加锁时机,而MySQL/InnoDB的记录锁是存储在物理page结构上的,因此逻辑和物理之间存在差异,需要把逻辑的加锁语义传递到物理page层面。如下代码调用栈,展示了四个逻辑层面的加锁函数。
这四个层面,和InnoDB的索引组织表的结构直接相关(数据的访问必须通过主键索引)
lock_rec_lock(lock0lock.cc) //没有语义的记录锁的加锁函数lock_rec_lock(),接受上层参数指定的加锁要求,然后进行加锁 lock_clust_rec_modify_check_and_lock(lock0lock.cc)//因为修改(更新、删除)操作在主键索引记录上加记录锁;此处带有加锁的环境和逻辑原因    lock_clust_rec_read_check_and_lock(lock0lock.cc)//因为读操作在主键索引记录上加记录锁;此处带有加锁的环境和逻辑原因 lock_sec_rec_modify_check_and_lock(lock0lock.cc)//在二级索引上的修改(更新、删除)操作触发加写锁;此处带有加锁的环境和逻辑原因 lock_sec_rec_read_check_and_lock(lock0lock.cc)//在二级索引上的读操作触发加读锁;此处带有加锁的环境和逻辑原因 lock_sec_rec_modify_check_and_lock(lock0lock.cc) btr_cur_upd_lock_and_undo(btr0cur.cc) btr_cur_del_mark_set_sec_rec(btr0cur.cc) lock_sec_rec_read_check_and_lock(lock0lock.cc) row_ins_set_rec_lock(row0ins.cc) sel_set_rtr_rec_lock(row0sel.cc) sel_set_rec_lock(row0sel.cc)//如下2个函数,在逻辑层面,决定了为什么需要加锁(分别是读、写操作触发加锁)    lock_clust_rec_modify_check_and_lock(lock0lock.cc)    lock_clust_rec_read_check_and_lock(lock0lock.cc)

3. 死锁检测

MySQL V8.0.33版本的死锁检测,由两部分构成:第一个是独立的系统进程,定期检查死锁是否存在;第二个是在事务执行的时候,事务自行检查是否存在死锁;前者是定期1秒进行一次死锁检测;后者是实时地进行检测。但二者所采用的数据结构不同。
第一种,系统级死锁检测方式:MySQL V8.0.33版本的死锁检测,由一个独立的系统线程进行。在InnoDB系统初始化的时候,innobase_init_files函数调用srv_start初始化出一个独立的线程,定期1秒从全局锁表“lock_sys->waiting_threads”的等待队列复制出一份静态的“等待队列内存快照”信息,然后根据该信息构造等待图(lock_wait_build_wait_for_graph),然后检查等待图上是否存在死锁(lock_wait_update_schedule_and_check_for_deadlocks)。
lock_wait_build_wait_for_graph(lock0wait.cc)//每次做死锁检测前,都要重新构造一次等待图lock_wait_compute_and_publish_weights_except_cycles(lock0wait.cc)lock_wait_update_schedule_and_check_for_deadlocks(lock0wait.cc)//此行及上层函数,被lock_wait_timeout_thread调用,但只有本行函数受innobase_deadlock_detect参数控制,不合理,可改进 lock_wait_timeout_thread(lock0wait.cc)//定期1秒进行死锁检查 srv_start(srv0start.cc) //开启一个系统级线程专门负责死锁检测 innobase_init_files(ha_innodb.cc)
第二种,实时检测方式:有两种实现:实现一,通过lock_wait_request_check_for_cycles()调用lock_set_timeout_event()实现锁等待超时机制,去消除死锁。实现二,在check_and_report_deadlock函数中对主从复制(master-slaver)机制中的slaver角色进行死锁判断,如果存在死锁则报告死锁;这种方式,作用范围有限,不对master起作用。换句话说,在一个独立的MySQL/InnoDB实例中,只有一种死锁检测方式发生作用,那就是前面讨论的第一种“系统级死锁检测方式”,这使得MySQL/InnoDB的死锁检测不会耗费大量的计算资源,而旧的版本则因实时检测死锁导致系统不可使用
Commit_order_manager::check_and_report_deadlock(rpl_replica_commit_order_manager.cc) //备机上检测死锁是否存在 thd_report_lock_wait(sql_thd_api.cc) lock_report_wait_for_edge_to_server(lock0lock.cc)            lock_create_wait_for_edge(lock0lock.cc)                RecLock::add_to_waitq(lock0lock.cc) //从数据页上找出锁施加者,然后与正在执行加锁的事务构成等待关系                    lock_rec_lock_slow(lock0lock.cc) lock_rec_lock(lock0lock.cc) //如下四个函数,在逻辑层面,决定了为什么需要加锁(分别是读、写操作触发加锁) lock_clust_rec_modify_check_and_lock(lock0lock.cc) lock_sec_rec_modify_check_and_lock(lock0lock.cc) lock_sec_rec_read_check_and_lock(lock0lock.cc) lock_clust_rec_read_check_and_lock(lock0lock.cc) lock_rec_insert_check_and_lock(lock0lock.cc) lock_prdt_insert_check_and_lock(lock0prdt.cc) lock_prdt_lock(lock0prdt.cc)               lock_table_enqueue_waiting(lock0lock.cc)           lock_update_wait_for_edge(lock0lock.cc)               lock_rec_grant_by_heap_no(lock0lock.cc) lock_grant_or_update_wait_for_edge(lock0lock.cc)
MySQL/InnoDB V5.7版本使用deadlock_check()对记录锁进行死锁检测,步骤较为简单,第一步是死锁处理,即找出死锁并回滚受害者事务;第二步是检查死锁处理的结果。这和V 8.0.33处理死锁的方式是不同的。

5.   为什么要创建Readview(快照)?

MySQL采用快照,有助于对带有较多的读操作的事务提高性能,这是因为读操作没有被写锁阻塞。但这仅仅是理论层面的分析结论,在实践中该结果尚需用真实场景检测(可惜现有的benchmark鲜有能测试到这样的重要细节之处)。
为什么说在实践中需要检验真实效果呢?这是因为:快照的创建和使用,均有较大的代价。有的数据库如MySQL和PostgreSQL,采用活跃事务列表作为快照,而并发量很高、每个数据项存在较多版本的情况下,活跃事务列表会很大、版本链会较长,导致遍历速度会变慢,而且每个事务因冲突判断有多次遍历活跃事务的可能(创建快照时对其他事务造成的阻塞影响、尤其是冲突判断如果在每个操作执行时都进行的话),因此其代价可能会超过单纯采用SS2PL算法写阻塞读的代价,因此需要在实践中权衡,更需要在内核的设计中针对特定场景镜像设计和权衡。
MySQL对于Readview的创建,与隔离级别有密切关系:对于可重复读和可串行化隔离级别,无论事务中有多少条SELECT语句,Readview只创建一次;而读已提交隔离级别,视图会随着SELECT语句的个数创建多次,这对并发性能的影响会较大,因此MySQL的RC的性能其实不优于RR这在事实上也打破了弱隔离级别性能好与比起强的隔离级别的美梦)。MySQL/InnoDB具体实现的代码调用栈如下,大家可调试、实测验证。
trx_assign_read_view() row_search_mvcc() ha_innobase::index_read() handler::index_read_map() handler::ha_index_read_map()
 示例与细节
如下多数示例,是在可串行化隔离级别下测试验证,在每个客户端执行如下命令:
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;SELECT @@transaction_isolation;
1. 死锁检测消除数据异常
MySQL对于Read Skew、Write skew等DDA类的数据异常,通常会在偏序环形成后报告“Deadlock found when trying to get lock”错误提示(如V8.0.33版本的死锁检测,由一个独立的系统线程进行死锁检测),表明数据异常被发现,然后回滚本事务。具体过程如表1所示,过程的描述对应了MySQL/InnoDB的并发算法的实现细节,需要仔细体会哈。
表1 MySQL对于Read Skew的执行过程
时间
T1
T2
说明
t0
R1[x]
事务T1在变量x上施加读锁
t1
W2[y]
事务T2在变量y上施加写锁
t2
W2[x]
事务T2在变量x上不能得到事务T1施加的锁,但会注册其写锁的信息到锁表,这将在变量x上形成T1->T2的偏序对RW;之后做死锁检测,没有死锁发生,故只能观察到T2处于等待的现象
t3
R1[y]
事务T1在变量y上不能得到事务T2施加的锁,但会注册其写锁的信息到锁表,这将在变量y上形成T2->T1的偏序对WR;之后做死锁检测,有死锁存在,故回滚一个事务(此时选择回滚了T1)
Lost Update、Lost Update Committed在MySQL也被死锁检测消除,其执行过程如表2所示。同时,该示例表明MySQL不支持托马斯写规则。
表2 MySQL对于Lost Update的执行过程
时间
T1
T2
说明
t0
R1[x]
事务T1在变量x上施加读锁
t1
W2[x]
事务T2在变量x上申请写锁(成功注册到锁表,)但未获得,在变量x上形成T1->T2的偏序对RW;之后做死锁检测,没有死锁发生,事务T2等待
t2
W1[x]
理论上:事务T1在变量x上持有锁,升级到写锁,同时在变量x上形成T2->T1的偏序对WW;之后事务T2会被检测存在死锁而被回滚
实践中:实际的情况是,因为事务T2已经在X上注册了写锁,事务T1实际上并未申请到写锁(指未执行过“rec_lock.create(trx)”类似代码(锁的状态值为LOCK_REC_FAIL),锁记录在page上,可能将导致同一个page上不同变量的锁的申请成功或失败),而是从page上查询到本事务曾经申请过读锁,因而可被授予写锁
t3
COMMIT
事务T1提交成功;系统进行死锁检测,发现死锁,选择T2作为受害者回滚
还有一种可能是(尤其是调试模式下,锁等待超时值较短的情况下),可能在事务T2上发生:ERROR 1205 (HY000): Lock wait  timeout exceeded; try restarting transaction

2. 避免方式消除数据异常

MySQL对于同一种数据异常,在不同隔离级别下处理方式是不同的(封锁与快照等技术之间相互影响,有主有次),因而会有不同的结果,这一点需要特别应以注意。
例如,SDA类的Non-repeatable Read数据异常,只在单个变量上发生,在可串行化隔离级别下,因为使用了快照技术,即使在封锁技术下,类似WR这样的偏序对不能形成,因此不会形成环,数据异常被避免(没有形成死锁)。示例如表3。
表3 MySQL对于Non-repeatable Read的执行过程
时间
T1
T2
说明
t0
R1[x]
事务T1在变量x上施加互斥锁
t1
W2[x]
事务T2在变量x上获得写锁(更新成功),这在变量x上形成T1->T2的偏序对RW;之后做死锁检测,没有死锁发生,事务T2等待
t2
R1[x]
事务T1在变量x上持有锁,使用同t1时刻的同一个快照,读到本事务所读过的变量值,避免了WR偏序对的形成
t3
COMMIT
事务T1提交成功后释放锁;T2得到锁后可被执行
对于与Non-repeatable Read数据异常类似的Non-repeatable Read Committed,不同隔离级别下,处理方式却有很大的不同。表4展示了可串行化隔离级别下的实现细节。
表4 MySQL对于Non-repeatable Read Committed的执行过程(可串行化)
时间
T1
T2
说明
t0
R1[x]
事务T1在变量x上施加互斥锁
t1
W2[x]
事务T2在变量x上获得写锁(更新成功),这在变量x上形成T1->T2的偏序对RW;之后做死锁检测,没有死锁发生,事务T2等待
t2
COMMIT
先提交者获胜,事务T2被允许提交;事务T2提交成功后释放锁
t3
R1[x]
事务T1在变量x上持有锁,使用同t1时刻的同一个快照,读到本事务所读过的变量值,避免了WR偏序对的形成
t4
COMMIT
事务T1提交成功  

3. 出现数据不一致的现象

在“一致性八仙图”发布后,有读者发来“mysqlrc隔离级别没有解决不可重复读”的提问。那么,MySQL究竟是否在RC隔离级别下消除了不可重复读数据异常?
首先,人们对数据异常的认知不明确,所以什么样的数据异常是同一类异常是不明确的。在一致性八仙图中,把传统的混在一起的“R1[x0] W2[x1] (C2) R1[x]”做了区分(C2表示事务T2提交,有无C2差异很大,这关系到了并发实现的具体规则,因此有必要区分,而ANSI-SQL标准把这种重要差异未做区分,导致导致认为带有与不带有C2都是一样的,其实不然),分解为Non-repeatable Read和Non-repeatable Read Committed两种情况。
其次,因对数据异常缺乏足够的了解,所以得到的并发处理算法的规则是不统一的。而MySQL等一部分数据库事实上在工程实现上会进行这些差异的区分,不完全拘泥于ANSI-SQL标准。
不同数据库对于一些貌似一样的问题,处理方式却不一样。
在可重复读隔离级别下,MySQL因为使用了同一个快照,Non-repeatable Read和Non-repeatable Read Committed都可以被避免;而在读已提交隔离级别下,Non-repeatable Read因为“读已经提交的数据项”这一规则而被避免,而Non-repeatable Read Committed异常却因为这一规则而发生(如表5)。
表5 MySQL对于Non-repeatable Read Committed的执行过程(读已提交)
时间
T1
T2
说明
t0
R1[x0]
事务T1在变量x上施加互斥锁
t1
W2[x1]
事务T2在变量x上获得写锁(更新成功),这在变量x上形成T1->T2的偏序对RW;之后做死锁检测,没有死锁发生,事务T2等待
t2
COMMIT
先提交者获胜,事务T2被允许提交;事务T2提交成功后释放锁
t3
R1[x1]
事务T1在变量x上持有锁,使用与t1时刻不同的快照,但在读已提交规则作用下,读到T2事务所写过的变量值x1,形成了数据异常
t4
COMMIT
事务T1提交成功,但数据异常发生
同样相似的例子,还有其他(如表6,每两个之间做对比,可以观察类似的数据异常只因为带有了COMMITTED则处理方式和结果却不同了),他们在弱隔离级别变迁到强隔离级别的过程中,采取了不同的解决方式,并且有着不同的结果。
表6 MySQL-简化的一致性八仙图
数据异常
SER
RR
RC
RU
Non-repeatable  Read
P
P
P
A
Non-repeatable  Read Committed
P
P
A
A
Write-read Skew
D
A
A
A
Write-read Skew Committed
D
P
P
A
   例如,表6中的Write-read Skew和Write-read Skew Committed在可重复读和读已提交隔离级别下的处理方式“居然”不同,但在可串行化下却都采用了死锁检测的方式消除异常。

五 写在最后

在一个TP型数据库系统中,事务处理技术时最难也是最最复杂的。如果是掌握MySQL整体的并发知识,如上内容基本上够用了。但如果想彻底掌握他们,除了掌握前述的原理外,还需要通过对比的方式,研究学习其他数据库的优点。最好是用所举的例子逐一比对其他数据库的实现细节,以掌握并发算法的重要细节。
掌握MySQL的并发算法细节,还欠缺一个重要环节——隔离级别。
结合隔离级别的发展史、理论、MySQL中的不同的隔离级别实现技术,方能更好地理解并发算法的处理细节。
下一篇,正是对隔离级别的发展史和MySQL中的不同的隔离级别实现技术进行详细描述,以作为本文的补充。
但是,隔离级别又是每个数据库中都具备的内容,因此下一篇的内容也适合其他数据库系统,因此独立成文。
[1]位于row0sel.cc文件中。
更多事务处理和分布式相关知识,可参考如下2本图书。
继续阅读
阅读原文