基础实现思路和难度总结

本次实验内容实现了查询重写和连接顺序优化的查询优化。

查询重写只涉及 分解复合选择谓词 下推选择运算 笛卡尔积转为连接运算

连接顺序优化以 贪心算法 为例

框架基本认识

本次实验只需修改optimizer.cpp 和 optimizer.h

对于查询优化而言,输入的为查询计划树,故先要了解查询计划树在框架中的数据结构。

查询计划操作 Operator

查询计划操作为查询计划的基本节点,一个Operator 作为基类,是构成查询计划树的主要组成部分,每个Operator作为节点,自然有 children_ 来记录孩子节点,其为空时,即为叶子节点;有左右孩子时,即连接操作。

对于以上枚举的所有操作种类,都设计了一个派生类,部分派生类有自己专属的成员,以下以几个基本的派生类作为例子介绍

enum class OperatorType {
  AGGREGATE,
  DELETE,
  FILTER,
  HASHJOIN,
  INSERT,
  LIMIT,
  LOCK_ROWS,
  MERGEJOIN,
  NESTEDLOOP,
  ORDERBY,
  PROJECTION,
  SEQSCAN,
  UPDATE,
  VALUES,
};

class Operator {
 public:
  Operator(OperatorType type, std::shared_ptr<ColumnList> column_list, std::vector<std::shared_ptr<Operator>> children)
      : type_(type), column_list_(std::move(column_list)), children_(std::move(children)) {}
  virtual ~Operator() = default;
  virtual std::string ToString(size_t indent_num = 0) const = 0;

  const ColumnList &OutputColumns() const { return *column_list_; }
  OperatorType GetType() const { return type_; }
  const std::vector<std::shared_ptr<Operator>> &GetChildren() const { return children_; }

  OperatorType type_;
  std::shared_ptr<ColumnList> column_list_;
  std::vector<std::shared_ptr<Operator>> children_;
};

筛选操作 FilterOperator

作为筛选操作,会有一个专属的predicate_即谓词,作为选择的条件,之后会介绍谓词表达式类。

class FilterOperator : public Operator {
 public:
  FilterOperator(std::shared_ptr<ColumnList> column_list, std::shared_ptr<Operator> child,
                 std::shared_ptr<OperatorExpression> predicate)
      : Operator(OperatorType::FILTER, std::move(column_list), {std::move(child)}), predicate_(std::move(predicate)) {}
  std::string ToString(size_t indent_num = 0) const override {
    return fmt::format("{}Filter: {}\\n{}", std::string(indent_num * 2, ' '), predicate_,
                       children_[0]->ToString(indent_num + 1));
  }

  std::shared_ptr<OperatorExpression> predicate_;
};

扫描读取表操作 SeqScanOperator

作为读取扫描表操作,自然需要记录扫描的表是谁,提供了GetTableNameOrAlias函数来获取扫描的表名。

class SeqScanOperator : public Operator {
 public:
  SeqScanOperator(std::shared_ptr<ColumnList> column_list, oid_t table_oid, std::string table_name,
                  std::optional<std::string> alias, bool has_lock)
      : Operator(OperatorType::SEQSCAN, std::move(column_list), {}),
        table_oid_(table_oid),
        table_name_(std::move(table_name)),
        alias_(std::move(alias)),
        has_lock_(has_lock) {}
  std::string ToString(size_t indent_num = 0) const override {
    if (alias_) {
      return fmt::format("{}SeqScan: {} {}", std::string(indent_num * 2, ' '), table_name_, *alias_);
    } else {
      return fmt::format("{}SeqScan: {}", std::string(indent_num * 2, ' '), table_name_);
    }
  }

  oid_t GetTableOid() const { return table_oid_; }
  const std::string &GetTableNameOrAlias() const {
    if (alias_) {
      return *alias_;
    } else {
      return table_name_;
    }
  }
  const std::string &GetTableName() const { return table_name_; }
  bool HasLock() const { return has_lock_; }

