文章目录
任务介绍
在6.830的Project2中,我们要实现常见的SQL语句所需要的数据库操作,在Project1中,我们已经实现了一种顺序扫描的操作SeqScan,它的实现方式是利用SimpleDB的Heap File类提供的迭代器来遍历整个数据表文件中的所有元组信息。而在Project2中,我们需要实现几种更复杂的算子,包括:
- Filter:按条件筛选符合的元组;
- Join:将两个表中符合条件的元组进行join操作;
- Aggregate:按照一定的目标对表中的元组进行聚合运算;
- Insert和Delete:插入、删除元组。
同时,对于需要修改文件内容的操作(这里只有Insert和Delete会修改已有文件中的内容,其他的都是另外生成新的内容),我们还需要在Buffer,File和Page三个层面来实现对应的修改操作,使得数据库能够保证一致性。
还有一项重要任务就是新增一个Buffer中的页淘汰策略。
exercise 1 - Filter,Join
主要是实现以下的类:
- src/java/simpledb/execution/Predicate.java
- src/java/simpledb/execution/JoinPredicate.java
- src/java/simpledb/execution/Filter.java
- src/java/simpledb/execution/Join.java
(1)Predicate
Filter操作是按照一定的条件对数据表中的元组进行过滤,这个判断的过程在SimpleDB中被抽象成了一个叫做Predicate的类,Predicate就是比较tuple某个字段的值与指定的值是否满足判断,这部分没什么难度,贴出成员变量:
private int field;
private Op op;
private Field operand;
-
需要判断的属性值的列号
-
判断的类型(大于,小于,等于,不等于),在SimpleDB中被抽象成了一个枚举类Op,具体操作如下:
if (this == EQUALS) return "="; if (this == GREATER_THAN) return ">"; if (this == LESS_THAN) return "<"; if (this == LESS_THAN_OR_EQ) return "<="; if (this == GREATER_THAN_OR_EQ) return ">="; if (this == LIKE) return "LIKE"; if (this == NOT_EQUALS) return "<>";
-
判断的基准值,用之前说到过的Field类型来表示。
(2)JoinPredicate
JoinPredicate和Predicate其实很像,只不过比较的是两个tuple,也就是用于表join操作时的比较。看下成员变量:
private int field1;
private Predicate.Op op;
private int field2;
唯一的不同点在于保存的是两个field序号,分别在一个tuple中取出指定的field进行比较。
(3)Filter
Filter操作就是进行具体的过滤了,先看下成员变量:
private Predicate predicate;
private OpIterator child;
private TupleDesc tupleDesc;
private Iterator<Tuple> it;
private final List<Tuple> childTuple = new ArrayList<>();
大概得执行逻辑是:
- 通过构造器拿到predicate和child,也就是过滤的判断过程和要过滤的所有tuple;
- 最终的所有过滤结果是保存到it(一个迭代器)中,也就是通过调用这个filter的open()方法就会进行过滤,将满足条件的tuple全部保存到childTuple中,然后返回一个过滤器。
- 通过这个过滤器可以获取所有满足条件得到tuple。
这里注意一下这个OpIterator,在lab1中,我们是不是实现了一个SeqScan,而这个SeqScan其实就是一个实现了OpIterator的迭代器,也就是,当我们如果没有建立索引,那我就是通过SeqScan顺序读取所有的tuple数据,然后传入Filter。
(4)Join
Join操作是对两个数据表进行操作,将分别来自于两个表的,满足一定条件的元组a和b合成一个新的元组c,并且c的所有属性是a和b汇总得到的,比如a元组的是 ( a 1 , a 2 , a 3 ) (a_1,a_2,a_3) (a1,a2,a3), b元组是 ( b 1 , b 2 ) (b_1,b_2) (b1,b2) 且ab满足join的判断条件,那么新生成的c就是 ( a 1 , a 2 , a 3 , b 1 , b 2 ) (a_1,a_2,a_3,b_1,b_2) (a1,a2,a3,b1,b2)。
而join操作也需要进行条件判断,和Filter不同的是,这里的判断的参数变成了两个元组,所以SimpleDB设计了一个类Join Predicate,也就是前面那个。
先看下成员变量:
private JoinPredicate joinPredicate;
private OpIterator child1;
private OpIterator child2;
private TupleDesc tupleDesc;
private Iterator<Tuple> it;
private final List<Tuple> childTuple = new ArrayList<>();
Join其实和Filter差不多,区别在于:
- tupleDesc保存的是join中两张表的tupleDesc合并;
- 比较时,是进行一次时间复杂度为O(n*n)的双层循环比较,以child1为驱动表,进行判断。
exercise 2 - Aggregates
主要是实现以下类:
- src/java/simpledb/execution/IntegerAggregator.java
- src/java/simpledb/execution/StringAggregator.java
- src/java/simpledb/execution/Aggregate.java
Aggregate操作是对数据表中的元组按照一定的规则进行聚合,常见的Aggregate操作包括:
- 计数类Count
- 求和类Sum / Avg
- 最值类Max / Min
Aggregate常常和Group By
一起使用,Group By
就是聚合的依据,当它指定了某个属性之后,该属性相同的元组会被聚合成一个结果,这时候Aggregate操作返回的结果就是形如**(groupValue, aggregateValue)** 的元组,对每个不同的group value分别进行聚合,而如果没有Group By
关键字,那么返回的结果就只剩一个aggregateValue,在lab2中,我们需要先分别实现两种不同数据类型(Int 和 String)各自的聚合运算,然后合并成SimpleDB的聚合运算符。
而判断有没有group主要是两种判断方法(任意一个都可以):
- gbField为-1;
- 分组的结果Map<Field, List<Field>> group中,key为null;
其实聚合操作的整体思想和Filter很像,都是将具体的过滤条件封装到一个类中,然后遍历每一个tuple去判断,将满足条件的field存入Map<Field, List<Field>>中,其中如果没有group by的话key就是null,也就是只有这一个key。然后使用迭代器的思想,将Map进行聚合操作后产生一个List,然后以此获取到结果的迭代器。
值得注意的是,在我们获取最终的结果时,需要自己定义返回结果的TupleDesc(就是上面提到的两种格式,判断依据是有没有进行group)。也就是迭代器输出的结果中,每一个tuple的TupleDesc都是根据具体情况进行定义的,而具体哪一种TupleDesc就是上面的两种判断方式,并且tuple的field个数判断方式也是一样的。
现在开始讲一下这三个类文件的实现和自己定义的结果集迭代器。
(1)Aggregate
Aggregate并不是真的进行聚合操作,就相当于exercise 1中filter,会根据传入的条件封装一个IntegerAggregator或StringAggregator,然后对每一个tuple进行判断。
看下成员变量:
private OpIterator child;
private int afield; //要聚合的field
private int gfield; //group by的field
private Aggregator.Op aop; //聚合的操作
private final Aggregator aggregator; //具体的聚合器,用于过滤
private OpIterator opIterator; //最终结果的迭代器
具体的逻辑方面,主要就是聚合器的构造要注意一下。根据有没有group by字段,以及聚合字段是什么类型分别进行创建:
if(gfield!=-1){
if(fieldType.equals(Type.INT_TYPE)){
//这里要改一下
aggregator = new IntegerAggregator(gfield,child.getTupleDesc().getFieldType(gfield),afield,aop);
}else if(fieldType.equals(Type.STRING_TYPE)){
aggregator = new StringAggregator(gfield,child.getTupleDesc().getFieldType(gfield),afield,aop);
}else{
aggregator = null;
}
}else{
if(fieldType.equals(Type.INT_TYPE)){
aggregator = new IntegerAggregator(gfield,null,afield,aop);
}else if(fieldType.equals(Type.STRING_TYPE)){
aggregator = new StringAggregator(gfield,null,afield,aop);
}else{
aggregator = null;
}
}
然后open()函数中,根据传入的数据迭代器迭代每一个tuple,用聚合器进行聚合,以此生成一个最终结果的迭代器。
(2)IntegerAggregator
这就是具体聚合字段为Integer类型的聚合器了。
private int gbfield;
private int afield;
private Type gbfieldType;
private Op what;
private Map<Field, List<Field>> group;
其实就是根据Aggregate调用mergeTupleIntoGroup函数,将同一组的field写入到map的对应value中。这个group就是聚合的中间结果集,key是group by字段或者null,List<Field>就是当前组的所有聚合字段。
看一下具体的逻辑:
public void mergeTupleIntoGroup(Tuple tup) {
// some code goes here
Field aField = tup.getField(afield);
Field gField = null;
if(this.gbfield!=-1){
gField = tup.getField(this.gbfield);
}
if(this.group.containsKey(gField)){
group.get(gField).add(aField);
}else{
List<Field> list = new ArrayList<>();
list.add(aField);
group.put(gField,list);
}
}
就是对传入的tuple进行分组,传入到对应的Map<Field, List<Field>>结构中。
public OpIterator iterator() {
return new AggregateIter(group,gbfield,gbfieldType,what);
}
主要就是根据group进行构建迭代器。这个迭代器就是自己定义的迭代器了。
(3)StringAggregator
这个类的实现和IntegerAggregator基本相同,就不说了。
(4)AggregateIter
这个迭代器应该是这个exercise中最重要也是最难实现的一部分了,主要就是对group中的每一组field进行相应的聚合操作。
先看下成员变量:
private Iterator<Tuple> tupleIterator;
private Map<Field, List<Field>> group;
private List<Tuple> resultSet; //用来存储聚合后的数据
private Aggregator.Op what;
private Type gbFieldType;
private TupleDesc tupleDesc; //如果有group by就是两条,否则就是一条。
private int gbField;
tupleIterator就是最终聚合结果所有tuple的迭代器。
tupleDesc是要自己根据有无group by进行定义的,如果gbField=-1,那么tupleDesc就只有一个Type,也就是全部数据的聚合结果(在构造器中进行判断):
if(gbField!=-1){
Type[] type = new Type[2];
type[0] = gbFieldType;
type[1] = Type.INT_TYPE;
this.tupleDesc = new TupleDesc(type);
}else{
Type[] type = new Type[1];
type[0] = Type.INT_TYPE;
this.tupleDesc = new TupleDesc(type);
}
而在该迭代器的open()函数中,就是进行具体的聚合操作,并放入到结果集中,其实max、min、count这些聚合操作大体逻辑相同,这里就列出min的来看看:
...
}else if(what == Aggregator.Op.MIN){
for(Field field:group.keySet()){
int min = Integer.MAX_VALUE;
Tuple tuple = new Tuple(tupleDesc);
for(int i=0;i<this.group.get(field).size();i++){
IntField field1 = (IntField)group.get(field).get(i);
if(field1.getValue()<min){
min = field1.getValue();
}
}
if(field!=null){
tuple.setField(0,field);
tuple.setField(1,new IntField(min));
}else{
tuple.setField(0,new IntField(min));
}
resultSet.add(tuple);
}
}
...
这里注意一下,如果key只有一个null的话,外面的大循环只会执行一次,然后存入到结果集中。
然后通过结果集得到一个迭代器,通过这个迭代器迭代所有的结果。
exercise 3 - HeapFile Mutability
主要是实现以下部分:
- src/simpledb/HeapPage.java
- src/simpledb/HeapFile.java
- src/simpledb/BufferPool.java 中的
- insertTuple()
- deleteTuple()
接下来我们还要实现Delete和Insert两种操作,这两种操作和前面的区别在于,Delete和Insert会改变数据表的Page中存储的元组信息,比如Insert会找到一个空的slot并插入元组,Delete会把对应slot上的元组标记为invalid,这样就表示这段位置上的元组已经失效,可以填入其他的元组。
为此我们要先实现每个Heap Page上的插入删除操作,然后实现每个Heap File上的插入删除,然后再实现BufferPool中的插入删除。这和我们在lab1中构建SimpleDB的存储体系的顺序是一样的。
- 同时,在一个Page发生了修改之后,这个Page就变成了脏页,需要标记为dirty,并让BufferPool在适当的时机写回磁盘存储的文件中
- 之后所有涉及到页修改的操作都需要通过BufferPool对外提供的方法进行,这其中执行的逻辑是BufferPool先判断要操作的页在不在Buffer中,如果不在就先去磁盘中把对应的Page读进来,然后在这个页上进行相关操作
其实在lab1中我就已经完成这部分的内容了,只是还缺少了BufferPool两个函数的构建,这里简单给出insertTuple的函数构造:
public void insertTuple(TransactionId tid, int tableId, Tuple t)
throws DbException, IOException, TransactionAbortedException {
DbFile dbFile = Database.getCatalog().getDatabaseFile(tableId);
//注意,insertTuple函数并不会
List<Page> pages = dbFile.insertTuple(tid, t);
for(Page page : pages){
page.markDirty(true,tid);
buffer.put(page.getId().hashCode(),page);
}
}
exercise 4 - Insertion and Deletion
主要实现以下类:
- src/simpledb/Insert.java
- src/simpledb/Delete.java
在实现了存储系统中的修改操作之后,我们才能进一步实现Delete和Insert操作的运算符,这里的运算符在进行页的修改的时候都需要通过BufferPool提供的方法来修改,我们只需要再次基础上实现每个运算符的fetchNext方法就行。
值得注意的是,Insert和Delete操作的运算符返回的结果依然是元组的形式,但是这个元组只有1个属性,并且这个属性值代表了Insert和Delete操作影响的元组数量。
(1)Insert
先看下成员变量吧:
private TransactionId t;
private OpIterator child;
private int tableId;
private ArrayList<Tuple> tupleList = new ArrayList<>();
private Iterator<Tuple> iterator;
在构造器中,会传入child和tableId,child就是所有要插入的tuple,tableId就是要插入的table。
而主要的逻辑就是在于open()函数:
child.open();
int count = 0;
while(child.hasNext()){
Tuple next = child.next();
count++;
try {
Database.getBufferPool().insertTuple(this.t,this.tableId,next);
} catch (IOException e) {
e.printStackTrace();
}
}
Tuple tuple = new Tuple(getTupleDesc());
tuple.setField(0, new IntField(count));
tupleList.add(tuple);
iterator = tupleList.iterator();
super.open();
就是迭代每一个要插入的元素,然后将最后插入的元素个数组成一个tuple,形成一个迭代器。
(2)Delete
和insert几乎差不多:
child.open();
int count = 0;
while(child.hasNext()){
Tuple next = child.next();
count++;
try {
Database.getBufferPool().deleteTuple(t,next);
} catch (IOException e) {
e.printStackTrace();
}
}
Tuple tuple = new Tuple(getTupleDesc());
tuple.setField(0,new IntField(count));
tupleList.add(tuple);
iterator = tupleList.iterator();
super.open();
exercise 5 - pageEviction
在lab2的最后,我们还需要实现页的置换算法,因为BufferPool是有容量上限的,如果我们在不断读入页的时候把容量用完了,那么就必须将一部分页置换出去,这样才能让新的页不断读入内存中,常见的页置换策略有LRU、Clock等等,我们在lab2中也需要自己实现一种Buffer中页的置换算法。
于是我选择了最简单的实现方式——替换Buffer中的第一个页,具体方法是每当要发生置换的时候,就用Buffer的迭代器获取位于最前面的页,然后将其替换,这个实现过程非常简单,也比较有效。
这里使用的是LRU策略,主要就是链表+map实现:
class DLinkedNode {
K key;
V value;
DLinkedNode prev;
DLinkedNode next;
public DLinkedNode() {}
public DLinkedNode(K _key, V _value) {key = _key; value = _value;}
}
private Map<K,DLinkedNode> cache = new ConcurrentHashMap<K,DLinkedNode>();
private int size;
private int capacity;
private DLinkedNode head, tail;
链表是为了记录每个page的最近一次获取顺序并进行淘汰,map主要是用来判断buffer是否存在该page。
每次通过key获取元素,如果存在就移到头部:
public synchronized V get(K key){
DLinkedNode node = cache.get(key);
if(node==null){
return null;
}
//如果存在,移到头部
moveToHead(node);
return node.value;
}
新增元素的时候,如果存在该元素,就修改;否者插入头部,然后判断有没有达到容量上限,超过上限的话,就淘汰最后一个元素:
public synchronized void put(K key,V value){
DLinkedNode node = this.cache.get(key);
if(node==null){
//新增
DLinkedNode newNode = new DLinkedNode(key,value);
this.cache.put(key,newNode);
addToHead(newNode);
this.size++;
//判断是否达到上限
if(this.size>this.capacity){
//删掉最后一个元素
DLinkedNode tmp = tail.prev;
//先在链表中移除
removeNode(tmp);
//然后在map中移除
this.cache.remove(tmp.key);
this.size--;
}
}else{
//修改
node.value = value;
moveToHead(node);
}
}
而在我们的BufferPool中,首先就要将buffer的Map结构换成LRUCache:
private LRUCache<PageId,Page> buffer;
然后实现以下函数:
- flushAllPages:将所有的page进行刷盘,准确来说是将所有脏页进行刷盘;
- discardPage:从BufferPool中移除指定PageId的page;
- flushPage:将指定pageId的脏页刷盘;
- flushPages:将指定TransactionId的所有脏页刷盘;
- evictPage:从buffer中选择一页淘汰,这里最好是选择一个非脏页,因为脏页要等到一次事务结束进行刷盘。(可以等到lab4事务中再去完成)
flushAllPages函数:
public synchronized void flushAllPages() throws IOException {
// some code goes here
// not necessary for lab1
LRUCache<PageId, Page>.DLinkedNode head = buffer.getHead();
LRUCache<PageId, Page>.DLinkedNode tail = buffer.getTail();
while(head!=tail){
Page page = head.value;
if(page!=null && page.isDirty()!=null){
DbFile dbFile = Database.getCatalog().getDatabaseFile(page.getId().getTableId());
//记录日志
try{
Database.getLogFile().logWrite(page.isDirty(),page.getBeforeImage(),page);
Database.getLogFile().force();
dbFile.writePage(page);
}catch (IOException e){
e.printStackTrace();
}
}
head = head.next;
}
}
discardPage函数:
public synchronized void discardPage(PageId pid) {
// some code goes here
// not necessary for lab1
LRUCache<PageId, Page>.DLinkedNode head = buffer.getHead();
LRUCache<PageId, Page>.DLinkedNode tail = buffer.getTail();
while(head!=tail){
PageId key = head.key;
if(key!=null && key.equals(pid)){
buffer.remove(head);
return;
}
head = head.next;
}
}