Impala 2.12.0与3.4.0版本的compute stats兼容问题

对于Impala来说,compute [incremental] stats [partition_spec]是我们经常会使用到的语句,这个语句的功能就是对表,执行统计信息计算。Impala在进行SQL解析的时候,就可以利用这些统计信息进行更好地优化,生成更高效地执行计划。但是,最近我们在将集群从2.12.0升级到3.4.0版本的时候,遇到了一些compute stats相关的问题。
本文在第一章和第三章分别描述了问题以及重现的步骤,第二章是详细的代码探究。如果不感兴趣的话,可以直接略过。

问题描述

当我们在3.4.0版本,对表的某个具体分区执行compute incremental stats table_name [partition_spec]时,发现执行过程中,会出现TableLoadingException的异常,如下所示:
exception
这个exception主要是由于列统计信息不符合约束导致的,这里就是由于numNulls_的约束检查失败导致的。我们可以在相关的类中找到如下代码,该方法在2.12.0中是不存在的:

  // 3.4.0  ColumnStats.java
  public void validate(Type colType) {
    // avgSize_ and avgSerializedSize_ must be set together.
    Preconditions.checkState(avgSize_ >= 0 == avgSerializedSize_ >= 0, this);

    // Values must be either valid or -1.
    Preconditions.checkState(avgSize_ == -1 || avgSize_ >= 0, this);
    Preconditions.checkState(avgSerializedSize_ == -1 || avgSerializedSize_ >= 0, this);
    Preconditions.checkState(maxSize_ == -1 || maxSize_ >= 0, this);
    Preconditions.checkState(numDistinctValues_ == -1 || numDistinctValues_ >= 0, this);
    Preconditions.checkState(numNulls_ == -1 || numNulls_ >= 0, this);
    if (colType != null && colType.isFixedLengthType()) {
      Preconditions.checkState(avgSize_ == colType.getSlotSize(), this);
      Preconditions.checkState(avgSerializedSize_ == colType.getSlotSize(), this);
      Preconditions.checkState(maxSize_ == colType.getSlotSize(), this);
    }
  }

我们观察Impala的页面发现,compute stats的两条相关SQL执行是成功的,如下所示:
web
这就说明,新版本部署启动之后,第一次加载这个表是正常的,并且compute stats相关的两条SQL都能正常执行成功。因此,问题应该是出在了计算完成之后,更新到metastore中导致的,我们通过查看元数据库对应的表发现,确实numNulls_对应的值是-6(这些统计信息位于元数据库的TAB_COL_STATS表,其中numNulls_对应的列是NUM_NULLS)。

深入研究
两个版本统计信息对比

当我们对表执行了compute stats之后,我们可以通过show column stats table_name来查看表的列统计信息,如下所示:
初始状态
以上是我们表的初始列统计信息状态,当我们执行了compute stats table_name之后,2.12.0版本的结果如下所示:
在这里插入图片描述
而3.4.0版本的结果如下所示:
3.4.0版本
通过上面两幅图对比,我们可以发现,“#Nulls”这一列在两个版本中的值是不一样的。2.12.0版本对于这一列,即使执行了compute stats之后,仍然是-1(除去分区列),而3.4.0版本则是实际的数值,是大于等于0的。这里的“#Nulls”列对应的就是异常日志中的“numNulls”。
值得一提的是,对于每一个分区(Hdfs表对应的是THdfsPartition结构体,这个thrift结构体包括了单个分区详细信息,如果存在多层分区的话,那么该结构体包含的是到最里层的分区,例如day=20200101/type=xxx/id=xxx这种),其中每一个THdfsPartition都有一个has_incremental_stats变量,这个变量默认是false(即没有执行compute stats),当我们执行相关SQL的时候,情况分别如下所示:

  • 执行compute stats table_name,所有分区对应的has_incremental_stats参数变为false;
  • 执行compute incremental stats table_name,所有分区对应的has_incremental_stats参数变为true;
  • 执行compute incremental stats table_name partition(day=‘2020-12-01’),这个分区对应的has_incremental_stats参数变为true,其他分区的仍然为false。

我们这里讨论的前提是:这个表是默认没有进行任何的compute stats操作的,上述情况对于2.12.0和3.4.0都是同样的情况。因此,当has_incremental_stats为true,就表示对应的某个分区包含了增量的历史统计信息,而初始状态,或者compute stats table_name是不算做增量统计信息计算的。我们后续的分析,都是基于增量的统计信息计算。

初始状态分析

