事务

Published 2022年03月31日 00:00 by james

事务

TL;DR

  • [ ] 特性
    • 原子性(Atomic): 构成事务的所有操作要么全部执行成功,要么全部执行失败,不可能出现部分执行成功,部分执行失败的情况。
    • 一致性(Consistency): 在事务执行之前和执行之后,数据始终处于一致的状态。
    • 隔离性(Isolation): 并发执行的两个事务之间互不干扰。
    • 持久性(Durability): 事务提交完成后,此事务对数据的更改操作会被持久化到数据库中,并且不会被回滚。
  • [ ] 类型
    • 扁平事务
    • 带有保存点的扁平事务
    • 链式事务
    • 嵌套事务
    • 分布式事务
  • [ ] 并发事务带来的问题
    • 更新丢失(脏写)
    • 脏读
    • 不可重复读
    • 幻读
  • [ ] 事务隔离级别
    • Read uncommitted(读未提交)
    • Read committed(读提交)
    • Repeatable read(重复读)
    • Serializable(序列化)

事务处理1

事务由一组操作构成,这组操作能够全部正确执行,如果这一组操作中的任意一个步骤发生错误,那么就需要回滚到之前已经完成的操作。也就是同一个事务中的所有操作,要么全部正确执行,要么全部不执行。

事务的概念虽然最初起源于数据库系统,但今天已经有所延伸,不再局限于数据库本身了。所有需要保证数据一致性的应用场景,包括但不限于数据库、事务内存、缓存、消息队列、分布式存储,等等,都有可能用到事务,后文里笔者会使用"数据源"来泛指所有这些场景中提供与存储数据的逻辑设备,但是上述场景所说的事务和一致性含义可能并不完全一致,说明如下:

  • 当一个服务只使用一个数据源时,通过AID来获得一致性是最经典的做法,也是相对容易的。此时,多个并发事务所读写的数据能够被数据源感知是否存在冲突,并发事务的读写在时间线上的最终顺序是由数据源来确定的,这种事务间一致性被称为"内部一致性"。
  • 当一个服务使用到多个不同的数据源,甚至多个不同服务同时涉及多个不同的数据源时,问题就变得困难了许多。此时,并发执行甚至是先后执行的多个事务,在时间线上的顺序并不由任何一个数据源来决定,这种涉及多个数据源的事务间一致性被称为"外部一致性"。

外部一致性问题通常很难使用AID来解决,因为这样需要付出很大甚至不切实际的代价;但是外部一致性又是分布式系统中必然会遇到且必须要解决的问题,为此我们要转变观念,将一致性从"是或否"的二元属性转变为可以按不同强度分开讨论的多元属性,在确保代价可承受的前提下获得强度尽可能高的一致性保障,也正因如此,事务处理才从一个具体操作上的"编程问题"上升成一个需要全局权衡的"架构问题"。

事务处理几乎在每一个信息系统中都会涉及,它存在的意义是为了保证系统中所有的数据都是符合期望的,且相互关联的数据之间不会产生矛盾,即数据状态的一致性(Consistency)。按照数据库的经典理论,要达成这个目标,需要三方面共同努力来保障。

  • 原子性(Atomic): 在同一项业务处理过程中,事务保证了对多个数据的修改,要么同时成功,要么同时被撤销。
    • 事务的原子性指的是构成事务的所有操作要么全部执行成功,要么全部执行失败,不可能出现部分执行成功,部分执行失败的情况。
  • 隔离性(Isolation): 在不同的业务处理过程中,事务保证了各业务正在读、写的数据相互独立,不会彼此影响。
    • 事务的隔离性指的是并发执行的两个事务之间互不干扰。也就是说,一个事务在执行过程中不能看到其他事务运行过程的中间状态。
    • 如果一个操作完全不会受到其他任何并发的操作或者事务的影响,那么这个操作是隔离的。

      MySQL通过锁和MVCC机制来保证事务的隔离性。

  • 持久性(Durability): 事务应当保证所有成功被提交的数据修改都能够正确地被持久化,不丢失数据。
    • 事务的持久性指的是事务提交完成后,此事务对数据的更改操作会被持久化到数据库中,并且不会被回滚。

      数据库的事务在实现时,会将一次事务中包含的所有操作全部封装成一个不可分割的执行单元,这个单元中的所有操作要么全部执行成功,要么全部执行失败。只要其中任意一个操作执行失败,整个事务就会执行回滚操作。

以上四种属性即事务的ACID特性,AID是手段,C是目的,前者是因,后者是果。

实现原子性和持久性

原子性和持久性在事务里是密切相关的两个属性:

  • 原子性保证了事务的多个操作要么都生效要么都不生效,不会存在中间状态;
  • 持久性保证了一旦事务生效,就不会再因为任何原因而导致其修改的内容被撤销或丢失。

