MIT6.830实现关系型数据库simpleDB

lab1 实现数据管理

主要需要实现存储、访问与管理物理层面的数据(二进制文件),以及将其映射为逻辑层面的数据(关系表)。在这一课题的最后,还要求实现SimpleDB中最基本的操作——SeqScan,因此完成这一章后,就可以扫描全表了。本节内容相对简单。

exercise1

主要是实现Tuple与TupleDesc这两个类的构造器和主要方法,较为简单。

Tuple:包含td,rid,fields三个属性,一条记录最基本的信息,其它的就是一些构造器,getter方法和迭代器等,看着注释实现就可以了。

TupleDesc:TupleDesc主要是需要实现一个TDItem内部类来表示一个字段的描述信息,包括字段类型,字段名;TupleDesc中用一个TDItem数组来表示多个字段的描述信息,有了这个,后面做起来就简单很多了。

exercise2

主要是实现Catalog.java这个类,这里的关键是在Catalog里实现一个Table类,来存放一张表格的信息;然后一个CataLog有多张表格,在Catalog里我们可以用map来存储tableid与table的映射关系;为了方便操作,我还多创建了一个存放table name与 table id映射关系的map

exercise3

无需实现淘汰策略,主要是实现getPage方法。根据PageId来查找BufferPool中有无该数据页,如果有直接返回,如果没有就从磁盘中获取,存入BufferPool中。其中BufferPool的容器是用map来存Page的,以pid的hashcode与Page建立映射关系。

exercise4

主要是实现以下三个类:

HeapPageId.java RecordId.java HeapPage.java
关键是HeapPage的实现,HeapPage中的核心是数据是怎样HeapPage中组织的,这里主要是由header和tuples两个部分组成。其中,一条tuple在HeapPage中表示为一个slot,而header以bitmap的形式来表示第i个slot是否被占用,最低位表示第一个slot是否被占用。由于header是一个byte类型数据,所以最后一个字节并不一定都表示一个slot,也可能无意义

exercise5

主要是实现heapfile,HeapFile是DbFile的实现,是对磁盘文件操作的入口

HeapFile要求我们实现一个readPage方法,根据PageId去从磁盘中读取数据。这里主要的难点是计算偏移量然后读取数据,这里需要先计算出offset,然后用seek移动指针,然后再开始读取.

exercise6

注意是做一个全表扫描,主要验证上一个exercise实现的迭代器的正确性,实现SeqScan即可,其中SeqScan给扫描的表支持别名,然后getTupleDesc需要去修改每个字段的名字,加上别名如a.table

lab2:实现操作符

这一部分主要是实现一系列操作符,包括insert、delete、select、join、group by、order by等。其中,join的高效率实现是一个难点(学习一下常见的算法即可,文末有推荐文章),其他操作符都比较简单。而且group by与order by功能被简化了,也省去了一部分工作量。而在课题最后,则要实现课题一未完成的一个功能——缓存管理。在这里,将要学习并实现缓存机制,包括缓存替换策略(LRU等)。

  1. Nested-Loop Join Algorithms_高爽的博客-CSDN博客
    这是Join算法的优化,建议在第2个课题实现到join操作时看

exercise1

要求我们完成Filter和Join两种操作符

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,是一个迭代器.

Join算法我选择的是BlockNestedLoopJoin

* 传统的BlockNestedLoopJoin只是对左表做缓存,使用Block来减少对左表的访问带来的磁盘IO次数(每一个Page都要带来一次IO访问)
* DoubleBlockNestedJoin是对右表也做缓存处理,后来发现效果并不明显,
* 我一开始认为还是IO瓶颈,所以给HeapFile加了对多个Pages的缓存
* 然而除了对于(左部)大表Join(右部)小表有了一些不明显的效率提高之外,对于大表Join大表仍然效率极低
* 最后发现此时性能瓶颈在于对两个缓存区Block做Join操作,Block虽然比一个大表小,
* 但实际上也不小(测试案例中两个Block是长度为16384和963的数组)
* 在查阅了MergeJoin算法之后,觉得先排序再Join的思想挺好,就将两个Block的Join换成了先排序再Join的版本,得到了bnlSortedJoin

