到lab4事务,这一块应该是数据库中概念比较多,且比较硬核的部分了,打算把之前学的很多概念再重新串一下,再重新复习一遍。
在本次的SimpleDB中将要实现一个简单的基于锁定的事务系统。您需要在代码中的适当位置添加锁和解锁调用,以及跟踪每个事务所持有的锁并根据需要向事务授予锁的代码。
事务是一组以原子方式执行的数据库操作(例如,插入、删除和读取);也就是说,要么所有的动作都完成了,要么没有完成,而且对于数据库的外部观察者来说,这些动作不是作为单个不可分割动作的一部分完成的。
原子性(Atomicity )
原子性是指事务是一个不可分割的工作单位,事务中的操作要么都发生,要么都不发生。
一致性(Consistency )
数据库总是从一个一致性的状态转移到另一个一致性的状态。一致性确保了即使在执行第三、第四条语句之间时系统崩溃,前面执行的第一、第二条语句也不会生效,因为事务最终没有提交,所有事务中所作的修改也不会保存到数据库中。
隔离性(Isolation )
一个事务的执行不能其它事务干扰。即一个事务内部的操作及使用的数据对其它并发事务是隔离的,并发执行的各个事务之间不能互相干扰。
持久性( Durability )
指一个事务一旦提交,它对数据库中的数据的改变就应该是永久性的。接下来的其它操作或故障不应该对其执行结果有任何影响。
而lab中对四大特性的保证也给出在outline中:
- 原子性:严格的两阶段锁定和谨慎的缓冲区管理确保了原子性。
- 一致性:由于原子性,数据库是事务一致的。SimpleDB中没有解决其他一致性问题(例如,关键约束)。
- 隔离:严格的两阶段锁定提供隔离。
- 持久性:FORCE缓冲区管理政策确保耐久性(见outline第2.3节)
事务隔离级别
eg: 张三的工资为5000,事务A中把他的工资改为8000,但事务A尚未提交。与此同时,事务B正在读取张三的工资,读取到张三的工资为8000。随后,事务A发生异常,而回滚了事务。张三的工资又回滚为5000。最后,事务B读取到的张三工资为8000的数据即为脏数据,事务B做了一次脏读。
eg: 在事务A中,读取到张三的工资为5000,操作没有完成,事务还没提交。与此同时,事务B把张三的工资改为8000,并提交了事务。随后,在事务A中,再次读取张三的工资,此时工资变为8000。在一个事务中前后两次读取的结果并不致,导致了不可重复读。
eg: 目前工资为5000的员工有10人,事务A读取所有工资为5000的人数为10人。此时,事务B插入一条工资也为5000的记录。这是,事务A再次读取工资为5000的员工,记录为11人。此时产生了幻读。
(1)共享锁「 S锁 」
又称读锁。若事务T对数据对象A加上S锁,则事务T可以读A但不能修改A,其他事务只能再对A加S锁,而不能加X锁,直到T释放A上的S锁。这保证了其他事务可以读A,但在T释放A上的S锁之前不能对A做任何修改。
(2)排他锁「 X锁 」
又称写锁。若事务T对数据对象A加上X锁,事务T可以读A也可以修改A,其他事务不能再对A加任何锁,直到T释放A上的锁。这保证了其他事务在T释放了A上的锁之前不能再读取和修改A。
(3) 意向锁「 L锁 」
加意向锁的目的是为了表明某个事务正在锁定一行或者将要锁定一行。表名加锁的“意图”。而为什么需要意向锁呢?
回到S锁与X锁,S锁和X锁都是行级别(rwo-level)的行锁,是加在索引(记录)上的,兼容与否是指对同一条记录(row)来说的。而假设需要对1000w数据的表加锁,则需要判断每条数据是否有正在上的锁,这个效率是非常低的。
这个时候,就会希望锁的层次能有区别,例如加X、S锁在行级时,先在表级别加个锁,这样判断有没有底层的Tuple是否有锁时,直接判断表级的锁就行,而这个锁就是意向锁。
意向锁有两种:
意向共享锁(IS):表示事务意图在表中的单个行上设置共享锁。
意向排他锁(IX):表明事务意图在表中的单个行上设置独占锁。
这部分则引用下CMU15-445的ppt。
两阶段锁协议是一种能够无需提前知道未来所有事务内容的情况下保证调度冲突可串行化的一种并发协议。
两阶段锁有三个阶段:
两阶段锁协议、严格两阶段锁协议和强两阶段锁协议。 这三个阶段是不断增强的,目的是解决并发过程中出现的各种问题。
其优点:
是保证冲突可串行化的充分条件。
当一个调度中有连续的R1(A)W2(A)时,说明事务1已经UNLOCK A的锁了,(因为W2(A)需要申请排它锁,必须等事务1释放A的锁)并且不再申请其他锁,则事务1和2不会有冲突的动作,即冲突可串行化。
但是对于两阶段锁协议会有一个问题,以下是2PL执行T1与T2事务的一个case:
当T1获取了A的锁后,对A进行了读写操作。而后又释放了A的锁,使T2对A可以读写。但是T1的事务最终又ABORT的,也就是T2对A的操作也需要回滚。这就是级联中止。
严格两阶段锁协议:
除了满足两阶段锁协议规定外,还要求事务的排它锁必须在事务提交之后释放。因此严格两阶段锁协议解决了级联中止的问题,也就是脏读的问题。因为如果其他事务如果获取了锁,而这个锁之前的事务也一定是被提交了,而不会再被回滚。但由于只是排他锁必须在事务提交后释放,所以会有重复读的问题。
强两阶段锁协议:
除了满足两阶段锁协议外,还要求所有锁都必须在事务提交之后释放,解决了不可重复读。
三个协议看下来,其实都是一个trade off的道理。虽然事务的隔离型上去了,但是锁的粒度其实变大了,而导致并发度下降了。
对于锁的粒度从大到小应该是 Database -> Table -> Page -> Tuple。
而本次的Exercise只要求实现Page级别,也就是在BufferPool中的Granting Locks。且实现的排它与共享锁应满足以下的条件:
- 在一个事务读取一个对象前,应该要有其的共享锁。
- 在一个事务写入一个对象前,应该要有其的排它锁。
- 一个对象的共享锁可以被多个事务共享。
- 一个对象的排它锁只能被一个事务所拥有。
- 如果一个事务已经拥有了一个对象的共享锁,则其拥有的锁可以被升级为排它锁。
由此就可以来分析设计锁、页、事务之间的三者关系。
可以看出一个页其实是可以有多个锁的(排它锁),而锁只能对应一个页,所以页与锁是一对多的关系。而锁与事务之间可以看出也是一对多的关系。因此可以构造关于PageLock的类:
@AllArgsConstructor
@Data
private class PageLock{
private TransactionId tid;
private LockType type;
private Integer pid;
}
关于LockType的枚举:
public enum LockType {
SHARE_LOCK (0,"共享锁"),
EXCLUSIVE_LOCK(1,"排它锁");
@Getter
private Integer code;
@Getter
private String value;
LockType(int code,String value) {
this.code = code;
this.value = value;
}
}
关于获取锁的流程图笔者简单画了个图:
则接下来可以构造outline中提到的LockManager类:
private class LockManager {
@Getter
public ConcurrentHashMap<PageId, ConcurrentHashMap<TransactionId, PageLock>> lockMap;
public LockManager() {
lockMap = new ConcurrentHashMap<>();
}
/**
* Return true if the specified transaction has a lock on the specified page
*/
public boolean holdsLock(TransactionId tid, PageId p) {
// some code goes here
// not necessary for lab1|lab2
if(lockMap.get(p) == null){
return false;
}
return lockMap.get(p).get(tid) != null;
}
public synchronized boolean acquireLock(PageId pageId, TransactionId tid, LockType requestLock, int reTry) throws TransactionAbortedException, InterruptedException {
// 重传达到3次
if (reTry == 3) return false;
// 用于打印log
final String thread = Thread.currentThread().getName();
// 页面上不存在锁
if (lockMap.get(pageId) == null) {
return putLock(tid,pageId,requestLock);
}
// 页面上存在锁
ConcurrentHashMap<TransactionId, PageLock> tidLocksMap = lockMap.get(pageId);
if (tidLocksMap.get(tid) == null) {
// 页面上的锁不是自己的
// 请求的为X锁
if (requestLock == LockType.EXCLUSIVE_LOCK) {
wait(100);
return acquireLock(pageId, tid, requestLock, reTry + 1);
} else if (requestLock == LockType.SHARE_LOCK) {
// 页面上是否都是读锁 -> 页面上的锁大于1个,就都是读锁
// 因为排它锁只能被一个事务占有
if (tidLocksMap.size() > 1) {
// 都是读锁直接获取
return putLock(tid,pageId,requestLock);
} else {
Collection<PageLock> values = tidLocksMap.values();
for (PageLock value : values) {
// 存在的唯一的一个锁为X锁
if (value.getType() == LockType.EXCLUSIVE_LOCK) {
wait(100);
return acquireLock(pageId, tid, requestLock, reTry + 1);
} else {
return putLock(tid,pageId,requestLock);
}
}
}
}
}else {
if (requestLock == LockType.SHARE_LOCK) {
tidLocksMap.remove(tid);
return putLock(tid,pageId,requestLock);
}else {
// 判断自己的锁是否为排它锁,如果是直接获取
if(tidLocksMap.get(tid).getType() == LockType.EXCLUSIVE_LOCK){
return true;
}else {
// 拥有的是读锁,判断是否还存在别的读锁
if(tidLocksMap.size() > 1){
wait(100);
return acquireLock(pageId, tid, requestLock, reTry + 1);
}else{
// 只有自己拥有一个读锁,进行锁升级
tidLocksMap.remove(tid);
return putLock(tid,pageId,requestLock);
}
}
}
}
return false;
}
public boolean putLock(TransactionId tid, PageId pageId, LockType requestLock){
ConcurrentHashMap<TransactionId, PageLock> tidLocksMap = lockMap.get(pageId);
// 页面上一个锁都没
if(tidLocksMap == null){
tidLocksMap = new ConcurrentHashMap<>();
lockMap.put(pageId,tidLocksMap);
}
PageLock pageLock = new PageLock(tid, pageId, requestLock);
tidLocksMap.put(tid, pageLock);
lockMap.put(pageId, tidLocksMap);
return true;
}
}
/**
* 释放某个页上tid的锁
*/
public synchronized void releasePage(TransactionId tid, PageId pid) {
if (holdsLock(tid,pid)){
ConcurrentHashMap<TransactionId,PageLock> tidLocks = lockMap.get(pid);
tidLocks.remove(tid);
if (tidLocks.size() == 0){
lockMap.remove(pid);
}
// 释放锁时就唤醒正在等待的线程,因为wait与notifyAll都需要在同步代码块里,所以需要加synchronized
this.notifyAll();
}
}
除此之外还需补齐释放锁、以及完善之前getPage关于事务的逻辑:
/**
* Releases the lock on a page.
* Calling this is very risky, and may result in wrong behavior. Think hard
* about who needs to call this and why, and why they can run the risk of
* calling it.
*
* @param tid the ID of the transaction requesting the unlock
* @param pid the ID of the page to unlock
*/
public synchronized void unsafeReleasePage(TransactionId tid, PageId pid) {
// some code goes here
// not necessary for lab1|lab2
lockManager.releasePage(tid,pid);
}
public Page getPage(TransactionId tid, PageId pid, Permissions perm)
throws TransactionAbortedException, DbException {
// some code goes here
LockType lockType;
if(perm == Permissions.READ_ONLY){
lockType = LockType.SHARE_LOCK;
}else {
lockType = LockType.EXCLUSIVE_LOCK;
}
try {
// 如果获取lock失败(重试3次)则直接放弃事务
if (!lockManager.acquireLock(pid,tid,lockType,0)){
// 获取锁失败,回滚事务
throw new TransactionAbortedException();
}
} catch (InterruptedException e) {
e.printStackTrace();
System.out.println("Method 「 getPage 」获取锁发生异常!!!");
}
// bufferPool应直接放在直接内存
if (lruCache.get(pid) == null) {
DbFile file = Database.getCatalog().getDatabaseFile(pid.getTableId());
Page page = file.readPage(pid);
lruCache.put(pid, page);
}
return lruCache.get(pid);
}
对于Exercise2部分其实主要还是完善getPage,以及调用getPage的相关锁的部分,因为获取锁的部分都在getPage()。主要的调用就在与HeapFile的insertTuple(),deleteTuple(),iterator() 等方法,以及需要check下是否把页变更为了脏页。而解锁部分则由Test代码调用transactionComplete()释放,也是后面练习实现的一部分。
这边还有一个两个点就是需要注意:
由此可以简单的给出代码:
public List<Page> insertTuple(TransactionId tid, Tuple t)
throws DbException, IOException, TransactionAbortedException {
// some code goes here
// not necessary for lab1
ArrayList<Page> pageList= new ArrayList<Page>();
for(int i=0;i<numPages();++i){
// took care of getting new page
HeapPageId heapPageId = new HeapPageId(this.getId(), i);
HeapPage p = (HeapPage) Database.getBufferPool().getPage(tid,
heapPageId,Permissions.READ_WRITE);
if(p.getNumUnusedSlots() == 0){
// lab4 解锁
Database.getBufferPool().unsafeReleasePage(tid,heapPageId);
continue;
}
p.insertTuple(t);
pageList.add(p);
return pageList;
}
// 如果现有的页都没有空闲的slot,则新起一页
BufferedOutputStream bw = new BufferedOutputStream(new FileOutputStream(f,true));
byte[] emptyData = HeapPage.createEmptyPageData();
bw.write(emptyData);
bw.close();
// 加载进BufferPool
HeapPage p = (HeapPage) Database.getBufferPool().getPage(tid,
new HeapPageId(getId(),numPages()-1),Permissions.READ_WRITE);
p.insertTuple(t);
p.markDirty(true, tid);
pageList.add(p);
return pageList;
}
private Iterator<Tuple> getTupleIterator(int pageNumber) throws TransactionAbortedException, DbException{
if(pageNumber >= 0 && pageNumber < heapFile.numPages()){
HeapPageId pid = new HeapPageId(heapFile.getId(),pageNumber);
HeapPage page = (HeapPage)Database.getBufferPool().getPage(tid, pid, Permissions.READ_ONLY);
return page.iterator();
}else{
throw new DbException(String.format("heapFile %d does not exist in page[%d]!", pageNumber,heapFile.getId()));
}
}
// see DbFile.java for javadocs
public List<Page> deleteTuple(TransactionId tid, Tuple t) throws DbException,
TransactionAbortedException {
// some code goes here
// not necessary for lab1
HeapPage page = (HeapPage) Database.getBufferPool().getPage(tid,
t.getRecordId().getPageId(),Permissions.READ_WRITE);
page.deleteTuple(t);
page.markDirty(true, tid);
return Collections.singletonList(page);
}
测试则还是练习1的LockingTest就不放了。
事务的修改仅在提交后写入磁盘。这意味着我们可以通过丢弃脏页并从磁盘重新读取它们来中止事务(回滚)。因此,我们不能驱逐脏页。
因此本次的lab还是根据S2PL检查之前写的相关驱逐政策。而这里笔者是直接写到内置的LRU中的,因此修改的是put方法。
public synchronized void put(PageId key, Page value) throws DbException {
Node newNode = new Node(key, value);
if(map.containsKey(key)){
remove(map.get(key));
}else{
size++;
if(size > cap){
Node removeNode = tail.pre;
// 丢弃不是脏页的页
while (removeNode.val.isDirty() != null && removeNode != head){
removeNode = removeNode.pre;
}
if(removeNode == head || removeNode == tail){
throw new DbException("没有合适的页存储空间或者所有页都为脏页!!");
}
map.remove(tail.pre.key);
remove(tail.pre);
size--;
}
}
moveToHead(newNode);
map.put(key,newNode);
}
在SimpleDB中,在每个查询的开头创建一个TransactionId对象。此对象将传递给查询中涉及的每个运算符。查询完成后,将调用BufferPool方法 transactionComplete()。
因此transactionComplete()需要对事务的完成做一个处理首先得分事务成功完成,还是失败(需要回滚)。
/**
* Release all locks associated with a given transaction.
*
* @param tid the ID of the transaction requesting the unlock
*/
public void transactionComplete(TransactionId tid) {
// some code goes here
// not necessary for lab1|lab2
transactionComplete(tid,true);
}
/**
* Commit or abort a given transaction; release all locks associated to
* the transaction.
*
* @param tid the ID of the transaction requesting the unlock
* @param commit a flag indicating whether we should commit or abort
*/
public void transactionComplete(TransactionId tid, boolean commit) {
// some code goes here
// not necessary for lab1|lab2
if(commit){
try {
flushPages(tid);
} catch (IOException e) {
e.printStackTrace();
}
}
else {
rollBack(tid);
}
lockManager.releasePagesByTid(tid);
}
@SneakyThrows
public synchronized void rollBack(TransactionId tid){
for (Map.Entry<PageId, LRUCache.Node> group : lruCache.getEntrySet()) {
PageId pageId = group.getKey();
Page page = group.getValue().val;
if (tid.equals(page.isDirty())) {
int tableId = pageId.getTableId();
DbFile table = Database.getCatalog().getDatabaseFile(tableId);
Page readPage = table.readPage(pageId);
lruCache.removeByKey(group.getKey());
lruCache.put(pageId,readPage);
}
}
}
死锁就是多个并发进程因争夺系统资源而产生相互等待的现象。
死锁产生的四个必要条件
互斥条件: 进程对所分配到的资源不允许其他进程访问,若其他进程访问该资源,只能等待,直至占有该资源的进程使用完成后释放该资源。
请求和保持条件: 进程获得一定的资源后,又对其他资源发出请求,但是该资源可能被其他进程占有,此时请求阻塞,但该进程不会释放自己已经占有的资源。
不可剥夺条件: 进程已获得的资源,在未完成使用之前,不可被剥夺或不能被其他进程强行夺走,只能在使用后自己释放。
循环等待条件: 进程发生死锁后,必然存在一个进程-资源之间的环形链 ,环路中每个进程都在等待下一个进程所占有的资源。
死锁的处理方法,主要有以下方法:
死锁检测与死锁恢复
死锁预防
死锁避免
1 、死锁的检测和解除
在死锁产生前不采取任何措施,只检测当前系统有没有发生死锁,若有,则采取一些措施解除死锁。
根据死锁定理:S 为死锁的条件是当且仅当 S 状态的资源分配图是不可完全简化的,该条件称为死锁定理。
①如果资源分配图中没有环路,则系统没有死锁; ②如果资源分配图中出现了环路,则系统可能有死锁。
1、资源剥夺: 挂起某些死锁进程,并抢占它的资源,将这些资源分配给其他死锁进程。(但应该防止被挂起的进程长时间得不到资源,而处于资源匮乏的状态。);
2、撤销进程: 强制撤销部分、甚至全部死锁进程并剥夺这些进程的资源。(撤销的原则可以按进程优先级和撤销进程代价的高低进行);
3、进程回退: 让一个或多个进程回退到足以避免死锁的地步。「 进程回退时自愿释放资源而不是被剥夺。要求系统保持进程的历史信息,设置还原点。」
利用抢占恢复。 从一个或多个进程中抢占足够数量的资源分配给死锁进程,以解除死锁状态。
利用回滚恢复。 周期性地检查进程的状态(包括请求的资源),将其写入一个文件,当发生死锁,回滚到之前的某个时间点
通过杀死进程恢复。 终止或撤销系统中的一个或多个死锁进程,直至打破死锁状态。
2. 死锁预防
破坏四个必要条件之一
2.1、 破坏互斥条件:
改造独占性资源为虚拟资源,大部分资源已无法改造。
即允许进程同时访问某些资源。但是,有的资源是不允许被同时访问的,像打印机等等。所以,这种办法并无实用价值。
2.2、破坏不可剥夺条件:
当一进程占有一独占性资源后又申请一独占性资源而无法满足,则退出原占有的资源。
当一个进程已占有了某些资源,它又申请新的资源,但不能立即被满足时,它必须释放所占有的全部资源,以后再重新申请。这就相当于该进程占有的资源被隐蔽地强占了。这种预防死锁的方法实现起来困难,会降低系统性能。
2.3、破坏请求与保持条件:
2.4、破坏循环等待条件:实行顺序资源分配法
实现资源有序分配策略,对所有设备实现分类编号,所有进程只能采用按序号递增的形式申请资源。
首先给系统中的资源编号,规定每个进程,必须按编号递增的顺序请求资源,同类资源一次申请完。也就是说,只要进程提出申请分配资源Ri,则该进程在以后的资源申请中,只能申请编号大于Ri的资源。
3、避免死锁
银行家算法「在动态分配资源的过程中,银行家算法防止系统进入不安全状态,从而避免死锁 」
当进程首次申请资源时,要测试该进程对资源的最大需求量,如果系统现存的资源可以满足它的最大需求量则按当前的申请量分配资源,否则就推迟分配。
当进程在执行中继续申请资源时,先测试该进程已占用的资源数与本次申请资源数之和是否超过了该进程对资源的最大需求量。若超过则拒绝分配资源。若没超过则再测试系统现存的资源能否满足该进程尚需的最大资源量,若满足则按当前的申请量分配资源,否则也要推迟分配。
是指系统能按某种进程推进顺序(P1, P2, P3, …, Pn),为每个进程 Pi 分配其所需要的资源,直至满足每个进程对资源的最大需求,使每个进程都可以顺序地完成。这种推进顺序就叫安全序列「 银行家算法的核心就是找到一个安全序列 」。
回到lab中的outline给的思路大致有三种:
对于笔者在之前的实现其实就已经实现了超时等待,所以想思考下后两种,但是对于本次lab给出的提示中,想要获取一个事务将要获取的资源,可能比较困难。因为事务的开始和结束,都是在test的代码中完成,也因此超时重传的解决方式相比后两种应该算是比较被动的实现,但是也是最简单,比较适合的可以完成本次lab的方式。
关于本次的lab没做之前会觉得比较难,因为有关事务,但是做了之后发现觉得是这四个lab中应该算是最简单的,主要是很多东西能和之前的串在一起。且并发部分相比6.824简单很多,java有很多控制并发的手段。大概复习概念到写完博客总结花了两天时间。接下来简单记录下遇到的bug。
解决比较久的bug是在AbortEvictionTest中的,debug了两三小时,大概发现了是LRU实现的有问题,因为key是pageId,而很多时候传进来的是new的一个对象。因此如果在用get与contain的map内置方式时,想要的效果,其实和自己想要的不一样。以及还有一个地方是put的时候lru删除的节点删除了,导致把脏页驱逐了。
修改后的LRUCache
private static class LRUCache {
int cap,size;
ConcurrentHashMap<PageId,Node> map ;
Node head = new Node(null ,null);
Node tail = new Node(null ,null);
public LRUCache(int capacity) {
this.cap = capacity;
map = new ConcurrentHashMap<>();
head.next = tail;
tail.pre = head;
size = 0;
}
public synchronized Page get(PageId key) {
if(contain(key)){
remove(map.get(key));
moveToHead(map.get(key));
return map.get(key).val ;
}else{
return null;
}
}
public synchronized void put(PageId key, Page value) throws DbException {
Node newNode = new Node(key, value);
if(contain(key)){
remove(map.get(key));
}else{
size++;
if(size > cap){
Node removeNode = tail.pre;
// 丢弃不是脏页的页
while (removeNode.val.isDirty() != null ){
removeNode = removeNode.pre;
if(removeNode == head || removeNode == tail){
throw new DbException("没有合适的页存储空间或者所有页都为脏页!!");
}
}
map.remove(removeNode.key);
remove(removeNode);
size--;
}
}
moveToHead(newNode);
map.put(key,newNode);
}
public synchronized void remove(Node node){
Node pre = node.pre;
Node next = node.next;
pre.next = next;
next.pre = pre;
}
public synchronized void removeByKey(PageId key){
Node node = map.get(key);
remove(node);
}
public synchronized void moveToHead(Node node){
Node next = head.next;
head.next = node;
node.pre = head;
node.next = next;
next.pre = node;
}
public synchronized int getSize(){
return this.size;
}
public synchronized boolean contain(PageId key){
for (PageId pageId : map.keySet()) {
if (pageId.equals(key)) {
return true;
}
}
return false;
}
@Data
private static class Node{
PageId key;
Page val;
Node pre;
Node next;
public Node(PageId key ,Page val){
this.key = key;
this.val = val;
}
@Override
public String toString(){
return "Node: 「 key:"+ key +";value:" + val +" 」";
}
}
public Set<Map.Entry<PageId, Node>> getEntrySet(){
return map.entrySet();
}
@Override
public String toString(){
return "LRU: 「 cap:"+ cap +"size:" + size +" 」";
}
}
如有不足欢迎指正~
gitee地址