前文介绍了两种恢复日志,它们的主要区别在于写日志时保存数据库元素的新值还是旧值,两种方式各有优劣:
-
undo 日志要求在事务提交前把数据写到磁盘,可能增加磁盘 IO 次数。
-
redo 日志要求在事务提交和日志刷写前保留所有的数据块缓存,这样会增加事务需要的缓冲区空间。
-
如果一个缓存块包含多个数据库元素,那么 undo 日志和 redo 日志在处理缓存区时都存在冲突。例如同一缓冲块包含一个已提交事务的元素 A 和一个未提交事务的元素 B,那么需要将 A 刷写到磁盘,但是 B 又要求不能这样做。
为了解决上述问题,引入了一种称为 undo/redo 日志的日志类型,它通过维护更多的信息来提供日志的灵活性。
undo/redo 日志与前两种日志类型基本相同,但在修改数据库元素时写入的日志记录有四个部分。一条 [T, X, v, w] 记录表示事务 T 将数据库元素 X 的值从 v 修改为 w。undo/redo 日志需要遵循以下原则:
UR1:在事务 T 对数据库元素 X 的修改到达磁盘前,日志记录 [T, X, v, w] 必须刷写到磁盘上。
【例 5.10】表 5-8 是例 5.6 中事务 T 的一个变体,其中日志内容和事务操作顺序发生了变化。一条更新日志记录同时包含数据库元素的旧值和新值,[COMMIT T] 可以出现在第 7 步之后的任意位置。
表 5-8
步骤 | 操作 | t | A(内存) | B(内存) | A(磁盘) | B(磁盘) | 日志 |
---|---|---|---|---|---|---|---|
1 | [START T] | ||||||
2 | READ(A, t) | 15 | 15 | 15 | 15 | ||
3 | t := t-10 | 5 | 15 | 15 | 15 | ||
4 | WRITE(A, t) | 5 | 5 | 15 | 15 | [T, A, 15, 5] | |
5 | READ(B, t) | 15 | 5 | 15 | 15 | 15 | |
6 | t := t+10 | 25 | 5 | 15 | 15 | 15 | |
7 | WRITE(B, t) | 25 | 5 | 25 | 15 | 15 | [T, B, 15, 25] |
8 | FLUSH LOG | ||||||
9 | OUTPUT(A) | 25 | 5 | 25 | 5 | 15 | |
10 | [COMMIT T] | ||||||
11 | OUTPUT(B) | 25 | 5 | 25 | 5 | 25 |
undo/redo 日志的恢复策略如下:
-
按照从前往后的顺序,重做所有已提交的事务。
-
按照从后往前的顺序,撤销所有未提交的事务。
【例 5.11】考虑表 5-8 中的动作序列。
-
假设故障发生在 [COMMIT T] 记录刷写到磁盘之后,这时 T 被认为是已提交的事务,需要为 A 和 B 写入日志记录中的新值。
-
假设故障发生在 [COMMIT T] 记录到达磁盘之前,则 T 被认为是未完成的事务,需要为 A 和 B 写入日志记录中的旧值。
和 undo 日志一样,使用 undo/redo 日志的系统中可能出现这样的行为:事务在用户看来已经提交(如在网上预订了一个航班座位然后断开连接),但由于 [COMMIT T] 记录尚未刷写到磁盘,后来的一次故障使该事务被撤销而不是重做。如果这样的可能性是一个问题,那么建议为 undo/redo 日志使用一条附加的规则:
UR2:[COMMIT T] 记录一旦出现在日志中就必须被刷写到磁盘上。
例如,在表 5-8 中我们将在第 10 步后加入“FLUSH LOG”。
undo/redo 日志的动态检查点相对来说更为简单,创建检查点主要有以下几步:
-
写入日志记录 [START CKPT(T1, ..., Tk)] 并刷写日志,其中 T1, ..., Tk 是所有活跃的事务。
-
将缓冲区的所有脏数据写到磁盘。可以容忍将未完成事务修改的数据写到磁盘,所以能够容忍小于完整块的数据库元素,并因此可以在事务之间共享缓冲区。
-
写入日志记录 [END CKPT]。
【例 5.12】表 5-9 给出了类似于表 5-7 中 redo 日志的一个 undo/redo 日志,区别在于更新记录的不同。
和例 5.8 中一样,T2 被确定为检查点开始时唯一的已提交事务。由于这一日志是 undo/redo 日志,有可能 T2 写入的 B 的新值 20 己写到磁盘,而这在 redo 日志中是不可能的。在检查点过程中,如果 B 的新值还不在磁盘上,因为会刷写所有的脏缓冲区,所以肯定会将 B 刷写到磁盘上。同样,如果由已提交的事务 T1 写入的 A 的新值还不在磁盘上,那么将刷写 A 到磁盘上。
表 5-9
[START T1] |
---|
[T1, A, 5, 10] |
[START T2] |
[COMMIT T1] |
[T2, B, 10, 20] |
[START CKPT(T2)] |
[T2, C, 20, 30] |
[START T3] |
[T3, D, 30, 40] |
[END CKPT] |
[COMMIT T2] |
[COMMIT T3] |
如果系统故障在这一系列步骤的末尾发生,则 T2 和 T3 被判定为已提交的事务。事务 T1 在检查点前提交,由于在日志中发现 [END CKPT] 记录,可知 T1 已经完成并已将其改变写到磁盘上。因此只需要重做 T2 和 T3,就像在例 5.8 中那样。当重做像 T2 这样的事务时,并不需要查看比 [START CKPT(T2)] 还早的记录,因为 T2 在检查点开始前的改变在检查点过程中已经被刷写到磁盘。
假设故障正好在 [COMMIT T3] 记录写到磁盘前发生,那么可以判定 T2 是已提交的而 T3 是未提交的。可以通过将磁盘上的 C 置为 30 来重做 T2;没有必要将 B 置为 20,因为我们知道这一改变在 [END CKPT] 前已到达磁盘。和 redo 日志不同的是,这里还要撤销 T3,也就是将磁盘上的 D 置为 30。如果 T3 在检查点开始时是活跃的,将不得不查看比 START CKPT 记录更早的记录,以确定 T3 是否有更多的动作已到达磁盘因而需要撤销。
可以注意到,这里并没有指明在使用 undo/redo 日志恢复时先撤销还是先重做。事实上,不管先撤销还是先重做,都会面临如下情况:事务 T 提交了且被重做了。然而,T 读取了一个值 X,该值是由某个未提交并被撤销的事务 U 写入的。
问题不在于先重做还是先撤销,实际上数据库管理系统必须保证上述的情况绝不会发生。我们将在后续章节中讨论隔离像 T 和 U 这样的事务的方法,使它们能够并行地访问数据库元素 X 但不会相互影响。
这里介绍两个新的概念:steal 和 force。
-
steal/no-steal
-
如果采用 steal 策略,那么数据库在事务提交之前就可以把修改后的数据写到磁盘,如果系统发生故障就可能导致数据库的不一致状态,因此数据库需要记录 undo log,在系统恢复时回滚未提交的事务。
-
如果采用 no-steal 策略,在事务提交之前对数据的任何修改都不会到达磁盘,因此不需要记录 undo log,但这种方式限制了缓存的使用方式,导致不得不缓存大量的脏数据直到事务提交。
-
-
force/no-force
-
如果采用 force 策略,在事务提交之前必须将修改的数据全部写到磁盘,这样做可以很好地保证数据库一致性,但会导致很多不必要的磁盘写入。
-
如果采用 no-force 策略,可以在事务提交之后再把数据写入磁盘,但由于系统故障可能导致缓存中的数据丢失,所以需要记录 redo log,在系统恢复时重做已提交的事务。
-
目前数据库常用的就是 steal/no-force 策略,既允许在事务提交之前将脏数据写入磁盘,也允许在脏数据全部写入磁盘之前提交事务,当然这种策略需要 undo/redo 日志来进行故障恢复。不过,undo/redo 日志虽然能够带来较高的性能,但是也会增加数据库从故障中恢复的时间,所以并非在所有情况下都适用。