exerciese2

要求我们实现各种聚合运算如count、sum、avg、min、max等,并且聚合器需要拥有分组聚合的功能。讲义告诉我们,我们只需实现根据一个字段去分组和聚合,也就是只有一个分组字段和一个聚合字段。

exercise3

需要我们实现HeapPage、HeapFile、BufferPool的插入元组和删除元组的方法。

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

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

在BufferPool中插入和删除元组
以插入元组为例,BufferPool与HeapFile的调用关系:

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

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

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

Exercise4:Insertion and deletion

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

批量记录是怎样被插入的?
写完exercise4,我们可以开始思考这个问题了,以便将整个过程连贯起来。
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

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

LRU

lab3:实现基于代价的查询优化器

我们可以使用表的统计数据去估计不同查询计划的代价。通常,查询计划的代价与中间进行连接和选择产生的记录数的基数有关,以及过滤和连接的选择性。

通过这些统计信息,我们可以选择最佳的连接和选择顺序,从多个查询方案中选择一个最佳的计划去执行。

简单总结一下查询优化器的构成:

1.Parser.Java在初始化时会收集并构造所有表格的统计信息,并存到statsMap中。当有查询请求发送到Parser中时,会调用parseQuery方法去处理‘

2.parseQuery方法会把解析器解析后的结果去构造出一个LogicalPlan实例,然后调用LogicalPlan实例的physicalPlan方法去执行,然后返回的是结果记录的迭代器,也就是我们在lab2中做的东西都会在physicalPlan中会被调用。

Exercise 1: IntHistogram.java

想要估计查询计划的代价,首先是得有数据。那么数据是怎么从table中获取,以怎样的形式收集呢?这里用到了直方图。

简单来讲,一个直方图用于表示一个字段的统计信息,直方图将字段的值分为多个相同的区间,并统计每个区间的记录数,每个区间可以看做是一个桶,单个区间的范围大小看成桶的宽,记录数看成桶的宽,可以说是非常的形象:

 exercise1要做的就是根据给定的数据去构造出这样的直方图,然后是根据直方图的统计信息去估算某个值的选择性(selectivity)

对于等值运算f = const,我们要利用直方图估计一个等值表达式f = const的选择性,首先需要计算出包含该const值的桶,然后进行计算:选择性 =( 桶高/桶宽)/ 记录数,也就是 (h / w) / ntups。其中 *(h/w)*我们可以容易理解到这是符合f = const的记录数,然后去除以总记录数,就可以得到该等值运算对表格记录的选择性了。
对于非等值运算,我们采用的也是同样的思想:找出这个范围的记录数,然后除以总记录数。以下的直方图给出了如何计算一个f > const的过程

Exercise 2: TableStats.java

exercise2要做的是根据给定的tableid,扫描出所有记录,并对每一个字段建立一个直方图。

Exercise 3: Join Cost Estimation

exercise3要做的是估计连接查询的代价

Implement
estimateJoinCost(LogicalJoinNode j, int card1, int card2, double
cost1, double cost2): This method estimates the cost of
join j, given that the left input is of cardinality card1, the
right input of cardinality card2, that the cost to scan the left
input is cost1, and that the cost to access the right input is
card2. You can assume the join is an NL join, and apply
the formula mentioned earlier.

做完这个之后,该exercise的另一个任务就是估计连接产生的基数(cardinality),也就是产生结果的记录数。计算选择性的公式为:基数/总记录数。

Finally, observe that the cost for the join plan p above
includes expressions of the form joincost((t1 join t2) join
t3). To evaluate this expression, you need some way to estimate
the size (ntups) of t1 join t2. This join
cardinality estimation problem is harder than the filter selectivity
estimation problem. In this lab, you aren’t required to do anything
fancy for this, though one of the optional excercises in Section 2.4
includes a histogram-based method for join selectivity estimation.

While implementing your simple solution, you should keep in mind the following:

For equality joins, when one of the attributes is a primary key, the number of tuples produced by the join cannot
be larger than the cardinality of the non-primary key attribute.(对于等值连接,如果有一个是主键,则连接生成的主键数不能比非主键字段的基数大,也就是结果记录数要小于等于非主键字段记录数)

