内存管理
内存管理
本章研究了数据库引擎的两个组成部分:日志管理器和缓冲区管理器。每个组件都负责某些文件:日志管理器负责日志文件,缓冲区管理器负责数据文件。
这两个组件都面临着如何有效地管理带有主存的磁盘块的读写的问题。数据库的内容通常比主内存大得多,因此这些组件可能需要在内存中穿梭块。本章研究了他们的内存需求和他们使用的内存管理算法。日志管理器只支持对日志文件的顺序访问,并具有一个简单的、最优的内存管理算法。另一方面,缓冲区管理器必须支持对用户文件的任意访问,这是一个更加困难的挑战。
数据库内存管理的两个原则
数据库引擎读取磁盘值的唯一方法是将包含它的块读入一个内存页面中,而写入磁盘值的唯一方法是将修改后的页面写回其块。数据库引擎在磁盘和内存之间移动数据时遵循两个重要的原则:最小化磁盘访问,并且不依赖于虚拟内存。
最小化磁盘访问
考虑一个应用程序,它从磁盘中读取数据,搜索数据,执行各种计算,进行一些更改,并将数据写回。你怎么估计这需要的时间呢?回想一下,RAM操作比flash快1000多倍,比磁盘快10万倍。这意味着,在大多数实际情况下,从磁盘上读取/写块所需的时间至少与在RAM中处理块所需的时间一样大。因此,数据库引擎可以做的一个最重要的一件事情是最小化块访问。
最小化块访问的一种方法是避免多次访问磁盘块。这种问题发生在计算的许多领域,并且有一个被称为缓存的标准解决方案。例如,CPU具有以前执行的指令的本地硬件缓存;如果下一条指令在缓存中,CPU不必从RAM加载它。另一个例子是,浏览器保存以前访问过的网页的缓存;如果用户请求缓存中的页面(例如,按浏览器的后退按钮),浏览器可以避免从网络中检索它。
数据库引擎使用内存页面来缓存磁盘块。通过跟踪哪些页面包含哪些块的内容,引擎可以通过使用现有页面来满足客户端请求,从而避免磁盘读取。类似地,引擎只在必要时将页面写入磁盘,希望可以通过一次磁盘写入对一个页面进行多次更改。
最小化磁盘访问的需要非常重要,以至于它贯穿于数据库引擎的整个实现过程中。例如,特别选择引擎所使用的检索算法是因为它们访问磁盘的方式很简单。当一个SQL查询有几种可能的检索策略时,计划者将选择它认为需要最少磁盘访问次数的策略。
原则2:不要依赖虚拟内存
现代操作系统支持虚拟内存。操作系统给每个进程一种错觉,即它有非常大的存储空间来存储其代码和数据。进程可以在其虚拟内存空间中任意分配对象;操作系统将每个虚拟页面映射到物理内存的实际页面。
操作系统支持的虚拟内存空间通常远远大于计算机的物理内存。由于不是所有的虚拟页面都适合于物理内存,所以操作系统必须将其中一些存储在磁盘上。当进程访问非内存中的虚拟页面时,会发生页面交换。操作系统选择一个物理页面,将该页面的内容写入磁盘(如果它已被修改),并将虚拟页面保存的内容从磁盘读取到该页面。
数据库引擎管理磁盘块的最直接的方法是为每个块提供自己的虚拟页面。例如,它可以为每个文件保留一个页面数组,为文件的每个块都有一个插槽。这些数组将会很大,但它们可以放在虚拟内存中。当数据库系统访问这些页面时,虚拟内存机制将根据需要在磁盘和物理内存之间交换它们。这是一个简单、易于实现的策略。不幸的是,它有一个严重的问题,即操作系统,而不是数据库引擎,控制页面何时被写入磁盘。出现两个问题。
第一个问题是,操作系统的页面交换策略可能会损害数据库引擎在系统崩溃后的恢复能力。原因是修改后的页面将有一些关联的日志记录,并且这些日志记录必须在页面之前写入磁盘。(否则,日志记录将无法用于在系统崩溃后帮助数据库恢复。)由于操作系统不知道日志,它可能会交换修改后的页面而不写入日志记录,从而破坏恢复机制。
第二个问题是,操作系统不知道当前正在使用的是哪些页面,以及数据库引擎不再关心哪些页面。该操作系统可以做出一个有根据的猜测,比如选择交换最近访问次数最少的页面。但是如果操作系统猜测不正确,它将交换一个再次需要的页面,导致两次不必要的磁盘访问。另一方面,数据库引擎对需要什么页面有了更好的了解,并且可以做出更智能的猜测。
因此,一个数据库引擎必须管理它自己的页面。它通过分配相对较少的它知道适合物理内存的页面来实现这一点;这些页面被称为数据库的缓冲池。该引擎会跟踪哪些页面可用于交换。当需要将块读入页面时,数据库引擎(而不是操作系统)从缓冲池中选择一个可用的页面,必要时将其内容(及其日志记录)写入磁盘,然后只读取指定的块。
管理日志信息
每当用户更改数据库时,数据库引擎都必须跟踪该更改,以防需要撤消它。描述更改的值保存在日志记录中,并且日志记录存储在日志文件中。新的日志记录将被附加到日志的末尾。
日志管理器是负责将日志记录写入日志文件的数据库引擎组件。日志管理器不了解日志记录的内容,相反,日志管理器只是将日志视为一个不断增加的日志记录序列。
本节研究日志管理器在将日志记录写入日志文件时如何管理内存。如下所示算法,这是将记录添加到日志中的最直接的方法。
- 1)在内存中分配一个页面。
- 2)将日志文件的最后一个块读入该页面。
- 3)a)如果有空间,请将日志记录放在页面上的其他记录之后,并将该页面写回磁盘。或者:b)如果没有空间,则分配一个新的空页面,将日志记录放在该页面中,并将该页面附加到日志文件末尾的一个新块中。
此算法需要对每个附加的日志记录进行磁盘读取和磁盘写入。它很简单,但效率很低。下图说明了在算法步骤3a的日志管理器的操作。日志文件包含三个块,其中包含8个记录,标记为r1到r8。日志记录可以有不同的大小,这就是为什么4条记录适合块0,但只有3条适合块1。块2还没有满,只包含一条记录(r8)。内存页面包含方块2的内容。除了记录r8之外,在页面中还有一个新的日志记录(记录r9)。
现在假设日志管理器通过将日志内存页面写回日志文件的块2来完成算法。当日志管理器最终被要求向文件中添加另一个日志记录时,它将执行算法的步骤1和步骤2,并将方块2读取到一个页面中。但是请注意,这个磁盘读取是完全不必要的,因为现有的日志页面已经包含了块2的内容!因此,该算法的步骤1和步骤2是不必要的。日志管理器只需要永久地分配一个页面来包含最后一个日志块的内容。因此,所有的磁盘读取都被消除了。
还可以减少磁盘写操作。在上述算法中,每次将新记录添加到页面时,日志管理器都会将其页面写入磁盘。再次回顾上图,可以看到不需要立即将记录r9立即写入磁盘。只要页面有空间,就可以简单地添加到页面中。当页面变满时,日志管理器可以将页面写入磁盘,清除其内容,并重新启动。这种新算法将导致每个日志块只写一个磁盘,这显然是最优的。
此算法有一个故障:由于日志管理器无法控制的情况,日志页可能需要在磁盘满之前写入磁盘。问题是,缓冲区管理器无法将修改后的数据页写入磁盘,直到该页的关联日志记录也已写入磁盘。如果其中一个日志记录恰好在日志页面中,但尚未在磁盘上,则日志管理器必须将其页面写入磁盘,而无论该页面是否已满。
如下所示,给出了生成的日志管理算法。该算法有两个位置,可以将内存页面写入磁盘:当日志记录需要强制写入磁盘时和当页面已满时。因此,一个内存页面可能会多次被写入同一个日志块。但是由于这些磁盘写入是绝对必要的,不能避免,您可以得出结论,算法是最优的。
- 永久分配一个内存页面,以保存日志文件的最后一个块的内容。调用此页面P。
- 当提交了一个新的日志记录时:
- a)如果P中没有空间,则:将P写入磁盘并清除其内容。
- b)将新的日志记录附加到P值中。
- 当数据库系统请求将特定的日志记录写入磁盘时:
- a)确定该日志记录是否在P中。
- b)如果是,则将P写入磁盘。
SimpleDB日志管理器
日志管理器的API
SimpleDB日志管理器实现在包simpledb.log中。这个包公开了类LogMgr,它的API如下代码所示:
LogMgr:
public LogMgr(FileMgr fm, String logfile);
public int append(byte[] rec);
public void flush(int lsn);
public Iterator<byte[]> iterator();
数据库引擎有一个LogMgr对象,它是在系统启动时创建的。构造函数的参数是对文件管理器和日志文件的名称的引用。
附加的方法会将一条记录添加到日志中,并返回一个整数。就日志管理器而言,日志记录是一个任意大小的字节数组;它将该数组保存在日志文件中,但不知道其内容表示什么。唯一的约束条件是,该数组必须适合放在页面内。附加项的返回值标识新的日志记录;此标识符称为其日志序列号(或LSN,log sequence number)。
将记录附加到日志中并不能保证记录将被写入磁盘;相反,日志管理器选择何时将日志记录写入磁盘,如上述算法所示。客户端可以通过调用刷新该方法来将特定的日志记录强制存储到磁盘上。要刷新的参数是日志记录的LSN;该方法可确保将此日志记录(以及此以前的所有日志记录)写入磁盘。
客户端调用迭代器(iterator)方法来读取日志中的记录;此方法为日志记录返回一个Java迭代器。对迭代器的next方法的每次调用都将返回一个字节数组,表示日志中的下一个记录。迭代器(iterator)方法返回的记录按相反的顺序排列,从最近的记录开始,然后在日志文件中向后移动。这些记录按此顺序返回,因为这是恢复管理器希望查看它们的方式。
如下代码块中的LogTest类提供了一个如何使用日志管理器API的示例。该代码创建了70条日志记录,每个日志记录由一个字符串和一个整数组成。整数是记录编号N,字符串是值“recordN”。代码在创建前35个之后打印一次记录,然后在创建所有70个之后再打印一次。
import simpledb.file.Page;
import simpledb.log.LogMgr;
import simpledb.server.SimpleDB;
import java.util.Iterator;
public class LogTest {
private static LogMgr lm;
public static void main(String[] args) {
SimpleDB db = new SimpleDB("logtest", 400, 8);
lm = db.logMgr();
createRecords(1, 35);
printLogRecords("The log file now has these records:");
createRecords(36, 70);
lm.flush(65);
printLogRecords("The log file now has these records:");
}
private static void printLogRecords(String msg) {
System.out.println(msg);
Iterator<byte[]> iter = lm.iterator();
while (iter.hasNext()) {
byte[] rec = iter.next();
Page p = new Page(rec);
String s = p.getString(0);
int npos = Page.maxLength(s.length());
int val = p.getInt(npos);
System.out.println("[" + s + ", " + val + "]");
}
System.out.println();
}
private static void createRecords(int start, int end) {
System.out.print("Creating records: ");
for (int i=start; i<=end; i++) {
byte[] rec = createLogRecord("record"+i, i+100);
int lsn = lm.append(rec);
System.out.print(lsn + " ");
}
System.out.println();
}
private static byte[] createLogRecord(String s, int n) {
int npos = Page.maxLength(s.length());
byte[] b = new byte[npos + Integer.BYTES];
Page p = new Page(b);
p.setString(0, s);
p.setInt(npos, n);
return b;
}
}
如果您运行该代码,您将发现,在第一次调用打印日志记录方法(printLogRecords)后,只打印了20条记录。原因是这些记录填充了第一个日志块,并在创建第21个日志记录时被刷新到磁盘。其他15条日志记录保留在内存日志页面中,没有刷新。第二次调用来创建记录(createRecords)方法,即创建记录36到70。对刷新的调用告诉日志管理器以确保记录65在磁盘上。但是由于记录66-70与记录65在同一个页面上,所以它们也被写入磁盘。因此,在第二次调用打印日志记录(printLogRecords)方法时,将以相反的顺序打印所有70条记录。
请注意createLogRecord方法创建日志记录,并分配一个字节数组作为日志记录。它创建了一个页面对象来包装该数组,这样它就可以使用页面的setInt和setString方法来将字符串和整数放置在日志记录中的适当偏移量上。然后,代码返回字节数组。类似地,方法打印日志记录会创建一个Page对象来包装日志记录,以便它可以从记录中提取字符串和整数。
实现日志管理器
LogMgr的代码如下方代码块所示。它的构造函数使用所提供的字符串作为日志文件的名称。如果日志文件为空,则构造函数将向其附加一个新的空块。构造函数还分配一个页面(称为日志页),并将其初始化以包含文件中最后一个日志块的内容。
public class LogMgr {
private FileMgr fm;
private String logfile;
private Page logpage;
private BlockId currentblk;
private int latestLSN = 0;
private int lastSavedLSN = 0;
public LogMgr(FileMgr fm, String logfile) {
this.fm = fm;
this.logfile = logfile;
byte[] b = new byte[fm.blockSize()];
logpage = new Page(b);
int logsize = fm.length(logfile);
if (logsize == 0)
currentblk = appendNewBlock();
else {
currentblk = new BlockId(logfile, logsize-1);
fm.read(currentblk, logpage);
} }
public void flush(int lsn) {
if (lsn >= lastSavedLSN)
flush();
}
public Iterator<byte[]> iterator() {
flush();
return new LogIterator(fm, currentblk);
}
public synchronized int append(byte[] logrec) {
int boundary = logpage.getInt(0);
int recsize = logrec.length;
int bytesneeded = recsize + Integer.BYTES;
if (boundary - bytesneeded < Integer.BYTES) { // It doesn't fit
flush(); // so move to the next block.
currentblk = appendNewBlock();
boundary = logpage.getInt(0);
}
int recpos = boundary - bytesneeded;
logpage.setBytes(recpos, logrec);
logpage.setInt(0, recpos); // the new boundary
latestLSN += 1;
return latestLSN;
}
private BlockId appendNewBlock() {
BlockId blk = fm.append(logfile);
logpage.setInt(0, fm.blockSize());
fm.write(blk, logpage);
return blk;
}
private void flush() {
fm.write(currentblk, logpage);
lastSavedLSN = latestLSN;
}
}
一个日志序列号(或LSN)标识了一个日志记录。调用追加(append)方法时,使用变量latestLSN,从1开始,按顺序分配LSN。日志管理器会跟踪下一个可用的LSN和写入磁盘的最近日志记录的LSN。方法刷新将最近的LSN与指定的LSN进行比较。如果指定的LSN较小,则所需的日志记录必须已写入磁盘;否则,日志页将写入磁盘,并且最新的LSN将成为最新的LSN。
追加(append)方法计算日志记录的大小,以确定它是否适合当前页面。如果没有,它会将当前页面写入磁盘,并调用附件新块来清除该页面,并将现在为空的页面附加到日志文件中。日志管理器通过向日志文件上添加一个空页面来扩展日志文件,而不是通过添加一个完整的页面来扩展日志文件。这个策略实现起来更简单,因为它允许刷新假设块已经在磁盘上。
追加(append)会将日志记录从右到左放置在页面中。变量边界包含最近添加的记录的偏移量。该策略使日志迭代器能够通过从左到右读取来以反向顺序读取记录。边界值被写入页面的前四个字节,以便迭代器将知道记录从哪里开始。
迭代器(iterator)方法会刷新日志(以确保整个日志都在磁盘上),然后返回一个日志迭代器对象。日志迭代器(LogIterator)类是实现迭代器的一个包私有类;它的代码显示在下方代码块中。日志迭代器对象分配一个页面来保存日志块的内容。构造函数将迭代器定位在日志的最后一个块中的第一个记录上(请记住,即写入最后一个日志记录的位置)。该方法接下来移到页面中的下一个记录;当没有更多记录时,它将前一个块读到页面中并返回第一个记录。当页面中没有更多的记录,也没有更多以前的块时,hasNext方法将返回false。
public class LogIterator implements Iterator<byte[]> {
private FileMgr fm;
private BlockId blk;
private Page p;
private int currentpos;
private int boundary;
public LogIterator(FileMgr fm, BlockId blk) {
this.fm = fm;
this.blk = blk;
byte[] b = new byte[fm.blockSize()];
p = new Page(b);
moveToBlock(blk);
}
public boolean hasNext() {
return currentpos<fm.blockSize() || blk.number()>0;
}
public byte[] next() {
if (currentpos == fm.blockSize()) {
blk = new BlockId(blk.fileName(), blk.number()-1);
moveToBlock(blk);
}
byte[] rec = p.getBytes(currentpos);
currentpos += Integer.BYTES + rec.length;
return rec;
}
private void moveToBlock(BlockId blk) {
fm.read(blk, p);
boundary = p.getInt(0);
currentpos = boundary;
}
}
管理用户数据
日志记录的使用方式是有限的、易于理解的。因此,日志管理器可以微调其对内存的使用;特别是,它能够通过单个专用页面最优地执行其作业。类似地,每个日志迭代器对象只需要一个页面。
另一方面,JDBC应用程序可以完全不可预测地访问它们的数据。没有办法知道应用程序下一个将请求哪个块,以及它是否会再次访问前一个块。即使在一个应用程序完全完成了它的块之后,您也不知道另一个应用程序是否会在不久的将来访问这些相同的块。本节介绍的是,在这种情况下,数据库引擎如何有效地管理内存。
缓冲区管理器
缓冲区管理器是数据库引擎的组件,负责保存用户数据的页面。缓冲区管理器分配一组固定的页面,称为缓冲区池。缓冲池应该适合计算机的物理内存,并且这些页面应该来自操作系统持有的I/O缓冲区。
为了访问一个块,一个客户端根据如下协议与缓冲区管理器进行交互。
- 客户端要求缓冲区管理器将一个页面从缓冲区池固定到该块中。
- 客户端可以随心所欲地访问页面的内容。
- 当客户端完成该页面时,它会告诉缓冲区管理器取消对它的占用。
如果客户端当前正在使用页面,则该页面被固定;否则,页面被取消占用。只要页面被固定,缓冲区管理器有义务保持其客户端可用的页面。相反,一旦一个页面被取消固定,就允许缓冲区管理器将其分配给另一个块。
当客户端要求缓冲管理器将页面固定到块时,缓冲管理器将遇到以下四种可能性之一:
- 该块的内容位于缓冲区中的某个页面中,并且:
- 页面被固定。
- 页面未被固定。
- 该块的内容当前不在任何缓冲区中,并且:
- 缓冲区池中至少存在一个未被固定的页面。
- 缓冲区池中的所有页面都被固定住了。
第一种情况发生在一个或多个客户端当前正在访问该块的内容时。由于一个页面可以被多个客户端固定,因此缓冲区管理器只需向该页面添加另一个固定点,并将该页面返回给该客户端。每个固定页面的客户端都可以自由地同时读取和修改其值。缓冲区管理器不关心可能发生的潜在冲突;该责任属于并发管理器。
第二种情况发生在使用缓冲区的(多个)客户端已经完成了缓冲区,但缓冲区尚未重新分配。由于块的内容仍然在缓冲区页面中,因此缓冲区管理器可以通过简单地固定该页面并将其返回给客户端来重用该页面。
第三种情况要求缓冲区管理器将块从磁盘读取到缓冲区页面中。涉及几个步骤。缓冲区管理器必须首先选择要重用的未固定页面(因为固定页面仍在被客户端使用)。其次,如果选定的页面已被修改,则缓冲区管理器必须将页面内容写入磁盘;此操作称为刷新页面。最后,可以将所请求的块读入选定的页面中,并可以固定该页面。
第四种情况发生在缓冲区被大量使用时,例如查询处理算法中,在这种情况下,缓冲区管理器不能满足客户端请求。最好的解决方案是让缓冲区管理器将客户端放入一个等待列表中,直到一个未固定的缓冲区页面可用。
缓冲区(Buffers)
缓冲区池中的每个页面都有关联的状态信息,例如它是否被固定,如果是,则它被分配给哪个块。缓冲区是包含此信息的对象。缓冲区池中的每个页面都有一个关联的缓冲区。每个缓冲区都会观察对其页面的更改,并负责将其修改后的页面写入磁盘。与日志一样,如果缓冲区可以延迟写入页面,则可以减少磁盘访问。例如,如果页面被修改了多次,那么在所有修改之后,写一次页面会更有效。一个合理的策略是让缓冲区推迟将其页面写入磁盘,直到页面被解除固定。
实际上,缓冲区等待的时间甚至更长。假设修改后的页面未固定,但没有写入磁盘。如果页面再次被固定到同一个块上(如上面的第二种情况),客户端将会看到修改后的内容。这样做的效果与页面已写入磁盘然后回读相同,但没有磁盘访问。在某种意义上说,缓冲区的页面充当其磁盘块的内存版本。任何希望使用该块的客户端都将被直接定向到缓冲区页面,客户端可以读取或修改该页面,而不引起任何磁盘访问。
事实上,只有两个原因一个缓冲区需要写一个修改后的页面磁盘:页面被替换,因为缓冲区被固定到一个不同的块(在上面的第三种情况下)或恢复管理器需要将其内容写入磁盘,以防止可能的系统崩溃。
缓冲区替换策略
缓冲区池中的页面开始未分配。当pin请求到达时,缓冲区管理器通过将请求的块分配给未分配的页面来启动缓冲区池。一旦分配了所有页面,缓冲区管理器将开始替换页面。缓冲区管理器可以选择缓冲区池中的任何未固定的页面进行替换。
如果缓冲区管理器需要替换一个页面,并且所有缓冲区页面都被固定,那么请求的客户端必须等待。因此,每个客户都有责任“成为一个好公民”,并在不再需要一个页面时立即打开它。
当取消固定多个缓冲区页时,缓冲区管理器必须决定替换哪一个缓冲页。这种选择可能会对数据库系统的效率产生显著的影响。例如,最糟糕的选择是替换下一个将要访问的页面,因为缓冲区管理器随后必须立即替换另一个页面。事实证明,最好的选择是总是替换那些将被很长时间未使用的页面。
由于缓冲区管理器无法预测下一步将访问哪些页面,因此它被迫进行猜测。在这里,缓冲区管理器在虚拟内存中交换页面时几乎处于与操作系统完全相同的情况。然而,有一个很大的区别:与操作系统不同,缓冲区管理器知道一个页面当前是否正在使用中,因为正在使用中的页面正是被固定的页面。无法替换固定页面的负担原来是一种祝福。客户端通过负责任地固定页面,防止缓冲区管理器做出真正糟糕的猜测。缓冲区替换策略只需要从当前不需要的页面中进行选择,这就远没有那么重要了。
给定一组未固定的页面,缓冲区管理器需要决定哪些页面在最长时间内不需要。例如,一个数据库通常有多个页面(如Chap的目录文件。7)在数据库的整个生命周期中不断使用的。缓冲区管理器应该避免替换这样的页面,因为它们几乎肯定很快就会被重新固定。有几种替代策略试图做出最好的猜测。本节将考虑其中的四个问题:Naïve、FIFO、LRU和Clock。
下图介绍了一个示例,它将允许我们比较这些替换算法的行为。(a)部分给出了一系列操作,固定和取消五个块的文件,(b)部分描述了缓冲池的结果状态,假设它包含四个缓冲区。唯一的页面替换发生在第五块(即块50)被固定时。但是,由于当时只有一个缓冲区被打开,因此缓冲区管理器没有选择。换句话说,无论页面替换策略如何,缓冲池都会像图b。
其中图b中的每个缓冲区都包含三个信息:它的块号、它被读入缓冲区的时间,以及它被解除固定的时间。图中的时间对应于图a中的操作位置。图b中的缓冲区都是未固定的。假设现在缓冲区管理器接收到另外两个pin请求:“pin(60); pin(70);”。
缓冲区管理器将需要替换两个缓冲区。所有的缓冲区都可用;它应该选择哪些?下面的每个替换算法都将给出不同的答案。
Naïve策略
最简单的替换策略是依次遍历缓冲区池,替换找到的第一个未固定的缓冲区。使用上图的示例,块60将被分配给缓冲区0,而块70将被分配给缓冲区1。
这个策略很容易实现,但没有什么可推荐的。例如,再次考虑上图中的缓冲区,并假设客户端重复固定和打开固定块60和70,例如:“pin(60); unpin(60); pin(70); unpin(70); pin(60); unpin(60);…”。
Naïve策略将对两个块使用缓冲区0,这意味着每次块被固定时都需要从磁盘读取块。问题是缓冲池没有得到均匀的利用。如果替换策略为块60和70选择了两个不同的缓冲区,那么每个块将分别从磁盘上读取一次——这是一个效率的巨大提高。
FIFO策略
Naïve策略仅基于方便性而选择缓冲区。FIFO策略试图更智能,通过选择最近最少被替换的缓冲区,即在缓冲区池中放置时间最长的页面。这种策略通常比Naïve策略更好,因为旧的页面不太可能需要最近获取的页面。在上图中,最老的页面是“读入时间”值最小的页面。因此,块60将被分配给缓冲区0,而块70将被分配给缓冲区2。
FIFO是一个合理的策略,但它并不总是做出正确的选择。例如,数据库经常使用页面,如目录页。 由于几乎每个客户端都使用这些页面,所以如果可能的话,不替换它们是有意义的。然而,这些页面最终将成为池中最古老的页面,而FIFO策略将选择它们进行替换。
FIFO的替代策略可以通过两种方式来实现。一种方法是让每个缓冲区保存最后一次替换页面的时间,如上图图b所示。然后,替换算法将扫描缓冲池,选择具有最早替换时间的未固定页面。第二种更有效的方法是,缓冲区管理器可以保存指向其缓冲区的指针列表,并按替换时间排序。替换算法搜索列表;找到的第一个未固定的页面被替换,指向它的指针被移动到列表的末尾。
LRU策略
FIFO策略根据页面添加到缓冲池时做出替换决定。类似的策略是根据最后一次访问页面的时间做出决定,其理由是在不久以前没有使用过的页面在不久的将来也不会被使用。这种策略叫做LRU,它是最近使用得最少的意思。在上图示例中,“取消固定的时间”值对应于上次使用缓冲区的时间。因此,块60将被分配给缓冲区3,而块70将被分配给缓冲器0。
LRU策略往往是一种有效的通用策略,并且避免了替换常用的页面。FIFO的这两个实现选项都可以适用于LRU。必须做的唯一更改是,每次页面被取消固定时,缓冲区管理器必须更新时间戳(更新第一个选项)或更新列表(更新第二个选项)。
Clock策略
该策略是上述策略的有趣组合,具有简单和直接的实现。与朴素策略一样,时钟替换算法扫描缓冲池,选择它找到的第一个未固定的页面。不同之处在于,该算法总是在上一次替换后在页面上开始扫描。如果您将缓冲池可视化为形成一个圆,那么替换算法就会像模拟时钟的手一样扫描池,在替换页面时停止,在需要另一个替换时开始。
上图图b的示例并没有表示时钟的位置。但它最后一次做的替换是缓冲区1,这意味着时钟在那之后立即被定位。因此,块60将被分配给缓冲器2,而块70将被分配给缓冲器3。
时钟策略试图尽可能均匀地使用缓冲区。如果一个页面被固定,时钟策略将跳过它,在检查完它在池中的所有其他缓冲区之前不会再次考虑它。这个特性给了这个策略一种LRU的味道。其想法是,如果一个页面经常被使用,那么当它被替换时到达时,它很有可能会被固定住。如果是这样,那么它就会被跳过,并给它“另一个机会”。
SimpleDB缓冲区管理器
缓冲区管理器的API
SimpleDB缓冲区管理器是在包simple.buffer实现的。缓冲区这个包实现了BufferMgr和Buffer两个类;它们的API如下方代码块所示:
BufferMgr
public BufferMgr(FileMgr fm, LogMgr lm, int numbuffs);
public Buffer pin(BlockId blk);
public void unpin(Buffer buff);
public int available();
public void flushAll(int txnum);
Buffer
public Buffer(FileMgr fm, LogMgr lm);
public Page contents();
public BlockId block();
public boolean isPinned();
public void setModified(int txnum, int lsn);
public int modifyingTx();
每个数据库系统都有一个BufferMgr对象,该对象将在系统启动期间创建。它的构造函数有三个参数:缓冲区池的大小和对文件管理器和日志管理器的引用。
BufferMgr对象具有固定和取消页面的方法。方法pin返回固定到包含指定块的页面的缓冲区对象,unpin方法将取消页面。可用的方法返回未固定的缓冲区页的数量。并且flushAll方法确保由指定事务修改的所有页面都已写入磁盘。
给定一个缓冲区(Buffer)对象,客户端可以调用其内容(contents)方法来获取关联的页面。如果客户端修改了页面,那么它还负责生成适当的日志记录,并调用缓冲区的设置为已修改(setModifified)方法。该方法有两个参数:一个标识已修改事务的整数和所生成的日志记录的LSN。
下方代码块中代码测试了缓冲区类。它在第一次执行时打印“新值为1”,每次执行都会增加打印的值。该代码的行为如下。它还创建了一个具有三个缓冲区的SimpleDB对象。它将页面固定到块1,在偏移量80处增加整数,并调用设置已修改以指示页面已被修改。设置已修改的参数应该是生成的日志文件的事务号和LSN。这两个值背后的细节将在第12章中进行讨论。所以在那之前,给定的参数都是合理的占位符。
import simpledb.buffer.Buffer;
import simpledb.buffer.BufferMgr;
import simpledb.file.BlockId;
import simpledb.file.Page;
import simpledb.server.SimpleDB;
public class BufferTest {
public static void main(String[] args) {
SimpleDB db = new SimpleDB("buffertest", 400, 3);
BufferMgr bm = db.bufferMgr();
Buffer buff1 = bm.pin(new BlockId("testfile", 1));
Page p = buff1.contents();
int n = p.getInt(80);
p.setInt(80, n+1); // This modification will
buff1.setModified(1, 0); // get written to disk.
System.out.println("The new value is " + (n+1));
bm.unpin(buff1);
// One of these pins will flush buff1 to disk:
Buffer buff2 = bm.pin(new BlockId("testfile", 2));
Buffer buff3 = bm.pin(new BlockId("testfile", 3));
Buffer buff4 = bm.pin(new BlockId("testfile", 4));
bm.unpin(buff2);
buff2 = bm.pin(new BlockId("testfile", 1));
Page p2 = buff2.contents();
p2.setInt(80, 9999); // This modification
buff2.setModified(1, 0); // won't get written to disk.
bm.unpin(buff2);
}
}
缓冲区管理器会从其客户端隐藏实际的磁盘访问。客户端不知道代表它发生了多少磁盘访问以及何时发生。磁盘读取只能在调用pin时发生,特别是在指定的块当前不在缓冲区中时。磁盘写入只能在调用引脚或刷新时发生。如果被替换的页面已被修改,则对pin的调用将导致磁盘写入,而对flushAll方法的调用将导致对被指定事务修改的每个页面进行磁盘写入。
例如,上方代码块的代码包含了对块1的两个修改。这些修改都没有显式地写入磁盘。执行代码表明第一次修改已写入磁盘,但第二次修改没有写入。考虑第一个修改。由于缓冲池中只有三个缓冲区,因此缓冲区管理器将需要替换块1的页面(从而将其写入磁盘),以便锁定块2、3和4的页面。另一方面,在第二次修改后,不需要更换,页面不写入磁盘,修改丢失。
假设数据库引擎有很多客户端,这些客户端都使用大量缓冲区。可以固定每个缓冲区页面。在这种情况下,缓冲区管理器不能立即满足pin请求。相反,它将客户机放在一个等待列表中。当缓冲区可用时,缓冲区管理器将客户端从等待列表中删除,以便它能够完成pin请求。换句话说,客户端将不会意识到缓冲区争用;客户端只会注意到引擎似乎已经变慢了。
有一种情况是,缓冲区争用可能会导致严重的问题。考虑客户端A和B各自需要两个缓冲区,但只有两个缓冲区可用。假设客户端A固定住第一个缓冲区。现在有了一场对第二个缓冲区的竞争。如果客户端A在客户端B之前得到它,那么B将被添加到等待列表中。客户端A最终将完成并取消固定缓冲区,此时客户端B可以固定它们。这是一个很好的场景。现在假设客户端B在客户端A之前得到第二个缓冲区,那么A和B都将在等待列表上。如果这是系统中仅有的两个客户端,那么没有一个缓冲区将被解除固定,而A和B都将永远在等待列表中。这是一个很糟糕的情况。据说客户A和客户B陷入了僵局。
在具有数千个缓冲区和数百个客户端的真实数据库系统中,这种死锁是极不可能的。然而,缓冲区管理器必须准备好处理这种可能性。SimpleDB所采用的解决方案是跟踪客户机等待缓冲区的时间。如果等待的时间太长(例如10秒),那么缓冲区管理器假定客户端处于死锁状态,并抛出一个缓冲区异常类型(BufferAbortException)的异常。客户端负责处理异常,通常是通过回滚事务并可能重新启动它。
下方代码块中的代码测试了缓冲器管理器。它再次创建一个只有三个缓冲区的SimpleDB对象,然后调用缓冲区管理器将它们的页面固定到文件“测试文件”的块0、1和2中。然后它打开插入块1,重新插入块2,并再次插入块1。这三个操作将不会导致任何磁盘读取,也将不会留下任何可用的缓冲区。尝试固定块3将把线程放入一个等待列表中。但是,由于线程已经保存了所有的缓冲区,因此没有一个缓冲区将被解固定,缓冲区管理器将在等待十秒后抛出异常。程序捕获异常并继续。它打开了方块2。它试图锁定块3的尝试现在将成功,因为缓冲区已经可用。
import simpledb.buffer.Buffer;
import simpledb.buffer.BufferAbortException;
import simpledb.buffer.BufferMgr;
import simpledb.file.BlockId;
import simpledb.server.SimpleDB;
public class BufferMgrTest {
public static void main(String[] args) {
SimpleDB db = new SimpleDB("buffermgrtest", 400, 3);
BufferMgr bm = db.bufferMgr();
Buffer[] buff = new Buffer[6];
buff[0] = bm.pin(new BlockId("testfile", 0));
buff[1] = bm.pin(new BlockId("testfile", 1));
buff[2] = bm.pin(new BlockId("testfile", 2));
bm.unpin(buff[1]); buff[1] = null;
buff[3] = bm.pin(new BlockId("testfile", 0));
buff[4] = bm.pin(new BlockId("testfile", 1));
System.out.println("Available buffers: " + bm.available());
try {
System.out.println("Attempting to pin block 3...");
buff[5] = bm.pin(new BlockId("testfile", 3));
}
catch(BufferAbortException e) {
System.out.println("Exception: No available buffers\n");
}
bm.unpin(buff[2]); buff[2] = null;
buff[5] = bm.pin(new BlockId("testfile", 3)); // now this works
System.out.println("Final Buffer Allocation:");
for (int i=0; i<buff.length; i++) {
Buffer b = buff[i];
if (b != null)
System.out.println("buff["+i+"] pinned to block "
+ b.block());
}
}
}
实现缓冲区管理器
下方代码块包含了缓冲区类的代码。缓冲区对象会跟踪有关其页面的四种信息:
- 对分配给其页面的块的引用。如果没有分配任何块,则该值为空。
- 页面被固定的次数。针数在每个针上增加,在每个未针上减少。
- 一个指示该页面是否已被修改的整数。值1表示页面未更改;否则,整数标识进行更改的事务。
- 日志信息。如果页面已被修改,则缓冲区将保存最近的日志记录的LSN。LSN值从不为负值。如果客户端调用用负的LSN修改,则表示未为该更新生成日志记录。
public class Buffer {
private FileMgr fm;
private LogMgr lm;
private Page contents;
private BlockId blk = null;
private int pins = 0;
private int txnum = -1;
private int lsn = -1;
public Buffer(FileMgr fm, LogMgr lm) {
this.fm = fm;
this.lm = lm;
contents = new Page(fm.blockSize());
}
public Page contents() {
return contents;
}
public BlockId block() {
return blk;
}
public void setModified(int txnum, int lsn) {
this.txnum = txnum;
if (lsn>=0) this.lsn = lsn;
}
public boolean isPinned() {
return pins > 0;
}
public int modifyingTx() {
return txnum;
}
void assignToBlock(BlockId b) {
flush();
blk = b;
fm.read(blk, contents);
pins = 0;
}
void flush() {
if (txnum >= 0) {
lm.flush(lsn);
fm.write(blk, contents);
txnum = -1;
} }
void pin() {
pins++;
}
void unpin() {
pins--;
}
}
方法刷新确保缓冲区分配的磁盘块与其页面具有相同的值。如果页面没有被修改,那么该方法不需要做任何操作。如果已修改,则该方法首先调用LogMgr.flush以确保相应的日志记录在磁盘上,然后将页面写入磁盘。
assignToBlock方法会将缓冲区与磁盘块关联起来。首先刷新缓冲区,以便保留对前一个块的任何修改。然后将缓冲区与指定的块关联,从磁盘中读取其内容。
BufferMgr的代码如下方代码块所示。该方法引脚为指定的块分配了一个缓冲区。它通过调用私有方法tryToPin来实现这一点。这种方法有两部分。第一部分“fifindExistingBuffer(找到存在缓冲区)”,尝试找到已分配给指定块的缓冲区。如果找到,则返回缓冲区。否则,算法的第二部分- chooseUnpinnedBuffer(选择一个未固定的缓冲区),使用Naïve的替换策略来选择一个未固定的缓冲区。调用所选缓冲区的assignToBlock方法,该方法处理将现有页面写入磁盘(如果必要)以及从磁盘读取新页面。如果该方法找不到未固定的缓冲区,则返回null。
public class BufferMgr {
private Buffer[] bufferpool;
private int numAvailable;
private static final long MAX_TIME = 10000; // 10 seconds
public BufferMgr(FileMgr fm, LogMgr lm, int numbuffs) {
bufferpool = new Buffer[numbuffs];
numAvailable = numbuffs;
for (int i=0; i<numbuffs; i++)
bufferpool[i] = new Buffer(fm, lm);
}
public synchronized int available() {
return numAvailable;
}
public synchronized void flushAll(int txnum) {
for (Buffer buff : bufferpool)
if (buff.modifyingTx() == txnum)
buff.flush();
}
public synchronized void unpin(Buffer buff) {
buff.unpin();
if (!buff.isPinned()) {
numAvailable++;
notifyAll();
} }
public synchronized Buffer pin(BlockId blk) {
try {
long timestamp = System.currentTimeMillis();
Buffer buff = tryToPin(blk);
while (buff == null && !waitingTooLong(timestamp)) {
wait(MAX_TIME);
buff = tryToPin(blk);
}
if (buff == null)
throw new BufferAbortException();
return buff;
} catch (InterruptedException e) {
throw new BufferAbortException();
}
}
private boolean waitingTooLong(long starttime) {
return System.currentTimeMillis() - starttime > MAX_TIME;
}
private Buffer tryToPin(BlockId blk) {
Buffer buff = findExistingBuffer(blk);
if (buff == null) {
buff = chooseUnpinnedBuffer();
if (buff == null)
return null;
buff.assignToBlock(blk);
}
if (!buff.isPinned())
numAvailable--;
buff.pin();
return buff;
}
private Buffer findExistingBuffer(BlockId blk) {
for (Buffer buff : bufferpool) {
BlockId b = buff.block();
if (b != null && b.equals(blk))
return buff;
}
return null;
}
private Buffer chooseUnpinnedBuffer() {
for (Buffer buff : bufferpool)
if (!buff.isPinned())
return buff;
return null;
}
}
如果tryToPin返回null,pin方法将调用Java的wait方法等待。在Java中,每个对象都有一个等待列表。对象的wait方法会中断调用线程的执行,并将其放在该列表中。在上方代码块中,线程将保持在该列表中,直到出现以下两种情况之一:
- 另一个线程调用nitifyAll(通过调用unpin发生)。
- 达到MAX_TIME毫秒,这意味着线程等待太长了。
当一个等待的线程恢复时,它会继续进行循环,试图获得一个缓冲区(与所有其他正在等待的线程一起)。线程将不断被放回等待列表中,直到它得到缓冲区或超过其时间限制。
unpin方法取消固定指定的缓冲区,然后检查该缓冲区是否仍然被固定。如果不是,则通知调用All以从等待列表中删除所有客户端线程。这些线程将争夺缓冲区;无论先安排谁就会赢。当调度其他一个线程时,它可能会发现所有缓冲区仍然被分配;如果是这样,它将被放回等待列表中。
小结
- 数据库引擎必须努力尽量减少磁盘访问。因此,它会仔细地管理用于保存磁盘块的内存中的页面。管理这些页面的数据库组件是日志管理器和缓冲区管理器。
- 日志管理器负责在日志文件中保存日志记录。因为日志记录总是附加到日志文件中,而且不会修改,所以日志管理器可以非常高效。它只需要分配一个页面,并且有一个简单的算法来将该页面写入磁盘。
- 缓冲区管理器分配几个页面,称为缓冲区池,来处理用户数据。缓冲区管理器会根据客户端的要求,将缓冲区页面固定到磁盘块上。客户端在固定缓冲区页面后访问它,并在完成后打开缓冲区。
- 修改后的缓冲区将在两种情况下被写入磁盘:当页面被替换时和当恢复管理器需要它在磁盘上时。
- 当客户端要求将一个页面固定到一个块时,缓冲区管理器会选择适当的缓冲区。如果该块的页面已经在缓冲区中,则使用该缓冲区;否则,缓冲区管理器将替换现有缓冲区的内容。
- 确定要替换哪个缓冲区的算法称为缓冲区替换策略。四种有趣的替代策略是:
- Naïve:选择它找到的第一个未固定的缓冲区。
- FIFO:选择其内容最近很少被替换的未固定的缓冲区。
- LRU:选择最近内容未固定的未固定缓冲区。
- Clock:从上次替换的缓冲区中依次扫描缓冲区;选择找到的第一个未固定的缓冲区。
推荐阅读
埃弗尔斯伯格(Effelsberg)等人(1984)的文章包含了对缓冲管理的全面处理,扩展了本章中的许多思想。Gray和Reuter(1993)的文章包含了对缓冲区管理的深入讨论,说明了他们对基于c语言的典型缓冲区管理器实现的讨论。
Oracle的默认缓冲区替换策略是LRU。然而,在扫描大表时,它使用了FIFO替换。其基本原理是,表扫描在解锁后通常不需要一个块,因此LRU最终保存了错误的块。详情可阅读Ashdown等人(2019)的文章。
一些研究人员已经研究了如何使缓冲区管理器本身更加智能。其基本思想是,缓冲区管理器可以跟踪每个事务的pin请求。如果它检测到一个模式(例如,事务重复读取相同的N个文件块),它将尝试避免替换这些页面,即使它们没有被固定。Ng等人(1991)的文章更详细地描述了这一想法,并提供了一些仿真结果。
Ashdown, L., et al. (2019). Oracle database concepts. Document E96138-01, Oracle Corporation.
Effelsberg W , T Här de r. Principles of database buffer management[J]. Acm Transactions on Database Systems, 1984, 9(4):560-595.
Gray, Jim, Reuter, Andreas. Transaction Processing: Concepts and Techniques[J]. Default, 1992, 9(3):466-473.
Ng R , Faloutsos C , Sellis T . Flexible buffer allocation based on marginal gains[C]// ACM. ACM, 1991:387-396.