Database
本文主要讲述cfengDB中Data Manager层的设计与实现
上文已经实现了TM事务管理器,而DM则是整个db中比较基础且重要的模块,主要负责进行数据的管理存储操作
cfengDB中最基础的就是data manager,从字面意思理解就是直接管理数据库Data文件和日志文件。
DM就是对于文件系统的抽象,向下直接读写db文件,向上提供数据封装, 将文件抽象为DataItem供上层使用
从之前的事务管理部分就可以看到,起始db的正确安全运行需要定义不同的规则,特别是针对这种文件操作,对于文件的存储的方式,需要设定一定的规则,能够进行数据校验,保证数据的准确性
比如在事务管理文件中, 定义的存储规则如下:
TID文件中:(long — 8byte)
8字节的header(代表tid数目) tid1状态 tid2状态(1byte) …
同时规定no Transaction tid0默认就是committed状态, 所以依照规则就可以计算各个tid的offset
在本数据管理模块: 底层操作的是db文件和log文件, 定义的规则如下:
db文件是所有数据直接存放的位置,整个文件由不同的页面组成,每页大小8KB,页号从1开始:
db文件:
page1(8KB) page2 …
对于每一个页面: 区分为两种页面: page1首页不存储数据,只是用作校验页,启动时随机8byte序列放到100—107位置, 正常结束时候复制到108-115位置
PageFirst: 首页启动运行检查
OFFSET_RUN_CONTROL = 100
… 8byte 启动随机序列 8byte 结束复制序列…
对于普通的存储数据的页面, 起始有2byte的FSO,代表当前页面的free space offset, cfengDB整体设计上采用逻辑删除, 所以就不存在删除之后的各种空间寻址问题和空间整理问题, 不需要考虑使用已经使用过的空间
PageCommon (short ---- 2byte)
FSO(2byte) data数据…
MAX_FREE_SPACE = PageCacheConstant.PAGE_SIZE - OFFSET_DATA; 【实际使用的为8KB - 2byte】
对于普通页面中存储的数据,更加抽象为DataItem单元,也即是pageCommon的data部分:
DataItem
valid(1byte) size(2bit) data
valid代表的是数据有效位: 0代表有效, 逻辑删除就是将对应的dataItem的有效位变为1
size代表后面的数据data的长度
log文件主要是记录系统的日志,可用于操作日志查看以及故障恢复DataRecover, 日志文件整体组成:
日志Log文件
TChecksum(4byte) [log1] … [badTail] ;
badTail就是组成的数据不是一条日志
Tchecksum 整体上就是对Tchecksum后面的所有日志去除badTail的部分计算即可
而对于日志文件中每一条日志也需要判断, 保证正确去除badTail
对于log中每一条日志:比如上面的log1
【Size(4byte)】[checksum(4byte)]【data数据…
size就是data段的byte数,而每一条日志的checksum 为 data部分的数据的校验和
日志的遍历设计为迭代器模式,依靠的position进行迭代,迭代的时候需要校验size,checksum…
由于cfengDB只是支持逻辑删除,所以这里的日志主要针对的插入和更新两类日志
这里解释的日志对应的就是上面的每条日志的data部分
插入日志:
logType(1byte) Tid(8byte) PageNo(4byte) offset(2byte) raw…
logType代表种类: 0 – insert 1— update
tid代表该次操作的事务id
pageNo代表数据插入的页面
offset 代表页内偏移 raw就是插入的数据
更新日志所需内容更多, 但是将pageNo和offset合并为8byte的uid, oldRaw和newRaw合并raw(二者length是相等的 , 不足的会进行0填充)
更新日志 logType tid pageNo offset oldRaw newRaw
logType(1byte) Tid(8byte) Uid(8byte) raw(…)
logType代表种类
Tid代表事务id
uid代表的是pageNo和offset
raw代表的是oldRaw和newRaw的合并
事务管理底层就是对tid文件的操作, 数据管理底层就是对log和db文件的操作,按照上面给定的规则进行校验
不管是在操作系统还是在实际的application中,都会使用到缓存,这里DM读写文件也不例外,因为读写文件是比较耗时的,根据局部性原理,缓存是提高性能瓶颈的必要手段
而db程序启动后手动分配一个内存mem,而不是直接使用JVM的最大内存,会将page装入内存,按照缓存策略进行操作, 除了Page需要直接管理起来,还有就是DataItem也需要利用策略进行管理,合理利用内存空间
常见的缓存淘汰策略: FIFO、LRU、LFU、 引用计数
LRU 和LFU是比较常见的,一个淘汰最久未使用的,一个淘汰使用频率最少的
而引用计数: 基于对象引用次数计数淘汰策略, 跟踪对象的引用次数,如果对象被引用,次数加1, 引用失效次数减1, 当引用次数为0,该对象可以淘汰,但是存在致命问题:
不能处理循环引用的情况
所以JVM中没有词用该方法进行对象分析,而是采用的可达性分析,从GCRoots触发, 循环引用的对象也可以分析进行GC
其中FIFO先进先出,只是简单的利用了时间局部性原理,先进入缓存的页面先进行淘汰
LRU:java的LinkedHashMap实现了LRU算法,我们可以直接get即可,缓存淘汰就是满了自动淘汰即可,就是最久未使用的页面会进行淘汰【和FIFO不同的是,当命中时,会将命中的页面放到最前面】,这里为何不用LRU?
假设一个场景: 某一个时刻,新进入一个页面,缓存已经满了,淘汰最久未使用的页面,这个时候上层需要将缓存强行刷回数据源,这个资源就是被淘汰的那个,这个时候就会出现问题:
- 如果我们选择放弃刷新数据源,那么肯定就可能出现数据丢失了,不安全不可取
- 选择刷新数据源,会出现问题,发现数据并没有发生变化,那么这次操作无效,并且还进行了文件操作,浪费时间
- 重新放回缓存,那么又重新驱赶了一个资源,可能下次同样的问题,导致缓存频繁置换,引起缓存抖动
可能的一个解决办法: 记录一下页面的最后修改时间,缓存记录淘汰时间,那么就增加了字段,方法不优
说白了: 就是这里的LRU是自动操作的,上层模块不能控制到底淘汰哪一块资源
因此该场景下,简单采用引用计数法进行缓存淘汰,只有上层模块主动释放引用,缓存确保没有使用该资源,再进行资源的释放
封装一个release()方法: 当上层模块未使用资源时,进行缓存淘汰,引用归0就可驱逐; 当缓存满了之后,并且引用无法自动释放缓存, 这个时候直接报错
缓存应该是通用的一个对象缓存工具类。 因此这里使用泛型来代表对象具体类型。 缓存需要记录的是当前缓存的资源, 每个资源对应的引用计数; 同时为了应对多线程环境,应该记录当前资源是否被使用, 同时记录cache的最大缓存数和当前资源个数
private HashMap<Long, T> cache; //当前能够缓存的实际的数据
private HashMap<Long, Boolean> accessing; //多线程下正在被获取的数据
private HashMap<Long, Integer> references; //每个资源引用计数个数
private int maxResource; //缓存中最大的资源数
private int count = 0; //当前缓存中的元素个数, 其实就是cache的长度
private Lock lock; //多线程并发安全
这里使用了3个hashMap, 其实就是T、Long、Boolean、Integer几个字段的一一对应, 我们用Long标号资源, Integer代表当前标号的引用计数, boolean代表是否正在被使用
因为在多线程状态下,可能会多个线程并发进行资源的操作,所以加了一个Long — Boolean的关联Map, 当资源正在被操作的时候,就不挂起了,而是自旋等待资源的释放
因为涉及修改成员变量,所以进入的时候就需要显式的进行加锁ReentrantLock,如果正在被使用,那么就sleep(1),并且释放本次want加的lock; 如果可以并且cache中包含资源,那么就直接放回该资源、==引用计数 ++==并且释放lock; 如果缓存已经满了,那么就需要报错并且释放lock; 其他情况就是缓存中没有当前资源,需要从源中获取资源并放入cache
这里就先预先count ++ 并且标识正在使用,并且释放当前的读取锁; 之后就需要进行资源的写入缓存操作
这里就是封装的方法getToCache 【 从源中获取数据】, 获取失败的时候, 就把之前的count++给变回去,accessing中设置的占位也恢复, 这里为了线程安全,再次加上Lock锁; 获取成功还是要加锁进行共享变量的修改【cache,references,accessing】
/**
* cpu空转一直尝试从cache获取,如果有其余线程正在获取,就等待一会sleep
*/
protected T get(long key) throws Exception {
//cpu空转等待直到获取到key, 或者缓存中存在直接返回, 因为涉及hashMap数据操作,需要加锁操作
while(true) {
//尝试获取时先加锁读取
lock.lock();
//其余线程在处理,睡眠等待, 每一次操作都需要unlock
if(accessing.containsKey(key)) {
lock.unlock();
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
continue;
}
continue;
}
//cache中存在,获取的时候计数 + 1
if(cache.containsKey(key)) {
T obj = cache.get(key);
references.put(key, references.get(key) + 1);
lock.unlock();
return obj;
}
//cache满
if(maxResource > 0 && count == maxResource) {
lock.unlock();
throw new DatabaseException(ErrorCode.CACHE_FULL);
}
//不在cache中,首先给当前设置为true,表明获取到
count ++;
accessing.put(key, true);
lock.unlock();
break;
}
//获取到之后需要调取放进cache
T obj = null;
try {
obj = getToCache(key);
} catch (Exception e) {
lock.lock(); //获取失败, count变回去
count --;
accessing.remove(key);
lock.unlock();
}
//获取成功,放入cache
lock.lock();
accessing.remove(key);
cache.put(key,obj);
references.put(key, 1);
lock.unlock();
}
除了获取操作,还有就是缓存释放操作,需要回写资源, 涉及共享变量操作,仍然加上Lock锁, 封装一个方法releaseForCache(obj), 将资源写入源中
/**
* 强行释放缓存
*/
protected void release(long key) {
lock.lock();;
try {
int ref = references.get(key) - 1; //释放时计数 - 1, 类属性修改都需要加锁
if(ref == 0) {
T obj = cache.get(key);
releaseForCache(obj); //回写资源
references.remove(key); //引用计数也需要删除
cache.remove(key); //cache标记的资源也移除
count --;
} else {
references.put(key, ref); //引用计数
}
} finally {
lock.unlock();
}
}
/**
* 关闭缓存, 写回所有资源到文件中
*/
protected void close() {
lock.lock();
try {
Set<Long> keys = cache.keySet();
for(long key: keys) {
T obj = cache.get(key);
releaseForCache(obj); //释放回写资源到文件
references.remove(key);
cache.remove(key);
}
} finally {
lock.unlock();
}
}
需要调用者实现的就是getToCache,从源中获取资源,以及releaseForCache,将资源回写源
/**
* 当资源不在cache中时,从源中获取
*/
protected abstract T getToCache(long key) throws Exception;
/**
* 资源从cache中回写到源中
*/
protected abstract void releaseForCache(T obj);
java中对于子数组的处理一般都是copy,也就是新的一块内存,而其他的语言比如go就是直接共享的原数组空间中的子数组的空间
在java中想要实现同样的效果,可以直接给一个start和end即可; 【最简单】
/**
* java中执行subArray时,底层是进行复制的,而不是进行共享
* 所以这里就需要实现共享
*/
public class SubArray {
/**
* 这里就是指定start和end
*/
public byte[] raw;
public int start;
public int end;
public SubArray(byte[] raw, int start, int end) {
this.raw = raw;
this.start = start;
this.end = end;
}
}
首先需要表明的是cfengDB目前的简约版本只能支持基本的数据操作,并且删除还是逻辑删除,日志只有插入日志和更新日志, 为了保证数据的准确性同时保证方案的简洁性,规定:
在进行数据操作前先写log文件
这样即便数据操作时db崩溃,可以依据log进行数据恢复
关于日志文件的相关规则定义已经解释过,不再赘述
日志文件的create和open和Tid类似, create时需要注意进行初始化TChecksum = 0, 而open日志文件 时候就需要init 【检查Tchecksum】
static Logger open(String path) {
File file = new File(path + LoggerConstant.LOG_SUFFIX);
if(!file.exists()) {
FaultHandler.forcedStop(new DatabaseException(ErrorCode.FILE_EXISTS));
}
if(!file.canRead() || !file.canWrite()) {
FaultHandler.forcedStop(new DatabaseException(ErrorCode.FILE_CANNOT_READ_OR_WRITE));
}
RandomAccessFile raf = null;
FileChannel fileChannel = null;
try {
raf = new RandomAccessFile(file, "rw");
fileChannel = raf.getChannel();
} catch (Exception e) {
FaultHandler.forcedStop(e);
}
LoggerImpl logger = new LoggerImpl(raf, fileChannel);
logger.init();
return logger;
}
日志文件由一条条规则的日志组成,为了便捷进行日志扫描,所以采取迭代器模式进行日志扫描, position代表的就是日志文件指针位置
使用next就不断读取下一条日志,初始时将postion放置在4(TChecksum结束)
/**
* 获取当前日志, 依靠指针position进行迭代, 读取当前条时,要先判断是否为最后一条日志(不完善)
* 直接返回null, 之后读取该条日志,进行checksum校验,position变化到下一条日志的起始
*/
private byte[] internNext() {
if(position + LoggerConstant.OFFSET_DATA >= fileSize) {
//该条日志的SIZE + CHECKSUM已经超过size,说明读取完了
return null;
}
ByteBuffer temp = ByteBuffer.allocate(4);
try {
fc.position(position);
fc.read(temp);
} catch (IOException e) {
FaultHandler.forcedStop(e);
}
int size = ByteBufferParser.parseInt(temp.array()); //日志data长度
if(position + size + LoggerConstant.OFFSET_DATA > fileSize) {
return null;//当前日志没写完,data不满足,所以不读取了
}
//一条日志的长度
ByteBuffer buffer = ByteBuffer.allocate(LoggerConstant.OFFSET_DATA + size);
try {
fc.position(position);
fc.read(buffer);
} catch (IOException e) {
FaultHandler.forcedStop(e);
}
byte[] log = buffer.array();
//读取日志后先计算日志里的校验和是否正确
int calculateCheckSum = calChecksum(0, Arrays.copyOfRange(log, LoggerConstant.OFFSET_DATA, log.length));
int checkSumInLog = ByteBufferParser.parseInt(Arrays.copyOfRange(log, LoggerConstant.OFFSET_CHECKSUM, LoggerConstant.OFFSET_DATA));
if(calculateCheckSum != checkSumInLog)
return null;
position += log.length;
return log;
}
private void checkAndRemoveTail() {
//position重置
rewind();
int TCheck = 0;
while(true) {
byte[] log = internNext();
if(log == null) //读取到最后一条文件的时候跳出
break;
TCheck = calChecksum(TCheck, log);
}
//判断校验和
if(TCheck != TChecksum) {
FaultHandler.forcedStop(new DatabaseException(ErrorCode.BAD_LOG_FILE));
}
//删除掉最后一个badTail,直接从当前position停留的之后截断
try {
truncate(position);
} catch (Exception e) {
FaultHandler.forcedStop(e);
}
try {
raf.seek(position);
} catch (IOException e) {
FaultHandler.forcedStop(e);
}
//初始化校验操作之后需要重置position指针
rewind();
}
日志数据传入时,需要封装为规则的log序列
//将data字节序列封装为满足要求的日志序列[size][checkSum][data]
private byte[] warpLog(byte[] data) {
byte[] checkSum = ByteBufferParser.int2Byte(calChecksum(0, data));
byte[] size = ByteBufferParser.int2Byte(data.length);
return ByteBufferParser.mergeBytes(size, checkSum, data);
}
校验和的计算就按照给定的算法进行计算即可,这里直接依靠seed进行生成
//日志内部校验和就是对于该条日志按照设定的算法求和
private int calChecksum(int TCheck, byte[] log) {
for(byte b: log)
TCheck = TCheck * LoggerConstant.seed + b;
return TCheck;
}
日志的写入就是封装为给定的日志格式后直接写入log文件即可(加上Lock保证安全), 写入之后需要修改校验和
/**
* @param data 日志内容
* 新写入日志
*/
@Override
public void log(byte[] data) {
byte[] log = warpLog(data);
ByteBuffer buffer = ByteBuffer.wrap(log);
lock.lock();
try {
fc.position(fc.size()); //position为结尾开始写
fc.write(buffer);
} catch (IOException e) {
FaultHandler.forcedStop(e);
} finally {
lock.unlock();
}
updateTChecksum(log);
}
成员变量除了position代表位置外还有Tchecksum代表整体的校验和,在插入日志时需要更新校验和
而删除文件结尾的badTail的方式也很简单,直接使用fileChannel的truncate即可
/* 直接调用的fileChannel的truncate方法
*/
@Override
public void truncate(long x) throws Exception {
lock.lock();
try {
fc.truncate(x);
} finally {
lock.unlock();
}
}
实现时需要注意的就是不要把日志内容和日志的data混淆了,日志内容 = data + checksum + size
按照数据库的理论,数据恢复主要就是基于checkPoint的REDO和UNDO操作,主要就是依靠日志进行数据的恢复
Data manager 提供的两种基本的操作: 插入数据Insert和更新数据update【目前只有这两种日志】, 由于文件管理和内存管理的方便性以及数据的准确性, 没有删除的日志,删除就只是将数据valid标志位变化即可
在进行insert和update之前,先进行日志操作,保证日志写入磁盘后再进行数据操作
这个上面解释过,更加可以保证数据的准确性,当然这个缺点就是写日志这个非关键操作可能会阻塞整个数据操作过程 【在性能和安全性上cfeng选择了安全性,当然这只是easy方案】
这两种数据操作分别对应的就是两种不同的日志
单线程下同时只能有一个事务执行,事务执行完之后再执行下一个事务,所以break down时前面的所有事务都是完成状态
不同的事务串行化,不会相交, 那么数据恢复就很容易, 假设break down时最后一个事务为Ti,那么需要
事务REDO操作:
事务Undo操作:
多线程状态下不同的事务可能是并发的,也就是存在交叉,考虑下面的情况:
T1 begin
T2 begin
T1 set x = x + 1 (T1, U, A, 0, 1)
T2 set x = x + 1 (T1,U, A, 1, 2)
T2 commit
cfengDB break down
这是典型的丢失修改的问题,MySQL的读未提交隔离级别即可解决
按照这个eg,数据库启动恢复数据时,因为T1未提交,所以需要UNDO, 而T2提交了所以需要REDO, 这就有问题了,因为T2依赖T1, 但是二者数据恢复所做的操作不同。其实是因为日志种类只有2种,只是记录了前相和后相,依据这种简单的日志的恢复,不能应对复杂系统的要求
对于这个问题的解决方案:
demo版本目前对于日志的设计就两种,连删除都没有,所以就采用增加限制了, 这里我们规定: 数据库的最低隔离级别为读已提交
- 正在进行的事务,不会修改其他任何未提交的事务 (丢失修改)
- 正在进行的事务,不会读取其他任何未提交的事务产生的数据 (脏读)
为了保证这两个规定,上面的version并发控制 manager模块会进行并发限制:
这样限制之后, 日志文件中的日志序列不会再出现级联事务操作不同的情况, 所以恢复操作和一样:
数据库最终恢复到所有已经完成事务结束,未完成事务未开始的状态
static class InsertLogInfo {
long tid; //事务ID
int pageNo; //页号
short offset; //页内偏移
byte[] raw; //数据
}
static class UpdateLogInfo {
long tid;
int pageNo; //页号
short offset;
byte[] oldRaw;
byte[] newRew;
}
两种日志对象, 同时实现对象和文件中byte序列的切换
Recover就是先扫描日志文件,找到数据操作在缓存中的最大页面page, 清楚page后续的页面
public static void recover(TransactionManager tm, Logger logger, PageCache pageCache) {
System.out.println("cfengBase is recovering.....");
//日志文件的position先恢复
logger.rewind();
int maxPageNo = 0;
while (true) {
byte[] log = logger.next();
if(log == null)
break;
int pageNo;
if(isInsertLog(log)) {
InsertLogInfo li = parseInsertLog(log);
pageNo = li.pageNo;
} else {
UpdateLogInfo ui = parseUpdateLog(log);
pageNo = ui.pageNo;
}
if(pageNo > maxPageNo)
maxPageNo = pageNo;
}
//page从1开始
if(maxPageNo == 0)
maxPageNo = 1;
//读取日志文件找到最大的日志的对应的页号,删除maxPage后面的页面
pageCache.truncateAfterPageNo(maxPageNo);
System.out.println("Truncate to " + maxPageNo + " pages");
redoTransactions(tm, logger, pageCache);
System.out.println("Redo Transactions Over");
undoTransactions(tm, logger, pageCache);
System.out.println("Undo Transactions Over");
}
redo 操作直接正序的操作所有已经完成的事务的日志即可
private static void redoTransactions(TransactionManager tm, Logger logger, PageCache pageCache) {
//指针初始化
logger.rewind();
while(true) {
byte[] log = logger.next();
if(log == null)
break;
if(isInsertLog(log)) {
InsertLogInfo logInfo = parseInsertLog(log);
long tid = logInfo.tid;
//判断事务是否是完成
if(!tm.isRunning(tid)) {
//REDO
doInsertLog(pageCache, logInfo, LoggerConstant.REDO);
}
} else {
UpdateLogInfo logInfo = parseUpdateLog(log);
long tid = logInfo.tid;
if(!tm.isRunning(tid)) {
doUpdateLog(pageCache, logInfo, LoggerConstant.REDO);
}
}
}
}
而undo操作就是逆序操作,同时对于插入数据,不是直接删除,而是逻辑删除
private static void redoTransactions(TransactionManager tm, Logger logger, PageCache pageCache) {
//指针初始化
logger.rewind();
while(true) {
byte[] log = logger.next();
if(log == null)
break;
if(isInsertLog(log)) {
InsertLogInfo logInfo = parseInsertLog(log);
long tid = logInfo.tid;
//判断事务是否是完成
if(!tm.isRunning(tid)) {
//REDO
doInsertLog(pageCache, logInfo, LoggerConstant.REDO);
}
} else {
UpdateLogInfo logInfo = parseUpdateLog(log);
long tid = logInfo.tid;
if(!tm.isRunning(tid)) {
doUpdateLog(pageCache, logInfo, LoggerConstant.REDO);
}
}
}
}
//对插入日志的恢复操作
private static void doInsertLog(PageCache pageCache, InsertLogInfo logInfo, int flag) {
Page page = null;
try {
page = pageCache.getPage(logInfo.pageNo);
} catch (Exception e) {
FaultHandler.forcedStop(e);
}
try {
if(flag == LoggerConstant.UNDO) {
DataItem.setDataItemRawInvalid(logInfo.raw);
} else {
//REDO
PageCommon.recoverInsert(page, logInfo.raw, logInfo.offset);
}
} finally {
page.release();
}
}
we知道缓存中是包含很多Page页面的,每一个PageCommon只要offset没有到达8kb都是可以进行数据的插入的, 那么对于一个insert操作,怎么选择合适的页面呢?
这里我们采用了pageIndex页面索引进行快速的插入, 实现思路很简单:
将一页空间划分为40块, 那么一块就是0.2KB,在内存中维护一个List
那么一个page含有的容量最小为0块,最多40块, 按照快速进行页面的分配
当然这种方法有很大局限,但是对于这个demo来说还是足够的,每次要进行插入操作时,就将数据/块容量 + 1 得到需要的容量,选择对应List中的页面即可
private int pageNo; //页号
private int freeSpace; //业内空闲空间大小
//页面索引,一页的空间划分为40块
private static final int INTERVALS_NO = 40;
private static final int THRESHOLD = PageCacheConstant.PAGE_SIZE/INTERVALS_NO;
/**
* 选择使用时只要空闲空间为给定块即可
*/
public PageInfo select(int spaceSize) {
lock.lock();
try {
int number = spaceSize/THRESHOLD;
if(number < INTERVALS_NO)
number ++; //向上取整
while(number <= INTERVALS_NO) {
if(lists[number].size() == 0) {
number ++;
continue;
}
//写的时候space改变,取出所以不能多线程同时写一个页面
return lists[number].remove(0);
}
return null;
} finally {
lock.unlock();
}
}
整个DataManager模块最上层的应该就是DataManager和DataItem, 这两个类直接暴露给上层的模块, 他们需要调用的包括TransactionManager、Logger、PageCache
而Page就是最终缓存的对象, 分为两种类型的Page: PageFirst和PageCommon, 两个类中封装了不同的页面操作方法
PageCache就是继承上面的Cache类,实现对Page的淘汰策略,DataItem是对于db文件中PageCommon中的data部分的数据切割
dataManager利用下层各实体完成数据的插入,读取,创建、打开db、log文件等
eg: create运行db时,划分一个指定大小的内存空间用于存储Page,同时创建log和db文件,同时需要初始化PageFirst
static DataManager create(String path, long mem, TransactionManager tm) {
PageCache pageCache = PageCache.create(path, mem);
Logger logger = Logger.create(path);
DataManagerImpl dm = new DataManagerImpl(pageCache, tm, logger);
dm.initPageOne(); //初始化第一页
return dm;
}
而打开DM时,需要初始化页面索引,同时需要校验上次是否正常关闭
static DataManager open(String path, long mem, TransactionManager tm) {
PageCache pageCache = PageCache.open(path, mem);
Logger logger = Logger.open(path);
DataManagerImpl dm = new DataManagerImpl(pageCache, tm, logger);
if(!dm.loadCheckPageOne()) {//校验不成功,说明故障,需要恢复
DataRecover.recover(tm, logger, pageCache);
}
dm.fillPageIndex();
PageFirst.setRunControlOpen(dm.getPageOne());
dm.pageCache.flushPage(dm.getPageOne());
return dm;
}
而读取操作,直接操作的就是数据项Item,需要判断是否被逻辑删除
public DataItem read(long uid) throws Exception {
DataItemImpl dataItem = (DataItemImpl) super.get(uid);
if(!dataItem.isValid()) {
dataItem.release();
return null;
}
return dataItem;
}
插入操作需要根据页面索引选择合适的页面进行数据插入,同时需要先记录日志
public long insert(long tid, byte[] data) throws Exception {
byte[] raw = DataItem.wrapDataItemRaw(data);
//数据长度超过了数据页的最大长度
if(raw.length > PageCommon.MAX_FREE_SPACE) {
throw new DatabaseException(ErrorCode.DATA_TOO_LARGE);
}
PageInfo pageInfo = null;
//重试5次
for(int i = 0; i < 5; i ++) {
pageInfo = pageIndex.select(raw.length);
if(pageInfo != null)
break;
else {
//当前内存中的页都满了,重新生成一页
int newPageNo = pageCache.newPage(PageCommon.initRaw());
pageIndex.add(newPageNo, PageCommon.MAX_FREE_SPACE);
}
}
//重试5次都不成功,说明数据库繁忙
if(pageInfo == null)
throw new DatabaseException(ErrorCode.DATABASE_BUSY);
//插入数据
Page page = null;
int freeSpace = 0;
try {
page = pageCache.getPage(pageInfo.getPageNo());
//操作之前先写日志
byte[] log = DataRecover.insertLog(tid, page, raw);
logger.log(log);
//写入数据
short offset = PageCommon.insert(page, raw);
//持久化
page.release();
return Types.addressToUid(pageInfo.getPageNo(), offset);
} finally {
//取出的page重新放入pageIndex
if(page != null) {
pageIndex.add(pageInfo.getPageNo(), PageCommon.getFreeSpace(page));
} else {
pageIndex.add(pageInfo.getPageNo(), freeSpace);
}
}
}
页号为1的PageFisrt不存储数据,从第二页开始建立页面索引
DataItem实际对应的就是PageCommon的data中的划分的数据,item的首位存储的valid
static void setDataItemRawInvalid(byte[] raw) {
raw[DataItemConstant.OFFSET_VALID] = (byte)1; //删除 --- 有效位变为1失效
}
//从页面offset开始解析dataItem
static DataItem parseDataItem(Page page, short offset, DataManagerImpl dm) {
byte[] raw = page.getData();
short size = ByteBufferParser.parseShort(Arrays.copyOfRange(raw, DataItemConstant.OFFSET_SIZE, DataItemConstant.OFFSET_DATA));
short length = (short)(size + DataItemConstant.OFFSET_DATA); //DataItem总长度
long uid = Types.addressToUid(page.getPageNumber(), offset);
return new DataItemImpl(new SubArray(raw, offset, offset + length), new byte[length], page, uid, dm);
}
这里同时向上层暴露了读写锁的方法,实际上就是调用的Lock的reentrantReadWriteLock
@Override
public void lock() {
wLock.lock();
}
@Override
public void unLock() {
wLock.unlock();
}
@Override
public void rLock() {
rLock.lock();
}
@Override
public void rUnLock() {
rLock.unlock();
}
在进行数据操作前应该进行备份,就是放到Item的oldRaw数据项中进行存储
/**
* 开始将数据写道oldraw中存储
*/
@Override
public void before() {
wLock.lock();//写锁
page.setModified(true);
System.arraycopy(raw.raw, raw.start, oldRaw,0, oldRaw.length);
}
/**
* 旧将oldRaw放回去
*/
@Override
public void unBefore() {
System.arraycopy(oldRaw, 0, raw.raw, raw.start, oldRaw.length);
wLock.unlock();
}
@Override
public void after(long tid) {
//日志
dm.logDataItem(tid, this);
wLock.unlock();
}
Page是内存中对于db文件的划分的抽象, pageFirst代表的是对于pageNo为1的页面的操作, 而pageCommon代表的是对后续的普通的数据存储页面的操作
Page直接进行release、get和文件操作直接依靠的是PageCache
public class PageImpl implements Page {
private int pageNumber; //当前的page页号
private byte[] data; //页面数据
private boolean isModified; //是否被修改
private Lock lock;
private PageCache pageCache; //pageCache操作方法
//PageCache在 Page之上, 这里没有使用Spring,不存在循环依赖,因为是在PageCache中才调用
public PageImpl(int pageNumber, byte[] data, PageCache pageCache) {
this.pageNumber = pageNumber;
this.data = data;
this.pageCache = pageCache;
lock = new ReentrantLock();
}
为了保证page和磁盘文件的一致性,我们需要记录page是否被修改,如果被修改,那么就需要进行relase持久化
public static byte[] initRaw() {
byte[] raw = new byte[PageCacheConstant.PAGE_SIZE];
setRunControlOpen(raw);
return raw;
}
/**
* 打开db时,随机字节序列
*/
public static void setRunControlOpen(Page page) {
page.setModified(true);
setRunControlOpen(page.getData());
}
private static void setRunControlOpen(byte[] raw) {
//随机生成byte数组放到100 -- 107
System.arraycopy(RandomUtil.randomBytes(LEN_RUN_CONTROL), 0, raw, OFFSET_RUN_CONTROL, LEN_RUN_CONTROL);
}
public static boolean checkRunControl(Page page) {
return checkRunControl(page.getData());
}
public static boolean checkRunControl(byte[] raw) {
return Arrays.equals(Arrays.copyOfRange(raw, OFFSET_RUN_CONTROL, OFFSET_RUN_CONTROL + LEN_RUN_CONTROL),
Arrays.copyOfRange(raw, OFFSET_RUN_CONTROL + LEN_RUN_CONTROL,OFFSET_RUN_CONTROL + 2 * LEN_RUN_CONTROL));
}
public static void setRunControlClose(Page page) {
page.setModified(true);
setRunControlClose(page.getData());
}
而PageFirst主要就是初始化、还有数据库启动时需要随机字节序列放入,数据库关闭时需要复制, 同时通过相关的校验方法
//初始化页面
public static byte[] initRaw() {
byte[] raw = new byte[PageCacheConstant.PAGE_SIZE];
setFSO(raw, OFFSET_DATA);
return raw;
}
//偏移的地址移动是8KB, 2位即可表示,提取的数成为short类型
private static void setFSO(byte[] raw, short ofData) {
System.arraycopy(ByteBufferParser.short2Byte(ofData),0,raw,OFFSET_FREE, ofData);
}
//获取当前页面的FSO, 读取前两个字节转为short类型
public static short getFSO(Page page) {
return getFSO(page.getData());
}
private static short getFSO(byte[] raw) {
return ByteBufferParser.parseShort(Arrays.copyOfRange(raw, 0 , 2));
}
//将raw插入page中,返回插入位置
public static short insert(Page page, byte[] raw) {
//内存中修改该页面内容,那么需要再移除时需要写回文件
page.setModified(true);
short offset = getFSO(page.getData());
//先在内存中写到byte[]后面
System.arraycopy(raw, 0, page.getData(), offset, raw.length);
setFSO(page.getData(), (short)(offset + raw.length));
return offset;
}
PageCommon页面就是对于普通数据页的操作,主要就是对FSO的操作, 提供数据恢复的数据操作
pageCache是数据db层面直接与磁盘文件交互的实体,继承了上面的缓存抽象类,主要提供Page的提取和持久化等文件操作
private RandomAccessFile raf;
private FileChannel fileChannel;
private Lock fileLock; //多线程操作都需要加锁
//AQS实现页面计数, 主要为了方便新建数据页时正确统计
private AtomicInteger pageNumbers;
public PageCacheImpl(int maxResource, RandomAccessFile raf, FileChannel fileChannel) {
super(maxResource);
//缓存不够
if(maxResource < PageCacheConstant.MEM_MIN_LIM)
FaultHandler.forcedStop(new DatabaseException(ErrorCode.MEM_TOO_SMALL));
long length = 0;
try {
length = raf.length();
} catch (IOException e) {
FaultHandler.forcedStop(e);
}
this.raf = raf;
this.fileChannel = fileChannel;
fileLock = new ReentrantLock();
this.pageNumbers = new AtomicInteger((int)length/PageCacheConstant.PAGE_SIZE);
}
初始相当于就是将一个文件的page容量当作加载入缓存的Page数量, 具体的大小Datamanager中可以设定
@Override
public void truncateAfterPageNo(int pageNo) {
long size = pageOffset(pageNo + 1);
try {
raf.setLength(size);
} catch (IOException e) {
FaultHandler.forcedStop(e);
}
//修改AQS量为pageNO, 通过set方法
pageNumbers.set(pageNo);
}
public void flush(Page page) {
int pageNo = page.getPageNumber();
long offset = pageOffset(pageNo);
fileLock.lock();
try {
ByteBuffer buffer = ByteBuffer.wrap(page.getData());
fileChannel.position(offset);
fileChannel.write(buffer);
fileChannel.force(false);
} catch (IOException e) {
FaultHandler.forcedStop(e);
} finally {
fileLock.unlock();
}
}
整个DM层大概就介绍这么多, 主要就是实现data和log的操作,提供了基于日志的数据恢复功能。 整个dm的实现都伴随着lock,因为多线程状态下需要保证共享变量的安全性,所以要么使用了AQS,要么修改时加上ReentrantLock, 同时为了利于并发管理层的实现,向上层提供了读写锁的接口