为了研究,到底是哪里导致的这个“#Nulls”的值小于-1,我们接下来跟着代码一步一步看下去(这里以3.4.0的代码为例)。
首先,假设表为初始状态,当我们第一次访问,加载表的时候,此时表没有任何统计信息,加载操作由catalogd执行。Catalogd会对表信息、分区信息等进行初始化,主要就是从metastore中进行加载,然后转换成Impala对应的各种类和结构体。我们这里主要关注HDFS表的加载,下面我们简单看下相关的函数调用栈:

HdfsTable.load()
-HdfsTable.loadAllPartitions()
--HdfsTable.createPartition() 分区表会循环调用这个函数
---HdfsPartition.ctor() public
----HdfsPartition.ctor() private
-----HdfsPartition.extractAndCompressPartStats()
------PartitionStatsUtil.partStatsBytesFromParameters()
------HdfsPartition.setPartitionStatsBytes() 使用上面函数的返回结果作为输入参数

可以看到,在加载表分区的信息时,会调用partStatsBytesFromParameters这个函数,我们将相关的代码粘贴出来:

    // PartitionStatsUtil.java
    public static final String INCREMENTAL_STATS_NUM_CHUNKS =
      "impala_intermediate_stats_num_chunks";
   // 省略后续代码
  /**
   * Reconstructs the intermediate stats from chunks and returns the corresponding
   * byte array. The output byte array is deflate-compressed. Sets hasIncrStats to
   * 'true' if the partition stats contain intermediate col stats.
   */
  public static byte[] partStatsBytesFromParameters(
      Map<String, String> hmsParameters, Reference<Boolean> hasIncrStats) throws
      ImpalaException {
    if (hmsParameters == null) return null;
    String numChunksStr = hmsParameters.get(INCREMENTAL_STATS_NUM_CHUNKS);
    if (numChunksStr == null) return null;
    int numChunks = Integer.parseInt(numChunksStr);
    if (numChunks == 0) return null;
   // 省略后续代码

我们通过上述函数代码可以看到:当分区的参数列表中(分区的参数列表,可以直接从metastore中加载),没有INCREMENTAL_STATS_NUM_CHUNKS参数时,整个函数会返回null。这里先提一下,当函数初始没有计算统计信息的时候,就不会有这个参数,后续我们还会再提到这个参数。紧接着就会使用上述函数的返回结果,来执行setPartitionStatsBytes这个函数。这里又涉及到了两个成员变量:partitionStats_和hasIncrementalStats_。这里我们暂且不详细探究其含义,我们只需要知道,在初始状态下,对于具体的某个分区而言,partitionStats_为null,而hasIncrementalStats_为false。

  // Binary representation of the TPartitionStats for this partition. Populated
  // when the partition is loaded and updated using setPartitionStatsBytes().
  private byte[] partitionStats_;

  // True if partitionStats_ has intermediate_col_stats populated.
  private boolean hasIncrementalStats_ ;
  // 省略后续代码
  public void setPartitionStatsBytes(byte[] partitionStats, boolean hasIncrStats) {
    if (hasIncrStats) Preconditions.checkNotNull(partitionStats);
    partitionStats_ = partitionStats;
    hasIncrementalStats_ = hasIncrStats;
  }

对于初始的状态而言,2.12.0和3.4.0版本,虽然在代码处理逻辑上有所不同,但是总体而言,这些主要的结构和成员变量都是相差不大的。

增量计算后的状态变化

上面我们已经了解一些,初始状态下的分区相关统计信息。现在来看一下,当我们执行了compute [incremental] stats [partition_spec]之后,状态会发生哪些变化,而这也跟我们最初的问题有关系。
当我们提交了SQL之后,Impala会自动提交两条子SQL来进行相应信息的获取,相关的SQL我们可以在第一章的第二幅图中看到,3.4.0和2.12.0版本的两个SQL略有不同。从截图中我们可以看到,这两条SQL的执行是没有问题,因此我们当前不关注这两条SQL的生成以及执行,着重于后续的统计信息更新部分。
我们假设当前表处于初始状态,此时提交执行compute incremental stats table_name partition(day=‘2020-12-01’)这种SQL。我们来看一下状态是如何更新的:
首先,当SQL提交到Impalad的时候,会进行一系列的计算操作,主要就是执行上述的两个子查询。计算完成之后,会生成相应的变量来保存信息,然后将变量传到catalogd进程进行元数据的更新。这里我们先看下catalogd的相关处理流程,主要的api调用如下所示:

ExecDdl(catalog-server.cc)
-ExecDdl(catalog.cc)
--execDdl(JniCatalog.java)
---CatalogOpExecutor.execDdlRequest
----CatalogOpExecutor.alterTable

可以看到,catalogd首先是在c++端通过JNI调用了Java的api,最终执行了一个alterTable的函数,来更新表的元数据信息(这里主要是统计信息),这里我们涉及到了一些thrift结构体信息:

CatalogService.TDdlExecRequest
-alter_table_params: JniCatalog.TAlterTableParams
--update_stats_params: JniCatalog.TAlterTableUpdateStatsParams
---partition_stats: map<list<string>, CatalogObjects.TPartitionStats>
----CatalogObjects.TPartitionStats
-----intermediate_col_stats: map<string, TIntermediateColumnStats>
------CatalogObjects.TIntermediateColumnStats
---column_stats: map<string, CatalogObjects.TColumnStats>
----CatalogObjects.TColumnStats

我们将一些相关的结构体包含关系列了出来。从上面的包含关系可以看到:本次计算涉及到的分区都会保存在partition_stats这个数组中,数组的每一个成员都是一个TPartitionStats结构体,代表一个分区的信息。这个结构体主要包括两个成员:TTableStats和一个map,如下所示:

// Per-partition statistics
struct TPartitionStats {
  // so would interfere with the non-incremental stats path
  1: required TTableStats stats

  // Intermediate state for incremental statistics, one entry per column name.
  2: optional map<string, TIntermediateColumnStats> intermediate_col_stats
}

每个TPartitionStats都包含一个map,叫intermediate_col_stats。这个map的key表示列名,而value就是TIntermediateColumnStats,表的每一列都会对应一条KV记录。其中TIntermediateColumnStats的结构体如下所示:

// Intermediate state for the computation of per-column stats. Impala can aggregate these
// structures together to produce final stats for a column.
struct TIntermediateColumnStats {
  // One byte for each bucket of the NDV HLL computation
  1: optional binary intermediate_ndv

  // If true, intermediate_ndv is RLE-compressed
  2: optional bool is_ndv_encoded

  // Number of nulls seen so far (or -1 if nulls are not counted)
  3: optional i64 num_nulls

  // The maximum width, in bytes, of the column
  4: optional i32 max_width

  // The average width (in bytes) of the column
  5: optional double avg_width

  // The number of rows counted, needed to compute NDVs from intermediate_ndv
  6: optional i64 num_rows
}

如果执行了增量的统计信息计算,那么partition_stats这个变量就会包含当前正在进行计算的各个分区信息,而每个分区又会包含各自的intermediate_col_stats成员,其中有相应的列的统计信息。这里需要注意的是,3.4.0版本和2.12.0版本是不一样的:

  • 在两个版本中,初始状态下,列的num_nulls都是-1;
  • 在3.4.0版本,如果执行了统计信息计算,num_nulls是一个大于等于0的值;
  • 在2.12.0版本,如果执行了统计信息计算,num_nulls仍然是-1;

但是,如果我们执行的是compute stats,而不是增量的话,那么每个分区的intermediate_col_stats是空的(注意,partition_stats不为空,其包含的stats也不为空,只是intermediate_col_stats这个变量为空)。这块的处理主要是在BE端进行的,只有当执行增量统计信息计算的时候,才会将分区的列统计信息存入intermediate_col_stats中,相关代码如下所示:

  // catalog-op-executor.cc ExecComputeStats()
  // 其中FinalizePartitionedColumnStats方法就是用来构造intermediate_col_stats的相关信息
  // col_stats_schema and col_stats_data will be empty if there was no column stats query.
  if (!col_stats_schema.columns.empty()) {
    if (compute_stats_params.is_incremental) {
      RuntimeProfile::Counter* incremental_finalize_timer =
          ADD_TIMER(profile_, "FinalizeIncrementalStatsTimer");
      SCOPED_TIMER(incremental_finalize_timer);
      FinalizePartitionedColumnStats(col_stats_schema,
          compute_stats_params.existing_part_stats,
          compute_stats_params.expected_partitions,
          col_stats_data, compute_stats_params.num_partition_cols, &update_stats_params);
    } else {
      SetColumnStats(col_stats_schema, col_stats_data, &update_stats_params);
    }
  }

这里我们可以看到,只有执行增量统计信息计算的时候(is_incremental为true),FinalizePartitionedColumnStats函数才会被调用。我们接着上面的catalogd处理流程继续往下看:

CatalogOpExecutor.alterTable
-CatalogOpExecutor.alterTableUpdateStats
--CatalogOpExecutor.alterTableUpdateStatsInner
---CatalogOpExecutor.updatePartitionStats
----PartitionStatsUtil.partStatsToPartition
-----HdfsPartition.setPartitionStatsBytes
---CatalogOpExecutor.bulkAlterPartitions
----HdfsPartition.toHmsPartition
-----PartitionStatsUtil.partStatsToParams

统计信息计算的结果,最终通过catalogd对表的分区进行了元数据更新,上述updatePartitionStats函数调用后两步,刚好与我们第一节中,提到的HDFS表加载形成了呼应,我们提到的INCREMENTAL_STATS_NUM_CHUNKS参数也会在partStatsToParams函数中进行设置。之后如果表再重新加载元数据的话,partStatsBytesFromParameters就不会返回空了。
除此之外,我们之前提到的partitionStats_和hasIncrementalStats_,最终在这里也进行了设置。我们将本节中涉及到的partition_stats数组,通过循环处理,将数组中的成员TPartitionStats进行压缩,最终保存到了HdfsPartition的partitionStats_成员变量中;hasIncrementalStats_保存是一个布尔值:TPartitionStats中的intermediate_col_stats成员是否为空,就是我们是否对该分区执行了增量统计信息计算(上面的分析已经提到过,只有执行增量统计信息计算的时候,intermediate_col_stats才不会为空)。这里我们对几种情况进行归纳:

状态INCREMENTAL_STATS_NUM_CHUNKSpartition_statsintermediate_col_statspartitionStats_hasIncrementalStats_
初始状态不包括false
compute stats包括不为空不为空false
增量compute stats包括不为空不为空不为空true

这里有几个地方,我们需要注意一下:

  • partition_stats包含了本次操作涉及到的分区信息集合,而partitionStats_和hasIncrementalStats_是针对单个分区的信息;
  • partitionStats_是由partition_stats中的单个成员,也就是TPartitionStats,经过处理之后得到的;
  • intermediate_col_stats是TPartitionStats的一个成员,所以它是否为空,不会影响TPartitionStats,继而也不会影响partitionStats_;

总结一下,当我们执行compute incremental stats [partition_spec]的时候,会在Impalad的BE端根据SQL解析和计算的结果,构造一个TDdlExecRequest变量,并且传到catalogd端,catalogd通过JNI调用Java的api对表的元数据信息进行更新。之后如果再加载表的元数据时,就能获取到这些已经计算的增量统计信息。

错误产生分析

上一节提到,当我们执行了compute incremental stats [partition_spec]的时候,表就会包含一些增量的统计信息,例如partitionStats_。因为我们最开始是在2.12.0版本每天执行了增量的统计信息计算,当我们升级到3.4.0版本之后,HDFS表被加载起来之后,就会包含相关的历史增量统计信息。此时,当我们在3.4.0版本再次执行增量统计信息计算的时候,就会出现了第一章中的问题。接下来就结合代码来看一下:

executeAndWait(impala-beeswax-server.cc)
-Execute(impala-server.cc)
--ExecuteInternal(impala-server.cc)
---Exec(client-request-stats.cc)
----ExecDdlRequest(client-request-stats.cc)
-----ExecAsync(child-query.cc)
------ExecChildQueries(child-query.cc)
-------ExecAndFetch(child-query.cc)
-Wait(client-request-state.cc)
--WaitInternal(client-request-state.cc)
---UpdateTableAndColumnStats(client-request-state.cc)
----ExecComputeStats(catalog-op-executor.cc)
-----SetTableStats(catalog-op-executor.cc)
-----FinalizePartitionedColumnStats(incr-stats-util.cc)
------Update(incr-stats-util.cc)

上述的代码调用都是属于BE模块的,这里主要分为两个分支流程:1)Execute函数,主要就是对两个子查询就行计算,并且保存相应地结果,这里我们不展开;2)Wait函数,这个后续的相关操作就是对统计信息的结构体进行更新。从上一节的代码中我们可以看到,在ExecComputeStats函数中,对FinalizePartitionedColumnStats进行了调用,其中涉及到了existing_part_stats这个成员变量。我们来看下相关的结构体:

Frontend.TExecRequest
-catalog_op_request: Frontend.TCatalogOpRequest
--ddl_params: CatalogService.TDdlExecRequest
---compute_stats_params: JniCatalog.TComputeStatsParams
----existing_part_stats: list<CatalogObjects.TPartitionStats>
-----CatalogObjects.TPartitionStats
------intermediate_col_stats: map<string, TIntermediateColumnStats>
-------CatalogObjects.TIntermediateColumnStats

从这里,我们就可以很明显的看出来,这个existing_part_stats变量,与我们上一节中提及到的partition_stats成员,其实是一样的内容。我们这里来看一下partition_stats是如何转换为existing_part_stats的:
首先,通过上一节的分析我们可以知道,如果某个分区进行了增量的统计信息计算,那么该分区包含的partitionStats_就不为空,并且hasIncrementalStats_为true(这两个成员变量都位于HdfsPartition类中)。
其次,Impala在进行SQL解析的时候,compute [incremental] stats [partiiton_spec]最终都会被解析为一个ComputeStatsStmt类,而这个类中就有一个变量:

  // The list of valid partition statistics that can be used in an incremental computation
  // without themselves being recomputed. Populated in analyze().
  private final List<TPartitionStats> validPartStats_ = new ArrayList<>();

validPartStats_这个变量就是在解析的过程中,根据表的元数据信息(这里就是每个分区的partitionStats_),将partition_stats解析出来,并保存下来,相关的解析流程位于ComputeStatsStmt.analyze()。最后,再构造TComputeStatsParams,通过thrift传到BE端,如下所示:

  // ComputeStatsStmt.toThrift()
  public TComputeStatsParams toThrift() {
    TComputeStatsParams params = new TComputeStatsParams();
    params.setTable_name(new TTableName(table_.getDb().getName(), table_.getName()));
    params.setTbl_stats_query(tableStatsQueryStr_);
    if (columnStatsQueryStr_ != null) {
      params.setCol_stats_query(columnStatsQueryStr_);
    } else {
      params.setCol_stats_queryIsSet(false);
    }
    params.setIs_incremental(isIncremental_);
    params.setExisting_part_stats(validPartStats_);
    params.setExpect_all_partitions(expectAllPartitions_);
    // 省略后续代码

最终我们就在BE端获取到了existing_part_stats。也就是说,只有分区执行过增量的统计信息计算,existing_part_stats才不为空。最终在FinalizePartitionedColumnStats函数中,对existing_part_stats进行循环处理,调用了Update函数。我们来看下最后的Update函数:

  // Updates all aggregate statistics with a new set of measurements.
  void Update(const string& ndv, int64_t num_new_rows, double new_avg_width,
      int32_t max_new_width, int64_t num_new_nulls) {
    DCHECK_EQ(intermediate_ndv.size(), ndv.size()) << "Incompatible intermediate NDVs";
    DCHECK_GE(num_new_rows, 0);
    DCHECK_GE(max_new_width, 0);
    DCHECK_GE(new_avg_width, 0);
    DCHECK_GE(num_new_nulls, 0);
    for (int j = 0; j < ndv.size(); ++j) {
      intermediate_ndv[j] = ::max(intermediate_ndv[j], ndv[j]);
    }
    num_nulls += num_new_nulls;
    max_width = ::max(max_width, max_new_width);
    avg_width += (new_avg_width * num_new_rows);
    num_rows += num_new_rows;
  }

我们可以很明显的看到,这个函数里面都是对统计信息的更新,而其中就有num_nulls的处理。到这里,这个问题产生的原因基本就已经明了:我们通过2.12.0版本执行了compute incremental stats [partition_spec],这些分区对应的列统计信息中,Nulls都是-1。当我们在3.4.0版本再次执行compute incremental stats [partition_spec],会对之前的增量分区统计信息进行汇总(对于Nulls,是多个-1相加,最终结果小于-1),并写入到metastore中。当catalogd再次触发表的元数据加载时,由于Nulls的约束检查失败,导致了表的加载失败。
需要注意的是,当我们使用debug模式进行编译、调试的话,那么当执行到DCHECK_GE(num_new_nulls, 0)这一行代码的时候,Impalad会直接挂掉,只有使用release进行编译,才会发生最上面提到的异常。

复现步骤

这里我们使用一个测试表进行测试,在2.12.0版本执行如下SQL:

CREATE TABLE stats_test (id INT, name STRING)
PARTITIONED BY (day STRING)
STORED AS PARQUET;
insert into stats_test partition(day='2020-01-01') values(1,'Jack');
insert into stats_test partition(day='2020-01-02') values(1,'Jack');
compute incremental stats stats_test;

启动3.4.0版本之后,再执行如下的SQL:

compute incremental stats stats_test partition(day='2020-01-01');

要触发这个错误,需要保证除当前待计算的分区之外,还有其他分区有增量的历史统计信息(如果我们在2.12.0中只对2020-12-02分区进行增量统计信息计算,3.4.0执行同样的SQL仍然会重现这个错误)。
目前的解决方法有两种:

  • 对于已经计算过统计信息的表,执行drop stats table_name,去掉已有的统计信息,然后再重新计算;
  • 将社区IMPALA-9699这个patch backport到较低的版本上来。
后续补充

后续我们发现,社区也已经有了类似的JIRA:IMPALA-10230

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值