Lab4 是实现 SimpleDB 的并发事务系统,跟前面的内容相比较为复杂。
一般来说数据库的事务需要满足 ACID 特性,即原子性、一致性、隔离性、持久性。
原子性就是该事务的所有操作要么全部完成,要么全部取消,要求通过下面的操作保证:
- 不从页面缓存中逐出“脏页”(被某个事务更新的页面)。——NO STEAL 规则
- 在事务正确提交时,强制刷新所有脏页到磁盘。
隔离性就是同时执行的多个事务不会相互干扰,通过将要实现的锁机制保证。
一致性在 SimpleDB 中没有强调,持久性应该在 Lab6 的恢复功能上体现。
SimpleDB 事务并发控制实现
锁机制
在数据库中锁定对象可以是表、页面、元组、属性等,SimpleDB 规定的锁定粒度是页面(Page)。可供事务获取的锁类型有两种:共享锁和排他锁,规则如下。
- 事务在读取页面之前,必须具有共享锁;
- 事务在修改页面之前,必须具有排他锁;
- 多个事务可以在一个页面上具有共享锁;
- 只有一个事务可以在一个对象有排他锁。
在该规则下,如果一个事务在请求页面的时候,无法获取该页面的锁,就必须被阻塞,以等待锁资源被其他事务释放留给自己去竞争。特别的是,如果一个事物在申请排他锁时,如果已经持有了该页面的共享锁且是唯一一个持有者,那么可将此共享锁升级为排他锁(锁升级)。
两阶段锁(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();
}
}
}
死锁检测
检测死锁通常是通过事务之间的等待关系图是否有回路(循环等待)来判断,具体的方法有两种:
- 拓扑排序:反复寻找一个入度为 0 的顶点,将顶点从图中删除并同时删除它的所有出边,如果最终图中全部剩下入度为 1 的顶点,则图中有回路;如果最终全部顶点都被删除,则不包含回路。
- 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 记录
- PageCache 中的 page 数量可能会小于 LockManger 中的 page 数量,所以在根据 LockManager 中的 Page 来 flush 的时候需要进行 null 判断。
- 在 Transaction system test 的 10 个线程测试中,出现了时而成功时而永久阻塞的情况。经过调试,
- 发现是在
flushAllPages()
的循环里卡住; - 发现 pageCache 返回的 Iterator 会不停的给出同一个 next 页面,死循环;
- 发现原因是自定义的双向链表尾结点 tail 丢失链接,导致无法停止遍历;
- 发现是没有注意线程安全的问题。在之前实现 LRUBasedPageCache 的时候没有使用
ConcurrentHashMap
类和synchronized
关键字,导致并发问题,修改之后就没有问题了。
- 发现是在
- 多线程情况下,所有的 Map 都最好用 ConcurrentHashMap,List 最好用 CopyOnWriteArrayList,它们除了是线程安全的,还支持遍历时修改,不会报并发修改异常。
- 在实现死锁检测的时候要注意:当一个事务已经获取共享锁,又要升级为排他锁,此时如果共享数不为 1,那么就要阻塞,但这个时候就会出现自己等待自己的情况(等待图中体现为自反边),但这不是死锁(因为其他的共享锁事务在 release 时会进行唤醒,当共享数为 1 时就不继续等待了),不应该被识别。所以在 DFS 的时候要跳过自反边。