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

以項目驅動學習,以實踐檢驗真知前端

前言

不少系統都有「處理金額」的需求,好比電商系統、財務系統、收銀系統,等等。只要和錢扯上關係,就不得不打起十二萬分精神來對待,一分一毫都不能出錯,不然對系統和用戶來講都是災難。java

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

溢出問題你們都知道如何解決,選擇位數長的數值類型便可,即不用 floatdouble 。而精度問題,double 就沒法解決了,由於浮點數會致使精度丟失。github

咱們來直觀感覺一下精度丟失:數據庫

double money = 1.0 - 0.9;
複製代碼

這個運算結果誰都知道該爲 0.1,然而實際結果倒是 0.09999999999999998。出現這個現象是由於計算機底層是二進制運算,而二進制並不能精準表示十進制小數。因此在商業計算等精確計算中要使用其餘數據類型來保證精度不丟失,必定不要使用浮點數。編程

本螃蟹接下來會詳細講解在實際開發中到底該怎樣進行商業計算,並將全部代碼和 SQL 語句放在了 Github 上,克隆下來便可運行。後端

解決方案

有兩種數據類型能夠知足商業計算的需求,第一個天然是專爲商業計算而設計的 Decimal 類型,第二個則是定長整數微信

Decimal

關於數據類型的選擇,一要考慮數據庫,二要考慮編程語言。即數據庫中用什麼類型來存儲數據,代碼中用什麼類型來處理數據markdown

數據庫層面天然是用 decimal 類型,由於該類型不存在精度損失的狀況,用它來進行商業計算再合適不過。app

將字段定義爲 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」微信公衆號,和螃蟹一塊兒橫行霸道。

相關文章
相關標籤/搜索