今天遇到一个问题,引发了这一篇文章。由于之前使用的都是MySQL,有自增主键的,到了PG之后发现也可以有但是需要多动手几步。加上后台人员使用的都是SpringBoot开发,所以就使用了Mybatis中的自动生成的主键,而不是数据库自动生成的?具体什么原理咱也不知道,咱也没问。反正就是搞出来一大堆很长很长的19位数字(据说是JAVA中的long类型?反正很长)。
很长会导致什么问题呢?在JS处理19位数字的时候,由于太长会导致精度丢失(最后几位就挂掉了。。。),比如下面这个例子:
let a = 1286218924822503435
console.log(a) // 19位数字,输出:1286218924822503400,最后两位被吃掉了
let b = 12862189248225034
console.log(b) // 17位数字,输出:12862189248225034,它的尾巴还健在
console.log(b+2) // 17位数字,输出:12862189248225036,精度未丢失
console.log(b+1) // 17位数字,输出:12862189248225036,精度丢失!!!
即使前端不进行计算处理,只是一个简单而又单纯的JSON.parse
,由于传过来的JSON中是number格式而不是string格式,所以就丢了。然后就会导致接口无法调用等问题。
原因分析
为什么导致这个现象呢?因为JS中所有数字格式在底层都是以64位double双精度格式进行存储的(具体介绍可以参看我的另外一篇文章:【JS时间戳】的番外篇:JS中的精度丢失,因为64位中只有52位用来存储数值的尾数部分,这就导致JS中最大的整型只能准确存储为2^(1+52)-1=9007199254740991(16位)
,即Number.MAX_SAFE_INTEGER
,再大就可能会导致运算时的精度丢失问题。
如何解决
转为String即可
最简单也是最有效的方法,过长的数字当作String进行存储,如果遇到计算场景,可以通过其他手段实现精确计算(网上很多,不再赘述)
使用ES10(ES2019)的BigInt
目前最新浏览器基本支持ES10的BigInt格式了,就像是PGSQL中的numeric,甚至更强大?理论上可以存储无限制位数的数字。这里不再详细讨论了
利用算法,将长数字转为短数字
这就是我们今天讨论的重点,通过进制转换的方式,实现长数字转为短数字的方式,减少传输过程中的数字位数。就像是常用的长链接转短链接一样。不过我们针对的是数字的转换(也可以说是数字与字符串的转换,毕竟10进制网上就不再是纯数字了)
PGSQL进制转换
这里基本都参考了下面这个链接中的方法,只不过是将Oracle中的SQL移植到了PGSQL下面,过程中还是遇到了一些问题,在这里进行一下记录和说明。
参考链接: ITPUB论坛 《10进制数转62进制》
首先帖上移植后的代码:
-- 10进制转62进制
WITH RECURSIVE T(N,S) AS (
SELECT 100000000000000000000::numeric(30,4) N, '' S
UNION ALL
SELECT trunc(N/62)::numeric(30,4),substr('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', (N%62)::int+1, 1 ) || S FROM T WHERE N>0
)
SELECT S FROM T WHERE N=0
-- 62进制转10进制
WITH RECURSIVE T(S,N) AS (
SELECT '1V973MbJYWoU' S,0::NUMERIC N
UNION ALL
SELECT SUBSTR(S,2),(POWER(62,LENGTH(S)::numeric-1) * (strpos('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',SUBSTR(S,1,1))-1) + N) FROM T WHERE LENGTH(S) > 0
)
SELECT N FROM T WHERE LENGTH(S) < 1
原理解释
常用的2,10,16进制间的相互转换这里就不说了。
其他不常见的进制间的转换(26进制、32进制、52进制、62进制等)原理也很简单,就是用除K取余法
,具体可参看百度百科和它的逆运算(名字我也没找到叫什么。。。),将余数和你对应的进制码表对应起来就可以啦。
有一些在线的工具网站,可以供您测试和比对:在线工具
在这个网站最上面可以看到,有一个删除线标记的关于大数超出范围导致精度丢失运算错误的提示,目前已经解决。(其他类似网站有存在这个问题且未解决的),所以才推荐这个啦!
在上面的SQL中,我们将进制的码表直接固定死了,其实这里你也可以做一些自己的自定义码表,或者加入一些特殊字符,实现数字的简单加密功能。
在PGSQL中我们通过强大的WITH
查询和RECURSIVE
递归的方式实现了这个进制间的转换功能。比如上面例子中10进制的100000000000000000000
经过转换后变为了62进制的1V973MbJYWoU
,他们直接可以相互转换的。
遇到的问题
本以为上面帖子中的SQL拷过来直接运行就可以了,没想到还是遇到了问题。涉及到数值类型和运算等问题。这里还不得不感谢原帖中给出的这个例子:100000000000000000000
,一个21位的整型数字。如果不是它,我可能想当然的以为直接用就可以了的。
-
PGSQL中数值类型和取值区间
PG中关于数字有好多的类型,比如有符号8字节的bigint,自增八字节整型bigserial,8字节的double precision等等,具体如下表:
类型 | 存储空间 | 描述 | 取值范围 |
---|---|---|---|
smallint | 2字节 | 小整型 | -32768 到 +32767(5位) |
integer | 4字节 | 常用整型 | -2147483648 到 +2147483647(10位) |
bigint | 8字节 | 大整形 | -9223372036854775808 到 +9223372036854775807 (19位) |
decimal | 变长 | 用户定义精度,可精确的表示小数 | 无限制 |
numeric | 变长 | 用户定义精度,可精确的表示小数 | 无限制 |
real | 4字节 | 精度可变,不能精确表示小数 | 精度是6个十进制位 |
double precision | 8字节 | 精度可变,不能精确的表示小数 | 精度是15个十进制位 |
serial | 4字节 | 小范围的自增整型 | 1到2147483647 |
bigserial | 8字节 | 大范围的自增整形 | 1到9223372036854775807 |
numeric类型
- numeric类型最多能存储有1000个数字位的数字并且能进行准确的数值计算。它主要用于需要准确地表示数字的场合,如货币金额。不过,对numeric 类型进行算术运算比整数类型和浮点类型要慢很多。
- numeric类型有两个术语,分别是标度和精度。numeric类型的标度(scale)是到小数点右边所有小数位的个数,numeric 的精度(precision)是所有数字位的个数,因例如, 23.5141 的精度是6而标度为4。可以认为整数的标度是零。
numeric 类型的最大精度和最大标度都是可以配置的。可以用下面的语法定义一个numeric类型:
(1)NUMERIC(precision, scale)
(2)NUMERIC(precision)
(3)NUMERIC
-
PGSQL中数值类型转换
上面提到了,我们刚开始用的也是帖子中例子给的数进行测试的,当我们用100000000000000000000
进行转换后发现,倒数第二位得到的和正确结果不同,正确结果应为:1V973MbJYWoU
,可我们计算的结果为1V973MbJYWpU
。这是什么原因呢?我们来调试查找原因:
在我们没有对100000000000000000000进行numeric(30,4)转换时
由于传入的要转换的数字100000000000000000000(22位)
已经远远超出上面我们说的bigint的类型的最大值,所以PGSQL会把它当作numeric来处理,但是当调用(N/62)的时候,由于数值过大,且62为int类型,系统计算后会得到如下结果:
可以看到,前两行的除法中,居然都没有小数点(这肯定是不可能的),而且第二行计算的数字1612903225806451613,通过计算器我们可以得到正确结果应该是1612903225806451612.9032........
如下图,我们将数字提前转换为numeric(30,4)后的结果:
小数出来了,而且trunc(N/62)=1612903225806451612
了,所以再次计算下一行的时候,得到的余数就是o对应的数值而不是p了。这其中具体的底层原理没有深究,但是多半就是因为数据超出范围后PGSQL默认的计算方式丢失了精度导致的。
这里我们尝试过另外一种方式,就是在计算
trunc(N/62)的时候,通过trunc(N/62::numeric(30,4))
的方式提前将62转为numeric后,得出的结论也是正确的,这样不禁让我们猜想出:由于N的初始值超过了bigint,所以默认计算的时候自然会当作numeric(xx, 0)计算,但是当计算N/62时,由于62是整型,所以系统会将计算结果转换为numeric(xx, 0)而不是计算两个numeric(xx,xx)的除法导致精度丢失。因为numeric的计算虽然能保证精确度,但是会对性能带来一定的影响。不过这样做还有一个弊端在于,当N的初始值处在bigint范围内时候,N/62::numeric(30,4)
自然会使numeric,这与其初始类型int不服,导致报错。
所以我们干脆一不做二不休,直接将N的初始值转为numeric(30,4)
(当然这里的30你可以根据N的长度来定,更长或者更短都可以),这样不管初始值是不是超大的数,都按照numeric的方式来计算,保证了一定的精度。如果你知道N的初始值不会超过bigInt的范围,你完全可以不进行强转,这样还能提高一定的运算效率。
-
逆运算中遇到的坑
前面总结了10进制转62进制时遇到的问题,那么现在来说说62进制转10进制的时候遇到的问题吧。
首先我们知道,逆运算不用担心小数精度的问题(不存在),过程中遇到的数值都是整型或者超大整型,所以我们将其类型转为numeric即可。如果不转会怎样呢?如下图:
第一个问题就是由于power(62,11)=62^11=5.20365606838371e+019
,超出了普通数值的范围,导致无法存储的问题。所以我们只需要把power(62,11)::numeric
一下就可以了吗?显然是不可以的,结果是:52036560683837100000
,虽然位数全都出来了,但是最后那几个0以看就不正常。所以这里我们需要将62或者是11先转为numeric
,这样系统在计算power的时候就是以numeric的精度来计算了,得到的结果如下:power(62,11::numeric)=52036560683837093888=power(62::NUMERIC,11)
,这就对啦!正确的计算过程如下:
可以看到 Nn+1=tt*dd+Nn
网友评论