Lab 6 实现 SimpleDB 的基于日志系统的回滚(rollback)和恢复(recover)功能。
WAL 机制
首先,SimpleDB 要实现的是预写式日志(Write-ahead logging, WAL),也就是所有修改在生效之前都要先写入 log 文件中,写入的内容包括 redo 和 undo 信息,分别保证事务的持久性和原子性。
在 SimpleDB 中,日志的单位和锁一样是页面,每个页面都可以通过 setBeforeImage
方法来设置 oldData,也就是每次 flush 到磁盘前页面还未变动时的旧数据(是上一次 flush 的时候保存的)。日志系统会在每次 flush 脏页的时候把 beforeImage 和 afterImage 写入日志文件,分别代表旧数据和新数据。这样在需要 redo 的时候,就把 afterImage 写入磁盘;需要 undo 的时候,就把 beforeImage 写入磁盘。
Lab 6 只要求实现 LogFile 类中的 rollback
和 recover
方法:
- 前者用在事务 abort 的时候,需要撤销 (undo) 该事务的所有操作,回滚数据库到之前的状态;
- 后者用在发生崩溃 crash 的时候,需要撤销 (undo) 所有未提交事务的所有操作、重做 (redo) 所有已提交事务的所有操作,恢复数据库到正常状态。
缓冲区管理策略
数据库的缓冲区管理策略有两类四种,分别是:
steal 策略
允许从页面缓存逐出“脏页”。此时磁盘上可能包含 uncommitted 的数据,因此系统需要记录 undo log,以在事务 abort 时进行回滚(rollback)。
no-steal 策略
不允许从页面缓存逐出“脏页”。表示磁盘上不会存在 uncommitted 数据,因此无需回滚操作,也就无需记录 undo log。
force 策略
事务在 committed 的时候必须将所有更新立刻持久化到磁盘,这样的话不需要 redo log,因为只要日志中存在 commit 记录就说明磁盘已经更新了全部数据。但是这样会导致磁盘发生很多小的写操作(更可能是随机写)。
no-force 策略
事务在 committed 之后可以不立即持久化到磁盘,这样可以缓存很多的脏页批量持久化到磁盘,这样可以降低磁盘操作次数(提升顺序写),但是如果 committed 之后发生crash,那么此时已经提交的事务数据将会丢失(因为还没有持久化到磁盘),因此系统需要记录 redo log,在系统重启时候进行回复(recover)操作。
在 SimpleDB 中, 之前的 Lab 要求实现的是 no-steal 和 force 策略,但是这种策略的效率不高。所以在本次 Lab 的 LogTest 中,它会时不时的打破 no-steal 策略,也就是通过随时调用
flushAllPages()
让磁盘上存在未提交的数据,测试 abort 后的回滚操作。同时也默认 no-force 的存在(虽然实际不是),以测试 crash 后的恢复操作。所以我们 redo 和 undo 都需要实现。
日志文件结构
Log File 中一条记录的格式是:
<RECORD_TYPE:int | TID:long | content | start:long>
- 其中 RECORD_TYPE 指记录的类型,TID 指事务的标识,content 在不同的类型中表示不同内容, start 指此条记录开始位置的偏移量。
- RECORD_TYPE 总共有 5 种表示不同的行为:
- BEGIN, 事务开始
- UPDATE, 事务对页面进行 UPDATE 操作
- COMMIT, 事务提交
- ABORT, 事务中断
- CHECKPOINT, 检查点
- 在运行过程中各类记录被不停地追加到 Log File 里面。
- 由于多个事务之间时并行执行的,所以日志文件里不同事务对不同页面的各项操作是混合交叉在一起的。
BEGIN、COMMIT 和 ABORT 这三种记录的 content
位置是空的,不存储数据;而 UPDATE 存储的是序列化后的 beforeImage 和 afterImage;CHECKPOINT 存储的首先是一个 INT 类型代表当前活跃事务(未提交)的数量,后面跟的是每个活跃事务的 TID 和 BEGIN 记录的位置 offset(都是 Long 类型)。
检查点是为了加快恢复过程的速度。如果没有检查点,那么系统在宕机重启后需要从头对 Log File 进行顺序访问,依次找到所有未提交和已提交的事务进行 undo 和 redo 操作,费时费力。而检查点机制要求在向 Log File 中添加 CHECKPOINT 的时候,将缓冲区中所有的脏页刷新到磁盘,也就代表着在检查点之前提交了的事务无需在重启后执行恢复操作,因为磁盘已经拥有这些事务更新后的数据。我们只需从检查点之后顺序访问 Log File 即可。
另外需要注意的是,检查点会记录那个时刻还未提交的所有事务 ID,这些事务并不能保证宕机后的原子性和持久性,因此也需要对这些事务进行恢复操作。
Rollback 实现
rollback
方法在事务被 abort 的时候调用,此时该事务对所有页面产生的所有修改都应该失效,也就是说需要将所有相关页面的 beforeImage(旧数据)恢复到磁盘上(undo)。
public void rollback(TransactionId tid){
//省略synchronized结构
preAppend();
// 找到该事务在file中的第一个记录的偏移量
long offset = tidToFirstLogRecord.get(tid.getId());
raf.seek(offset);
// 顺序访问直到文件末尾
while (true) {
try {
int type = raf.readInt(); // 记录类型
long record_tid = raf.readLong(); // TID
switch (type) {
case UPDATE_RECORD: // 更新记录
Page before = readPageData(raf); // 旧数据
Page after = readPageData(raf); // 新数据
if(record_tid == tid.getId()){
// 先把此页面从缓存中去除
Database.getBufferPool().discardPage(before.getId());
// 然后把旧数据写入Table文件
Database.getCatalog().getDatabaseFile(before.getId().getTableId()).writePage(before);
}
break;
case CHECKPOINT_RECORD: // 跳过所有检查点记录
int numXactions = raf.readInt();
while (numXactions-- > 0) {
long xid = raf.readLong();
long xoffset = raf.readLong();
}
break;
}
raf.readLong(); // 跳过start指针
} catch (EOFException e) {
break;
}
}
}
Recover 实现
recover
方法在数据库 crash 重启后调用,需要将检查点(如果有的话)中及其之后的所有事务进行恢复操作,未提交的 undo,已提交的 redo。
public void recover() throws IOException {
// 省略synchronized结构
recoveryUndecided = false;
/* redo就是写入afterimage,undo就是写入beforeimage */
// 已提交的事务ID集合
Set<Long> commitedIds = new HashSet<>();
// 检查点存储的活跃事务集合
Map<Long, Long> activeTxns = new HashMap<>();
// 从检查点往后所有事务的集合(所有的旧页面和新页面)
Map<Long, List<Page>> beforePages = new HashMap<>();
Map<Long, List<Page>> afterPages = new HashMap<>();
long cpOffset = raf.readLong(); // 检查点位置
if(cpOffset != -1){
raf.seek(cpOffset); // 如果有检查点,直接从此处开始
}
// 顺序访问直到文件末尾
while (true) {
try {
int type = raf.readInt(); // 记录类型
long record_tid = raf.readLong(); // TID
switch (type) {
case UPDATE_RECORD:
Page before = readPageData(raf); // 旧数据
Page after = readPageData(raf); // 新数据
beforePages.computeIfAbsent(record_tid, k->new ArrayList<>()).add(before);
afterPages.computeIfAbsent(record_tid, k->new ArrayList<>()).add(after);
break;
case CHECKPOINT_RECORD:
int numXactions = raf.readInt();
while (numXactions-- > 0) {
long xid = raf.readLong();
long xoffset = raf.readLong();
activeTxns.put(xid, xoffset); // 记录活跃事务
}
break;
case COMMIT_RECORD:
commitedIds.add(record_tid); // 记录已提交事务
break;
}
raf.readLong(); // 跳过start指针
} catch (EOFException e) {
break;
}
}
/* 注意undo和redo的顺序不能乱,否则redo被undo覆盖 */
// undo未commit的
for(Long record_id : beforePages.keySet()){
if(!commitedIds.contains(record_id)){
List<Page> befores = beforePages.getOrDefault(record_id, new ArrayList<>());
for(Page page : befores){
Database.getCatalog().getDatabaseFile(page.getId().getTableId()).writePage(page);
}
}
}
// redo已经commit的
for(Long record_tid : commitedIds){
List<Page> afters = afterPages.getOrDefault(record_tid, new ArrayList<>());
for(Page page : afters){
Database.getCatalog().getDatabaseFile(page.getId().getTableId()).writePage(page);
}
}
// 处理在checkpoint之前开始但是在checkpoint还未提交的事务
for(Map.Entry<Long,Long> entry : activeTxns.entrySet()){
long active_id = entry.getKey();
long active_offset = entry.getValue();
boolean commited = commitedIds.contains(active_id);
raf.seek(active_offset);
// 代码与上文类似
while (true) {
try {
int type = raf.readInt();
long record_tid = raf.readLong();
switch (type) {
case UPDATE_RECORD:
Page before = readPageData(raf);
Page after = readPageData(raf);
if(commited){
// redo
Database.getCatalog().getDatabaseFile(after.getId().getTableId()).writePage(after);
}else{
// undo
Database.getCatalog().getDatabaseFile(before.getId().getTableId()).writePage(before);
}
break;
case CHECKPOINT_RECORD:
int numXactions = raf.readInt();
while (numXactions-- > 0) {
long xid = raf.readLong();
long xoffset = raf.readLong();
}
break;
}
raf.readLong();
} catch (EOFException e) {
break;
}
}
}
}
- 需要注意 undo 和 redo 的顺序不能颠倒,否则会出现数据覆盖问题。