【项目实践】商业计算怎样才能保证精度不丢失

以项目驱动学习,以实践检验真知前端

前言

不少系统都有「处理金额」的需求,好比电商系统、财务系统、收银系统,等等。只要和钱扯上关系,就不得不打起十二万分精神来对待,一分一毫都不能出错,不然对系统和用户来讲都是灾难。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」微信公众号,和螃蟹一块儿横行霸道。

相关文章
相关标签/搜索