OpenMLDB:一文了解带参数查询语句(paramterized query statement) 的细节

979 阅读12分钟

背景

In database management systems (DBMS), a prepared statement or parameterized statement is a feature used to execute the same or similar database statements repeatedly with high efficiency. Typically used with SQL statements such as queries or updates, the prepared statement takes the form of a template into which certain constant values are substituted during each execution. (en.wikipedia.org/wiki/Prepar…)

在数据库系统中,带参数的语句(parameterized statement),一方面,能够提供预编译的能力,以达到高效执行语句、提高性能的目的。另一方面,能够预防SQL注入攻击,安全性更好。以上两点是传统的数据库系统使用支持带参数语句的主要原因。

从数据库系统角度看,OpenMLDB 支持Parameterized query statement能进一步完善数据库查询能力。从业务角度上看,它使得OpenMLDB能够在规则引擎场景下,支持规则特征计算。

场景示例:规则引擎特征计算

SELECT 
SUM(trans_amount) as F_TRANS_AMOUNT_SUM, 
COUNT(user) as F_TRANS_COUNT,
MAX(trans_amount) as F_TRANS_AMOUNT_MAX,
MIN(trans_amount) as F_TRANS_AMOUNT_MIN,
FROM t1 where user = 'ABC123456789' and trans_time between 1590115420000 and 1592707420000;

在示例中,我们计算了用户ABC1234567892020-05-22 02:43:402020-06-20 07:43:40这段期间的交易总额交易次数最大交易金额最小交易金额。这些特征将传递可给下游的组件(规则引擎)使用。

在实际场景中,不可能针对每个用户写一段SQL查询代码。因此,需要一个规则特征计算的模版,而用户,时间区间则是动态变化的。

最简单的方式,就是写一段类似下面程序,把用户名,时间区间作为变量拼接到一段SQL语句中。

String query = "SELECT "+
  "SUM(trans_amount) as F_TRANS_AMOUNT_SUM, "+
  "COUNT(user) as F_TRANS_COUNT,"+
  "MAX(trans_amount) as F_TRANS_AMOUNT_MAX,"+
  "MIN(trans_amount) as F_TRANS_AMOUNT_MIN,"+
  "FROM t1 where user = '"+ user +"' and trans_time between " 
  + System.currentTimestamp()-30*86400000+ " and " + System.currentTimestamp();

executor.execute(query);

这种实现方法比较直接,但查询性能将很差,并且可能有SQL注入的风险。更为推荐的方式,是使用带参数查询(Parameterized query)

PreparedStatement stmt = conn.prepareStatement("SELECT "+
  "SUM(trans_amount) as F_TRANS_AMOUNT_SUM, "+
  "COUNT(user) as F_TRANS_COUNT,"+
  "MAX(trans_amount) as F_TRANS_AMOUNT_MAX,"+
  "MIN(trans_amount) as F_TRANS_AMOUNT_MIN,"+
  "FROM t1 where user = ? and trans_time between ? and ? ");

stmt.setString(1, user);
stmt.setTimestamp(2, System.currentTimestamp()-30*86400000);
stmt.setTimestamp(3, System.currentTimestamp())
ResultSet rs = stmt.executeQuery();
rs.next();

实现细节

在OpenMLDB中,支持一个新的语法功能,通常需要依次完成语法解析、计划生成和优化、表达式Codegen、执行查询等步骤。 必要时,还需要考虑在客户端新增或者重构相关接口。Paramteried Query的支持基本就涵盖的对上述几个模块的修改和开发,因此,了解相关实现细节有助于大家快速了解OpenMLDB的开发,特别是OpenMLDB Engine的开发。

