测试提了个单:0.06 的账对不上
入职第三个月,我接手了个结算的小需求。上线第二天测试同学甩过来一张截图,说订单金额 0.06 元的对账差了 1 分钱。我当时还嘴硬:"double 怎么可能算错?"然后自己跑了一遍。
System.out.println(0.1 + 0.2); // 0.30000000000000004
System.out.println(1.0 - 0.42); // 0.5800000000000001
System.out.println(4.015 * 100); // 401.49999999999994
System.out.println(123.3 / 100); // 1.2329999999999999
脸有点疼。
为什么 double 算不准
double 是二进制浮点数,遵循 IEEE 754。问题在于:0.1 这种在我们看来很整的数字,在二进制里是无限循环小数,就像十进制里 1/3 = 0.3333… 一样,double 只有 53 位有效数字,存不下就只能截断,误差从这一刻就埋下了。
所以 float/double 天生不适合做金额计算。金融场景一律用 BigDecimal。
第一个坑:new BigDecimal(0.1)
我兴冲冲地把 double 全换成 BigDecimal,结果第一版还是错的:
BigDecimal a = new BigDecimal(0.1);
System.out.println(a); // 0.1000000000000000055511151231257827021181583404541015625
打印出来一长串。原因很简单:new BigDecimal(double) 是把那个本来就不精确的 double 值原封不动地转成 BigDecimal,误差不但没消失,还被精确记录下来了。
正确做法是用 String 构造:
BigDecimal a = new BigDecimal("0.1");
BigDecimal b = new BigDecimal("0.2");
System.out.println(a.add(b)); // 0.3
// 或者用 valueOf,它内部走的也是 Double.toString
BigDecimal c = BigDecimal.valueOf(0.1); // 0.1
我们组后来定了个规矩:金额字段一律用 String 或 BigDecimal 入参,禁止 new BigDecimal(double)。前端传上来的 JSON 里金额也要求用字符串,避免 Jackson 反序列化时走 double 中转。
第二个坑:除法不指定精度和舍入模式
这个坑我踩得最惨。上线后日志里蹦出来一坨:
java.lang.ArithmeticException: Non-terminating decimal expansion;
no exact representable decimal result.
at java.math.BigDecimal.divide(BigDecimal.java:1690)
原因:1 除以 3 是无限小数,BigDecimal 非要精确表示,又表示不了,就抛异常了。必须指定精度和舍入模式:
// 错误:可能抛 ArithmeticException
a.divide(b);
// 正确
a.divide(b, 2, RoundingMode.HALF_UP);
舍入模式这里也有讲究。RoundingMode.HALF_UP 就是我们熟悉的四舍五入,国内业务基本用它。HALF_EVEN 是银行家舍入(四舍六入五取偶),统计上更公平,但跟财务对账时容易被质疑,除非对方明确要求否则别用。还有个 DOWN,直接截断,一般用在优惠金额上——往对商家有利的方向舍。
第三个坑:用 equals 比较金额
这是我 debug 了半小时才反应过来的:
BigDecimal x = new BigDecimal("1.0");
BigDecimal y = new BigDecimal("1.00");
System.out.println(x.equals(y)); // false !!!
System.out.println(x.compareTo(y)); // 0
BigDecimal 的 equals 被重写过,它同时比较值和小数位数(scale)。1.0 的 scale 是 1,1.00 的 scale 是 2,所以 equals 返回 false。判断金额相等必须用 compareTo() == 0。
我们线上出过一次事故:优惠券的满减门槛判断用了 equals,数据库里存的 100.00 和代码里写的 100(scale=0)比不出来,导致满 100 减 20 的券死活不生效。改成 compareTo 之后就好了。
我最后沉淀的工具方法
public final class MoneyUtil {
private static final int SCALE = 2;
public static BigDecimal add(String a, String b) {
return new BigDecimal(a).add(new BigDecimal(b)).setScale(SCALE, RoundingMode.HALF_UP);
}
public static BigDecimal divide(String a, String b) {
return new BigDecimal(a).divide(new BigDecimal(b), SCALE, RoundingMode.HALF_UP);
}
public static boolean isEqual(BigDecimal a, BigDecimal b) {
return a.compareTo(b) == 0;
}
}
还有一点,BigDecimal.ZERO、BigDecimal.ONE 这些常量可以直接用,别每次 new BigDecimal("0")。做累加的时候初始值用 ZERO,我以前写 new BigDecimal(0),正好又踩了 double 构造那个坑。
另外数据库字段也别偷懒用 float/double,DECIMAL(18,2) 是标配。我见过有人用 double 存余额,MySQL 5.7 里 double 依然是不精确类型,查出来的 SUM 和 Java 里算的对不上,排查起来很痛苦。
还有两个小坑
toString 可能会输出科学计数法
金额特别小或者特别大的时候,BigDecimal 的 toString() 会用科学计数法,直接返回给前端会出问题:
System.out.println(new BigDecimal("0.000000123").toString());
// 1.23E-7
System.out.println(new BigDecimal("0.000000123").toPlainString());
// 0.000000123
给前端返回金额时统一用 toPlainString()。我们有个汇率接口返回了 1.23E-7,前端 JSON.parse 之后当成字符串处理,页面上直接显示成了 "1.23E-7"。
setScale 返回的是新对象
BigDecimal 是不可变的,所有运算方法都返回新实例,不会改原来的对象。我刚用的时候写过这种无效代码:
BigDecimal total = new BigDecimal("100.5678");
total.setScale(2, RoundingMode.HALF_UP); // 这行没有任何效果
System.out.println(total); // 100.5678
total = total.setScale(2, RoundingMode.HALF_UP); // 要接住返回值
System.out.println(total); // 100.57
这个错误编译器不会报错,看着也像对的,我在自测的时候才发现金额没四舍五入。后来我给自己定了个规矩:调用 BigDecimal 的任何方法,都要问一句"返回值接住了吗"。
就写到这。如果哪天你也被《BigDecimal 金额计算踩坑:0.1 + 0.2 与精度丢失》里同一个坑绊住,回来翻这篇,能省半小时。