For equality joins when there is no primary key, it’s hard to say much about what the size of the output
is – it could be the size of the product of the cardinalities of the tables (if both tables have the
same value for all tuples) – or it could be 0. It’s fine to make up a simple heuristic (say,
the size of the larger of the two tables).

(参与连接的字段没有主键,则结果记录数很难确定,可以很大也可以很小)

For range scans, it is similarly hard to say anything accurate about sizes.
The size of the output should be proportional to
the sizes of the inputs. It is fine to assume that a fixed fraction
of the cross-product is emitted by range scans (say, 30%). In general, the cost of a range
join should be larger than the cost of a non-primary key equality join of two tables
of the same size.

通常,范围联接的代价应该大于相同大小的两个表的非主键相等联接的代价。

Exercise 4: Join Ordering
exercise3我们完成了连接查询的成本估计与基数估计,而exercise4我们要做的是根据在多表连接的情况下,去选择一个最优的连接顺序,来实现对连接查询的优化。有了这个连接顺序就可以生成执行计划了。

本实验采用的是一种基于动态规划的查询计划生成。我们的思路是这样的,先找出部分表的最优连接顺序,然后固定这些表的顺序,然后去连接其它表,这样也可以达到最优。

核心是:分割成两个子集,然后选出两个子集中各自最佳的连接顺序,最后两个子集的最优结果去确定连接顺序。

为了实现这个算法,exercise4给我们提供了两个很好用的方法:

1.enumerateSubsets(List v, int size):分割子集,给定集合和分割大小,返回指定大小子集的所有可能

该方法可以进行优化,对作者给的方法进行优化,作者用的是Set加双层for循环遍历,效率十分垃圾。可以改用回溯来求全排列。

2.computeCostAndCardOfSubplan:计算子计划的代价

lab4:lab4要做的是让SimpleDB支持事务

所以实验前需要对事务的基本概念有了解,并知道ACID的特点。lab4是基于严格两阶段封锁协议去实现原子性和隔离性的,所以开始前也需要了解两阶段封锁协议是如何实现事务的。对于一致性和持久性,这里假设暂时不会发送断电等异常,所以暂时不需要崩溃恢复,不需要undo log从,后面lab6会有专门的崩溃恢复的解决方案。

两阶段封锁协议
首先是封锁协议:我们将要求在系统中的每一个事务遵从封锁协议,封锁协议的一组规则规定事务何时可以对数据项们进行加锁、解锁。

对于两阶段封锁协议:两阶段封锁协议要求每个事务分两个节点提出加锁和解锁申请:

增长阶段:事务可以获得锁,但不能释放锁;
缩减阶段:事务可以释放锁,但不能获得新锁。
最初,事务处于增长阶段,事务根据需要获得锁。一旦该事务释放了锁,它就进入了缩减阶段,并且不能再发出加锁请求。

严格两阶段封锁协议不仅要求封锁是两阶段,还要求事务持有的所有排他锁必须在事务提交后方可释放。这个要求保证未提交事务所写的任何数据在该事务提交之前均已排他方式加锁,防止了其他事务读这些数据。

强两阶段封锁协议。它要求事务提交之前不释放任何锁。在该条件下,事务可以按其提交的顺序串行化。

锁转换:在两阶段封锁协议中,我们允许进行锁转换。我们用升级表示从共享到排他的转换,用降级表示从排他到共享的转换。锁升级只能发送在增长阶段,锁降级只能发生在缩减阶段。

exercise1

需要做的是在getPage获取数据页前进行加锁,这里我们使用一个LockManager来实现对锁的管理,LockManager中主要有申请锁、释放锁、查看指定数据页的指定事务是否有锁这三个功能,其中加锁的逻辑比较麻烦,需要基于严格两阶段封锁协议去实现。事务t对指定的页面加锁时,思路如下:

* 加读锁
* 如果tid已经在pid上有读锁,返回true
* 如果tid在pid上已经有写锁,或者没有锁但条件允许tid给pid加读锁,则加锁后返回true
* 如果tid此时不能给pid加读锁,返回false
当前页只有一个锁,判断是否为自己的锁,如果是读锁,直接返回否则加锁再返回,若是别人的读锁,加锁再返回,是写锁则需要等待.
//多个锁有四种情况
// 1.两个锁,且都属于tid即自己的锁(一读一写)->直接返回2.两个锁,且都属于非tid的事务(一读一写)->等待锁释放
// 3.多个读锁,且其中有一个为tid的读锁->直接返回4.多个读锁,且没有tid的读锁->加锁
* 加写锁
* 如果tid已经在pid上有写锁,则返回true
* 如果仅tid拥有pid的读锁,或tid在pid上没有锁但条件允许tid给pid加写锁,则加锁后返回true
* 如果tid此时不能给pid加写锁,返回false
//多个锁有三种情况,只有第一种情况返回true,其余返回wait
// 1.两个锁,且都属于tid(一读一写) 2.两个锁,且都属于非tid的事务(一读一写) 3.多个读锁

Exercise2 Lock Lifetime

exercise2主要是要让我们考虑什么时候要加锁,什么时候要解锁,其实和exercise1是连成一块的。

Ensure that you acquire and release locks throughout SimpleDB. Some (but
not necessarily all) actions that you should verify work properly:

Reading tuples off of pages during a SeqScan (if you
implemented locking in BufferPool.getPage(), this should work
correctly as long as your HeapFile.iterator() uses
BufferPool.getPage().)
Inserting and deleting tuples through BufferPool and HeapFile
methods (if you
implemented locking in BufferPool.getPage(), this should work
correctly as long as HeapFile.insertTuple() and
HeapFile.deleteTuple() use
BufferPool.getPage().)
You will also want to think especially hard about acquiring and releasing
locks in the following situations:

Adding a new page to a HeapFile. When do you physically
write the page to disk? Are there race conditions with other transactions
(on other threads) that might need special attention at the HeapFile level,
regardless of page-level locking?
Looking for an empty slot into which you can insert tuples.
Most implementations scan pages looking for an empty
slot, and will need a READ_ONLY lock to do this. Surprisingly, however,
if a transaction t finds no free slot on a page p, t may immediately release the lock on p.
Although this apparently contradicts the rules of two-phase locking, it is ok because
t did not use any data from the page, such that a concurrent transaction t’ which updated
p cannot possibly effect the answer or outcome of t

当我们要插入一个元组时,会去找到一个有空的slot的page,但是当我们获取的page没有空的slot了,我们应该立即释放在这个page的锁,即使这样会不符合严格二阶段封锁协议,但后续我们不会再使用到这个page了,所以并没有影响,这样能够让其它事务可以访问该page

Exercise3 Implementing NO STEAL

Modifications from a transaction are written to disk only after it
commits. This means we can abort a transaction by discarding the dirty
pages and rereading them from disk. Thus, we must not evict dirty
pages. This policy is called NO STEAL.

You will need to modify the evictPage method in BufferPool.
In particular, it must never evict a dirty page. If your eviction policy prefers a dirty page
for eviction, you will have to find a way to evict an alternative
page. In the case where all pages in the buffer pool are dirty, you
should throw a DbException. If your eviction policy evicts a clean page, be
mindful of any locks transactions may already hold to the evicted page and handle them
appropriately in your implementation.

前面我们提到,为了支持原子性,我们对脏页的处理是在事务提交时才写入磁盘,或者事务中断时将脏页恢复成磁盘文件原来的样子。在之前我们实现的LRU缓存淘汰策略中,我们并没有对脏页加以区分。exercise4要我们在淘汰数据页时不能淘汰脏页,如果bufferpool全部是脏页则抛出异常,我们只需要修改淘汰页面时的代码

Exercise4 Transactions

SimpleDB是如何实现事务的?

在SimpleDB中,每个事务都会有一个Transaction对象,我们用TransactionId来唯一标识一个事务,TransactionId在Transaction对象创建时自动获取。事务开始前,会创建一个Transaction对象,后续的操作会通过传递TransactionId对象去进行,加锁时根据加锁页面、锁的类型、加锁的事务id去进行加锁。当事务完成时,调用transactionComplete去完成最后的处理。transactionComplete会根据成功还是失败去分别处理,如果成功,会将事务id对应的脏页写到磁盘中,如果失败,会将事务id对应的脏页淘汰出bufferpool或者从磁盘中获取原来的数据页。脏页处理完成后,会释放事务id在所有数据页中加的锁。

