以转账为例理解事务的基本概念
假设有两个事件,第一个是从Y转账1块钱到X,另一个是对于所有的银行账户做审计,确保总的钱数不会改变,因为在账户间转钱不会改变所有账户的总钱数。 我们假设这两个事件同时发生。这里的每一个事件可以理解为一个事务,第一个事务称为T1,程序员会标记它的开始,我们称之为BEGIN,之后是对于两个账户的操作,我们会对账户X加1,对账户Y加-1。之后我们需要标记事务的结束,我们称之为END。
BEGIN_T1
ADD(X,1)
ADD(Y,-1)
END_T1
同时,我们还有一个事务,会检查所有的账户,对所有账户进行审计,确保尽管可能存在转账,但是所有账户的金额加起来总数是不变的。所以,第二个事务是审计事务,我们称为T2。我们也需要为事务标记开始和结束。这一次我们只是读数据,所以这是一个只读事务。我们需要获取所有账户的当前余额,因为现在我们只有两个账户,所以我们使用两个临时的变量,第一个是用来读取并存放账户X的余额,第二个用来读取并存放账户Y的余额,之后我们将它们都打印出来,最后是事务的结束。
BEGIN_T2
ACCOUNT_X=GET(X)
ACCOUNT_Y=GET(Y)
PRINT(ACCOUNT_X+ACCOUNT_Y)
END_T2
有一个问题是,这两个事务运行后的合法结果是什么?这是我们首先想要确定的事情。最初的状态是,两个账户都是10块钱,但是在同时运行完两个事务之后,最终结果可能是什么?我们需要一个概念来定义什么是正确的结果。一旦我们知道了这个概念,我们需要构建能执行这些事务的机制,在可能存在并发和失败的前提下,仍然得到正确的结果。
ACID
在数据库中对于这个正确性的概念称为 ACID
Consistent:一致性,是指事务操作前和操作后,数据满足完整性约束,数据库保持一致性状态。一致性主要一般是针对业务数据的逻辑始终保持一致例如上文中提到转账,转帐前两个账户的余额加起来是 20,那么在转账后两个账户的余额加起来也应该是 20。Atomic:原子性,一个事务可能有多个步骤,比如说写多个数据记录,尽管可能在某个步骤存在故障,原子性需要保证要么全写成功,要么都没有写成功(All or Nothing)。不应该发生类似这种情况:在一个特定的时间发生了故障,导致事务中一半的写数据完成并可见,另一半的写数据没有完成。Isolated:隔离性,数据库允许多个事务同时对某个数据进行读写或修改,隔离可以防止多个事务并发执行时由于交叉执行而导致读取数据的不一致,隔离可以保证每个事务都以为自己是独占整个数据库。Durable:持久化的。这意味着,在事务提交之后,在客户端或者程序提交事务之后,并从数据库得到了回复说,yes,我们执行了你的事务,那么这时,在数据库中的修改是持久化的,它们不会因为一些错误而被擦除。在实际中,这意味着数据需要被写入到一些非易失的存储(Non-Volatile Storage),持久化的存储,例如磁盘。
一个数据库事务需要保证数据库中数据的一致性,其中一致性又由隔离性、原子性、持久性保证

