【項(xiàng)目實(shí)踐】商業(yè)計(jì)算怎樣才能保證精度不丟失
前言以項(xiàng)目驅(qū)動(dòng)學(xué)習(xí),以實(shí)踐檢驗(yàn)真知
很多系統(tǒng)都有「處理金額」的需求,比如電商系統(tǒng)、財(cái)務(wù)系統(tǒng)、收銀系統(tǒng),等等。只要和錢扯上關(guān)系,就不得不打起十二萬分精神來對(duì)待,一分一毫都不能出錯(cuò),否則對(duì)系統(tǒng)和用戶來說都是災(zāi)難。
保證金額的準(zhǔn)確性主要有兩個(gè)方面:溢出和精度。溢出是指存儲(chǔ)數(shù)據(jù)的空間得充足,不能金額較大就存儲(chǔ)不下了。精度是指計(jì)算金額時(shí)不能有偏差,多一點(diǎn)少一點(diǎn)都不行。
溢出問題大家都知道如何解決,選擇位數(shù)長的數(shù)值類型即可,即不用 float 用 double 。而精度問題,double 就無法解決了,因?yàn)楦↑c(diǎn)數(shù)會(huì)導(dǎo)致精度丟失。
我們來直觀感受一下精度丟失:
double?money?=?1.0?-?0.9;
這個(gè)運(yùn)算結(jié)果誰都知道該為 0.1,然而實(shí)際結(jié)果卻是 0.09999999999999998。出現(xiàn)這個(gè)現(xiàn)象是因?yàn)橛?jì)算機(jī)底層是二進(jìn)制運(yùn)算,而二進(jìn)制并不能精準(zhǔn)表示十進(jìn)制小數(shù)。所以在商業(yè)計(jì)算等精確計(jì)算中要使用其他數(shù)據(jù)類型來保證精度不丟失,一定不要使用浮點(diǎn)數(shù)。
本螃蟹接下來會(huì)詳細(xì)講解在實(shí)際開發(fā)中到底該怎樣進(jìn)行商業(yè)計(jì)算,并將所有代碼和 SQL 語句放在了 Github 上(文末有地址),克隆下來即可運(yùn)行。
解決方案有兩種數(shù)據(jù)類型可以滿足商業(yè)計(jì)算的需求,第一個(gè)自然是專為商業(yè)計(jì)算而設(shè)計(jì)的 Decimal 類型,第二個(gè)則是定長整數(shù)。
Decimal
關(guān)于數(shù)據(jù)類型的選擇,一要考慮數(shù)據(jù)庫,二要考慮編程語言。即數(shù)據(jù)庫中用什么類型來存儲(chǔ)數(shù)據(jù),代碼中用什么類型來處理數(shù)據(jù)。
數(shù)據(jù)庫層面自然是用 decimal 類型,因?yàn)樵擃愋筒淮嬖诰葥p失的情況,用它來進(jìn)行商業(yè)計(jì)算再合適不過。
將字段定義為 decimal 的語法為 decimal(M,N),M 代表存儲(chǔ)多少位,N 代表小數(shù)存儲(chǔ)多少位。假設(shè) decimal(20,2),則代表一共存儲(chǔ) 20 位數(shù)值,其中小數(shù)占 2 位。
我們新建一張用戶表,字段很簡單就兩個(gè),主鍵和余額:

這里小數(shù)位置保留 2 點(diǎn),代表金額只存儲(chǔ)到分,實(shí)際項(xiàng)目中存儲(chǔ)到什么單位得根據(jù)業(yè)務(wù)需求來定,都是可以的。
數(shù)據(jù)庫層面搞定了咱們來看代碼層面,在 Java 中對(duì)應(yīng)數(shù)據(jù)庫 decimal 的是 java.math.BigDecimal類型,它自然也能保證精度完全準(zhǔn)確。
要?jiǎng)?chuàng)建BigDecimal主要有三種方法:
BigDecimal?d1?=?new?BigDecimal(0.1);?//?BigDecimal(double?val)
BigDecimal?d2?=?new?BigDecimal("0.1");?//?BigDecimal(String?val)
BigDecimal?d3?=?BigDecimal.valueOf(0.1);?//?static?BigDecimal?valueOf(double?val)
前面兩個(gè)是構(gòu)造函數(shù),后面一個(gè)是靜態(tài)方法。這三種方法都非常方便,但第一種方法禁止使用!看一下這三個(gè)對(duì)象各自的打印結(jié)果就知道為什么了:
d1:?0.1000000000000000055511151231257827021181583404541015625
d2:?0.1
d3:?0.1
第一種方法通過構(gòu)造函數(shù)傳入 double 類型的參數(shù)并不能精確地獲取到值,若想正確的創(chuàng)建 BigDecimal,要么將 double 轉(zhuǎn)換為字符串然后調(diào)用構(gòu)造方法,要么直接調(diào)用靜態(tài)方法。事實(shí)上,靜態(tài)方法內(nèi)部也是將 double 轉(zhuǎn)換為字符串然后調(diào)用的構(gòu)造方法:

如果是從數(shù)據(jù)庫中查詢出小數(shù)值,或者前端傳遞過來小數(shù)值,數(shù)據(jù)會(huì)準(zhǔn)確映射成 BigDecimal 對(duì)象,這一點(diǎn)我們不用操心。
說完創(chuàng)建,接下來就要說最重要的數(shù)值運(yùn)算。運(yùn)算無非就是加減乘除,這些 BigDecimal 都提供了對(duì)應(yīng)的方法:
BigDecimal?add(BigDecimal);?//?加
BigDecimal?subtract(BigDecimal);?//?減
BigDecimal?multiply(BigDecimal);?//?乘
BigDecimal?divide(BigDecimal);?//?除
BigDecimal 是不可變對(duì)象,意思就是這些操作都不會(huì)改變?cè)袑?duì)象的值,方法執(zhí)行完畢只會(huì)返回一個(gè)新的對(duì)象。若要運(yùn)算后更新原有值,只能重新賦值:
d1?=?d1.subtract(d2);
口說無憑,我們來驗(yàn)證一下精度是否會(huì)丟失 :
BigDecimal?d1?=?new?BigDecimal("1.0");
BigDecimal?d2?=?new?BigDecimal("0.9");
System.out.println(d1.subtract(d2));
輸出結(jié)果毫無疑問為 ?0.1。
代碼方面已經(jīng)能保證精度不會(huì)丟失,但數(shù)學(xué)方面除法可能會(huì)出現(xiàn)除不盡的情況。比如我們運(yùn)算 10 除以 3,會(huì)拋出如下異常:

為了解決除不盡后導(dǎo)致的無窮小數(shù)問題,我們需要人為去控制小數(shù)的精度。除法運(yùn)算還有一個(gè)方法就是用來控制精度的:
BigDecimal?divide(BigDecimal?divisor,?int?scale,?int?roundingMode)
scale 參數(shù)表示運(yùn)算后保留幾位小數(shù),roundingMode 參數(shù)表示計(jì)算小數(shù)的方式。
BigDecimal?d1?=?new?BigDecimal("1.0");
BigDecimal?d2?=?new?BigDecimal("3");
System.out.println(d1.divide(d2,?2,?RoundingMode.DOWN));?//?小數(shù)精度為2,多余小數(shù)直接舍去。輸出結(jié)果為0.33
用 RoundingMode 枚舉能夠方便地指定小數(shù)運(yùn)算方式,除了直接舍去,還有四舍五入、向上取整等多種方式,根據(jù)具體業(yè)務(wù)需求指定即可。
注意,小數(shù)精度盡量在代碼中控制,不要通過數(shù)據(jù)庫來控制。數(shù)據(jù)庫中默認(rèn)采用四舍五入的方式保留小數(shù)精度。
比如數(shù)據(jù)庫中設(shè)置的小數(shù)精度為 2,我存入
0.335,那么最終存儲(chǔ)的值就會(huì)變?yōu)?0.34。
我們已經(jīng)知道如何創(chuàng)建和運(yùn)算 BigDecimal 對(duì)象,只剩下最后一個(gè)操作:比較。因?yàn)槠洳皇腔緮?shù)據(jù)類型,用雙等號(hào) == 肯定是不行的,那我們來試試用 equals比較:
BigDecimal?d1?=?new?BigDecimal("0.33");
BigDecimal?d2?=?new?BigDecimal("0.3300");
System.out.println(d1.equals(d2));?//?false
輸出結(jié)果為 false,因?yàn)?BigDecimal 的 equals 方法不光會(huì)比較值,還會(huì)比較精度,就算值一樣但精度不一樣結(jié)果也是 false。若想判斷值是否一樣,需要使用int compareTo(BigDecimal val)方法:
BigDecimal?d1?=?new?BigDecimal("0.33");
BigDecimal?d2?=?new?BigDecimal("0.3300");
System.out.println(d1.compareTo(d2)?==?0);?//?true
d1 大于 d2,返回 1;
d1 小于 d2,返回 -1;
兩值相等,返回 0。
BigDecimal 的用法就介紹到這,我們接下來看第二種解決方案。
定長整數(shù)
定長整數(shù),顧名思義就是固定(小數(shù))長度的整數(shù)。它只是一個(gè)概念,并不是新的數(shù)據(jù)類型,我們使用的還是普通的整數(shù)。
金額好像理所應(yīng)當(dāng)有小數(shù),但稍加思考便會(huì)發(fā)覺小數(shù)并非是必須的。之前我們演示的金額單位是元,1.55 就是一元五角五分。那如果我們單位是角,一元五角五分的值就會(huì)變成 15.5。如果再將單位縮小到分,值就為 155。沒錯(cuò),只要達(dá)到最小單位,小數(shù)完全可以省略!這個(gè)最小單位根據(jù)業(yè)務(wù)需求來定,比如系統(tǒng)要求精確到厘,那么值就是1550。當(dāng)然,一般精確到分就可以了,咱們接下來演示單位都是分。
咱們現(xiàn)在新建一個(gè)字段,類型為 bigint,單位為分:

代碼中對(duì)應(yīng)的數(shù)據(jù)類型自然是 Long。基本類型的數(shù)值運(yùn)算我們是再熟悉不過的了,直接使用運(yùn)算操作符即可:
long?d1?=?10000L;?//?100元
d1?+=?500L;?//?加五元
d1?-=?500L;?//?減五元
加和減沒什么好說的,乘和除可能會(huì)出現(xiàn)小數(shù)的情況,比如某個(gè)商品打八折,運(yùn)算就是乘以 0.8:
long?d1?=?2366L;?//?23.66元
double?result?=?d1?*?0.8;?//?打八折,運(yùn)算后結(jié)果為1892.8
d1?=?(long)result;?//?轉(zhuǎn)換為整數(shù),舍去所有小數(shù),值為1892。即18.92元
進(jìn)行小數(shù)運(yùn)算,類型自然而然就會(huì)變?yōu)楦↑c(diǎn)數(shù),所以我們還要將浮點(diǎn)數(shù)轉(zhuǎn)換為整數(shù)。
強(qiáng)轉(zhuǎn)會(huì)將所有小數(shù)舍去,這個(gè)舍去并不代表精度丟失。業(yè)務(wù)要求最小單位是什么,就只保留什么,低于分的單位我們壓根沒必要保存。這一點(diǎn)和 BigDecimal 是一致的,如果系統(tǒng)中只需要到分,那小數(shù)精度就為 2, 剩余的小數(shù)都舍去。
不過有些業(yè)務(wù)計(jì)算可能要求四舍五入等其他操作,這一點(diǎn)我們可以通過 Math類來完成:
long?d1?=?2366L;?//?23.66元
double?result?=?d1?*?0.8;?//?運(yùn)算后結(jié)果為1892.8
d1?=?(long)result;?//?強(qiáng)轉(zhuǎn)舍去所有小數(shù),值為1892
d1?=?(long)Math.ceil(result);?//?向上取整,值為1893
d1?=?(long)Math.round(result);?//?四舍五入,值為1893
...
再來看除法運(yùn)算。當(dāng)整數(shù)除以整數(shù)時(shí),會(huì)自動(dòng)舍去所有小數(shù):
long?d1?=?2366L;
long?result?=?d1?/?3;?//?正確的值本應(yīng)該為788.6666666666666,舍去所有小數(shù),最終值為788
如果要進(jìn)行四舍五入等其他小數(shù)操作,則運(yùn)算時(shí)先進(jìn)行浮點(diǎn)數(shù)運(yùn)算,然后再轉(zhuǎn)換成整數(shù):
long?d1?=?2366L;
double?result?=?d1?/?3.0;?//?注意,這里除以不是?3,而是?3.0?浮點(diǎn)數(shù)
d1?=?(long)Math.round(result);?//?四射勿入,最終值為789,即7.89元
雖說數(shù)據(jù)庫存儲(chǔ)和代碼運(yùn)算都是整數(shù),但前端顯示時(shí)若還是以分為單位就對(duì)用戶不太友好了。所以后端將值傳遞給前端后,前端需要自行將值除以 100,以元為單位展示給用戶。然后前端傳值給后端時(shí),還是以約定好的整數(shù)傳遞。
收尾關(guān)于金額處理就講解完畢了。我們學(xué)會(huì)了兩個(gè)商業(yè)計(jì)算方案:
- Decimal 類型
- 定長整數(shù)
其實(shí)商業(yè)計(jì)算并沒有什么技術(shù)難度,但如果沒有正確處理則會(huì)導(dǎo)致難以估量的損失,畢竟和錢相關(guān)的事都不是小事。
本文為了方便大家理解,所以省略了前后端聯(lián)調(diào)以及數(shù)據(jù)庫操作的內(nèi)容。但既然是項(xiàng)目實(shí)踐,那就得有一個(gè)完整項(xiàng)目,所以本螃蟹基于 Spring Boot 搭建了一個(gè)完整的 Web 項(xiàng)目,數(shù)據(jù)庫操作和接口都已寫好,SQL 語句也有,Github 倉庫地址:
https://github.com/RudeCrab/rude-java
克隆下來即可感受在真實(shí)項(xiàng)目中如何運(yùn)用的本文知識(shí)。倉庫中還有許多其他項(xiàng)目實(shí)踐,涵蓋各個(gè)業(yè)務(wù)各個(gè)功能,其中一些模塊的質(zhì)量甚至可以單開一個(gè)倉庫,讓你再也不用尋找各個(gè)框架 Demo 和腳手架。歡迎 star,螃蟹會(huì)更新更多項(xiàng)目實(shí)踐的!
最近掘金弄了一個(gè)投票活動(dòng),希望大家能為我投上幾票。掃碼或者點(diǎn)擊原文即可參與投票,可以投多次哦~謝謝大家了
(左下角點(diǎn)擊原文也可參與投票)