Exercise5 Deadlocks and Aborts

循环等待图检测。我们可以建立事务等待关系的等待图,当等待图出现了环时,说明有死锁发生,在加锁前就进行死锁检测,如果本次加锁请求会导致死锁,就终止该事务.

lab5

主要是实现B+树索引,主要有查询、插入、删除等功能,查询主要根据B+树的特性去递归查找即可,插入要考虑节点的分裂(节点tuples满的时候),删除要考虑节点内元素的重新分配(当一个页面比较空,相邻页面比较满的时候),兄弟节点的合并(当相邻两个页面的元素都比较空的时候)

实验前,需要理清整个B+树的结构。B+的页面节点类型主要有四种:

1.根节点页面:一个B+树的根节点,在SimpleDB中实现为BTreeRootPtrPage.java;

2.内部节点页面:除去根节点和叶子节点外的节点,在SimpleDB中实现为BTreeInternalPage,每个BTreeInternalPage由一个一个的entry组成;

3.叶子节点页面:存储tuple的叶子节点,在SimpleDB中实现为BTreeLeafPage;

4.头部节点页面:用于记录整个B+树中的一个页面的使用情况,在SimpleDB中实现为BTreeHeaderPage。

Exercise1 Search

给定一个field和一个page,要从这个page往下递归找到tuple在的叶子节点。

这部分主要根据讲义的提示来做,主要实现思路如下:

1.获取数据页类型;

2.判断该数据页是否为叶子节点,如果是则递归结束,将该页面返回;

3.如果不是则说明该页面是内部节点,将页面进行类型转换;

4.获取内部节点的迭代器;

5.对内部节点的entry进行迭代,这里要主要field是空的处理,如果是空直接找到最左的叶子页面即可;

6.找到第一个大于(或等于)filed的entry,然后递归其左孩子;

7.如果到了最后一个页面,则递归其右孩子;

B+树索引查找的过程:

1.创建运算符,因为该B+树只支持单列索引,运算符只有大于,小于,等于,大于等于,小于等于,不等于
2.调用BTreeFile的indexIterator方法获取查找结果,indexIterator方法是会创建BTreeSearchIterator
3.在需要获取查找结果时,会调用BTreeSearchIterator的open和getnext方法来获取查询的结果

4.首先是open,开启迭代器。首先是getPage获取页面,这里会加锁,然后第一次调用会从BTreeFile.getPage()获取根节点,因为写入文件时根节点是按内部节点的类型去写的,然后每个根节点有9个entry,第一次遍历实际上是遍历了根节点的9个entry然后往下查找,当然这里只是找出了叶子节点页面并创建了迭代器,真正的查找在下一步。
5.然后是要获取结果时,调用迭代器的readNext,然后会根据运算符就获取结果,这里迭代的时候是对一个leaf page的所有元组进行迭代,然后筛选出满足运算符的结果,比如说是age > 18这个条件,会先找到最后一个小于18的entry,然后获取entry的左孩子得到leaf page,然后在leaf page中迭代找到age > 18的元组,如果该leaf page 遍历完了,会一直往右兄弟的方向找下一个页面的元组,因为多个leaf page之间就是双向链表。

Exercise2 Insert

exercise2要做的是分裂叶子节点和分裂内部节点两个方法的实现。

分裂叶子节点的思路:

1.新建一个leaf page,作为新的页面;

2.将满页面的元组复制到新页面,边复制边删除;

3.检查之前的满页面是否有右兄弟,有的话需要更新指针,这里有点像在双向链表中插入一个结点,一开始没有考虑到,后面测试用例过不了重新整理思路才发现要更新这个指针;

4.更新脏页;

5.更新兄弟指针;

6.找出父节点并创建entry进行插入,最后更新脏页;

7.根据field找出要插入的页面并返回

分裂内部节点的思路:

1.新建一个internal page,作为新的页面;

