【GaussDB】真相比你想的更复杂--执行exp函数报错
背景
客户有个正态分布计算的存储过程从ORACLE迁移到GaussDB 506.0.0SPC0500,运行时发现报错ERROR: value out of range: underflow,但相同的SQL和相同的数据在ORACLE没有报错。经过排查,发现是执行到exp函数时报的错。我大概搜了一下,了解到postgresql系的数据库exp函数入参的确是有个范围的,有人甚至利用这个报错来对应用进行SQL注入。我自己实际测试了一下,发现不报错的整数范围是 -745 到 709 ,大了小了都会报错。
ORACLE为什么不报错
为什么ORACLE不报错?我之前有分析过ORACLE的NUMBER类型存储算法(【ORACLE】详解oracle数据库UTL_RAW包各个函数的模拟算法),想着应该与这个存储算法有关系,就来推理一下。
ORACLE 的NUMBER类型最多38个有效数字 ,在数据字典dba_tab_cols里可以看到NUMBER类型的字段DATA_LENGTH为22,但ORACLE官方文档则说NUMBER是21个字节。
正数范围 1.0*power(10, −130) 到 9.99...9*power(10,125) (38个9)
负数范围 -9.99...9×power(10,125) (38个9) 到 -1.0×power(10, −130)
对于power函数,当第一个参数为10时:
第二个参数大于125会报错;
第二个参数小于-130会返回0
这个边界和ORACLE官方文档一致。
对于exp函数,输入参数大于 290 时会报错,输入参数小于 -300 时会返回0。
从power函数和exp函数的测试结果来看,可以发现小数部分小到一定程度,就直接舍弃了;而正数部分超过一定程度会报错。
这是因为NUMBER类型的存储机制决定的:
最小正数的的NUMBER十六进制存储为8002,两个字节,
80是指数位,要减193,得-65,
02减1,得1,
则为 1*100^-65 ,即 1E-130
而80则是表示0,所以大于0的最小正数就是8002了(不存在8001,因为那会和80算出来的结果一样)
而最大的一个有效数字的整数情况是FF5B,第一个字节最大FF,减193后,指数就为62了,5B减1的十进制是90, 即90*100^62 ,即 9E+125。(更大的FF65 是 1E126,已经无法使用了)
如果整数的38位有效数字填满情况,则接近于 power(10,125)*9.9999999999999999999999999999999999999 ,存储为 FF64646464646464646464646464646464646464,
64减1就是十进制的99了,38个9,占了19个字节,算上前面的指数位FF,一共20个字节,但此时这个存储的值已经无法正常显示了,utl_raw.cast_to_number会变成1E126,再to_char会返回~,
如果再多一个9 (39个9),就存储为FF646464646464646464646464646464646464645B ,最后的5B就是90,此时占到了21个字节,已经逼近了NUMBER的22字节上限,如果再加一个9 (40个9),此时其实还是21个字节,但其实已经溢出。(其实手动解析是能解到最多40个有效数字的,但ORACLE做了限制,38个9和39个9和40个9都是1E126,不过并不相等,42个9已经超了范围,和40个9是相等的)
SQL> select 1 as r from dual where
2 utl_raw.cast_to_number('FF64646464646464646464646464646464646464') --38个9
3 =
4 utl_raw.cast_to_number('FF6464646464646464646464646464646464646464'); --40个9
R
----------
SQL> select 1 as r from dual where
2 utl_raw.cast_to_number('FF646464646464646464646464646464646464646464') --42个9
3 =
4 utl_raw.cast_to_number('FF6464646464646464646464646464646464646464'); --40个9
R
----------
1
SQL> select
2 utl_raw.cast_to_number('FF64646464646464646464646464646464646464') as c1,--38个9
3 utl_raw.cast_to_number('FF646464646464646464646464646464646464645B') as c2,--39个9
4 utl_raw.cast_to_number('FF6464646464646464646464646464646464646464') as c3,--40个9
5 utl_raw.cast_to_number('FF646464646464646464646464646464646464646464') as c4 --42个9
6 from dual;
C1 C2 C3 C4
---------- ---------- ---------- ----------
1E126 1E126 1E126 1E126
SQL>
SQL> select
2 to_char(utl_raw.cast_to_number('FF64646464646464646464646464646464646464')) as c1, --38个9
3 to_char(utl_raw.cast_to_number('FF646464646464646464646464646464646464645B')) as c2, --39个9
4 to_char(utl_raw.cast_to_number('FF6464646464646464646464646464646464646464')) as c3, --40个9
5 to_char(utl_raw.cast_to_number('FF646464646464646464646464646464646464646464')) as c4 --42个9
6 from dual;
C1 C2 C3 C4
---- ---- ---- ----
~ ~ ~ ~
ORACLE官方文档有说
Oracle stores values of the NUMBER datatype in a variable-length format. The first byte is the exponent and is followed by 1 to 20 mantissa bytes.
Oracle 以变长格式存储 NUMBER 数据类型的值。第一个字节为指数,其后跟随 1 至 20 个尾数字节。
Up to 20 data bytes can represent the mantissa. However, only 19 are guaranteed to be accurate.
最多 20 个数据字节可表示尾数。但只有 19 个字节保证精确。
即,数据字典22字节,官方文档21字节,表示数字的20字节,其中实际19字节保证精确。计算时(不限于),小数小到一定程度则忽略,而大数大到一定程度则报错。
PG为什么"报错"
回头看看为什么PG系会报错。我先在GaussDB上测试
power(10,-324) 报错
power(10,-323) = 9.88131291682493088e-324
power(10,308) = 9.99999999999999909e-309
power(10,309) 报错
power函数第一个参数为10时:
第二个参数的整数范围为-323 到 308 ,大了小了都会报错
让AI捞了下PG的源码,说是PG直接用的标准C库里的pow函数,IEEE 754标准的确就有这个限制。
而对于exp函数的限制,也是设计如此,在openGauss的测试用例集里已经包含了exp的边界测试:
https://gitcode.com/opengauss/Yat/blob/6.0.2/openGaussBase/testcase/SQL/INNERFUNC/numeric/Opengauss_Function_Innerfunc_Exp_Case0006.sql
-- @testpoint: exp函数入参范围校验,合理报错
select exp(709);
select exp(710);
select exp(-745);
select exp(-746);
不过这个时候我注意到了,AI给出来的源码引用并不是numeric类型的,而是float.c里的。而且我在numeric的power函数里,并没有看到使用标准的pow函数,于是乎我做了个测试select power(10,-324::numeric) 并没有报错,numeric会不会有新的边界呢?
我又分别针对numeric的power和exp函数做了下测试
- power
power(10,131072::numeric) 报错
power(10,131071::numeric) 131072位数
power(10,-16::numeric) = 0.0000000000000001
power(10,-17::numeric) = 0
power(10,-1000000::numeric) 0
--numeric的有效范围内,继续小都一直返回0了
- exp
exp(6000::numeric) 报错
exp(5999::numeric) 不报错,2606位精确数
exp(-2303::numeric) = 1E-1000
exp(-2304::numeric) = 0
exp(-5999::numeric) = 0
exp(-6000::numeric) 报错
很明显的不对劲,power第二个参数小那么多都不报错,而exp在-6000时就报错了。
以上都是在GaussDB上进行的测试,然而当我把相同的用例搬到多个PG版本上时( https://dbfiddle.uk/XSOhWTce ),测试结果又发生了变化:
从pg9.6起 ,exp(-6000::numeric) 不报错了,让AI去查了下
Commit:
7d9a4737c268f61fb8800957631f12d3f13be218
Author: Dean Rasheed
Date: 2015-11-14
Message: Improve type numeric's calculations for ln(), log(), exp(), pow().这个 commit 彻底重写了
exp_var()的实现逻辑:In exp_var(), get rid entirely of the logic that separated the calculation into integer and fractional parts: that was neither accurate nor particularly fast. The existing range-reduction method of dividing by 2^n can be applied across the full input range instead of only 0..1, as long as we are careful to set an appropriate rscale for each step.
旧实现把参数拆成整数部分和小数部分分别计算,对大负数会溢出报错。新实现改用统一的 range-reduction(除以 2^n)方式处理整个输入范围,
exp(-6000)这种情况就不会再报错了。影响版本:
- 首次发布于 PostgreSQL 9.6(2016-09-29)
- 没有回移到 9.5 及更早版本。Tom Lane 原话:"This is arguably a bug fix, but in view of the lack of field complaints, it does not seem worth the risk of back-patching."
当年没有"complaints",现在有了,在GaussDB上。
也就是说,GaussDB上exp函数报错,可以认为的确是个BUG。
另外,虽然说PG9.5和GaussDB 都报错,但是报错不一样
- PG9.5是
ERROR: argument for function "exp" too big - GaussDB是
ERROR: value overflows numeric format
所以我又让AI去对比了一下PG和openGauss的源码
三家
exp_var()核心溢出检查对比PG 9.5 — 旧算法(拆整数/小数)
// 把 |x| 的整数部分逐位累加到 xintval while (x.weight >= 0) { xintval *= NBASE; if (x.ndigits > 0) xintval += x.digits[0]; x.digits++; x.ndigits--; x.weight--; if (xintval >= NUMERIC_MAX_RESULT_SCALE * 3) // ← 只检查正方向 ereport(ERROR, (errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE), errmsg("argument for function \"exp\" too big"))); }
- 取绝对值后逐位提取整数部分,一旦超限就报错
- 不区分正负,
exp(-6000)取绝对值后 xintval=6000,超限 → 报错- 错误信息是 exp 专用的:
argument for function "exp" too big
PG 9.6 — 新算法(range-reduction + Taylor)✅ 有 underflow 处理
val = numericvar_to_double_no_overflow(&x); /* Guard against overflow/underflow */ if (Abs(val) >= NUMERIC_MAX_RESULT_SCALE * 3) { if (val > 0) // 正数 → 溢出报错 ereport(ERROR, (errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE), errmsg("value overflows numeric format"))); zero_var(result); // 负数 → 返回 0 result->dscale = rscale; return; }
- 先转 double 估算范围
- 区分正负:
val > 0报溢出,val < 0直接zero_var(result)返回 0exp(-6000)→ val ≈ -6000,走zero_var→ 返回 0,不报错
openGauss — 基于 9.6 算法 ❌ 缺少 underflow 处理
val = numericvar_to_double_no_overflow(&x); /* Guard against overflow */ // ← 注释只写了 overflow if (Abs(val) >= NUMERIC_MAX_RESULT_SCALE * 3) ereport(ERROR, // ← 无差别报错 (errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE), errmsg("value overflows numeric format")));
- 算法框架和 PG 9.6 一样(range-reduction + Taylor)
- 但漏掉了
val <= 0的分支处理,不管正负一律报错exp(-6000)→ val ≈ -6000,直接走 ereport → 报错value overflows numeric format
一图总结
输入: exp(-6000::numeric) PG 9.5: 旧算法,xintval=6000 超限 → ERROR: argument for function "exp" too big PG 9.6: 新算法,val≈-6000 < 0,走 zero_var() → 返回 0 ✅ openGauss: 新算法,val≈-6000,但无 val>0/val≤0 分支 → ERROR: value overflows numeric format根因: openGauss 从 PG 9.6 搬代码时,只搬了溢出检查的"壳"(
Abs(val) >= 阈值 → 报错),没搬 PG 9.6 里区分 overflow/underflow 的分支逻辑(if (val > 0) ... else zero_var())。看起来是合入时的遗漏。
是不是感觉已经真相了?不,基于我理性的判断,openGauss不可能在搬代码的时候漏了这个逻辑。我猜测它搬的时候,原生PG里的代码就长这样,于是继续让AI查
时间线
时间 事件 exp_var()溢出检查2015-11-14 7d9a4737c2— 重写 exp_var,进入 PG 9.6 开发分支if (Abs(val) >= 阈值) 报错 "value overflows numeric format"— 不区分正负2016-09-29 PG 9.6.0 发布 同上,没有 underflow 处理 2021-07-31 5cf350ce02— backport 到 REL9_6_STABLE加上 if (val > 0)分支,负数走zero_var()返回 02021-11-11 PG 9.6.24 发布(含上述 backport) exp(-6000)→ 返回 0 ✅openGauss fork 基于 PG 9.6 代码(2021 年 backport 之前) 和 9.6.0 一样,不区分正负
关键 diff(
5cf350ce02,REL9_6_STABLE 的 backport)- /* Guard against overflow */ + /* Guard against overflow/underflow */ if (Abs(val) >= NUMERIC_MAX_RESULT_SCALE * 3) - ereport(ERROR, - (errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE), - errmsg("value overflows numeric format"))); + { + if (val > 0) + ereport(ERROR, + (errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE), + errmsg("value overflows numeric format"))); + zero_var(result); + result->dscale = rscale; + return; + }
结论
openGauss 的代码没有遗漏,它就是 PG 9.6.0 的原版逻辑。
underflow → 0 的处理是 2021 年才 backport 到 REL9_6_STABLE 的(9.6.24),openGauss fork 的时间点在这个 backport 之前,所以它的
exp_var()和 PG 9.6.0 完全一致——Abs(val)超限就无差别报错。三方代码实际对应的是:
PG 9.5 → 旧算法,专用报错 "argument for function \"exp\" too big" PG 9.6.0 → 新算法,通用报错 "value overflows numeric format" ← openGauss 就是这个 PG 9.6.24+ → 新算法 + underflow 分支,负数返回 0
看了下openGauss的这段代码,从首次开源起就没改过,时间点是在 2020-06-30 ,刚好在2015-11-14(7d9a4737c2) 到 2021-07-31(5cf350ce02) 之间,即使用了新算法,但是没特殊处理小于0的分支。但众所周知,openGauss的PG内核版本锁在了9.2.4,但开源时的这段代码却长得和9.6.0一样。
lishifu 6 years ago (June 30th, 2020 5:38 PM)
first commit for openGauss server
815a977 | | Explain | |
+ /* Guard against overflow */
Changes added in 815a977 |
所以我直接去找了PG9.2.4的代码
https://github.com/postgres/postgres/blob/REL9_2_4/src/backend/utils/adt/numeric.c
PG9.2.4的确没有合入来自PG9.6.0的这个新算法,所以openGauss虽然是基于9.2.4版本,但是首次开源的时候,就已经包含了一些新版PG的代码了。
到此,真相终于大白。
总结
ORACLE 和最新的PG,在exp的入参是个绝对值很大的负NUMBER/NUMERIC值时(-6000),都不会报错;而PG9.6.24之前(不含)和openGauss/GaussDB会报错。
追寻真相的过程中,AI的确帮我节省了不少时间,但是一开始AI生成的看上去很靠谱的分析报告,却并不是真相。AI的幻觉问题,后续通过大模型能力提升肯定是可以减轻的。但是当下阶段使用AI,最好还是保持清醒的认知,不要让AI做凌驾于你认知之上的事,哪天被AI忽悠瘸了还不知道;如果要这么做,也要有确保可信的手段去核实真假。

