探討系統中?錢的精度問題

Gopher指北發表於2022-02-27
來自公眾號:Gopher指北
錢,乃亙古之玄物,有則氣粗神壯,缺則心卑力淺

在一個系統中,特別是一個和錢相關的系統,錢乃重中之重,計算時的精度將是本篇討論的主題。

精度為何如此重要

“積羽沉舟”用在此處最為合適。假如某電商平臺每年訂單成交數量為10億,每筆訂單少結算1分錢,則累計損失1000萬!有一說一,這損失的錢就是王某人的十分之一個小目標。如果因為精度問題在給客戶結算時,少算會損失客戶,多算會損失錢。由此可見,精確的計算錢十分重要!

為什麼會有精度的問題

經典案例,我們來看一下0.1 + 0.2在計算機中是否等於0.3

上述case學過計算機的應該都知道,計算機是二進位制的,用二進位制表示浮點數時(IEEE754標準),只有少量的數可以用這種方法精確的表示出來。下面以0.3為例看一下十進位制轉二進位制小數的過程。

計算機的位數有限制,因此計算機用浮點數計算時肯定無法得到精確的結果。這種硬限制無法突破,所以需要引入精度以保證對錢的計算在允許的誤差範圍內儘可能準確。

關於浮點數在計算機中的實際表示本文不做進一步討論,可以參考下述連線學習:

單精度浮點數表示:

https://en.wikipedia.org/wiki...

雙精度浮點數表示:

https://en.wikipedia.org/wiki...

浮點數轉換器:

https://www.h-schmidt.net/Flo...

用浮點數計算

還是以上述0.1 + 0.2為例,0.00000000000000004的誤差完全可以忽略,我們嘗試小數部分保留5位精度,看下面結果。

此時的結果符合預期。這也是為什麼很多時候判斷兩個浮點數是否相等往往採用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
}

上述程式碼輸出結果如下:

上述輸出結果完全符合預期,所以用整型來表示浮點數看起來是一個可行的方案。但,我們不能侷限於個例,還需要更多的測試。

fmt.Println(float2Int(2.3))

上述程式碼輸出結果如下:

這個結果是如此的出乎意料,卻又是情理之中。

上圖表示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位。

我們看下面的例子。

var (
    a float64 = 123456789012345.678
    b float64 = 1.23456789012345678
)

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

上述程式碼輸出結果如下:

根據輸出結果知,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)

上述程式碼輸出結果如下:

由計算結果知,每人分得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)

上述程式碼輸出結果如下:

通過減法我們終於找回了那丟失的0.0000000000000001元。當然上面僅是老許舉的一個例子,在實際的計算過程中可能需要通過decimal庫進行減法以保證錢不憑空消失也不憑空增加。

以上均為老許的淺薄之見,有任何疑慮和錯誤請及時指出,衷心希望本文能夠對各位讀者有一定的幫助。

注:

寫本文時, 筆者所用go版本為: go1.16.6

文章中所用部分例子:https://github.com/Isites/go-...

相關文章