2.将满页面的entry复制到新页面,边复制边删除;

3.将中间节点挤出去;这里与leaf page不同,要注意,其实看图示就能发现不同之处了;

4.更新脏页;

5.更新左右孩子指针;

6.更新左右叶面的孩子指针,因为前面有大量的entry插入和移除;

7.根据中间节点获取父节点,将midEntry插入到父节点中,并更新脏页和指针;

8.根据field找出要插入的页面并返回;

Exercise 3 Delete


删除的话有两种情况,一种是兄弟页面比较满,自己因为删除一些tuple或者entry比较空,这时可以从兄弟页面拿一些元素过来,这样兄弟页面可以不用那么早去分裂页面,自己也可以达到元素比较多,这个对应于exercise3要做的东西;另一种情况是两个页面都是比较空的时候,这个时候需要考虑将两个页面合并成一个,以达到节省空间的目的

 

stealFromLeafPage Implement
* Steal tuples from a sibling and copy them to the given page so that both pages are at least
* half full.  Update the parent's entry so that the key matches the key field of the first
* tuple in the right-hand page.
// Move some of the tuples from the sibling to the page so
// that the tuples are evenly distributed. Be sure to update
// the corresponding parent entry.
stealFromLeftInternalPage Implement

* Steal entries from the left sibling and copy them to the given page so that both pages are at least
* half full. Keys can be thought of as rotating through the parent entry, so the original key in the 
* parent is "pulled down" to the right-hand page, and the last key in the left-hand page is "pushed up"
* to the parent.  Update parent pointers as needed.
// Move some of the entries from the left sibling to the page so
// that the entries are evenly distributed. Be sure to update
// the corresponding parent entry. Be sure to update the parent
// pointers of all children in the entries that were moved.

Exercise4 实现两个页面的合并

mergeLeafPages Implement
* Merge two leaf pages by moving all tuples from the right page to the left page. 
* Delete the corresponding key and right child pointer from the parent, and recursively 
* handle the case when the parent gets below minimum occupancy.
* Update sibling pointers as needed, and make the right page available for reuse.
// Move all the tuples from the right page to the left page, update
// the sibling pointers, and make the right page available for reuse.
// Delete the entry in the parent corresponding to the two pages that are merging -
// deleteParentEntry() will be useful here

 lab6 Rollback and Recovery:

lab6要实现的是simpledb的日志系统,以支持回滚和崩溃恢复;在lab4事务中,我们并没有考虑事务执行过程中,如果机器故障或者停电了数据丢失的问题,bufferpool采用的是no-steal/force的策略,而这个实验我们实现的是steal/no-force策略,两种策略的区别如下:

steal/no-steal: 是否允许一个uncommitted的事务将修改更新到磁盘,如果是steal策略,那么此时磁盘上就可能包含uncommitted的数据,因此系统需要记录undo log,以防事务abort时进行回滚(roll-back)。如果是no steal策略,就表示磁盘上不会存在uncommitted数据,因此无需回滚操作,也就无需记录undo log。
force/no-force:force策略表示事务在committed之后必须将所有更新立刻持久化到磁盘,这样会导致磁盘发生很多小的写操作(更可能是随机写)。no-force表示事务在committed之后可以不立即持久化到磁盘, 这样可以缓存很多的更新批量持久化到磁盘,这样可以降低磁盘操作次数(提升顺序写),但是如果committed之后发生crash,那么此时已经committed的事务数据将会丢失(因为还没有持久化到磁盘),因此系统需要记录redo log,在系统重启时候进行前滚(roll-forward)操作。

为了支持steal/no-force策略,即我们可以将未提交事务的数据更新到磁盘,也不必在事务提交时就一定将修改的数据刷入磁盘,我们需要用日志来记录一些修改的行为。在simpledb中,日志不区分redo log和undo log,格式较为简单,也不会记录事务执行过程中对记录的具体修改行为。

对于redo log,为确保事务的持久性,redo log需要事务操作的变化,simpledb中用UPDATE格式的日志来保存数据的变化,在每次将数据页写入磁盘前需要用logWrite方法来记录变化.
对于这些脏页,即使断电丢失数据了,我们也可以通过事务id来判断事务是否已经提交(这里提交事务会记录另一种格式的日志),如果事务已经提交,则重启时根据日志的内容就可以把数据恢复了;总而言之,通过这样的方式,可以让simpledb支持崩溃恢复;

