【專案實踐】商業計算怎樣才能保證精度不丟失

RudeCrab發表於2021-01-27

商業計算.png

以專案驅動學習,以實踐檢驗真知

前言

很多系統都有「處理金額」的需求,比如電商系統、財務系統、收銀系統,等等。只要和錢扯上關係,就不得不打起十二萬分精神來對待,一分一毫都不能出錯,否則對系統和使用者來說都是災難。

保證金額的準確性主要有兩個方面:溢位精度。溢位是指儲存資料的空間得充足,不能金額較大就儲存不下了。精度是指計算金額時不能有偏差,多一點少一點都不行。

溢位問題大家都知道如何解決,選擇位數長的數值型別即可,即不用 floatdouble 。而精度問題,double 就無法解決了,因為浮點數會導致精度丟失。

我們來直觀感受一下精度丟失:

double money = 1.0 - 0.9;

這個運算結果誰都知道該為 0.1,然而實際結果卻是 0.09999999999999998。出現這個現象是因為計算機底層是二進位制運算,而二進位制並不能精準表示十進位制小數。所以在商業計算等精確計算中要使用其他資料型別來保證精度不丟失,一定不要使用浮點數。

本螃蟹接下來會詳細講解在實際開發中到底該怎樣進行商業計算,並將所有程式碼和 SQL 語句放在了 Github 上,克隆下來即可執行。

解決方案

有兩種資料型別可以滿足商業計算的需求,第一個自然是專為商業計算而設計的 Decimal 型別,第二個則是定長整數

Decimal

關於資料型別的選擇,一要考慮資料庫,二要考慮程式語言。即資料庫中用什麼型別來儲存資料,程式碼中用什麼型別來處理資料

資料庫層面自然是用 decimal 型別,因為該型別不存在精度損失的情況,用它來進行商業計算再合適不過。

將欄位定義為 decimal 的語法為 decimal(M,N)M 代表儲存多少位,N 代表小數儲存多少位。假設 decimal(20,2),則代表一共儲存 20 位數值,其中小數佔 2 位。

我們新建一張使用者表,欄位很簡單就兩個,主鍵和餘額:

balance.png

這裡小數位置保留 2 點,代表金額只儲存到,實際專案中儲存到什麼單位得根據業務需求來定,都是可以的。

資料庫層面搞定了我們們來看程式碼層面,在 Java 中對應資料庫 decimal 的是 java.math.BigDecimal型別,它自然也能保證精度完全準確。

要建立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)

前面兩個是建構函式,後面一個是靜態方法。這三種方法都非常方便,但第一種方法禁止使用!看一下這三個物件各自的列印結果就知道為什麼了:

d1: 0.1000000000000000055511151231257827021181583404541015625
d2: 0.1
d3: 0.1

第一種方法通過建構函式傳入 double 型別的引數並不能精確地獲取到值,若想正確的建立 BigDecimal,要麼將 double 轉換為字串然後呼叫構造方法,要麼直接呼叫靜態方法。事實上,靜態方法內部也是將 double 轉換為字串然後呼叫的構造方法:

static.png

如果是從資料庫中查詢出小數值,或者前端傳遞過來小數值,資料會準確對映成 BigDecimal 物件,這一點我們不用操心。

說完建立,接下來就要說最重要的數值運算。運算無非就是加減乘除,這些 BigDecimal 都提供了對應的方法:

BigDecimal add(BigDecimal); // 加
BigDecimal subtract(BigDecimal); // 減
BigDecimal multiply(BigDecimal); // 乘
BigDecimal divide(BigDecimal); // 除

BigDecimal 是不可變物件,意思就是這些操作都不會改變原有物件的值,方法執行完畢只會返回一個新的物件。若要運算後更新原有值,只能重新賦值:

d1 = d1.subtract(d2);

口說無憑,我們來驗證一下精度是否會丟失 :

BigDecimal d1 = new BigDecimal("1.0");
BigDecimal d2 = new BigDecimal("0.9");
System.out.println(d1.subtract(d2));

輸出結果毫無疑問為 0.1

程式碼方面已經能保證精度不會丟失,但數學方面除法可能會出現除不盡的情況。比如我們運算 10 除以 3,會丟擲如下異常:

ArithmeticException.png

為了解決除不盡後導致的無窮小數問題,我們需要人為去控制小數的精度。除法運算還有一個方法就是用來控制精度的:

BigDecimal divide(BigDecimal divisor, int scale, int roundingMode)

scale 參數列示運算後保留幾位小數,roundingMode 參數列示計算小數的方式。

BigDecimal d1 = new BigDecimal("1.0");
BigDecimal d2 = new BigDecimal("3");
System.out.println(d1.divide(d2, 2, RoundingMode.DOWN)); // 小數精度為2,多餘小數直接捨去。輸出結果為0.33

RoundingMode 列舉能夠方便地指定小數運算方式,除了直接捨去,還有四捨五入、向上取整等多種方式,根據具體業務需求指定即可。

