Lab 2 和 Lab 3 都与数据库的查询过程有关,在执行查询的过程中会先后生成LogicalPlan和PhysicalPlan。逻辑计划由一系列的逻辑算子结点列表组成,它保存了需要进行 Scan、Join、Filter 等操作的表名、列名、谓词等信息。由逻辑计划生成的物理计划其实就是一系列物理算子嵌套形成的结点树。
火山模型
SimpleDB 采用的是最经典且最广泛使用的查询模型:火山模型(Volcano),也叫流水线模型(Pipeline)。
该模型要求:每一个物理计划算子(Operator)都要实现 next()
方法,在该方法中,循环调用它的 child 算子的 next 方法以获取元组并进行数据处理,根据本算子的逻辑返回给父算子一个元组。直到 child 没有元组可获取,则返回 null。因此数据是从最底层数据表,一层一层的经过中间算子的处理、过滤,被传递到顶层的客户端的,因此被形象地叫做“火山模型”。而“流水线”的意思是,每当父算子调用 child 的 next 方法后,如果它想调用下一次 next,就只能等待这一次获取的数据经过物理计划自底向上的“流水线算子”的处理直至“涌出”,这期间父算子无法做其他事情。
火山模型的优点是每一层的算子只需要无脑从子算子获取元组,并根据自己的逻辑考虑如何返回元组给父算子,而不需要关心父算子和子算子具体的逻辑和实现。
在 SimpleDB 中,最顶层的算子是 Project(投影),它负责把所有的结果元组按照查询要求只显示指定的 Field 列;最底层的算子是 SeqScan(顺序扫),它负责从指定的数据表中一行一行的顺序读取元组;在这两者之间的算子有:Aggregate, Filter, Join, OrderBy, Insert, Delete 等。其中 Insert 和 Delete 比较特殊,因为他们不从数据表读取数据,而是从要插入或删除的元组集合中读取数据。
Lab2 总共有 5 个 exercise,主要练习了与执行计划相关的各种 execution 操作。比如过滤、连接、聚合、插入、删除等操作。每一个操作算子都继承了 Operator
(OpIterator)类,它们会:
- 接受一个 child
OpIterator
,用以读取(遍历)目标数据; - 接受一些控制该算子的参数;
- 同时实现
hasNext()
、next()
等供外界遍历的方法。
Exercise 1
- src/java/simpledb/execution/Predicate.java
- src/java/simpledb/execution/JoinPredicate.java
- src/java/simpledb/execution/Filter.java
- src/java/simpledb/execution/Join.java
谓词和连接谓词的作用是根据指定的 field 、操作符和操作数来判断某一个 Tuple 是否需要过滤。而 Filter
和 Join
算子则遍历 child 数据,利用上述谓词来进行来进行过滤,返回留下来的 Tuple。实现起来比较简单。
这里有个注意的点是,每个实现了 Operator
的算子都要重写 getTupleDesc()
方法,生成该算子每次 next()
后返回的元组结构描述。比如 Join 算子返回的是两个元组合并后的结构:
public TupleDesc getTupleDesc() {
return TupleDesc.merge(child1.getTupleDesc(), child2.getTupleDesc());
}
Exercise 2
- src/java/simpledb/execution/IntegerAggregator.java
- src/java/simpledb/execution/StringAggregator.java
- src/java/simpledb/execution/Aggregate.java
IntegerAggregater
和 StringAggregator
是具体类型的聚合器,它们的作用是在聚合算子 Aggregate
遍历表的过程中统计分组(group)信息并得到最终聚合结果。Integer 类型有五种基本聚合操作:MIN, MAX, SUM, AVG, COUNT,而 String 类型只有 COUNT 一种。不同的聚合操作进行不同的计算即可。
Exercise 3, 4
- src/java/simpledb/storage/HeapPage.java
- src/java/simpledb/storage/HeapFile.java
- src/simpledb/BufferPool.java
- insertTuple()
- deleteTuple()
- src/java/simpledb/execution/Insert.java
- src/java/simpledb/execution/Delete.java
练习 3 和练习 4 要求实现 HeapFile 和 HeapPage 的可变性,即可以随时插入、删除元组,并且实现 Insert
和 Delete
算子。
首先,向 HeapPage 中插入元组需要根据 header 标志位找到一个空闲 slot,在插入后(数组赋值)header 对应位置标志为 1;删除元组则反之。因为 header 使用 byte 数组存储的,所以需要一定算法将对应 byte 取出更改某一位值后再放回:
private void markSlotUsed(int i, boolean value) {
byte markBit = value?(byte)1:0;
byte oldByte = header[i/8];
byte newByte = (byte) 0 ;
for(int pos=7; pos>=0; pos--){ // 这里注意顺序
byte originBit = (byte) (oldByte >> pos & 1); // 不变的bit
if(pos == i%8){ // 到了要设置的bit
newByte |= markBit;
}else{
newByte |= originBit;
}
newByte <<= pos!=0?1:0; // 除了最后一位,填充后左移
}
header[i/8] = newByte;
}
HeapFile 的插入方法需要遍历所有的 HeapPage,判断页面是否有空闲 slot,如果有的话,调用该页的 insert 方法,如果所有页面都无空闲,就要新建一个页面再行插入。
而最终所有的应用程序在插入元组时,是调用 BufferPool 的方法。BufferPool 调用 HeapFile 的 insert 方法,接收一个 Page 列表,存储所有被影响的页面(如果不考虑副本的话只有 1 个页面)。这些页面就是所谓的脏页(dirty page),即在缓存中发生了改动但还没有同步到硬盘中的页面。BufferPool 需要将这些页面标记为“脏页”。
BufferPool、HeapFile、HeapPage 之间必须遵循固定的调用关系:
Exercise 5
- src/java/simpledb/storage/BufferPool.java
- evictPage()
练习 5 要求在缓冲区满了以后实现页面置换算法。因为之前没有考虑这个功能所以 getPage()
方法要重新写。
最常见的置换算法是 LRU(最近最久未使用算法),实现它可以用一个 List,每次访问一个页面就把它放到表头,这样需要同置换时,表尾的页面就是最近最久未使用的,直接逐出。然而仅仅用一个 List,在访问页面时还需要遍历查找,不如 HashMap 高效,但是仅仅用 HashMap 又无法实现算法要求。
所以我将两者结合,自定义了一个 PageCache 接口和 LRUBasedCache 实现类,手动实现双向链表,结合 HashMap,实现了 O(1) 复杂度 GET、PUT 操作和灵活置换的 LRU 算法。
页面缓存接口 PageCache
:
public interface PageCache {
// 向缓存中添加页面
void putPage(Page page);
// 系统内部获取页面 - LRU不生效
Page getPage(PageId pid);
// 外部(事务)获取页面 - LRU生效
Page accessPage(PageId pid);
// 从缓存中删除页面
void removePage(PageId pid);
// 缓存是否已满
boolean isFull();
// 下一个要被置换的页面PID
PageId pidToBeEvicted();
// 置换页面(删除)
void evictPage();
// 页面迭代器
Iterator<Page> iterator();
}
实现类 LRUBasedCache
主要就是维护一个双向链表和一个 PageId 到链表节点的映射,然后在 accessPage 的时候实现 LRU 规则(将被访问的节点向链表头移动):
public class LRUBasedCache implements PageCache{
/**
* 双向链表结点
*/
private static class Node{
Page page;
Node pre;
Node next;
public Node(Page page){
this.page = page;
}
}
private final int capacity;
private Map<PageId, Node> map;
private Node head;
private Node tail;
...
@Override
public synchronized Page accessPage(PageId pid) {
Node node = map.get(pid);
if(node == null){
return null;
}
moveToHead(node); // LRU算法 - 向链表头部移动
return node.page;
}
@Override
public synchronized PageId pidToBeEvicted() {
Node n = tail.pre;
while(n != head){
if(n.page.isDirty() != null){ // 确保不是脏页(no-steal规则)
n = n.pre;
continue;
}
break;
}
return n==head?null:n.page.getId(); // 返回null代表全都是脏页
}
...
}