对于undo log,我们采用的是对heappage中保存一份旧数据.数据页一开始的旧数据是空的,那什么时候会对旧数据进行更新呢?答案是事务提交时,当事务提交时,就意味着这个修改已经是持久化到磁盘了,新的事务修改后就数据页的数据就是脏数据了,而在新事务回滚时,由于我们采用的是steal策略,脏页可能已经在页面淘汰时被写入磁盘中了,那么该如何进行恢复呢?答案是before-image,即oldData,通过上一次成功事务的数据,我们可以恢复到事务开始前的样子,这样,就可以实现了事务的回滚了。

simpledb的日志记录一共有5种:ABORT, COMMIT, UPDATE, BEGIN, and CHECKPOINT,分别记录事务失败、事务提交、写入磁盘前的脏页、事务开始、检测点,这些格式的日志都记录在同一个日志文件中;日志文件以及每条日志的通用格式如下:

对于ABORT, COMMIT, and BEGIN这三种,中间的content是空的;对于UPDATE格式的记录,有两部分组成,即before image和after image,分别记录修改前和修改后的日志;事务提交失败回滚我们会用到before image,事务提交成功但数据由于故障丢失数据我们会用到after image;对于CHECKPOINT 记录,主要记录在checkpoint点活跃的事务数,以及每个事务的的事务id和第一条日志记录的偏移量;

其中checkpoint可以说是整个日志文件的核心,在崩溃恢复时很有用;在崩溃恢复时,我们会读取到checkpoint所在的位置,在checkpoint之前的修改已经是刷入磁盘的,除非磁盘坏了否则就是永久不会丢失的;对于checkpoint之后的日志,我们只保证修改持久化到日志,但未保证将日志记录的内容持久化到磁盘,因此崩溃恢复时,我们需要从checkpoint开始往后读,然后根据日志记录进行恢复。

Exercise1 Rollback


rollback是undo log做的事,即提供上一个版本的快照(相比MVCC真是微不足道),在回滚时将上一个版本的数据写回磁盘,思路比较简单:

1.根据tidToFirstLogRecord获取该事务第一条记录的位置;

2.移动到日志开始的地方;

3.根据日志格式进行读取日志记录,读到update格式的记录时根据事务id判断是否为要修改的日志,如果是,写before image

Exercise2 Recovery


崩溃恢复是redo log要做的事,在因故障数据丢失时,有部分数据是还未写入数据库的,这个时候可以利用到undo log。从日志文件中,我们可以获取到checkpoint所在位置,然后对checkpoint后面的日志记录进行读取并进行恢复数据。

1.对于未提交的事务:使用before-image对其进行恢复;

2.对于已提交的事务:使用after-image对其进行恢复;

面试常见问题:

B+树索引为什么会加快查询速度?

BTree索引

BTree又叫多路平衡查找树,一颗m叉的BTree特性如下:

  • 树中每个节点最多包含m个孩子。

  • 除根节点与叶子节点外,每个节点至少有[ceil(m/2)]个孩子(ceil()为向上取整)。

  • 若根节点不是叶子节点,则至少有两个孩子。

  • 所有的叶子节点都在同一层。

  • 每个非叶子节点由n个key与n+1个指针组成,其中[ceil(m/2)-1] <= n <= m-1 。

这是一个3叉(只是举例,真实会有很多叉)的BTree结构图,每一个方框块我们称之为一个磁盘块或者叫做一个block块,这是操作系统一次IO往内存中读的内容,一个块对应四个扇区,紫色代表的是磁盘块中的数据key,黄色代表的是数据data,蓝色代表的是指针p,指向下一个磁盘块的位置。

来模拟下查找key为29的data的过程:

1、根据根结点指针读取文件目录的根磁盘块1。【磁盘IO操作1次

2、磁盘块1存储17,35和三个指针数据。我们发现17<29<35,因此我们找到指针p2。

3、根据p2指针,我们定位并读取磁盘块3。【磁盘IO操作2次