转账中的隔离性
如果在同一时间并行的执行一系列的事务会生成一系列的结果。这里的结果包括两个方面: 1、 由任何事务中的修改行为产生的数据库记录的修改; 2、 和任何事务生成的输出。所以前面例子中的两个事务 T1的结果是修改数据库记录,T2的结果是打印出数据。 隔离性(Isolated)意味着可序列化(Serializable),可序列化是指并行的执行一些事物得到的结果,与按照某种串行的顺序来执行这些事务可以得到相同的结果。实际的执行过程或许会有大量的并行处理,但是这里要求得到的结果与按照某种顺序一次一个事务的串行执行结果是一样的。 例如上文所描述的转账例子中的事务,只有两种一次一个的串行顺序,要么是T1、T2,要么是T2、T1,我们可以看一下这两种串行执行生成的结果。 我们先执行T1,再执行T2,我们得到X=11、Y=9,因为T1先执行,T2中的打印,可以看到这两个更新过后的数据,所以这里会打印字符串“11,9”。另一种可能的顺序是,先执行T2,再执行T1,这种情况下,T2可以看到更新之前的数据,但是更新仍然会在T1中发生,所以最后的结果是X=11,Y=9。但是这一次,T2打印的是字符串“10,10”。
//先执行T1,再执行T2
开始
|
v
执行T1 (转账事务)
|-> 从账户Y扣除1美元 (Y = 9)
|-> 向账户X添加1美元 (X = 11)
|
v
执行T2 (审计事务)
|-> 读取账户X的余额 (X = 11)
|-> 读取账户Y的余额 (Y = 9)
|-> 打印 "11, 9"
|
v
结束
//先执行T2,再执行T1
开始
|
v
执行T2 (审计事务)
|-> 读取账户X的余额 (X = 10)
|-> 读取账户Y的余额 (Y = 10)
|-> 打印 "10, 10"
|
v
执行T1 (转账事务)
|-> 从账户Y扣除1美元 (Y = 9)
|-> 向账户X添加1美元 (X = 11)
|
v
结束
所以,这是两种串行执行的合法结果。如果我们同时执行这两个事务,看到了这两种结果之外的结果,那么我们运行的数据库不能提供序列化执行的能力(也就是不具备隔离性 Isolated)。 我们可以提出有关执行顺序的假设。例如,假设系统实际上先开始执行T2,并执行到读X之后执行了T1,在T1结束之后T2再继续执行。
开始
|
v
执行T2 (审计事务)
|
v
读取账户X的余额 (此时为10美元)
|
v
执行T1 (转账事务)
|-> 从账户Y扣除1美元
|-> 向账户X添加1美元
|
v
T1完成
|
v
T2继续执行
|
v
读取账户Y的余额 (账户Y为9美元)
|
v
T2完成
|
v
结束
最后的打印将会是字符串“10,9”,这不符合之前的两种结果,所以这里描述的执行方式不是可序列化的,它不合法。所以实现隔离性的目的就是通过各种手段避免这种情况的发生,不管事务之间是怎样运行,最后的结果必须是合法的,要符合上述转账例子中合法结果中的其中一种。 可序列化是一个应用广泛且实用的定义,背后的原因是,它定义了事务执行过程的正确性。它是一个对于程序员来说是非常简单的编程模型,作为程序员可以写非常复杂的事务而不用担心系统同时在运行什么,或许有许多其他的事务想要在相同的时间读写相同的数据,或许会发生错误,这些都不需要关心。可序列化特性确保可以安全的写事务,就像没有其他事情发生一样。因为系统最终的结果必须表现的就像,事务在这种一次一个的顺序中是独占运行的。这是一个非常简单,非常好的编程模型。 可序列化的另一方面优势是,只要事务不使用相同的数据,它可以允许真正的并行执行事务。我们之前的例子之所以有问题,是因为T1和T2都读取了数据X和Y。但是如果它们使用完全没有交集的数据库记录,那么这两个事务可以完全并行的执行。在一个分片的系统中,不同的数据在不同的机器上,可以获得真正的并行速度提升,因为可能一个事务只会在第一个机器的第一个分片上执行,而另一个事务并行的在第二个机器上执行。所以,这里有可能可以获得更高的并发性能。
转账中的持久性与原子性
在执行事务的过程中随时可能会发生故障,例如在执行 T1 事务时刚从 Y 账户扣除 1 美元此时发生了宕机,该事务异常中止,我们需要通过一种手段保证 Y 账户的余额回滚到事务执行之前(10 美元)。这就是原子性,事务执行过程中即使发生了故障但是该事务就好像没有发生过一样没有表现得没有更改过任何数据。 当我们执行完了事务,在我们后续的查询事务中,账户 Y 和 X 的余额就应该为 9、10,所以我们需要一些持久化的手段将余额写入硬盘中,这就是持久性。
开始
|
v
执行T1 (转账事务)
|-> 从账户Y扣除1美元 (Y = 9)
|-> 向账户X添加1美元 (X = 11)
|
v
结束
并发控制
事务并行可能发生的情况
当两个事务同时访问数据库中的相同数据时,可能有几种情况:
读+读:两个事务都查询数据。当两个事务对相同数据全部是读操作时,不会产生任何并发问题。读+写:一个事务查询数据,一个事务修改数据。当两个事务对相同数据有读有写时,可能会产生脏读、不可重复度、幻读的问题。写+写:两个事务都修改数据,当两个事务对相同数据全部是写操作时,可能产生数据丢失(回滚丢失、覆盖丢失)等问题。
以下是可能会产生的异常问题
脏写(Dirty Write)
Dirty Write 就是一个事务覆盖了另一个之前还未提交事务写入的值。假设现在我们有两个事务,一个事务写入 x = y = 1,而另一个事务写入 x = y = 2,那么最终的结果,我们是希望看到 x 和 y 要不全等于 1,要不全等于 2。但在 Dirty Write 情况下面,可能会出现如下情况:
+------+-------+-------+-------+-------+
| T1 | Wx(1) | | | Wy(1) |
+------+-------+-------+-------+-------+
| T2 | | Wx(2) | Wy(2) | |
+------+-------+-------+-------+-------+
| x(0) | 1 | 2 | 2 | 2 |
+------+-------+-------+-------+-------+
| y(0) | 0 | 0 | 2 | 1 |
+------+-------+-------+-------+-------+
可以看到,最终的值是 x = 2 而 y = 1,已经破坏了数据的一致性了。
脏读(Dirty Read)
Dirty Read 出现在一个事务读取到了另一个还未提交事务的修改数据。假设现在我们有一个两个账户,x 和 y,各自有 50 块钱,x 需要给 y 转 40 元钱,那么无论怎样,x + y = 100 这个约束是不能打破的,但在 Dirty Read 下面,可能出现:
+-------+--------+--------+--------+--------+
| T1 | Wx(10) | | | Wy(90) |
+-------+--------+--------+--------+--------+
| T2 | | Rx(10) | Ry(50) | |
+-------+--------+--------+--------+--------+
| x(50) | 10 | 10 | 10 | 10 |
+-------+--------+--------+--------+--------+
| y(50) | 50 | 50 | 50 | 90 |
+-------+--------+--------+--------+--------+
在事务 T2,读取到的 x + y = 60,已经打破了约束条件了。
不可重复读(Non-Repeatable/Fuzzy Read)
一个还在执行的事务读取到了另一个事务的更新操作,仍然是上面的转账例子:
+-------+--------+--------+--------+--------+
| T1 | Rx(50) | | | Ry(90) |
+-------+--------+--------+--------+--------+
| T2 | | Wx(10) | Wy(90) | |
+-------+--------+--------+--------+--------+
| x(50) | 50 | 10 | 10 | 10 |
+-------+--------+--------+--------+--------+
| y(50) | 50 | 50 | 90 | 90 |
+-------+--------+--------+--------+--------+
在 T1 还在运行的过程中,T2 已经完成了转账,但 T1 这时候能读到最新的值,也就是 x + y = 140 了,破坏了约束条件。
幻读(Phantom Read)
幻读通常发生在一个事务首先进行了一次按照某个条件的 read 操作,譬如 SQL 里面的 SELECT WHERE P,然后在这个事务还没结束的时候,另外的事务写入了一个新的满足这个条件的数据,这时候这个新写入的数据就是 Phantom 的了。
+----------------+-----------+--------------+--------------+--------------+
| T1 | {a, b, c} | | | R(4) |
+----------------+-----------+--------------+--------------+--------------+
| T2 | | W(d) | W(4) | |
+----------------+-----------+--------------+--------------+--------------+
| Employees | {a, b, c} | {a, b, c, d} | {a, b, c, d} | {a, b, c, d} |
+----------------+-----------+--------------+--------------+--------------+
| Employee Count | 3 | 3 | 4 | 4 |
+----------------+-----------+--------------+--------------+--------------+
假设现在 T1 按照某个条件读取到了所有雇员 a,b,c,这时候 count 是 3,然后 T2 插入了一个新的雇员 d,同时更新了 count 为 4,但这时候 T1 在读取 count 的时候会得到 4,已经跟之前读取到的 a,b,c 冲突了。
隔离级别
SQL 标准提出了四种隔离级别来规避这些现象,隔离级别越高,性能效率就越低,这四个隔离级别如下,不同的隔离级别可以控制出现并发异常的可能,隔离级别的实现有很多,不同的数据库实现的方式不同。

