几句闲话:08年还是大二的时候,在导师的建议下,读了布朗大学Edward Sciore教授做的一个教学RDBMS,叫simpledb,然后和同学一起照猫画虎实现一个C#版本的,当初只是为了了解RDBMS的实现原理。几年后,电面时几次被问到当初的这个小东西,发现自己竟然有好多细节忘记了。不由生出心思,再读一遍。或许,RDBMS不想以前那么火了,Nosql在大行其道,不过,想起在《Readings in database》里面有一篇论文叫《What goes around, comes around》,复习一些,还是会有些裨益吧。而且,simpledb麻雀虽小,五脏俱全,对于了解RDBMS的底层实现还是挺有益处的。核心代码约6000多行,代价也不算很大了(这次的文章基于的是自己重现的C#版本的代码)。
步入正题了。
simpledb下总共有15个包:buffer, file, index, log, materialize, metadata, multibuffer, opt, parse, planner, query, record, remote, server, tx。
图1 SimpleDB 文件的结构
----------Add on 2012.8.29-----------
偶然翻到之前的资料,看到一张simpledb各个模块的逻辑图,觉得对于了解整个RDBMS的结构颇有益处,就在这里补上
-------------------------------------------
本文先从最底层的存储说起,buffer & file。这里的log也算是底层的:log包内只提供在磁盘块级别对int和string的读写,不关心log的内容表意。但是由于其倒序读写的特殊数据结构,下一篇来单独说它。
file包内的文件结构如下:
图2 file包的类图
> Block
是最基础的磁盘存储单元,也是系统中存取数据的最小单元。所有的数据,都是通过flush到Block上,进而实现的持久化。Block的成员参数包括:所属文件名filename,在所属文件中的编号blknum。Block与文件的关系,可以用下图来示意:
图3 Block与文件的关系
一个文件由整数个Block来组成。Block的编号从0开始。新建一个文件指出,文件是空的,随着系统的需求,向文件中附加一个Block,使文件的大小扩张。
> Page
是系统运行时实际数据的保存位置。一个Page包含一个byte类型的contents数组,系统读写数据的时候,都是先将数据存储到这个contents数组,然后,通过这个数组持久化到一个Block中,或者传递给别的内存对象。通常一个Block的大小为4K,byte数组长度为4096。但是,要注意的是,Page自己,没有绑定某一个Block。
图4 Page与Block的关系
有两点要注意的:
1、读写数据的时候,要考虑到多个线程并发访问同一个文件的情况,为保持一致性,采用lock加锁的机制,实现互斥的访问。threadLock是一个为lock加锁提供的“桩”。第一次写的时候,用的是"lock(this);",这样的话,只是锁住线程自己,没办法做到线程之间的干扰,后来debug时,改成设置一个加锁的“桩”。
2、Page中的read,write,append,并没有真正地实现底层数据的读写,而是通过其包装的一个FileMgr的对象,调用了FileMgr的read,write,append方法。这三个方法,实际是Block与Page之间的操作:将Block中数据独到Page中,将Page中数据写到Block中,将Page中的数据附加到指定的文件中(即写入指定文件的块中)。Page只需要关心在contents数组上,数据的读写:setInt,setString,getInt,getString 就可以了。
> FileMgr
是一个工具类。具体实现了数据在内存与磁盘之间的转移。
数据库的存储结构,在这里可以初见端倪。
dbDirectory是数据库目录名,新建一个数据库 testdb 后,会在指定目录下,创建一个testdb 命名的目录。之后,数据库中的表,都保存在该目录下。
openFiles作为一个字典,保存了文件名与文件流的映射,有点像句柄列表的意思。openFiles维护了当前数据库下打开的文件。
getFile是对openFiles操作的唯一方法。输入是一个文件名,输出时一个文件流。read、write、append都需要通过getFile获取一个文件流。如果文件存在,就打开,返回文件流;如果文件不存在,就创建一个新的文件。getFile包含了文件创建,因而是private的。
FileMgr下有两个公共方法:
size() 返回的是指定的文件中Block的个数。文件中Block的编号从0开始,这里返回的size,对于一个要向文件中新添加的Block来说,也就是Block的编号。size()通常被各种工具类调用,获取文件的结束位置。
isNew()返回数据库文件夹是否创建。在系统初始化的时候被调用。
read、write、append三个方法是在全系统中,真正将内存数据与磁盘数据转化的。首先加锁,然后通过getFile获取指定文件对应的文件流对象fs,再通过fs实现byte[] 与Block的 数据往来。
buffer包内的文件结构如下:
图5 buffer包的类图
Buffer包提供了simpledb自己的一套缓冲机制,实现了自己的缓冲池和缓冲调度算法。
> Buffer
是系统中其他上层的类直接使用的对象,也就是说,Log、Transaction等需要读写数据的时候,首先创建一个Buffer对象,然后,利用这个对象来进行读写。
通过类图可以看到,Buffer中既包含了一个Page对象contents,又包含了一个Block对象blk。联系前面的Page,可以知道Page里面核心是一个byte型数组contents,是具体磁盘块的内存映射,而blk是一个磁盘块对象,这里可以看出,Buffer保持了这种内存数据与磁盘数据的映射。
成员参数中logSequenceNumber初始为-1,Buffer被写入数据的时候被改变,记录该写操作对应日志记录的LSN;modiffiedBy初始为-1,有写入操作的时候被改变,记录该写入操作的事务编号,就是记下被哪个事务修改的。从这两处可以发现,这里只对写入操作做日志,读操作不做任何记录,直接读,也是对系统性能的考虑。
getInt(),getSring()两个方法,直接包装的contents的对应方法,不做赘述,setInt(),setString()包装了contents的对应方法,只是增加了对事务编号,Lsn的记录。由于在Page类中的对应方法都考虑了并发、加锁的问题,所以在Buffer这个级别,直接调用,不用再考虑。
flush()确认当前buffer被修改过后,调用contents的write()方法,将数据写入blk中,同时将modifiedBy复位为-1。注意这里体现了WAL(Write Ahead Log),先调用LogMgr的flush(),将日志写入文件,然后再调用write()将数据写入文件。
assignToBlock(Block b)将指定的Block对象读到当前Buffer中。先调用flush(),保证了如果当前Buffer中含有脏数据,则先将脏数据写入磁盘;然后将blk引用指向目标Block对象,并将该Block的数据读到contents中。
assignToNew(string filename, PageFormatter fmtr) 按照指定的页格式格式化contents对象,并将该Page对象附加到指定的filename文件下。理解这个有点费劲,参看了后面的代码,format方法,实际上是向Page中写入一些实际数据之外的附加信息,如标记位等,这样format后,contents中实际上有内容了,然后调用的append方法,将contents中的数据附加到filename文件中,同时将写入数据所在的磁盘块的引用返回给blk。这样,就明白了。当然,这一套之前,要处理原有buffer中脏数据,如前所述。
总结上上面两个assign*方法:Buffer没有重写构造函数,而且blk定义的时候就被初始化为null,统观整个Buffer类中的代码,对blk的值操作的只有两个assign*方法。而只有assignToNew带有了filename参数,因为显然的事实是Buffer绑定的Block必须属于某一个文件,所以得到的结论是:新打开的文件,最先被调用的assignToNew,绑定文件,取该文件中的一个块到缓冲;之后,依次从文件块中读数据到缓冲的时候,用的是assignToBlock。
最后,来说一下Buffer的pins标记,以及pin()和unpin()方法。
Buffer中的pin(),unpin()方法,只是修改pins的值。通过判断pins的值,来标记Buffer是否被使用。结合了BasicBufferManager来看,通过pin和unpin,管理了缓冲池中的Buffer片,实现了对Buffer的分配和回收。详情留到后面的都BasicBufferMgr中讲。
> BasicBufferMgr
是基本的缓冲调度器,管理缓冲池中Buffer的pin和unpin。不考虑忙等待和任何的调度策略。
BasicBufferMgr维护了一个Buffer数组作为缓冲池,用numAvailable标记当前可用的Buffer个数。
缓冲池作为一种临界资源,需要被互斥地访问。因为申请缓冲片的时候,多个线程同时申请的时候,可用Buffer数依次减少,并发会导致幻影问题出现,所以需要设置threadLock,加锁的“桩”。同时,操作缓冲池bufferPool的方法,都需要用lock加锁,保证互斥访问。
首先看构造函数,输入的参数是numbuffs,即缓冲池中缓冲片的个数。在构造函数中,bufferpool数组被初始化。
再看关于bufferpool的遍历:
findExistingBuffer,输入一个Block兑现的引用,遍历缓冲池的缓冲片,看是否有Buffer已经分配给该Block。
chooseUnpinnedBuffer,遍历整个缓冲池,查看是有有未使用的缓冲片。
这两个方法只是对bufferpool的读操作,没有用lock加锁。为什么读就不加锁了?因为这两个方法是在pin,unpin的内部被调用的,在调用的时候,临界区已经只剩下一个线程在读bufferpool了,故不用再次加锁。
然后是操作bufferpool的几个方法:
pin() 将一个给参数中的Block分配一个Buffer对象。先要加锁。然后调用findingExistingBuffer,查看是否已经该Block对象分配过Buffer。未找到,则调用chooseUnpinnedBuffer(),尝试找一个未使用过的Buffer。如果未找到,则返回null;如果找到,则将该buffer分配给Block;之后修改numAvailable数量,调用Buffer自己的pin,修改Buffer自己的计数器pins。最后,返回设置好的Buffer对象。
pinNew() 与pin()不用之处在于pinNew是从一个新的文件filename中获取一个Block,而pin则是使用输入参数中指定得到Block。如此,pinNew在使用的时候,用的是Buffer的assignToNew方法。缓冲片分配的方法与pin类似。由于是从一个全新的文件中得到Block,所以不存在“查看是否已经该Block对象分配过Buffer”的情况。
unpin()收回指定的缓冲片。加锁后,只涉及Buffer对象自己的pins值的修改,以及numAvailable值的修改。
flushAll() 遍历整个缓冲池bufferpool,将每个缓冲片中的数据都刷写到对应的磁盘块上。
> BufferMgr
是系统中其他模块可以公开访问的缓冲管理器。提供了与BasicBufferMgr相同的方法,只是不同之处在于,增加了请求缓冲片时候的忙等待,使得pin和pinNew不会返回空值。
忙等待机制:如果当前没有可用的缓冲片,请求线程进入一个等待序列,当有可用缓冲片的时候,请求线程从等待队列中移除。请求线程的等待时间有一个阈值,超过阈值之后,系统会跑出一个异常BufferAbortException。
设置的等待时间的阈值是10s,线程等待时间超过了10s,便自动退出。
pin()使用try {} catch(){} 处理异常抛出;最开始,记录时间戳:long timestamp = DateTime.Now.Ticks; 然后,调用BasicBufferMgr的pin()方法,申请缓冲片,如果成功则返回,否则,线程进入忙等待。继续等待的条件有2:A,缓冲片未申请到,B,等待未超时。waitTooLong()方法不停检测是否超时。等待期间,使用了Monitor。Monitor只是为了阻塞当前线程,阻塞最长MAX_TIME的时间。
pinNew()同pin类似,除了忙等待之外,具体实现,参见前面的BasicBufferMgr.pinNew()。
unpin()在释放缓冲片的时候,同时唤醒阻塞线程。
其他方法,基本都是包装的BasicBufferMgr,不再重复。
> PageFormatter
是一个接口,用来初始化一个数据块。只有一个方法,format,如前面提到,format方法就是向文件块(Block)中写入一些辅助信息,将磁盘块格式化成指定的形式。PageFormatter哟两个实现BTPageFormatter,RecordPageFormatter,分别是B+树页面格式化器和数据记录页面格式化器。
> BufferAbortException
是一个异常类,不多赘述。
--
The end。