CMU 15-445:project #3 - Query Execution
1. 代码分析
在本次project中,如果想要正确实现,需要阅读大量的相关的代码,所以在此先讲相关的代码及其架构分析一下.
1) Catalog
关于Catalog,简而言之是一个数据库系统记录一些全局信息的数据结构,在后面的执行器中,可以通过Catalog,检索到所需要的信息,比如说Index,Table等.
首先看catalog.h
中的Catalog,其中的私有成员如下:
private:
[[maybe_unused]] BufferPoolManager *bpm_;
[[maybe_unused]] LockManager *lock_manager_;
[[maybe_unused]] LogManager *log_manager_;
std::unordered_map<table_oid_t, std::unique_ptr<TableInfo>> tables_;
/** Map table name -> table identifiers. */
std::unordered_map<std::string, table_oid_t> table_names_;
/** The next table identifier to be used. */
std::atomic<table_oid_t> next_table_oid_{0};
std::unordered_map<index_oid_t, std::unique_ptr<IndexInfo>> indexes_;
std::unordered_map<std::string, std::unordered_map<std::string, index_oid_t>> index_names_;
std::atomic<index_oid_t> next_index_oid_{0};
由此可见,可以由table_oid_t
来得到相应的TableInfo
.还有name字符串道相应的table_oid_t
.下面还有与索引的相关的.其中主要的API有:GetTable,GetIndex等,大多数API的实现都是围绕着上面几个map数据结构的操作的.但是除了这些之外,像CreateIndex,CreateTable这些API则会涉及到更加复杂的BufferPoolManager的操作.
然后看schema.h
,这个数据结构定义了一个表的模式,主要包括每一列的信息(类型,大小)等.其中核心的数据结构如下:
uint32_t length_;
/** All the columns in the schema, inlined and uninlined. */
std::vector<Column> columns_;
/** True if all the columns are inlined, false otherwise. */
bool tuple_is_inlined_;
/** Indices of all uninlined columns. */
std::vector<uint32_t> uninlined_columns_;
其中比较常用的API是GetColumns
,GetColumn
等,这些关于Get的实现一般都是比较简单的.
但是column.h
中有什么呢?一个表的Scheme是由多个Column
(如同前面的vector数据成员)组成的,其中包含一些内部信息(name,type,length,offset)等.此外值得注意的是每一个Colmns
成员中还会有:
/** Expression used to create this column **/
const AbstractExpression *expr_;
及其相关的Get接口.至于其具体作用,在后面具体分析.
2) Table
Tuple
相关的类主要是:Tuple,TableHeap,TableIterator
.其中Tuple
之最基础的单位,即为“元组”,表示关系中的一个条目.其中的数据成员如下:
bool allocated_{false}; // is allocated?
RID rid_{}; // if pointing to the table heap, the rid is valid
uint32_t size_{0};
char *data_{nullptr};
其中rid是一个用来在磁盘上检索Tuple的索引(更具体地说,表明了所属的page_id,以及再改page_id中对应的slot的位置),size表明了该Tuple的大小,其中data是一个缓冲区对应该tuple的实际内容.其中相关的API主要是一些Get操作,其中有:
Tuple KeyFromTuple(const Schema &schema, const Schema &key_schema, const std::vector<uint32_t> &key_attrs);
Value GetValue(const Schema *schema, uint32_t column_idx) const;
API比较关键,也比较复杂.GetTuple
用于从缓冲区的内容中,根据一个shema提取出column_idx,并将其“翻译”成Value类型,作为返回.关于KeyFromTuple
比较复杂,在后面也多次用到,留到后面再说.
总的来说关于其中Value的读取,主要根据参数中Scheme中的Colmns来在Tuple中的data缓冲区中定位到数据,然后翻译成Value.
TableHeap
TableHeap
表示的是一个磁盘中的物理页(不是内存),一个物理页中包含多个TablePage(也就对应于Buffer
Pool中可以装载的内存页).其中对于这些TablePage的访问,是通过TableIterator进行的(提供了Begin,Next等迭代器接口).其余的API主要围绕着对于Tuple的操作,其中RID是TableHeap检索Tuple的重要参数.比如说下面的UpdateTuple中:
bool TableHeap::UpdateTuple(const Tuple &tuple, const RID &rid, Transaction *txn) {
// Find the page which contains the tuple.
auto page = reinterpret_cast<TablePage *>(buffer_pool_manager_->FetchPage(rid.GetPageId()));
// If the page could not be found, then abort the transaction.
if (page == nullptr) {
txn->SetState(TransactionState::ABORTED);
return false;
}
// Update the tuple; but first save the old value for rollbacks.
Tuple old_tuple;
page->WLatch();
bool is_updated = page->UpdateTuple(tuple, &old_tuple, rid, txn, lock_manager_, log_manager_);
page->WUnlatch();
buffer_pool_manager_->UnpinPage(page->GetTablePageId(), is_updated);
// Update the transaction's write set.
if (is_updated && txn->GetState() != TransactionState::ABORTED) {
txn->GetWriteSet()->emplace_back(rid, WType::UPDATE, old_tuple, this);
}
return is_updated;
}
首先会借助RID中的Fetch获取其所处的页,然后进行对该页的Update(也就是基于我们所熟悉的BufferPool的一些API完成).
TableIterator
前面提到过,TableHeap中有提供的迭代器接口(Begin,End)可以用来遍历其中的Page,其返回类型为TableIterator.每一个TableIterator的数据成员如下:
TableHeap *table_heap_;
Tuple *tuple_;
Transaction *txn_;
一个TableIterator的原子单位是该Page中的一个tuple,也就是上面的tuple指针,而table_heap_也就是该迭代器所处的TableHeap.至于内部关于迭代器的一些重载实现,我们主要关注迭代器中最重要的,比如*,->,++操作的实现.
Tuple *TableIterator::operator->() {
assert(*this != table_heap_->End());
return tuple_;
}
const Tuple &TableIterator::operator*() {
assert(*this != table_heap_->End());
return *tuple_;
}
上面的实现比较简单都是基于TableIterator的最小操作单位,也就是一个Tuple.
而关于++的实现则是比较复杂的.结合前面的理解,一个++操作意味着Tuple指针移动到当前table_heap中的下一个Tuple.因此这涉及到了需要借助Buffer Pool的Fetch,Unpin,GetTuple`等操作,可能涉及到不只一个内存页的读取和Unpin.
3) Executor
ExecutorContext
顾名思义,该类为执行器提供一些全局性的信息(也就是执行器背景).其中成员函数是这些全局性信息的指针(比如说Catalog,Buffer Pool,Lock Manafer等),主要API也是GET类型的,比如说:
Transaction *GetTransaction() const { return transaction_; }
Catalog *GetCatalog() { return catalog_; }
BufferPoolManager *GetBufferPoolManager() { return bpm_; }
LogManager *GetLogManager() { return nullptr; }
LockManager *GetLockManager() { return lock_mgr_; }
TransactionManager *GetTransactionManager() { return txn_mgr_; }
ExecutorPlan
对于一个SQL查询语句,内核会将其组织称一个树状的形式,自树的leaf道root递归地计算,最终从root得到最终的结果.其中每个ExecutorPlan代表查询树中的一个节点.
其中为了对应多态实现的Executor,Executor也是多态的,首先说明一下其中抽象类的实现(数据成员):
private:
const Schema *output_schema_;
std::vector<const AbstractPlanNode *> children_;
output_schema定义了执行完该节点后所得到Tuple的模式(相当于于投影机制).而children则对应了查询树中的子结点.关于其所能提供的API,也是一些围绕着output_schema_
和children_
展开的.至于其派生类,后面结合Executor的实现,进行分析.
ExecutorFactory
这个类的实现采用了工厂模式.由于容器在bustub中的实现是基于多态实现的,也就是说定义一个执行器基类,然后可以定义不同种类的执行器.其中相关的接口如下:
static std::unique_ptr<AbstractExecutor> CreateExecutor(ExecutorContext *exec_ctx, const AbstractPlanNode *plan);
2.代码实现
1) SeqScanExecutor
是整个project中最最基础的Executor类型,其他像UpdateExecutor,DeleteExector等类型往往都是以SeqScanExecutor作为child_executor的.
我的实现中有如下几个数据成员及其构造函数:
private:
/** The sequential scan plan node to be executed */
TableInfo *info_;
std::shared_ptr<TableIterator> iterator_;
const SeqScanPlanNode *plan_;
......
SeqScanExecutor::SeqScanExecutor(ExecutorContext *exec_ctx, const SeqScanPlanNode *plan)
: AbstractExecutor(exec_ctx), info_(nullptr), iterator_(nullptr), plan_(plan) {}
构造函数中有一个plan和exec_ctx.其中exec_ctx封装了一些全局的对象,比如说当前的事务,Catalog,BufferPool等.而关于plan对象,其数据成员如下:
const AbstractExpression *predicate_;
/** The table whose tuples should be scanned */
table_oid_t table_oid_;
再结合相关的API,我们可以知道这个对象会给出SeqScan作用的表id以及相关的谓词判断,用于执行条件过滤.
围绕着这几个数据成员,在Init函数中会初始化iterator_
(设置成TableHeap的Begin),还有info_
指针的设置.
关于Next的实现,基于遍历TableIterator实现,最终只返回一个,其中的重点是对于每个遍历的Tuple需要借助谓词来筛选.相关的代码如下:
if (plan_->GetPredicate() == nullptr || plan_->GetPredicate()->Evaluate(&tup, &info_->schema_).GetAs<bool>()) {
......
}
此外,对于遍历的Tuple还需要根据OutputScheme进行调整.其中相关的实现如下:
std::vector<Value> values;
values.reserve(output_schema->GetColumnCount());
for (size_t i = 0; i < output_schema->GetColumnCount(); i++) {
values.push_back(output_schema->GetColumn(i).GetExpr()->Evaluate(&tup, &info_->schema_));
}
2) Insert
Insert有两种类型,需要根据plan判断:
bool IsRawInsert() const { return GetChildren().empty(); }
然后根据Insert的类型,确定在初始化时是否需要对child_executor进行初始化.
关于Next操作,需要注意的是Next操作是一次性将插入全部,而不是一次性插入一个.其方法基本上是遍历RawValues或者chile_executor,从中获取或者构造出一个Tuple,然后插入调用InsertEntry.其中需要注意的是索引相关的更新.其相关代码如下:
auto indexes = exec_ctx_->GetCatalog()->GetTableIndexes(info_->name_);
for (auto &index : indexes) {
index->index_->InsertEntry(
tmp_next.KeyFromTuple(info_->schema_, index->key_schema_, index->index_->GetKeyAttrs()), tmp_rid,
AbstractExecutor::exec_ctx_->GetTransaction());
}
关于Update,Delete与之类似.
3) Nested Loop Join
这个无非就是在初始化(Init)的时候将两个表设置到Init,Next中就是两重循环(也就是对应两个表).并且需要检查条件,如果符合条件则返回,一次返回一个.关于条件判断的部分,代码如下:
if (plan_->Predicate() == nullptr || plan_->Predicate()
->EvaluateJoin(&curr_left_tuple_, left_executor_->GetOutputSchema(),
&right_tup, right_executor_->GetOutputSchema())
.GetAs<bool>()) {
4) Hash Join
实现的基本思路是,首先将其中一个表将其中与Hash Join有关的key构造一个Hash Map.这个Hash Map的key是这些表中有关的键值,value则是一个个的Tuple.其中具体的实现如下:
class SimpleHashJoinHashTable {
public:
using tuples = std::vector<Tuple>;
SimpleHashJoinHashTable() = default;
void Insert(const HashJoinKey &hashkey, const Tuple &tuple) {
if (buckets_.count(hashkey) == 0) {
tuples init_tuples;
init_tuples.push_back(tuple);
buckets_.insert({hashkey, std::move(init_tuples)});
} else {
buckets_[hashkey].push_back(tuple);
}
}
Tuple GetTuple(const HashJoinKey &hashkey, uint32_t idx) const {
auto find_it = buckets_.find(hashkey);
assert(find_it != buckets_.end());
return find_it->second[idx];
}
int32_t GetSize(const HashJoinKey &hashkey) const {
auto find_it = buckets_.find(hashkey);
if (find_it == buckets_.end()) {
return -1;
}
return static_cast<int32_t>(find_it->second.size());
}
private:
std::unordered_map<HashJoinKey, tuples, HashJoinHashFunc> buckets_;
};
其中这个类的数据成员是一个std::unordered_map
,HashJoin也就对应了一个表中和Hash
Join有关的键值.其中关于std::unordered_map
的一些有关于STL的操作还是值得注意的.独具键值来说HashJoinKey是一个对象,其中的‘==’是需要重载的.还有关于仿函数的定义和设置:
class HashJoinKey {
public:
Value value_;
bool operator==(const HashJoinKey &key) const {
auto cmp = value_.CompareEquals(key.value_);
return cmp == CmpBool::CmpTrue;
}
};
class HashJoinHashFunc {
public:
std::size_t operator()(const bustub::HashJoinKey &hashJoinKey) const {
size_t curr_hash = 0;
curr_hash = bustub::HashUtil::CombineHashes(curr_hash, bustub::HashUtil::HashValue(&hashJoinKey.value_));
return curr_hash;
}
};
定义好SimpleHashJoinTable
的数据结构,就可以实现Init和Next.其中Init需要将其中一个表遍历一遍,将该表记为left
child,每遍历一个Tuple都需要根据plan中的LeftJoinKeyExpression
来从Tuple中提取Value,然后构造出{joinkey, tuple}
.插入到SimpleHashJoinTable
中.在Next中,遍历的线索有两条,第一条是SimpleHashJoinTable中的key,第二条则是另一个表(没有在Init中用来构造哈希表的,记为right
child).更具体地说,我们遍历另一个表,得出其中的Value,然后代入到Hash
Table中可以得出对应Tuple,每次Next返回一个.因此我们需要借助额外的变量记录上一次Next所到达的left
child的Tuple.如下:
std::pair<HashJoinKey, int32_t> curr_left_tuple_;
Tuple curr_right_tuple_;
5) Aggregation
Aggregation的实现思路和Hash Join差不多,都需要借助一个Hash
Table,但是其实现相对来说比较简单,对于这个Hash表,直接使用了给出的SimpleAggregationHashTable
.只有Distinct也差不多,不过Distinct对于SimpleAggregationHashTable
上,我将key设置成一个Tuple的Value,而Hash
Table中的value我设置为null(就是什么都没有).因为我们只需要实现一个去重的作用.
3.其他记录
最后的结果,继续冲!