在上一节中我们实现了Page
类, 它是数据库操作内存所用的类。内存和硬盘之间的操作得遵从两大原则:
-
减少硬盘访问
-
不要依赖虚拟内存
第一点挺好理解, 因为都知道, 内存与硬盘的速度是差一个量级, 内存比固态硬盘快1000陪, 比机械硬盘快100,000陪, 这意味着读写场景会被硬盘拖慢。其中一种方法去避免频繁访问硬盘是利用缓存, 例如可以把频繁读取的数据放入redis, 作为缓存使用。至于写的话, 则利用内存缓冲区, 尽可能延迟写入硬盘, 例如某个数据多次更改, 则把它放在缓冲区, 暂不写入硬盘, 或是利用缓冲区存下数据, 到一定量才一次写入。
第二点比较难理解, 考虑有些人不太懂什么叫虚拟内存, 我简单介绍下。先看一张图:
我们都知道所有编程语言都得编译, 最后运行是以汇编形式运行。这是利用objdump反汇编某可执行文件。有三列, 第一列是汇编指令在内存的位置, 第二列是指令十六进制形式, 第三列是汇编形式。现在问题是第一列所指的内存位置指的是什么?如果是直接是内存的位置, 怎么保证这段汇编在另一台电脑运行, 不会和另一程序有冲突?或开两个进程运行同一程序, 如果内存位置一样, 不就gg?所以肯定不是直接的内存位置 (或叫物理内存位置), 而是虚拟内存位置, 它的物理内存位置是经操作系统和CPU硬件配合转换得出。
利用虚拟内存的好处是通过中间层, 不用理会内存是怎么管理, 类似面向接口编程的思想, 而且可以做权限管理, 不会有用户把程序写入内存重要位置。还有是利用虚拟内存, 可以做到换页。即尽管从虚拟内存看起来还有好多内存空间, 但其实内存基本用完, 操作系统则把程序中不常用的部分存入硬盘, 到用的时候再取出, 或也有可能某部分不频繁使用, 也可能存入硬盘。这也是为什么有时电脑会好卡的原因, 因为内存容量不足, 操作系统得频繁去交换内存与硬盘的内容。
这也意味着, 数据库中的缓冲区有可能有一部分会被换页, 这就带来严重问题。首先, 操作系统不知道缓冲区中什么是重要的, 它只是靠推断来作决定, 意味着可能导致不必要的IO操作, 还有是这会影响数据库的恢复能力。例如, 涉及的区域存有日志的内容, 而它们必须写入硬盘, 而不是直接换入 在之后谈及事务的章节得知, 这可能导致数据库没法恢复。
这意味着数据库得有一部分内存空间是在物理内存上, 可以直接操作, 不让它有换页发生, 但这就超出java能力以及这里的范围内, 得要熟悉操作系统才行操作。
日志
用户在数据库中所做的任何变更操作, 都得写入日志, 以作回滚。这里要注意是, 日志是只管记录, 至于怎样解析它们, 是另外的类负责。最直接的日志记录操作如下:
-
在内存中找出可使用的页
-
读取日志文件中的最后的块
-
如果页还有空间, 把日志记录写入页中, 然后写入硬盘; 如果没有空间, 则另外开新的页, 把记录写入, 然后写入硬盘
但这非常没有效率, 每次操作都会有IO操作, 所以我们得优化, 尽可能避免IO操作。仔细观察下, 第一二步不用每次重复, 因为上次写入的位置和下次的位置, 只差一条日志记录, 大可以在内存中记录最新的文件块位置。另一方面, 也没必要每次有一条日志记录就直接写入硬盘, 可以一直写入内存页, 直到页满才一次写入硬盘。当然也有可能是修改日志, 那就得看修改项是否在内存页中, 是的话, 就修改内存页然后写入硬盘, 否则就直接写入硬盘。总结一下:
-
缓冲区得找一内存页来存放日志最新的块内容, 这里叫它P
-
当有新增日志记录, 看一下P页里有没有空间, 有就存在页里, 没有则写入硬盘, 清除页里内容
-
当有指定记录修改, 看它是否在P页里,是的话就修改写入硬盘, 否则就直接写入硬盘
Log Manager
现在开始实现一个简单的日志管理, 它是在数据库启动时创建。先看一下它的api:
public LogMgr(FileMgr fm, String logfile);
public int append(byte[] rec);
public void flush(int lsn);
public Iterator<byte[]> iterator()
这里可以看出LogMgr
是基于FileMgr
操作, 传字节追加日志, 如果要写入硬盘, 得要手动调用flush
方法才行。iterator则是读取日志里的记录。
先看看构造方法:
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);
// 什么都没有, 则创建, 否则读最近的, 注入logpage
if (logsize == 0) {
currentblk = appendNewBlock();
} else {
currentblk = new BlockId(logfile, logsize - 1);
fm.read(currentblk, logpage);
}
}
/**
* Initialize the bytebuffer and append it to the log file.
*/
private BlockId appendNewBlock() {
BlockId blk = fm.append(logfile);
logpage.setInt(0, fm.blockSize());
fm.write(blk, logpage);
return blk;
}
构造方法挺简单, 就是传FileMgr
和日志名, 看一下日志大小, 没有内容则创建新的块, 有的话取最新的。
/**
* Appends a log record to the log buffer.
* The record consists of an arbitrary array of bytes.
* Log records are written right to left in the buffer.
* The size of the record is written before the bytes.
* The beginning of the buffer contains the location
* of the last-written record (the "boundary").
* Storing the records backwards makes it easy to read
* them in reverse order.
* @param logrec a byte buffer containing the bytes.
* @return the LSN of the final value
*/
public synchronized int append(byte[] logrec) {
// 先看一下当前有多少空间
int boundary = logpage.getInt(0);
int recsize = logrec.length;
// 日志的长度 + 日志的长度值
int bytesneeded = recsize + Integer.BYTES;
// 假如空间不足, 少于Integer.BYTES是因为要考虑一开始的空间大小值
if (boundary - bytesneeded < Integer.BYTES) {
// 写入硬盘
flush();
// 开新的块, 获取新的块的空间值
currentblk = appendNewBlock();
boundary = logpage.getInt(0);
}
int recpos = boundary - bytesneeded;
log.info("boundary: {}, recsize: {}, bytesneeded: {}, new current boundry: {}", boundary, recsize, bytesneeded, recpos);
logpage.setBytes(recpos, logrec);
logpage.setInt(0, recpos);
// 最新日志序号加一
latestLSN += 1;
return latestLSN;
}
新增日志方法逻辑不复杂, 就是写入内存页, 然后当前日志序号加一, 注意新增只是写入内存, 没有写入硬盘。要写入硬盘, 要用flush
方法:
/**
* Ensures that the log record corresponding to the
* specified LSN has been written to disk.
* All earlier log records will also be written to disk.
* @param lsn the LSN of a log record
*/
public void flush(int lsn) {
if (lsn >= lastSavedLSN) {
flush();
}
}
private void flush() {
fm.write(currentblk, logpage);
lastSavedLSN = latestLSN;
}
flush
方法要比较传入的日志序号和lastSavedLSN
, lastSavedLSN
指的是最近写入的硬盘的日志序号, 表示它之前的序号日志已经写入硬盘。如果是大于等于, 表示还是在内存, 没写入硬盘, 所以可以写入。
缓冲管理
内存页是用来存放块的信息, 而一堆固定大小的内存页集合, 称为缓冲池 (Buffer Pool)。当用户把块信息放入内存页, 则把它标记为占用 (pinned), 不用则恢复它 (unpin)。当用户想使用缓冲池, 则会有四种情况发生:
| 访问的块在内存页 | 访问的块不在内存页 |
| ---- | ---- |
| 该页被占用 | 有至少一个没有被占用的页 |
| 该页没有被占用 | 所有页都被占用 |
-
访问的块在内存页 该页被占用: 这种情况在多用户都想访问同一信息下会发生, 所有用户都可以并发读写该页, 当中可能存在并发问题, 这是由并发管理类负责处理
-
访问的块在内存页 该页没有被占用: 如果某一用户访问完文件块的信息, 不再占用该页, 把它标记为没有占用, 与此同时, 另一用户想访问该块的信息, 这种情况就发生了。缓冲池则把该页分配给该用户继续使用, 不用去访问硬盘
-
访问的块不在内存页 有至少一个没有被占用的页: 这个就好理解, 不多说了
-
访问的块不在内存页 所有页都被占用: 这时用户就得等待有空闲内存页
有了缓冲池, 则可以尽可能延迟或减少访问硬盘, 只有两个原因才不得不去访问硬盘:
-
内存页另有所用, 得把里面的内容写入
-
恢复管理需要把它的内容写入硬盘, 以防系统崩坏
替代策略
前文所述可以得知缓冲池是有限空间, 意味当有新用户想占用内存页, 得要替换。问题是到底替换哪一个呢? 这不能随便替换, 最理想肯定是替换后, 长时间不会被替换。这要看用什么策略。用一个例子来说明比较好:
现在有一个缓冲池, 作了以下行动:
pin(10); pin(20); pin(30); pin(40); unpin(20);
pin(50); unpin(40); unpin(10); unpin(30); unpin(50)
pin表示占用, unpin表示不占用。然后现在缓冲池的结果如下:
| 内存页 | 0 | 1 | 2 | 3 |
| ---------- | --- | --- | ---| ---|
| 文件块 | 10 | 50 | 30 | 40 |
| 读取次数 | 1 | 6 | 3 | 4 |
| 不占用次数 | 8 | 10 | 9 | 7 |
假设现在所有内存页都是没有被占用, 现在有两个请求:
pin(60), pin(70)
那到底要换哪一个?
最简单的方法是直接看到第一个没有被占用, 就替换它。假设现在用户不断重复占用又不占用:
pin(60), unpin(60), pin(70), unpin(70)...
那就意味每次都得去访问硬盘, 内存页也没有充分利用。
FIFO
FIFO, 即First in First out, 先入先出。根据我们日常的经验, 通常如果一条数据很久没有访问, 意味着它之后也不怎么会被访问。回到上述例子, 就是看那个读取次数最少, 就替代它, 即pin(60)占用内存页0, 而pin(70)则占用内存页2。
LRU
LRU, Lease Recently used, 最近最少使用。通常如果最少使用, 意味着未来也不怎么使用。回到例子, 则是看哪个是最少被标记为不占用, 即pin(60)占用内存页, 70占用0。
时钟策略
这策略类似一开始说的简单方法。把缓冲池想像成一个时钟, 中间的指针指向最近被占用的内存页, 当有新的占有请求, 则去找第一个未被占用的页。例如, 最近被占用的是内存页1, pin(60)则占用2, 70则占用3。时钟策略的好处是可以均匀去使用缓冲池。
缓冲管理实现
接下来就是实现环节, 要实现两个类: BufferMgr
和Buffer
。BufferMgr
在数据库创建时启动, 用来管理页的占用, 以及把它们写入硬盘。Buffer
则是对Page
作封装, 操作内存页的行为。
先看看Buffer
的实现:
/**
* An individual buffer. A databuffer wraps a page
* and stores information about its status,
* such as the associated disk block,
* the number of times the buffer has been pinned,
* whether its contents have been modified,
* and if so, the id and lsn of the modifying transaction.
* @author Edward Sciore
*/
@Slf4j
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;
}
/**
* Returns a reference to the disk block
* allocated to the buffer.
* @return a reference to a disk block
*/
public BlockId block() {
return blk;
}
public void setModified(int txnum, int lsn) {
this.txnum = txnum;
if (lsn >= 0)
this.lsn = lsn;
}
/**
* Return true if the buffer is currently pinned
* (that is, if it has a nonzero pin count).
* @return true if the buffer is pinned
*/
public boolean isPinned() {
return pins > 0;
}
public int modifyingTx() {
return txnum;
}
void assignToBlock(BlockId b) {
flush();
blk = b;
fm.read(blk, contents);
pins = 0;
}
/**
* Write the buffer to its disk block if it is dirty.
*/
void flush() {
if (txnum >= 0) {
log.info("Flushing to the disk: block file {}, block number {}", blk.fileName(), blk.number());
lm.flush(lsn);
fm.write(blk, contents);
txnum = -1;
}
}
/**
* Increase the buffer's pin count.
*/
void pin() {
pins++;
}
/**
* Decrease the buffer's pin count.
*/
void unpin() {
pins--;
}
}
先看构造方法:
public Buffer(FileMgr fm, LogMgr lm) {
this.fm = fm;
this.lm = lm;
contents = new Page(fm.blockSize());
}
传FileMgr
和LogMgr
, 封装它们的操作, 然后生成Page
, 一开始因为没占用任何文件块, 所以BlockId
是null, 直到调用assignToBlock
。
void assignToBlock(BlockId b) {
flush();
blk = b;
fm.read(blk, contents);
pins = 0;
}
/**
* Write the buffer to its disk block if it is dirty.
*/
void flush() {
if (txnum >= 0) {
log.info("Flushing to the disk: block file {}, block number {}", blk.fileName(), blk.number());
lm.flush(lsn);
fm.write(blk, contents);
txnum = -1;
}
}
修改方法这里作占位处理, 只是设置事务和日志编号。事务的一节再写。
接着是BufferMgr
:
/**
* Manages the pinning and unpinning of buffers to blocks.
* @author Edward Sciore
*
*/
public class BufferMgr {
private Buffer[] bufferpool;
private int numAvailable;
private static final long MAX_TIME = 10000; // 10 seconds
/**
* Creates a buffer manager having the specified number
* of buffer slots.
* This constructor depends on a {@link FileMgr} and
* {@link simpledb.log.LogMgr LogMgr} object.
* @param numbuffs the number of buffer slots to allocate
*/
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);
}
/**
* Returns the number of available (i.e. unpinned) buffers.
* @return the number of available buffers
*/
public synchronized int available() {
return numAvailable;
}
/**
* Flushes the dirty buffers modified by the specified transaction.
* @param txnum the transaction's id number
*/
public synchronized void flushAll(int txnum) {
for (Buffer buff : bufferpool) {
if (buff.modifyingTx() == txnum)
buff.flush();
}
}
/**
* Unpins the specified data buffer. If its pin count
* goes to zero, then notify any waiting threads.
* @param buff the buffer to be unpinned
*/
public synchronized void unpin(Buffer buff) {
buff.unpin();
if (!buff.isPinned()) {
numAvailable++;
notifyAll();
}
}
/**
* Pins a buffer to the specified block, potentially
* waiting until a buffer becomes available.
* If no buffer becomes available within a fixed
* time period, then a {@link BufferAbortException} is thrown.
* @param blk a reference to a disk block
* @return the buffer pinned to that block
*/
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;
}
}
可以看出它就是管理一堆Buffer
类。先看看pin
:
/**
* Pins a buffer to the specified block, potentially
* waiting until a buffer becomes available.
* If no buffer becomes available within a fixed
* time period, then a {@link BufferAbortException} is thrown.
* @param blk a reference to a disk block
* @return the buffer pinned to that block
*/
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;
}
真正占用的方法是tryToPin
:
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;
}
逻辑挺简单, 就是在缓冲池看有没有占有相同的块的内存页, 有则返回它, 没有则去看看有没有没被占用的Buffer
。这里用的是最简单的策略去替换, 如果想用FIFO或LRU, 则要在Buffer
加上读取次数或未被占用次数的属性, 在chooseUnpinnedBuffer
中遍历比较取最小值就可以了。
切換至bufferAndLog就可以看到