lab4实现与事务Transaction有关的方法,并实现页粒度的两段锁,实现SimpleDB多事务处理的功能,至此已经简单实现了一个具备增、删、改、查能力的数据库。
代码链接:SimpleDB Lab4
提交历史
设计思路
Transaction(事务)是一组以原子方式执行的数据库操作(插入、删除或读取等),要么所有的动作都完成了,要么一个动作都没有完成。
为了防止事务之间相互干涉导致读写异常,Lab4中将为SimpleDB实现两段锁,两段锁的阶段分别为:
-
第一阶段:扩展阶段,事务可以申请获得任意数据上的任意锁,但是不能释放任何锁;
-
第二阶段:收缩阶段,事务可以释放任何数据上的任何类型的锁,但是不能再次申请任何锁
两段锁协议并不要求事务必须一次将所有要使用的数据全部加锁,因此遵守两段锁协议的事务可能发生死锁。于是Lab4中也要实现死锁的检测和解决。
此外,Lab4还要实现NO STEAL策略,即事务对page的修改只有在commit之后才会写入到磁盘(flush),但是在之前的Lab中实现页面置换策略时,当置换掉的页面是dirty
page时,也会将更改写回到磁盘。这是不允许的,所以需要完善evictPage()方法,当需要置换的page是dirty page时,需要跳过此page,去置换下一个非dirty的page。
Exercise 1 实现页粒度的锁并完善相应方法
SimpleDB中的两端锁的要求如下:
-
在事务可以读取一个对象之前,它必须拥有一个共享锁(SHARED LOCK);
-
在一个事务可以写一个对象之前,它必须有一个互斥锁(EXCLUSIVE LOCK);
-
多个事务可以在一个对象上拥有一个共享锁;
-
只有一个事务能对一个对象具有互斥锁;
-
如果对象o上只有事务t持有共享锁,则t可以将其对o的锁升级为互斥锁;
为此,首先在BufferPool中实现一个Lock类:
private class Lock{
public static final int SHARE = 0; //0 stands for shared lock,
// Multiple transactions can have a shared lock on an object before reading
public static final int EXCLUSIVE = 1; //1 stands for exclusive lock,
// Only one transaction may have an exclusive lock on an object before writing
private TransactionId tid;
private int type;
public Lock(TransactionId tid, int type){
this.tid = tid;
this.type = type;
}
public TransactionId getTid() {
return tid;
}
public int getType() {
return type;
}
public void setType(int type) {
this.type = type;
}
}
而后,需要再创建一个LockManager类实现对每个事务在每个页上的锁的管理,包括获取和释放,而获取锁的逻辑如下:
整个BufferPool中只需要一个LockManager成员变量即可。
在LockManager中,创建了一个ConcurrentHashMap<PageId,ConcurrentHashMap<TransactionId,PageLock>> lockMap
对象来记录每个Page上事务t和其锁的映射,该类型的变量在多线程访问时更加安全。
具体的代码和注释如下:
private class LockManager{
private ConcurrentHashMap<PageId,ConcurrentHashMap<TransactionId,Lock>> pageLocks;
//use concurrentHashMap instead of HashMap cuz it's safer than the latter
//when facing multiple threads
public LockManager(){
pageLocks = new ConcurrentHashMap<PageId,ConcurrentHashMap<TransactionId,Lock>>();
}
public synchronized boolean acquireLock(PageId pid,TransactionId tid,int lockType)
throws TransactionAbortedException {
if(pageLocks.get(pid)==null){ // no exsisting locks, create newPageLock and return ture
Lock newLock = new Lock(tid,lockType);
ConcurrentHashMap<TransactionId,Lock> newPageLock = new ConcurrentHashMap<>();
newPageLock.put(tid,newLock);
pageLocks.put(pid,newPageLock);
return true;
}
//if there's locks already
ConcurrentHashMap<TransactionId,Lock> nowPageLock = pageLocks.get(pid);
if(nowPageLock.get(tid)==null){ //no locks from tid
if(nowPageLock.size()>1){ //exists locks from other transactions
if(lockType == Lock.SHARE){
Lock newLock = new Lock(tid,lockType); //if requring a read lock
nowPageLock.put(tid,newLock);
pageLocks.put(pid,nowPageLock);
return true;
}
else{ //requiring a write lock
return false;
}
}
else if(nowPageLock.size()==1){ //only one other lock,could be a read or write lock
Lock existLock = null;
for(Lock lock:nowPageLock.values())
existLock = lock;
if(existLock.getType() == Lock.SHARE){ // if it is a read lock
if(lockType == Lock.SHARE){// require a read lock too, return true
Lock newLock = new Lock(tid,lockType);
nowPageLock.put(tid,newLock);
pageLocks.put(pid,nowPageLock);
return true;
}
else if(lockType == Lock.EXCLUSIVE){
return false;
}
}
else if(existLock.getType() == Lock.EXCLUSIVE){ //some transaction is writing this page
return false;
}
}
}
else{
Lock preLock = nowPageLock.get(tid);
if(preLock.getType() == Lock.SHARE){ //if previous lock is a read lock
if(lockType == Lock.SHARE){ //and require a read lock too
return true;
}
else{ // if want a write lock
if(nowPageLock.size() == 1){ // if only this tid hold a lock,we could update it
preLock.setType(Lock.EXCLUSIVE);
nowPageLock.put(tid,preLock);
return true;
}
else{
throw new TransactionAbortedException();
}
}
}
else if(preLock.getType() == Lock.EXCLUSIVE){ //the previous one is a write lock
//means no other locks on this page
return true;
}
}
return false;
}
public synchronized boolean holdsLock(TransactionId tid,PageId pid){
if(pageLocks.get(pid) == null){
return false;
}
ConcurrentHashMap<TransactionId,Lock> nowPageLock = pageLocks.get(pid);
if(nowPageLock.get(tid) == null){
return false;
}
return true;
}
public synchronized boolean releaseLock(TransactionId tid,PageId pid){
if(holdsLock(tid,pid)){
ConcurrentHashMap<TransactionId,Lock> nowPageLock = pageLocks.get(pid);
nowPageLock.remove(tid);
if(nowPageLock.size() == 0){
pageLocks.remove(pid);
}
this.notifyAll();
return true;
}
return false;
}
public synchronized boolean TransactonCommitted(TransactionId tid){ // when a transaction completes,release all the locks
for(PageId pid : pageLocks.keySet()){
releaseLock(tid,pid);
}
return true;
}
}
在lockManager里的每一个方法前都加上了synchronized关键字,也是出于对多线程并发控制的考虑,其中acquireLock即是对以上获取锁的逻辑的代码实现,holdsLock查看页面p上是否有事务t的锁;releaseLock则是释放事务t在页面p上的锁;TransactionCommitted则是释放一个事务t获取过的所有锁,表示该事务完成。
Exercise 2+Exercise 5 getPage的完善和死锁解决
在此前实现的文件的读取页面的方法中,都是通过调用BufferPool的getpage()方法来完成的,因此只用在BufferPool中实现锁的获取即可。在此处我直接与Exercise5中的死锁检测和解决结合起来完善了getpage()方法。
在如HeapFile等文件的getPage函数中,调用BufferPoo的getpage方法时会传输一个Permission对象,表示对该页面的操作权限,可根据此来对应判断该获取哪一种锁。除了READ_ONLY是SHARED LOCK外,其余的都是EXCLUSIVE LOCK,因此getpage中需要加入这一段代码:
int lockType = (perm == Permissions.READ_ONLY)? Lock.SHARE : Lock.EXCLUSIVE;
//only READ_ONLY stands for a shared lock
long begin = System.currentTimeMillis();
Booleanan ifAcquired = false;
while (!ifAcquired){
ifAcquired = manager.acqureLock(pid,tid,lockType);
long rightNow = System.currentTimeMillis();
if(rightNow - begin > 500) { //timeout policy
//System.out.println("time out"+begin+" "+rightNow);
throw new TransactionAbortedException();
}
}
其中lockType会根据Permission的类型判断是哪一种锁,此后在while循环中不停尝试获取锁,如果在500毫秒内无法得到锁,那么说明有死锁存在,这便是Exercise 5中的超时检测死锁(timeouts)的机制。
此外,还可以用依赖图的原理实现死锁检测,由于进行该Lab4时没有学习到该部分内容,故没有实现。该方法的思路为:画出数据库访问的依赖图,然后可以利用深度优先搜索算法进行环的检测,如果出现有环,则说明是死锁,这是一种更加科学的方法,不过在性能方面没有做探究。
Exercise 2中还提到,在事务向page中的空slot插入tuple时,首先需要遍历该page上有无空slot,如果该page上没有空的slot,则需要创建一个新的page将tuple插入其中。此时事务不会对已满的page进行操作,但它依旧持有这些page上的写锁,其它事务也不能访问这些page,所以当判断某一page上的slot已满时,可以释放掉该page上的写锁。尽管不满足两段锁协议,但该事务并没有对page进行任何操作,所以并不会有任何影响,而且也可以让其他事务访问这些page,于是在HeapFile的insertTuple方法中,需要改写这一段:
for(int i=0;i<numPages();i++){
HeapPage page = (HeapPage) bp.getPage(tid, new HeapPageId(this.getId(),i),Permissions.READ_WRITE); //读取对应页
if(page.getNumEmptySlots()==0) { //full
//added in lab4,when there's no emptslots, wewe could unlock the page
bp.releasePage(tid,new HeapPageId(this.getId(),i));
//this will do no harm to 2PL lock,cuz we didn't read any data
continue;
}
page.insertTuple(t);
PageList.add(page);
//page.markDirty(true,tid); // added in lab4
return PageList;
}
可见,当某page的空slots数为0时,将释放事务t对此页的锁。
Exercise 3 实现NO STEAL策略
在此前实现的汰换策略evictPage()中,会将最近最少使用的页面刷新到磁盘上然后从BufferPool中删除,然而在Lab4中,为了使事务运行正常,被事务修改过的dirtypages只有在commit之后才会写入到磁盘,因此Exercise3中要对evictPage()做一些改动,使得脏页不会被汰换:
private synchronized void evictPage() throws DbException {
int length = recentUsedPages.size()-1;
while (recentUsedPages.get(length).isDirty()!=null){ //NO STEAL policy
length--; //if it is a dirpage, do,do not evict it
if(length<0)
throw new DbException("all pages are dirty");
}
Page page = recentUsedPages.remove(length);
discardPage(page.getId()); //remove it from the BufferPool
}
Exercise4 实现transactionComplete
BufferPool中transactionComplete()有重载,一个带有布尔参数(为true时,进行提交;false时进行回滚),另一个无参数,总是提交committed。
在提交事务t时,所有被t修改的脏页将全部刷新至磁盘(调用flushpages),然后t拥有的所有锁将全部释放;如果进行回滚,那么将先从内存中删去所有t修改过的脏页,然后从磁盘中重新读取这些内存页,具体代码如下:
public void transactionComplete(TransactionId tid, boolean commit)
throws IOException {
// some code goes here
// not necessary for lab1|lab2
if(commit){
try {
flushPages(tid);
}catch (IOException e){
e.printStackTrace();
}
}
else{ //recovery
for(Page page : idToPage.values()){
PageId pid = page.getId();
if(tid.equals(page.isDirty())){ //if tid has modified this page,we try to recover it
int tableId = pid.getTableId();
DbFile file = Database.getCatalog().getDatabaseFile(tableId);
Page recoverPage = file.readPage(pid); //reload the page
idToPage.put(pid,recoverPage);
recentUsedPages.add(0,recoverPage); //reput it
}
}
}
manager.TransactonCommitted(tid);
}
其中,在LockManager中实现的TransactionCommitted就起到了作用,用于把事务t的所有锁释放。
重难点
本次实验的重难点主要在:
-
获取锁的逻辑:获取锁时不仅要判断页面是否有锁,锁的类型,有时候甚至要通过页面上锁的数量来进行分支操作;
-
死锁的判断和解决:对于如何判断事务间发生死锁也是重难点,因为此次实现的两段锁并没有保证死锁不会发生。而Lab4中我采用了超时检测死锁的方法,如果某事务在500ms内没有得到需要的锁,说明可能发生了死锁,则停止该事务并抛出TransactionAbortedException异常;
-
NO STEAL策略也是重点之一,具体可以参见Exercise 3;
-
多线程并发读写时的管理:在此次的test中,包括多线程对于BufferPool的访问,其中LinkedArrayList类型的用以存储最近使用页面的recentUsedPage在最初面临多线程访问和修改时常常报错,因此我对其的add方法和remove方法在外层又进行了包装,用synchronized关键字修饰,使得多线程访问的安全:
private synchronized void moveToHead(PageId pid){
String thread = Thread.currentThread().getName();
//System.out.println("modify from "+thread);
for(int i = 0;i<recentUsedPages.size();i++){
Page page = recentUsedPages.get(i);
if(page.getId().equals(pid)) {
recentUsedPages.remove(i);
recentUsedPages.add(0,page); //find the required page,and put it at the top as the recent used page
break;
}
}
//System.out.println("done from "+thread);
}
private synchronized void addRecentUsed(Page page){
recentUsedPages.add(0,page);
}
private synchronized void delRecentUsed(PageId pid){
for(int i = 0;i<recentUsedPages.size();i++){
Page page = recentUsedPages.get(i);
if(pid.equals(page.getId())) {
recentUsedPages.remove(i);
break;
}
注意:如果要通过test中的BTreeTest,则必须实现Tuple粒度的两段锁,此处不做讨论。