众所周知,数据必须要成功写入磁盘、磁带等持久化存储器后才能拥有持久性,只存储在内存中的数据,一旦遇到应用程序忽然崩溃,或者数据库、操作系统一侧崩溃,甚至是机器突然断电宕机等情况就会丢失,后文我们将这些意外情况都统称为"崩溃"(Crash)。实现原子性和持久性的最大困难是"写入磁盘"这个操作并不是原子的,不仅有写入未写入状态,还客观存在着正在写的中间状态。由于写入中间状态与崩溃都不可能消除,所以如果不做额外保障措施的话,将内存中的数据写入磁盘,并不能保证原子性与持久性。

由于写入存在中间状态,所以可能出现以下情形:

  • 未提交事务,写入后崩溃:

    程序还没修改完三个数据,但数据库已经将其中一个或两个数据的变动写入磁盘,若此时出现崩溃,一旦重启之后,数据库必须要有办法得知崩溃前发生过一次不完整的购物操作,将已经修改过的数据从磁盘中恢复成没有改过的样子,以保证原子性。

  • 已提交事务,写入前崩溃:

    程序已经修改完三个数据,但数据库还未将全部三个数据的变动都写入磁盘,若此时出现崩溃,一旦重启之后,数据库必须要有办法得知崩溃前发生过一次完整的购物操作,将还没来得及写入磁盘的那部分数据重新写入,以保证持久性。 由于写入中间状态与崩溃都是无法避免的,为了保证原子性和持久性,就只能在崩溃后采取恢复的补救措施,这种数据恢复操作被称为"崩溃恢复"(Crash Recovery,也有资料称作Failure RecoveryTransaction Recovery)。

为了能够顺利地完成崩溃恢复,在磁盘中写入数据就不能像程序修改内存中的变量值那样,直接改变某表某行某列的某个值,而是必须将修改数据这个操作所需的全部信息,包括修改什么数据、数据物理上位于哪个内存页和磁盘块中、从什么值改成什么值,等等,以日志的形式——即以仅进行顺序追加的文件写入的形式(这是最高效的写入方式)先记录到磁盘中。只有在日志记录全部安全落盘,数据库在日志中看到代表事务成功提交的"提交记录"(Commit Record)后,才会根据日志上的信息对真正的数据进行修改,修改完成后,再在日志中加入一条"结束记录"(End Record)表示事务已完成持久化,这种事务实现方法被称为"提交日志"(Commit Logging)。

Commit Logging保障数据持久性、原子性的原理并不难理解:

  • 首先,日志一旦成功写入Commit Record,那整个事务就是成功的,即使真正修改数据时崩溃了,重启后根据已经写入磁盘的日志信息恢复现场、继续修改数据即可,这保证了持久性;
  • 其次,如果日志没有成功写入Commit Record就发生崩溃,那整个事务就是失败的,系统重启后会看到一部分没有Commit Record的日志,将这部分日志标记为回滚状态即可,整个事务就像完全没有发生过一样,这保证了原子性。

Commit Logging的原理很清晰,也确实有一些数据库就是直接采用Commit Logging机制来实现事务的,譬如较具代表性的是阿里的OceanBase。但是,Commit Logging存在一个巨大的先天缺陷: 所有对数据的真实修改都必须发生在事务提交以后,即日志写入了Commit Record之后。在此之前,即使磁盘I/O有足够空闲,即使某个事务修改的数据量非常庞大,占用了大量的内存缓冲区,无论何种理由,都决不允许在事务提交之前就修改磁盘上的数据,这一点是Commit Logging成立的前提,却对提升数据库的性能十分不利。为此,ARIES提出了"提前写入日志"(Write-AheadLogging)的日志改进方案,所谓"提前写入"(Write-Ahead),就是允许在事务提交之前写入变动数据的意思。

Write-Ahead Logging按照事务提交时点,将何时写入变动数据划分为FORCESTEAL两类情况。

  • FORCE:

    当事务提交后,要求变动数据必须同时完成写入则称为FORCE,如果不强制变动数据必须同时完成写入则称为NO-FORCE。现实中绝大多数数据库采用的都是NO-FORCE策略,因为只要有了日志,变动数据随时可以持久化,从优化磁盘I/O性能考虑,没有必要强制数据写入时立即进行。

  • STEAL:

    在事务提交前,允许变动数据提前写入则称为STEAL,不允许则称为NO-STEAL。从优化磁盘I/O性能考虑,允许数据提前写入,有利于利用空闲I/O资源,也有利于节省数据库缓存区的内存。

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): 该阶段从最后一次检查点(Checkpoint,可理解为在这个点之前所有应该持久化的变动都已安全落盘)开始扫描日志,找出所有没有End Record的事务,组成待恢复的事务集合,这个集合至少会包括事务表(Transaction Table)和脏页表(Dirty Page Table)两个组成部分。
  • 重做阶段(Redo): 该阶段依据分析阶段中产生的待恢复的事务集合来重演历史(Repeat History),具体操作是找出所有包含Commit Record的日志,将这些日志修改的数据写入磁盘,写入完成后在日志中增加一条End Record,然后移出待恢复事务集合。
  • 回滚阶段(Undo): 该阶段处理经过分析、重做阶段后剩余的恢复事务集合,此时剩下的都是需要回滚的事务,它们被称为Loser,根据Undo Log中的信息,将已经提前写入磁盘的信息重新改写回去,以达到回滚这些Loser事务的目的。

