查看原文
其他

案例分析 | 由Decimal操作计算引发的Spark数据丢失问题

Hadoop Team eBay技术荟 2022-03-15
供稿 | Hadoop Team编辑 | 顾欣怡本文3058字,预计阅读时间10分钟更多干货请关注“eBay技术荟”公众号


导读

eBay的Hadoop集群上面每天运行着大量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百万TPS支付账务系统的设计与实现
干货 | 起底eBay Flink的上云之路实战 | 总有刁民想害朕——Payments打造360°监控体系的实践干货 | eBay Kubernetes集群的存储实践实战 | eBay PB级日志系统的存储方案实践SRE重案调查组 第六集 | 剖析Java的非常规线程死锁问题

↓点击阅读原文,一键投递 

 eBay大量优质职位,等的就是你!

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存