MIT6.830 SimpleDB 实现笔记 Lab 4

Lab4 是实现 SimpleDB 的并发事务系统,跟前面的内容相比较为复杂。

一般来说数据库的事务需要满足 ACID 特性,即原子性、一致性、隔离性、持久性
原子性就是该事务的所有操作要么全部完成,要么全部取消,要求通过下面的操作保证:

  1. 不从页面缓存中逐出“脏页”(被某个事务更新的页面)。——NO STEAL 规则
  2. 在事务正确提交时,强制刷新所有脏页到磁盘。
    隔离性就是同时执行的多个事务不会相互干扰,通过将要实现的锁机制保证。
    一致性在 SimpleDB 中没有强调,持久性应该在 Lab6 的恢复功能上体现。

SimpleDB 事务并发控制实现

锁机制

在数据库中锁定对象可以是表、页面、元组、属性等,SimpleDB 规定的锁定粒度是页面(Page)。可供事务获取的锁类型有两种:共享锁和排他锁,规则如下。

  1. 事务在读取页面之前,必须具有共享锁;
  2. 事务在修改页面之前,必须具有排他锁;
  3. 多个事务可以在一个页面上具有共享锁;
  4. 只有一个事务可以在一个对象有排他锁。

在该规则下,如果一个事务在请求页面的时候,无法获取该页面的锁,就必须被阻塞,以等待锁资源被其他事务释放留给自己去竞争。特别的是,如果一个事物在申请排他锁时,如果已经持有了该页面的共享锁且是唯一一个持有者,那么可将此共享锁升级为排他锁(锁升级)。

两阶段锁(2PL)

考虑两个事务按照上述锁机制正常执行,有可能发生下图的情况:(X 排他,S 共享)

T1 和 T2 都正常提交了,但是 T1 对于页面 A 发生了“不可重复读”现象,即在同一个事务先后两次读到的数据有可能不一样(被别的事务如 T2 修改了)。

解决这个问题的办法就是实现两阶段锁协议(2PL)。2PL 的两个阶段分别是扩展阶段(Growing)收缩阶段(Shrinking),在扩展阶段事务只能获取锁,在收缩阶段事务只能释放锁。

两阶段锁协议本身足以保证冲突可串行性,但它可能会导致级联中止(Cascading aborts) 问题,即一个事务的中止可能导致其他多个事务也一起中止。这是因为在 2PL 中一个事务可能基于另一个事务尚未提交的数据进行操作,如果那个事务被中止,就会发生级联中止。如下图:

为了避免这种情况,需要实现严格两阶段锁协议(Strict 2PL),即一个事务只能在它提交或中止时释放所有锁。SimpleDB 要求实现 Strict 2PL 协议。这其实简化了操作,因为在赋予事务锁的时候不用考虑什么时候执行完了操作该释放,而是通通等到最后 commit 时释放。

页面级锁机制实现

刚开始时看到需要实现读写锁,自然会想到 JUC 中的 ReentrantReadWriteLock 类,然而该类是负责线程同步的,一个事务可以有多个线程,所以他们不在同一粒度。另外只用 Java 提供的这些类并不能很好地实现 2PL 协议,也并不契合事务并发场景。因此我们需要自己实现事务的读写锁机制,但是类库中的一些思想可以借鉴。

我们定义一个 LockManger 来负责维护事务和锁的状态,在 BufferPool 中事务只需调用相应的方法来获取和释放锁就行了。可以想到 LockManger 需要提供以下方法:

// 获取共享锁
void acquireSharedLock(TransactionId tid, PageId pid);
// 获取排他锁
void acquireExclusiveLock(TransactionId tid, PageId pid);
// 释放锁
void releaseLock(TransactionId tid, PageId pid);
// 是否持有锁
boolean holdsLock(TransactionId tid, PageId pid)

更新后的 BufferPool.getPage 方法如下(因为是页面级锁定,所以只在这里获取锁):