并发控制概念
如果两个事务不触及相同的数据,它们可以安全地并行运行,当一个事务读取由另一个正在运行事务将要修改的数据时,或者当两个事务试图同时修改相同的数据时,并发问题(竞争条件)才会出现。
所谓并发控制,就是保证并发执行的事务在某一 隔离级别 上的正确执行的机制。需要指出的是并发控制由数据库的调度器负责,事务本身并不感知,如下图所示,Scheduler 将多个事务的读写请求,排列为合法的序列,使之依次执行:
这个过程中,对可能破坏数据正确性的冲突事务,调度器可能选择下面两种处理方式。
- Delay:延迟某个事务的执行到合法的时刻
- Abort:直接放弃事务的提交,并回滚该事务可能造成的影响
可以看出Abort比Delay带来更高的成本
事务并发分类
从图中的两个维度,可以对常见的并发控制机制进行分类,需要指出的是这些并发控制机制并不与具体的隔离级别绑定。
实际,这两种策略哪个更好取决于不同的环境。如果冲突非常频繁,你或许会想要使用悲观并发控制,因为如果冲突非常频繁的话,在乐观并发控制中你会有大量的Abort操作。如果冲突非常少,那么乐观并发控制可以更快,因为它完全避免了锁带来的性能损耗。
乐观程度
不同的实现机制,基于不同的对发生冲突概率的假设,悲观方式认为只要两个事务访问相同的数据库对象,就一定会发生冲突,因而应该尽早阻止;而乐观的方式认为,冲突发生的概率不大,因此会延后处理冲突的时机。如上图横坐标所示,乐观程度从左向右增高:
- 基于
Lock:最悲观的实现,需要在操作开始事务开始前,对要访问的数据库对象加锁,对冲突操作Delay; - 基于
Timestamp:乐观的实现,每个事务在开始时获得全局递增的时间戳,期望按照开始时的时间戳依次执行,在操作数据库对象时检查冲突并选择Delay或者Abort; - 基于
Validation:更乐观的实现,仅在Commit前进行Validate,对冲突的事务Abort
可以看出,不同乐观程度的机制本质的区别在于,检查或预判冲突的时机,Lock在事务开始时,Timestamp在操作进行时,而Validation在最终Commit前。相对于悲观的方式,乐观机制可以获得更高的并发度,而一旦冲突发生,Abort事务也会比Delay带来更大的开销。
多版本
如上图纵坐标所示,相同的乐观程度下,还存在多版本的实现。所谓多版本,就是在每次需要对数据库对象修改时,生成新的数据版本,每个对象的多个版本共存。读请求可以直接访问指定版本的数据,从而避免读写事务和只读事务的相互阻塞。当然多版本也会带来对不同版本的维护成本,如需要垃圾回收机制来释放不被任何事物可见的版本。
事务并发控制常见手段
TPL(两阶段锁)
核心思想:对于并发可能冲突的操作,比如读-写,写-读,写-写,通过锁使它们互斥执行。 锁通常分为共享锁和排他锁两种类型
- 共享锁(S):事务T对数据A加共享锁,其他事务只能对A加共享锁但不能加排他锁。
- 排他锁(X):事务T对数据A加排他锁,其他事务对A既不能加共享锁也不能加排他锁
基于锁的并发控制流程:
- 事务根据自己对数据项进行的操作类型申请相应的锁(读申请共享锁,写申请排他锁)
- 申请锁的请求被发送给锁管理器。锁管理器根据当前数据项是否已经有锁以及申请的和持有的锁是否冲突决定是否为该请求授予锁。
- 若锁被授予,则申请锁的事务可以继续执行;若被拒绝,则申请锁的事务将进行等待,直到锁被其他事务释放。
- 申请到的锁直到事务结束后才能释放,不允许在事务的中间过程释放锁。
可能出现的问题:
- 死锁:多个事务持有锁并互相循环等待其他事务的锁导致所有事务都无法继续执行。
- 饥饿:数据项A一直被加共享锁,导致事务一直无法获取A的排他锁。
- 性能下降:因为2PL对所有读写的数据都进行了加锁,因此不同事物之间的读写冲突都可以避免,也正是由于对所有读写的数据都进行了加锁,并发访问冲突比较多的时候,由于锁竞争,会导致事物执行效率大大降低。
对于可能发生冲突的并发操作,锁使它们由并行变为串行执行,是一种悲观的并发控制。
Time Order(时间戳排序)
每个事务在开始时都会被分配一个唯一的时间戳(Timestamp),这个时间戳代表了事务的开始时间。
数据库中的每条记录也会存储两个时间戳:一个是最后一次读取该记录的事务的时间戳(Read Timestamp),另一个是最后一次写入该记录的事务的时间戳(Write Timestamp)。
当事务尝试读取或写入一条记录时,它会检查自己的时间戳与记录的时间戳之间的关系,以确定是否允许进行操作。以下是一些基本的规则:
- 读取操作(Read)
1、 如果记录的Write Timestamp小于或等于事务的时间戳(TS),那么事务可以读取该记录。 2、 如果记录的Write Timestamp大于事务的时间戳,说明有其他事务在当前事务之后修改了这条记录,当前事务需要回滚。
- 写入操作(Write)
1、 如果记录的Read Timestamp小于事务的时间戳,说明没有其他事务正在读取这条记录,事务可以写入。 2、 如果记录的Read Timestamp大于或等于事务的时间戳,说明有其他事务正在读取或已经读取了这条记录,当前事务需要回滚。
OCC(有效性检查)
核心思想:事务对数据的更新首先在自己的工作空间进行,等到要写回数据库时才进行有效性检查,对不符合要求的事务进行回滚。 基于有效性检查的事务执行过程会被分为三个阶段:
- 读阶段:数据项被读入并保存在事务的局部变量中。所有write操作都是对局部变量进行,并不对数据库进行真正的更新。
- 有效性检查阶段:对事务进行有效性检查,判断是否可以执行write操作而不违反可串行性。如果失败,则回滚该事务。
- 写阶段:事务已通过有效性检查,则将临时变量中的结果更新到数据库中。
MVCC (多版本控制)
MVCC 的核心思想是为数据库中的数据项维护多个版本,这样不同的事务可以在不同的时间点看到数据的不同快照,从而避免了传统锁机制带来的性能瓶颈。 MVCC 的工作原理如下:
- 写操作时创建新版本:
当一个事务需要修改数据时,数据库不会直接覆盖原有数据,而是创建一个新的数据版本。这个新版本包含了事务所做的修改。原有数据版本仍然保留在数据库中,以便其他事务可以访问。
- 读操作访问旧版本:
事务在读取数据时,会根据其开始时的时间戳(或版本号)来获取数据的相应版本。这意味着每个事务都看到了一个一致的数据快照,即使其他事务正在修改数据。这种快照是只读的,事务不能修改它所读取的数据版本。
- 版本垃圾回收:
随着时间的推移,一些数据版本可能不再被任何事务引用。数据库系统会定期清理这些过时的版本,以节省存储空间。 读写操作不互相阻塞:由于读操作不锁定数据,写操作不等待读操作完成,因此大大提高了并发性能,但是写写操作还需要通过其他手段。
乐观程度+MVCC
多版本可以解决读写互相不阻塞,而写写事务之间还是有冲突的情况,所以结合多版本有以下并发控制的变种
- Multi Version Timestamp Ordering (MVTO): 多版本解决读写互相不阻塞,写写采用 Time Order
- Multi Version Optimistic Concurrency Control (MVOCC):多版本解决读写互相不阻塞,写写采用 OCC
- Multi Version Two-phase Locking (MV2PL):多版本解决读写互相不阻塞,写写采用 2PL,innodb 就是采用了这种并发控制模型
故障恢复
故障恢复概念
一般数据库的实现都会有一个内存池用于管理从磁盘获取的 page,数据库对所有数据的查看以及修改都是在这个内存池中进行然后进行后续的刷盘操作,将更改结果持久化,所以在持久化过程中会出现所说两大系统故障
事务故障:在事务提交前出现故障,但是事务对数据库的部分修改已经写入磁盘中。这导致了事务的原子性被破坏。系统故障:在系统崩溃前事务已经提交,但数据还在内存缓冲区中,没有写入磁盘。系统恢复时将丢失此次已提交的修改。这是对事务持久性的破坏。
故障恢复需要保障假如在红线时 Crash recovery,那么 tx1 需要回滚,而 tx2 需要保证持久化落盘