重做阶段和回滚阶段的操作都应该设计为幂等的。为了追求高I/O性能,以上三个阶段无可避免地会涉及非常烦琐的概念和细节(如Redo LogUndo Log的具体数据结构等)。Write-Ahead LoggingARIES理论的一部分,整套ARIES拥有严谨、高性能等诸多优点,但这些也是以高度复杂为代价的。数据库按照是否允许FORCESTEAL可以产生四种组合,从优化磁盘I/O的角度看,NO-FORCESTEAL的组合的性能无疑是最高的;从算法实现与日志的角度看,NO-FORCESTEAL的组合的复杂度无疑也是最高的。这四种组合与Undo LogRedo Log之间的具体关系如下图所示。

FORCE和STEAL的四种组合关系

FORCE和STEAL的四种组合关系

实现隔离性

本节我们来探讨数据库是如何实现隔离性的。隔离性保证了每个事务各自读、写的数据互相独立,不会彼此影响。只从定义上就能嗅出隔离性肯定与并发密切相关,因为如果没有并发,所有事务全都是串行的,那就不需要任何隔离,或者说这样的访问具备了天然的隔离性。但现实情况是不可能没有并发,那么,要如何在并发下实现串行的数据访问呢?几乎所有程序员都会回答: 加锁同步呀!正确,现代数据库均提供了以下三种锁。

  • 写锁(Write Lock,也叫作排他锁,eXclusive Lock,简写为X-Lock): 如果数据有加写锁,就只有持有写锁的事务才能对数据进行写入操作,数据加持着写锁时,其他事务不能写入数据,也不能施加读锁。
  • 读锁(Read Lock,也叫作共享锁,Shared Lock,简写为S-Lock): 多个事务可以对同一个数据添加多个读锁,数据被加上读锁后就不能再被加上写锁,所以其他事务不能对该数据进行写入,但仍然可以读取。对于持有读锁的事务,如果该数据只有它自己一个事务加了读锁,则允许直接将其升级为写锁,然后写入数据。
  • 范围锁(Range Lock): 对于某个范围直接加排他锁,在这个范围内的数据不能被写入。如下语句是典型的加范围锁的例子: sql SELECT * FROM books WHERE price < 100 FOR UPDATE;

请注意"范围不能被写入"与"一批数据不能被写入"的差别,即不要把范围锁理解成一组排他锁的集合。加了范围锁后,不仅不能修改该范围内已有的数据,也不能在该范围内新增或删除任何数据,后者是一组排他锁的集合无法做到的。

ANSI/ISO SQL-92标准定义了4种隔离级别: 读未提交(Read Uncommitted)读已提交(Read Committed)可重复读(Repeatable Read)串行化(Serializable)。 - InnoDB默认是可重复读的。MySQL中的InnoDB储存引擎提供SQL标准所描述的4种事务隔离级别 - TiDB实现了快照隔离(Snapshot Isolation)级别的一致性。为与MySQL保持一致,又称其为"可重复读"。该隔离级别不同于ANSI可重复读隔离级别和MySQL可重复读隔离级别。 - 有些数据库如Oracle还提供了只读(Read Only)这个隔离级别。

串行化访问提供了最高强度的隔离性,ANSI/ISO SQL-92中定义的最高等级的隔离级别便是可串行化(Serializable)。可串行化完全符合普通程序员对数据竞争加锁的理解,如果不考虑性能优化的话,对事务所有读、写的数据全都加上读锁、写锁和范围锁即可做到可串行化,"即可"是简化理解,实际还是很复杂的,要分成加锁(Expanding)和解锁(Shrinking)两阶段去处理读锁、写锁与数据间的关系,称为两阶段锁(Two-Phase Lock2PL)。但数据库不考虑性能肯定是不行的,并发控制(Concurrency Control)理论决定了隔离程度与并发能力是相互抵触的,隔离程度越高,并发访问时的吞吐量就越低。现代数据库一定会提供除可串行化以外的其他隔离级别供用户使用,让用户自主调节隔离级别,根本目的是让用户可以调节数据库的加锁方式,取得隔离性与吞吐量之间的平衡。

可串行化的下一个隔离级别是可重复读(Repeatable Read),可重复读对事务所涉及的数据加读锁和写锁,且一直持有至事务结束,但不再加范围锁。可重复读比可串行化弱化的地方在于幻读问题(Phantom Read),它是指在事务执行过程中,两个完全相同的范围查询得到了不同的结果集。

