MySQL 系列 事务概念篇

从概念(理论)上讲述事物,不涉及具体实现

Posted by lichao modified on October 26, 2023

事务会把数据库从一种一致状态转换到另一种一致状态。事务是访问并更新数据库中各种数据项的一个程序执行单元。在数据库提交事务时,可以保证要么所有修改都保存成功,要么所有修改都不保存。

事务是上层执行所产生的一系列操作,这些操作有如下限制:

  • 要么全做,要么全不做;
  • 并发事务之间是相互隔离的,使得操作前后始终遵守所有约束(一致性);

事务四大特性(ACID)

原子性(Atomicity)

原子性是指事务必须是一个原子的操作序列单元。事务中包含的各项操作在一次执行过程中,只允许出现两种状态之一: 全部执行成功或全部执行失败。

任何一项操作失败都会导致整个事务的失败,同时其它已经被执行的操作都将被撤销并回滚,只有所有的操作全部成功,整个事务才算是成功。

一致性(Consistency)

一致性是指事务必须使数据库从一个一致性状态变换到另一个一致性状态,也就是说一个事务执行之前和执行之后都必须处于一致性状态。事务开始前和结束后,数据库的完整性约束没有被破坏 。当然也不仅仅是数据定义的完整性约束。

另一种说法,一致性是指这些数据在事务执行完成这个时间点之前,读到的一定是更新前的数据,之后读到的一定是更新后的数据,不应该存在一个时刻,让用户读到更新过程中的数据。

隔离性(Isolation)

事务隔离性是指在并发环境中,并发的事务是互相隔离的,一个事务的执行不能被其它事务干扰。也就是说,不同的事务并发操作相同的数据时,每个事务都有各自完整的数据空间(多个事务操作同一个临界区时,需要通过锁逻辑等机制进行处理)。

事务内部的操作及使用的数据对其它并发事务是隔离的,并发执行的各个事务是不能互相干扰的。

持久性(Durability)

事务持久性是指事务一旦提交后,数据库中的数据必须被永久的保存下来。即使服务器系统崩溃或服务器宕机等故障。只要数据库重新启动,那么一定能够将其恢复到事务成功结束后的状态。持久性保证事务系统的高可靠性, 而不是高可用性

事务特征实现

原子性

事务具有原子性说明事务是数据库操作的最小单位。做到原子性需要以下能力的支持:

  1. RollBack

类比普通原子操作,事务就是临界区,如果都是内存操作,当事务执行发生错误时,直接丢弃内存,释放临界区即可。但不幸的是部分操作有可能已经持久化了,所以数据库必须支持回滚操作,将修改的脏数据再改回去。

  • 识别异常

在事务执行过程中如果发生宕机等异常,如果重启后数据库不能识别异常,将没有机会对宕机前未提交数据产生的修改进行回滚,所以必须提供一种机制来判定事务提交是否发生异常。

数据库发展的历史长河中,主要有两种方案解决原子性问题:

  • Shadow Page
  • Write Ahead Log(WAL)

Shadow Page

Shadow Page秉承的是Copy On Write的思想,发生修改时:

  • 当page发生修改,先copy出来一个副本,作为shadow page,在shadow page上进行修改;
  • 事务提交时,将所有的修改页面在磁盘中持久化,不能覆盖之前的页面;
  • 最后修改Database Root,标识着新事务的提交,此前发生故障,其他事务的访问操作不会访问到shadow page。 脏读示例

该方案主要以System R为代表,因此我们采用了System R的图例。

Shadow Page的方案性能不优,但恢复逻辑相对简单。

简单解释原子性逻辑:

  1. 事务提交前,数据库访问看到的是蓝线的访问,不会看到未提交的数据;
  2. 事务提交后,所有的修改已经持久化,肯定不会丢失,数据库访问看到的是红线的访问;
  3. 事务提交前Fail,所有红色页面都是Garbage,需要被清理,无需关心红色数据页面是否只有部分页面修改;

Write Ahead Log(WAL)

Shadow Page的持久化方式比较中,页面的修改是随机写,性能不优。Write Ahead Log方案希望把事务的修改顺序的持久化。当代数据库产品,主要采用这种方式:

  • 事务在最新数据页面上修改,可能是原地修改+undo log,也可能是追加修改;
  • 事务的修改顺序的写WAL日志;
  • 事务提交时:
    • 先持久化WAL日志;
    • 再异步刷数据页面

