CMU 15-445 (2023 Spring)数据库实验p4记录--完结篇

全局的LM

根据隔离级别授予或释放锁

1.1 lock

  1. Lock为阻塞方法,直到获取锁才返回;如果事务在这个期间中止,不用授予锁并且返回false
  2. 对每个资源使用队列维护请求队列,采用FIFO方式。对兼容的锁请求(符合FIFO方式)可以同时授予。
  3. Table锁支持所有锁模式,Row锁只支持X/S
  4. 根据隔离级别处理锁的请求
  5. 当对Row加锁时,要确保该事务对Table持有相应的锁。
  6. 锁升级:锁升级的请求比其他事务请求优先级高。对同一资源多个并发锁升级请求应该将事务中止。
  7. 更新事务的lock sets

(1)请求队列放入请求,在头部的请求会授予锁,即队列头部的granted_设为True,解锁时,从头部删除节点。兼容的锁可以同时授予。请求队列中授予锁的请求肯定都在前面,而且是连续的,这就方便了后续遍历,只需找到第一个granted_ == false的即可停止遍历。

(2)判断是否为升级锁请求时,从头遍历请求队列,如果遇到granted_ = false则停止,否则判断是否为同一事务和同一资源,若是,则代表为锁升级请求。

也可以采用如下方式判断是否持有锁。不如遍历的方式简介。

  std::bitset<5> bs;
  bs[0] = txn->IsTableSharedLocked(oid);                            // S
  bs[1] = txn->IsTableExclusiveLocked(oid);                         // X
  bs[2] = txn->IsTableIntentionSharedLocked(oid);                   // IS
  bs[3] = txn->IsTableIntentionExclusiveLocked(oid);                // IX 
  bs[4] = txn->IsTableSharedIntentionExclusiveLocked(oid);          // SIX 
  if(  bs.any()  ) {                              //  如果持有该Tabel的某个锁
    if(  bs[static_cast<size_t>(lock_mode)] ) {     // 已经持有相同类型锁,返回True
      return true;
    }
    // 枚举需要的锁类型
    switch (lock_mode) {
      case LockMode::SHARED:
      case LockMode::INTENTION_EXCLUSIVE:
          is_update = bs[2];
          break;
      case LockMode::EXCLUSIVE:
          is_update = true;
          break;
      case LockMode::INTENTION_SHARED:
          is_update = false;
          break;
      case LockMode::SHARED_INTENTION_EXCLUSIVE:
          is_update = bs[0] || bs[2] || bs[3];
          break;
    }
    if( !is_update ) {                                              // 不允许的升级
      txn->LockTxn();
      txn->SetState(TransactionState::ABORTED);
      txn->UnlockTxn();
      throw TransactionAbortException(txn_id, AbortReason::INCOMPATIBLE_UPGRADE);
    }
    if(bs[0]) {
      original_mode = LockMode::SHARED;
    } else if(bs[1]) {
      original_mode = LockMode::EXCLUSIVE;
    } else if(bs[2]) {
      original_mode = LockMode::INTENTION_SHARED;
    } else if(bs[3]) {
      original_mode = LockMode::INTENTION_EXCLUSIVE;
    } else if(bs[4]) {
      original_mode = LockMode::SHARED_INTENTION_EXCLUSIVE;
    }
  }


  // 检查是否持有锁
  std::bitset<5> bs;
  bs[0] = txn->IsTableSharedLocked(oid);                            // S
  bs[1] = txn->IsTableExclusiveLocked(oid);                         // X
  bs[2] = txn->IsTableIntentionSharedLocked(oid);                   // IS
  bs[3] = txn->IsTableIntentionExclusiveLocked(oid);                // IX 
  bs[4] = txn->IsTableSharedIntentionExclusiveLocked(oid);          // SIX 
  LockMode original_mode;
  if(  bs.none()  ) {     
    txn->LockTxn();
    txn->SetState(TransactionState::ABORTED);
    txn->UnlockTxn();
    throw TransactionAbortException(txn_id, AbortReason::ATTEMPTED_UNLOCK_BUT_NO_LOCK_HELD);
  }

(3)调用wait时,其实期望的唤醒顺序为锁请求的顺序,即队列中的顺序。但实际上很难实现,因此每次从请求队列删除请求时都需要唤醒所有阻塞线程。

(4)处理普通锁请求时,在wait函数中也是相同逻辑。

  1. 由于锁升级请求拥有更高优先级,因此需要先判断是否有锁升级请求,若有,则直接等待。否则,转到第2步。
  2. 判断当前请求是否位于队列最前面,若在最前面,直接授予锁。
  3. 若不是最前面,要考虑兼容锁请求的实现,这里采用了上述std::bitset<5>的做法,遇到granted_ == true的请求时,就将其不兼容的锁设置为False,若遇到第一个granted_ == false的请求是当前请求,则判断bitset相应锁模式是否为True,若为则授于锁,否则进入等待。