{
    if(perm == Permissions.READ_ONLY){  
        // 获取共享锁  
        if(!holdsLock(tid, pid)) {  
            lockManager.acquireSharedLock(tid, pid);  
	    }  
	}else{  
		// 获取排他锁 - 存在锁升级情况,所以不判断holdsLock  
		lockManager.acquireExclusiveLock(tid, pid);  
	}
    ...
    //缓存中获取页面
    ...
    return page;
} 

所谓事务获取到锁就是“放行”,获取不到就是“阻塞”。

这里的“锁”其实说成“锁的使用权”或“钥匙”更贴切一点。每个页面其实只有一把锁,需要钥匙才能进入访问。而共享锁,就是说这把锁可以有多把钥匙开启,每一把钥匙给一个事务;排他锁就是只能有一把钥匙给唯一的事务。如果事务获取不到钥匙就被阻塞。其实 Java 中的重量级锁也是这个道理,有时候会被“锁”这个名词给绕晕。

LockManger 的作用就是记录谁拥有某个页面的钥匙,是把什么样的钥匙,为了统一起见,下文仍称“锁”。

接下来是 LockManger 的实现,既然每个页面只有一把锁,并且需要维护这把锁的状态和与事务的关系,那么就可以设计一个 PageLock 类来管理:

class PageLock{  
    private PageId pageId; // 页面ID
    private int lockState; // 0:空闲,-1:排他锁,>0:获取到共享锁的事务数量  
    CopyOnWriteArrayList<TransactionId> holds; // 获取锁到的事务  
    public PageLock(PageId pageId){  
        this.pageId = pageId;  
        holds = new CopyOnWriteArrayList<>();  
    }    
    // 避免并发修改
    public synchronized void stateIncrement(int n){  
	    lockState += n;  
	}  
	public synchronized int getLockState(){  
	    return lockState;  
	}
}

用一个 lockState 记录这个页面锁的状态。等于 0 代表该页面是空闲的,没有事务访问(无人持锁);等于 -1 代表该页面的锁为排他锁;大于 0 代表该页面的锁为共享锁,具体数字表示有多少事务正在共享该锁。holds 记录了都是哪些(个)事务获取到该锁。

在 LockManger 中,我们用一个 Map 记录页面和锁的对应关系;为了方便查询,同样用一个 Map 记录事务和其所持有的锁集合的对应关系:

private Map<PageId, PageLock> pageLocks;
private Map<TransactionId, List<PageId>> lookups;

在实现“阻塞”效果时,采用了 wait/notify 的方式,也可采用 JUC 中的各种合适的工具类。注意如果仅仅是为了实现读写锁的话,不需要我们自己记录哪些事务陷入了等待,因为这些工具内部已经实现了记录阻塞线程的逻辑,可以在需要时唤醒。但是在后面实现死锁检测的时候,还是需要记录的。

LockManager 需要对外提供的四个方法实现如下:
申请共享锁

public void acquireSharedLock(TransactionId tid, PageId pid) throws TransactionAbortedException {  
	// 拿到页面对应的锁,如果还没有就新建一个
    PageLock pageLock = getPageLock(pid);  
	synchronized(pageLock){
		// 是排他锁且不是同一个事务(如果是同一个事务直接放行)  
	    while(pageLock.getLockState() == -1
			    && !pageLock.holds.get(0).equals(tid)){  
			try {  
				pageLock.wait();  // 阻塞
			} catch (InterruptedException e) {  
				throw new RuntimeException(e);  
			}    
		} 
		if(pageLock.getLockState() > 0
				 && pageLock.holds.contains(tid)){  
		    // 重入共享锁 - 不记录  
		    return;  
		}
		// 锁空闲、是共享锁、有排他申请共享,这几种都放行
		// 获取到锁后,记录已获取状态  
	    pageLock.stateIncrement(1); // 共享数量+1
	    pageLock.holds.add(tid);  
	    addToLookups(tid, pid);
	}
}

申请排他锁