4、磁盘块3存储26,30和三个指针数据。我们发现26<29<30,因此我们找到指针p2。

5、根据p2指针,我们定位并读取磁盘块8。【磁盘IO操作3次

6、磁盘块8中存储28,29。我们找到29,获取29所对应的数据data。

由此可见,BTree索引使每次磁盘I/O取到内存的数据都发挥了作用,从而提高了查询效率。

但是有没有什么可优化的地方呢?

我们从图上可以看到,每个节点中不仅包含数据的key值,还有data值。而每一个页的存储空间是有限的,如果data数据较大时将会导致每个节点(即一个页)能存储的key的数量很小,当存储的数据量很大时同样会导致B-Tree的深度较大,增大查询时的磁盘I/O次数,进而影响查询效率。 

B+Tree是在B-Tree基础上的一种优化,使其更适合实现外存储索引结构。在B+Tree中,所有数据记录节点都是按照键值大小顺序存放在同一层的叶子节点上,而非叶子节点上只存储key值信息,这样可以大大加大每个节点存储的key值数量,降低B+Tree的高度。

InnoDB存储引擎中页的大小为16KB,一般表的主键类型为INT(占用4个字节)或BIGINT(占用8个字节),指针类型也一般为4或8个字节,也就是说一个页(B+Tree中的一个节点)中大概存储16KB/(8B+8B)=1K个键值(因为是估值,为方便计算,这里的K取值为〖10〗^3)。

也就是说一个深度为3的B+Tree索引可以维护10^3 * 10^3 * 10^3 = 10亿 条记录。(这种计算方式存在误差,而且没有计算叶子节点,如果计算叶子节点其实是深度为4了)

我们只需要进行三次的IO操作就可以从10亿条数据中找到我们想要的数据

而且在B+Tree上通常有两个头指针,一个指向根节点,另一个指向关键字最小的叶子节点,而且所有叶子节点(即数据节点)之间是一种链式环结构。所以我们除了可以对B+Tree进行主键的范围查找和分页查找,还可以从根节点开始,进行随机查找。

数据库中的B+Tree索引可以分为聚集索引(clustered index)和辅助索引(secondary index)。

上面的B+Tree示例图在数据库中的实现即为聚集索引,聚集索引的B+Tree中的叶子节点存放的是整张表的行记录数据,辅助索引与聚集索引的区别在于辅助索引的叶子节点并不包含行记录的全部数据,而是存储相应行数据的聚集索引键,即主键。

当通过辅助索引来查询数据时,InnoDB存储引擎会遍历辅助索引找到主键,然后再通过主键在聚集索引中找到完整的行记录数据

不过,虽然索引可以加快查询速度,提高 MySQL 的处理性能,但是过多地使用索引也会造成以下弊端

  • 创建索引和维护索引要耗费时间,这种时间随着数据量的增加而增加。

  • 除了数据表占数据空间之外,每一个索引还要占一定的物理空间。如果要建立聚簇索引,那么需要的空间就会更大。

  • 当对表中的数据进行增加、删除和修改的时候,索引也要动态地维护,这样就降低了数据的维护速度。

注意:索引可以在一些情况下加速查询,但是在某些情况下,会降低效率。

索引只是提高效率的一个因素,因此在建立索引的时候应该遵循以下原则:

  • 在经常需要搜索的列上建立索引,可以加快搜索的速度。

  • 在作为主键的列上创建索引,强制该列的唯一性,并组织表中数据的排列结构。

  • 在经常使用表连接的列上创建索引,这些列主要是一些外键,可以加快表连接的速度。

  • 在经常需要根据范围进行搜索的列上创建索引,因为索引已经排序,所以其指定的范围是连续的。

  • 在经常需要排序的列上创建索引,因为索引已经排序,所以查询时可以利用索引的排序,加快排序查询。

  • 在经常使用 WHERE 子句的列上创建索引,加快条件的判断速度。

参考自:Mysql索引——B+树是怎么提高查询效率?_一蓑烟雨任平生-CSDN博客_b+树查询效率

 项目地址:Zzzzs1/z2zzSimpleDB (github.com)

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值