程序員是如何神不知鬼不覺的弄丟銀行1分錢的?
前段時(shí)間和某銀行合作共同開發(fā)了適合我們的一套支付系統(tǒng)。最近,我們對賬發(fā)現(xiàn)某些訂單始終都對不齊。銀行的下單金額與對賬金額始終少了1分錢。
這就奇怪了,如果這種異常訂單一多就是少了很多錢。在涉及錢的金融領(lǐng)域,這是個(gè)很謹(jǐn)慎嚴(yán)肅的問題。
我們跟銀行排查發(fā)現(xiàn)了問題的原因,也就是我今天想聊的關(guān)于技術(shù)上的東西:double精度的丟失問題。
1問題復(fù)現(xiàn)
我們先舉個(gè)簡單的例子
- double result = 1.0 - 0.9;
這段代碼中result等于多少?0.1么?如果執(zhí)行代碼的話,分分鐘打臉。
double精度丟失問題
2背后原理
無論是我們本文提到的double,還是float,都是浮點(diǎn)數(shù)。
在計(jì)算機(jī)科學(xué)中,浮點(diǎn)(英語:floating point,縮寫為FP)是一種對于實(shí)數(shù)的近似值數(shù)值表現(xiàn)法,由一個(gè)有效數(shù)字(即尾數(shù))加上冪數(shù)來表示,通常是乘以某個(gè)基數(shù)的整數(shù)次指數(shù)得到。以這種表示法表示的數(shù)值,稱為浮點(diǎn)數(shù)(floating-point number)。
計(jì)算機(jī)使用浮點(diǎn)數(shù)運(yùn)算的主因,在于計(jì)算機(jī)使用二進(jìn)位制的運(yùn)算。
例如:4÷2=2,4=100(二進(jìn)制)、2=010(二進(jìn)制)。在二進(jìn)制中除以2相當(dāng)于退一位數(shù)。
那么1.0÷2=0.5=0.1(二進(jìn)制)也就是1/2,依此類推二進(jìn)制的0.01(二進(jìn)制)就是十進(jìn)制 1/(2^2) = 1/4 = 0.25。
上面看到的1、0.5、0.25那都是可以轉(zhuǎn)換成二進(jìn)制的小數(shù),如十進(jìn)制的0.1,就無法用二進(jìn)制準(zhǔn)確的表示出來。因此只能使用近似值的方式表達(dá)。
比如,我們嘗試著把10進(jìn)制的0.1轉(zhuǎn)化成二進(jìn)制試試,步驟如下:
- 0.1*2=0.2……0——整數(shù)部分為“0”。整數(shù)部分“0”清零后為“0”,用“0.2”接著計(jì)算。
- 0.2*2=0.4……0——整數(shù)部分為“0”。整數(shù)部分“0”清零后為“0”,用“0.4”接著計(jì)算。
- 0.4*2=0.8……0——整數(shù)部分為“0”。整數(shù)部分“0”清零后為“0”,用“0.8”接著計(jì)算。
- 0.8*2=1.6……1——整數(shù)部分為“1”。整數(shù)部分“1”清零后為“0”,用“0.6”接著計(jì)算。
- 0.6*2=1.2……1——整數(shù)部分為“1”。整數(shù)部分“1”清零后為“0”,用“0.2”接著計(jì)算。
- 0.2*2=0.4……0——整數(shù)部分為“0”。整數(shù)部分“0”清零后為“0”,用“0.4”接著計(jì)算。
- 0.4*2=0.8……0——整數(shù)部分為“0”。整數(shù)部分“0”清零后為“0”,用“0.8”接著計(jì)算。
- 0.8*2=1.6……1——整數(shù)部分為“1”。整數(shù)部分“1”清零后為“0”,用“0.6”接著計(jì)算。
- 0.6*2=1.2……1——整數(shù)部分為“1”。整數(shù)部分“1”清零后為“0”,用“0.2”接著計(jì)算。
- 0.2*2=0.4……0——整數(shù)部分為“0”。整數(shù)部分“0”清零后為“0”,用“0.4”接著計(jì)算。
- 0.4*2=0.8……0——整數(shù)部分為“0”。整數(shù)部分“0”清零后為“0”,用“0.2”接著計(jì)算。
- 0.8*2=1.6……1——整數(shù)部分為“1”。整數(shù)部分“1”清零后為“0”,用“0.2”接著計(jì)算。
- ……
可以發(fā)現(xiàn),這個(gè)過程是除不盡的,除出了一個(gè)***循環(huán)小數(shù):二進(jìn)制的 0.0001100110011…
那么,如何在計(jì)算機(jī)中表示這個(gè)***不循環(huán)的小數(shù)呢?只能考慮按照不同的精度保理不同的位數(shù)。
我們知道float是單精度的,double是雙精度的。不同的精度,其實(shí)就是保留的有效數(shù)字位數(shù)不同,保留的位數(shù)越多,精度越高。
所以,浮點(diǎn)數(shù)在Java中是無法精確表示的,因?yàn)榇蟛糠指↑c(diǎn)數(shù)轉(zhuǎn)換成二進(jìn)制是一個(gè)***不循環(huán)的小數(shù),只能通過保留精度的方式進(jìn)行近似表示。
在《阿里巴巴Java開發(fā)手冊》中其實(shí)也有著明確的規(guī)定,說明了小數(shù)類型禁止使用float或者double來表示。(雖然這條是Mysql相關(guān)規(guī)則,但是Java代碼同樣適用。)
3問題引申
我們現(xiàn)在基本已經(jīng)知道了double的精度問題是什么問題。
在實(shí)際的訂單交易過程中,出現(xiàn)這個(gè)問題的更多場景是金額單位元與分的轉(zhuǎn)換。銀行給你的單位是元,你自己的運(yùn)算是分;前端輸入是元,計(jì)算是分等等。
舉個(gè)例子:用戶下了一筆64.6元的訂單,你在需要轉(zhuǎn)換成分。如果直接除以100,你會(huì)發(fā)現(xiàn)計(jì)算出來的分始終是6459,少1分錢。
金額丟失1分問題
4解決方式
1)使用BigDecimal
為了解決這種浮點(diǎn)小數(shù)進(jìn)度丟失問題,java提供了一種計(jì)算方式BigDecimal。
BigDecimal
這樣就可以了么? 不是,這樣能解決大部分問題,假如其他系統(tǒng)或語言不支持BigDecimal呢。當(dāng)我們無法解決這個(gè)問題的時(shí)候,我們需要做的是想辦法規(guī)避這個(gè)問題帶來的影響。
2)以分為單位,Long為數(shù)據(jù)結(jié)構(gòu)存儲
目前我們某些核心系統(tǒng)在金額傳輸?shù)倪^程和存儲中還是以元存儲浮點(diǎn)數(shù)。導(dǎo)致低于10元的訂單計(jì)算利息費(fèi)率的時(shí)候,無法計(jì)算清楚,使得我們的業(yè)務(wù)服務(wù)在處理這些問題頭疼死了。
整數(shù)與整數(shù)的計(jì)算,就沒有這些精度丟失問題。Long取值范圍(9223372036854775807)完全夠用。
3)除不盡怎么辦
對于除法,始終會(huì)產(chǎn)生除不盡的情況怎么辦?有個(gè)詞叫軋差
什么意思呢?舉個(gè)簡單例子。假如現(xiàn)在需要把10元分成3分,如果是10除以3這么除,會(huì)發(fā)現(xiàn)為3.33333無窮盡的3。這些數(shù)字完全無法在程序或數(shù)據(jù)庫中進(jìn)行精確的存儲。

簡單理解就是,當(dāng)除不盡或需去除小數(shù)點(diǎn)的時(shí)候,前面的n-1筆(這里n=3)做四舍五入。***一筆做兜底(總金額減去前面n-1筆之和)。這樣保證總金額的不會(huì)丟失。
這里我們的具體應(yīng)用場景是用戶使用了現(xiàn)金券,然后有部分退款,計(jì)算應(yīng)退本金的問題。現(xiàn)金券的處理又是一大篇文章,這里以后有機(jī)會(huì)再介紹。
5總結(jié)能用Long不用浮點(diǎn)數(shù)存儲。
前后端傳輸金額(元)的時(shí)候,請使用字符串,不要使用浮點(diǎn)數(shù)。
浮點(diǎn)數(shù)運(yùn)算請使用BigDecimal。
實(shí)在無法除盡,可以考慮通過軋差的方式解決。
double精度不是坑,是個(gè)容易忽視的巨坑,小小經(jīng)驗(yàn)希望對大家有所幫助,謹(jǐn)慎對待。>-<
關(guān)于銀行1分差的問題,等待銀行修復(fù),歷史訂單做調(diào)賬處理,哈哈哈哈哈。