譬如现在要准备统计一下数据库中售价小于100元的书的本数,可以执行以下第一条SQL语句:

SELECT count(1) FROM books WHERE price < 100             /* 时间顺序:1,事务: T1 */
INSERT INTO books(name, price) VALUES ('生活黑客', 90)    /* 时间顺序:2,事务: T2 */
SELECT count(1) FROM books WHERE price < 100             /* 时间顺序:3,事务: T1 */

根据前面对范围锁、读锁和写锁的定义可知,假如这条SQL语句在同一个事务中重复执行了两次,且这两次执行之间恰好有另外一个事务在数据库插入了一本小于100元的书,这是会被允许的,那这两次相同的SQL查询就会得到不一样的结果,原因是可重复读没有范围锁来禁止在该范围内插入新的数据,这是一个事务受到其他事务影响,隔离性被破坏的表现。

注意,这里的介绍是以ARIES理论为讨论目标,具体的数据库并不一定要完全遵照理论去实现。一个例子是MySQL/InnoDB的默认隔离级别为可重复读,但它在只读事务中可以完全避免幻读问题,譬如上面例子中事务T1只有查询语句,是一个只读事务,所以上述问题在MySQL中并不会出现。但在读写事务中,MySQL仍然会出现幻读问题,譬如例子中事务T1如果在其他事务插入新书后,不是重新查询一次数量,而是将所有小于100元的书改名,那就依然会受到新插入书的影响。

可重复读的下一个隔离级别是读已提交(Read Committed),读已提交对事务涉及的数据加的写锁会一直持续到事务结束,但加的读锁在查询操作完成后会马上释放。读已提交比可重复读弱化的地方在于不可重复读问题(Non-Repeatable Read),它是指在事务执行过程中,对同一行数据的两次查询得到了不同的结果。譬如笔者要获取数据库中《生活黑客》这本书的售价,同样执行了两条SQL语句,在此两条语句执行之间,恰好有另外一个事务修改了这本书的价格,将书的价格从90元调整到了110元,如下SQL所示:

SELECT * FROM books WHERE id = 1;                   /* 时间顺序:1,事务: T1 */
UPDATE books SET price = 110 WHERE id = 1; COMMIT;  /*时间顺序:2,事务: T2 */
SELECT * FROM books WHERE id = 1; COMMIT;           /* 时间顺序:3,事务: T1 */

如果隔离级别是读已提交,这两次重复执行的查询结果就会不一样,原因是读已提交的隔离级别缺乏贯穿整个事务周期的读锁,无法禁止读取过的数据发生变化,此时事务T2中的更新语句可以马上提交成功,这也是一个事务受到其他事务影响,隔离性被破坏的表现。假如隔离级别是可重复读,由于数据已被事务T1施加了读锁且读取后不会马上释放,所以事务T2无法获取到写锁,更新就会被阻塞,直至事务T1被提交或回滚后才能提交。

读已提交的下一个级别是读未提交(Read Uncommitted),它只会对事务涉及的数据加写锁,且一直持续到事务结束,但完全不加读锁。读未提交比读已提交弱化的地方在于脏读问题(Dirty Read),它是指在事务执行过程中,一个事务读取到了另一个事务未提交的数据。

譬如笔者觉得《生活黑客》从90元涨价到110元是损害消费者利益的行为,又执行了一条更新语句把价格改回了90元,在提交事务之前,同事说这并不是随便涨价,而是印刷成本上升导致的,按90元卖要亏本,于是笔者随即回滚了事务,如下SQL所示:

SELECT * FROM books WHERE id = 1;           /* 时间顺序:1,事务: T1 */
/* 注意没有COMMIT */
UPDATE books SET price = 90 WHERE id = 1;   /* 时间顺序:2,事务: T2 */
/* 这条SELECT模拟购书的操作的逻辑 */
SELECT * FROM books WHERE id = 1;           /* 时间顺序:3,事务: T1 */
ROLLBACK;                                   /* 时间顺序:4,事务: T2 */

不过,在之前修改价格后,事务T1已经按90元的价格卖出了几本。原因是读未提交在数据上完全不加读锁,这反而令它能读到其他事务加了写锁的数据,即上述事务T1中两条查询语句得到的结果并不相同。如果你不能理解这句话中的"反而"二字,请再读一次写锁的定义: 写锁禁止其他事务施加读锁,而不是禁止事务读取数据,如果事务T1读取数据前并不需要加读锁的话,就会导致事务T2未提交的数据也马上能被事务T1所读到。这同样是一个事务受到其他事务影响,隔离性被破坏的表现。假如隔离级别是读已提交的话,由于事务T2持有数据的写锁,所以事务T1的第二次查询就无法获得读锁,而读已提交级别是要求先加读锁后读数据的,因此T1中的查询就会被阻塞,直至事务T2被提交或者回滚后才能得到结果

