一、概述
事务的ACID特性是关系型数据库的重要特点,在很多领域有着重要作用。本文试图介绍下事务ACID的实现机制,不求全面和准确,仅提供一些思路,以在未来遇到类似场景时,能有一点启发。
二、ACID特性
A-原子性:事务中的statement操作,要么同时成功,要么同时失败。事务是执行的不可分割的最小单元;
C-一致性:一致性在计算机领域有很多不同的解释,比如在分布式理论中一般是指“各节点保证各自的数据副本同步一致”,而在事务这里的C,指的是在事务执行之后,数据仍然满足如级联、约束等这些
I-隔离:当多个事务同时执行的时候,它们在数据可见性上的隔离程度。分别是Read Uncommitted、Read Committed、Repeatable Read、Serializable;
D-持久性:当客户端一旦收到数据库返回成功的消息,那么事务中的数据就被永久保存,不会丢失;
那这几个特性,关系型数据库一般是如何做到的呢? 主要依靠MVCC(MultiVersion Concurrent Control)和WAL(Write Ahead Log)。其中MVCC控制的是ACID中的I部分,WAL控制的是AD部分。C特性由谁保证呢? 我理解在保证了AD特性——不会在事务结束后出现半截写入、或部分丢失的问题——也就满足了C特性。
三、MVCC
如果把事务理解为线程,I特性归根结底是个多线程的并发模型——在“多事务同时对一条数据进行操作的时候,是否影响到其他事务”,以及“如果影响的话,会是怎么影响”。
在线程模型中,如果针对同一块内存区域进行修改,那么最简单的办法,就是当某线程工作的时候,持有该块内存区域的互斥锁,完成后释放;如果要在操作过程中,内存区域对其他线程可读,那就持有读写锁。有些关系型数据库也的确是这么做的,基于Lock-based Concurrent Control来控制隔离级别的。拿RR(Repeatable Read)来说,在第一个select开始,就在数据行上加write lock,一直到该事务结束。在此过程中,别的事务不可以对这些数据行进行写操作,否则就会导致不可重复读,这种模型效率比较低。
更高级的线程模型,应该允许最大程度的并发。除了尽可能地缩小同步块的粒度之外,在机制上,大致有这两种:
乐观锁,以CAS(compare and swap)为代表。在更新前,先compare下,看在自己执行的这段时间内,变量值有没有变化。如果未变化,则是安全的,继续操作;如果变化了,则表明其他线程曾经修改过,不可以继续了。
各线程拷贝副本(Copy-On-Write),这样各内存可以各自操作各自的,互不影响。但这样会导致脏数据,即对变量X,A线程更新了(如++),但由于对B线程不能及时可见,如果B线程也更新(如++),则会丢失一次++。因此为了保证必要的可见性,需要定义一些happen-before的局部有序。举个例子,比如volitale关键字要保证,其修饰的变量如果发生了更新,其他线程都要可见。
主流的关系型数据库,像MySQL/Oracle,基本使用这类机制,称为MultiVersion Concurrent Control(MVCC)。事务之间读读、读写、写读不仅可以并存(理论上写写也可以并存),而且可以保证相互之间正确的可见性。
在介绍MVCC之前,先回顾一个术语read view,在MySQL Innodb手册中read view的定义是
An internal snapshot used by the MVCC mechanism of InnoDB. Certain transactions, depending on their isolation level, see the data values as they were at the time the transaction (or in some cases, the statement) started. Isolation levels that use a read view are REPEATABLE READ, READ COMMITTED, and READ UNCOMMITTED.
简单地说,就是事务/Statement执行时所读到数据的快照,这就很像前面线程模型中提到的“各线程拷贝副本”,也是Copy-On-Write机制。有些数据库,是把待修改数据Copy出来之后,在新的副本上执行事务;有些则是Copy出个副本仅作为恢复使用(redo/undo时用),事务在真正的数据段上执行,MySQL的InnoDB是后一种方式。下面以MySQL的InnoDB为例,详细地说下MVCC机制大概是怎么设计的。
我们知道InnoDB中,每个数据行会保存额外的三个字段,分别是:
- ROWID:innodb为每个新插入的记录行,都会生成的唯一ID;
- TXID:最近一次在该行执行insert/update的事务ID,该事务可能已commit,也可能未commit;
- ROLL_PDR:指向一个数据块,这数据块包含了“如果回滚当前事务,所需要恢复的数据”,是undo log中的一个地址;
下面这个case,是“用户个人信息”表的一条记录。它的前后变迁历史是:
- 事务(TXID=100)于16:00(下午4点钟)插入了这条记录,且随即commit了,此时age=20。
- 事务(TXID=102)于16:28创建并更新该条记录,update age=25,且于16:31时commit;
- 事务(TXID=103)于16:29创建,并第一次select age from table,然后于16:32再一次select age from table;
- 事务(TXID=104)于16:30创建并更新该条记录,update age=30,且于16:35时commit;
那么事务(TXID=103),前后2次select age from table,各自应该看到的age是多少呢? 不同的隔离级别,看到的内容不一样。之前我们说过read view不是? 这里就发挥作用了,在创建read view时,会“保存当时所有活动的(已开始)但未提交的事务ID列表,并记录下事务ID区间[minTxId,maxTxId)”。下面分别以RR(Repeatable Read)和RC(Read Committed)这两种隔离级别看一下。事务TXID103在启动事务时(实际时间点是在事务第一次执行statement(insert/update)时),创建read view。
隔离级别为RR:
在RR级别下,一个事务只会创建一次read view,其执行时间点在事务第一次执行statement(insert/update)时。
- 在事务TXID103第一次执行select age from table时,发现这是该事务的第一次statement,则创建read view——找出当前正在活动的事务列表。在本例中就是[102,104];
- 此时它发现当前数据行的TXID=102(TXID103在16:29执行第一次查询,此时TXID102已经开始,TX104还没有),把102和[102,104]这个区间对比,发现处于这个区间之内,则数据不可见,因此通过undo log去找其上一个版本,找到TXID100。发现100不在[102,104]之内,所以可见,看到的age=20;
- 当第二次select age from table时,它查到当前数据行的TXID已经变成104了,同样把104和[102,104]这个区间对比,发现处于这个区间之内,数据不可见,因此通过undo log去找其上一个版本——本例中就找到TXID102;
- 发现102也仍然处在[102,104]这个区间内,仍然不可见,所以再往前找,找到TXID100;
- 此时发现100不处于[102,104]之内了,所以可见,看到的age=20;
可以看到,在RR级别下,前后两次select age from table,看到的age值是一样的,虽然在这个过程中,有其他事务(TXID102)修改了age的值且已提交。
隔离级别为RC:
在RC级别下,每个statement(insert/update)执行之前,都会创建一次read view。
在事务TXID103第一次执行select age from table时,创建read view,并找出当前正在活动的事务列表,本例中是[102,104];经过同样的对比方法,它发现当前数据行的TXID=102处于该区间之内,所以往前找,找到TXID100,因此得到age=20;
当第二次执行select age from table时,再次创建read view,此时的活动事务列表只有[104]了(因为TXID102已经commit结束了),也发现了当前数据行的TXID=104,于是把104和[104]进行比较,发现处于该区间内,所以不可见,只能通过undo log去找其上一个版本——本例中找到TXID102;
发现102已经不在[104]区间内了,所以可见,看到的age=25;
可以看到,在RC级别下,前后两次select age from table,看到的age值是不一样的,因为这个过程中,TXID102修改了age值且已提交。
上面这是一个例子,在实际中,InnoDB还不会出现这样的同时有两个更新的情况,因为InnoDB的“写”是独占的,即同一时间只允许一个事务对同一个数据行写入。这在一些事情的处理上会比较简单些,假如允许多事务写,虽然通过乐观锁机制(类似CAS)可以实现更高的并发,但也会出现一些问题(假设事务A,要更新ROW1和ROW2,更新ROW1成功,而更新ROW2失败,需要回滚,那么如果在更新ROW1成功后,事务B也更新了ROW1,那么此回滚,会导致事务B的更新丢失,俗称“第一类更新丢失”)。
RR和RC在MVCC中的一个最主要区别,就是创建read view的时机不同——RR是在事务第一条statement语句执行之前创建,而RC是在每条statement执行之前都创建,这个特性是实现它们不同的可见性的核心。下图是个例子
四、WAL
在以前没有电池的台式机时代,我们曾经遇到过,如果突然断电,就有可能会导致数据丢失、或者文件被损坏。数据库也是以文件的方式存储,那么当它在写入的过程中,如果发生了断电等瞬时crash的操作,那么就有可能文件被破坏,或内容丢失,甚至是写入半截数据(想象一下数据库正在写入一条数据,结果在刚插入前3个字段,断电了,导致后面一些字段没有插入),导致系统处于不一致的状态。如果这种事情发生,ACID也就无法保证。为了解决此类问题,数据库系统通过引入Write Ahead Log(WAL)技术来解决这类问题。
Write Ahead Log会在每个事务操作的时候(包括事务begin/commit,以及insert/update),都会先写日志文件,再去修改db表数据。当事务commit时,fsync()日志文件到磁盘之后,保证其被成功持久化之后,才respond客户端OK。如果在这之后发生crash,则在数据库重启时,从WAL日志(具体来说,是redo log和undo log)中进行恢复。我们回顾下Durability的定义——当客户端收到服务端返回的OK消息时,意味着消息已经被永久保存。有了WAL之后,是不是就满足了Durability的要求?
等等,如果在WSL被fsync到磁盘之前,发生crash怎么办? 如果按照严格ACID的定义,此时是不会respond客户端OK的,也就是说,客户端会捕获到这个现象,并可能重试。在实践中,比如InnoDB允许适当妥协,比如innodb_flush_log_at_trx_commit参数可以控制在什么时机flush log到disk。如果是在commit时flush,则保证了严格的ACID;如果为了性能,采取1秒刷新一次的策略,就可能会丢失1秒的数据。
那么WSL要保存哪些内容,以及该如何恢复数据? 下图很好地阐述了一个例子由上图可以看到,WSL的内容主要包括:
- LSN:Log Sequence Number,是日志块的唯一编号;
- type:操作
- oldValue:更新前的数据
- newValue:更新后的数据
当事务commit的时候,WSL一定要刷新到磁盘,这样保证了Durability。同时这些更新是个整体操作,这也就同时保证了Atomic。如果发生了crash,那么在db recover时,分析该WAL日志,找到之前的数据,并决定是redo还是undo——如果日志中有commit操作,则redo;如果没有,则undo。
作者张轲目前任职于杭州大树网络技术有限公司,担任首席架构师,负责系统整体业务架构以及基础架构,熟悉微服务、分布式设计、中间件领域,对运维、测试、敏捷开发等相关领域也有所涉猎。下方是我的微信公众号,欢迎关注。