 private:
  oid_t table_oid_;
  std::string table_name_;
  std::optional<std::string> alias_;
  bool has_lock_;
};

连接操作 NestedLoopJoinOperator

需要一个连接的条件join_condition_,即一个条件表达式,当其为常量 true的时候,即笛卡尔积连接,所有连接情况都被允许通过。

class NestedLoopJoinOperator : public Operator {
 public:
  NestedLoopJoinOperator(std::shared_ptr<ColumnList> column_list, std::shared_ptr<Operator> left,
                         std::shared_ptr<Operator> right, std::shared_ptr<OperatorExpression> join_condition,
                         JoinType join_type = JoinType::INNER)
      : join_condition_(std::move(join_condition)),
        join_type_(join_type),
        Operator(OperatorType::NESTEDLOOP, std::move(column_list), {std::move(left), std::move(right)}) {}
  std::string ToString(size_t indent_num = 0) const override {
    return fmt::format("{}NestedLoopJoin: {}\\n{}\\n{}", std::string(indent_num * 2, ' '), join_condition_,
                       children_[0]->ToString(indent_num + 1), children_[1]->ToString(indent_num + 1));
  }
  std::shared_ptr<OperatorExpression> join_condition_;
  JoinType join_type_;
};

查询计划表达式 OperatorExpression

查询计划表达式一般属于一些操作,例如上面的选择操作。其也有多个子类。

不论是一条完整的表达式,还是一个单独的常量,都用OperatorExpression 基类可以代表。

对于一个完整表达式,即为多个OperatorExpression 嵌套,同时使用类型标识其属于常量还是表达式。

enum class OperatorExpressionType {
  AGGREGATE,
  ARITHMETIC,
  TYPE_CAST,
  COLUMN_VALUE,
  COMPARISON,
  CONST,
  FUNC_CALL,
  LIST,
  LOGIC,
  NULL_TEST
};

class OperatorExpression {
 public:
  OperatorExpression() = default;
  OperatorExpression(OperatorExpressionType expr_type, std::vector<std::shared_ptr<OperatorExpression>> children,
                     Type value_type, std::string name = "<no_name>", size_t size = 0)
      : expr_type_(expr_type),
        children_(std::move(children)),
        value_type_(value_type),
        name_(std::move(name)),
        size_(size) {}
  virtual Value Evaluate(std::shared_ptr<const Record> record) { throw DbException("Evaluate method not implemented"); }
  virtual Value EvaluateJoin(std::shared_ptr<const Record> left, std::shared_ptr<const Record> right) {
    throw DbException("EvaluateJoin method not implemented");
  }
  virtual std::string ToString() const { return "OperatorExpression"; }

  OperatorExpressionType GetExprType() const { return expr_type_; }
  Type GetValueType() const { return value_type_; }
  size_t GetSize() const { return size_; }
  void SetName(const std::string &name) { name_ = name; }

  OperatorExpressionType expr_type_;
  std::string name_;
  std::vector<std::shared_ptr<OperatorExpression>> children_;
  Type value_type_;
  size_t size_;
};

逻辑表达式 Logic

逻辑表达式会有 children_ 即用 逻辑关系AND, OR 连接的左右表达式,其中,本二元逻辑表达式的children_ 中只有两个对象,右对象可能是单个表达式也可能是再一个逻辑表达式,左对象可能是单个表达式也可能是再一个逻辑表达式,一个Logic只记录一个逻辑关系,通过不断嵌套,来做到多逻辑关系的逻辑表达式。

enum class LogicType { AND, OR, NOT };

class Logic : public OperatorExpression {
 public:
  Logic(LogicType logic_type, std::shared_ptr<OperatorExpression> arg)
      : OperatorExpression(OperatorExpressionType::LOGIC, {std::move(arg)}, Type::BOOL), logic_type_(logic_type) {
    assert(logic_type == LogicType::NOT);
  }

