由Decimal操作计算引发的Spark数据丢失问题

一、症状

一天,金融分析团队的同事报告了一个问题,他们发现在两个生产环境中(为了区分,命名为环境A和B), Spark大版本均为2.3。但是,当运行同样的SQL语句,对结果进行对比后,却发现两个环境中有一列数据并不一致。此处对数据进行脱敏,仅显示发生数据丢失那一列的数据,如下:

由此可见,在环境A中可以查询到该列数据,但是在环境B中却出现了部分数据缺失。

二、排查

上述两个查询中用的Spark大版本是一致的,团队的同事通过对比两个环境中的配置,发现有一个参数在最近进行了变更。该参数为:spark.sql.decimalOperations.allowPrecisionLoss, 默认为true。

在环境A中未设置此参数,所以为true,而在环境B下Spark client的spark-defaults.conf中,该参数设置为false。

该参数为PR SPARK-22036 引入,是为了控制在两个Decimal类型操作数做计算的时候,是否允许丢失精度。在本文中,我们就针对乘法这种计算类型做具体分析

关于Decimal类型

在详细介绍该参数之前,先介绍一下Decimal。

Decimal是数据库中的一种数据类型,不属于浮点数类型,可以在定义时划定整数部分以及小数部分的位数。对于一个Decimal类型,scale表示其小数部分的位数,precision表示整数部分位数和小数部分位数之和。

一个Decimal类型表示为Decimal(precision, scale),在Spark中,precision和scale的上限都是38

一个double类型可以精确地表示小数点后15位,有效位数为16位

可见,Decimal类型则可以更加精确地表示,保证数据计算的精度。

例如一个Decimal(38, 24)类型可以精确表示小数点后23位,小数点后有效位数为24位。而其整数部分还剩下14位可以用来表示数据,所以整数部分可以表示的范围是-10^14+1~10^14-1。

关于精度和Overflow

关于精度的问题其实我们小学时候就涉及到了,比如求两个小数加减乘除的结果,然后保留小数点后若干有效位,这就是保留精度。

乘法操作我们都很清楚,如果一个n位小数乘以一个m位小数,那么结果一定是一个**(n+m)位**小数。

举个例子, 1.11 * 1.11精确的结果是 1.2321,如果我们只能保留小数点后两位有效位,那么结果就是1.23。

上面我们提到过,对于Decimal类型,由于其整数部分位数是(precision-scale),因此该类型能表示的范围是有限的,一旦超出这个范围,就会发生Overflow。而在Spark中,如果Decimal计算发生了Overflow,就会默认返回Null值

举个例子,一个Decimal(3,2)类型代表小数点后用两位表示,整数部分用一位表示,因此该类型可表示的整数部分范围为-9~9。如果我们CAST(12.32 as Decimal(3,2)),那么将会发生Overflow。

下面介绍spark.sql.decimalOperations. allowPrecisionLoss参数。

当该参数为true(默认)时,表示允许Decimal计算丢失精度,并根据Hive行为和SQL ANSI 2011规范来决定结果的类型,即如果无法精确地表示,则舍入结果的小数部分。

当该参数为false时,代表不允许丢失精度,这样数据就会表示得更加精确。eBay的ETL部门在进行数据校验的时候,对数据精度有较高要求,因此我们引入了这个参数,并将其设置为false以满足ETL部门的生产需求。

设置这个参数的初衷是美好的,但是为什么会引发数据损坏呢?

用户的SQL数据非常长,通过查看相关SQL的执行计划,然后进行简化,得到一个可以复现的SQL语句,如下:

上面的select语句将会返回一个NULL。

我们将上述语句的执行计划打印出来。

执行计划很简单,里面有一个二元操作(乘法),左边的case when 是一个Decimal(34, 24)类型,右边是一个Literal(1)。

程序员都知道,在编程中,如果两个不同类型的操作数做计算,就会将低级别的类型向高级别的类型进行类型转换,Spark中也是如此。

一条SQL语句进入Spark-sql引擎之后,要经历Analysis->optimization->生成可执行物理计划的过程。而这个过程就是不同的Rule不断作用在Plan上面,然后Plan随之转化的过程。

在Spark-sql中有一系列关于类型转换的Rule,这些Rule作用在Analysis阶段的Resolution子阶段

其中就有一个Rule叫做ImplicitTypeCasts,会对二元操作(加减乘除)的数据类型进行转换,如下图所示:

用文字解释一下,针对一个二元操作(加减乘除), 如果左边的数据类型和右边不一致,那么会寻找一个左右操作数的通用类型(common type), 然后将左右操作数都转换为通用类型。针对我们此案例中的 Decimal(34, 24) 和Literal(1), 它们的通用类型就是Decimal(34, 24),所以这里的Literal(1)将被转换为Decimal(34, 24)。

这样该二元操作的两边就都是Decimal类型。接下来这个二元操作会被Rule DecimalPrecision中的decimalAndDecimal方法处理。

在不允许精度丢失时,Spark会为该二元操作计算一个用来表达计算结果的Decimal类型,其precision和scale的计算公式如下表所示,这是参考了SQLServer的实现。

此处我们的操作数都已经是Decimal(34, 24)类型了,所以p1=p2=34, s1=s2=24。

