CMU_15445_Project4_BonusTask_Abort
事务 Abort
tainted 状态存在什么问题
在此之前, 在 BUSTUB 中检测到写写冲突的时候, 会将事务的状态设置为 tained, 但是 tained 是一个临时保存的状态, tained 状态的事务不会释放已经获取的资源, 例如如果一个事务 txn_A 在修改一个 tuple1 的时候正常, 在修改另一个 tuple2 的时候发生了写写冲突, 导致该事务 txn_A tained, tained 状态的事务不会正常 commit, 因此其他时候尝试访问 tuple1 的时候也会发生写写冲突. 正确的做法应该是, 当事务发生写写冲突的时候, 冲突的事务应该 Abort, 正确的释放资源, 并且回退已经做的修改.
Abort 的实现方式
BUSTUB 给出了两种思路, 我采用了较为复杂的一种, 也是比较正常的一种. 这种实现 Abort 的步骤如下图所示:
上图中表现了 Abort 过程中两个重要的步骤, 分别是:
- 删除 VersionUndoLink 中该事务生成的 UndoLog, 重新链接版本链(UndoLink).
- 回退该事务对 TableHeap 中数据的修改.
注意上述的两个步骤必须都是原子的, 在 Abort 中, 修改 VersionUndoLink 与 回退 TableHeap 中的 tuple 需要同时获取表级锁与 VersionUndoLink 级别的锁. 这种方式同时支持, 在 Abort 某个事务之后, 可以直接在 transaction map 中删除该事务的信息, 而不必等到 Garbage Collection 再删除这个事务.
TableHeap 中新的锁管理机制
在之前的 Project 中需要使用 VersionUndoLink 的 in_progress 的原因是我们使用 TableHeap 中的 tuple_meta 中的 timestamp 在 Update Executor 的入口作为判断写写冲突的时候, 可能有多个事务同时检测到当前 tuple 是可以修改的.
现在我们引入新的锁, 也就是 Page 级别的锁, 使用 TableHeap::AcquireTablePage{Read|Write}Lock
可以在修改 TableHeap 中的 tuple 之前获取这个 tuple 所在页面的读锁或者写锁, 例如, 当前事务获取了某个 tuple 所在页面的写锁之后, 其他尝试获取该 tuple 所在 Page 写锁的事务都会被阻塞, 知道该事务释放写锁.
由于其他事务在 commit 之前可能就会释放 Page 的读写锁, 因此事务获取读写锁之后仍然需要检测是否存在写写冲突. 检测到写写冲突的时候需要 Abort.
Page 级别的互斥写锁保证了, 当一个事务获取了某个 tuple 所在 Page 的写锁之后, 其他事务无法获取到该锁, 因此只有获取到锁的事务可以修改这个 tuple 以及对应的版本链, 因此不需要继续使用 VersionUndoLink 中的 in_progress.
新的写写冲突检查与回退的步骤
在使用上述的新的锁管理机制后, 以 Update Executor 为例冲突的检查与回退的步骤可以总结如下:
- 当事务试图修改 TableHeap 中的内容, 在进入 Update Executor 的时候, 首先应该获取 rid 对应页面的写锁, 如果其他事务正在使用这个页面, 该事务会阻塞.
- 当事务成功获取到写锁之后, 进行写写冲突检查, 因为其他事务可能修改了这个 tuple, 但是还没有 commit.
- 如果检测到冲突, 当前事务 Abort.
- 执行 Abort 函数, Abort 函数应该将当前事务已经完成但是还没有 commit 的修改全部回退, 同时需要修改对应的 VersionUndoLink 中的 undolog, 也就是回退当前事务
write_set_
中的所有内容.
不同修改的回滚步骤
在 BUSTUB 中, 对 TableHeap 的修改有三种情况, 分别是 Insert 一个 tuple, Update 一个 tuple, 以及 Delete 一个 tuple. 针对这三种修改的情况的回退步骤分别是:
- 当一个在 TableHeap 中 Insert 了一个新的 tuple, 并且该 tuple 没有 VersionUndoLink 的时候, 该事务 Abort 的时候, 需要回退的时候, 只需要将新插入的 tuple 设置删除标记即可, 即
is_deleted
为 true, 并且设置其 tuplemeta 中的ts_
为 0 即可. - 在更新主键的时候, 如果是 Insert 一个 tuple 的过程中需要回退的时候, 此时只需要设置
is_deleted
为 true 即可. - 在 Delete 与 Update 一个 tuple 的时候, 需要回退 UndoLog 中的信息到 TableHeap 中, 同时删除 VersionUndoLink 中对应的 UndoLog.
- 对索引表的插入与修改无需回退
VersionUndoLink 中 UndoLog 的删除步骤
与 TableHeap 中的数据回退的步骤类似, 事务 Abort 的时候需要遍历被修改的 Tuples, 如果是被 Update 或者删除的 Delete 的 tuple, 回退之后需要删除对应的 UndoLog.
实现步骤
Abort 的实现步骤其实和 Commit 类似, 我的实现方式如下, 当然多线程的 BUG 总是让人感到意外, 也不一定对把, 测试案例是肯定没有问题了.
/**
* Abort 中需要回退的 tuple 是当前事务已修改但是还没有 commit 的 tuple, 对于这些 tuple, 其他尝试修改这些 tuple 的事务
* 都会发生写写冲突, 因此 Abort 回退修改一个 TableHeap, 实际就是当前事务修改正在修改的 TableHeap, 不会出现写写冲突.
*/
/** 获取这个事务修改的 Tuple 集合 */
auto write_sets = txn->GetWriteSets();
/** 遍历 write_sets 中的所有的表, 和这些表中修改的 Tuples */
for (const auto &[table_oid, rid_set] : write_sets) {
/** 根据表号获取画布中这个表的信息 */
auto table_info = catalog_->GetTable(table_oid);
/** 遍历这张表中所有修改的 RID, 更新这个 RID 的时间片信息 */
for (const auto &rid : rid_set) {
/** Abort 过程中修改 TableHeap 仍然需要获取写锁, 这是因为如果直接使用 UpdateTupleInPlace,
* 由于其他事务可能已经获取到了写锁, 直接修改会导致死锁 */
/** first get the WriteLock of the tupel's Page */
auto update_page_gurad = table_info->table_->AcquireTablePageWriteLock(rid);
auto *table_page = update_page_gurad.template AsMut<TablePage>();
/** get the current tuple's information, tuple_meta, tuple data and tuple RID */
auto [current_tuple_meta, current_tuple] = table_info->table_->GetTupleWithLockAcquired(rid, table_page);
auto version_undolink = this->GetVersionLink(rid);
/** 如果这个事务修改或者删除过这个 tuple, 那么会生成 undolog */
if (version_undolink.has_value()) {
auto revert_undolog = this->GetUndoLog(version_undolink->prev_);
/** Reconstruct the tuple from the VersionUndoLink */
auto prev_tuple =
ReconstructTuple(&table_info->schema_, current_tuple, current_tuple_meta, std::vector{revert_undolog});
if (!prev_tuple.has_value()) {
prev_tuple = current_tuple;
}
current_tuple_meta.ts_ = revert_undolog.ts_;
current_tuple_meta.is_deleted_ = revert_undolog.is_deleted_;
/** update the tuple in the TableHeap */
if (prev_tuple.has_value()) {
table_info->table_->UpdateTupleInPlaceWithLockAcquired(current_tuple_meta, prev_tuple.value(), rid,
table_page);
}
/** delete the undolog in the version_undolink, then update the version_undolink */
version_undolink->prev_ = revert_undolog.prev_version_;
this->UpdateVersionLink(rid, version_undolink, nullptr);
} else {
/** 如果这个事务没有版本链, 说明这个事务 Insert 了这个 tuple */
current_tuple_meta.ts_ = 0;
current_tuple_meta.is_deleted_ = true;
table_info->table_->UpdateTupleInPlaceWithLockAcquired(current_tuple_meta, current_tuple, rid, table_page);
}
}
}
/** set the status of the Txn, and delete it from txn_map */
std::unique_lock<std::shared_mutex> lck(txn_map_mutex_);
txn->state_ = TransactionState::ABORTED;
running_txns_.RemoveTxn(txn->read_ts_);
// txn_map_.erase(txn->GetTransactionId());
BUG 记录
- 获取当前 Tuple 的版本链的时候, current_version_undolink 为空的情况需要新建一个, 因为插入 undolog 的时候, 需要使用
current_version_undolink->prev
, 不新建, 实际上UpdateVersionLink
实际上更新的是一个空的 - 在 IndexScan Executor 与 SeqScan Executor 中, 无需一开始就获取
AcquireTablePageReadLock
, 而是和原来一样, 这样会降低并发. 应该按照版本链的方式读取, 但是这样也有一个问题, 就是读取版本链的时候, 有可能这个版本链正在被其他事务的Abort
函数修改, 删除了某个事务以及对应的 undolog, 导致访问不到这个 undolog, 此时, 当前的这个访问这个被删除 undolog 的事务也应该 Abort. 如下
// ! 如果运行到这里的时候, 当前版本链中的一个 undolog 已经随某个 Abort 的事务被删除掉了,
// ! 这里会访问到已经删除的 undolog, 此时当前事务也应该 Abort
auto undo_log = exec_ctx_->GetTransactionManager()->GetUndoLogOptional(undo_link);
if (!undo_log.has_value()) {
exec_ctx_->GetTransaction()->SetTainted();
LOG_DEBUG("Txn%ld will be Aborted the VersionUndoLink is not Latest!",
exec_ctx_->GetTransaction()->GetTransactionIdHumanReadable());
throw ExecutionException("The VersionUndoLink is Out of Version !!!");
}
- Abort 的时候, 如果最后执行的是一个 Insert, 但是更新的是主键, 调用的函数是 InsertByUpdate, 那么 ReconstructTuple 返回的是一个空的, 直接插入版本链会报错. 还有, 当一个事务修改 tuple 的时候, 假设首先 Update 这个 tuple, 生成一个 undolog, 然后 delete 这个 tuple, 然后该事务 Insert 的时候, 如果调用 InsertByUpdate, 不能生成新的 undolog, 只需要保持原来的 undolog 版本链即可.
- 也不能算是 BUG, 是之前 WaterMark 中的一个问题, 由于高并发的启动下, 在事务
Begin()
和Commit
的时候会发生奇怪的事情, 假设某个事务 Txn_A 开始的时候通过读取全局 commit_ts, 得到的 read_ts 为 5, 但是其他事务 Txn_B 也读到了 read_ts 为 5, 并且快速执行成功 commit 了, 更新了全局 commit_ts 为 6. 此时, Txn_A 加入 WaterMark 的时候, 检查就会失败, 因为事务一开始的时候, read_ts 就小于全局的 commit_ts 了, 但是其实不影响, 这个这个事务 Txn_A, 在后续一定会因为写写冲突 Abort, 不会执行, 在 WaterMark 中因为没有记录, 直接跳过删除. 当然, 这种概率很小. - 测试案例中很奇怪的地方, 按道理来说, Abort 之后是可以直接删除这个事务的, 但是测试案例中因为要判断 Abort 之后事务的信息, 不能直接删除.
DEBUG 记录
我发现运行过程中会出现死锁的现象, 但是在获取写锁前, 如果让线程 sleep(50ms), 可以避免死锁, 但是这并不能从根本上解决问题, 应该修改锁的使用方式, 使用 GDB 调试死锁的程序, 可以看到下面的线程信息:
(gdb) info threads
Id Target Id Frame
* 1 Thread 0x70578c115780 (LWP 714514) "txn_index_concu" 0x000070578be98d71 in __futex_abstimed_wait_common64 (private=128, cancel=true,
abstime=0x0, op=265, expected=714535, futex_word=0x705789e00990) at ./nptl/futex-internal.c:57
2 Thread 0x70578b2006c0 (LWP 714537) "txn_index_concu" __futex_abstimed_wait_common (cancel=false, private=<optimized out>,
abstime=0x0, clockid=0, expected=3, futex_word=0x5d9aa16481e8) at ./nptl/futex-internal.c:103
3 Thread 0x70578a8006c0 (LWP 714536) "txn_index_concu" __futex_abstimed_wait_common (cancel=false, private=<optimized out>,
abstime=0x0, clockid=0, expected=3, futex_word=0x5d9aa16cc1c8) at ./nptl/futex-internal.c:103
4 Thread 0x705789e006c0 (LWP 714535) "txn_index_concu" __futex_abstimed_wait_common (cancel=false, private=<optimized out>,
abstime=0x0, clockid=0, expected=3, futex_word=0x5d9aa16481ec) at ./nptl/futex-internal.c:103
5 Thread 0x70578bc006c0 (LWP 714534) "txn_index_concu" 0x000070578be98d71 in __futex_abstimed_wait_common64 (private=0, cancel=true,
abstime=0x0, op=393, expected=0, futex_word=0x5d9aa1647d28) at ./nptl/futex-internal.c:57
thread2 的关键栈帧信息如下:
#0 __futex_abstimed_wait_common (cancel=false, private=<optimized out>, abstime=0x0, clockid=0, expected=3, futex_word=0x5d9aa16481e8)
at ./nptl/futex-internal.c:103
#1 __GI___futex_abstimed_wait64 (futex_word=futex_word@entry=0x5d9aa16481e8, expected=expected@entry=3, clockid=clockid@entry=0,
abstime=abstime@entry=0x0, private=<optimized out>) at ./nptl/futex-internal.c:128
#2 0x000070578bea288a in __pthread_rwlock_rdlock_full64 (abstime=0x0, clockid=0, rwlock=0x5d9aa16481e0)
at ./nptl/pthread_rwlock_common.c:460
#3 ___pthread_rwlock_rdlock (rwlock=0x5d9aa16481e0) at ./nptl/pthread_rwlock_rdlock.c:26
#4 0x00005d9a7fa9d433 in std::__glibcxx_rwlock_rdlock (__rwlock=0x5d9aa16481e0)
at /usr/bin/../lib/gcc/x86_64-linux-gnu/13/../../../../include/c++/13/shared_mutex:81
#5 0x00005d9a7faae9bd in std::__shared_mutex_pthread::lock_shared (this=0x5d9aa16481e0)
at /usr/bin/../lib/gcc/x86_64-linux-gnu/13/../../../../include/c++/13/shared_mutex:232
#6 0x00005d9a7faae995 in std::shared_mutex::lock_shared (this=0x5d9aa16481e0)
at /usr/bin/../lib/gcc/x86_64-linux-gnu/13/../../../../include/c++/13/shared_mutex:429
#7 0x00005d9a7fb624d5 in bustub::ReaderWriterLatch::RLock (this=0x5d9aa16481e0)
at /home/windyday/study/CMU_15-445/src/include/common/rwlatch.h:40
#8 0x00005d9a7fb623b9 in bustub::Page::RLatch (this=0x5d9aa16481c8) at /home/windyday/study/CMU_15-445/src/include/storage/page/page.h:61
#9 0x00005d9a7fb77a55 in bustub::BufferPoolManager::FetchPageRead (this=0x5d9aa1647c20, page_id=2)
at /home/windyday/study/CMU_15-445/src/buffer/buffer_pool_manager.cpp:266
#10 0x00005d9a7fb62cfa in bustub::TableHeap::GetTupleMeta (this=0x5d9aa16d27d0, rid=...)
at /home/windyday/study/CMU_15-445/src/storage/table/table_heap.cpp:109
#11 0x00005d9a7fac4058 in bustub::TransactionManager::Commit (this=0x5d9aa16cc1c0, txn=0x70577c0064d0)
at /home/windyday/study/CMU_15-445/src/concurrency/transaction_manager.cpp:87
#12 0x00005d9a7fa55cb2 in bustub::TxnIndexTest_IndexConcurrentUpdateAbortTest_Test::TestBody()::$_5::operator()() const (
this=0x5d9aa16cb278) at /home/windyday/study/CMU_15-445/test/txn/txn_index_concurrent_test.cpp:284
在 frame 8, 打印出锁的使用信息如下:
(gdb) frame 8
#8 0x00005d9a7fb623b9 in bustub::Page::RLatch (this=0x5d9aa16481c8) at /home/windyday/study/CMU_15-445/src/include/storage/page/page.h:61
61 inline void RLatch() { rwlatch_.RLock(); }
(gdb) p rwlatch_
$1 = {mutex_ = {_M_impl = {_M_rwlock = {__data = {__readers = 11, __writers = 0, __wrphase_futex = 3, __writers_futex = 3, __pad3 = 0,
__pad4 = 0, __cur_writer = 714536, __shared = 0, __rwelision = 0 '\000', __pad1 = "\000\000\000\000\000\000", __pad2 = 0,
__flags = 0},
__size = "\v\000\000\000\000\000\000\000\003\000\000\000\003", '\000' <repeats 11 times>, "(\347\n", '\000' <repeats 28 times>,
__align = 11}}}}
可以看到 thread2(714537) 阻塞在获取 rwlatch_
读锁上, 并且 thread3(714536) 正在尝试获取 rwlatch_
的写锁, thread3 阻塞在这个写锁上.
查看 thread3(714536) 的关键栈帧信息如下:
[Switching to thread 3 (Thread 0x70578a8006c0 (LWP 714536))]
#0 __futex_abstimed_wait_common (cancel=false, private=<optimized out>, abstime=0x0, clockid=0, expected=3, futex_word=0x5d9aa16cc1c8)
at ./nptl/futex-internal.c:103
103 in ./nptl/futex-internal.c
(gdb) bt
#0 __futex_abstimed_wait_common (cancel=false, private=<optimized out>, abstime=0x0, clockid=0, expected=3, futex_word=0x5d9aa16cc1c8)
at ./nptl/futex-internal.c:103
#1 __GI___futex_abstimed_wait64 (futex_word=futex_word@entry=0x5d9aa16cc1c8, expected=expected@entry=3, clockid=clockid@entry=0,
abstime=abstime@entry=0x0, private=<optimized out>) at ./nptl/futex-internal.c:128
#2 0x000070578bea288a in __pthread_rwlock_rdlock_full64 (abstime=0x0, clockid=0, rwlock=0x5d9aa16cc1c0)
at ./nptl/pthread_rwlock_common.c:460
#3 ___pthread_rwlock_rdlock (rwlock=0x5d9aa16cc1c0) at ./nptl/pthread_rwlock_rdlock.c:26
#4 0x00005d9a7fa9d433 in std::__glibcxx_rwlock_rdlock (__rwlock=0x5d9aa16cc1c0)
at /usr/bin/../lib/gcc/x86_64-linux-gnu/13/../../../../include/c++/13/shared_mutex:81
#5 0x00005d9a7faae9bd in std::__shared_mutex_pthread::lock_shared (this=0x5d9aa16cc1c0)
at /usr/bin/../lib/gcc/x86_64-linux-gnu/13/../../../../include/c++/13/shared_mutex:232
#6 0x00005d9a7faae995 in std::shared_mutex::lock_shared (this=0x5d9aa16cc1c0)
at /usr/bin/../lib/gcc/x86_64-linux-gnu/13/../../../../include/c++/13/shared_mutex:429
#7 0x00005d9a7fa9e268 in std::shared_lock<std::shared_mutex>::shared_lock (this=0x70578a7fa720, __m=...)
at /usr/bin/../lib/gcc/x86_64-linux-gnu/13/../../../../include/c++/13/shared_mutex:741
#8 0x00005d9a7facda2f in bustub::TransactionManager::GetUndoLogOptional (this=0x5d9aa16cc1c0, link=...)
at /home/windyday/study/CMU_15-445/src/concurrency/transaction_manager_impl.cpp:108
#9 0x00005d9a7facdbde in bustub::TransactionManager::GetUndoLog (this=0x5d9aa16cc1c0, link=...)
at /home/windyday/study/CMU_15-445/src/concurrency/transaction_manager_impl.cpp:120
#10 0x00005d9a7faff027 in bustub::IndexScanExecutor::Next (this=0x70578400ce20, tuple=0x70578a7fada0, rid=0x70578a7fad98)
at /home/windyday/study/CMU_15-445/src/execution/index_scan_executor.cpp:92
#11 0x00005d9a7fb1d71a in bustub::UpdateExecutor::Init (this=0x705784004fc0)
可以看到, thread3(714536) 被阻塞在获取读锁 txn_map_mutex_
上, 查看这个锁的信息, 我们可以得到下面的信息
(gdb) p txn_map_mutex_
$13 = {_M_impl = {_M_rwlock = {__data = {__readers = 11, __writers = 0, __wrphase_futex = 3, __writers_futex = 1, __pad3 = 0, __pad4 = 0,
__cur_writer = 714537, __shared = 0, __rwelision = 0 '\000', __pad1 = "\000\000\000\000\000\000", __pad2 = 0, __flags = 0},
__size = "\v\000\000\000\000\000\000\000\003\000\000\000\001", '\000' <repeats 11 times>, ")\347\n", '\000' <repeats 28 times>,
__align = 11}}}
可以看到的是 thread2(714537) 在尝试获取 txn_map_mutex_
的写锁, 也就是 thread2(714537) 被阻塞. 我们可以总结的现象就是:
- thread2(714537) 持有
rwlatch_
的读锁, thread3(714536) 尝试获取rwlatch_
的写锁被阻塞. - thread3(714536) 持有
txn_map_mutex_
的读锁, thread2(714537) 尝试获取txn_map_mutex_
的写锁导致被阻塞.
那么实际情况是:
我在 Commit 事务的时候实现错误:
std::unique_lock<std::shared_mutex> lck(txn_map_mutex_);
/** 获取这个事务修改的 Tuple 集合 */
auto write_sets = txn->GetWriteSets();
/** 遍历 write_sets 中的所有的表, 和这些表中修改的 Tuples */
for (const auto &[table_oid, rid_set] : write_sets) {
/** 根据表号获取画布中这个表的信息 */
auto table_info = catalog_->GetTable(table_oid);
/** 遍历这张表中所有修改的 RID, 更新这个 RID 的时间片信息 */
for (const auto &rid : rid_set) {
// ! 有一个线程阻塞在了这里, 这是为什么呢
auto temp_tuple_meta = table_info->table_->GetTupleMeta(rid);
temp_tuple_meta.ts_ = new_commited_time;
table_info->table_->UpdateTupleMeta(temp_tuple_meta, rid);
}
}
在 Commit 中获取了 txn_map_mutex_
的写锁, 假设我一个线程已经获取到了这个写锁, 但是没有获取到 UpdateTupleMeta
中某个 Page1
的写锁, 因此会阻塞, 另一个执行 IndexScan Executor 的线程, 已经持有 Page1
的读锁, 但是想获取 txn_map_mutex_
的读锁, 因此也会阻塞, 这两个线程就陷入了死锁, 我的疑惑是不知道为什么 GDB 调试的时候, 按照锁的使用情况, 并不是这种结果, 而是两个线程都阻塞在了获取读锁上, 而且没有线程持有写锁, 这里很疑惑.