  Logic(LogicType logic_type, std::shared_ptr<OperatorExpression> left, std::shared_ptr<OperatorExpression> right)
      : OperatorExpression(OperatorExpressionType::LOGIC, {std::move(left), std::move(right)}, Type::BOOL),
        logic_type_(logic_type) {}

  Value Evaluate(std::shared_ptr<const Record> record) override {
    if (logic_type_ == LogicType::NOT) {
      return children_[0]->Evaluate(record).Not();
    } else {
      Value lhs = children_[0]->Evaluate(record);
      Value rhs = children_[1]->Evaluate(record);
      return Compute(lhs, rhs);
    }
  }

  Value EvaluateJoin(std::shared_ptr<const Record> left, std::shared_ptr<const Record> right) override {
    if (logic_type_ == LogicType::NOT) {
      return children_[0]->Evaluate(left).Not();
    } else {
      Value lhs = children_[0]->EvaluateJoin(left, right);
      Value rhs = children_[1]->EvaluateJoin(left, right);
      return Compute(lhs, rhs);
    }
  }

  std::string ToString() const override {
    if (logic_type_ == LogicType::NOT) {
      return fmt::format("{} {}", logic_type_, children_[0]);
    }
    return fmt::format("{} {} {}", children_[0], logic_type_, children_[1]);
  }
  LogicType GetLogicType() const { return logic_type_; }

 private:
  LogicType logic_type_;
  Value Compute(const Value &lhs, const Value &rhs) {
    if (lhs.IsNull() || rhs.IsNull()) {
      return Value();
    }
    switch (lhs.GetType()) {
      case Type::BOOL:
        return Value(DoOperation(lhs.GetValue<bool>(), rhs.GetValue<bool>()));
      default:
        throw DbException("Type unsupported for logic operation");
    }
  }

  bool DoOperation(bool lhs, bool rhs) {
    switch (logic_type_) {
      case LogicType::AND:
        return lhs && rhs;
      case LogicType::OR:
        return lhs || rhs;
      default:
        throw DbException("Unknown logic type");
    }
  }
};

查询重写

lab5/10-filter-pushdown.test

lab5/20-join-pushdown.test

分解复合的选择谓词

分解复合的选择谓词即将单个节点的长表达式 A and B 变成两个节点连接代替原节点,在原本查询树的基础上多了一个选择节点。

分解复合谓词有前提条件,即当前节点是选择节点,并且这个节点的表达式为AND逻辑,我们即可将表达式分割为两个,然后再分别用这两个表达式来创建出两个选择节点,让左的指向右的。再让右指向原来的孩子,再将原本指针指向左选择表达式节点。

需要注意的是,作为表达式,一次的分割可能不够,需要多次不断修改,故将得到的表达式递归即可。

为了遍历这一整个计划查询树,故在后面再遍历所有的子节点。使用的方式类似于DFS的递归。

std::shared_ptr<Operator> Optimizer::SplitPredicates(std::shared_ptr<Operator> plan) {
  if(plan->GetType() == OperatorType::FILTER){
    ptrFilter curFilter = std::dynamic_pointer_cast<FilterOperator>(plan);
    ptrExpr curExpr = curFilter->predicate_;
    if(curExpr->GetExprType() == OperatorExpressionType::LOGIC){
      ptrLogic curLogic = std::dynamic_pointer_cast<Logic>(curExpr);
      if(curLogic->GetLogicType() == LogicType::AND){
        ptrExpr leftExpr = curExpr->children_[0];
        ptrExpr rightExpr = curExpr->children_[1];
        
        std::shared_ptr<ColumnList> columns = std::make_shared<ColumnList>(plan->OutputColumns());

        ptrFilter rightFilter = std::make_shared<FilterOperator>(columns, plan->GetChildren()[0], rightExpr);
        ptrOP rightNode = SplitPredicates(rightFilter);
        ptrFilter leftFilter = std::make_shared<FilterOperator>(plan->column_list_, rightNode, leftExpr);
        ptrOP leftNode = SplitPredicates(leftFilter);
        plan = leftNode;
      }
    }
  }
  for(ptrOP &son: plan->children_){
    son = SplitPredicates(son);
  }

  return plan;
}

