探讨系统中💰钱的精度问题

来自公众号:Gopher指北

钱,乃亘古之玄物,有则气粗神壮,缺则心卑力浅

在一个系统中,特别是一个和钱相关的系统,钱乃重中之重,计算时的精度将是本篇讨论的主题。

精度为何如此重要

“积羽沉舟”用在此处最为合适。假如某电商平台每年订单成交数量为10亿,每笔订单少结算1分钱,则累计损失1000万!有一说一,这损失的钱就是王某人的十分之一个小目标。如果因为精度问题在给客户结算时,少算会损失客户,多算会损失钱。由此可见,精确的计算钱十分重要!

为什么会有精度的问题

经典案例,我们来看一下0.1 + 0.2在计算机中是否等于0.3

image

上述case学过计算机的应该都知道,计算机是二进制的,用二进制表示浮点数时(IEEE754标准),只有少量的数可以用这种方法精确的表示出来。下面以0.3为例看一下十进制转二进制小数的过程。

image

计算机的位数有限制,因此计算机用浮点数计算时肯定无法得到精确的结果。这种硬限制无法突破,所以需要引入精度以保证对钱的计算在允许的误差范围内尽可能准确。

关于浮点数在计算机中的实际表示本文不做进一步讨论,可以参考下述连接学习:

单精度浮点数表示:

https://en.wikipedia.org/wiki/Single-precision_floating-point_format

双精度浮点数表示:

https://en.wikipedia.org/wiki/Double-precision_floating-point_format

浮点数转换器:

https://www.h-schmidt.net/FloatConverter/IEEE754.html

用浮点数计算

还是以上述0.1 + 0.2为例,0.00000000000000004的误差完全可以忽略,我们尝试小数部分保留5位精度,看下面结果。

image

此时的结果符合预期。这也是为什么很多时候判断两个浮点数是否相等往往采用a - b <= 0.00001的形式,说白了这就是小数部分保留5位精度的另一种表现形式。

用整型计算

前面提到只有少量的浮点数可以用IEEE754标准表示,而整型可精确表示所有有效范围内的数。因此很容易想到,使用整型表示浮点数。

例如,事先定好小数保留8位精度,则0.10.2分别表示成整数为1000000020000000, 浮点数的运算也就转换为整型的运算。还是以0.1 + 0.2为例。

// 表示小数位保留8位精度
const prec = 100000000

func float2Int(f float64) int64 {
    return int64(f * prec)
}

func int2float(i int64) float64 {
    return float64(i) / prec
}
func main() {
    var a, b float64 = 0.1, 0.2
    f := float2Int(a) + float2Int(b)
    fmt.Println(a+b, f, int2float(f))
    return
}

上述代码输出结果如下:

image

上述输出结果完全符合预期,所以用整型来表示浮点数看起来是一个可行的方案。但,我们不能局限于个例,还需要更多的测试。

fmt.Println(float2Int(2.3))

上述代码输出结果如下:

image

这个结果是如此的出乎意料,却又是情理之中。

image

上图表示2.3在计算机中实际的存储值,因此使用float2Int函数进行转换时的结果是229999999而不是230000000

这个结果很明显不符合预期,在确定的精度范围内仍有精度损失,如果把这个代码发到线上,很大概率第二天就会光速离职。要解决这个问题也很简单,只需引入github.com/shopspring/decimal即可,看下面修正后的代码。

// 表示小数位保留8位精度
const prec = 100000000

var decimalPrec = decimal.NewFromFloat(prec)

func float2Int(f float64) int64 {
    return decimal.NewFromFloat(f).Mul(decimalPrec).IntPart()
}

func main() {
    fmt.Println(float2Int(2.3)) // 输出:230000000
}

此时结果符合预期,系统内部的浮点运算(加法、减法、乘法)均可转换为整型运算,而运算结果只需要一次浮点转换即可。

到这里,用整型计算基本能满足大部分场景,但仍有两个问题尚需注意。

1、整型表示浮点数的范围是否满足系统需求。

2、整型表示浮点数时除法依旧需要转换为浮点数运算。

整型表示浮点数的范围

int64为例,数值范围为-9223372036854775808~9223372036854775807,如果我们对小数部分精度保留8位,则剩余表示整数部分依旧有11位,即只表示钱的话仍旧可以存储上百亿的金额,这个数值对很多系统和中小型公司而言已经绰绰有余,但是使用此方式存储金额时范围依旧是需要慎重考虑的问题。

整型表示浮点数的除法

在Go中没有隐式的整型转浮点的说法,即整型和整型相除得到的结果依旧是整型。我们以整型表示浮点数时,就尤其需要注意整型的除法运算会丢失所有的小数部分,所以一定要先转换为浮点数再进行相除。

浮点和整型的最大精度

