事务的概念最早起源于数据库系统,但在今天已经不限于数据库本身。所有需要保证数据一致性的场景,包括但不限于数据库、事务内存、缓存、消息队列、分布式存储等,都有可能用到事务。
按照数据库的经典理论,为了达到数据的一致性(consistency
),需要满足下面三方面:
原子性(atomic
):在同一项业务处理过程中,事务保证了对于多个数据的修改,要么全部成功,要么全部失败。
隔离性(Isolation
):在不同的业务处理过程中,事务保证了各业务正在读写的数据相互独立,不会相互影响。
持久性(durability
):事务应当保证所有成功被提交的数据修改都能够正确地被持久化,不丢失数据。
上面的一致性、原子性、隔离性、持久性即为事务的四大属性,其中一致性是事务的目的,原子性、隔离性和持久性是达到一致性这一目的的手段。
下面以在线书店卖书的例子说明,当一本书被成功售出时,需要做以下三件事情:
原子性和持久性在事务里是密切相关的两个属性:
为了满足持久性,数据必须要写入磁盘等持久性设备,但是“写入磁盘”这个操作并不是原子的,不仅存在着“未写入”和“写入”状态,还客观存在着“正在写入”的中间状态。
对于在线书店卖书的场景来说,由于磁盘写入存在中间状态,所以可能出现以下数据不一致情形:
未提交事务,写入后崩溃:程序还没有修改完三个数据,但数据库已经将其中一个或两个的变动写入磁盘,若此时出现崩溃,一旦重启之后,数据库必须要有办法知道崩溃之前发生过一次不完整的购物操作,将已经修改过的数据从磁盘中恢复成没有改过的样子,以保证原子性。
已提交事务,写入前崩溃:程序已经修改完三个数据,但数据库还未将全部三个数据的改动全部写入磁盘,若此时出现崩溃,一旦重启之后,数据库必须要有办法知道崩溃之前发生过一次完整的购物操作,将还没来得及写入磁盘的那部分数据重新写入,以保证持久性。
由于写入磁盘的”正在写入”中间状态和崩溃都是无法避免的,为了保证原子性和持久性,就只能在崩溃后采取恢复的补救操作,这种数据恢复操作被称为“崩溃恢复”。
如何实现崩溃恢复呢?
步骤:
(1)记录日志:将修改数据这个操作所需的全部信息(修改什么数据、数据物理上位于哪个内存页和磁盘块中、从什么值修改为什么值等)以追加日志的方式记录到磁盘文件中
(2)修改数据:在日志记录安全落盘之后,数据库在日志中看到代表事务成功提交的“提交记录”(Commit Record
)后,根据日志上的信息对真正的数据进行修改,修改完成后,再在日志中加上一条“结束记录”(End Record
)表示事务已完成持久化。
上述这种事务实现方法被称为提交日志(Commit Logging
)。那么它是如何来保障数据的持久性和原子性的呢?
(1)如果日志成功写入 End Record
,表示事务完成持久化,即时系统崩溃,重启后也无需处理。
(2)如果日志成功写入Commit Record
但是没有写入End Record
,表示事务成功提交,如果系统崩溃,重启后根据日志信息记录修改数据即可,这保证了持久性。
(3)如果日志没有成功写入 Commit Record
就发生崩溃,表示事务提交失败,如果系统崩溃,重启后回滚这部分没有 Commit Record
的日志即可,这保证了原子性。
那么提交日志这种方法有没有什么缺点呢?
当然有,这种先写日志然后修改数据的方式必然导致:所有对数据的真实修改都必须发生在事务提交之后。这样,即使磁盘 IO 有足够空闲,即使某个事务修改的数据量非常庞大,占用了大量的内存缓冲区,都决不允许在事务提交之前修改磁盘上的数据。这一点是提交日志这一事务实现方法的前提,但是对于提升数据库的性能十分不利。
为了解决提交日志方法的缺陷,ARIES 提出了“提前写入日志”(Write-Ahead Logging
)的日志改进方案,所谓“提前写入”,就是指允许在事务提交之前写入变动数据。
按照事务提交时间点,将何时写入变动数据划分为 FORCE
和 STEAL
两类情况:
STEAL
:在事务提交前,允许变动数据提前写入则称为 STEAL
,不允许提前写入则称为NO-STEAL
。允许提前写入可以有效利用磁盘 IO,也可以有效节省数据库缓存区内存。
FORCE
:当事务提交后,要求变动数据必须同时完成写入称为 FORCE
,如果不强制要求变动数据必须同时完成写入称为NO-FORCE
。现实中绝大多数数据库采取的都是 NO-FORCE
策略,因为有了日志,变动数据随时可以持久化,这样可以提高磁盘IO性能。
Commit Logging
允许 NO-FORCE
,但是不允许 STEAL
。因为假如事务提交之前就有部分变动数据写入磁盘,一旦事务要回滚,这些提前写入的变动数据是无法回滚的。
Write-Ahead Logging
允许 NO-FORCE
,也允许 STEAL
。那么它是如何支持 STEAL
的呢?
Write-Ahead Logging
增加了一种被称为Undo Log
的日志类型。在变动数据写入磁盘前,必须先记录Undo Log
,注明修改了哪个位置的数据、从什么纸改成什么值等,以便在事务回滚或者崩溃恢复时根据 Undo Log
对提前写入的数据变动进行擦除。Undo Log
被翻译为回滚日志,此前记录的用于崩溃恢复时重演数据变动的日志就相应被命名为 Redo Log
,一般翻译为重做日志。
Write-Ahead Logging
是如何进行崩溃恢复的呢?
(1)分析阶段:找到所有没有 End Record
的事务,作为待恢复的事务集合;
(2)重做阶段:找到所有包含 Commit Record
的日志,说明这些事务成功提交,将这些事务修改的数据写入磁盘,写入完成后在日志中增加一条 End Record
,然后从待恢复事务集合中移除;
(3)回滚阶段:此时待恢复的事务集合中都是待回滚的事务,根据 Undo Log
中的信息,将已经提前写入磁盘的信息重新改写回去。
总结一下,数据库通过日志来实现事务的原子性和持久性。Redo Log允许我们在事务提交后再选择合适的时机修改真实数据;而 Undo Log 允许我们在事务提交之前就修改真实数据,两者都是为了提高数据库的 IO 性能。
隔离性保证了每个事务各自读写的数据相互独立,不会相互影响。
隔离性和并发密切相关,如果没有并发,所有事务全都是串行的,那么也就不需要隔离。
数据库是如何实现事务的隔离性的呢?或者说,数据库是如何进行并发访问控制的呢?
最容易想到的方案就是锁,现代数据库均提供了下面三种锁:
● 写锁:也叫排它锁、互斥锁,只有持有写锁的事务才能对数据进行写入操作,其他事务不能写入,也不能施加读锁。
● 读锁:也叫共享锁,多个事务可以对同一个数据添加多个读锁,如果只有一个事务施加了读锁,可以直接升级为写锁。
● 范围锁:对于某个范围的数据直接加写锁,在这个范围内的数据不能被写入。如下是典型的加范围锁的例子:
SELECT * FROM books WHERE price < 100 FOR UPDATE;
注意:加了范围锁之后,不仅不能修改该范围内已有的数据,也不能在该范围内新增或删除任何数据。
按照加锁程度的不同,数据库有以下四种事务隔离级别:串行化、可重复读、读提交、读未提交。
(1)串行化(Serializable
):对事务所有读写的数据全部加上读锁、写锁和范围锁即可实现串行化。
串行化的隔离级别最高,相应地,并发能力最低,完全不允许并发。
(2)可重复读(repeatable read
):对事务涉及数据加读锁和写锁,持有至事务结束,不再加范围锁。
可重复读会导致幻读问题,它是指在事务执行过程中,两个完全相同的范围查询得到了不同的结果集。
注意:MySQL innoDB 引擎的默认隔离级别为可重复读,但是它在只读事务中可以完全避免幻读问题,但在读写事务中仍然会出现幻读问题。
(3)读已提交(read committed
):对事务涉及数据加读锁和写锁,写锁持有至事务结束,读锁在查询操作完成后马上释放。
读已提交会导致不可重复读问题,它是指在事务执行过程中,对同一行数据的两次查询得到了不同的结果。
(4)读未提交(read uncommitted
):对事务涉及数据加写锁,持有至事务结束,但不加读锁。
读未提交会导致脏读问题,它是指在事务执行过程中,一个事务读取到了另一个事务未提交的修改。
总结:数据库的四种隔离级别是不同加锁策略产生的结果,以锁为手段来实现隔离性才是数据库表现出不同隔离级别的根本原因。
除了使用锁来实现事务的隔离性外,还有没有其他的方式来实现隔离性呢?
上面四种隔离级别可能会导致的幻读、脏读、不可重复读问题的场景都是:一个事务读+另一个事务写。针对这种场景,有一种名为“多版本并发控制”(MVCC
)的无锁优化方案被广大商业数据库广泛采用。
MVCC
是一种读取优化方案,它的“无锁”特指读取时不需要加锁。
那么 MVCC
是如何实现事务的隔离性的呢?
MVCC
的基本思路是对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版本与老版本共存。版本可以理解为数据库中的每一行记录都存在两个隐藏字段:create_version
和delete_version
,这两个字段记录了事务 ID,按照以下规则写入数据:
插入数据:create_version
记录插入数据的事务 ID,delete_version
为空;
删除数据:delete_version
记录删除数据的事务 ID,create_version
为空;
修改数据:等同于“删除旧数据+插入新数据”,先将原有数据复制一份,原有数据的delete_version
记录修改数据的事务ID,create_version
为空。复制后的新数据的 create_version
为修改数据的事务 ID,delete_version
为空。
如果有另外一个事务要读取这些发生了变化的数据,将根据隔离级别来决定到底应该读取哪个版本的数据:
● 可重复读:总是读取create_version
小于或等于当前事务 ID 的记录,如果满足条件的有多个版本,读取事务 ID 最大的版本;
● 读已提交:总是读取最新的版本
对于读未提交,无需使用 MVCC
。
对于可串行化,无法使用 MVCC
。
适用场景:MVCC
是只针对“读+写”场景的优化,对于“写+写”场景,必须加锁解决。
总结:数据库为了保证数据的一致性,使用日志(Redo Log 和 Undo Log)来实现原子性和持久性,使用锁和MVCC来实现隔离性,原子性、持久性和隔离性共同保证了数据的一致性。