Impala cast timestamp导致相同SQL查询不一致问题排查

问题描述

最近,线上业务在使用Impala进行查询的时候,遇到这种问题:同一个SQL执行,有时候提示AnalysisException,有时候执行正常,错误信息如下所示:

org.apache.impala.common.AnalysisException: xxxxxxxxx org.apache.impala.analysis.SelectStmt$SelectAnalyzer.verifyAggregation(SelectStmt.java:832)
	at org.apache.impala.analysis.SelectStmt$SelectAnalyzer.analyze(SelectStmt.java:233)
	at org.apache.impala.analysis.SelectStmt$SelectAnalyzer.access$100(SelectStmt.java:199)
	at org.apache.impala.analysis.SelectStmt.analyze(SelectStmt.java:192)
	at org.apache.impala.analysis.AnalysisContext.analyze(AnalysisContext.java:518)
	at org.apache.impala.analysis.AnalysisContext.analyzeAndAuthorize(AnalysisContext.java:426)

我们在测试环境构造了测试表和SQL,如下所示,目前在2.12.0和3.4.0版本都碰到了同样的问题,但是4.0的开发环境目前没有出现过:

create table test_table(dt STRING) partitioned by(day STRING) STORED AS PARQUET;

SELECT
	(CASE WHEN (DAYS_ADD(CAST(CAST(TO_DATE(TO_TIMESTAMP(`t1`.`dt`, 'yyyy-MM-dd')) AS TIMESTAMP) AS TIMESTAMP), 7) 
		> CAST('2021-01-26' AS TIMESTAMP))
		THEN 0 ELSE 1 END) `d1`
FROM
 (SELECT dt FROM test_table
  WHERE day=to_date(days_sub(now(),1))
  GROUP BY dt) `t1`
GROUP BY (CASE WHEN (DAYS_ADD(CAST(CAST(TO_DATE(TO_TIMESTAMP(`t1`.`dt`, 'yyyy-MM-dd')) AS TIMESTAMP) AS TIMESTAMP), 7) 
	> CAST('2021-01-26' AS TIMESTAMP))
	THEN 0 ELSE 1 END)
LIMIT 20;

如果我们设置enable_expr_rewrites为false,则SQL可以成功执行。Impala默认设置了enable_expr_rewrites为true,所以在解析完成之后,会对SQL进行重写,然后再次解析,接下来我们从错误出发,倒着来看问题产生的原因。

代码分析

首先,问题出现的地方是在SelectStmt.SelectAnalyzer.verifyAggregation函数中,当我们对SelectStmt进行了rewrite之后,再次analyze,会进行verify,相关代码如下所示:

      for (int i = 0; i < selectList_.getItems().size(); ++i) {
        if (!resultExprs_.get(i).isBound(multiAggInfo_.getResultTupleId())) {
          SelectListItem selectListItem = selectList_.getItems().get(i);
          throw new AnalysisException(
              "select list expression not produced by aggregation output "
              + "(missing from GROUP BY clause?): "
              + selectListItem.toSql());
        }
      }

这里主要就是通过对resultExprs_中的各个expr进行bound检查。经过多次测试和对比,我们发现,两种情况下的resultExprs_变量内容不同,导致了这种结果的差异。在SQL执行失败的情况下,resultExprs_的内容如下所示:
1
而当SQL成功执行的情况下,resultExprs_的内容如下所示:
2我们通过比较这两张截图可以看到,resultExprs_包含的expr在不同情况下,一个是CaseExpr,一个则是SlotRef,这两个成员对应的是SQL中的case when子句。正是这个CaseExpr造成了bound的检查失败。这个bound检查就是通过递归,不断对这个CaseExpr以及其children进行检查,我们将这个CaseExpr及其children的树状关系画出来了,如下所示:
tree最终在左下角的SlotRef中,bound检查失败。需要注意的是,这是我们经过ExprRewrite之后的再次执行SelectStmt.analyze()。重写之前的SelectStmt.analyze()是没有问题的,如下所示:
3需要注意的是,由于这里还没有经过重写,因此截图里面显示的仍然是CAST(‘2021-01-26’ AS TIMESTAMP),与图一和图二中的不一样。现在,我们的关注点就在于:为什么重写之后,这个resultExprs_包含的这个expr,有时候会是CaseExpr,有时候是SlotRef。而这正是SQL执行有时候成功,有时候失败的关键。
为了弄清楚这个问题,我们需要关注下resultExprs_这个变量是如何来的。我们查看这个变量的定义:

// QueryStmt.java
  // For a select statment:
  // original list of exprs in select clause (star-expanded, ordinals and
  // aliases substituted, agg output substituted)
  // For a union statement:
  // list of slotrefs into the tuple materialized by the union.
  protected List<Expr> resultExprs_ = new ArrayList<>();

首先,会在SelectStmt.SelectAnalyzer.analyzeSelectClause()方法中,将SelectList的成员对应的expr依次加入到resultExprs_中,这里SQL解析的SelectList,第二个成员对应的expr是CaseExpr,这里是没有问题的。紧接着,会在SelectStmt.SelectAnalyzer.buildResultExprs()方法中进行substitute操作,相关调用栈如下所示:

SelectStmt.analyze()
-SelectStmt.SelectAnalyzer.analyze()
--SelectStmt.SelectAnalyzer.buildResultExprs()
---Expr.substituteList()
----Expr.trySubstituteList()
-----Expr.trySubstitute()
------Expr.substituteImpl()
-------ExprSubstitutionMap.get()
--------Expr.equals()
---------Expr.matches()
----------TimestampLiteral.localEquals()

由于这里涉及到的调用路径比较长,我这里简单的总结下:当进行substitute操作的时候,会从一个ExprSubstitutionMap中进行匹配,如果匹配上了,则使用ExprSubstitutionMap中的expr来替代原先的expr,相关代码如下所示:

// ExprSubstitutionMap.java
  public Expr get(Expr lhsExpr) {
    for (int i = 0; i < lhs_.size(); ++i) {
      if (lhsExpr.equals(lhs_.get(i))) return rhs_.get(i);
    }
    return null;
  }

关于ExprSubstitutionMap这里我们不用展开说明。可以看到,当lhs_中能匹配到时,则返回rhs_中对应的成员。这里我们就是用CaseExpr进行匹配。所以,当匹配到了,就会将resultExprs_中的CaseExpr替换为SlotRef(来自rhs_),此时SQL就能执行成功;如果匹配不到,则保持原先的CaseExpr不变,此时SQL执行报错。
所以问题就在于这个equals方法,代码如下所示:

// Expr.java
  public final boolean equals(Object obj) {
    return obj instanceof Expr && matches((Expr) obj, SlotRef.SLOTREF_EQ_CMP);
  }

在Expr.matches()方法中,就是对expr的各个children进行比较,我们发现,有时候TIMESTAMP '2021-01-26’这个TimestmapLiteral的比较失败(由CAST(‘2021-01-26’ AS TIMESTAMP)重写得到),导致SQL执行失败;有时候,能够比较成功,则SQL能执行成功。这里的TimestmapLiteral就对应树状图中的黄色节点部分。
我们发现ExprSubstitutionMap中的CaseExpr的TimestmapLiteral内容总是如下所示:
4而resultExpr_中的CaseExpr包含的TimestmapLiteral,有时候与上述一样,有时候则不一样,如下所示:
5我们可以看到,后四位是明显不一样的,正是因为这个不一样,导致ExprSubstitutionMap匹配为空,进而影响CaseExpr没有替换为SlotRef,最终影响了SQL的执行。所以现在的问题就是要搞清楚,为什么这个TimestmapLiteral包含的16位字节数组,多次执行的结果不一致。
目前的问题,主要就是对CAST(‘2021-01-26’ AS TIMESTAMP)的处理导致的,在进行重写的时候,这个表达式会通过FoldConstantsRule这个规则进行重写,这其中会调用到BE端的代码:

FoldConstantsRule.apply()
-LiteralExpr.createBounded()
--FeSupport.EvalExprWithoutRowBounded()
---FeSupport.EvalExprsWithoutRowBounded()
----FeSupport.NativeEvalExprsWithoutRow()
------Java_org_apache_impala_service_FeSupport_NativeEvalExprsWithoutRow fe-support.cc

最终在BE端,通过这个函数进行了计算和转换,得到对应的TColumnValue,然后在FE端转换成相应的TimestmapLiteral,在BE端的主要转换流程如下:

TExpr->ScalarExpr->ScalarExprEvaluator->TimestampVal->TimestampValue->TColumnValue->TResultRow

最终将构造好的TResultRow序列化传到FE端。在Java_org_apache_impala_service_FeSupport_NativeEvalExprsWithoutRow方法中,我们通过GDB打印TColumnValue包含的binary_val(最终会使用这个来构造TimestmapLiteral),发现SQL执行失败的情况下,最后几个字节确实会有问题:
6
而SQL执行成功的时候,最后几个节点是这样的:
7
这与我们在java的ide进行远程调试的时候,看到的TimestmapLiteral包含的字节数组的最后几位也是一致的,这就说明我们在BE端构造TColumnValue的时候就已经有问题了。
我们继续往上追溯发现,TColumnValue的binary_val构造代码如下所示:

// fe-support.cc SetTColumnValue()
    case TYPE_TIMESTAMP: {
      const uint8_t* uint8_val = reinterpret_cast<const uint8_t*>(value);
      col_val->binary_val.assign(uint8_val, uint8_val + type.GetSlotSize());
      col_val->__isset.binary_val = true;
      RawValue::PrintValue(value, type, -1, &col_val->string_val);
      col_val->__isset.string_val = true;
      break;
    }

这里的value是一个void*,在当前情况下,对应的是TimestampValue类型的指针;对于TImestmap类型,type.GetSlotSize()会返回16。TimestampValue的构造代码如下:

// ScalarExprEvaluator.cc GetValue()
    case TYPE_TIMESTAMP: {
      impala_udf::TimestampVal v = expr.GetTimestampVal(this, row);
      if (v.is_null) return nullptr;
      result_.timestamp_val = TimestampValue::FromTimestampVal(v);
      return &result_.timestamp_val;
    }

我们通过GDB来打印result_.timestamp_val的最后4个字节内容,如下所示:
8
从这里可以看到,在构造完TimestampValue之后,最后几个节点对应的值就已经有问题了。我们继续查看TimestampVal发现最后几个字节都是0,也就是说TimestampVal构造没有问题,但是在构造result_.timestamp_val的时候,出现了问题:
10
result_.timestamp_val是一个TimestampValue的便利,包含2个成员变量:4个字节的date_和8个字节的time_,由于对齐机制,一共16个字节。通过调试我们发现:对于TimestampValue变量,0~7字节存储的是time_,对于“2021-01-26 00:00:00”而言,一直为0,所以0~7字节的值一直是0;8~11字节存储的是date_,对应截图中的:105、-122、37、0;最后的8~15是填充字节,而正是这四个字节的不同,导致了整个TimestampValue的不同。
经过调试发现,对于代码:result_.timestamp_val = TimestampValue::FromTimestampVal(v),timestamp_val变量在被赋值之前,就已经有内容了,由于最后8~15这四个填充字节的不同,导致了返回到FE端的字节数组不同。
这个result_属于ScalarExprEvaluator,是一个ExprValue类型的变量,初始化流程如下所示:

Java_org_apache_impala_service_FeSupport_NativeEvalExprsWithoutRow fe-support.cc
-Create() scalar-expr-evaluator.cc
-ctor() scalar-expr-evaluator.cc
-ctor() expr-value.h

对于timestamp_val的初始化直接使用了timestamp_val (),目前我怀疑是因为初始化该SQL的时候,timestamp_val分配的内存没有置0。为验证这个猜想,我们在ExprValue的构造函数中显示对timestamp_val的内存进行清空,如下所示:

  ExprValue()
    : bool_val(false),
      tinyint_val(0),
      smallint_val(0),
      int_val(0),
      bigint_val(0),
      float_val(0.0),
      double_val(0.0),
      string_val(NULL, 0),
      timestamp_val(),
      decimal4_val(),
      decimal8_val(),
      decimal16_val(),
      collection_val(),
      date_val(0) {
    memset(&timestamp_val, 0, sizeof(timestamp_val));
  }

重新编译之后,再测试,多次执行SQL没有再出现同样的问题了。

解决方案

目前,针对这种情况,由于社区的4.x开发版本,我们无法复现该问题,并且我们也没有看到相关的patch,因此怀疑是4.0依赖的编译器之类的,会保证在new的时候,直接对分配的内存空间置0,所以不会出现该问题。我们已经将问题反馈到社区,等待社区的相关回复:IMPALA-10461
针对3.4.0版本的问题,我们目前的解决方案有两种:

  1. 上面其实已经提到了,就是在ExprValue的构造函数中,显示地对Timestamp置0:
memset(&timestamp_val, 0, sizeof(timestamp_val));
  1. 由于是最后的4个padding字节导致的问题,因此我们可以对FE端的TimestampLiteral.localEquals方法进行调整,只比较前12个字节:
  public boolean localEquals(Expr that) {
   return super.localEquals(that) &&
       // Arrays.equals(value_, ((TimestampLiteral) that).value_);
       Arrays.equals(Arrays.copyOfRange(value_, 0, 12),
           Arrays.copyOfRange(((TimestampLiteral) that).value_, 0, 12));
 }
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值