下推选择运算收集阶段

作为下推选择,实际上分为三步:

  • 记录所有的选择谓词
  • 再遍历下面子树中判断能将之前的选择谓词下推
  • 遍历后判断这个选择谓词是否下推成功,若成功,删除本节点,若不成功不删除。

三步操作实际上就是再 递归前 递归中 递归后 的三部分编写即可。

不过要考虑到的是,这个步骤在分解复合的选择谓词之后,所以所有的选择节点的表达式都是单独的表达式,而不是逻辑表达式。

先判断谓词是否Comparison 类型:

选择谓词分为普通谓词和连接谓词,其区别在于表达式的类型。如果表达式左右两边都是COLUMN_VALUE类型,即例如 a.id = b.id,这就属于连接谓词。否则属于普通谓词。

作为下推选择运算,本函数只要考虑记录和判断是否删除节点即可。

使用 join_predicates 和 selection_predicates 来记录有哪些谓词,使用一个映射predicate_pushdown_status 来记录谓词是否下移成功。故第三部分就可以借助predicate_pushdown_status 来判断是否要删除

std::shared_ptr<Operator> Optimizer::PushDownFilter(std::shared_ptr<Operator> plan) {
  ptrFilter curFilter = std::dynamic_pointer_cast<FilterOperator>(plan);
  ptrExpr curExpr = curFilter->predicate_;

  if(curExpr->GetExprType() == OperatorExpressionType::COMPARISON){
    ptrExpr left = curExpr->children_[0];
    ptrExpr right = curExpr->children_[1];
    if (left->GetExprType() == OperatorExpressionType::COLUMN_VALUE 
    and right->GetExprType() == OperatorExpressionType::COLUMN_VALUE){
      join_predicates.push_back(curExpr);
    }else{
      selection_predicates.push_back(curExpr);
    }
    predicate_pushdown_status[curExpr] = false;
  }

  plan->children_[0] = PushDown(plan->children_[0]);

  if(predicate_pushdown_status[curExpr]){
    return plan->children_[0];
  }else{
    return plan;
  }
}

下推选择运算(普通谓词下推)

遇到扫描操作的时候就会执行这个函数,也就是我们实际下推谓词的时候,此时,如果记录的谓词符合当前扫描的标准,就用该谓词创建一个选择操作节点,再将这个节点插入这个扫描操作的上方即可。

ColumnValue 的 name_ 字段为 “table_name.column_name” 的形式,再借助扫描操作可以获取表的名字,通过比较table_name来判断能否下推该谓词到这个扫描,同时记录当前谓词已成功下移。

std::shared_ptr<Operator> Optimizer::PushDownSeqScan(std::shared_ptr<Operator> plan) {
  ptrScan curScan = std::dynamic_pointer_cast<SeqScanOperator>(plan);

  for(ptrExpr expr: selection_predicates){
    ptrExpr columnValue = expr->children_[0];

    std::string columnName = columnValue->name_;
    size_t pos = columnName.find('.');
    std::string tableName = columnName.substr(0, pos);

    std::string scanName = curScan->GetTableNameOrAlias();

    if(tableName == scanName){
      std::shared_ptr<ColumnList> column = plan->column_list_;
      ptrFilter newFilter = std::make_shared<FilterOperator>(column, plan, expr);
      plan = newFilter;
      predicate_pushdown_status[expr] = true;
    }
  }

  return plan;
}

至此10-filter-pushdown.test已通过。

下推选择运算(笛卡尔积转连接)(连接谓词下推)

和下推普通谓词类似,对于笛卡尔积转连接,只需要同样判断是否符合条件,一旦符合条件,就将该连接置于操作的join_condition_ ,即连接的条件。同时标记该连接谓词已使用。