下图是执行带参数查询流程示意图。

  1. 用户在应用程序JavaApplication中s使用JDBC(PrepraredStatement)来执行带参数查询。
  2. 客户端(TabletClient)提供接口ExecuteSQLParameterized来处理带参数的查询,并通过RPC调用服务端(Tablet)的Query服务。
  3. 服务端(Tablet)的依赖Engine模块进行查询编译和执行。
  4. 查询语句的编译需要经过SQL语法分析,计划生成优化,表达式Codegen三个主要阶段。编译成功后,编译结果会存放在当前执行会话(jizSeesion)的SQL上下文中(SqlContext)。如果当前查询语句已经预编译过,则不需要重复编译。可直接从编译缓存中获取相对应的编译产物存放到RunSession的SqlContext中。
  5. 查询语句的执行需要调用RunSeesion的Run接口。执行结果run output会存放到response的附件中,回传给TabletClient。最终存放到ResultSet返回给JavaApplication

1. JDBC PreparedStatement

1.1 JDBC Prepared Statements 概览

Sometimes it is more convenient to use a PreparedStatement object for sending SQL statements to the database. This special type of statement is derived from the more general class, Statement, that you already know.

If you want to execute a Statement object many times, it usually reduces execution time to use a PreparedStatement object instead.[[2]](Using Prepared Statements)

JDBC提供PreparedStatement给用户执行参数的SQL语句。用户可以使用PrepareStatment执行带参数的查询、插入、更新等操作。这个小节,我们讲详细OpenMLDB的PrepareStatement执行带参数查询语句的细节。

1.2 OpenMLDB PreapredStatement的用法介绍

public void parameterizedQueryDemo() {
  SdkOption option = new SdkOption();
  option.setZkPath(TestConfig.ZK_PATH);
  option.setZkCluster(TestConfig.ZK_CLUSTER);
  option.setSessionTimeout(200000);
  try {
    SqlExecutor executor = new SqlClusterExecutor(option);
    String dbname = "demo_db";
    boolean ok = executor.createDB(dbname);
    // create table
    ok = executor.executeDDL(dbname, "create table t1(user string, trans_amount double, trans_time bigint, index(key=user, ts=trans_time));");
    // insert normal (1000, 'hello')
    ok = executor.executeInsert(dbname, "insert into t1 values('user1', 1.0, 1592707420000);");
    ok = executor.executeInsert(dbname, "insert into t1 values('user1', 2.0, 1592707410000);");
    ok = executor.executeInsert(dbname, "insert into t1 values('user1', 3.0, 1592707400000);");
    ok = executor.executeInsert(dbname, "insert into t1 values('user2', 4.0, 1592707420000);");
    ok = executor.executeInsert(dbname, "insert into t1 values('user2', 5.0, 1592707410000);");
    ok = executor.executeInsert(dbname, "insert into t1 values('user2', 6.0, 1592707400000);");
    
    PreparedStatement query_statement 
      = executor.getPreparedStatement(dbname, "select SUM(trans_amout), COUNT(trans_amout), MAX(trans_amout) from t1 where user=? and trans_time between ? and ?");
    
    query_statement.setString(1, "user1");
    query_statement.setLong(2, 1592707410000);
    query_statement.setLong(3, 1592707420000);
    com._4paradigm.openmldb.jdbc.SQLResultSet rs1
      = (com._4paradigm.openmldb.jdbc.SQLResultSet) query_statement.executeQuery();

    query_statement.setString(1, "user2");
    query_statement.setLong(2, 1592707410000);
    query_statement.setLong(3, 1592707420000);
    com._4paradigm.openmldb.jdbc.SQLResultSet rs2
      = (com._4paradigm.openmldb.jdbc.SQLResultSet) query_statement.executeQuery();
    
    query_statement.setString(1, "user3");
    query_statement.setLong(2, 1592707410000);
    query_statement.setLong(3, 1592707420000);
    com._4paradigm.openmldb.jdbc.SQLResultSet rs3
      = (com._4paradigm.openmldb.jdbc.SQLResultSet) query_statement.executeQuery();
  }  catch (Exception e) {
    e.printStackTrace();
  }
}
  • Step 1: 构造executor。准备数据库,表,表数据(如果需要的话)
  • Step 2: 使用带参数的查询语句新建一个PreparedStatement实例