理论上还存在更低的隔离级别,就是"完全不隔离",即读、写锁都不加。读未提交会有脏读问题,但不会有脏写问题(Dirty Write),即一个事务没提交之前的修改可以被另外一个事务的修改覆盖掉。脏写已经不单纯是隔离性上的问题了,它将导致事务的原子性都无法实现,所以一般谈论隔离级别时不会将完全不隔离纳入讨论范围内,而是将读未提交视为最低级的隔离级别。

以上四种隔离级别属于数据库理论的基础知识,多数大学的计算机课程应该都会讲到,可惜的是不少教材、资料将它们当作数据库的某种固有属性或设定来讲解,导致很多同学只能对这些现象死记硬背。其实不同隔离级别以及幻读、不可重复读、脏读等问题都只是表面现象,是各种锁在不同加锁时间上组合应用所产生的结果,以锁为手段来实现隔离性才是数据库表现出不同隔离级别的根本原因。

事务隔离级别 脏写 脏读 不可重复读 幻读
读未提交 (read-uncommitted) 不可能 可能 可能 可能
不可重复读 (read-committed) 不可能 不可能 可能 可能
可重复读 (repeatable-read) 不可能 不可能 不可能 可能
串行化 (serializable) 不可能 不可能 不可能 不可能
  1. 读未提交允许脏读,即在读未提交的事务隔离级别下,可能读取到其他会话未提交事务修改的数据。这种事务隔离级别下存在脏读、不可重复读和幻读的问题。
  2. 读已提交只能读取到已经提交的数据。Oracle等数据库使用的默认事务隔离级别就是读已提交。这种事务隔离级别存在不可重复读和幻读的问题。
  3. 可重复读就是在同一个事务内,无论何时查询到的数据都与开始查询到的数据一致,这是MySQLInnoDB存储引擎默认的事务隔离级别。这种事务隔离级别下存在幻读的问题。
  4. 串行化是指完全串行地读,每次读取数据库中的数据时,都需要获得表级别的共享锁,读和写都会阻塞。这种事务隔离级别解决了并发事务带来的问题,但完全的串行化操作使得数据库失去了并发特性,所以这种隔离级别往往在互联网行业中不太常用。

除了都以锁来实现外,以上四种隔离级别还有另外一个共同特点,就是幻读、不可重复读、脏读等问题都是由于一个事务在读数据的过程中,受另外一个写数据的事务影响而破坏了隔离性。针对这种"一个事务读+另一个事务写"的隔离问题,近年来有一种名为"多版本并发控制"(Multi-Version Concurrency ControlMVCC)的无锁优化方案被主流的商业数据库广泛采用。MVCC是一种读取优化策略,它的"无锁"特指读取时不需要加锁。MVCC的基本思路是对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版本与老版本共存,以此达到读取时可以完全不加锁的目的。在这句话中,"版本"是个关键词,你不妨将版本理解为数据库中每一行记录都存在两个看不见的字段: CREATE_VERSIONDELETE_VERSION,这两个字段记录的值都是事务ID事务ID是一个全局严格递增的数值,然后根据以下规则写入数据。

  • 插入数据时: CREATE_VERSION记录插入数据的事务IDDELETE_VERSION为空。
  • 删除数据时: DELETE_VERSION记录删除数据的事务IDCREATE_VERSION为空。
  • 修改数据时: 将修改数据视为"删除旧数据,插入新数据"的组合,即先将原有数据复制一份,原有数据的DELETE_VERSION记录修改数据的事务IDCREATE_VERSION为空。复制后的新数据的CREATE_VERSION记录修改数据的事务IDDELETE_VERSION为空。

此时,如有另外一个事务要读取这些发生了变化的数据,将根据隔离级别来决定到底应该读取哪个版本的数据。

  • 隔离级别是可重复读: 总是读取CREATE_VERSION小于或等于当前事务ID的记录,在这个前提下,如果数据仍有多个版本,则取最新(事务ID最大)的。
  • 隔离级别是读已提交: 总是取最新的版本即可,即最近被提交的那个版本的数据记录。

另外两个隔离级别都没有必要用到MVCC,因为读未提交直接修改原始数据即可,其他事务查看数据的时候立刻可以看到,根本无须版本字段。可串行化本来的语义就是要阻塞其他事务的读取操作,而MVCC是做读取时的无锁优化的,自然不会放到一起用。

MVCC是只针对读+写场景的优化,如果是两个事务同时修改数据,即写+写的情况,那就没有多少优化的空间了,此时加锁几乎是唯一可行的解决方案,稍微有点讨论余地的是加锁策略是选乐观加锁(Optimistic Locking)还是选悲观加锁(Pessimistic Locking)。前面笔者介绍的加锁都属于悲观加锁策略,即认为如果不先加锁再访问数据,就肯定会出现问题。相对地,乐观加锁策略认为事务之间数据存在竞争是偶然情况,没有竞争才是普遍情况,这样就不应该在一开始就加锁,而是应当在出现竞争时再找补救措施。这种思路也被称为乐观并发控制(Optimistic Concurrency ControlOCC),囿于篇幅与主题,这里就不再展开了,不过笔者提醒一句,没有必要迷信什么乐观锁要比悲观锁更快的说法,这纯粹看竞争的激烈程度,如果竞争激烈的话,乐观锁反而更慢。

