Project #4 - Concurrency Control 项目要求
OVERVIEW
这个项目是关于在 BusTub 中增加对事务的支持!为了实现这个目标,你将在你的数据库系统中添加一个 Lock Manager,然后用它来支持并发查询的执行。Lock Manager 负责跟踪 tables 和 tuples 上的 lock,有五种不同的模式:intention-shared, intention-exclusive, shared-intention-exclusive, shared, and exclusive。Lock Manager 将处理来自事务的锁请求,向事务授予锁,并根据事务的隔离级别检查锁是否被适当释放。
PROJECT SPECIFICATION
本项目的正确性取决于你对以前项目的实现是否正确;除了 Project #3 中的 B+ Tree wrapper 外,我们不会提供解决方案或二进制文件。
Task #1 - Lock Manager
为确保事务操作的正确并发,DBMS 将使用 Lock Manager (LM) 来控制何时允许事务访问数据项。 LM 的基本思想是它维护一个由当前 active 事务持有的 lock 的内部数据结构。 事务在允许访问数据项之前要向 LM 发出锁请求。 LM 将选择:授予该事务锁 / block 该事务 / abort 该事务。
在你的实现中,整个系统将有一个全局 LM (类似于 buffer pool manager)。每当一个事务想要访问/修改一个 tuple 时,TableHeap
和 Executor
类将使用 LM 来获取 tuple records (by record id RID
) 的锁。
这项任务要求你实现一个 table-level 和 tuple-level 的 LM,支持三种常见的隔离级别:READ_UNCOMMITED
,READ_COMMITTED
,和 REPEATABLE_READ
。Lock Manager 应根据事务的隔离级别授予或释放锁。请参考 lecture slides ,了解隔离级别的复习情况。
我们为你提供了一个 Transaction
context handle (include/concurrency/transaction.h),其中有一个隔离级别属性 (i.e., READ_UNCOMMITED
, READ_COMMITTED
, and REPEATABLE_READ
) 以及关于其获取的 locks 的信息。LM 将需要检查事务的隔离级别,并在 lock/unlock 请求上做出正确的行为。任何失败的锁操作都应该导致 ABORTED 事务状态 (隐式 abort ) 并抛出一个异常。transaction manager (include/concurrency/transaction_manager.h
) 将进一步捕捉这个异常,并回滚由事务执行的写操作。
我们推荐你阅读这篇文章以回顾C++并发知识。这里有更详细的文档。
REQUIREMENTS
Task1 中你只需要修改LockManager
类 (concurrency/lock_manager.cpp
and include/concurrency/lock_manager.h
)。需要实现以下函数:
LockTable(Transaction, LockMode, TableOID)
UnlockTable(Transction, TableOID)
LockRow(Transaction, LockMode, TableOID, RID)
UnlockRow(Transaction, TableOID, RID)
Lock Manager 采取的具体锁机制取决于事务隔离级别,是 table-level lock 还是 tuple-level lock,以及涉及的锁的类型。你应该先看一下 transaction.h
和 lock_manager.h
,熟悉我们提供的 API 和成员变量。然后,仔细阅读 [LOCK_NOTE]
, [UNLOCK_NOTE]
, 以及 lock_manager.h
中的各个函数规范,了解你的 LM 的预期行为。
我们还建议回顾一下隔离级别和 hierarchical locking 的概念,因为这些函数的实现应与提出锁定/解锁请求的事务的隔离级别兼容。你可以在 lock_manager.h
中自由添加任何必要的数据结构。你应该参考教科书中的 Chapters 15.1-15.2 和 Lecture 中涉及的隔离级别概念,以确保你的实现满足要求。
HINTS
- 虽然你的 Lock Manager 需要使用死锁检测,但我们建议在添加检测机制之前,先测试和验证你的 Lock Manager 实现的正确性,不需要任何死锁处理。
- 你将需要一些方法来跟踪哪些事务正在等待一个锁。看看
LockRequestQueue
class inlock_manager.h
- 什么时候需要升级一个锁?当你需要更新 table/tuple lock 时,需要对
LockRequestQueue
进行哪些操作? - 当有锁竞争时,你将需要某种方式来通知正在等待的事务。我们推荐使用作为
LockRequestQueue
的一部分提供的std::condition_variable
。 - 你应该维护一个事务的状态。例如,由于解锁操作,事务的状态可能从
GROWING
阶段变为SHRINKING
阶段 (提示:查看transaction.h
中的方法)。 - 你还应该使用
*_lock_set_
跟踪事务获得的锁,这样当TransactionManager
想要 commit/abort 事务时,LM 可以正确地释放这些锁。 - 将一个事务的状态设置为 ABORTED 隐式 abort 了该事务,但直到
TransactionManager::Abort
被调用时才会显式地 abort。你应该通读这个函数和提供的测试,以了解它的作用,以及你的 Lock Manager 在 abort过程中是如何使用的。
Task #2 - Deadlock Detection
你的 Lock Manager 应该在后台运行死锁检测,以中止阻塞的事务。
更确切地说,这意味着一个后台线程应该定期地在运行中建立一个 waits-for 图,并打破任何循环。
REQUIREMENTS
你必须实现并用于你的周期检测以及测试的 graph API 是如下:
AddEdge(txn_id_t t1, txn_id_t t2)
: 在你的图中添加一条从 t1 到 t2 的边。如果这条边已经存在,你不必做任何事情。RemoveEdge(txn_id_t t1, txn_id_t t2)
: 从你的图中删除边 t1 到 t2。如果不存在这样的边,你不必做任何事情。HasCycle(txn_id_t& txn_id)
: 通过使用深度优先搜索 (DFS) 算法来寻找一个 cycle。如果找到一个 cycle,HasCycle
应该在txn_id
中存储该 cycle 中最年轻的 transaction id,并返回 true。你的函数应该返回它找到的第一个 cycle。如果你的图没有循环,HasCycle
应该返回 false。GetEdgeList()
: 返回一个代表你的图中的边的 tuple list。我们将用它来测试你的图形的正确性。(t1, t2) pair 对应于从 t1 到 t2 的一条边。RunCycleDetection()
: 包含在后台运行周期检测的骨架代码。你应该在这里实现你的周期检测逻辑。
NOTES
- 你的后台线程应该在每次唤醒时都建立 graph。你不应该维护一个 graph,它应该在每次线程唤醒时被建立和销毁。
- 你的 DFS 周期检测算法必须是确定性的。为了做到这一点,你必须总是选择首先探索最低的 transaction id。这意味着当选择哪个未探索的节点来运行 DFS 时,总是选择具有最低 transaction id 的节点。这也意味着在探索邻居时,要按照从低到高的排序来探索它们。
- 当你发现一个 cycle 时,应中止最年轻的 transaction,通过将该 transaction 的状态设置为 ABORTED 来打破 cycle。
- 当你的检测线程被唤醒时,它负责打破所有存在的 cycle。如果你遵循上述要求,你将总是以确定的顺序找到循环。这也意味着,当你构建你的 graph 时,你不应该为 aborted 的事务添加节点,也不应该为 aborted 的事务画边。
- 你的后台检测算法可能要获取一个事务的指针
txn_id
,我们增加了一个静态方法Transaction* GetTransaction(txn_id_t txn_id)
来让你做这个。 - 你可以使用
std::this_thread::sleep_for
来排序线程来编写测试案例。你也可以在测试用例中调整common/config.h
中的CYCLE_DETECTION_INTERVAL
。
HINTS
- waits for graph 是个有向图
- 当一个事务在等待另一个事务时,waits for graph 会画一条边。请记住,如果多个事务在同一个对象上持有锁,一个事务可能在等待多个事务。
- 当一个事务被中止时,请确保将该事务的状态设置为
ABORTED
。transaction manager 将处理明确的 abort 和 rollback。 - 一个等待锁的事务可能会被后台周期检测线程中止。你必须有办法通知等待中的事务他们已经被中止了。
Task #3 - Concurrent Query Execution
在并发查询执行期间,执行者需要适当地 lock/unlock tuples,以实现相应事务中指定的隔离级别。为了简化这项任务,你可以忽略并发索引的执行,只关注表的 tuples。
你将需要更新 Project 3 中实现的一些 executors (sequential scan, insert, and delete) 的 Next()
方法。注意,当 lock/unlock 失败时,事务应该中止。虽然没有并发索引执行的要求,但我们仍然需要在事务中止时适当撤销之前对表 tuples 和 indexes 的所有写操作。为了实现这一点,你需要维护事务中的写集,这是事务管理器的 Abort()
方法所要求的。当执行器无法获得锁时,你应该抛出一个 ExecutionException
,这样执行引擎就会告诉用户,查询失败了,应该被中止。
你不应该假定一个事务只由一个查询组成。具体来说,这意味着一个 tuple 在一个事务中可能会被不同的查询访问不止一次。思考一下在不同的隔离级别下应该如何处理这个问题。
更具体地说,你将需要在以下 executors 中添加对并发查询执行的支持:
- src/execution/seq_scan_executor.cpp
- src/execution/insert_executor.cpp
- src/execution/delete_executor.cpp
在这个任务中,你还需要在没有 segfault 和死锁的情况下通过 Terrier Benchmark。
Terrier Benchmark
在一个很远很远的星系中,有一个星球。在这个星球上,生活着高度文明的 Jack Russell terriers。NFTs (不可伪造的代币) 越来越受欢迎。有一天,猎犬们决定寻找一些数据库系统来追踪他们的 NFT,而 BusTub 是其中一个候选系统。
每个 terrier 都有一个 unique ID。每个 NFT 也有一个 unique ID。terriers 首先创建一个 NFT 表,记录每个 NFT 属于哪个 terrier。
CREATE TABLE nft(id INT, terrier INT);
INSERT INTO nft VALUES (0, 0), (1, 1), ...;
然后他们以 repeatable read 隔离级别来运行事务交换 NFT。将会有多个 terriers 同时运行交易 (在多个线程中)。
-- begin txn1
SELECT * FROM nft WHERE id = <nft_id>; -- record the terrier_id
DELETE FROM nft WHERE id = <nft_id>;
-- end txn1
-- begin txn2
INSERT INTO nft VALUES (<nft_id>, <terrier_id>)
-- end txn2
在交换过程中,他们想知道每个 terrier 拥有多少个 NFT。
SELECT count(*) FROM nft WHERE terrier = <terrier_id>;
你需要确保 BusTub 不会崩溃或死锁,同时在 30 秒的基准测试过程中产生正确的结果,这样 terriers 就不会失去对它们 NFTs 的跟踪。
你可以使用下面的命令来运行 Terrier 基准测试:
make -j`nproc` terrier-bench
./bin/bustub-terrier-bench --duration 30000
Leaderboard Task (Optional)
Terriers 通过吞吐量 (throughput) 来衡量数据库性能 —— 计算在一定时间内处理了多少交易。在 Leaderboard Task 中,你需要优化 BusTub 以有效地处理 NFT 交换。
terrier benchmark 将启动 2 个线程来交换 NFT,并启动另外两个线程来计算每个 terrier 拥有多少 NFT。最终的QPS (每秒查询次数) 的计算方法是 0.8 * update_qps + 0.2 * count_qps
Here is a list of recommended optimizations:
Predicate pushdown to SeqScan: 你可以在 SeqScanExecutor 中实现谓词过滤,这样你在做 SeqScan 时可以锁定更少的 tuples。你可以启用 MergeFilterScan 优化器规则 merge_filter_scan.cpp
并实现这一优化。
Implement UpdateExecutor: 你可以实现 UpdateExecutor,这样 tuples 就可以被就地更新,而且可能会更有效率。修改 terrier_benchmark_config.h
以指示 Terriers 使用 UPDATE
来交换 NFTs。
Use Index: 你可以在 NFT 表上创建一个索引,然后将谓词下推到 IndexScanExecutor 来做索引查询。例如,如果我们在 NFT 的 id 列上有一个索引,SELECT * FROM nft WHERE id = 1
实际上可以像 (1) 提取 id = 1
谓词和 (2) 在索引上直接调用 GetValue(1)
,而不是做一个完整的索引扫描或表扫描。你将需要更新索引扫描计划以促进这一优化。修改 terrier_benchmark_config.h
,指示 Terriers 在交换 NFTs 之前创建索引。
注意:你不需要让你的 optimization 变得完美。例如,如果你想实现索引查找 + update executor,你只需要考虑 terrier benchmark 的情况:索引只包含固定数量的 items,而且 RID 不会改变。
注意:terrier benchmark 将先在没有 index creation 和没有 update executor 的 debug 模式下运行,然后用你指定的 optimization 在 release 模式下运行。
INSTRUCTIONS
本地测试
$ cd build
$ make lock_manager_test
$ make deadlock_detection_test
$ make transaction_test
$ ./test/lock_manager_test
$ ./test/deadlock_detection_test
$ ./test/transaction_test
SUBMISSION
$ make format
$ make check-lint
$ make check-clang-tidy-p4
$ make submit-p4
本文作者:Joey-Wang
本文链接:https://www.cnblogs.com/joey-wang/p/17338510.html
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步