PreparedStatement query_statement 
  = executor.getPreparedStatement(dbname, "select SUM(trans_amout), COUNT(trans_amout), MAX(trans_amout) from t1 where user=? and trans_time between ? and ?");
  • Step 3: 设置每一个位置上的参数值。
query_statement.setString(1, "user1");
query_statement.setLong(2, 1592707410000);
query_statement.setLong(3, 1592707420000);
  • Step 4: 执行查询。获取查询结果。请注意,执行完一次查询后,PrepareStatement里的参数数据会自动清空。可以直接配置新参数值,进行新一轮查询
com._4paradigm.openmldb.jdbc.SQLResultSet rs2
  = (com._4paradigm.openmldb.jdbc.SQLResultSet) query_statement.executeQuery();
query_statement.setString(1, "user2");
query_statement.setLong(2, 1592707410000);
query_statement.setLong(23,  1592707420000);
com._4paradigm.openmldb.jdbc.SQLResultSet rs2
  = (com._4paradigm.openmldb.jdbc.SQLResultSet) query_statement.executeQuery();

1.3 PreparedStatement的实现细节

public class PreparedStatement implements java.sql.PreparedStatement {
  	//...
  	// 参数行
    protected String currentSql;
  	// 参数数据
  	protected TreeMap<Integer, Object> currentDatas;
  	// 参数类型
    protected TreeMap<Integer, com._4paradigm.openmldb.DataType> types;
  	// 上次查询的参数类型
    protected TreeMap<Integer, com._4paradigm.openmldb.DataType> orgTypes;
		//...
}

PrepareaStatement继承了JDBC标准接口java.sql.PreparedStatement。它维护了查询编译和执行需要的一些基本要素:查询语句(currentSql), 参数数据(currentDatas) 参数类型(types)等。

  • 构建PrepareaStatement后,我们初始化了PreparedStatement,并设置currentSql
  • 设置参数值后, currentDatas, types会被更新。
  • 执行查询时,query_statement.executeQuery()
@Override
public SQLResultSet executeQuery() throws SQLException {
    checkClosed();
    dataBuild();
    Status status = new Status();
    com._4paradigm.openmldb.ResultSet resultSet = router.ExecuteSQLParameterized(db, currentSql, currentRow, status);
    // ... 此处省略
    return rs;
}
  • 首先,执行dataBuild: 按参数类型和位置将参数数据集编码编码到currentRow中。值得注意的是,如果参数类型不发生变化,我们可以复用原来的currentRow实例。
protected void dataBuild() throws SQLException {
  // types has been updated, create new row container for currentRow
  if (null == this.currentRow || orgTypes != types) {
    // ... 此处省略
    this.currentRow = SQLRequestRow.CreateSQLRequestRowFromColumnTypes(columnTypes);
    this.currentSchema = this.currentRow.GetSchema();
    this.orgTypes = this.types;
  }

  // ... 此处currentRow初始化相关的代码
     
  for (int i = 0; i < this.currentSchema.GetColumnCnt(); i++) {
     DataType dataType = this.currentSchema.GetColumnType(i);
     Object data = this.currentDatas.get(i+1);
     if (data == null) {
        ok = this.currentRow.AppendNULL();
      } else {
      // 省略编码细节
      // if (DataType.kTypeInt64.equals(dataType)) {
      //    ok = this.currentRow.AppendInt64((long) data);
      // } 
      // ...
      }
      if (!ok) {
        throw new SQLException("append data failed, idx is " + i);
      }
    }
    if (!this.currentRow.Build()) {
      throw new SQLException("build request row failed");
    }
    clearParameters();
  }
}
  • 接着,调用客户端提供的带参数查询接口ExecuteSQLParameterized。 

2. TabletClient 和 Tablet

2.1 客户端tablet_client

客户端提供接口ExecuteSQLParameterized来支持带参数查询。

/// Execute batch SQL with parameter row
std::shared_ptr<hybridse::sdk::ResultSet> 
ExecuteSQLParameterized(const std::string& db, const std::string& sql,
                        std::shared_ptr<SQLRequestRow> parameter,
                        ::hybridse::sdk::Status* status) override;