注意,小數精度儘量在程式碼中控制,不要通過資料庫來控制。資料庫中預設採用四捨五入的方式保留小數精度。

比如資料庫中設定的小數精度為2,我存入 0.335,那麼最終儲存的值就會變為 0.34

我們已經知道如何建立和運算 BigDecimal 物件,只剩下最後一個操作:比較。因為其不是基本資料型別,用雙等號 == 肯定是不行的,那我們來試試用 equals比較:

BigDecimal d1 = new BigDecimal("0.33");
BigDecimal d2 = new BigDecimal("0.3300");
System.out.println(d1.equals(d2)); // false

輸出結果為 false,因為 BigDecimalequals 方法不光會比較值,還會比較精度,就算值一樣但精度不一樣結果也是 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 的用法就介紹到這,我們接下來看第二種解決方案。

定長整數

定長整數,顧名思義就是固定(小數)長度的整數。它只是一個概念,並不是新的資料型別,我們使用的還是普通的整數。

金額好像理所應當有小數,但稍加思考便會發覺小數並非是必須的。之前我們演示的金額單位是1.55 就是一元五角五分。那如果我們單位是,一元五角五分的值就會變成 15.5。如果再將單位縮小到,值就為 155。沒錯,只要達到最小單位,小數完全可以省略!這個最小單位根據業務需求來定,比如系統要求精確到,那麼值就是1550。當然,一般精確到分就可以了,我們們接下來演示單位都是分。

我們們現在新建一個欄位,型別為 bigint,單位為分:

otherBalance.png

程式碼中對應的資料型別自然是 Long。基本型別的數值運算我們是再熟悉不過的了,直接使用運算操作符即可:

long d1 = 10000L; // 100元
d1 += 500L; // 加五元
d1 -= 500L; // 減五元

加和減沒什麼好說的,乘和除可能會出現小數的情況,比如某個商品打八折,運算就是乘以 0.8

long d1 = 2366L; // 23.66元
double result = d1 * 0.8; // 打八折,運算後結果為1892.8
d1 = (long)result; // 轉換為整數,捨去所有小數,值為1892。即18.92元

進行小數運算,型別自然而然就會變為浮點數,所以我們還要將浮點數轉換為整數。

強轉會將所有小數捨去,這個捨去並不代表精度丟失。業務要求最小單位是什麼,就只保留什麼,低於分的單位我們壓根沒必要儲存。這一點和 BigDecimal 是一致的,如果系統中只需要到分,那小數精度就為 2, 剩餘的小數都捨去。

不過有些業務計算可能要求四捨五入等其他操作,這一點我們可以通過 Math類來完成:

long d1 = 2366L; // 23.66元
double result = d1 * 0.8; // 運算後結果為1892.8
d1 = (long)result; // 強轉捨去所有小數,值為1892
d1 = (long)Math.ceil(result); // 向上取整,值為1893
d1 = (long)Math.round(result); // 四捨五入,值為1893
...

再來看除法運算。當整數除以整數時,會自動捨去所有小數:

long d1 = 2366L;
long result = d1 / 3; // 正確的值本應該為788.6666666666666,捨去所有小數,最終值為788

如果要進行四捨五入等其他小數操作,則運算時先進行浮點數運算,然後再轉換成整數:

long d1 = 2366L;
double result = d1 / 3.0; // 注意,這裡除以不是 3,而是 3.0 浮點數
d1 = (long)Math.round(result); // 四射勿入,最終值為789,即7.89元

雖說資料庫儲存和程式碼運算都是整數,但前端顯示時若還是以為單位就對使用者不太友好了。所以後端將值傳遞給前端後,前端需要自行將值除以 100,以為單位展示給使用者。然後前端傳值給後端時,還是以約定好的整數傳遞。

unit.png

收尾

關於金額處理就講解完畢了。我們學會了兩個商業計算方案:

  • Decimal 型別
  • 定長整數

其實商業計算並沒有什麼技術難度,但如果沒有正確處理則會導致難以估量的損失,畢竟和錢相關的事都不是小事。

本文為了方便大家理解,所以省略了前後端聯調以及資料庫操作的內容。但既然是專案實踐,那就得有一個完整專案,所以本螃蟹基於 Spring Boot 搭建了一個完整的 Web 專案,資料庫操作和介面都已寫好,SQL 語句也有,將 Github 倉庫克隆下來即可感受在真實專案中如何運用的本文知識。倉庫中還有許多其他專案實踐,涵蓋各個業務各個功能,其中一些模組的質量甚至可以單開一個倉庫,讓你再也不用尋找各個框架 Demo 和腳手架。歡迎 star,螃蟹會更新更多專案實踐的!

我是「RudeCrab」,一隻粗魯的螃蟹,追求簡單粗暴地講解技術。

關注「RudeCrab」微信公眾號,和螃蟹一起橫行霸道。

相關文章