额外知识

Shadow Paging

通过日志实现事务的原子性和持久性是当今的主流方案,但并不是唯一的选择。除日志外,还有另外一种称为Shadow Paging(有中文资料翻译为影子分页)的事务实现机制,常用的轻量级数据库SQLite 3采用的事务机制就是Shadow Paging

Shadow Paging的大体思路是对数据的变动会写到硬盘的数据中,但不是直接就地修改原先的数据,而是先复制一份副本,保留原数据,修改副本数据。在事务处理过程中,被修改的数据会同时存在两份,一份是修改前的数据,一份是修改后的数据,这也是影子(Shadow)这个名字的由来。当事务成功提交,所有数据的修改都成功持久化之后,最后一步是修改数据的引用指针,将引用从原数据改为新复制并修改后的副本,最后的修改指针这个操作将被认为是原子操作,现代磁盘的写操作的作用可以认为是保证了在硬件上不会出现改了半个值的现象。所以Shadow Paging也可以保证原子性和持久性。Shadow Paging实现事务要比Commit Logging更加简单,但涉及隔离性与并发锁时,Shadow Paging实现的事务并发能力就相对有限,因此在高性能的数据库中应用不多。

其他补充材料

事务与并发控制

事务

事务规范了数据库操作的语义,每个事务使得数据库从一个一致的状态原子地转移到另一个一致的状态。数据库事务具有原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)以及持久性(Durability),即ACID属性,这些特性使得多个数据库事务并发执行时互不干扰,也不会获取到中间状态的错误结果。

多个事务并发执行时,如果它们的执行结果和按照某种顺序一个接着一个串行执行的效果等同,这种隔离级别称为可串行化。可串行化是比较理想的情况,商业数据库为了性能考虑,往往会定义多种隔离级别。事务的并发控制一般通过锁机制来实现,锁可以有不同的粒度,可以锁住行,也可以锁住数据块甚至锁住整个表格。由于互联网业务中读事务的比例往往远远高于写事务,为了提高读事务性能,可以采用写时复制(Copy-On-WriteCOW)或者多版本并发控制(Multi-Version Concurrency ControlMVCC)技术来避免写事务阻塞读事务。

事务是数据库操作的基本单位,它具有原子性、一致性、隔离性和持久性这四个基本属性。

  1. 原子性

    事务的原子性首先体现在事务对数据的修改,即要么全都执行,要么全都不执行,例如,从银行账户A转一笔款项a账户B,结果必须是从A的账户上扣除款项a并且在B的账户上增加款项a,不能只是其中一个账户的修改。但是,事务的原子性并不总是能够保证修改一定完成了或者一定没有进行,例如,在ATM机器上进行上述转账,转账指令提交后通信中断或者数据库主机异常了,那么转账可能完成了也可能没有进行:如果通信中断发生前数据库主机完整接收到了转账指令且后续执行也正常,那么转账成功完成了;如果转账指令没有到达数据库主机或者虽然到达但后续执行异常(例如写操作日志失败或者账户余额不足),那么转账就没有进行。要确定转账是否成功,需要待通信恢复或者数据库主机恢复后查询账户交易历史或余额。事务的原子性也体现在事务对数据的读取上,例如,一个事务对同一数据项的多次读取的结果一定是相同的。

  2. 一致性

    事务需要保持数据库数据的正确性、完整性和一致性,有些时候这种一致性由数据库的内部规则保证,例如数据的类型必须正确,数据值必须在规定的范围内,等等;另外一些时候这种一致性由应用保证,例如一般情况下银行账务余额不能是负数,信用卡消费不能超过该卡的信用额度等。

  3. 隔离性

    许多时候数据库在并发执行多个事务,每个事务可能需要对多个表项进行修改和查询,与此同时,更多的查询请求可能也在执行中。数据库需要保证每一个事务在它的修改全部完成之前,对其他的事务是不可见的,换句话说,不能让其他事务看到该事务的中间状态,例如,从银行账户A转一笔款项a账户B,不能让其他事务(例如账户查询)看到A账户已经扣除款项aB账户却还没有增加款项a的状态。

  4. 持久性

    事务完成后,它对于数据库的影响是永久性的,即使系统出现各种异常也是如此。