ExecuteSQLParameterized将从参数行parameter中提取参数类型、参数行大小等信息,装入QueryRequest,并把参数数据行装入roc附件中。客户端调用rpc,在服务端完成查询的编译和运行。

  • 将参数行大小、分片数、参数类型列表装入QueryRequest
request.set_parameter_row_size(parameter_row.size());
request.set_parameter_row_slices(1);
for (auto& type : parameter_types) {
  request.add_parameter_types(type);
}
  • 参数数据行存放在rpc的附件中cntl->request_attachment()
auto& io_buf = cntl->request_attachment();
if (!codec::EncodeRpcRow(reinterpret_cast<const int8_t*>(parameter_row.data()), parameter_row.size(), &io_buf)) {
  LOG(WARNING) << "Encode parameter buffer failed";
  return false;
}
  • 调用RPC
bool ok = client_.SendRequest(&::openmldb::api::TabletServer_Stub::Query, cntl, &request, response);

2.2 服务端Tablet

服务端tablet的Query函数负责从QueryRequest中获取参数行信息,然后调用接口engine_->Get()[编译查询语句并调用接口session.Run](https://link.zhihu.com/?target=http%3A//xn--%2560session-i30nta9680a88mb6mci9ble5a3t6bkia4g4p.run/)()执行查询语句。

::hybridse::vm::BatchRunSession session;
if (request->is_debug()) {
  session.EnableDebug();
}
session.SetParameterSchema(parameter_schema);
{
  bool ok = engine_->Get(request->sql(), request->db(), session, status);
  // ...
}

::hybridse::codec::Row parameter_row;
auto& request_buf = static_cast<brpc::Controller*>(ctrl)->request_attachment();
if (request->parameter_row_size() > 0 &&
    !codec::DecodeRpcRow(request_buf, 0, request->parameter_row_size(), request->parameter_row_slices(),
                         &parameter_row)) {
  response->set_code(::openmldb::base::kSQLRunError);
  response->set_msg("fail to decode parameter row");
  return;
}
std::vector<::hybridse::codec::Row> output_rows;
int32_t run_ret = session.run(parameter_row, output_rows);

想了解更多细节,可以阅读 tablet客户端 和 tablet 的源码实现。

3. Compile: 查询语句的编译

3.1 查询语句的编译

  • step 1: 对带参数查询语句来说,编译时,相比普通查询,需要额外配置参数类型信息。
session.SetParameterSchema(parameter_schema);
  • step 2: 配置参数列表后,调用engine.Get(...)接口编译SQL语句

查询语句的编译需要经过SQL语法分析(3.2. Parser: 语法解析),计划生成( 3.3 Planner: 计划生成),表达式Codegen( 3.4 Codegen: 表达式的代码生成)三个主要阶段。编译成功后,编译结果会存放在当前执行会话(RunSeesion)的SQL上下文中(SqlContext)。后面几个小节将依次介绍带参数查询语句的编译过程。

如果当前查询语句已经预编译过,则不需要重复编译。可直接从编译缓存中获取相对应的编译产物存放到RunSession的SqlContext中。我们需要需要特别关注一下编译缓存的设计变动。对于带参数的查询来说,命中缓存需要同时匹配SQL和参数类型列表

// check if paramter types matches with target compile result or not.
for (int i = 0; i < batch_sess->GetParameterSchema().size(); i++) {
  if (cache_ctx.parameter_types.Get(i).type() != batch_sess->GetParameterSchema().Get(i).type()) {
    status = Status(common::kEngineCacheError, "Inconsistent cache parameter type, expect " +
                    batch_sess->GetParameterSchema().Get(i).DebugString()," but get ", cache_ctx.parameter_types.Get(i).DebugString());
    return false;
  }
}

3.2. Parser: 语法解析

OpenMLDB的语法解释器是基于ZetaSQL的SQL的解释器开发的:除了覆盖Zetasql原有的语法能力,还额外支持了OpenMLDb特有语法特性。例如,为AI场景引入的特殊拼表类型LastJoin和窗口类型ROWS_RANGE等。关于OpenMLDB的语法解析以及新语法特性会陆续在未来的技术文章中阐述。

SQL的Parameterized 语句使用?作为参数的占位符,这种占位符被ZetaSQL解释器解析为zetasql::ASTParameterExpr。由于ZetaSQL中已经支持了Parameterized Query Statement的解析,所以我们并不需要对语法解析模块作太多额外修改,仅需要将原来的限制打开,识别这种参数表达式,将其转化为OpenMLDB的ParameterExpr类型的表达式节点并存放在语法树中。

/// Convert zetasql::ASTExpression into ExprNode
base::Status ConvertExprNode(const zetasql::ASTExpression* ast_expression, node::NodeManager* node_manager,
                             node::ExprNode** output) {
  //...
  base::Status status;
  switch (ast_expression->node_kind()) {
      //...
      case zetasql::AST_PARAMETER_EXPR: {
        const zetasql::ASTParameterExpr* parameter_expr = 
          ast_expression->GetAsOrNull<zetasql::ASTParameterExpr>();
        CHECK_TRUE(nullptr != parameter_expr, common::kSqlAstError, "not an ASTParameterExpr")
        // Only support anonymous parameter (e.g, ?) so far.
       	CHECK_TRUE(nullptr == parameter_expr->name(), common::kSqlAstError,
                     "Un-support Named Parameter Expression ", parameter_expr->name()->GetAsString());
        *output = node_manager->MakeParameterExpr(parameter_expr->position());
        return base::Status::OK();
      }
      //...
  }
}

例如,下面这条参数查询语句:

SELECT col0 FROM t1 where col1 <= ?;

在语法解析后,将生成如下查询语法树:

+-list[list]:
  +-0:
    +-node[kQuery]: kQuerySelect
      +-distinct_opt: false
      +-where_expr:
      |  +-expr[binary]
      |    +-<=[list]:
      |      +-0:
      |      |  +-expr[column ref]
      |      |    +-relation_name: <nil>
      |      |    +-column_name: col1
      |      +-1:
      |        +-expr[parameter]
      |          +-position: 1
      +-group_expr_list: null
      +-having_expr: null
      +-order_expr_list: null
      +-limit: null
      +-select_list[list]:
      |  +-0:
      |    +-node[kResTarget]
      |      +-val:
      |      |  +-expr[column ref]
      |      |    +-relation_name: <nil>
      |      |    +-column_name: col0
      |      +-name: <nil>
      +-tableref_list[list]:
      |  +-0:
      |    +-node[kTableRef]: kTable
      |      +-table: t1
      |      +-alias: <nil>
      +-window_list: []

这里可以重点关注一下过滤条件, where col1 <= ?被解析为:

+-where_expr:
      |  +-expr[binary]
      |    +-<=[list]:
      |      +-0:
      |      |  +-expr[column ref]
      |      |    +-relation_name: <nil>
      |      |    +-column_name: col1
      |      +-1:
      |        +-expr[parameter]
      |          +-position: 1

3.3 Planner: 计划生成

  • 逻辑计划

逻辑计划阶段,带参数查询和普通参数并没有什么区别。因此,本文并不打算展开逻辑计划的细节。下面这条参数查询语句:

SELECT col0 FROM t1 where col1 <= ?;

逻辑计划如下:

: +-[kQueryPlan]
  +-[kProjectPlan]
        +-table: t1
        +-project_list_vec[list]:
          +-[kProjectList]
            +-projects on table [list]:
            |  +-[kProjectNode]
            |    +-[0]col0: col0
  +-[kFilterPlan]
    +-condition: col1 <= ?1
  +-[kTablePlan]

对逻辑计划以及物理计划细节感兴趣的读者可以关注我们专栏。后续会陆续推出介绍引擎技术细节的系列文章。

  • 物理计划

​在物理计划生成阶段,为了支持带参数查询,要完成两件事:

首先,在物理计划上下文,表达式分析上下文以及CodeGen上下文中维护参数类型列表

在带参数查询语句中,最终执行使用的参数是用户动态指定的,所以参数类型也是外部动态指定。为此,我们提供了相关接口,使用户在编译SQL时,可以配置参数类型列表(如果有参数的话)。这个列表最终会存放进物理计划上下文,表达式分析上下文以及CodeGen上下文中。

// 物理计划上下文
class PhysicalPlanContext {
  // ...
  private:
    const codec::Schema* parameter_types_;
}
// 表达式分析上下文
class ExprAnalysisContext {
	// ...
  private:
    const codec::Schema* parameter_types_;
}
// Codegen上下文
class CodeGenContext {
 // ...
 private:
  	const codec::Schema* parameter_types_;
}

其次,根据参数类型列表完成参数表达式的类型推断

Parameterized query语句完成语法解释后,几乎就是一棵普通的查询语句生成的语法树。唯一的区别是,parameterized query的语法树里有参数表达式节点(ParamterExpr)。因为参数的类型既与查询上游表的schema无关,也不是常量。所以,我们无法直接对这个参数表达式进行类型推断。这使得我们在计划生成阶段,特别是表达式的类型推断过程中,需要对ParamterExpr进行特别处理。具体的做法是:在推断ParamterExpr输出类型时,需要根据参数所在位置从参数类型列表中找到相应的类型。

Status ParameterExpr::InferAttr(ExprAnalysisContext *ctx) {
    // ignore code including boundary check and nullptr check
  	// ...
    type::Type parameter_type = ctx->parameter_types()->Get(position()-1).type();
    node::DataType dtype;
    CHECK_TRUE(vm::SchemaType2DataType(parameter_type, &dtype), kTypeError,
               "Fail to convert type: ", parameter_type);
    SetOutputType(ctx->node_manager()->MakeTypeNode(dtype));
    return Status::OK();
}

还是之前那个SQL语句,物理计划生成结果如下:

SIMPLE_PROJECT(sources=(col0))
  FILTER_BY(condition=col1 <= ?1, left_keys=, right_keys=, index_keys=)
    DATA_PROVIDER(table=auto_t0)

其中,FILTER_BY节点中的过滤条件就包含了参数表达式condition=(col1 <= ?1)

3.4 Codegen: 表达式的代码生成

Codegen模块负责分析每个计划节点的表达式列表,然后进行一系列表达式和函数的代码生成处理。codegen后,每一个需要计算表达式的计划节点都将生成至少一个codegen函数。这些函数负责计算表达式的计算。

  • Codegen函数增加一个参数

OpenMLDB的通过LLVM将每一个涉及表达式计算的节点生成中间代码(IR)。具体地实现方式是为每一个节点的表达式列表生成类似@__internal_sql_codegen_6的函数(这些函数将在执行语句的过程中,被调用(4 Run: 查询语句的执行):

; ModuleID = 'sql'
source_filename = "sql"
define i32 @__internal_sql_codegen_6(i64 /*row key id*/, 
                                     i8* /*row ptr*/, 
                                     i8* /*rows ptr*/, 
                                     i8** /*output row ptr ptr*/) {
__fn_entry__:
// 此处省略
}

这个函数的参数主要包含一些int_8指针,这些指针指向数据行(row ptr)或者数据集(rows ptr)(聚合计算依赖数据集)。函数体负责每一个表达式的计算,并将结果按顺序编码成行,并将编码地址到最后一个i8**输出参数上。

当表达式列表中包含参数表达式的时候,我们还额外需要获得参数数据,因此,需要做的就是在原来的函数结构上,新增一个指向参数行的指针(parameter_row ptr)。

Status RowFnLetIRBuilder::Build(...) { // 此处省略
    std::vector<std::string> args;
    std::vector<::llvm::Type*> args_llvm_type;
    args_llvm_type.push_back(::llvm::Type::getInt64Ty(module->getContext()));
    args_llvm_type.push_back(::llvm::Type::getInt8PtrTy(module->getContext()));
    args_llvm_type.push_back(::llvm::Type::getInt8PtrTy(module->getContext()));
    args_llvm_type.push_back(::llvm::Type::getInt8PtrTy(module->getContext())); // 新增一个int8ptr类型的参数
    args_llvm_type.push_back(
        ::llvm::Type::getInt8PtrTy(module->getContext())->getPointerTo());
  	// ...
}

于是,支持参数表达式后,codegen函数的结构就变成如下样子:

; ModuleID = 'sql'
source_filename = "sql"
define i32 @__internal_sql_codegen_6(i64 /*row key id*/, 
                                     i8* /*row ptr*/, 
                                     i8* /*rows ptr*/, 
                                     i8* /*parameter row ptr*/, 
                                     i8** /*output row ptr ptr*/) {
__fn_entry__:
// 此处省略
}
  • 参数表达式的codegen

参数行和普通的数据行一样,遵循OpenMLDB的编码格式,参数行的第0个元素就是参数查询语句中的第1个参数,第1个元素就是第2个参数,依次类推。因此,计算参数表达式实际上就是从参数行中读取相应位置的参数

// Get paramter item from parameter row
// param parameter
// param output
// return
Status ExprIRBuilder::BuildParameterExpr(const ::hybridse::node::ParameterExpr* parameter, NativeValue* output) {
   	// ...
    VariableIRBuilder variable_ir_builder(ctx_->GetCurrentBlock(), ctx_->GetCurrentScope()->sv());
    NativeValue parameter_row;
  	// 从全局scope中获取参数行parameter_row
    CHECK_TRUE(variable_ir_builder.LoadParameter(&parameter_row, status), kCodegenError, status.msg);
   
    // ...
  	// 从参数行中读取相应位置的参数
    CHECK_TRUE(
        buf_builder.BuildGetField(parameter->position()-1, slice_ptr, slice_size, output),
        kCodegenError, "Fail to get ", parameter->position(), "th parameter value")
    return base::Status::OK();
}

于是,前面例子中的查询语句的Filter节点的条件col1 < ? 会生成如下代码:

; ModuleID = 'sql'
source_filename = "sql"
define i32 @__internal_sql_codegen_6(i64, i8*, i8*, i8*, i8**) {
__fn_entry__:
  %is_null_addr1 = alloca i8, align 1
  %is_null_addr = alloca i8, align 1
  // 获取行指针row = {col0, col1, col2, col3, col4, col5}
  %5 = call i8* @hybridse_storage_get_row_slice(i8* %1, i64 0)
  %6 = call i64 @hybridse_storage_get_row_slice_size(i8* %1, i64 0)
  // Get field row[1] 获取数据col1
  %7 = call i32 @hybridse_storage_get_int32_field(i8* %5, i32 1, i32 7, i8* nonnull %is_null_addr)
  %8 = load i8, i8* %is_null_addr, align 1
  // 获取参数行指针paramter_row = {?1}
  %9 = call i8* @hybridse_storage_get_row_slice(i8* %3, i64 0)
  %10 = call i64 @hybridse_storage_get_row_slice_size(i8* %3, i64 0)
  // Get field of paramter_row[0] 获取第一个参数
  %11 = call i32 @hybridse_storage_get_int32_field(i8* %9, i32 0, i32 7, i8* nonnull %is_null_addr1)
  %12 = load i8, i8* %is_null_addr1, align 1
  %13 = or i8 %12, %8
  // 比较 col1 <= ?1
  %14 = icmp sle i32 %7, %11
  // ... 此处省略多行
  // 将比较结果%14编码输出
  store i1 %14, i1* %20, align 1
  ret i32 0

}

在此,我们并不打算展开codegen的具体细节。后续会陆续更新Codegen设计和优化相关的技术文章。如果大家感兴趣,可以持续关注OpenMLDB技术专栏。

4. Run: 查询语句的执行

  • 查询语句编译后,会将编译产物存放在当前运行会话(RunSession)中。
  • RunSession提供Run接口支持查询语句的执行。对带参数查询语句来说,执行查询时,相比普通的查询,需要额外传入参数行的信息。
session.run(parameter_row, outputs)
  • 参数行paramter_row会存放在运行上下文RunContext中: 
RunnerContext ctx(&sql_ctx.cluster_job, parameter_row, is_debug_);
  • 带参数查询过程中,表达式的计算可能依赖动态传入的参数。所以,我们需要在执行计划的时候,从运行上下文中获取参数行,并带入到表达式函数中计算。以TableProject节点为例,
  • 对于普通查询来说,实现TableProject就是遍历表中每一行,然后为每一个行作RowProject操作。在带参数的查询场景中,因为表达式的计算除了依赖数据行还可能依赖参数。所以,我们需要从运行行下文中获取参数行,然后project_gen_.Gen(iter->GetValue(), parameter)
std::shared_ptr<DataHandler> TableProjectRunner::Run(
  RunnerContext& ctx,
  const std::vector<std::shared_ptr<DataHandler>>& inputs) {
    // ... 此处省略部分代码
    // 从运行上下文中获取参数行(如果没有则获得一个空的行指针
    auto& parameter = ctx.GetParameterRow();
    iter->SeekToFirst();
    int32_t cnt = 0;
    while (iter->Valid()) {
      if (limit_cnt_ > 0 && cnt++ >= limit_cnt_) {
        break;
      }
      // 遍历表中每一行,计算每一个行的表达式列表
      output_table->AddRow(project_gen_.Gen(iter->GetValue(), parameter));
      iter->Next();
    }
    return output_table;
  }

const Row ProjectGenerator::Gen(const Row& row, const Row& parameter) {
  return CoreAPI::RowProject(fn_, row, parameter, false);
}
  • CoreAPI::RowProject函数数据行和参数行来计算表达式列表。它最重要的工作就是调用fn函数。fn函数是查询语句的编译期根据表达式列表Codegen而成的函数。在小节表达式的代码生成(3.4 Codegen: 表达式的代码生成)中我们已经介绍过了,我们在codegen函数的的参数列表中增加了一个参数行指针。
// 基于输入数据行和参数行计算表达式列表并输出
hybridse::codec::Row CoreAPI::RowProject(const RawPtrHandle fn,
                                         const hybridse::codec::Row row,
                                         const hybridse::codec::Row parameter,
                                         const bool need_free) {
// 此处省略部分代码
auto udf = reinterpret_cast<int32_t (*)(const int64_t, const int8_t*,
                                        const int8_t* /*paramter row*/, 
                                        const int8_t*, int8_t**)>(
  const_cast<int8_t*>(fn));
      
  auto row_ptr = reinterpret_cast<const int8_t*>(&row);
  auto parameter_ptr = reinterpret_cast<const int8_t*>(&parameter);
  int8_t* buf = nullptr;
  uint32_t ret = udf(0, row_ptr, nullptr, parameter_ptr, &buf);
  // 此处省略部分代码
   return Row(base::RefCountedSlice::CreateManaged(
              buf, hybridse::codec::RowView::GetSize(buf)));
}

未来的工作

PreparedStatement的预编译在服务端tablet上完成,预编译产生的编译结果会缓存在tablet上。下次查询时,只要SQL语句和参数类型匹配成功,即可复用编译结果。但这就意味着,每次客户端执行一次查询,都需要将SQL语句和参数类型传输到服务端tablet上。当查询语句很长时,这部分开销就很可存放观。因此,我们的设计仍有优化的空间。可以考虑在服务端产生一个唯一的预编译查询QID,这个QID会传回给客户端,保存在PrepareStatemetn的上下文中。只要查询参数的类型不发生改变,客户端就可以通过QID和参数执行查询。这样,可以减少查询语句的传输开销。

std::shared_ptr<hybridse::sdk::ResultSet> 
      ExecuteSQLParameterized(const std::string& db, const std::string& qid,
                              std::shared_ptr<SQLRequestRow> parameter,
                              ::hybridse::sdk::Status* status) override;

欢迎更多开发者关注和参与OpenMLDB开源项目。