(5)判断当前事务的状态,若中止,则删除当前请求,调用cv_.notify_all()。否则,授予锁。

1.1.1 出现的问题

(1)创建新的锁请求时,如下所示。然后再将此变量加入请求队列,而请求队列中保存的为指针。
但如下方式中这个请求为局部变量,当直接可以获取锁时,局部变量会销毁,从而引发问题。
将请求队列保存的裸指针修改为共享指针。

LockRequest cur_res(txn_id, lock_mode, oid);   
// 改为共享指针的做法
std::list<std::shared_ptr<LockRequest>> request_queue_;

(2)高并发情况下,会出现尝试解锁但未持有锁的异常,但此时应该是持有锁的状态。
出现了锁提前释放的情况,这个情况主要是由于释放锁时不正确的行为导致的,如下所示

req_ptr->request_queue_.pop_front();

锁释放时释放头部并不一定正确,因为可能有同时授予多个锁的情况,释放时一定要注意

(3)访问请求队列时,也一定要进行加锁,因为此时可能有另一个线程正在插入或删除,容易造成异常行为。
例如,一个线程使用for-range遍历list,还未遍历到a,另一个线程正要删除元素a,这会导致a的迭代器失效,此时,初始线程可能正好遍历到了a

(4)处理锁升级请求时,需要确保一些条件成立。

  1. 升级为X锁情况:例如S锁升级为X锁,此时需要确保S锁只授予了一个事务,即需要确保只有该事务持有该资源的锁。 任何其他锁升级为X锁,都是同理。
  2. 升级为SIX锁情况:只有当前事务持有S/IX锁,其余为IS锁或没有锁。或全为IS锁。
  3. 升级为S锁情况:资源上的其他锁全为IS或S锁
  4. 升级为IX锁情况:资源上的其他锁全为IS或IX锁

遍历当前授予锁的情况,统计各个锁的数量。

// sum_num 表示锁的总数(不包含本事务)
    if(sum_num == 0)   // 只有本事务持有锁
      is_wait = false;
    } else if(  sum_num == lock_num[2] && lock_mode != LockMode::EXCLUSIVE ) {
      is_wait = false;													 // 除了本事务之外全为IS锁,可以升级为S/IX/SIX
    } else if( sum_num == lock_num[2] + lock_num[0] && lock_mode == LockMode::SHARED ) {
      is_wait = false;													// 除了本事务之外全为IS和S锁,可以升级为S锁
    } else if( sum_num ==  lock_num[2] + lock_num[3] && lock_mode == LockMode::INTENTION_EXCLUSIVE ) {
      is_wait = false;													// 除了本事务之外全为IS和IX锁,可以升级为IX锁
    }

(5)根据测试用例的要求,再等待升级锁之前,就应该先释放原先锁。

  1. 正常的做法是删除原先在请求队列里的请求,然后插入新的请求,这个请求插在所有授予锁请求的后面(即第一个未授予的位置)。
  2. 其实没有必要删除原先在请求队列里的请求,只需要在事务的*lock_set中删掉原先锁即可,如果锁允许升级,直接修改原先请求的lock_mode_即可。在判断升级锁条件时,需要遍历请求队列,此时若遇到原先请求直接跳过即可。

(6)对Row加锁时,若加读锁,则只有Tuple持有锁即可。若加写锁,则Tuple需要持有X/IX/SIX锁。

(7)被一个测试用例卡麻的经验教训,改变了好几次策略一直是超时;最后看其他大佬的文章,发现原因是锁升级后,事务其实可能处于终止状态,需要释放掉当前锁,并且注意重置upgrading_之前只对正常的锁请求考虑了事务终止的情况,对锁升级请求没考虑这一点就直接进行了锁升级。

在这里插入图片描述
在这里插入图片描述

1.2 unlock

  1. 确保事务保持该锁,否则抛出异常

  2. 对table调用unlock时,要确保table中的row上没有任何锁

  3. 释放锁,应该通知等待队列,以便新的事务可以尽快拿到锁。

  4. unlock X/S锁时才会改变事务状态

  5. 更新事务的lock sets

使用链表时,需要注意。无论是使用插入前还是插入后的迭代器,进行加减运算时都有一定问题,插入前迭代器仍像原来的行为一样。
插入时,虽然当前元素的迭代器不会失效,但很可能并不会展现出期望的行为。(猜测是编译器优化的原因?)
使用push_back/emplace_back的话,可以展现出期望的行为。

 list<int> lst = {2,3,4,5};
 list<int>::iterator iter = lst.end();  // 原先迭代器
 auto iter2 = lst.insert( iter, 6 );	// 插入后返回的迭代器
 for(auto x : lst) {
     cout << x << endl;
 }
 cout << "iter1 " << (*(++iter)) << endl;     // 输出为 2
 cout << "iter2 " << (*(++iter2)) << endl;	  // 输出为 5
