Skip to content

Lec 17 Logging

阅读参考书 §9.3

论文阅读: MapReduce

上个lec我们介绍了用影子复制实现原子性,但是它性能表现不太好,因为即便很小的改动都需要替换整个文件。本节课介绍用日志实现原子性,接下来我们看到,如何在不损失性能的情况下实现原子性。这种机制叫日志记录(logging)。

思考题

  • 日志基础(不考虑cell存储、缓存)
    • 每种类型的日志记录(例如UPDATE、COMMIT)包含了哪些内容?
      • 给定一系列事务,你应该能构建出相应的日志
    • 如何使用日志来读取某个变量的值
    • 日志的性能表现如何
  • 添加cell存储
    • 在添加了cell存储之后,读写操作如何进行?
    • cell存储实在磁盘上还是内存上的?为什么很重要
    • 为什么写入之前必须记录日志?
    • 系统崩溃后,如何修复cell存储
      • 给定一个日志 + cell 存储系统发生了崩溃,你应该能够在恢复后修复 cell 存储
    • 添加了cell存储之后,日志的性能表现如何?
  • 添加缓存
    • 在同时拥有 cell 存储和缓存的情况下,读写操作是如何进行的?
    • 缓存是在磁盘上(非易失性)还是在内存中(易失性)?为什么这很重要?
    • 当系统崩溃且存在缓存时,如何修复 cell 存储?
      • 给定一个日志 + cell 存储 + 缓存系统发生崩溃的情况,你应该能够在恢复后修复 cell 存储。
    • 添加缓存后,日志的性能表现如何?
    • 截断日志并写入检查点(checkpoint)的目的是什么?

引例

