引言

事务管理是日常开发过程中绕不开的技术,引入事务的目的是为了保证数据持久化过程中的 一致性(Consistency) 。事务的概念最初是源于数据库,但今天的信息系统中,所有需要保证数据正确性(一致性)的场景下,包括但不限于数据库、缓存、事务内存、消息、队列、对象文件存储等等,都有可能会涉及到事务处理。这篇总结主要是总结清楚事务的来龙去脉,无关具体的某个中间件。深度推荐《周志明的软件架构课》

定义事务的ACID

  • 原子性(Atomic)同一业务处理过程中,事务保证了多项对数据的修改,要么全部成功,要么全部失败。
  • 隔离性(Isolation)在不同的业务处理过程中,事务保证了各自读写的业务数据相互不可见,不会彼此影响。
  • 持久性(Durability)事务保证了当业务处理完成后,事务提交后能够被持久化,不会丢失。
  • 一致性(Consistency)一致性。利用A, I, D 保证了业务数据的一致性。

A, I, D是手段,C 是目的。 ACID更像是凑单词。

本地事务(局部事务)Local Transaction

  • 本地事务是指仅操作特定单一事务资源,不需要全局事务管理器进行协调的事务。

  • 本地事务是最基础的一种事务处理方案,通常只适用于单个服务使用单个数据源的场景,它是直接依赖于数据源(通常是数据库系统)本身的事务能力来工作的。

  • 本地事务的开启、终止、提交、回滚、嵌套、设置隔离级别、乃至与应用代码贴近的传播方式,全部都要依赖底层数据库的支持。

  • 例如:Spring提供的事务注解,如果数据库引擎不支持那么这个注解也是无意义的。

    1
    2
    3
    4
    @Transactional(propagation = Propagation.REQUIRED, //传播方式
    rollbackFor = Exception.class, //回滚异常
    isolation = ISOLATION_REPEATABLE_READ //隔离级别
    )

原子性和持久性

扩展知识:ARIES 理论,现代主流数据库的事务实现都受到该理论的影响

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

崩溃回复(Crash Recovery)

  • 未提交事务:程序还没修改完一系列整个业务数据,数据库已经将其中一个或者两个数据的变动写入了磁盘,此时崩溃,一旦重启后,数据库必须想办法得知崩溃前发生过一次不完整的购物操作,将已经修改过的数据从磁盘中恢复成为之前没有改过的样子。
  • 已提交事务:程序已经修改完了所有数据,数据还没将全部的数据写入磁盘,此时出现崩溃。重启之后数据库必须知道崩溃前发生过一次的完整操作,将还没来得及写入磁盘的数据重新写入。保证持久性。

这种数据恢复操作被称作是:崩溃恢复。

Commit Logging

为了能够顺利完成崩溃恢复,那么需要记录下数据操作的全部信息:比如修改的什么数据,位于哪个内存页和磁盘块中,从什么值改成了什么值等等,以顺序追加的文件写入方式记录的磁盘中(顺序写磁盘是高效的)。只有日志记录全部落盘,见到代表事务成功提交的“Commit Record”,数据库才会根据日志上的信息真正的对数据进行修改。修改完成后在日志中加一条“End Record”标识已经完成持久化,这种实现方式被称为“Commit Logging”

另外一种实现:Shadow Paging

SQLite Version 3采用这种方式实现。

将原来数据复制一份副本也可以称之为临时数据,原来数据保持不变,在副本数据上进行操作。再修改完成后改变“两份数据的指针”。

Shadow Paging 相对简单,但涉及到隔离性与锁时,Shadow Paging 实现的事务并发能力相对有限,因此在高性能的数据库中应用不多。

Commit Logging保证持久性:日志一旦写入Commit Logging,那么整个事务就是成功的。即使崩溃,重启后根据日志进行恢复现场,继续修改即可。

Commit Logging保证原子性:如果日志没有写入成功就发生崩溃,重启后会看到一部分没有Commit Logging的数据,那么将这部分数据标识为回滚状态即可,整个事务就像没有发生过。保证了原子性。

Commit Logging 存在一个巨大的缺陷:所有对数据的真实修改都必须发生在事务提交、日志写入了 Commit Record 之后,即使事务提交前磁盘 I/O 有足够空闲、即使某个事务修改的数据量非常庞大,占用大量的内存缓冲,无论何种理由,都决不允许在事务提交之前就开始修改磁盘上的数据,这一点对提升数据库的性能是很不利的。

Write-Ahead Logging(先写日志)

为了修复Commit Logging缺陷ARIES 提出了“Write-Ahead Logging”的日志改进方案,其名字里所谓的“提前写入”(Write-Ahead),就是允许在事务提交之前,提前写入变动数据的意思。

Write-Ahead Logging 先将何时写入变动数据,按照事务提交时点为界,分为了 FORCE 和 STEAL 两类:

  • 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,一般翻译为“重做日志”。

Write-Ahead Logging 在崩溃恢复时,会以此经历以下三个阶段:

  1. 分析阶段(Analysis):从最后一次检查点Checkpoint(从这个检查点之前所有的数据都成功落盘)开始扫描日志,找出所有没有End Record的事务,组成待恢复的事务集合(一般包括 Transaction Table 和 Dirty Page Table);
  2. 重做阶段(Redo):这个阶段依据分析阶段产生的待恢复的事务集合来重演历史(Repeat History)找出所有的包含Commit Record的日志,将它们写入磁盘,写入完成后追加一个End Record,然后移除待恢复 集合。
  3. 回滚阶段(Undo):该阶段处理经过分析、重做阶段后剩余的恢复事务集合,此时剩下的都是需要回滚的事务(被称为 Loser),根据 Undo Log 中的信息回滚这些事务。