int64的范围为-9223372036854775808~9223372036854775807,则用整型表示浮点型时,整数部分和小数部分的有效十进制位最多为19位。

uint64的范围为0~18446744073709551615,则用整型表示浮点型时,整数部分和小数部分的有效十进制位最多为20位,因为系统中表示金额时一般不会存储负数,所以和int64相比,更加推荐使用uint64

float64根据IEEE754标准,并参考维基百科知其整数部分和小数部分的有效十进制位为15-17位。

image

我们看下面的例子。

var (
    a float64 = 123456789012345.678
    b float64 = 1.23456789012345678
)

fmt.Println(a, b, decimal.NewFromFloat(a), a == 123456789012345.67)
return

上述代码输出结果如下:

image

根据输出结果知,float64无法表示有效位数超过17位的十进制数。从有效十进制位来讲,老许更加推荐使用整型表示浮点数。

计算中尽量保留更多的精度

前面提到了精度的重要性,以及整型和浮点型可表示的最大精度,下面我们以一个实际例子来探讨计算过程中是否要保留指定的精度。

var (
    // 广告平台总共收入7.11美元
    fee float64 = 7.1100
    // 以下是不同渠道带来的点击数
    clkDetails = []int64{220, 127, 172, 1, 17, 1039, 1596, 200, 236, 151, 91, 87, 378, 289, 2, 14, 4, 439, 1, 2373, 90}
    totalClk   int64
)
// 计算所有渠道带来的总点击数
for _, c := range clkDetails {
    totalClk += c
}
var (
    floatTotal float64
    // 以浮点数计算每次点击的收益
    floatCPC float64 = fee / float64(totalClk)
    intTotal int64
    // 以8位精度的整形计算每次点击的收益(每次点击收益转为整形)
    intCPC        int64 = float2Int(fee / float64(totalClk))
    intFloatTotal float64
    // 以8位进度的整形计算每次点击的收益(每次点击收益保留为浮点型)
    intFloatCPC  float64 = float64(float2Int(fee)) / float64(totalClk)
    decimalTotal         = decimal.Zero
    // 以decimal计算每次点击收益
    decimalCPC = decimal.NewFromFloat(fee).Div(decimal.NewFromInt(totalClk))
)
// 计算各渠道点击收益,并累加
for _, c := range clkDetails {
    floatTotal += floatCPC * float64(c)
    intTotal += intCPC * c
    intFloatTotal += intFloatCPC * float64(c)
    decimalTotal = decimalTotal.Add(decimalCPC.Mul(decimal.NewFromInt(c)))
}
// 累加结果对比
fmt.Println(floatTotal) // 7.11
fmt.Println(intTotal) // 710992893
fmt.Println(decimal.NewFromFloat(intFloatTotal).IntPart()) // 711000000
fmt.Println(decimalTotal.InexactFloat64()) // 7.1100000000002375

对比上面的计算结果,只有第二种精度最低,而造成该精度丢失的主要原因是float2Int(fee / float64(totalClk))将中间计算结果的精度也只保留了8位,因此在结果上面产生了误差。其他计算方式在中间计算过程中尽可能的保留了精度因此结果符合预期。

除法和减法的结合

根据前面的描述,在计算除法的过程中要使用浮点数且尽可能保留更多的精度。这依旧不能解决所有问题,我们看下面的例子。

// 1元钱分给3个人,每个人分多少?
var m float64 = float64(1) / 3
fmt.Println(m, m+m+m)

上述代码输出结果如下:

image

由计算结果知,每人分得0.3333333333333333元,而将每人分得的钱再次汇总时又变成了1元,那么
0.0000000000000001元是从石头里面蹦出来的嘛!有些时候我真的搞不懂这些计算机。

这个结果很明显不符合人类的直觉,为了更加符合直觉我们结合减法来完成本次计算。

// 1元钱分给3个人,每个人分多少?
var m float64 = float64(1) / 3
fmt.Println(m, m+m+m)
// 最后一人分得的钱使用减法
m3 := 1 - m - m
fmt.Println(m3, m+m+m3)

上述代码输出结果如下:

image

通过减法我们终于找回了那丢失的0.0000000000000001元。当然上面仅是老许举的一个例子,在实际的计算过程中可能需要通过decimal库进行减法以保证钱不凭空消失也不凭空增加。

以上均为老许的浅薄之见,有任何疑虑和错误请及时指出,衷心希望本文能够对各位读者有一定的帮助。

注:

写本文时, 笔者所用go版本为: go1.16.6

文章中所用部分例子:https://github.com/Isites/go-coder/blob/master/money/main.go

©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,794评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,050评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,587评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,861评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,901评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,898评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,832评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,617评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,077评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,349评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,483评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,199评论 5 341
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,824评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,442评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,632评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,474评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,393评论 2 352

推荐阅读更多精彩内容