出于性能考虑,许多数据库允许使用者选择牺牲隔离属性来换取并发度,从而获得性能的提升。SQL定义了4种隔离级别:

  • Read Uncommitted(RU): 读取未提交的数据,即其他事务已经修改但还未提交的数据,这是最低的隔离级别;
  • Read Committed(RC): 读取已提交的数据,但是,在一个事务中,对同一个项,前后两次读取的结果可能不一样,例如第一次读取时另一个事务的修改还没有提交,第二次读取时已经提交了;
  • Repeatable Read(RR): 可重复读取,在一个事务中,对同一个项,确保前后两次读取的结果一样;
  • Serializable(S): 可序列化,即数据库的事务是可串行化执行的,就像一个事务执行的时候没有别的事务同时在执行,这是最高的隔离级别。

隔离级别的降低可能导致读到脏数据或者事务执行异常,例如:

  • Lost Update(LU) 第一类丢失更新: 两个事务同时修改一个数据项,但后一个事务中途失败回滚,则前一个事务已提交的修改都可能丢失;
  • Dirty Reads(DR): 一个事务读取了另外一个事务更新却没有提交的数据项;
  • Non-Repeatable Reads(NRR): 一个事务对同一数据项的多次读取可能得到不同的结果;
  • Second Lost Updates problem(SLU) 第二类丢失更新: 两个并发事务同时读取和修改同一数据项,则后面的修改可能使得前面的修改失效;
  • Phantom Reads(PR): 事务执行过程中,由于前面的查询和后面的查询的期间有另外一个事务插入数据,后面的查询结果出现了前面查询结果中未出现的数据。

下表说明了隔离级别与读写异常(不一致)的关系。容易发现,所有的隔离级别都保证不会出现第一类丢失更新,另外,在最高隔离级别(Serializable)下,数据不会出现读写的不一致。 隔离级别与读写异常的关系

隔离级别与读写异常的关系

并发控制

数据库锁

事务分为几种类型: 读事务,写事务以及读写混合事务。相应地,锁也分为两种类型: 读锁以及写锁,允许对同一个元素加多个读锁,但只允许加一个写锁,且写事务将阻塞读事务。这里的元素可以是一行,也可以是一个数据块甚至一个表格。事务如果只操作一行,可以对该行加相应的读锁或者写锁;如果操作多行,需要锁住整个行范围。

解决死锁的思路主要有两种: 第一种思路是为每个事务设置一个超时时间,超时后自动回滚。第二种思路是死锁检测。死锁出现的原因在于事务之间互相依赖,T1依赖T2T2又依赖T1,依赖关系构成一个环路。检测到死锁后可以通过回滚其中某些事务来消除循环依赖。

写时复制

互联网业务中读事务占的比例往往远远超过写事务,很多应用的读写比例达到6:1,甚至10:1。写时复制(Copy-On-WriteCOW)读操作不用加锁,极大地提高了读取性能。

写时复制写时复制的B+树执行写操作的步骤如下:

隔离级别与读写异常的关系

写时复制的B+树

  1. 拷贝: 将从叶子到根节点路径上的所有节点拷贝出来。
  2. 修改: 对拷贝的节点执行修改。
  3. 提交: 原子地切换根节点的指针,使之指向新的根节点。

如果读操作发生在第3步提交之前,那么,将读取老节点的数据,否则将读取新节点,读操作不需要加锁保护。写时复制技术涉及引用计数,对每个节点维护一个引用计数,表示被多少节点引用,如果引用计数变为0,说明没有节点引用,可以被垃圾回收。

写时复制技术原理简单,问题是每次写操作都需要拷贝从叶子到根节点路径上的所有节点,写操作成本高,另外,多个写操作之间是互斥的,同一时刻只允许一个写操作。

多版本并发控制

除了写时复制技术,多版本并发控制,即MVCC(Multi-Version Concurrency Control),也能够实现读事务不加锁。MVCC对每行数据维护多个版本,无论事务的执行时间有多长,MVCC总是能够提供与事务开始时刻相一致的数据。

MySQL InnoDB存储引擎为例,InnoDB对每一行维护了两个隐含的列,其中一列存储行被修改的"时间",另外一列存储行被删除的"时间",注意,InnoDB存储的并不是绝对时间,而是与时间对应的数据库系统的版本号,每当一个事务开始时,InnoDB都会给这个事务分配一个递增的版本号,所以版本号也可以被认为是事务号。对于每一行查询语句,InnoDB都会把这个查询语句的版本号同这个查询语句遇到的行的版本号进行对比,然后结合不同的事务隔离级别,来决定是否返回改行。

下面分别以SELECTDELETEINSERTUPDATE语句来说明(RR级别)。

  1. SELECT

    对于SELECT语句,只有同时满足了下面两个条件的行,才能被返回:

    • 行的修改版本号小于等于该事务号。
    • 行的删除版本号要么没有被定义,要么大于事务的版本号。

      如果行的修改或者删除版本号大于事务号,说明行是被该事务后面启动的事务修改或者删除的。在可重复读取隔离级别下,后开始的事务对数据的影响不应该被先开始的事务看见,所以应该忽略后开始的事务的更新或者删除操作。

  2. INSERT

    对新插入的行,行的修改版本号更新为该事务的事务号。

  3. DELETE

    对于删除,InnoDB直接把该行的删除版本号设置为当前的事务号,相当于标记为删除,而不是物理删除。

  4. UPDATE

    在更新行的时候,InnoDB会把原来的行复制一份,并把当前的事务号作为该行的修改版本号。