数据库按照“是否允许 FORCE 和 STEAL”可以产生四种组合,从优化磁盘 I/O 的角度看,NO-FORCE 加 STEAL 组合的性能无疑是最高的;从算法实现与日志的角度看,NO-FORCE 加 STEAL 组合的复杂度无疑是最高的。这四种组合与 Undo Log、Redo Log 之间的具体关系如下图所示:

image-20220705093501892

隔离性

隔离性保证了每个事务各自读写的相互独立性。主要是为了解决事务并发问题,有并发的地方就有锁。

  • 写锁(Write Lock 也叫排他锁eXclusive Lock,简写X-Lock):只有持有写锁的事务才能对数据进行写入操作,数据加写锁时,其它事务不能写入数据,也不能施加读锁(写锁禁止其他事务施加读锁,而不是禁止事务读取数据。)。

  • 读锁(Read Lock 也叫共享锁Shared Lock,简写S-Lock):多个事务可以对同一个数据添加读锁,添加了读锁后就不能添加写锁,所以事务不能对数据进行写入,但仍然可以读取。对持有读锁的事务,如果该事务只有一个读锁就可以直接升级为写锁,然后写入数据。

  • 范围锁(Range Lock)对某个范围数据直接添加排他锁,在这个范围的数据不能被读取,也不能被写入。如 select * from books where price < 100 for update;

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

可串行化(Serializable)

对事务所有读、写的数据全都加上读锁、写锁和范围锁即可(这种可串行化的实现方案称为 Two-Phase Lock)。

强度最高的隔离性。粗粒度锁,事务顺序执行那么就不会互相产生影响。但是吞吐量最低。

可重复读(Repeatable Read)

可重复读的意思是对事务所涉及到的数据加读锁和写锁,并且一直持续到事务结束,但是不再添加范围锁。

缺点:在可读事务中的幻读问题(MySQL 中的InnoDB在只读事务中不存在幻读问题,只在读写事务提升为当前读后存在这个问题)如下读事务:

1
2
3
4
5
6
7
8
9

/* 时间顺序:1,事务: T1 */
SELECT count(1) FROM books WHERE price < 100

/* 时间顺序:2,事务: T2 */
INSERT INTO books(name,price) VALUES ('深入理解Java虚拟机',90)

/* 时间顺序:3,事务: T1 会查出来 T2 插入的数据,但是InnoDB不会*/
SELECT count(1) FROM books WHERE price < 100

读提交(Read Committed)

读提交会对事务涉及到的数据加写锁,会一直持续到事务结束,加的读锁会在读完之后立马释放。

读提交有了不可重复读—-对同一行数据两次查询得到了不同的结果。

原因是读已提交的隔离级别缺乏贯穿整个事务周期的读锁,无法禁止读取过的数据发生变化。

读未提交(Read UnCommitted)

读未提交只会对事务所涉及到的数据加写锁,不会加读锁。

读未提交有了脏读—-一个事务读到了另外一个事务未提交的数据

其实,不同隔离级别以及幻读、脏读等问题都只是表面现象,它们是各种锁在不同加锁时间上组合应用所产生的结果,锁才是根本的原因。除了锁之外,以上对四种隔离级别的介绍还有一个共同特点,就是一个事务在读数据过程中,受另外一个写数据的事务影响而破坏了隔离性。针对这种“一个事务读 + 另一个事务写”的隔离问题,有一种名为“多版本并发控制”(Multi-Version Concurrency Control,MVCC)的无锁优化方案被主流的商业数据库广泛采用。

多版本并发控制MVCC(Multi-Version Concurrency Control)无锁化。

MVCC是一种读取优化策略,它的“无锁”是特指读取数据时不需要加锁。

MVCC只针对读+写的场景优化,如果是两个事务同时修改数据,即写+写的场景那么几乎没有什么优化的空间

MVCC 的基本思路是对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版副本与老版本共存,以此达到读取时可以完全不加锁的目的。这句话里的“版本”是个关键词,你不妨将其理解为数据库中每一行记录都存在两个看不见的字段:CREATE_VERSION 和 DELETE_VERSION,这两个字段记录的值都是事务 ID(事务 ID 是一个全局严格递增的数值),然后:

  1. 数据被插入时:CREATE_VERSION 记录插入数据的事务 ID,DELETE_VERSION 为空。
  2. 数据被删除时:DELETE_VERSION 记录删除数据的事务 ID,CREATE_VERSION 为空。
  3. 数据被修改时:将修改视为“删除旧数据,插入新数据”,即先将原有数据复制一份,原有数据的 DELETE_VERSION 记录修改数据的事务 ID,CREATE_VERSION 为空。复制出来的新数据的 CREATE_VERSION 记录修改数据的事务 ID,DELETE_VERSION 为空。
  • 隔离级别是可重复读:总是读取 CREATE_VERSION 小于或等于当前事务 ID 的记录,在这个前提下,如果数据仍有多个版本,则取最新(事务 ID 最大)的。
  • 隔离级别是读已提交:总是取最新的版本即可,即最近被 Commit 的那个版本的数据记录。