std::shared_ptr<Operator> Optimizer::PushDownJoin(std::shared_ptr<Operator> plan) {
  for (auto &child : plan->children_) {
    child = PushDown(child);
  }

  ptrJoin curJoin = std::dynamic_pointer_cast<NestedLoopJoinOperator>(plan);
  if(curJoin->join_condition_->ToString() == "true"){
    for(ptrExpr expr: join_predicates){
      if(predicate_pushdown_status[expr]){
        continue;
      }
      std::string columnA = expr->children_[0]->name_;
      std::string columnB = expr->children_[1]->name_;

      if (curJoin->column_list_->TryGetColumnIndex(columnA) != std::nullopt
      and curJoin->column_list_->TryGetColumnIndex(columnB) != std::nullopt){
        curJoin->join_condition_ = expr;
        predicate_pushdown_status[expr] = true;
      }
    }
  }
  return plan;
}

至此,通过 20-join-pushdown.test 测试样例

连接顺序选择(贪心算法)

30-join-order.test

代价估计

贪心算法的实现基于代价估计,对于表的代价估计,基于表的统计,需要用到的有:

  • T®:关系R的元组数目
  • V(R,A):关系R在属性A上不同值个数。当A扩展为属性集合a时,V(R,a)表示关系R在属性集a上投影后不同的元组数,即 Πa® 元组数

复习一遍需要知道的前提条件,基于贪心算法的连接顺序选择,连接的代价估计为连接之后T的大小,例如要连接Ra和Rb代价即T(Ra⋈Rb)

对于Ra和Rb在col上进行连接即 Ra.col = Rb.col,有以下:

T(Ra⋈Rb) = T(Ra) * T(Rb) / max(V(Ra, col), V(Rb, col))

同时更新其V,有以下:

V(Ra⋈Rb, col) = min(V(Ra, col), V(Rb, col))

贪心算法

步骤:

  • 选择一个大小T最小的关系作为当前Rcur
  • 从余下没有被选择过的关系中选择与Rcur连接后结果最小的Rnext,将Rcur更新为Rcur ⋈Rnext
  • 不断重复步骤2,直到所有关系都被选择过。

其中,如果T相等,我们优先选择能连接选择最多的表。

实现

类似于图的性质,因此我们将表看做节点,通过tables映射来记录,通过递归的DFS来记录所有的表,同时记录所有的连接,连接即看做一条边,我们此时将一个连接计划变成了一个图,图中记录了边的情况,使用了邻接表的结构。

这段代码没有进行很深的优化,以展现算法思路为主。

使用collect DFS进行信息收集,收集所有的边,将原来的题目转化为一个图。

遍历一遍所有的table,通过T和边的数目来选择初始的表。

使用一个near来记录所有当前联通情况下的边,例如我已经连接了AB,那就把A的所有边和B的所有边记录下来。

使用一个visited来记录所有的表是否已经被连接。

使用curV和curT来记录当前连接情况下的T和V的情况,通过连接不断更新,同时更新指针结构,构建新的计划树。

  • 选择起点
  • 不断遍历能连接到的所有的表
  • 找到连接代价最小的表
  • 更新当前连接情况下的统计数据
  • 更新当前连接计划树结构
  • 回到第二步,直到所有表连接完