我们今天的目标是确保一系列事务(例如下面这样的事务序列)中的每一个事务都是原子的,同时确保我们的系统具有良好的性能。(请记住,影子副本的一个问题是:即使只做了很小的修改,它也会重写整个文件

截屏2024-07-02 10.23.07

假如执行到T3发生crash了, 如下图所示,我们需要一种方法将A的恢复到先前提交到值,即80。

截屏2024-07-02 10.27.22

每当开始一个事务时候,我们会分配一个事务ID,并且你会看到有2种不同状态的记录,update和commit 。我们可以看到,只写入发生改变的值。并且要注意到下面的记录都是通过追加操作进行的,这种变化会反映到磁盘上。这样为何能提供原子性呢?下面,我们先看如何利用日志来读取的。

当我们执行到的第二个事务的第一个行表达式时,即write(A, read(A)-20), 我们会从的倒叙开始遍历记录,直到到达起始点,找到了我们感兴趣的A的值。

image-20250323153813669

python
commits = []
def read(log, var):
	commits = []
  # scan backwards
	for record r in log[len[log]-1] .. log[0]:
    # keep track of commits
		if r.type == COMMIT:
			commits.add(r.tid)
    # find var's last committed value
		elif r.type = UPDATE and r.tid in commits and r.var == var:
			return r.new_value

为了更好的说明, 我们将代码临时调整如上图,T2应该能够读取到自己的写入,即使它尚未完全提交,上述代码显然就不适用了, 因此我们需要调整下。

python
commits = []
def read(log, var):
	commits = []
  # scan backwards
	for record r in log[len[log]-1] .. log[0]:
    # keep track of commmits
		if r.type == COMMIT:
			commits.add(r.tid)
    # find var's last committed value
		elif r.type = UPDATE and (r.tid in commits or r.tid == current_tid) and r.var == var:
			return r.new_value

截屏2024-07-02 13.19.52

这样一来,即便T3执行过程中出现crash,日志仍然是正确的,未提交的更新不会被读取,这就是“预写日志”的含义。一般来说,写入日志这个操作数据量很小,可以视为在一个扇区写入,因此这是安全的。当然,如果因为某些原因需要大于一个扇区,还有其他方法可供选择。其他方法涉及到在记录上放校验和,如果记录只提交了一半,会向你发出警告。

这里会有个性能问题,如果有百万条记录,而你要的在第一条。


解决方案:

我们会在上下文中,添加单元存储(cell storage),这不是一个内存缓存,而是存储在磁盘上的,如果机器崩溃并重启,无论你最后写入存储的内容是什么, 他都会保留。 如何用单元存储读取数据呢? 我们不会再去扫描日志,而是直接从单元存储中读取。则意味着不管我在哪里写入值,我都会确保单元存储保持最新状态。

截屏2024-07-02 13.38.05
python
def read(var):
  return cell_read(var)

def write(var, value):
  log.append(current_tid, "UPDATE", var, read(var), value)
  cell_write(var, value)

当更新内容写入日志时称为,日志更新;而更新内容写入日志时称为, 安装 。


当执行到T3发生crash时,会有个问题,单元存储的A并没有提交(因此在恢复后不应该被读取),我们需要修复单元存储。具体而言,我们需要撤销(undo)已经安装的更新,所做的就是扫描日志,确定哪些操作是未提交的,并将其撤销

截屏2024-07-02 13.43.17
python
def recover(log):
	commits = []
  for reocrd r in log[len(log)-1] ... log[0]:
    if r.type == COMMIT:
      commits.add(r.tid)
    if r.type == UPDATE and r.tid not in commits:
      cell_write(r.var, r.old_val) # undo

IMPORTANT

Always log the update before you install it

始终在安装更新前将其更新写入日志。

日志是权威的,单元存储支持提升性能的作用。

这个协议就是所谓的预写式日志(write-ahead logging)。

问题: 读性能现在已经很好了,但写入速度有所降低,恢复速度则大幅减慢


添加缓存

python
def read(var):
 	if var in cache:
  	return cache[var]
 	else:
 		# may evict(驱逐) others from cache to cell storage
 		cache[var] = cell_read(var)
 		return cache[var]

def write(var, value):
	log.append(current_tid, update, var, read(var), value)
	cache[var] = value
  
def flush(): # called "occasionally"
  cell_write(var, cache[var]) for each var

问题: 在系统崩溃时,是否可能会有一些应该在存储中的更新却没有写入?那么是否也有一些不应该在存储中的更改却已经写入?

假设T1提交后我们已经将缓存刷回了, 但是从那以后没有再刷回了,此时如果发生crash,会发生什么?

python
def recover(log):
  commits = []
  for record r in log[len(log)-1] .. log[0]:
    if r.type == COMMIT:
    	commits.add(r.tid)
    if r.type == UPDATE and r.tid not in commits:
    	cell_write(r.var, r.old_val) # undo

我们困难点在于修复单元存储,他会以两种方式出错,一种是A,B的值都是错误的。我们之前的做法是倒序向前扫描的,目的为了撤销任何未提交的更新,之所以能做到这个,是因为日志和单元存储是保持同步的。当我们现在的情况是,T2已经提交了,但是我们并没有更新单元存储。上面的恢复过程并没有将哪些提交重新纳入单元存储中。实际上我们要对日志进行2次便利,首先向后回溯以移除未提交的更新,然后向前推进提交已经确认的更新。除了crash外,可能因为代码问题需要中断(abort)事务,因此不管怎样都要去了解这个过程

python
def recover(log):
  commits = []
  for record r in log[len(log)-1] .. log[0]:
    if r.type == COMMIT:
    	commits.add(r.tid)
  if r.type == UPDATE and r.tid not in commits:
  	cell_write(r.var, r.old_val) # undo
  for record r in log[0] .. log[len(log)-1]:
  	if r.type == UPDATE and r.tid in commits:
  		cell_write(r.var, r.new_value) # redo

Checkpoint

我们的目标是为了能够高性能执行原子性,但是现在看来情况好像越来越糟糕了,想象一下日志越来越大,它就开始无限增长,此时要去恢复将不太可能了。一种可以缩短日志扫描的技术是将一些附加信息写入非易失性存储器。一个检查点信息来源两种:

  1. 来自单元存储的信息(关键数据的快照)
  2. 来自日志信息(称为checkpoint record,检查点记录)
  3. 两者兼有

假设日志系统在易失性内存中维护一个列表,记录已开始但尚未记录 END 记录。日志系统会定期将此列表记录为CHECKPOINT(检查点)记录。

引入CHECKPOINT记录后,恢复过程虽然更加复杂,但可能会减少时间和工作量,具体步骤如下:

  1. 从日志末尾进行 LIFO 扫描,回溯至最近的CHECKPOINT记录,收集失败者的标识符,并撤销(undo)它们已记录的所有操作。
  2. 根据检查点信息补充失败者列表。
  3. 继续 LIFO 扫描,撤销所有失败者的操作,直到找到每个失败者对应的BEGIN记录为止。
  4. 从该点向前扫描到日志末尾,对所有在失败者列表中且在日志中记录了状态为COMMITTED的全有全无操作,执行其已提交的更改(redo)。

在内存数据库(in-memory database)中,也使用检查点来确保持久性,而无需在每次系统崩溃后重新处理整个日志。

一种有用的内存数据库检查点方法是:

  1. 为整个数据库生成快照,将其写入两个交替使用的专用非易失性存储区域(以确保全有全无的原子性)。
  2. 记录一个包含最新快照地址的 CHECKPOINT 记录。

恢复过程包括以下步骤:

  1. 回溯到最近的 CHECKPOINT 记录,收集所有已提交的全有全无操作的列表。
  2. 恢复该 CHECKPOINT 记录所描述的快照。
  3. 从 CHECKPOINT 记录向前扫描到日志末尾,对已提交的操作执行重做(redo)。

这种场景中的主要挑战在于处理与快照生成并发的更新操作。可以通过以下方法解决该挑战:

  • 在快照生成期间阻止所有更新操作,或
  • 使用更复杂的先前或之后(before-or-after)原子性技术(本章后续部分将介绍这些技术)。

预写日志提供了原子性,相比影子复制具有更好的性能。因为其只需要为每次更新追加一点数据,而非每次都替换整个文件。单元存储时用来提高读性能,缓存和截断能用来提高写和Recovery性能。而这些提升性能的基数会给系统的Recovery处理更加复杂

我们的目标是为了能够高性能执行原子性,但是现在看来情况好像越来越糟糕了,想象一下日志越来越大,它就开始无限增长,此时要去恢复将不太可能了。

截断日志

我们的目标是为了能够高性能执行原子性,但是现在看来情况好像越来越糟糕了,想象一下日志越来越大,它就开始无限增长,此时要去恢复将不太可能了。

解决思路: 写checkpoints 然后截断日志。尽管日志具有权威性,但是单元存储是一种永久性的数据存储。具体做法是在我即将截断的时刻 ,将缓存中所有的更新数据刷回到单元存储中,编写一种特殊的记录,checkpoint record以某种方式写入到日志中,然后我们可以截断日志,在这个点上,日志和单元存储是一致的。要么舍去要么归档到其他地方。这些办法有个前提,就是操作的内容都是可以redo和undo,但是有些东西是不可以撤销的,比如现金交易、快递送达。

我们要开始意识到,我们因为拥有这个恢复过程使得机器在重新上线前能够清理事务。


原子性日志记录的基本思想是将日志存储的全有或全无原子性 与 单元存储 的高速性结合起来,通过应用程序对数据的每次更改进行两次记录。应用程序首先在日志存储中更改记录,然后在单元存储中安装(install)这些更改。这种分离允许进行特定的优化,从而使整个系统更快。

第一次记录(写入日志存储)针对快速写入进行了优化,采用的是一个所有变量的单一交错版本历史,称为日志(log)。每次数据更新的信息会形成一条记录(record),应用程序将其追加(append)到日志的末尾。如果日志介质是磁盘,并且磁盘仅用于日志记录,且磁盘存储管理系统按扇区连续分配,那么磁头只有在磁盘柱面(cylinder)写满时才需要移动,从而消除了大部分寻道(seek)延迟。

第二次记录(写入单元存储)针对快速读取进行了优化:应用程序通过覆盖(overwriting)单元存储中该变量的先前记录来执行安装操作。单元存储中的记录可以被视为一个缓存(cache),在读取时可以绕过从日志中定位最新版本的复杂过程。此外,由于不需要从日志中读取数据,日志磁盘的磁头可以保持在当前位置,随时准备进行下一次更新。

这两个步骤——LOG(记录日志)和INSTALL(安装数据)——构成了WRITE_NEW_VALUE接口的一种实现方式。图 9.17 进一步说明了这种两步实现。其核心思想是:日志是操作结果的权威记录,而单元存储仅仅是一个参考副本。如果单元存储中的数据丢失,可以通过日志进行重建。将副本安装到单元存储的目的是为了加速日志记录和数据读取的过程。

image-20250323151758387

如何处理Cache为非写直通的?

在日志记录和写直通缓存之间,需要对非易失性存储进行两次写入,这需要写入完成的延迟。由于引入的日志的最初原因是为了提高性能,因此这两个同步写入延迟通常会成为性能瓶颈。因此设计人员更愿意采用非写直通缓存,这样写入就可以延迟到方便的时间。

第一个关注点在于日志本身,在被安装到cell存储之前,Change日志要直写,但允许Cell存储在方便时进行安装。这意味,如果系统崩溃, 则无法保证任何特定的安装实际上已经迁移到非易失性存储。最坏的情况是,无法利用检查点,并且必须再次回到日志的开头进行安装。为了避免这点,通常的做法是将flush缓存作为每个checkpoint记录的一部分。可不幸的是,flush缓存和记录checkpoint可能存在并发,但是这个挑战是可以克服的,但复杂性会增加。

有些系统甚至更进一步追求性能。一个流行的技术,将日志写入易失性缓冲区,并且仅在提交All-or-Nothing操作时将整个缓冲区写入到非易失性存储器。这种方法允许系统将多个更改记录与事务结果记录OUTCOME 记录)合并为一个同步写操作,从而减少磁盘的随机写次数。这种方法表面上看违反了 WAL 协议,因为在系统崩溃时,缓冲区中的日志可能尚未写入非易失性存储,导致某些更改无法恢复。但可以通过对单元存储的缓存进行更精细的管理来解决这个问题。关键思路就是,按照顺序对每个日志记录进行编号,并使用其日志记录的序列号标记单元存储中的每个记录。当系统强制flush日志时,它都会告诉缓存管理器它写入的最后一个日志记录的序列号,并且缓存管理器会小心,永远不会协会任何带有更高日志序列号的缓存记录。

论文阅读: MapReduce