wal

简单解释原子性逻辑:

  1. 事务未提交前,虽然页面被修改了,其他事务的读操作会根据WAL构造出脏页前的版本进行读取;
  2. 事务提交后,脏页标识为提交页,根据可见性逻辑进行读取;
  3. 事务提交时Fail
    1. 如果WAL已经写完,标识事务已经提交,即使所有的脏页没有完成刷盘进行持久化,恢复操作也需要回放WAL日志,保证事务所有修改都作用到数据页面上;
    2. 如果WAL还没写完,标识事务没有完成提交,根据WAL规则数据页也没写完,部分已经持久化的脏页需要根据WAL进行回滚;

一致性与隔离性

一致性和隔离性在实现层面不是相互独立的,它们是耦合在一起的,主流解决方案有:

  • 2PL并发控制
  • MVCC并发控制

持久性

持久性的解决方案在Atomicity中已经描述,不过Atomicity关注的是宕机情况下重启后仍然能识别异常和回滚,而持久性关注的是已经提交的事务必须保证持久化。

  • Shadow Page——事务提交先持久化Shadow Page和更新Database Root;
  • WAL——事务提交先写WAL日志,保证提交信息落盘;

事务并发控制

事务并发控制是实现Isolation和Consistency的基础理论。

可串行化理论

从直观引出的正确性

并发中需要解决的问题是——什么样的事务执行是符合隔离性和一致性的?

直观上来讲,某个时刻只有一个事务执行,那么这种执行是满足隔离性和一致性的。 扩展到多个事务的执行,多个事务是One by One执行的,那么这种执行也是满足隔离性和一致性的。

多个事务并发执行呢?如果多个事务的并发执行的结果等价于One by One执行的结果,且整个过程满足顺序一致性,即每个时刻都可以找到One by One执行的对应时刻,且结果按相同时间序变化,那么这个执行就是正确的,满足隔离性和一致性。

串行化理论就是研究各种调度与串行调度等价的理论。

相关概念:

串行调度:一个接一个依次完成的调度就是串行调度;

等价调度:对于任何数据库状态,两个调度都达成相同的状态,则认为两个调度是等价的;

可串行化调度:如果一个调度与一个串行调度是等价调度,那么这个调度是可串行化的调度(换句话说,它就是一个正确的能够满足隔离性和一致性的调度);

冲突可串行化理论

冲突可串行化,是从易操作角度出发,找到一个可串行化调度的子集,便于并发性保证事务的CI特性。

冲突可串行化,是调度正确的充分条件,而不是必要条件!

什么是冲突
  • 将事务action抽象为一系列的读(R)操作和写(W)操作;
  • 属于不同的事务操作才能引发冲突;
  • 处理相同的对象才能引发冲突;
  • 不同的操作顺序产生不同的结果才有冲突:
    • Read-Write 冲突(R-W),跨越W的读会发生变化;
    • Write-Read 冲突 (W-R),读写之前的数据是已提交的,读写之后的数据可能是未提交的;
    • Write-Write 冲突 (W-W),后面的写会覆盖前面写的结果;
    • 补充:Read-Read是不冲突的,谁先读都是相同的结果;
冲突可串行化

冲突可串行化是通过冲突判断是否可串行化的方法。

如果一个调度与一个串行化的调度冲突等价,那么这个调度是冲突可串行化的。

冲突等价

两个调度是冲突等价的,当且仅当,两个调度有相同操作且冲突操作的顺序是相同的。比如:

1
2
S1:R(A) R(B) W(A) W(B)
S2:R(B) W(B) R(A) W(A)

<R(A), W(A)>、<R(B), W(B)>是调度中 的两对冲突,每对冲突在两个调度中的顺序都是相同的,都是先读后写,所以两个调度是等价的。(A和B是不同的对象,W(A)与W(B)没有冲突)

基于锁的并发控制

基于锁的并发控制就是从冲突角度出发,控制调度顺序,使之满足冲突可串行化。

锁使用的本质就是阻止冲突的顺序在调度中改变!

基于锁的并发控制