// 即使插入元素仍像原先一样
iter1 5
iter1 2
iter1 3
iter1 4
iter1 5

// iter2的遍历序列,遍历完5才后续行为正确。
iter2 6
iter2 5
iter2 2
iter2 3
iter2 4
iter2 5
iter2 6

1.3 死锁检测

等待图为有向图 A —> B 代表A正在等待B 且放入的顺序是有序的,按txn_id的从小到大放入,方便查找及后续的DFS。

(1)如何建立等待图?

遍历table_lock_map_row_lock_map_,然后判断等待的锁请求和已经授予的锁请求是否兼容,若不兼容,并且两个事务的状态都不为Abort,则在等待图中加入一条边。

因为遍历时要从最小的txn_id开始,因此在建立等待图时,还需要维护一个map来存储当前等待图中存在节点,之后按照map的顺序进行遍历即可。

(2)如何在有向图中检测环?

采用深度优先遍历,同时维护当前路径path和全局访问过的节点global。
每访问一个节点就需要在global中进行标识,防止重复遍历。如果要遍历的下一个节点已经出现在path中,那么就产生了环。

  cur_path.insert(cur_node);
  res = DFS(cur_node, global, cur_path);
  cur_path.erase(cur_node);

(3)如何打破环?

中止youngest的事务(就是txn_id最大的事务),需要遍历环中的所有节点,找到txn_id最大的节点,设置其状态为Abort的。
这里我的实现方式为使用了一个哈希表 + 链表的方式,链表保证了可以按插入顺序来访问路径,哈希表保证了快速查找是否遍历过了节点,其值为list的迭代器。
将其正在等待的边全部删除即可,再检测是否还有其他环即可。
若想释放事务持有的锁,且删除相应的锁请求的话,直接调用下述函数即可,在该函数其内部会调用unlockTable等函数删除持有的锁请求,被终止的事务肯定会持有一些锁;若其正在等待的锁请求也不用管,等该请求被相应时,会判断其状态,若已中止,则不用授予锁

txn_manager_->Abort(txn_ptr);

最好在各等待函数中添加如下条件,若事务被中止,等待的锁请求可以及时被删除。(因为等待的锁请求肯定有其他事务持有资源锁,等该事务释放锁时会进行删除,根据主动查找等待锁请求的方式不太高效,以这种被动方式来删除会浪费一点空间)

 if (txn->GetState() == TransactionState::ABORTED) {
   return true;
 }

1.4 查询并行执行

(1)加/解锁失败时,需要中止事务。并且需要执行undo之前事务的写操作,因此需要维护事务的write set;并且抛出异常。
一个事务中会有多个查询来访问Tuple。

(2)在维护write set时,对插入和删除操作,进行undo,有可能遇到先插入再删除或者先删除再插入同一数据的情况,这样如果直接进行undo就会引发错误。

主要是防止 先插入,再删除同一数据的情况; 而对先删除某一数据,再插入获得新的rid, undo可以正确处理,可以采用哈希表的方式去除。但也可以在undo时额外判断下当前的tuple meta是否正确,对于Insert的Undo操作,其meta.is_deleted_应该为False,如果出现为True的情况,则表明出现了先插入再删除的情况,因此需要在删除Undo时,额外进行判断。

std::unordered_set<RID> reduction_map;

(3)插入操作
新插入的Tuple会加X锁,那么其对Table应该加IX锁

对于Delete操作,应该对表加IX锁,而不是X锁。若加X锁,则可能造成有多个线程同时升级锁的情况。

(4)不能按照实验说明的做法来进行
you should assume all tuples scanned will be deleted, and you should take X locks on the table and tuple as necessary in step 2.
在Scan中,当前查询为Delete操作时,如果对Tuple都施加X锁,则无法通过MixedTest测试。
因为在该测试中,有串行执行的txn1和txn2事务,txn1会先进行Delete,然后txn2进行Scan,因为这txn1未提交,txn2会无限等待。

正确做法为:在Scan中,对Table加IS/IX锁(判断IsDelete()标志,避免Table的并行锁升级问题,在Init函数中实现更方便),对Tuple统一加S锁

在Delete中对删除的元素加X锁,此处需要注意 要先强制释放掉原Tuple的S锁(设置force = true,在可重复读隔离级别下才需要),再加X锁,避免Tuple的并发锁升级。

这样其他事务Scan时,也可以正常加S锁,删除的Tuple虽然有X锁,但可以跳过删除的元素并不影响结果。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值