日志恢复技术
Undo Log(撤销日志)
- 目的:记录事务执行过程中对数据所做的修改,以便在事务失败或需要回滚时,能够撤销这些修改,恢复到事务开始前的状态。
- 工作原理:每当事务对数据库进行修改时,数据库系统会在undo log中记录这些修改的详细信息,包括修改前的数据状态。如果事务需要回滚,系统会查找undo log中的相应记录,并根据这些记录将数据恢复到原始状态。
Redo Log(重做日志)
- 目的:记录事务对数据库所做的修改,以便在系统崩溃后能够重新应用这些修改,确保已提交事务的持久性。
- 工作原理:当事务提交前,其修改的数据会被写入redo log。在系统崩溃后,数据库系统会从redo log中读取这些记录,并重新执行这些修改,以确保数据库状态与崩溃前一致。
steal/no-steal:
steal策略: 是否允许一个uncommitted的事务的修改更新到磁盘,如果是steal策略,那么此时磁盘上就可能包含uncommitted的数据,因此系统需要记录undo log,以便事务abort时进行回滚(roll-back)。no steal策略:数据只会在内存中更新, 磁盘上不会存在uncommitted的数据,因此无需回滚操作,也就无需记录undo log。
force/no-force:
force策略:表示事务在committed之后必须将所有更新立刻持久化到磁盘,这样会导致磁盘发生很多小的写 io(更可能是随机写)会严重影响磁盘性能。no-force策略:表示事务在committed之后可以不立即持久化到磁盘, 这样可以缓存很多的更新批量持久化到磁盘,这样可以降低磁盘操作次数(提升顺序写),但是如果committed之后发生crash,那么此时已经committed的事务数据将会丢失(因为还没有持久化到磁盘),因此系统需要记录redo log,在系统重启时候进行前滚(roll-forward)操作。
undolog&redolog
首先确定数据库是 force 还是 no force,很显然 no force 的性能更好但是需要 redo log 但因为 redo log 是追加写所以相比较 force 的随机写性能还是要更好一点,所以采用 no force+redo log 起到了提高性能的作用。
no steal 策略不把 uncommitted 的数据更新到硬盘中,这样确实能够减少硬盘 IO,但此时内存中存在大量的脏页在高并发场景下影响系统的整体性能,并可能增加事务等待提交的时间。所以目前主流数据库选择的方案是 no force + steal

