项目 #3 - 查询执行

记得从 bustub 仓库拉取最新代码。不要将代码发布到公开的 GitHub 仓库。

概述

在本学期的这个阶段,你已经实现了数据库管理系统的许多内部组件。 在项目 #1 中,你实现了缓冲池管理器。在项目 #2 中,你实现了 B+ 树索引。在本项目中,你将实现允许 BusTub 执行查询的组件。你将创建执行 SQL 查询的算子执行器,并实现优化器规则来转换查询计划。

本项目由以下几个任务组成:

本项目必须独立完成(即不允许组队)。 在开始之前,运行 git pull public master 从公共 BusTub 仓库拉取最新代码。

背景

请仔细阅读本节,因为你需要构造自己的 SQL 查询来测试执行器实现。你还可以使用 bustub shell 来了解:

简介

BusTub 的架构如下:

在公共 BusTub 仓库中,我们提供了完整的查询处理层。你可以使用 BusTub shell 执行 SQL 查询,就像在其他数据库系统中一样。使用以下命令编译并运行 BusTub shell:

cd build && make -j$(nproc) shell
./bin/bustub-shell

你还可以使用 BusTub Web Shell 来运行下面的示例。它是一个在浏览器中运行的完整参考解决方案!

在 shell 中,你可以使用 \dt 查看所有表。默认情况下,BusTub shell 会自动创建三个预填充数据的表。这样做是为了方便你,无需在每次重新构建解决方案时加载数据。重启 DBMS 后,对这些表的更改不会被持久化。

bustub> \dt
+-----+----------------+------------------------------+
| oid | name           | cols                         |
+-----+----------------+------------------------------+
| 0   | __mock_table_1 | (colA:INTEGER, colB:INTEGER) |
| 1   | __mock_table_2 | (colC:VARCHAR, colD:VARCHAR) |
| 2   | __mock_table_3 | (colE:INTEGER, colF:VARCHAR) |
| ... | ...            | ...                          |
+-----+----------------+------------------------------+

你可以使用 SELECT 语句查看表中的所有数据:

bustub> SELECT * FROM __mock_table_1;
+---------------------+---------------------+
| __mock_table_1.colA | __mock_table_1.colB |
+---------------------+---------------------+
| 0                   | 0                   |
| 1                   | 100                 |
| 2                   | 200                 |
| 3                   | 300                 |
| 4                   | 400                 |
| 5                   | 500                 |
| ...                 | ...                 |
+---------------------+---------------------+

请注意:

检查 SQL 查询计划

BusTub 支持 EXPLAIN 命令来打印查询的执行计划。你可以在任何查询前添加 EXPLAIN。例如:

bustub> EXPLAIN SELECT * FROM __mock_table_1;
=== BINDER ===
BoundSelect {
  table=BoundBaseTableRef { table=__mock_table_1, oid=0 },
  columns=[__mock_table_1.colA, __mock_table_1.colB],
  groupBy=[],
  having=,
  where=,
  limit=,
  offset=,
  order_by=[],
  is_distinct=false,
}
=== PLANNER ===
Projection { exprs=[#0.0, #0.1] } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)
MockScan { table=__mock_table_1 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)
=== OPTIMIZER ===
MockScan { table=__mock_table_1 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)

EXPLAIN 的结果提供了查询处理层中转换过程的概览。该语句首先由解析器和绑定器处理,生成一个表示查询的抽象语法树(AST)。在此示例中,查询由 __mock_table_1 上的 BoundSelect 表示,它将检索两列(colAcolB)。注意,绑定器会自动将原始 SQL 查询中的 * 字符展开为表中的实际列。

接下来,绑定器的 AST 由计划器处理,计划器会生成一个合适的查询计划。在这种情况下,查询计划是由两个节点组成的树,数据从叶节点流向根节点:

之后,优化器会优化查询计划。在这种情况下,它移除了投影,因为该投影是多余的。

让我们考虑一个更复杂的示例:

bustub> EXPLAIN (o) SELECT colA, MAX(colB) FROM
  (SELECT * FROM __mock_table_1, __mock_table_3 WHERE colA = colE) GROUP BY colA;
=== OPTIMIZER ===
Agg { types=[max], aggregates=[#0.1], group_by=[#0.0] }
  NestedLoopJoin { type=Inner, predicate=(#0.0=#1.0) }
    MockScan { table=__mock_table_1 }
    MockScan { table=__mock_table_3 }

对于这个示例,优化后的查询计划为:

在本项目中,你需要构造 SQL 查询来测试每个执行器的实现。EXPLAIN 对于你了解某个 SQL 查询是否使用了特定的执行器非常有帮助。

示例执行器

在 BusTub 公共仓库中,我们提供了几个示例执行器实现。

投影

投影节点可以表示对其输入的各种计算。它总是恰好有一个子节点。在 BusTub shell 中,检查以下查询的查询计划:

EXPLAIN SELECT 1 + 2;
EXPLAIN SELECT colA FROM __mock_table_1;
EXPLAIN SELECT colA + colB AS a, 1 + 2 AS b FROM __mock_table_1;

投影计划节点由一个或多个表示计算的表达式组成:

  • ColumnValueExpression:直接将子执行器的一列放入输出。语法 #0.0 表示第一个子节点中的第一列。在连接计划中,你会看到类似 #0.0 = #1.0 的内容。
  • ConstantExpression:表示一个常量值(例如 1)。
  • ArithmeticExpression:表示算术计算的树。例如,1 + 2 将由一个 ArithmeticExpression 表示,其两个子节点为 ConstantExpression12)。

过滤

过滤计划节点用于根据谓词过滤子节点的输出。例如:

EXPLAIN SELECT * FROM __mock_table_1 WHERE colA > 1;

过滤节点恰好有一个子节点并包含一个谓词。

计划节点用于直接生成值:

EXPLAIN values (1, 2, 'a'), (3, 4, 'b');
CREATE TABLE table1(v1 INT, v2 INT, v3 VARCHAR(128));
EXPLAIN INSERT INTO table1 VALUES (1, 2, 'a'), (3, 4, 'b');

值计划节点在将用户提供的值插入表时很有用。

查询计划语法

你可能已经注意到,EXPLAIN 在每个计划节点之后会生成一串列描述。这就是该节点的输出模式。考虑以下示例输出:

Projection { exprs=[#0.0, #0.1] } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)

这表示代表该计划节点的执行器将生成两列,均为整数类型。输出模式在计划器中推断。在本项目中,你的执行器实现必须生成与计划节点中指定的模式完全一致的元组,否则将无法通过我们的单元测试。

项目规范

在本项目中,你将向 BusTub 添加新的算子执行器和查询优化。BusTub 使用基于行的向量化查询处理模型,其中每个执行器都实现一个 Next 函数来获取最多 BUSTUB_BATCH_SIZE 个元组的下一批结果。当 DBMS 调用执行器的 Next 函数时,执行器返回 (1) 一批元组或 (2) 表示没有更多元组的指示器。使用这种方法,每个执行器实现一个循环,持续调用其子节点的 Next 来检索元组并逐批处理它们。

在 BusTub 的向量化模型实现中,每个执行器的 Next 函数除了返回一批元组外,还返回一批记录标识符(RID)。记录标识符用作元组的唯一标识符。

执行器从 src/execution/executor_factory.cpp 中的执行计划创建。

本项目中的所有测试用例都使用一种称为 SQLLogicTest 的特殊文件格式(源自 SQLite)。你可以在本页末尾找到使用方法。

任务 #1 - 访问方法执行器

在上面的背景部分,我们看到 BusTub 已经可以在 SELECT 查询中从模拟表检索数据。这是在没有真实表的情况下通过使用 MockScan 执行器始终使用预定义算法生成相同元组来实现的。这就是你无法更新这些表的原因。

在此任务中,你将实现从存储系统中读取和写入表的执行器。你需要在以下文件中完成实现:

下面描述每个执行器。

SeqScan

SeqScanPlanNode 可以通过 SELECT * FROM table 语句来规划。

bustub> CREATE TABLE t1(v1 INT, v2 VARCHAR(100));
Table created with id = 15
bustub> EXPLAIN (o,s) SELECT * FROM t1;
=== OPTIMIZER ===
SeqScan { table=t1 } | (t1.v1:INTEGER, t1.v2:VARCHAR)

SeqScanExecutor 遍历一个表并逐批返回其元组。

提示:确保你在使用 TableIterator 对象时理解前置递增和后置递增运算符之间的区别。(查看这里快速复习。)

提示:不要发出在 TableHeap 中已删除的元组。 检查每个元组对应的 TupleMetais_deleted_ 字段。

提示:顺序扫描的输出是每个匹配元组及其原始记录标识符(RID)的副本。

注意:BusTub 不支持 DROP TABLEDROP INDEX。你可以通过重新启动 shell 来重置数据库。

Insert

InsertPlanNode 可以通过 INSERT 语句来规划。注意,你需要使用单引号来指定 VARCHAR 值。

bustub> EXPLAIN (o,s) INSERT INTO t1 VALUES (1, 'a'), (2, 'b');
=== OPTIMIZER ===
Insert { table_oid=15 } | (__bustub_internal.insert_rows:INTEGER)
  Values { rows=2 } | (__values#0.0:INTEGER, __values#0.1:VARCHAR)

InsertExecutor 将元组插入表中并更新所有受影响的索引。它恰好有一个子节点生成要插入表中的值。计划器将确保值与表具有相同的模式。执行器将生成一个整数类型的单个元组作为输出,表示已插入表中的行数。如果表有关联的索引,请记住在插入表时更新索引。

提示:请参阅下面的系统目录部分了解有关系统目录的信息。要初始化此执行器,你需要查找有关要插入的表的信息。

提示:请参阅下面的索引更新部分了解有关更新表索引的更多详细信息。

提示:你需要使用 TableHeap 类来执行表修改。

Update

UpdatePlanNode 可以通过 UPDATE 语句来规划。它恰好有一个子节点包含表中要更新的记录。

bustub> explain (o,s) update test_1 set colB = 15445;
=== OPTIMIZER ===
Update { table_oid=20, target_exprs=[#0.0, 15445, #0.2, #0.3] } | (__bustub_internal.update_rows:INTEGER)
  SeqScan { table=test_1 } | (test_1.colA:INTEGER, test_1.colB:INTEGER, test_1.colC:INTEGER, test_1.colD:INTEGER)

UpdateExecutor 修改指定表中的现有元组。执行器将生成一个整数类型的单个元组作为输出,表示已更新的行数。请记住更新受更新影响的任何索引。

提示:要实现更新,首先删除受影响的元组,然后插入新的元组。

Delete

DeletePlanNode 可以通过 DELETE 语句来规划。它恰好有一个子节点包含表中要删除的记录。你的删除执行器应生成一个整数输出,表示它从表中删除的行数。它还需要更新所有受影响的索引。

bustub> EXPLAIN (o,s) DELETE FROM t1;
=== OPTIMIZER ===
Delete { table_oid=15 } | (__bustub_internal.delete_rows:INTEGER)
  Filter { predicate=true } | (t1.v1:INTEGER, t1.v2:VARCHAR)
    SeqScan { table=t1 } | (t1.v1:INTEGER, t1.v2:VARCHAR)

bustub> EXPLAIN (o,s) DELETE FROM t1 where v1 = 1;
=== OPTIMIZER ===
Delete { table_oid=15 } | (__bustub_internal.delete_rows:INTEGER)
  Filter { predicate=#0.0=1 } | (t1.v1:INTEGER, t1.v2:VARCHAR)
    SeqScan { table=t1 } | (t1.v1:INTEGER, t1.v2:VARCHAR)

你可以假设 DeleteExecutor 始终位于其出现的查询计划的根节点。DeleteExecutor 不应修改其结果集。

提示:要删除一个元组,你需要从子执行器获取 RID 并更新该元组对应的 TupleMetais_deleted_ 字段。

IndexScan

IndexScanExecutor 使用 B+ 树索引执行点查找和有序扫描来检索表中的元组。该执行器应能够支持在同一索引上的多次点查找。

你需要通过支持以下两种类型的查询来实现索引扫描:

  1. 点查找:SELECT FROM <table> WHERE <index column> = <val>。你将在下一节中实现优化器规则,将 SeqScan 转换为 IndexScan
  2. 有序扫描:SELECT FROM <table> ORDER BY <index column>。为在索引列上使用 ORDER BY 的查询生成 IndexScan 的优化器规则已为你实现。你的任务是仅在 IndexScan 中处理 ASC(或默认)排序。
bustub> CREATE TABLE t1(v1 int, v2 int);
Table created with id = 22

bustub> CREATE INDEX t1v1 ON t1(v1);
Index created with id = 0

bustub> EXPLAIN (o,s) SELECT * FROM t1 WHERE v1 = 1;
=== OPTIMIZER ===
IndexScan { index_oid=0, filter=(#0.0=1) } | (t1.v1:INTEGER, t1.v2:INTEGER)

bustub> EXPLAIN (o,s) SELECT * FROM t1 ORDER BY v1;
=== OPTIMIZER ===
IndexScan { index_oid=0 } | (t1.v1:INTEGER, t1.v2:INTEGER)

在本项目中,计划中索引对象的类型将始终为 BPlusTreeIndexForTwoIntegerColumn。你可以安全地将对象转换为此类型并将其存储在需要时的执行器中:

tree_ = dynamic_cast<BPlusTreeIndexForTwoIntegerColumn *>(index_info_->index_.get())

然后你可以使用 B+ 树索引进行点查找或有序扫描,并发出满足条件的元组。在本学期中,你只需支持单个唯一整数列上的索引。我们的测试用例不会包含重复键。因此,此执行器每次点查找返回一个元组(如果存在的话)。

你需要在下一节中完成优化器规则,将 SeqScan 转换为 IndexScan在实现 IndexScan 之前先实现优化器规则可能更有意义,这样可以了解 IndexScanExecutor 需要支持的查询类型。

提示:我们永远不会向带有索引的表插入重复行。

提示:如上所述,不要发出已删除的元组。

提示:请使用 ScanKey 进行点查找,使用 Index Iterator 进行有序扫描。

将 SeqScan 优化为 IndexScan

正如我们在课堂上学到的,当在索引列上查询时,使用 IndexScan 将显著提高查找性能。为此,我们需要将过滤条件下推到扫描器中,以便知道在索引中查找的键。然后我们可以直接通过索引检索值,而不是进行全表扫描。

你需要修改优化器,在可能的情况下将 SeqScanPlanNode 转换为 IndexScanPlanNode

考虑以下示例:

bustub> EXPLAIN (o) SELECT * FROM t1 WHERE v1 = 1;

在不应用 MergeFilterScan 和 SeqScan as IndexScan 优化器规则的情况下,计划可能如下所示:

 Filter { predicate=(#0.0=1) } | (t1.v1:INTEGER, t1.v2:INTEGER, t1.v3:INTEGER)
   SeqScan { table=t1 } | (t1.v1:INTEGER, t1.v2:INTEGER, t1.v3:INTEGER)                              

在应用 MergeFilterScan 和 SeqScan as IndexScan 优化器规则之后,我们可以直接进行快速索引查找,而无需遍历整个表。结果计划将如下所示:

 IndexScan { index_oid=0, filter=(#0.0=1) } | (t1.v1:INTEGER, t1.v2:INTEGER, t1.v3:INTEGER)

以下是实现此优化器规则的简要步骤:

  1. 启用谓词下推到 SeqScan: 我们可以在 SeqScanExecutor 中实现谓词过滤,以便后续索引扫描节点拥有该谓词。我们已经在入门优化器规则中为你启用了 MergeFilterScan 优化器规则 src/optimizer/merge_filter_scan.cpp

  2. 使用索引: 你可以检查谓词中的过滤列。如果该列上恰好存在索引,则创建一个 IndexScanPlanNode。注意,要获得满分,你需要在几种不同情况下支持此优化器规则:(1) 当谓词中索引列上有一个等值测试时(即 WHERE v1 = 1)(2) 当索引列的顺序反转时(即 WHERE 1 = v1)(3) 当同一索引上有多个点查找时(即 WHERE v1 = 1 or v1 = 4)。注意,SELECT * FROM t1 WHERE v1 = 1 AND v2 = 2 形式的查询仍应使用顺序扫描,因此你不需要拆分谓词。

请查看优化器规则实现指南部分了解实现优化器规则的详细信息。

现在你已经实现了所有与存储相关的执行器。在接下来的任务中,你可以自己创建表并插入一些值来测试你的执行器实现!此时,你还应该已经通过了 SQLLogicTests #1 到 #6

提示:你可能会发现工具函数 BPlusTreeIndex::ScanKey 很有帮助。

提示:思考如何处理 WHERE v1 = 1 OR v1 = 1 形式的查询。查看 /src/include/execution/plans/aggregation_plan.h 中的 AggregateKey 可能会有所帮助。

提示:请仅在上面提到的两种场景中将 SeqScan 优化为 IndexScan(即点查找和有序扫描)。

任务 #2 - 聚合与连接执行器

你需要在以下文件中完成实现:

聚合

AggregationPlanNode 用于支持如下查询:

EXPLAIN SELECT colA, MIN(colB) FROM __mock_table_1 GROUP BY colA;
EXPLAIN SELECT COUNT(colA), min(colB) FROM __mock_table_1;
EXPLAIN SELECT colA, MIN(colB) FROM __mock_table_1 GROUP BY colA HAVING MAX(colB) > 10;
EXPLAIN SELECT DISTINCT colA, colB FROM __mock_table_1;

聚合执行器为每组输入计算一个聚合函数。它恰好有一个子节点。 输出模式由分组列后跟聚合列组成。

正如课堂上讨论的,实现聚合的常见策略是使用哈希表,以分组列作为键。在本项目中,你可以假设聚合哈希表适合内存。这意味着你不需要实现多阶段的基于分区的策略,哈希表也不需要由缓冲池页面支持。

我们提供了一个 SimpleAggregationHashTable 数据结构,它公开了一个内存哈希表(std::unordered_map),但具有专为计算聚合设计的接口。此类还公开了一个 SimpleAggregationHashTable::Iterator 类型,可用于遍历哈希表。你需要完成此类的 CombineAggregateValues 函数。

聚合执行器本身不需要处理 HAVING 谓词。计划器将带有 HAVING 子句的聚合规划为 AggregationPlanNode 后跟一个 FilterPlanNode

提示:在查询计划的上下文中,聚合是流水线阻断器。这可能会影响你在实现中使用 AggregationExecutor::Init()AggregationExecutor::Next() 函数的方式。仔细决定聚合的构建阶段应在 AggregationExecutor::Init() 还是 AggregationExecutor::Next() 中执行。

提示:你必须处理聚合函数输入中的 NULL 值(即元组可能在聚合函数使用的属性上具有 NULL 值)。请参阅测试用例了解预期行为。

提示:分组列也可以具有 NULL 值。你可能需要考虑修改聚合键的比较方式。思考 null 比较通常如何工作以及在这种情况下应该如何工作。

提示:在空表上执行聚合时,CountStarAggregate 应返回零,所有其他聚合类型应返回 integer_null。这就是 GenerateInitialAggregateValue 将大多数聚合值初始化为 NULL 的原因。

NestedLoopJoin

默认情况下,DBMS 将对所有连接操作使用 NestedLoopJoinPlanNode。考虑以下示例查询:

EXPLAIN SELECT * FROM __mock_table_1, __mock_table_3 WHERE colA = colE;
EXPLAIN SELECT * FROM __mock_table_1 INNER JOIN __mock_table_3 ON colA = colE;
EXPLAIN SELECT * FROM __mock_table_1 LEFT OUTER JOIN __mock_table_3 ON colA = colE;

你需要使用课堂上的简单嵌套循环连接算法为 NestedLoopJoinExecutor 实现内连接和左连接。此算子的输出模式是左表的所有列后跟右表的所有列。 对于外表中的每个元组,考虑内表中的每个元组,如果连接谓词满足则发出一个输出元组。

提示:你应该使用 NestedLoopJoinPlanNode 中的谓词。请参阅 AbstractExpression::EvaluateJoin,它处理左元组和右元组及其各自的模式。注意,这返回一个 Value,可能是 false、true 或 NULL。请参阅 FilterExecutor 了解如何在元组上应用谓词。

NestedIndexJoin

如果查询包含带有等值条件的连接,且连接的右侧在条件上有索引,DBMS 将使用 NestedIndexJoinPlanNode

考虑以下示例:

CREATE TABLE t1(v1 int, v2 int);
CREATE TABLE t2(v3 int, v4 int);
CREATE INDEX t2v3 on t2(v3);
EXPLAIN SELECT * FROM t1 INNER JOIN t2 ON v1 = v3;
=== PLANNER ===
Projection { exprs=[#0.0, #0.1, #0.2, #0.3] } | (t1.v1:INTEGER, t1.v2:INTEGER, t2.v3:INTEGER, t2.v4:INTEGER)
  NestedLoopJoin { predicate=#0.0=#1.0 } | (t1.v1:INTEGER, t1.v2:INTEGER, t2.v3:INTEGER, t2.v4:INTEGER)
    SeqScan { table=t1 } | (t1.v1:INTEGER, t1.v2:INTEGER)
    SeqScan { table=t2 } | (t2.v3:INTEGER, t2.v4:INTEGER)
=== OPTIMIZER ===
NestedIndexJoin { type=Inner, key_predicate=#0.0, index=t2v3, index_table=t2 } | (t1.v1:INTEGER, t1.v2:INTEGER, t2.v3:INTEGER, t2.v4:INTEGER)
  SeqScan { table=t1 } | (t1.v1:INTEGER, t1.v2:INTEGER)

在计划阶段,查询被规划为两个表的 NestedLoopJoin。优化器识别到连接的右侧(SeqScan t2)在列 v3 上有索引,且连接条件是等值条件 v1 = v3。这意味着对于左侧的所有元组,系统可以使用键 v1 查询索引 t2v3 来生成连接结果。

NestedIndexJoin 的模式是左表(子节点,外表)的所有列,然后是右表(索引,内表)的所有列。此执行器将只有一个子节点,传播对应于连接外表的元组批。对于每个这些元组,你需要通过在目录中利用索引来找到与索引键匹配的内表中的对应元组。

提示:你需要从外表获取元组,使用 key_predicate 构造索引探测键,然后在索引中查找 RID 以检索内表的对应元组。

我们将在 Gradescope 上按原样提供所有测试用例。我们不会测试涉及 NULL 的奇怪边缘情况(例如分组子句或索引中的 NULL)。此时,你应该通过 SQLLogicTests - #7 到 #13

任务 #3 - HashJoin 执行器与优化

你需要在以下文件中完成实现:

在开始此任务之前,你需要先完成任务 #2 中的 NestedLoopJoinExecutor 实现。

HashJoin

如果查询包含两个列之间多个等值条件的合取(等值条件由 AND 分隔),DBMS 可以使用 HashJoinPlanNode。考虑以下示例查询:

EXPLAIN SELECT * FROM __mock_table_1, __mock_table_3 WHERE colA = colE;
EXPLAIN SELECT * FROM __mock_table_1 INNER JOIN __mock_table_3 ON colA = colE;
EXPLAIN SELECT * FROM __mock_table_1 LEFT OUTER JOIN __mock_table_3 ON colA = colE;
EXPLAIN SELECT * FROM test_1 t1, test_2 t2 WHERE t1.colA = t2.colA AND t1.colB = t2.colC;
EXPLAIN SELECT * FROM test_1 t1 INNER JOIN test_2 t2 on t1.colA = t2.colA AND t2.colC = t1.colB;
EXPLAIN SELECT * FROM test_1 t1 LEFT OUTER JOIN test_2 t2 on t2.colA = t1.colA AND t2.colC = t1.colB;

你需要使用课堂上的哈希连接算法为 HashJoinExecutor 实现内连接和左连接。此算子的输出模式是左表的所有列后跟右表的所有列。探测哈希表可能完全适合内存(假设我们的内存最多可以支持 4KB 元组的哈希表)。因此,你的实现应遵循课堂上讨论的 Grace Hash Table 算法。

你可能已经知道,中间分区结果需要特定的页面格式。在本项目中,你将负责设计页面格式。查看 intermediate_result_page.h,你会发现 IntermediateResultPage,它专门用于存储中间执行结果,但只包含最少的预定义成员/方法。你应该设计页面布局并实现页面的读/写方法。在设计 IntermediateResultPage 之前,建议先阅读任务 4 - 外部归并排序部分,以决定是否要在两个任务中使用相同的 IntermediateResultPage 实现。

你的实现应正确处理多个元组发生哈希冲突的情况(在连接的任一侧)。你将需要使用 HashJoinPlanNode 中的连接键访问器函数 GetLeftJoinKey()GetRightJoinKey() 来分别构造连接的左侧和右侧的连接键。

提示:你需要一种方法来对具有多个属性的元组进行哈希处理,以构造唯一的键。作为起点,请查看 AggregationExecutor 中的 SimpleAggregationHashTable 如何实现此功能。

提示:与聚合一样,哈希连接的构建侧是流水线阻断器。你应该再次考虑哈希连接的构建阶段应在 HashJoinExecutor::Init() 还是 HashJoinExecutor::Next() 中执行。

将 NestedLoopJoin 优化为 HashJoin

哈希连接通常比嵌套循环连接产生更好的性能。 你应该修改优化器,在可以使用哈希连接时将 NestedLoopJoinPlanNode 转换为 HashJoinPlanNode。具体来说,当连接谓词是两个列之间多个等值条件的合取时,可以使用哈希连接算法。在本项目中,你应该能够处理由 AND 连接的不同数量的等值条件。

考虑以下示例:

bustub> EXPLAIN (o) SELECT * FROM test_1 t1, test_2 t2 WHERE t1.colA = t2.colA AND t1.colB = t2.colC;

在不应用 NLJAsHashJoin 优化器规则的情况下,计划可能如下所示:

 NestedLoopJoin { type=Inner, predicate=((#0.0=#1.0)and(#0.1=#1.2)) } 
   SeqScan { table=test_1 }                                           
   SeqScan { table=test_2 }

在应用 NLJAsHashJoin 优化器规则之后,将从 NestedLoopJoinPlanNode 的单个连接谓词中提取左右连接键表达式。结果计划将如下所示:

 HashJoin { type=Inner, left_key=[#0.0, #0.1], right_key=[#0.0, #0.2] } 
   SeqScan { table=test_1 }                                             
   SeqScan { table=test_2 } 

请查看优化器规则实现指南部分了解实现优化器规则的详细信息。此时,你应该通过 SQLLogicTests - #14 到 #15

提示:确保检查等值条件每一侧的列属于哪个表。外表的列可能位于等值条件的右侧。你可能会发现 ColumnValueExpression::GetTupleIdx 很有帮助。

提示:应用优化器规则的顺序很重要。例如,你应该在过滤器和 NestedLoopJoin 合并之后再优化 NestedLoopJoin 为 HashJoin。

提示 在处理多个等值条件时,尝试递归地提取键,而不是用多层 if 子句来匹配连接条件。

任务 #4:外部归并排序 + Limit 执行器 + 窗口函数

你需要在以下文件中完成实现:

在开始此任务之前,你需要先完成任务 #1 中的 IndexScanExecutor。如果表上有索引,查询处理层将自动选择它进行排序。在其他情况下,你将需要一个特殊的排序执行器来完成这项工作,在我们的情况下,就是外部归并排序执行器。

Limit 执行器的实现将比外部归并排序执行器容易得多。因此,如果你在外部归并排序执行器上遇到困难,可以先实现 Limit 执行器。但是你应该注意,在没有外部归并排序执行器的情况下我们不会测试 Limit 执行器。

对于所有 ORDER BY 子句,我们假设每个排序键只出现一次。你不需要担心排序中的并列情况。你还需要支持使用外部归并排序对包含 NULL 值的列进行排序。

外部归并排序

除非 ORDER BY 属性与索引的键匹配,否则 BusTub 将对所有 ORDER BY 算子使用 SortPlanNode

EXPLAIN SELECT * FROM __mock_table_1 ORDER BY colA ASC, colB DESC NULLS FIRST;

此计划节点不改变模式(即输出模式与输入模式相同)。你可以从 order_bys 中提取排序键。如果查询在 ORDER BY 子句中未包含排序方向(即 ASCDESC),则排序模式将为 default(即 ASC)。如果查询在 ORDER BY 子句中未指定 NULLS FIRSTNULLS LAST 选项,则 NULL 值的放置将使用 default,即升序时为 NULLS FIRST,降序时为 NULLS LAST

关于外部归并排序的一个重要假设是表中的条目能全部放入内存。因此,你需要按照课堂上学到的内容:将中间排序结果存储在临时页面中,并基于上一轮的排序结果递归地进行归并排序。

与 HashJoin 类似,中间排序结果需要特定的页面格式。你应该设计页面布局并实现页面的读/写方法。你可以选择重用与 HashJoin 中使用的 IntermediateResultPage 相同的布局,或者在需要时定义新的布局。

你的 IntermediateResultPage 实现必须能够支持包含 VARCHAR 属性的元组排序。为了简化页面实现,包含 VARCHAR 属性的元组永远不会大于页面大小,即你不必担心将单个元组存储在两个 IntermediateResultPage 中。如果当前页面无法容纳该元组,你可以获取一个新页面并将其存储在那里。

然后你将实现归并排序算法。进行内存归并排序并不难。但当涉及磁盘时,你应该仔细思考如何通过页面接口和缓冲池操作排序后的元组。值得注意的是,不再使用的页面(即来自上一轮归并排序的页面)应该被删除,否则你将有一堆永远不会被引用但仍然存在于缓冲池中的"僵尸"页面。

重要的是你确实在进行外部归并排序(不是将所有数据纯粹存储在内存中)。你可以使用 std::sort 对适合一个排序页面的元组进行排序,但不能对所有元组使用。此外,你的 IntermediateResultPage 布局应该是紧凑的,即尽可能少的碎片。你还应该确保在归并排序完成后页面确实被删除了。

此外,本学期我们只测试两路外部归并排序,即使 ExternalMergeSortExecutor 是模板化的。因此,你可以自由地实现两路归并排序算法,而不是 k 路归并。

提示:对于基于排序键的元组比较,我们在 execution_common.h 中提供了一个辅助类 TupleComparator。你可以在 execution_common.cpp 中完成其实现。

提示:为了更好地理解 IntermediateResultPage 应该做什么以及从哪里开始,你可以查看你在项目 2 中使用的索引页面布局(例如,char 数组可以用作页面数据的起始)。思考你的执行器将如何调用读/写方法也会很有帮助。

提示:在进行外部归并排序时,你实际上不需要手动操作"写入磁盘"的部分。缓冲池管理器会为你处理这些。记住你在 P1 中实现的内容:页面守卫会自动固定和取消固定页面,以及设置可驱逐性。只要正确使用页面守卫,你就不必在这个项目中过多担心页面操作。

Limit

LimitPlanNode 指定查询将生成的元组数量。考虑以下示例:

EXPLAIN SELECT * FROM __mock_table_1 LIMIT 10;

LimitExecutor 限制其子执行器输出的元组数量。如果其子执行器生成的元组数量少于计划节点中指定的限制,则此执行器无效,并产生它接收到的所有元组。

此计划节点不改变模式(即输出模式与输入模式相同)。你需要支持偏移量。

窗口函数

一般来说,窗口函数有三个部分:partition by、order by 和窗口帧。这三部分都是可选的,因此这些功能的多种组合使得窗口函数一开始看起来令人望而生畏。 然而,窗口函数的概念模型有助于使其更容易理解。概念模型如下: * 根据 partition by 子句中的条件拆分数据。 * 然后,在每个分区中,按 order by 子句排序。 * 然后,在每个分区(现已排序)中,遍历每个元组。对于每个元组,我们计算该元组的帧的边界条件。每个帧有一个起始和结束(由窗口帧子句指定)。窗口函数在每个帧中的元组上计算,我们输出在每个帧中计算的结果。

下图展示了窗口函数的一般执行模型。

让我们使用以下表深入了解几个示例:

CREATE TABLE t (user_name VARCHAR(1), dept_name VARCHAR(16), salary INT);
INSERT INTO t VALUES ('a', 'dept1', 100);
INSERT INTO t VALUES ('b', 'dept1', 200);
INSERT INTO t VALUES ('c', 'dept1', 300);
INSERT INTO t VALUES ('e', 'dept2', 100);
INSERT INTO t VALUES ('d', 'dept2', 50);
INSERT INTO t VALUES ('f', 'dept2', 60);

示例 #1 下面的示例计算每个部门工资的移动平均值。你可以将其视为首先按 name 对每个分区的行排序,然后计算当前行之前的行、当前行和当前行之后的行的平均值。

bustub> SELECT user_name, dept_name, AVG(salary) OVER \
  (PARTITION BY dept_name ORDER BY user_name ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) \
  FROM t;
+-----------+-----------+-----------------------------+
| user_name | dept_name | salary                      |
+-----------+-----------+-----------------------------+
| a         | dept1     | 150                         |
| b         | dept1     | 200                         |
| c         | dept1     | 250                         |
| d         | dept2     | 75                          |
| e         | dept2     | 70                          |
| f         | dept2     | 80                          |
+-----------+-----------+-----------------------------+

示例 #2 下面的查询计算每个部门工资的移动平均值。与前面的示例不同,当窗口帧被省略且 order by 子句未被省略时,它计算从每个分区的第一行到当前行的平均值。

bustub> SELECT user_name, dept_name, AVG(salary) OVER (PARTITION BY dept_name ORDER BY user_name) FROM t;
+-----------+-----------+-----------------------------+
| user_name | dept_name | salary                      |
+-----------+-----------+-----------------------------+
| a         | dept1     | 100                         |
| b         | dept1     | 150                         |
| c         | dept1     | 200                         |
| d         | dept2     | 50                          |
| e         | dept2     | 75                          |
| f         | dept2     | 70                          |
+-----------+-----------+-----------------------------+

示例 #3 此查询展示了当 order by 和窗口帧都被省略时,它计算从每个分区的第一行到最后一行的平均值,这意味着分区内的结果应该是相同的。

bustub> SELECT user_name, dept_name,AVG(salary) OVER (PARTITION BY dept_name) FROM t;
+-----------+-----------+-----------------------------+
| user_name | dept_name | salary                      |
+-----------+-----------+-----------------------------+
| a         | dept1     | 200                         |
| b         | dept1     | 200                         |
| c         | dept1     | 200                         |
| e         | dept2     | 70                          |
| d         | dept2     | 70                          |
| f         | dept2     | 70                          |
+-----------+-----------+-----------------------------+
bustub> SELECT user_name, dept_name, AVG(salary) OVER () FROM t;
+-----------+-----------+-----------------------------+
| user_name | dept_name | salary                      |
+-----------+-----------+-----------------------------+
| a         | dept1     | 135                         |
| b         | dept1     | 135                         |
| c         | dept1     | 135                         |
| e         | dept2     | 135                         |
| d         | dept2     | 135                         |
| f         | dept2     | 135                         |
+-----------+-----------+-----------------------------+

对于此任务,你需要处理窗口帧。如上面的示例所示,你只需要实现 PARTITION BYORDER BY 子句。你可能会注意到 ORDER BY 子句也改变了非窗口函数列的顺序。这不是必须的,因为输出顺序不保证,取决于实现。为简单起见,BusTub 确保查询中的所有窗口函数具有相同的 ORDER BY 子句。这意味着以下查询在 BusTub 中受支持,你的实现不需要处理它们:

SELECT SUM(v1) OVER (ORDER BY v1), SUM(v1) OVER (ORDER BY v2) FROM t1;
SELECT SUM(v1) OVER (ORDER BY v1), SUM(v2) OVER () FROM t1;

测试用例不会检查输出行的顺序,只要每行内的列匹配即可。因此,当有 ORDER BY 子句时,你可以在计算之前先对元组排序,当没有 order by 子句时,不改变来自子执行器的元组顺序。

你可以按以下步骤实现执行器:

  1. 按照 ORDER BY 中的指示对元组排序。
  2. 为每个分区生成初始值
  3. 为每个分区合并值并记录每行的值。

你可以重用排序执行器中的代码来完成步骤 1,重用聚合执行器中的代码来完成步骤 2 和步骤 3。

除了之前任务中实现的聚合函数外,你还需要实现 RANK。BusTub 计划器确保如果存在 RANK 窗口函数,则 ORDER BY 子句不为空。请注意可能存在并列情况,请参阅测试用例了解预期行为。

可选排行榜任务

在本项目的排行榜挑战中,你应该实现新的执行器和优化器规则,使系统尽可能快地执行以下查询。请注意,在 Gradescope 上,我们将随机化排行榜任务的数据生成,因此自动评分器的输出可能与本地输出不同。

这些任务是可选的。你不需要完成它们就能在项目中获得满分。与之前的项目不同,项目 3 排行榜测试中的段错误和超时不会影响你的分数。

你的实现可能会在实现排行榜优化后对现有查询产生不同的结果。我们要求你在实现新的优化规则后通过所有测试。我们还将强制对某些测试用例使用入门规则。例如,为了确保你的索引扫描执行器正常工作,我们在此 sqllogictest 文件中强制使用入门规则 set force_optimizer_starter_rule=yes

查询 1:太多连接了!

考虑以下示例数据库:

CREATE TABLE t4(x int, y int);
CREATE TABLE t5(x int, y int);
CREATE TABLE t6(x int, y int);

用户不是来自 CMU,他们写出了糟糕的 SQL。他们忘记了如何编写带连接的查询,所以把所有谓词都放在了 WHERE 子句中。

SELECT * FROM t4, t5, t6
  WHERE (t4.x = t5.x) AND (t5.y = t6.y) AND (t4.y >= 1000000)
    AND (t4.y < 1500000) AND (t6.x >= 100000) AND (t6.x < 150000);

推荐优化: 分解过滤条件以提取哈希连接键,并将剩余的过滤条件下推到哈希连接之下。

查询 2:疯狂的数据科学家

有一位数据科学家把所有的钱都投资了 NFT。在意识到他们可怕的错误之后,他们发疯了,开始写一些奇怪的 SQL 查询。考虑以下示例:

SELECT v, d1, d2 FROM (
  SELECT v,
         MAX(v1) AS d1, MIN(v1), MAX(v2), MIN(v2),
         MAX(v1) + MIN(v1), MAX(v2) + MIN(v2),
         MAX(v1) + MAX(v1) + MAX(v2) AS d2
    FROM t7 LEFT JOIN (SELECT v4 FROM t8 WHERE 1 == 2) ON v < v4
    GROUP BY v
)

(这与实际的排行榜查询不同;请参阅测试文件。)

推荐优化:

  • 列裁剪 —— 你只需要从聚合中的左表计算 v、d1、d2。我们提供了一个骨架文件 column_pruning.cpp
  • 公共表达式消除,将永远为假的过滤器转换为虚拟扫描(零行的值计划节点)
  • 如果你能想到更多的话 :)

提示:你不需要实现完整的规则来优化这些查询。(1) 完整的谓词下推需要你处理所有计划节点 —— limit、order by 等。但要优化 Q2,你只需要实现对哈希连接/嵌套循环连接的谓词下推。(2) 完整的连接重排序需要你正确处理谓词(并且可能需要将中间的过滤器吸收回连接谓词),你不需要这样做。只需让你的优化器能处理这些查询就够了。

查询 3:白忙一场?

在完成了 Q1 和 Q2 的辛苦工作之后,这里有一个简短而有趣的题目。假设你有以下两个表,分别表示所有 16 岁以下的人和所有目前在大学的人。有人可能会尝试构造一个查询来输出这两个表的交集,以发现神童。你知道结果将很少有匹配,但你的连接执行器不知道,你能做什么来帮助加速这个过程?

SELECT * FROM under_16s a INNER JOIN college_students b ON a.id = b.id

(这与实际的排行榜查询不同;请参阅测试文件。)

推荐优化: 为哈希连接构建阶段使用的哈希表添加布隆过滤器。

排行榜政策

附加信息

本节提供了一些关于 BusTub 中其他系统组件的附加信息,你需要与这些组件交互才能完成本项目。

系统目录

数据库维护一个内部目录来跟踪有关数据库的元数据。在本项目中,你将与系统目录交互以查询有关表、索引及其模式的信息。

目录实现的全部内容在 src/include/catalog/catalog.h 中。你应该特别注意成员函数 Catalog::GetTable()Catalog::GetIndex()。你将在执行器的实现中使用这些函数来查询目录中的表和索引。

索引更新

对于表修改执行器(InsertExecutorUpdateExecutorDeleteExecutor),你必须修改操作目标表的所有索引。你可能会发现 Catalog::GetTableIndexes() 函数对于查询特定表定义的所有索引很有用。一旦你获得了表的每个索引的 IndexInfo 实例,你就可以在底层索引结构上调用索引修改操作。

在本项目中,我们使用你在项目 #2 中实现的 B+ 树索引作为所有索引操作的底层数据结构。因此,本项目的成功完成依赖于一个可工作的 B+ 树实现。

优化器规则实现指南

BusTub 优化器是一个基于规则的优化器。大多数优化器规则以自底向上的方式构造优化计划。由于查询计划具有这种树结构,在将优化器规则应用于当前计划节点之前,你需要首先递归地将规则应用于其子节点。

在每个计划节点,你应该确定源计划结构是否与你尝试优化的结构匹配,然后检查该计划中的属性以查看是否可以将其优化为目标优化计划结构。

在公共 BusTub 仓库中,我们已经提供了几个优化器规则的实现。请查看它们作为参考。

说明

请参阅项目 #0 说明了解如何创建你的私有仓库和设置开发环境。

你必须从上游 BusTub 仓库拉取最新的更改,以获取我们在本项目中提供的测试文件和其他补充文件。

测试

我们将使用 SQLLogicTest 进行测试和基准测试。使用方法如下,

make -j$(nproc) sqllogictest
./bin/bustub-sqllogictest ../test/sql/p3.00-primer.slt --verbose

你可以使用 bustub-sqllogictest 程序运行 slt 文件。记得在进行任何测试之前重新编译 sqllogictest。在本项目中,我们向提供了所有测试用例。没有隐藏测试。测试用例位于 test/sql/

格式化

你的代码必须遵循 Google C++ 风格指南。 我们使用 Clang 自动检查你的源代码质量。 如果你的提交未通过这些检查中的任何一项,你的项目成绩将为

执行以下命令检查你的语法。 format 目标将自动纠正你的代码。 check-lintcheck-clang-tidy-p3 目标将打印错误并指导你如何修复以符合我们的风格指南。

$ make format
$ make check-lint
$ make check-clang-tidy-p3

内存泄漏

在本项目中,我们使用 LLVM Address Sanitizer (ASAN) 和 Leak Sanitizer (LSAN) 来检查内存错误。要启用 ASAN 和 LSAN,请在调试模式下配置 CMake 并像平常一样运行测试。如果存在内存错误,你将看到内存错误报告。注意 macOS 仅支持地址检测器而不支持泄漏检测器

在某些情况下,地址检测器可能会影响调试器的可用性。在这种情况下,你可能需要通过以下方式配置 CMake 项目来禁用所有检测器:

$ cmake -DCMAKE_BUILD_TYPE=Debug -DBUSTUB_SANITIZER= ..

开发提示

你可以在调试模式下使用 BUSTUB_ASSERT 进行断言。注意 BUSTUB_ASSERT 中的语句在发布模式下不会被执行。 如果你需要在所有情况下进行断言,请改用 BUSTUB_ENSURE

如果你遇到问题,我们鼓励你使用图形调试器来调试你的项目。

如果你遇到编译问题,运行 make clean 不会完全重置编译过程。你需要删除你的 build 目录并在重新运行 make 之前再次运行 cmake ..

将所有关于本项目的问题发布在 Piazza 上。要直接向助教发送电子邮件提问。

评分标准

每个项目提交将根据以下标准进行评分:

  1. 提交是否成功执行了所有测试用例并产生了正确答案?
  2. 提交是否在没有内存泄漏的情况下执行?
  3. 提交是否遵循了代码格式化和风格策略?

迟交政策

请参阅课程大纲中的迟交政策

提交

完成作业后,你可以将你的实现提交到 Gradescope:

在你的 build/ 目录中运行 make submit-p3 将在项目根目录下生成一个名为 project3-submission.zipzip 归档文件,你可以将其提交到 Gradescope。

记得在提交之前解决所有风格问题:

make format
make check-clang-tidy-p3

协作政策

警告:本项目的所有代码必须是你自己的。你不能从其他学生或你在网上找到的其他来源复制源代码。抄袭不会被容忍。请参阅 CMU 的学术诚信政策获取更多信息。