如果是冲突可串行化的,那么T1和T2的冲突操作一定都需要保持相同的顺序,如:

  • S1:T1_R(A)-->T2_W(A)
  • S2:T2_W(A)-->T1_R(A) -->:左侧操作在前,右侧操作在后,在图示例子中,T1_R(A)-->T2_W(A) and T2_W(A)-->T1_R(A) 矛盾。不能冲突等价于串行化调度。所以需要通过锁来避免某些调度。

两阶段锁协议——2PL

两阶段提交是在冲突可串行化理论指引下控制调度序列的一套协议。在该协议下能够完成的调度都是冲突可串行化调度。也就是说这种调度下的并发是正确的。

两阶段锁协议的定义:

Phase #1: 加锁阶段:

  • 只有加锁阶段可以进行加锁操作;
  • Well-formed Reads and Write。任何数据在访问前都需要加锁;
    • 读操作申请共享锁(S锁);
    • 写操作申请排它锁(X锁);
  • 加锁不成功,需要进入等锁状态,直至成功; 在该阶段可以进行加锁操作。在对任何数据进行读操作之前要申请并获得S锁,在进行写操作之前要申请并获得X锁。加锁不成功,则事务进入等待状态,直到加锁成功才继续执行。

Phase #2: 解锁阶段:

  • 从第一个解锁操作开始,进入解锁阶段;
  • 在该阶段只能进行解锁操作不能再进行加锁操作;
  • 解锁之后不能再访问数据; 示例2PL解决上述不可重复读问题: 2pl 示例调度冲突等价于串行调度T1-->T2

基于MVCC的并发控制

MVCC并发控制另辟蹊径,它并不是完全在同一份数据上控制调度顺序,而是将历史修改的物理版本留存了下来,称之为快照,增加了对快照的访问。

Multi-version concurrency control定义

Multi-version concurrency control(MVCC)是维护了数据库内对象的多个修改版本,读和写分别有针对性的访问各自所需的版本,从而避免冲突的一种并发访问协议:

  • 写事务提交时,对所修改的数据产生一个新的版本。对这个版本可见的所有数据,称之为当前版本的快照;
  • 读事务以特定的版本读取与之对应的快照数据;

MVCC带来了什么?

  1. 读写操作不再冲突。因为读操作读取的都是已经提交的历史版本,既不需要再加锁(意味着性能的提升),也不会有脏读问题;
  2. 写写操作仍然需要判别冲突。因为写操作都是在最新版本上完成的,需要防止丢失修改。

多版本并发控制原理

读写不再冲突,处理了写写冲突,就实现了冲突可串行化。

读历史版本:

多版本并发控制解决读写不冲突的问题。用例采用较难的Phenomenon——Phantom read(参见事务隔离级别——Phenomena)。 2pl

读操作始终使用事务获取快照时刻的Snapshot,相当于把事务中所有的读操作都凝聚成了获取快照点的读操作,不会再有读取操作跨写操作的情况。后续的写操作,即使是插入操作,也会获取更高的版本,对读快照永远不可见。

写写冲突处理:

MVCC并发控制的写通常采用OCC(Optimistic Concurrency Control)的方式,即先在本地修改,构建write set,提交时check write set是否与读的版本一致(发生改变,说明被其他事务先提交),如果一致可以提交,否则事务提交失败。

  1. 如果Write set不是最新版本,即其中的数据被其他事务更改为更高版本了,则回滚;
  2. 如果Write set是最新版本,以更高的版本进行原子提交; 2pl

ps:仅仅对write set进行check并没有解决所有的正确性问题,因为在一个事务中读写并不是割裂的,read set也需要保证不变才能保证可串行化。

Write skew

多版本并发控制MVCC兴起后,由于其优异的读写不互相阻塞特性,在新兴数据库中被广为使用。这其中有一段小故事,Oracle最初一段时间宣称它基于MVCC实现的最高隔离级别是Serializable隔离级别,直到后来发现Write skew问题。

write_skew 事务T1和事务T2分别读取了相同的快照,T1的write set是{record1},T2的write set是{record2},所以各自提交的时候都没有冲突,修改的数据都是最新的版本,最终的结果是record1和record2的val互换。

在串行化的执行中,如果执行顺序是T1、T2,执行的结果record1和record2都为50;如果执行顺序是T2、T1,执行的结果record1和record2都为80。不存在其他的串行化顺序。所以以上基于快照的并发调度结果一定不是可串行化的。