MVCC读取数据的时候不用加锁,每个查询都通过版本检查,只获得自己需要的数据版本,从而大大提高了系统的并发度。当然,为了实现多版本,必须对每行存储额外的多个版本的数据。另外,MVCC存储引擎还必须定期删除不再需要的版本,及时回收空间。

并发事务带来的问题

更新丢失(脏写)

当两个或两个以上的事务选择数据库中的同一行数据,并基于最初选定的值更新该行数据时,因为每个事务之间都无法感知彼此的存在,所以会出现最后的更新操作覆盖之前由其他事务完成的更新操作的情况。也就是说,对于同一行数据,一个事务对该行数据的更新操作覆盖了其他事务对该行数据的更新操作。

更新丢失(脏写)本质上是写操作的冲突,解决办法是让每个事务按照串行的方式执行,按照一定的顺序依次进行写操作。

脏读

一个事务正在对数据库中的一条记录进行修改操作,在这个事务完成并提交之前,当有另一个事务来读取正在修改的这条数据记录时,如果没有对这两个事务进行控制,则第二个事务就会读取到没有被提交的脏数据,并根据这些脏数据做进一步的处理,此时就会产生未提交的数据依赖关系。我们通常把这种现象称为脏读,也就是一个事务读取了另一个事务未提交的数据。

脏读本质上是读写操作的冲突,解决办法是先写后读,也就是写完之后再读。

不可重复读

一个事务读取了某些数据,在一段时间后,这个事务再次读取之前读过的数据,此时发现读取的数据发生了变化,或者其中的某些记录已经被删除,这种现象就叫作不可重复读。即同一个事务,使用相同的查询语句,在不同时刻读取到的结果不一致。

不可重复读本质上也是读写操作的冲突,解决办法是先读后写,也就是读完之后再写。

幻读

一个事务按照相同的查询条件重新读取之前读过的数据,此时发现其他事务插入了满足当前事务查询条件的新数据,这种现象叫作幻读。即一个事务两次读取一个范围的数据记录,两次读取到的结果不同。

幻读本质上是读写操作的冲突,解决办法是先读后写,也就是读完之后再写。

重复读和幻读的区别:

  1. 不可重复读的重点在于更新和删除操作,而幻读的重点在于插入操作。
  2. 使用锁机制实现事务隔离级别时,在可重复读隔离级别中,SQL语句第一次读取到数据后,会将相应的数据加锁,使得其他事务无法修改和删除这些数据,此时可以实现可重复读。这种方法无法对新插入的数据加锁。如果事务A读取了数据,或者修改和删除了数据,此时,事务B还可以进行插入操作,导致事务A莫名其妙地多了一条之前没有的数据,这就是幻读。
  3. 幻读无法通过行级锁来避免,需要使用串行化的事务隔离级别,但是这种事务隔离级别会极大降低数据库的并发能力。
  4. 从本质上讲,不可重复读和幻读最大的区别在于如何通过锁机制解决问题。

另外,除了可以使用悲观锁来避免不可重复读和幻读的问题外,我们也可以使用乐观锁来处理,例如,MySQLOraclePostgreSQL等数据库为了提高整体性能,就使用了基于乐观锁的MVCC(多版本并发控制)机制来避免不可重复读和幻读。

隔离级别场景演示

脏读

时间顺序 转账事务 取款事务
1 开始事务
2 - 开始事务
3 - 查询账户余额为 2000 元
4 - 取款 1000 元,余额被更改为 1000 元
5 查询账户余额为 1000 元(产生脏读) -
6 - 取款操作发生未知错误,事务回滚,余额变更为 2000 元
7 转入 2000 元,余额被更改为 3000 元(脏读的 1000+2000) -
8 提交事务 -

备注: 按照正确逻辑,此时账户余额应该为4000

不可重复读

时间顺序 事务 A 事务 B
1 开始事务 -
2 第一次查询,小明的年龄为 20 岁 -
3 - 开始事务
4 其他操作 -
5 - 更改小明的年龄为 30 岁
6 - 提交事务
7 第二次查询,小明的年龄为 30 岁 -

备注: 按照正确逻辑,事务A前后两次读取到的数据应该一致

幻读

时间顺序 事务 A 事务 B
1 开始事务 -
2 第一次查询,数据总量为 100 条 -
3 - 开始事务
4 其他操作 -
5 新增 100 条数据
6 提交事务
7 第二次查询,数据总量为 200 条 -

备注: 按照正确逻辑,事务A前后两次读取到的数据总量应该一致

0 comments

There are no comments yet.

Add a new comment