一、实验概览

以下是资料对本实验的介绍

  • Implement the operators Filter and Join and verify that their corresponding tests work. The Javadoc comments for
    these operators contain details about how they should work. We have given you implementations of
    Project and OrderBy which may help you understand how other operators work.(过滤、连接)
  • Implement IntegerAggregator and StringAggregator. Here, you will write the logic that actually computes an
    aggregate over a particular field across multiple groups in a sequence of input tuples. Use integer division for
    computing the average, since SimpleDB only supports integers. StringAggegator only needs to support the COUNT
    aggregate, since the other operations do not make sense for strings.(聚合函数)
  • Implement the Aggregate operator. As with other operators, aggregates implement the OpIterator interface so that
    they can be placed in SimpleDB query plans. Note that the output of an Aggregate operator is an aggregate value of
    an entire group for each call to next(), and that the aggregate constructor takes the aggregation and grouping
    fields.
  • Implement the methods related to tuple insertion, deletion, and page eviction in BufferPool. You do not need to
    worry about transactions at this point.(插入、删除、淘汰策略)
  • Implement the Insert and Delete operators. Like all operators, Insert and Delete implement
    OpIterator, accepting a stream of tuples to insert or delete and outputting a single tuple with an integer field
    that indicates the number of tuples inserted or deleted. These operators will need to call the appropriate methods
    in BufferPool that actually modify the pages on disk. Check that the tests for inserting and deleting tuples work
    properly.

这个实验需要完成的内容有:

  1. 实现过滤、连接运算符,这些类都是继承与OpIterator接口了,该实验提供了OrderBy的操作符实现,可以参考实现。最终的SQL语句解析出来都是要依靠这些运算符的;
  2. 实现聚合函数,由于该数据库只有int和string两种类型,int类型可实现的聚合函数有max,min,avg,count等,string类型只需要实现count;这些与分组查询一起使用,选择进行聚合操作时,可以选择是否进行分组查询。
  3. 对IntegerAggregator和StringAggregator的封装,查询计划是调用Aggregate,再去调用具体的聚合器,最后获得聚合结果。
  4. 实现插入、删除记录。包括从HeapPage、HeapFile、BufferPool中删除,这里需要把三个之间的调用逻辑搞清楚,代码会好写很多。
  5. 实现BufferPool的数据页淘汰策略。BufferPool的默认数据页容量为50页,进行插入数据页的操作时,如果数据页数量大于BufferPool的容量,需要选择某中页面淘汰策略去淘汰页面,我选择的是LRU算法来淘汰页面。

二、实验过程

Exercise1:Filter and Join

exercise1要求我们完成Filter和Join两种操作符,下面是相关描述:

  • Filter: This operator only returns tuples that satisfy a Predicate that is specified as part of its constructor.
    Hence, it filters out any tuples that do not match the predicate.
  • Join: This operator joins tuples from its two children according to a JoinPredicate that is passed in as part of
    its constructor. We only require a simple nested loops join, but you may explore more interesting join
    implementations. Describe your implementation in your lab writeup.

Filter实现思路

Filter是SQL语句中where的基础,如select * from students where id > 2.Filter起到条件过滤的作用。我们进行条件过滤,使用的是迭代器FIlter的next去获取所有过滤后的记录,比如上述SQL语句的结果,相当于List<Tuple> list;即一个含有多条tuple的集合,而忽略其中的实现细节Filter就相当于list.iterator()返回的一个跌打器,我们通过it.next()去获取一条一条符合过滤条件的Tuple。

Filter是继承于Operator的,而Operator继承于抽象类OpIterator,是一个迭代器:

由于Operator帮我们实现了next和hasNext方法,而这两个方法最终都是调用fetchNext去获取下一条记录的,所以我们在Filter要做的就是返回下一条符合过滤条件的记录;Filter的属性如下:

其中,predicate是断言,实现条件过滤的重要属性;而child是数据源,我们从这里获取一条一条的Tuple用predicate去过滤;td是我们返回结果元组的描述信息,在Filter中与传入的数据源是相同的,而在其它运算符中是根据返回结果的情况去创建TupleDesc的;

Predicate的作用

前面我们提到:忽略其中的实现细节Filter就相当于list.iterator()返回的一个迭代器器,我们通过it.next()去获取一条一条符合过滤条件的Tuple。而其中的实现细节就是通过Predicate来实现的:

可以看到,每次调用fetchNext,我们是从Filter的child数据源中不断取出tuple,只要有一条Tuple满足predicate的filter的过滤条件,我们就可以返回一条Tuple,即这条Tuple是经过过滤条件筛选之后的有效Tuple。

Filter是依赖于断言来实现的,即实现Filter之前需要实现Predicate,Predicate的基本属性如下:

其中field表示的是利用传入Tuple的第几个字段来于操作数字段operand进行op运算,其中op支持的运算有:相等、大于、小于、等于、不等于、大于等于、小于等于、模糊查询这几种。

而operand是用于参与比较的,比如上述SQL语句select * from students where id > 2;假如id是第0个字段,这里的field = 0,op = GREATER_THAN(大于),operand = new IntField(1)。这里进行比较过滤的实现在filter方法中,我们在Filter类中获取过滤后的tuple也是通过predicate.filter(tuple)方法来实现的,filter方法的实现思路如下:

可以看到,Predicate的作用就是将传入的Tuple进行判断,而Predicate的field属性表明使用元组的第几个字段去与操作数operand进行op运算操作,比较的结果实际是调用Field类的compare方法,compare方法会根据传入的运算符和操作数进行比较,以IntField为例:

可以看到支持的运算符有相等、大于、小于、不等于、大于等于、小于等于这些运算符,这里LIKE和EQUALS都表示等于的意思。

OrderBy的实现思路

实验提供了OrderBy的实现,其思路与我们实现的Filter也是相似的,区别在于对fetchNext的获取下一条tuple的实现有所不同。OrderBy的属性如下:

关键的属性:

1、child:数据源,传入进行排序的所有记录Tuple;

2、childTups:OrderBy的实现思路是在open时将数据源child的所有记录存入list中,然后进行排序;

3、asc:升序还是降序,true表示升序;

4、orderByField:依据元组的第几个字段进行排序;