如果不允许精度丢失,那么其结果类型就是 Decimal(p1+p2+1, s1+s2)。由于precision和scale都不能超过上限38,所以这里的结果类型是Decimal(38, 38), 也就是小数部分为38位。于是整数部分就只剩下0位来表示,也就是说如果整数部分非0,那么这个结果就会Overflow。在当前版本中,如果Decimal Operation 计算发生了Overflow,就会返回一个Null的结果。

这也解释了在前面的场景中,为什么使用环境B中Spark客户端跑的结果,非Null的结果中整数部分都是0,而小数部分精度更高(因为不允许精度丢失)。

好了,问题定位到这里结束,下面讲解决方案。

三、解决方案

01 合理处理操作数类型

通过观察Spark-sql中Decimal 相关的Rule,发现了Rule DecimalPrecision中的nondecimalAndDecimal方法,这个方法是用来处理非Decimal类型和Decimal类型操作数的二元操作。

此方法代码不多,作用就是前面提到的左右操作数类型转换,将两个操作数转换为一样的类型,如下图所示:

文字描述如下:

如果其中非Decimal类型的操作数是Literal类型, 那么使用DecimalType.fromLiteral方法将该Literal转换为Decimal。例如,如果是Literal(1),则转化为Decimal(1, 0);如果是Literal(100),则转化为Decimal(3, 0)。

如果其中非Decimal类型操作数是Integer类型,那么使用DecimalType.forType方法将Integer转换为Decimal类型。由于Integer.MAX_VALUE 为2147483647,小于3*10^9,所以将Integer转换为Decimal(10, 0)。当然此处省略了其他整数类型,例如,如果是Byte类型,则转换为Decimal(3,0);Short类型转换为Decimal(5,0);Long类型转换为Decimal(20,0)等等。

如果其中非Decimal类型的操作是float/double类型,则将Decimal类型转换为double类型(此为DB通用做法)。

因此,这里用DecimalPrecision Rule的nonDecimalAndDecimal方法处理一个Decimal类型和另一个非Decimal类型操作数的二元操作的做法要比前面提到的ImplicitTypeCasts规则处理更加合适。ImplicitTypeCasts 会将Literal(1) 转换为Decimal(34, 24), 而DecimalPrecision将Literal(1)转换为Decimal(1, 0) 。

经过DecimalPrecision Rule的nonDecimalAndDecimal处理之后的两个Decimal类型操作数会被DecimalPrecision中的decimalAndDecimal方法(上文提及过)继续处理。

上述提到的案例是一个乘法操作,其中,p1=34, s1=24, p2 =1, s2=0。

其结果类型为Decimal(36,24),也就是说24位表示小数部分, 12位表示整数部分,不容易发生Overflow

前面提到过,Spark-sql中关于类型转换的Rule作用在Analysis阶段的Resolution子阶段。而Resolution子阶段会有一批Rule一直作用在一个Plan上,直到这个Plan到达一个不动点(Fixpoint),即Plan不再随Rule作用而改变。

因此,我们可以在ImplicitTypeCasts规则中对操作数类型进行判断。如果在一个二元操作中有Decimal类型的操作数,则此处跳过处理,这个二元操作后续会被DecimalPrecision规则中的nonDecimalAndDecimal方法和decimalAndDecimal方法继续处理,最终到达不动点。

我们向Spark社区提了一个PR SPARK-29000, 目前已经合入master分支。

02 用户可感知的Overflow

除此之外,默认的DecimalOperation如果发生了Overflow,那么其结果将返回为NULL值,这样的计算结果异常并不容易被用户感知到(此处非常感谢金融分析团队的同事帮我们检查到了这个问题)。

在SQL ANSI 2011标准中,当算术操作发生Overflow时,会抛出一个异常。这也是大多数数据库的做法(例如SQLService, DB2, TeraData)。

PR SPARK-23179 引入了参数spark.sql. decimalOperations.nullOnOverflow 用来控制在Decimal Operation 发生Overflow时候的处理方式。

默认是true,代表在Decimal Operation发生Overflow时返回NULL的结果。

如果设置为false,则会在Decimal Operation发生Overflow时候抛出一个异常。

因此,我们在上面的基础上合入该PR,引入spark.sql.decimalOperations.nullOnOverflow参数,设置为false, 以保证线上计算任务的数据质量。

四、总结

本文分析了一个Decimal操作计算时发生的数据质量问题。我们不仅修复了其不合适的类型转换问题,减小了其结果Overflow的机率,还引入了一个参数,以便在计算发生Overflow时抛出异常,让用户感知到计算中存在的问题,保证线上计算的数据质量。

在大数据计算场景中,我们不仅关心数据计算得快不快,更关心结果数据的质量高不高。这需要各个团队的密切配合,平台开发人员需要提供可靠稳定的计算平台,业务团队需要写出高质量的SQL,数据服务团队则要提供良好的调度和校验服务。相信在各个团队的共同努力下,eBay在大数据这条路上能走得更远、更宽阔。

本文转载自公众号eBay技术荟(ID:eBayTechRecruiting)。

原文链接

https://mp.weixin.qq.com/s/yKFzO41l-2n617xICN2ObQ

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章