public void acquireExclusiveLock(TransactionId tid, PageId pid) throws TransactionAbortedException {  
    PageLock pageLock = getPageLock(pid);  
	synchronized(pageLock){
		// 只要锁不空闲,就不能获取排他锁(除非锁升级)
	    while(pageLock.getLockState() != 0){  
	        // 该事务已经获取了共享锁,且它独占  
	        if(pageLock.getLockState() == 1 
		        && pageLock.holds.get(0).equals(tid)){  
	            // 升级为排他锁 - 放行且不记录
	            pageLock.stateIncrement(-2);  // 此时lockState变-1
	            return;  
	        }else if(pageLock.getLockState() == -1
		        && pageLock.holds.get(0).equals(tid)){  
	            // 该事务已经获取了排他锁,又重入 - 放行且不记录  
	            return;  
	        }          
	        try {  
		        // 否则阻塞
	            pageLock.wait();  
	        } catch (InterruptedException e) {  
	            throw new RuntimeException(e);  
	        }    
	    }
	    // 获取到锁后,记录已获取状态  
	    pageLock.stateIncrement(-1);  
	    pageLock.holds.add(tid);  
	    addToLookups(tid, pid);  
    }
}

这里直接在方法上加 synchronized 也是可行的,但是这样需要每次 notifyAll 所有阻塞的线程,针对性不强。因为每个页面有一个锁,不妨对 pageLock 加锁,这样每次只需 notify 一个阻塞在本页面的线程即可。注意后者需要在 wait 的时候设定等待超时时间,因为会出现别的线程先 notify 后,本线程才进入 wait 的情况,会永久阻塞下去,而设置超时时间后就会不停的循环判断锁条件。这是 wait/notify 方法的固有问题,如果想避免可以用 Semaphore 等其他工具。 (搞错了,没有这个问题,因为 PageLock 已经互斥访问了)

释放锁

public void releaseLock(TransactionId tid, PageId pid){ 
	PageLock pageLock = getPageLock(pid);  
	synchronized(pageLock){
		pageLock.holds.remove(tid);  // 从持有者中去除
	    removeFromLookups(tid, pid); // 从查询表中去除
	    if(pageLock.getLockState() == -1) {  
	    // 如果当前为排他锁,更新为空闲
	        pageLock.stateIncrement(1);  
	    }else if(pageLock.getLockState() > 0){ 
	    // 如果当前为共享锁,持有数-1
	        pageLock.stateIncrement(-1);  
	    }    
	    // 当没有事务拿着锁了(空闲状态),或只有一个事务拿着锁(可能有锁升级不成功从而等待的情况)  
	    if(pageLock.getLockState()==0||pageLock.getLockState()==1){  
		    // 唤醒该页面阻塞的某个事务去竞争空闲锁或升级锁
	        pageLock.notify();
	    }
    }
}
死锁检测

检测死锁通常是通过事务之间的等待关系图是否有回路(循环等待)来判断,具体的方法有两种:

  1. 拓扑排序:反复寻找一个入度为 0 的顶点,将顶点从图中删除并同时删除它的所有出边,如果最终图中全部剩下入度为 1 的顶点,则图中有回路;如果最终全部顶点都被删除,则不包含回路。
  2. DFS:从所有的点开始进行深度优先搜索,如果一条 DFS 路线中有顶点被第二次访问到,则图中有回路,否则不包含回路。

本实验采用 DFS 方法。

设计死锁检测器类 DeadlockDetector:

public class DeadlockDetector {  
    // 图的邻接表
    private Map<TransactionId, List<TransactionId>> adjList;   
    // 顶点状态 - null/0:未访问,1:已访问,2:在递归栈内
    private Map<TransactionId, Integer> nodeState;   
  