5、it:对childTups进行排序后childTups.iterator()返回的迭代器,原数据源child依据field字段进行排序后的所有数据。

这里的实现个人觉得不是特别好,当数据源的tuple特别多的时,可能会出现OOM(有点十亿数据进行排序那味了)。

这里主要看open的实现,因为在open中实现了排序并存入it迭代器中,后续调用fetchNext只需要在it中取就行了:

fetchNext就简单很多了,直接从结果迭代器中取就完事了:

Join与JoinPredicate的实现

理解了上面Filter与Predicate的关系以及OrderBy的实现思路,来做Join和JoinPredicate就会容易一点点了。

Join是连接查询实现的基本操作符,我们在MySQL中会区分内连接和外连接,我们这里只实现内连接。一条连接查询的SQL语句如下:

select a.*,b.* from a inner join b on a.id=b.id

Join的主要属性如下:

其中child1,child2是参与连接查询的两个表的元数据,从里面取出tuple使用joinPredicate进行连接过滤。td是结果元组的描述信息,使用内连接我们是将两个表连起来,所以如果显示连接两个表的所有字段的记录,td可以简单理解成两个child数据源的两种tuple的td的拼接,这里在构造器中完成:

实现连接查询的算法有很多种,这里实现的是最简单的嵌套循环连接(NLP),就是从数据源child1中取出一条tuple,然后不断的与child2取出的tuple进行比较,如果满足条件则拼成新的结果tuple加入结果集,不满足则继续取child2的下一条tuple,直到child2没有记录了,再从child1中取出下一条,继续从child2的第一条开始比较,如此往复,直到child1没有记录了。这个算法的时间复杂度是O(m * n)其中m是child1的记录条数,n是child2的记录条数。

具体实现代码在fetchNext中,如下:

protected Tuple fetchNext() throws TransactionAbortedException, DbException {// some code goes here//后面如果it1走到了后面,但是it2还有数据,可以用t1取匹配it2的数据TupleDesc td1 = child1.getTupleDesc(), td2 = child2.getTupleDesc();while (child1.hasNext() || t1 != null) {if(child1.hasNext() && t1 == null) {t1 = child1.next();}Tuple t2;while(child2.hasNext()) {t2 = child2.next();if(joinPredicate.filter(t1, t2)) {Tuple res = new Tuple(td);int i = 0;for(; i < td1.numFields(); i++) {res.setField(i, t1.getField(i));}for(int j = 0; j < td2.numFields(); j++) {res.setField(i + j, t2.getField(j));}//如果刚好是最后一个匹配到,需要重置child2指针和设置t1=nullif(!child2.hasNext()) {child2.rewind();t1 = null;}return res;}}//每次child2迭代器走到终点,需要进行重置child2的指针,否则会导致死循环;t1=null是为了选取child1的下一个tuplechild2.rewind();t1 = null;}return null;}

这里要注意的child2指针重置的时机,一个是child1匹配到的刚好是child2的最后一条记录,这时需要重置(不重置的话取出child1的下一条tuple就不是与child2的第一条tuple进行比较了);另一个时机是child1的一条tuple与child2所有tuple都不匹配,这时child1需要选取下一条tuple进行比较,child2理所应当要从第一条tuple的位置开始迭代。

上面所提到的进行比较看是否匹配,跟前面Filter一样要我们去实现JoinPredicate来实现过滤,而JoinPredicate的实现也是依托与具体Field的compare方法来实现的:

Exercise2:Aggregates

exercise2的介绍:

An additional SimpleDB operator implements basic SQL aggregates with a
GROUP BY clause. You should implement the five SQL aggregates
(COUNT, SUM, AVG, MIN,
MAX) and support grouping. You only need to support aggregates over a single field, and grouping by a single field.

In order to calculate aggregates, we use an Aggregator(聚合器)
interface which merges a new tuple into the existing calculation of an aggregate. The Aggregator is told during
construction what operation it should use for aggregation. Subsequently, the client code should
call Aggregator.mergeTupleIntoGroup() for every tuple in the child iterator. After all tuples have been merged, the
client can retrieve a OpIterator of aggregation results. Each tuple in the result is a pair of the
form (groupValue, aggregateValue), unless the value of the group by field was Aggregator.NO_GROUPING, in which case
the result is a single tuple of the form (aggregateValue).

Note that this implementation requires space linear in the number of distinct groups. For the purposes of this lab, you
do not need to worry about the situation where the number of groups exceeds available memory.

exerciese2要求我们实现各种聚合运算如count、sum、avg、min、max等,并且聚合器需要拥有分组聚合的功能。如以下SQL语句:

SELECT SUM(fee) AS country_group_total_fee, country FROM member GROUP BY country

这条语句的功能是查询每个国家的费用总和及国家名称(根据国家名称进行分组),这里用到了聚合函数SUM。其中fee是聚合字段,country是分组字段,这两个字段是我们理解聚合运算的关键点。

You only need to support aggregates over a single field, and grouping by a single field.讲义告诉我们,我们只需实现根据一个字段去分组和聚合,也就是只有一个分组字段和一个聚合字段。

exercise2的实验要求:

Implement the skeleton methods in:


  • src/java/simpledb/execution/IntegerAggregator.java
  • src/java/simpledb/execution/StringAggregator.java
  • src/java/simpledb/execution/Aggregate.java

At this point, your code should pass the unit tests IntegerAggregatorTest, StringAggregatorTest, and AggregateTest.
Furthermore, you should be able to pass the AggregateTest system test.

IntegerAggregator的实现

IntegerAggregator的本质是一个迭代器,用于对指定的字段进行聚合,下面是基本属性:

    private int groupField;private Type groupFieldType;private int aggregateField;private Op aggregateOp;private TupleDesc td;/*** 计算int类型字段的聚合值,可以实现MIN、MAX、COUNT、SUM*/private Map<Field, Integer> groupMap;/*** AVG比较特殊,需要先加到list中,最后再算平均值,保证准确性*/private Map<Field, List<Integer>> avgMap;

其中,groupField是指依据tuple的第几个字段进行分组,当无需分组时groupField的值为-1,在上面的SQL语句中相当于country这个字段;groupFieldType是分组字段的类型,如果无需分组这个属性值为null;aggregateField是指对tuple的第几个字段进行聚合,在上面的SQL语句中相当于fee字段;aggregateOp是进行聚合运算的操作符,相当于上述SQL语句的SUM。td是结果元组的描述信息,对于有分组的聚合运算,td是一个拥有两个字段的TupleDesc,以(groupField, aggregateField)的形式,保存原tuple进行分组聚合后每个分组对应的聚合结果,对于没有分组的聚合运算,td只有一个字段来保存聚合结果;而groupMap、avgMap用于保存聚合的结果集,后面进行运算会用到。

下面是构造器,主要是根据传入的参数对以上的属性进行初始化,其中NO_GROUPING是常数-1

public IntegerAggregator(int gbfield, Type gbfieldtype, int afield, Op what) {// some code goes herethis.groupField = gbfield;this.groupFieldType = gbfieldtype;this.aggregateField = afield;this.aggregateOp = what;groupMap = new HashMap<>();avgMap = new HashMap<>();this.td = gbfield != NO_GROUPING ?new TupleDesc(new Type[]{gbfieldtype, Type.INT_TYPE}, new String[]{"gbVal", "aggVal"}): new TupleDesc(new Type[]{Type.INT_TYPE}, new String[]{"aggVal"});}

不管是IntegerAggregator还是StringAggregator,他们的作用都是进行聚合运算(分组可选),所以他们的核心方法在于mergeTupleIntoGroup。IntegerAggregator.mergeTupleIntoGroup(Tuple tup)的实现思路是这样的:

1.根据构造器给定的aggregateField获取在tup中的聚合字段及其值;

2.根据构造器给定的groupField获取tup中的分组字段,如果无需分组,则为null;这里需要检查获取的分组类型是否正确;

3.根据构造器给定的aggregateOp进行分组聚合运算,对于MIN,MAX,COUNT,SUM,我们将结果保存在groupMap中,key是分组字段(如果无需分组则为null),val是聚合结果;对于AVG,我们不能直接进行运算,因为整数的除法是不精确的,我们需要把所以字段值用个list保存起来,当需要获取聚合结果时,再进行计算返回。

下面是具体代码:

    public void mergeTupleIntoGroup(Tuple tup) {// some code goes here//获取聚合字段IntField aField = (IntField) tup.getField(aggregateField);//获取聚合字段的值int value = aField.getValue();//获取分组字段,如果单纯只是聚合,则该字段为nullField gbField = groupField == NO_GROUPING ? null : tup.getField(groupField);if (gbField != null && gbField.getType() != this.groupFieldType && groupFieldType != null) {throw new IllegalArgumentException("Tuple has wrong type");}//根据聚合运算符处理数据switch (aggregateOp) {case MIN:groupMap.put(gbField, Math.min(groupMap.getOrDefault(gbField, value), value));break;case MAX:groupMap.put(gbField, Math.max(groupMap.getOrDefault(gbField, value), value));break;case COUNT:groupMap.put(gbField, groupMap.getOrDefault(gbField, 0) + 1);break;case SUM:groupMap.put(gbField, groupMap.getOrDefault(gbField, 0) + value);break;case AVG:if (!avgMap.containsKey(gbField)) {List<Integer> list = new ArrayList<>();list.add(value);avgMap.put(gbField, list);} else {List<Integer> list = avgMap.get(gbField);list.add(value);avgMap.put(gbField, list);}break;default:throw new IllegalArgumentException("Wrong Operator!");}}

IntegerAggregator的另一个关键的方法是iterator方法,它用于把聚合的结果封装成tuple然后以迭代器的形式返回,它的实现思路是这样的:

1.创建一个list来保存生成的结果;

2.判断运算符的类型;

3.如果是AVG,则需要从avgMap中取每个字段的list,将所有list的值相加并除以总数求出平均值,然后再根据是否分组(groupField == NO_GROUPING)封装到tuple并存入list中

4.如果不是AVG,那么直接从groupMap中取出每个字段的val即可,因为已经在上面聚合过了,直接根据是否分组封装到tuple并存入list即可。

5.遍历完map中的所有field,根据list创建迭代器并返回。

下面是具体实现代码:

public OpIterator iterator() {// some code goes hereArrayList<Tuple> tuples = new ArrayList<>();if(aggregateOp == Op.AVG) {for(Field gField : avgMap.keySet()) {List<Integer> list = avgMap.get(gField);int sum = 0;for(Integer i : list) {sum += i;}int avg = sum / list.size();Tuple tuple = new Tuple(td);if(groupField != NO_GROUPING) {tuple.setField(0, gField);tuple.setField(1, new IntField(avg));} else {System.out.println(tuple + "<====>");tuple.setField(0, new IntField(avg));}tuples.add(tuple);}return new TupleIterator(td, tuples);} else {for(Field gField : groupMap.keySet()) {Tuple tuple = new Tuple(td);if(groupField != NO_GROUPING) {tuple.setField(0, gField);tuple.setField(1, new IntField(groupMap.get(gField)));} else {tuple.setField(0, new IntField(groupMap.get(gField)));}tuples.add(tuple);}return new TupleIterator(td, tuples);}}

Aggregate的实现

上面说到,AVG运算当需要获取聚合结果时,再进行计算返回,那么在哪里会来获取聚合结果呢?在Aggregate中,因为Aggregate是真正暴露给外部执行SQL语句调用的,Aggregate会根据聚合字段的类型来选择具体的聚合器。下面是Aggregate的基本属性及构造器:

    /*** 数据源,从这里读取数据tuple然后用Aggregator进行聚合运算,计算完返回结果*/private OpIterator child;/*** 用于创建聚合器*/private int afield;private int gfield;private Aggregator.Op aop;/*** 聚合结果的元组的描述信息*/private TupleDesc td;/*** 聚合结果形成的迭代器*/private OpIterator it;/*** 聚合器,用于将一条一条的Tuple进行聚合运算,所有tuples运算完返回iterator*/private Aggregator aggregator;public Aggregate(OpIterator child, int afield, int gfield, Aggregator.Op aop) {// some code goes herethis.child = child;this.afield = afield;this.gfield = gfield;this.aop = aop;//NO_GROUPING == -1Type gFieldType = gfield == -1 ? null : child.getTupleDesc().getFieldType(gfield);//根据聚合类型创建聚合器if(this.child.getTupleDesc().getFieldType(afield) == Type.STRING_TYPE) {this.aggregator = new StringAggregator(this.gfield, gFieldType, this.afield, aop);} else {this.aggregator = new IntegerAggregator(this.gfield, gFieldType, this.afield, aop);}this.td = aggregator.getTupleDesc();}

可以看到,创建Aggregate会传入分组字段和聚合字段,Aggregate会有一个聚合器属性,这个聚合器是IntegerAggregator还是StringAggregator是根据聚合属性来创建的。

Aggregate在open函数就对数据源child进行处理,即利用聚合器进行聚合运算,运算完将结果保存到迭代器it中,下面是open的实现代码:

public void open() throws NoSuchElementException, DbException,TransactionAbortedException {// some code goes heresuper.open();child.open();while (child.hasNext()) {Tuple tuple = child.next();aggregator.mergeTupleIntoGroup(tuple);}it = aggregator.iterator();it.open();}

可以看到,open做的事很简单:不断的读取数据源,利用聚合器的mergeTupleIntoGroup进行聚合运算,当所有记录都聚合完成,返回聚合器的iterator,即聚合结果。

而其它的方法都很常规,依据迭代器一般的写法去写即可,下面是fetchNext的实现:

StringAggregator的实现

理解了IntegerAggregator和Aggregate的调用关系,来写StringAggregator就很容易,因为StringAggregator只支持count运算,所以相当于IntegerAggregator的简化版了,下面是mergeTupleIntoGroup的实现:

 public void mergeTupleIntoGroup(Tuple tup) {// some code goes hereStringField aggField = (StringField) tup.getField(aField);String value = aggField.getValue();Field groupField = gbField == NO_GROUPING ? null : tup.getField(gbField);if(groupField != null && gbFieldType != groupField.getType()) {throw new IllegalArgumentException("error gbField type!");}if(aggOp == Op.COUNT) {groupMap.put(groupField, groupMap.getOrDefault(groupField, 0) + 1);} else {throw new IllegalArgumentException("Error Op Type");}}

iterator的实现:

 public OpIterator iterator() {// some code goes hereArrayList<Tuple> tuples = new ArrayList<>();for(Field gField : groupMap.keySet()) {Tuple tuple = new Tuple(this.td);if (this.gbField == NO_GROUPING) {tuple.setField(0, new IntField(groupMap.get(gField)));} else {tuple.setField(0, gField);tuple.setField(1, new IntField(groupMap.get(gField)));}tuples.add(tuple);}return new TupleIterator(td, tuples);}

可以看到基本思路和IntegerAggregator几乎一样。

一条带有聚合函数的分组查询语句是怎样实现的?

实现了上面的东西,这个问题似乎就变得清晰一点了。以下面这条SQL语句为例来讲一下我的理解:

SELECT SUM(fee) AS country_group_total_fee, country FROM member GROUP BY country
这里我们假设fee是第2个字段,country是第1个字段,第0个字段是主键id

0.客户端发起请求,请求消息的有效内容是上述的sql语句(假如我们有客户端和服务端);

1.sql解析器进行解析,得出需要从member表中获取数据,分组字段是country(gbField = 1),聚合字段是fee(aggField = 2),聚合运算符op=SUM;

2.根据member表的id,调用Database.getCatalog().getDatabaseFile(tableid)获取数据表文件HeapFile,调用HeapFile的iterator方法获取所有表记录,即数据源child;

3.根据gbField、aggField、op、child创建Aggregate,Aggregate构造器中会根据gbField、aggField、op创建出聚合器IntegerAggregator、聚合结果元组的描述信息td;

4.调用Aggregate的open方法(这里记住Aggregate本身也是迭代器,open后才能next),在open方法中会不断的从数据源child取出记录,并调用聚合器的mergeTupleIntoGroup进行聚合运算;运算结束后通过聚合器的iterator方法生成结果迭代器it

5.不断从迭代器it取出结果并返回给客户端;

Exercise3:HeapFile Mutability

讲义介绍:

Now, we will begin to implement methods to support modifying tables. We begin at the level of individual pages and
files. There are two main sets of operations: adding tuples and removing tuples.

Removing tuples: To remove a tuple, you will need to implement
deleteTuple. Tuples contain RecordIDs which allow you to find the page they reside on, so this should be as simple
as locating the page a tuple belongs to and modifying the headers of the page appropriately.

Adding tuples: The insertTuple method in
HeapFile.java is responsible for adding a tuple to a heap file. To add a new tuple to a HeapFile, you will have to
find a page with an empty slot. If no such pages exist in the HeapFile, you need to create a new page and append it to
the physical file on disk. You will need to ensure that the RecordID in the tuple is updated correctly.

需要实现的内容:

Implement the remaining skeleton methods in:


  • src/java/simpledb/storage/HeapPage.java
  • src/java/simpledb/storage/HeapFile.java

    (Note that you do not necessarily need to implement writePage at this point).

  • src/java/simpledb/storage/BufferPool.java:
    • insertTuple()
    • deleteTuple()

简单来说,exercise3需要我们实现HeapPage、HeapFile、BufferPool的插入元组和删除元组的方法。

在HeapPage中插入和删除元组

我们要在HeapPage中插入元组,要做的第一件事就是找空槽位然后进行插入,再处理相关细节;我们要在HeapPage删除tuple,首先需要找到tuple在哪个slot,再进行删除即可。

插入元组的思路:找到一个空的slot,然后进行插入,并标记slot已经被使用。代码如下:

public void insertTuple(Tuple t) throws DbException {// some code goes here// not necessary for lab1if (t == null || !td.equals(t.getTupleDesc())) {throw new DbException("the TupleDesc of t is mismatch!--HeapPage");}for (int i = 0; i < numSlots; i++) {if(!isSlotUsed(i) && tuples[i] == null) {markSlotUsed(i, true);RecordId recordId = new RecordId(pid, i);t.setRecordId(recordId);tuples[i] = t;return;}}throw new DbException("the page is full!--HeapPage");}

其中标记slot被使用,需要用到位运算来解决:

 private void markSlotUsed(int i, boolean value) {// some code goes here// not necessary for lab1int ith = i / 8;int mask = 1 << (i % 8);if(value) {header[ith] = (byte) (header[ith] | mask);} else {header[ith] = (byte) (header[ith] & (~mask));}}

删除元组的思路:找到元组对应的slot,标记slot为使用,并将tuples数组对应slot的tuple置为空,具体实现代码:

 public void deleteTuple(Tuple t) throws DbException {// some code goes here// not necessary for lab1int slotId = t.getRecordId().getTupleNumber();if (slotId < 0 || slotId >= tuples.length || tuples[slotId] == null || !isSlotUsed(slotId)) {throw new DbException("slot is already null");}if (!tuples[slotId].equals(t)) {throw new DbException("no exist tuple error!");}markSlotUsed(slotId, false);tuples[slotId] = null;}

在HeapFile中插入和删除元组

实际我们插入和删除元组,都是以HeapFile为入口的,以插入元组为例,HeapFile和HeapPage的调用关系应该是这样的:

1.调用HeapFile的insertTuple

2.HeapFile的insertTuple遍历所有数据页(用BufferPool.getPage()获取,getPage会先从BufferPool再从磁盘获取),然后判断数据页是否有空slot,有的话调用对应有空slot的page的insertTuple方法去插入页面;如果遍历完所有数据页,没有找到空的slot,这时应该在磁盘中创建一个空的数据页,再调用HeapPage的insertTuple方法进行插入

3.插入的页面保存到list中并返回,表明这是脏页,后续会用到。

HeapFile.insertTuple(Tuple tup)实现代码如下:

 public List<Page> insertTuple(TransactionId tid, Tuple t)throws DbException, IOException, TransactionAbortedException {// some code goes here// not necessary for lab1ArrayList<Page> dirtyPages = new ArrayList<>();for (int i = 0; i < numPages(); i++) {//这里pid需要自己去创建PageId pid = new HeapPageId(getId(), i);//需要使用buffer pool来获取页面,getPage方法从BufferPool获取不到会调用HeapFile.getPage()去磁盘的文件找HeapPage page = (HeapPage) Database.getBufferPool().getPage(tid, pid, Permissions.READ_WRITE);//如果没有空的slotif (page.getNumEmptySlots() == 0) continue;page.insertTuple(t);dirtyPages.add(page);return dirtyPages;}//所有页都已经满了BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(new FileOutputStream(file, true));byte[] data = HeapPage.createEmptyPageData();bufferedOutputStream.write(data);bufferedOutputStream.close();//再次写入PageId pid = new HeapPageId(getId(), numPages() - 1);HeapPage page = (HeapPage) Database.getBufferPool().getPage(tid, pid, Permissions.READ_WRITE);page.insertTuple(t);dirtyPages.add(page);return dirtyPages;}

而删除更加简单,只需要根据tuple得到对应的数据页HeapPage,然后调用数据页的deleteTuple即可:

 public ArrayList<Page> deleteTuple(TransactionId tid, Tuple t) throws DbException,TransactionAbortedException, IOException {// some code goes here// not necessary for lab1ArrayList<Page> pages = new ArrayList<>();HeapPage page = (HeapPage) Database.getBufferPool().getPage(tid, t.getRecordId().getPageId(), Permissions.READ_WRITE);if (page == null) throw new DbException("the tuple is not a member of this table");page.deleteTuple(t);pages.add(page);return pages;}

在BufferPool中插入和删除元组

以插入元组为例,BufferPool与HeapFile的调用关系:

1.BufferPool插入元组,会先调用Database.getCatalog().getDatabaseFile(tableId)获取HeapFile即表文件;

2.执行HeapFile.insertTuple(),插入元组并返回插入成功的页面;

3.使用HeapPage的markDirty方法,将返回的页面标记为脏页,并放入缓存池中

实现代码如下:

 public void insertTuple(TransactionId tid, int tableId, Tuple t)throws DbException, IOException, TransactionAbortedException {// some code goes here// not necessary for lab1DbFile file = Database.getCatalog().getDatabaseFile(tableId);List<Page> pages = file.insertTuple(tid, t);//将页面写到缓存中for (Page p : pages) {p.markDirty(true, tid);Page page = pageCache.put(p.getId().hashCode(), p);}}

删除元组也是同样的套路:

 public  void deleteTuple(TransactionId tid, Tuple t)throws DbException, IOException, TransactionAbortedException {// some code goes here// not necessary for lab1DbFile file = Database.getCatalog().getDatabaseFile(t.getRecordId().getPageId().getTableId());List<Page> pages = file.deleteTuple(tid, t);//将页面写到缓存中for (Page p : pages) {p.markDirty(true, tid);//后期需要保证线程安全pageCache.put(p.getId().hashCode(), p);}}

Exercise4:Insertion and deletion

exercise4要求我们实现Insertion and deletion两个操作符,实际上就是两个迭代器,实现方式与exercise1相似,将传入的数据源进行处理,并返回处理结果,而处理并返回结果一般都是写在fetchNext中。这里的处理结果元组,只有一个字段,那就是插入或删除影响的行数,与MySQL相似。具体实现插入和删除,需要调用我们exercise3实现的插入删除元组相关方法。

Insert的实现

基本属性和构造器:

 private static final long serialVersionUID = 1L;private TransactionId tid;/*** 插入数据的数据源*/private OpIterator child;private int tableId;private TupleDesc td;private boolean open;/*** 插入影响的行数*/private int affectRows;/*** 第二次调用返回null。即使是第一次调用但是删除的0行,也要返回0*/private boolean call;public Insert(TransactionId t, OpIterator child, int tableId)throws DbException {// some code goes herethis.tid = t;this.child = child;this.tableId = tableId;this.td = new TupleDesc(new Type[]{Type.INT_TYPE}, new String[]{"affect_rows"});}

可以看到,都是比较常规的属性,包括数据源child、本次插入操作的事务id、插入某个表的tableId等,这里会创建一个td,表示结果元组。

操作符都是继承于Operator抽象类的,Operator抽象类已经把next和hasNext方法写好了,而这两个方法都会调用fetchNext方法,因此我们获取结果元组的实现应该放在fetchNext中实现(除非你不想用fetchNext,那你可以重写next和hasNext方法)。fetchNext主要是调用BufferPool的insertTuple去实现插入的,边插入边统计记录数,最后将记录数封装到tuple中并返回。具体实现代码如下:

    protected Tuple fetchNext() throws TransactionAbortedException, DbException{// some code goes here//如果不是第一次获取next,返回nullif (call) return null;call = true;while (child.hasNext()) {Tuple insert = child.next();try {Database.getBufferPool().insertTuple(tid, tableId, insert);affectRows ++;} catch (IOException e) {e.printStackTrace();break;}}Tuple t = new Tuple(td);t.setField(0, new IntField(affectRows));return t;}

这里要注意的是,我们只能调用一次next来获取批量插入的执行结果,调用第二次应该让它返回null,因为这些记录已经插入过了。

Delete的实现

类似的,Delete操作符的实现也很简单,核心代码如下:

 private static final long serialVersionUID = 1L;private TransactionId tid;private OpIterator child;private TupleDesc td;private boolean open;private int affectRows;private boolean call;public Delete(TransactionId t, OpIterator child) {// some code goes herethis.tid = t;this.child = child;td = new TupleDesc(new Type[]{Type.INT_TYPE}, new String[]{"affect_rows"});}protected Tuple fetchNext() throws TransactionAbortedException, DbException {// some code goes hereif (call) return null;call = true;while (child.hasNext()) {Tuple delete = child.next();try {Database.getBufferPool().deleteTuple(tid, delete);affectRows ++;} catch (IOException e) {e.printStackTrace();}}Tuple tuple = new Tuple(td);tuple.setField(0, new IntField(affectRows));return tuple;}

批量记录是怎样被插入的?

写完exercise4,我们可以开始思考这个问题了,以便将整个过程连贯起来。以下面的SQL语句为例:

INSERT INTO Persons VALUES ('Gates', '18', 'Beijing');
INSERT INTO Persons VALUES ('LIHUA', '21', 'ShangHai');
INSERT INTO Persons VALUES ('ANPU', '30', 'GuangZhou');

0.客户端发起请求,请求消息的有效内容是上述的sql语句(假如我们有客户端和服务端);

1.SQL解析器解析上述语句,并获取要插入的表,记录信息;

2.根据表获取表的id,并将记录信息封装成数据源child(实质是一个迭代器);

3.生成本次批量插入操作的事务id;

4.把tid、tableId、child传入Insert操作符的构造器中,生成Insert对象;

5.调用Insert的hasNext方法,判断是否有结果,因为是第一次调用,hasNext会调用我们写的fetchNext方法,去执行插入操作并获取结果;

6.在fetchNext执行操作的具体步骤是:调用Database.getBufferPool().insertTuple(tid, tuple)方法进行插入,BufferPool的insertTuple会根据tableId从获取数据库文件HeapFile,并调用HeapFile的insertTuple方法;而HeapFile的insertTuple方法会调用BufferPool.getPage()方法从缓冲池取出页面HeapPage(如果缓冲池没有才会从磁盘中取并放入缓冲池);获取HeapPage后,调用HeapPage.insertTuple()方法,去插入元组;插入完成后,HeapFile会返回从BufferPool中获取并插入了元组的页面,在BufferPool的insertTuple中把它标记为脏页并写回缓冲池;

7.整个过程下来,插入的元组并不是真正插入到了磁盘,而是在缓冲池中取出页面插入元组标记脏页并写回缓冲池。

8.上述插入操作全部完成后,我们会得到一个结果元组,将结果处理后返回给客户端即可。

下面是第6步的调用顺序:

Exercise5: Page eviction

exercise5要求我们实现一种BufferPool的页面淘汰策略:

If you did not implement writePage() in
HeapFile.java above, you will also need to do that here. Finally, you should also implement discardPage() to
remove a page from the buffer pool without flushing it to disk. We will not test discardPage()
in this lab, but it will be necessary for future labs.

At this point, your code should pass the EvictionTest system test.

Since we will not be checking for any particular eviction policy, this test works by creating a BufferPool with 16
pages (NOTE: while DEFAULT_PAGES is 50, we are initializing the BufferPool with less!), scanning a file with many more
than 16 pages, and seeing if the memory usage of the JVM increases by more than 5 MB. If you do not implement an
eviction policy correctly, you will not evict enough pages, and will go over the size limitation, thus failing the test.

You have now completed this lab. Good work!

  • 为什么需要页面淘汰策略?

该BufferPool缓冲的最大页面数是50,当我们写入的页面超过50时,需要将暂时不需要的页面从BufferPool中淘汰出去。

  • 采取哪种页面淘汰算法?

常见的有FIFO(先进先出)、LRU(最近最少使用使用)、LFU(最不经常使用),本次实验我采取的是LRU算法来完成页面置换。

LRU的基本思路

LRU的核心思想是:**最近使用的页面数据会在未来一段时期内仍然被使用,已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用。**为什么会有这样的结论,是因为程序的运行具有时间上的局部性和空间上的局部性,时间上的局部性是指某段已经执行过的程序指令可能在不久后会被执行,空间上的局部性是指一旦程序访问了某个存储单元,则不久之后,其附近的存储单元也将被访问。

而我们去实现LRU,就是根据这个思想去写代码的。实现LRU,我们需要一个双向链表和一个HashMap,双向链表相当于一个队列,按淘汰顺序保存各个页面,为了加快页面的获取,而双向链表保证我们能够快速删除一个页面(结点);为了加快获取结点的访问速度,我们还需要一个哈希表来存储key对应的页面(链表结点),而在我们的BufferPool中也是用pageId的hashcode作为key,Page作为value的。我们对页面的访问可以分为get和insert两种:

1.对于get操作,我们需要从hashmap中查找是否存在,如果存在则获取对应的结点,并依据结点在链表中删除并加入队尾;如果不存在返回null;

2.对于insert操作,我们需要从hashmap中查找是否存在,如果存在,我们需要获取对应的结点并从链表中删除结点,并把结点加入队尾;如果不存在,则需要以(key,val)的形式加入哈希表中,并把结点加入链表尾部,如果哈希表的页面数超过了给定的容量,那么需要把链表头部的结点删掉,并根据其key从哈希表中去除。

具体实现代码,以leetcode146.LRU缓存为例,代码如下:

class LRUCache {private int capacity;private DoubleList cache;private Map<Integer, Node> map;public LRUCache(int capacity) {this.capacity = capacity;cache = new DoubleList();map = new HashMap<>();}public int get(int key) {if(!map.containsKey(key)) {return -1;} else {Node node = map.get(key);cache.remove(node);cache.addFirst(node);return node.val;}}public void put(int key, int value) {if(map.containsKey(key)) {Node node = map.get(key);Node input = new Node(key, value);cache.remove(node);cache.addFirst(input);map.put(key, input);} else {Node node = new Node(key, value);if(cache.getSize() == capacity) {Node old = cache.removeLast();map.remove(old.key);map.put(key, node);cache.addFirst(node);} else {map.put(key, node);cache.addFirst(node);}}}
}class Node {Node next, prev;int key, val;public Node(int key, int val) {this.key = key;this.val = val;}
}class DoubleList{private Node head, tail;private int size;public void addFirst(Node node){if(head == null) {head = tail = node;} else {Node h = head;h.prev = node;node.next = head;head = node;}size ++;}public void remove(Node node) {if(node == head && node == tail) {head = tail = null;} else if (node == head) {node.next.prev = null;head = node.next;node.next = null;} else if (tail == node) {tail.prev.next = null;tail = tail.prev;} else {node.prev.next = node.next;node.next.prev = node.prev;}size --;}public Node removeLast() {Node node = tail;remove(tail);return node;}public int getSize() {return size;}
}/*** Your LRUCache object will be instantiated and called as such:* LRUCache obj = new LRUCache(capacity);* int param_1 = obj.get(key);* obj.put(key,value);*/

在BufferPool中实现LRU算法

对于LRU,Java有一种很天然的实现,那就是LinkedHashMap。首先我们看看LinkedHashMap的构造器:

public LinkedHashMap(int initialCapacity,float loadFactor,boolean accessOrder) {super(initialCapacity, loadFactor);this.accessOrder = accessOrder;}

其中,accessOrder可以指定淘汰的顺序是按照访问的顺序还是插入的顺序,如果是true则按照访问的顺序。而我们上述实现的链表,在LinkedHashMap中也有,直接继承于HashMap的Node结点:

LinkedHashMap很天然的为我们实现了LRU算法,考虑到使用自己写的会有很多代码依赖问题,要改的很多,就使用了LinkedHashMap偷一下懒。

使用LinkedHashMap实现LRU,只需要将之前用HashMap来存储缓存的页面换成LinkedHashMap,并在构造器中重写淘汰页面的页面以及其它需要做的事情:

    /*** 存放PageId的hashcode与Page的映射关系*/private final LinkedHashMap<Integer, Page> pageCache;public BufferPool(int numPages) {// some code goes herethis.numPages = numPages;pageCache = new LinkedHashMap<Integer, Page>(pageSize, 0.75f, true){@Overrideprotected boolean removeEldestEntry(Map.Entry<Integer, Page> eldest) {boolean res = size() > numPages;if (res) {//如果需要淘汰页面,需要判断被淘汰的页面是否为脏页面,如果是脏页面则需要刷入磁盘//否则有数据不一致的风险Page page = eldest.getValue();if (page.isDirty() != null) {try {flushPage(page.getId());} catch (IOException e) {e.printStackTrace();}}}return res;}};}

这里注意在淘汰页面时,需要对脏页面进行写回磁盘处理。该exercise还要求我们实现flushPage和flushAllPages方法,将脏页写回磁盘,以下是代码实现:

 private synchronized  void flushPage(PageId pid) throws IOException {// some code goes here// not necessary for lab1Page page = pageCache.get(pid.hashCode());DbFile file = Database.getCatalog().getDatabaseFile(page.getId().getTableId());file.writePage(page);page.markDirty(false, null);}public synchronized void flushAllPages() throws IOException {// some code goes here// not necessary for lab1for(Page page : pageCache.values()) {flushPage(page.getId());}}

三、踩坑记录

踩坑1:HeapFile的迭代器有漏洞

1.exercise3被前面挖的坑卡了好久,测试用例是先往HeapFile交替写入empty和full的page,然后返回迭代器去遍历tuples,测试用例如下:

由于之前写next写的思路是先判断当前页是否有下一条,如果没有就判断取下一个页,然后直接返回it.next(),当时没有加上再次判断it.hasNext()导致迭代器执行next时报错,然后加了个判断:

最后还是没有解决,究其原因是测试用例中添加了两个空数据页,如果第二个数据页不为空这个能过,当时第二个数据页为空,但是后面的数据页不为空,则需要继续往下走,取找到不为空的数据页并返回非空数据页的第一条记录,所以这里应该采用while的方式,即当前数据页为空时,要继续往后找到第一个不为空的数据页,当遍历到结尾时,才退出循环返回null,表示没有下一条tuple了:

踩坑2:BufferPool中的脏页没有写回缓存

2.还是exercise3,在做BufferPool的插入tuple时,由于没有刷新BufferPool的page,导致出错。测试用例一开始插入10个空页,然后分别往10个页面插入一条记录,然后用迭代器取数据并统计条数:

由于迭代器都是先从BufferPool取page,再返回tuple,而BufferPool中的page是有的,只是都是老数据,前面插入tuple都是写入到page后再写到文件中,导致读到的是脏页的数据

正确的做法是把修改完成后的page写到文件后也将脏页写入缓存中:

删除tuple也会返回脏页,所以这里也顺便修改了一下:

踩坑3:Insert实现插入时没有考虑到插入记录数为0的情况

exercise4出现的问题:在写Insert操作符的时候,fetchNext实现思路少考虑了部分情况。

一开始是这样处理的:

实现思路应该是这样的:第一次读取next,遍历数据源的所有tuple并进行插入;第二次读取next,返回null。

但是具体实现的时候我仅仅用判断数据源child是否有数据来推测这是否是第一次插入,这是不可靠的,systemtest有两个用例过不去:

究其原因就是因为要考虑到数据源一条记录都没有的情况;如果第一次插入,但是数据源是没有记录的,这时应该返回affectRows为0的tuple,而不应该是null,所有需要一个Boolean变量call来表示这是否是第一次调用:

修改后就全过了:

Delete修改后也都过了:

四、实验总结

实验二的整体难度要比实验一要大一点,前前后后花了5-6天的时间来写,其中exercise2和exercise3的难度感觉是最大的,因为一开始空空如也,尤其是exercise2,对于没有接触过的,每个属性是什么意思可能都理解不了,后面找了一些分组和聚合的博客看看,稍微有点感觉就可以开始写代码了。整个实验贯穿的还是迭代器相关的思维,甚至很多地方都要我们去写迭代器(或许是作者的偏爱把hhh)。写代码最重要的是要先捋清楚整体的思路,然后再去写,不然效率会很低,这是写了exercise2后的感受。当然,写的过程中也会存在一些细节的疏漏,最后还是被测试用例这个照妖镜测出来了(所以说好的测试代码很关键!!!)。写完这个实验的收获,更多的是以一种简单的思路去理解数据库中的一些操作的简单实现,如分组查询、条件查询、插入和删除操作等,为了写exercise5也顺便把LRU的算法题给做了,真香(虽然上学期操作系统课写过一次了hhh),除了这些,面对一些错误的代码,debug的技巧也很重要,一般打日志会很有用,但打日志的前提是你需要了解整体,然后预测问题可能出现在哪一步,然后就是要有足够的耐心去调代码hh。明天可以开始做lab3了!!!

实验时间:2021.10.03-2021.10.08

报告撰写时间:2021.10.09

MIT6.830 lab2 SimpleDB Operators 实验报告相关推荐

  1. MIT6.830 lab4 SimpleDB Transactions 实验报告

    一.实验预览 lab4要做的是让SimpleDB支持事务,所以实验前需要对事务的基本概念有了解,并知道ACID的特点.lab4是基于严格两阶段封锁协议去实现原子性和隔离性的,所以开始前也需要了解两阶段 ...

  2. MIT6.830 Lab3 Query Optimization 实验报告

    一.实验概览 lab3实现的是基于代价的查询优化器,以下是讲义给出的实验的大纲: Recall that the main idea of a cost-based optimizer is to: ...

  3. MIT6.830 lab2 一个简单数据库实现

    文章目录 前言 一.关于lab2? 二.lab2 1.Exercise 1 2.Exercise 2 3.Exercise 3 4.Exercise 4 5.Exercise 5 总结 前言 上次说要 ...

  4. MIT6.830 lab1 SimpleDb 实验报告

    一.环境搭建 1.课程官网:6.830/6.814: Database Systems 2.Github地址:simple-db-hw-2021 3.安装配置ant 二.实验概览 SimpleDB c ...

  5. MIT6.830 lab6 Rollback and Recovery 实验报告

    一.概览 1.steal/no-force策略 lab6要实现的是simpledb的日志系统,以支持回滚和崩溃恢复:在lab4事务中,我们并没有考虑事务执行过程中,如果机器故障或者停电了数据丢失的问题 ...

  6. MIT6.830 lab5 B+ Tree Index 实验报告

    一.实验概览 lab5主要是实现B+树索引,主要有查询.插入.删除等功能,查询主要根据B+树的特性去递归查找即可,插入要考虑节点的分裂(节点tuples满的时候),删除要考虑节点内元素的重新分配(当一 ...

  7. 软件构造lab2 - 实验报告

    软件构造lab2 - 实验报告 1.实验目标概述 2.环境配置 3.实验过程 3.1Poetic Walks 3.1.1Get the code and prepare Git repository ...

  8. 哈工大2020软件构造Lab2实验报告

    本项目于3.17日实验课验收,请放心参考 参考时文中有给出一些建议,请查看 基本更新完成 2020春计算机学院<软件构造>课程Lab2实验报告 Software Construction ...

  9. MIT 6.828 学习笔记4 Lab2实验报告

    Lab2实验报告 Execrise 1 static void *boot_alloc(uint32_t n) {static char *nextfree;char *result;if (!nex ...

最新文章

  1. 【错误记录】Windows 控制台程序编译报错 ( WINDOWS.H already included. MFC apps must not #include <Windows.h> )
  2. SparkContext转化为JavaSparkContext
  3. flask 安装flask_resultful
  4. .net core精彩实例分享 -- 应用启动
  5. mysql循环map_java Map 遍历速度最优解
  6. 方法大纲_社会工作师(中级)中级实务 考试大纲 附使用方法
  7. 通过Docker Cloud部署应用
  8. 冲刺一团队五亲亲精英队
  9. FF与IE兼容性总结(转载)
  10. 使用keytool转换签名证书格式,keyStore、jks签名证书相互转换
  11. 程序员为什么要学习数据库
  12. 吴永辉教授2021年讲课1-2
  13. iframe(标签的使用)
  14. elementUI:阻止form的enter(回车键)事件
  15. 酷键盘 Midi Keyboard for Mac - MIDI钢琴键盘模拟器
  16. 中国的chatGpt-中国chatGPT软件
  17. java 把文字转成图片_java文本文件转化为图片文件怎么弄?
  18. java addservlet_servlet增删改查
  19. mysql多对多第二范式_【Mysql】第一范式与第二范式
  20. 非最大值抑制(NMS)(一)

热门文章

  1. 基于深度学习的信道估计(DL-CE)基础知识
  2. php 添加透明水印,php加水印的代码(支持半透明透明打水印,支持png透明背景)
  3. redist mysql_redist命令操作(三)--集合Set
  4. 用mysql建立商城数据字典_把mysql数据库生成数据字典,直接可用
  5. 计算机内存不足吃鸡怎么办,Win10玩吃鸡游戏提示虚拟内存不足怎么办?
  6. 2021-08-12
  7. 得天独厚的生态优势_云南农业得天独厚的三大优势
  8. java oracle 视图不存在_java – 获取异常ORA-00942:表或视图不存在 – 插入现有表时...
  9. pads 文本不能修改_修改PDF文件很难?其实很简单,只是你少了一个好用的PDF编辑器...
  10. python import pandas报错找不到_扎心!“我学了半年 Python,还是找不到工作”