- 持久性故障:Redo 内容保证,已提交事务的未刷盘修改,利用Redo Log中的内容重放,之后可见;
- 系统性故障:Undo内容保证,失败事务的已刷盘的修改会在恢复阶段通过Undo日志回滚,不再可见。

日志恢复流程
假设我们有一个简单的银行账户系统,其中包含两个账户A和B。在系统崩溃前,发生了以下事务:
- 事务T0开始,尝试将账户A的余额从1000元减少到950元,将账户B的余额从2000元增加到2050元。
- 事务T0成功提交。
- 事务T1开始,尝试将账户C的余额从700元减少到600元。
在系统崩溃之前,这些操作都被记录在了数据库的事务日志中。日志记录可能如下所示:
<T0 start>:表示事务T0开始。
<T0,A,1000,950>:表示事务T0对账户A进行了操作,将余额从1000元减少到950元。
<T0,B,2000,2050>:表示事务T0对账户B进行了操作,将余额从2000元增加到2050元。
<T0 commit>:表示事务T0已成功提交。
<T1 start>:表示事务T1开始。
<T1,C,700,600>:表示事务T1对账户C进行了操作,将余额从700元减少到600元。
现在,假设在事务T1执行过程中,系统崩溃了。系统崩溃时,事务T1的日志中只有开始记录,没有提交()或中止()记录。这意味着事务T1的操作可能只部分完成,或者根本没有完成。 在系统恢复时,数据库管理系统(DBMS)会执行以下步骤:
- Redo(重做):首先,DBMS会检查日志,找到所有已提交的事务(即有记录的事务)。在这个例子中,只有事务T0是已提交的。DBMS会重放这些事务的日志记录,将数据库状态更新到这些事务提交时的状态。这意味着账户A的余额会被更新为950元,账户B的余额会被更新为2050元。
- Undo(撤销):接下来,DBMS会检查日志,找到所有未完成的事务(即有记录但没有或记录的事务)。在这个例子中,事务T1是未完成的。DBMS会回滚这些事务的操作,将数据库状态恢复到事务开始之前的状态。这意味着账户C的余额会从600元恢复到700元。
通过这种方式,DBMS确保了即使在系统崩溃后,数据库的一致性和事务的原子性也得到了维护。已提交的事务对数据库的修改被永久保存,而未完成的事务则被撤销,确保了数据库状态的一致性。