在锁实现中,如果T2读取Val=80加的是长读锁,T1是不能获得这个record上的写锁的,所以不会出现这种异常。这种异常是基于快照实现所独有的。

事务隔离级别

隔离级别,实际上是正确性向性能的一种妥协。满足一致性,那么数据库层面在事务执行前后所有的约束都应该是满足的,不应该有任何错误的现象。不幸的是,为了实现一致性,数据库系统的运行需要加上比较强的限制,导致数据库并行运行效率低下。举例来讲,每个事务定好序,或者执行前对全局加锁,依次执行,可以保证一致性,但丧失了并发能力。减少并发限制,牺牲一定程度的一致性,这就是隔离级别的初衷。将一致性按照问题现象的严重程度进行划分,与之对应定义相应的隔离级别。

本文介绍基础的广为大家认知的问题现象和隔离级别,即ANSI SQL92中对问题现象的定义和相应的隔离级别。在SQL92标准产生的历史时期,并发控制主要采用基于锁的并发控制,所以其中定义的隔离级别都是锁实现下的比较直观的隔离级别。当然,这些基础的隔离级别是有一定的普适性的。随着事务研究的发展和基于MVCC并发控制的普及,更多的问题现象和隔离级别被提出,有兴趣可参见《A Critique of ANSI SQL Isolation Levels》,本文仅扩展到Snapshot Isolation。

问题现象(Phenomena)

按照严重性从强到弱依次为:Dirty read、Non-repeatable read、Phantom read

脏读(Dirty read)

脏数据是指还未提交的数据。如果读到了脏数据,即一个事务可以读到另外一个事务中未提交的数据,则显然违反了数据库的隔离型。

脏读发生的条件是需要事务的隔离级别为 Read UnCommited。

脏读会有什么问题呢?

  • 写事务可能最终没有提交,按照原子性要求,数据会回滚到最初的样子,而读数据读了一个不存在的数据;
  • 写事务可能对同一个数据有多次修改,读数据读到的是中间数据,即使写数据最终提交了,读到的数据也不会是正确的;

脏读示例

  • T1是一个充值操作,但是最终回滚了,充值取消;
  • T2是一个消费操作,如果允许Dirty read,T2将消费成功,而实际上账户并没有那么多钱。

脏写

更新的数据一段时间后没了。A 和 B 先后更新同一条记录,A 更新完后使用 undo log 回滚,导致之后写入的数据也被回滚了。

不可重复读

不可重复读是指,一个读事务在执行的不同阶段读取同一条数据两次,但两次读到的值却不同,其违反了数据库事务一致性的要求。产生的原因是读的过程中并没有对读取的数据加限制,使得读取过程中,另外一个事务可以修改这条数据并提交(没有脏读)。 重复读

丢失更新(Non-repeatable read(fuzzy read))

一个事务的更新操作会被另一个事务的更新操作所覆盖,从而导致数据的不一致。例如:

  1. 事务 T1 将行记录 r 更新为 v1, 但是事务 T1 并未提交
  2. 与此同时,事务 T2 将行记录 r 更新为 v2, 但是事务 T2 未提交
  3. 事务 T2 提交
  4. 事务 T1 提交

在当前数据库的任何隔离级别下,都不会导致数据库理论意义上的丢失更新问题。 这是因为即使在 Read Uncommitted 的事务隔离级别,对于行的 DML 操作,需要对行或其他粒度级别的对象加锁。因此在步骤 2 中,事务 T2 并不能对行记录 r 进行更新操作,其会被阻塞,直到事务 T1 提交。

幻读(Phantom read)

幻读是指给定限制条件的查询在同一个事务中执行两次,两次查询的结果条目数不相同,好像发生了幻觉。因为两次查询之间有另外一个事务在查询范围内插入或删除了条目。(区别于不可重复读,不可重复读是读取的数据发生了变化,而幻读是读取的范围发生了变化。本质上也是一种特殊的的不可重复读,但因为在锁理论层面的特殊性,还是把二者区分开来。)

隔离级别定义

在标准 SQL 规范中,定义了 4 个事务隔离级别,不同的隔离级别对事务的处理不同。4个隔离级别分别是:读未提交(READ_UNCOMMITTED)、读已提交(READ_COMMITTED)、可重复读(REPEATABLE_READ)、顺序读(SERIALIZABLE)。

