本地事务
任何一个应用系统都会涉及到事务处理,事务存在意义是为了保证系统中所有的数据都是符合预期的,且相关联的数据之间不会产生矛盾,即数据状态的一致性(Consistency)。
按照数据库的经典理论,要达到数据一致性(Consistency)的目标,需要三个手段来共同保障:
- 原子性(Atomic):在同一项业务处理过程中,事务保证了对多个数据的修改,要么同时成功,要么同时被撤销。
- 持久性(Durability):事务应该保证所有成功被提交的数据修改都能够正确地被持久化,不丢失数据。
- 隔离性(Isolation):在不同的业务处理过程中,事务保证了各自业务正在读、写的数据互相独立,不会彼此影响。
以上四种属性即事务的ACID特性,但一致性(C)和其它三个特性原子性(A)、持久性(D)、隔离性(I) 并不类似,A,I,D是手段,而C是目的。前者是因,后者是果。
事务的概念虽然最初是起源于数据库系统,但今天已经有所延伸,而不再局限于数据库本身了。所有需要保证数据一致性的场景都有可能会用到事务,包括但不限于数据库、事务内存、缓存、消息队列、分布式存储等等。
原子性和持久性是在事务中密切相关的两个属性,原子性保证了事务的多个操作要么都生效,要么都不生效,而持久性保证了事务一旦生效,就不会再因任何原因而导致其修改的内容被撤销或者丢失。
众所周知,数据必须要成功写入磁盘等持久化设备中才能拥有持久性,然而实现原子性和持久性最大的困难就是“写入磁盘”这个操作并不是原子的,不仅有“写入”和“未写入”的状态,还存在着“正在写”的中间状态。因为正在写的中间状态和系统崩溃等原因是无法避免的,所以如果不做额外的保障措施,直接将内存的数据写入磁盘,其实是无法保证原子性和持久性的。
由于写入的中间状态和崩溃都是无法避免的,为了保证原子性和持久性,就只能在崩溃之后采取恢复的补救措施,这种数据恢复操作被称为“崩溃恢复”(Crash Recovery)。为了能够顺利完成崩溃恢复,在修改数据之前,就必须把修改数据的全部操作以日志的形式先记录到磁盘中。当日志记录全部安全落盘,数据库在日志中看到事务成功提交的提交记录(Commit Record)后,才会根据日志上的信息对真正的数据进行修改,修改完成之后,再在日志中加入一条结束记录(End Record),表示事务已完成持久化。这种事务实现的方式被称为“Commit Logging”(提交日志)。
以提交日志的方式来实现事务,其保障数据原子性、持久性的原理并不难理解:首先,日志一旦成功写入Commit Record,那整个事务就是成功的,即使真正修改数据的时候崩溃了,重启之后根据已经写入磁盘的日志信息恢复现场、继续修改数据即可,这就保证了持久性。其次,如果日志没有成功写入Commit Record就发生了崩溃,那么整个事务失败了,系统重启后会看到一部分没有Commit Record的日志,那么将这部分日志标记为回滚状态即可,整个事务就像完全没有发生过一样,这保证了原子性。
Commit Logging的原理很清晰,但是Commit Logging存在一个巨大的先天缺陷:所有对数据的真实修改都必须发生在事务提交以后,即在日志写入了Commit Record之后。在此之前,即使磁盘I/O足够空闲、即使某个事务修改的数据量非常庞大,占用了大量的内存缓冲区,但是无论何种理由,都不允许在事务提交之前就修改磁盘上的数据,这一点是Commit Logging成立的前提,但是对数据库性能的提升却十分不利。
因此ARIES理论提出了“Write-Ahead Logging”的日志改进方案,所谓提前写入(Write-Ahead),就是允许在事务提交之前,提前写入变动的数据。Write-Ahead Logging 先将何时写入变动数据,按照事务的提交点为界,划分为 force 和 steal 两类情况:
- force:当事务提交后,要求变动的数据必须同时完成写入则称为force,如果不强制变动的数据必须同时完成写入则称为no-force。现实中绝大多数数据库采用的都是no-force策略,因为只要有了日志,变动的数据随时都可以持久化,从优化磁盘IO性能考虑,没有必要强制数据立即写入。
- steal:在事务提交前,允许变动数据提前写入则称为steal,不允许则称为no-steal。从优化磁盘IO性能考虑,允许数据提前写入,有利于利用空闲IO资源,也有利于节省数据库缓存区的内存。
Commit Logging允许no-force,但是不允许steal。因为假设事务提交前就有部分变动数据写入磁盘,那一旦事务要回滚,或者发生了崩溃,这些提前写入的变动数据就都成了错误。
Write-Ahead Logging允许no-force,也允许steal,它给出的解决办法是增加另一种被称为Undo Log的日志类型。当变动数据写入磁盘前,必须先记录Undo Log,注明修改了哪个位置的数据、从什么值改成了什么值等等。以便在事务回滚或者崩溃恢复时根据Undo Log对提前写入的数据变动进行擦除。
Undo Log现在一般被翻译成“回滚日志”。此前记录的用于崩溃恢复时重演数据变动的日志被称为Redo Log,一般翻译为“重做日志”。
由于Undo Log的加入,Write-Ahead Logging在崩溃恢复时会执行以下三个阶段的操作:
- 分析阶段(Analysis):该阶段从最后一次检查点开始扫描日志,找出所有没有End Record的事务,组成待恢复的事务集合,这个集合至少会包括 Transaction Table 和 Dity Page Table 两个组成部分。检查点(Checkpoint),可理解为在这个点之前所有应该持久化的变动都已经安全落盘。
- 重做阶段(Redo):该阶段依据分析阶段中产生的待恢复的事务集合来重演历史(Repeat History),具体操作为:找出所有包含Commit Record的日志,将这些日志修改的数据写入磁盘,写入完成之后在日志中增加一条End Record,然后移除待恢复事务的集合。
- 回滚阶段(Undo):该阶段处理经过分析、重做阶段后剩余的恢复事务集合,此时剩下的都是需要回滚的事务,它们被称为Loser,根据Undo Log中的信息,将已经提前写入磁盘的信息恢复原样,已达到回滚这些Loser事务的目的。
以上讨论了原子性和持久性的实现方式,下面讨论隔离性是如何实现的。
隔离性保证了每个事务各自读写的数据相互独立,不会影响彼此 。隔离性和并发密切相关,如果没有并发,所有的事务都是串行的,那就不需要隔离,或者说串行的访问天然具备了隔离性。
但现实情况不可能没有并发,要在并发下实现串行的数据访问该怎么做?加锁同步。现代数据库均提供了以下三种锁,理解这三把锁是理解事务隔离性的关键。
- 写锁(Write Lock,也叫排它锁,Exclusive Lock,简写为X-Lock):如果对数据加写锁,那么只有持有写锁的事务才能对数据进行写入操作。数据加写锁时,其他事务不能写入数据,也不能对数据加读锁。
- 读锁(Read Lock,也叫共享锁,Shared Lock,简写为S-Lock):多个事务可以对同一个数据加多个读锁,数据被加读锁之后就不能再加写锁,所以其他事务不能对该数据进行写入,但仍然可以读取。对于持有读锁的事务,如果该数据只有它自己一个事务加了读锁,允许直接将读写升级为写锁,然后写入数据。
- 范围锁(Range Lock):对于某个范围直接加排它锁,在这个范围内的数据就不能被写入。查询语句
select * from books where price < 100 for update
注意:不要把范围锁理解成一组排它锁的集合。加了范围锁之后,不仅无法修改该范围内已有的数据,也不能在该范围内新增或删除任何数据,而一组排它锁的集合是无法做到这一点的。
串行化访问提供了强度最高的隔离性。SQL-92标准中定义了最高等级的隔离级别就是可串行化(Serializable)。可串行化非常好理解,就是轮流排队对数据进行加锁,然后再访问。如果不考虑数据库性能的话,这无疑是最直观简单的实现方式。然而数据库不可能不考虑性能,并发控制理论决定了隔离程度与并发能力是相抵触的,隔离程度越高,并发访问时的吞吐量就越低。
现代数据库都提供了除可串行化以外的隔离级别供用户选择,让用户自己选择隔离级别,根本目的是让用户调节数据库的加锁方式,以取得隔离性和吞吐量之间的平衡。
另外三个隔离级别分别是:
可重复读(Repeatable Read),可重复读对事务所涉及的数据加读锁和写锁,且一直持有直到事务结束,但不加范围锁。可重复读 比 可串行化 弱化的地方是存在幻读问题(Phantom Reads)。所谓幻读,就是在一个事务的执行过程中,存在两个相同的范围查询,两次查询得到了不同数据量的结果集。这是因为存在另外一个事务在上一个事务的两次查询之间新增或者删除了范围查询中的数据。
比如如下两个事务:
事务T1执行了两次相同范围内的查询,而事务T2在T1的两次查询中间新增了一条记录,那么事务T1的两次范围查询的结果集中的记录数量肯定是不一样的。这种现象对于事务T1来说,就叫做幻读。
产生幻读的原因是在可重复读的隔离级别只加了读锁和写锁,没有加范围锁。所以在事务T1的两次查询之间,事务T2插入新数据,这个操作是被允许的。这是一个事务受到其他事务的影响,隔离性被破坏的表现。MySQL的InnoDB引擎的默认隔离级别就是可重复读。
读已提交(Read Committed),读已提交对事务所涉及的数据加的写锁会一直持续到事务结束,然而加的读锁在查询操作完成后就会马上释放。读已提交 比 可重复读 弱化的地方是存在不可重复读(Non-Repeatable Reads)的问题。所谓不可重复读,就是在同一个事务中对同一行数据的两次查询,查询到的数据内容不一样。
比如如下两个事务:
如果数据库的隔离级别是读已提交,那么事务T1的两次查询,查询到的结果就不一样。原因是读已提交的隔离级别缺乏贯穿整个事务周期的读锁,无法禁止读取过的数据发生变化,此时事务T2的更新语句如果马上提交成功,就是影响事务T1,这也是一个事务受到其它事务的影响,隔离性被破坏的表现。假如数据库的隔离级别是可重复读的话,由于数据被事务T1施加了读锁,且读取后不会马上释放,所以事务T2无法获取到写锁,更新操作就会被阻塞,直到事务T1被提交或者回滚。
注意:幻读和不可重复读的区别,幻读是前后多次读取,数据总量不一致,而不可重复读是前后多次读取,数据内容不一致。
读未提交(Read Uncommitted),读未提交对事务所涉及的数据只会加写锁,会一直持续到事务结束,但完全不加读锁。读未提交 比 读已提交 弱化的地方是存在脏读(Dirty Reads)。所谓脏读,一个事务读取到了另外一个事务未提交的数据,而另外这个事务如果将数据进行了回滚,第一个事务就不知道自己其实读到的其实是一个错误的数据,这就是脏读。
比如如下两个事务:
读未提交的隔离级别在读取数据时不加读锁,这反而令它能读到其他事务加了写锁的数据。因为根据写锁的定义:写锁禁止其他事务施加读锁,而不是禁止其他事务读取数据。如果事务T1读取数据并不需要加读锁的话,就会导致事务T2未提交的数据也马上能被事务T1读到。这同样也是一个事务受到其他事务的影响,隔离性被破坏的表现。假如隔离级别是读已提交的话,由于事务T2持有数据的写锁,那么事务T1的第二次查询就无法获得读锁,因此T1的第二次查询就会被阻塞,直到事务T2被提交或者回滚后才能查询到结果。
以上四种隔离级别属于数据库理论的基础知识,可惜不少教材、资料将他们当做数据库的某种固有属性或设定来讲解,这导致很多同学只能对这些现象死记硬背。其实不同隔离级别以及幻读、不可重复读、脏读等问题都是表面现象,是各种锁在不同加锁时间上组合应用所产生的结果,以锁为手段来实现隔离性才是数据库表现出不同隔离级别的根本原因。