    public DeadlockDetector(){  
        adjList = new ConcurrentHashMap<>();  
        nodeState = new ConcurrentHashMap<>();  
    }  
    // 阻塞将要发生 - 进行记录
    public void blockOccurs(TransactionId tid, List<TransactionId> listToWait){  
        adjList.put(tid, listToWait);  
    }  
    // 事务被唤醒 - 删除记录
    public void notified(TransactionId tid){  
        adjList.remove(tid);  
    }  
    // DFS检测是否有回路
    public boolean detectCycle(){  
        nodeState.clear();  
        for(TransactionId tid:adjList.keySet()){  
            if(dfs(tid)){  
                return true;  
            }        
        }        
        return false;  
    }  
    private boolean dfs(TransactionId tid){  
        nodeState.put(tid, 2); // 标记入递归栈  
        List<TransactionId> adj = adjList.get(tid);  
        if(adj != null){  
            for(TransactionId t:adj){  
                // 跳过自反边的情况 - 单个锁升级等待不算死锁
                if(tid.equals(t)) continue;  
                int state = nodeState.getOrDefault(t, 0);  
                if(state == 2){  
                    return true; // 找到环  
                }else if(state == 0 && dfs(t)){   // 递归
                    return true; // 找到环  
                }  
            }        
        }        
        nodeState.put(tid, 1); // 出递归栈,标记已访问  
        return false;  
    }

为了简便,不再设计顶点类,而是让每一个 TransactionId 代表自己的顶点,采用图的邻接表表示法。nodeState 记录 DFS 中顶点的状态。

每当发生一个阻塞就调用 blockOccurs 方法,因为是页面级的锁定,所以一个事务陷入阻塞后一定等待的是持有页面锁的所有事务,也就是 PageLock 里面 holds 列表所存储的事务。所以我们只需每次将 holds 传入第二个参数,当做该事务顶点的所有出边(表示等待)即可。当事务获得锁(或者发现死锁)后,调用 notified 方法删除该顶点的所有出边。

更新获取共享锁的代码如下(循环部分):

...
while(pageLock.getLockState() == -1 
	  && !pageLock.holds.get(0).equals(tid)){  
    deadlockDetector.blockOccurs(tid, pageLock.holds); // 添加等待边 
    if(deadlockDetector.detectCycle()){ // 检测到死锁  
        deadlockDetector.notified(tid);  // 移除等待边
        // 抛出异常,SimpleDB会abort该事务
        throw new TransactionAbortedException(); 
    }    
    try {  
        pageLock.wait(10);  
        deadlockDetector.notified(tid);  // 移除等待边
    } catch (InterruptedException e) {  
        throw new RuntimeException(e);  
    }
}
...

获取排他锁同理。

DEBUG 记录

  1. PageCache 中的 page 数量可能会小于 LockManger 中的 page 数量,所以在根据 LockManager 中的 Page 来 flush 的时候需要进行 null 判断。
  2. 在 Transaction system test 的 10 个线程测试中,出现了时而成功时而永久阻塞的情况。经过调试,
    1. 发现是在 flushAllPages() 的循环里卡住;
    2. 发现 pageCache 返回的 Iterator 会不停的给出同一个 next 页面,死循环;
    3. 发现原因是自定义的双向链表尾结点 tail 丢失链接,导致无法停止遍历;
    4. 发现是没有注意线程安全的问题。在之前实现 LRUBasedPageCache 的时候没有使用 ConcurrentHashMap 类和 synchronized 关键字,导致并发问题,修改之后就没有问题了。
  3. 多线程情况下,所有的 Map 都最好用 ConcurrentHashMap,List 最好用 CopyOnWriteArrayList,它们除了是线程安全的,还支持遍历时修改,不会报并发修改异常。
  4. 在实现死锁检测的时候要注意:当一个事务已经获取共享锁,又要升级为排他锁,此时如果共享数不为 1,那么就要阻塞,但这个时候就会出现自己等待自己的情况(等待图中体现为自反边),但这不是死锁(因为其他的共享锁事务在 release 时会进行唤醒,当共享数为 1 时就不继续等待了),不应该被识别。所以在 DFS 的时候要跳过自反边。

Lab 仓库地址:zyrate/simple-db-hw-2021 (github.com)

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值