std::shared_ptr<Operator> Optimizer::ReorderJoin(std::shared_ptr<Operator> plan) {
  // 通过 catalog_.GetCardinality 和 catalog_.GetDistinct 从系统表中读取表和列的元信息
  // 可根据 join_order_algorithm_ 变量的值选择不同的连接顺序选择算法,默认为 None,表示不进行连接顺序优化
  using namespace std;

  map<string, ptrOP> tables;
  map<string, set<string>> colOfTable;
  map<pair<string, string>, ptrJoin> joins;
  map<string, vector<pair<string, string>>> edges;
  map<string, ptrJoin> col2Join;

  function<string(ptrOP)> getName = [&](ptrOP cur){
    if(cur->GetType() == OperatorType::SEQSCAN){
      ptrScan curScan = dynamic_pointer_cast<SeqScanOperator>(cur);
      return curScan->GetTableNameOrAlias();
    }else{
      return getName(cur->children_[0]);
    }
  };

  ptrOP PreOP = nullptr;

  function<void(ptrOP, ptrOP)> collect = [&](ptrOP cur, ptrOP par){

    if(cur->GetType() == OperatorType::NESTEDLOOP){
      if(PreOP == nullptr){
        PreOP = par;
      }
      ptrJoin curJoin = dynamic_pointer_cast<NestedLoopJoinOperator>(cur);
      ptrExpr curExpr = curJoin->join_condition_;

      string table1 = curExpr->children_[0]->name_;
      string table2 = curExpr->children_[1]->name_;
      size_t pos = table1.find('.');
      table1 = table1.substr(0, pos);
      pos = table2.find('.');
      string column = table2.substr(pos + 1);
      table2 = table2.substr(0, pos);

      tables[getName(curJoin->children_[1])] = curJoin->children_[1];
      joins[{table1, table2}] = curJoin;
      joins[{table2, table1}] = curJoin;
      edges[table1].push_back({table2, column});
      edges[table2].push_back({table1, column});
      colOfTable[table1].insert(column);
      colOfTable[table2].insert(column);
      col2Join[column] = curJoin;

      if(cur->children_[0]->GetType() == OperatorType::NESTEDLOOP){
        collect(cur->children_[0], cur);
      }else{
        tables[getName(curJoin->children_[0])] = curJoin->children_[0];
      }
    }
    if(cur->children_.size() > 0){
      collect(cur->children_[0], cur);
    }
  };

  if(join_order_algorithm_ == JoinOrderAlgorithm::GREEDY){
    collect(plan, nullptr);
    if(tables.empty()){
      return plan;
    }
    set<pair<string, string>> nears;
    ptrOP ptrCur = nullptr;
    int curT = 1e9;
    string minTable;

    map<string, int> curV;
    map<string, bool> visited;

    for(auto[table, op]: tables){
      visited[table] = false;
      if(catalog_.GetCardinality(table) < curT){
        curT = catalog_.GetCardinality(table);
        ptrCur = op;
        minTable = table;
      }
      if(catalog_.GetCardinality(table) == curT and edges[table].size() > edges[minTable].size()){
        curT = catalog_.GetCardinality(table);
        ptrCur = op;
        minTable = table;
      }
    }

    visited[minTable] = true;
    for(auto col: colOfTable[minTable]){
      curV[col] = catalog_.GetDistinct(minTable, col);
    }

    for(auto edge: edges[minTable]){
      nears.insert(edge);
    }
    
    while(true){

      bool all_connected = true;
      for(auto[table, _]:tables){
        if(not visited[table]){
          all_connected = false;
        }
      }
      if(all_connected){
        break;
      }

      int minCost = 1e9;
      string nextTableName;
      string nextCol;

      for(auto[table, col]: nears){
        if(visited[table]) continue;
        int T2 = catalog_.GetCardinality(table);
        int V2 = catalog_.GetDistinct(table, col);

        int cost = curT * T2 / max(V2, curV[col]);
        if(cost < minCost){
          minCost = cost;
          nextTableName = table;
          nextCol = col;
        }
      }
      curT = minCost;
      for(auto col:colOfTable[nextTableName]){
        if(curV.count(col)){
          curV[col] = min(curV[col], (int)catalog_.GetDistinct(nextTableName, col));
        }else{
          curV[col] = catalog_.GetDistinct(nextTableName, col);
        }
      }
      for(auto edge:edges[nextTableName]){
        nears.insert(edge);
      }
      ptrOP tmp = col2Join[nextCol];
      tmp->children_ = {ptrCur, tables[nextTableName]};
      ptrCur = tmp;
      visited[nextTableName] = true;
    }
    PreOP->children_ = {ptrCur};
  }

  
  return plan;
}

至此,通过样例30-join-order.test

本次实验完成

难度和坑的总结

对于连接优化,许多统计数据都需要自己重新部分暴力的实现,属于一个比较少资源的情况下完成的测试样例。