隔离级别就是从避免什么样的Phenomena来定义的,一张图展示隔离级别:

Isolation level Dirty read Non- repeatable read Phantom read
Read uncommitted Possible Possible Possible
Read committed Prevented Possible Possible
Repeatable read Prevented Prevented Possible
Serializable Prevented Prevented Prevented

工具术语:

基础的隔离级别发源于锁并发控制理论,所以下面将从锁实现角度来解释四种隔离级别。

  • Well-formed Reads,好形式的读是指在读数据项前对数据项或者谓词(Predicate)加读锁,即不能裸读;
  • Well-formed Writes,好形式的写是指在修改数据项前必须获得数据项或者谓词的写锁,即不能裸写;
  • Short locks,短锁是指在访问数据期间持锁,而不需要整个事务期间持锁;
  • Long locks,长锁是指在获得数据或者谓词的锁之后,即使不访问也不能释放,直到事务结束;
  • Data-item locks,数据项锁是指加在已有数据上的锁,与实现形式有关,不好对不存在的数据加锁;
  • Predicate locks,谓词可以直观的理解为访问条件,谓词锁就是在查询条件上加锁,如访问范围,范围内不能新增和删除数据;

后续解释所用术语将是这些基础术语的组合。

read uncommitted 读未提交

读未提交,就是不加任何控制的并发。此隔离级别,所有事务都可以“看到”未提交事务的变更。其隔离级别是最低的。

前文所讲任何 Phenomena 都可能发生;

read committed 读已提交

大多数数据库系统的默认隔离级别(不是 MySQL 的默认隔离级别),满足了隔离的早先简单定义:一个事务开始时,只能“看见”已经提交事务所做的改变,一个事务从开始到提交前,所做的任何数据改变都是不可见的,除非已经提交。显然,这是对写操作加了限制,直到事务提交才能释放写锁。

read_commited

Read committed 当读取数据库时,只能看到已提交的数据,即无脏读。同时,当写入数据库时,也只能覆盖掉已提交的数据,即无脏写,但是不能避免“不可重复读”和“幻读”:

  • 因为,读锁是短锁,读过数据之后就可以释放,当再次读时重新加锁,两次读之间可能出现和完成其他事务的更新操作,造成两次读的结果不一致,不保证没有“不可重复读”;
  • 读锁是 Data-item 锁,即使读锁没有释放,读的过程中虽然可以避免已经访问过的数据被修改,但不能拒止新数据的插入,所以再次可能访问到其他事务在本事务读过程中插入的数据,不能保证没有“幻读”;

repeatable read 可重复读

可重复度,是在Read committed的基础上加入了对读的限制,保证两次读取过程中,数据不能被其他事务修改。

repeatable read 通过增加了锁持有的时长,保证了已经读过的数据再次读的时候不会被修改,因为和写锁互斥,会阻塞写锁到读操作完全结束。 但是 repeatable read 不能避免“幻读”,因为它持有的长锁类型仍然是Data-item锁,这种锁只能锁住已经存在的数据,不能锁住空间,阻止新数据的插入。所以依然不能避免“幻读”。

serializable 可串行化

可串行化,是在Repeatable read的基础上丰富了锁的类型,保证访问的谓词区间内不会有新的数据插入。

至Serializable隔离级别,所有的问题线性都已经消除,可以保证事务的隔离性和一致性。 Serializable

从Read committed隔离级别至Serializable隔离级别,锁的限制是越来越严格的,越严格的锁,临界区越大,并发效果理论上越差,所以为了性能,数据库的实现定义了不同的隔离级别,允许不同级别的Phenomena的发生,隔离级别越高,发生的Phenomena种类越少;

\[Read uncommitted < Read committed < Repeatable read < Serializable\]

在 serializable 隔离级别中,对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。

serializable 的事务隔离级别主要用于 InnoDB 存储引擎的分布式事务。

serializable 保证了以某种顺序执行事务,并不能保证一定要以某个确定的顺序来执行。

Snapshot Isolation

采用 MVCC 实现的可重复读隔离级别,具有 Write skew 问题。最终,这种几乎等价于Repeatable read,也不是Serializable的隔离级别被定义为Snapshot Isolation。Snapshot Isolation与Repeatable read的强弱不可比较,互有自己可排除的Phenomenon。