对账差了一分钱
月底对账,订单系统与支付渠道的金额差了 0.01 元。往下查,是一笔用了优惠券的订单分三次退款,三次的退款金额加起来比用户实际付的多一分。代码里用的是
BigDecimal,每一步都写了setScale(2, RoundingMode.HALF_UP)。
用了 BigDecimal、每一步都四舍五入,金额仍然会差。原因分三层:小数是怎么构造出来的,按什么规则舍入,以及多个舍入过的数加在一起还等不等于原来的总数。前两层是单个数值的问题,第三层是多个数值之间的问题,也是最容易漏掉的。
实验见文末,全部是确定性计算。
一、小数从哪里来
new BigDecimal(0.1) = 0.1000000000000000055511151231257827021181583404541015625
new BigDecimal("0.1") = 0.1
BigDecimal.valueOf(0.1) = 0.1new BigDecimal(double) 忠实地保存了那个 double 的精确值,而 double 本身就存不下 0.1。从字符串构造,或者用 BigDecimal.valueOf(它先把 double 转成最短的十进制字符串),得到的才是字面上的 0.1。
这个差别在舍入时显现出来。把 2.675 保留两位,五种写法:
| 写法 | 结果 |
|---|---|
new BigDecimal(2.675).setScale(2, HALF_UP) | 2.67 |
new BigDecimal("2.675").setScale(2, HALF_UP) | 2.68 |
Math.round(2.675 * 100) / 100.0 | 2.68 |
String.format("%.2f", 2.675) | 2.68 |
new DecimalFormat("0.00").format(2.675) | 2.67 |
double 里的 2.675 实际是 2.67499999999999982…,不到半分,第一行按规则舍掉了。Math.round 这一行得到 2.68 是碰巧:2.675 * 100 在浮点乘法里正好进成了 267.5。换一个数就不成立,Math.round(1.005 * 100) / 100.0 是 1.0,因为 1.005 * 100 是 100.49999999999999。DecimalFormat 的默认舍入方式是 HALF_EVEN,与另外几个都不同。
同一个金额,页面上用 String.format 显示的、导出报表用 DecimalFormat 格式化的、入库前用 BigDecimal 舍入的,可以是三个不同的数。
浮点金额转成分
金额用「分」存成整数是常见的做法。转换这一步如果经过了 double:
(long) (19.99 * 100) = 1998(19.99 * 100 = 1998.9999999999998)
(long) (0.29 * 100) = 28,(long) (4.35 * 100) = 434,(long) (1.15 * 100) = 114
0.00 到 99.99 这一万个金额里,强制转换得到的分数不对的有 573 个
new BigDecimal("19.99").movePointRight(2).longValueExact() = 1999强制转换是截断,不是舍入。一万个金额里错 573 个,测试时随手挑的几个数很可能都是对的。
金额从进入系统的那一刻起就不经过 double:请求里的金额按字符串或整数的最小单位接收,JSON 反序列化时直接解析成 BigDecimal。浮点数的误差是怎么来的,见同一组数,换个顺序求和,结果就不一样了。
二、舍入规则
RoundingMode 有七种常用的取值,差别集中在「正好一半」与负数上:
| 舍入方式 | 2.5 | 3.5 | -2.5 | 2.1 | -2.1 |
|---|---|---|---|---|---|
HALF_UP | 3 | 4 | -3 | 2 | -2 |
HALF_EVEN | 2 | 4 | -2 | 2 | -2 |
HALF_DOWN | 2 | 3 | -2 | 2 | -2 |
UP | 3 | 4 | -3 | 3 | -3 |
DOWN | 2 | 3 | -2 | 2 | -2 |
CEILING | 3 | 4 | -2 | 3 | -2 |
FLOOR | 2 | 3 | -3 | 2 | -3 |
HALF_UP是通常说的四舍五入,对正负数对称。HALF_EVEN在正好一半时取偶数,也叫银行家舍入。CEILING与FLOOR对正负数不对称。退款、冲正用负数表示时,FLOOR把 -2.1 变成 -3,方向与正数相反。
HALF_UP 遇到一半总是往上,大量数据累加时有系统性的偏差:
1000 笔恰好落在半分上的金额:精确合计 5000.000,逐笔 HALF_UP 后合计 5005.00(多 5.000),逐笔 HALF_EVEN 后合计 5000.00(多 0.000)这是构造出来的极端情况,每一笔都正好落在半分上。真实数据里落在一半上的比例小得多,偏差也小得多,但方向是固定的。利息、手续费这类按比例算出来又大量累加的金额,采用哪种舍入方式通常由合同或监管规则规定;规则没有规定时,也要在系统里统一成一种,并且写下来。
除法与输出
new BigDecimal("1").divide(new BigDecimal("3")) 抛出 ArithmeticException:Non-terminating decimal expansion; no exact representable decimal result.
1 ÷ 4 不指定精度 = 0.25(除得尽就不报错)
new BigDecimal("10").divide(new BigDecimal("3"), HALF_UP) = 3;被除数写成 "10.00" = 3.33
new BigDecimal("1234.567").round(new MathContext(2)) = 1.2E+3(MathContext 的 2 是有效数字,不是小数位)
new BigDecimal("100.00").stripTrailingZeros() 的 toString = 1E+2,toPlainString = 100- 不指定精度的
divide在除不尽时抛异常,除得尽时正常返回。测试数据恰好都除得尽,这个异常就留到了线上。 - 只指定舍入方式、不指定小数位的
divide,结果的小数位取被除数的。"10"除出来是 3,"10.00"除出来是 3.33。除法一律写明小数位:divide(x, 2, RoundingMode.HALF_UP)。 MathContext的参数是有效数字的位数。stripTrailingZeros()之后的toString()可能是科学计数法,写进 JSON、CSV 或拼进 SQL 时要用toPlainString()。
BigDecimal 的 equals 把 2.0 和 2.00 当成两个值,见同一个金额,HashSet 里是两个,TreeSet 里是一个。
三、先舍入还是先求和
3 件 0.99 的商品,税率 8.25%:逐行算税再相加 = 0.24,按合计算税 = 0.25每件的税是 0.081675,舍入成 0.08,三件 0.24;先把金额加起来再算税是 0.245025,舍入成 0.25。两种算法都说得通,差一分。
这类分歧通常出在两个系统之间:订单系统逐行计算,财务系统按合计计算;或者明细页逐行显示,汇总页重新算了一遍。哪一个是准的,要定下来,另一个从它推导,不要各算各的。常见的约定是以明细为准,合计等于明细之和;汇总页显示的是求和的结果,不是用合计重新算出来的数。
四、一笔钱拆给多方
把一个金额按比例拆成几份,每份单独舍入:
| 做法 | 结果 |
|---|---|
| 100.00 平分给 3 方,各自舍入 | 33.33 × 3 = 99.99,少了 0.01 |
0.05 平分给 10 方,各自 HALF_UP | 0.01 × 10 = 0.10,多了 0.05 |
0.05 按 5:3:2 拆,各自 HALF_UP | 0.03 + 0.02 + 0.01 = 0.06,多了 0.01 |
舍入只保证每一份与精确值的差不超过半个最小单位,份数多了,差的总和可以是好几个最小单位。
一个常见的修补是「前面几份各自舍入,最后一份用总额减去前面的和」。它保证了合计,但误差全压在最后一份上:
0.05 平分给 10 方,前 9 份各自舍入、最后一份拿余数:最后一份 = -0.04前 9 份各拿了 0.01,已经是 0.09,最后一份只能是负的。金额大、份数少时看不出问题,金额小、份数多时(小额红包、按件分摊的运费、积分抵扣)就会出现负数或者明显偏大的一份。
最大余数法能同时保证两点:合计等于总额,每一份与精确值的差不超过一个最小单位。做法是在最小单位上用整数计算:
- 每一份先按比例向下取整。
- 总额减去各份之和,剩下的是若干个最小单位,个数一定小于份数。
- 把这几个最小单位按余数从大到小,每份补一个。
static long[] allocate(long totalMinor, long[] weights) {
long sum = Arrays.stream(weights).sum(), given = 0;
long[] share = new long[weights.length], rem = new long[weights.length];
for (int i = 0; i < weights.length; i++) {
share[i] = Math.floorDiv(Math.multiplyExact(totalMinor, weights[i]), sum);
rem[i] = Math.floorMod(Math.multiplyExact(totalMinor, weights[i]), sum);
given += share[i];
}
List<Integer> order = new ArrayList<>();
for (int i = 0; i < weights.length; i++) order.add(i);
order.sort(Comparator.comparingLong((Integer i) -> -rem[i]).thenComparingInt(i -> i));
for (int k = 0; k < totalMinor - given; k++) share[order.get(k)]++;
return share;
}最大余数法,100.00 按 1:1:1 = [33.34, 33.33, 33.33],合计 100.00
最大余数法,0.05 按十个 1 = [0.01, 0.01, 0.01, 0.01, 0.01, 0.00, 0.00, 0.00, 0.00, 0.00],合计 0.05
最大余数法,0.05 按 5:3:2 = [0.03, 0.01, 0.01],合计 0.05余数相同时谁先拿,算法本身不规定,上面的实现按原顺序。这是一条业务规则:多出来的一分给第一件商品、给金额最大的一件,还是给平台,要明确写下来,而且保证同样的输入每次得到同样的结果。
五、分摊的结果要存下来
回到开头的那笔订单:3 件 33.33 元的商品,用了 10.00 元的优惠,实付 89.99 元。
退款时按比例现算每件 = 30.00 30.00 30.00,三件都退合计 90.00
下单时用最大余数法把实付分到每件并保存 = [30.00, 30.00, 29.99],合计 89.99每件商品摊到的实付金额精确值是 29.9967 元。退款时现算,每次都舍入成 30.00,退三次就是 90.00,比用户付的多一分。每一次计算单独看都没有错。
问题在于「每件值多少」被算了三次,每次独立舍入,没有任何一处保证三次的和等于实付。下单时一次性分好,把每件摊到的金额存进订单明细,之后的退款、开票、对账、分账都读这个字段。部分退款同理:退掉的是明细上记录的金额,不是按比例重新算出来的。
推而广之:凡是需要「各部分之和等于整体」的金额,在产生的那一刻分摊并持久化,之后只读不算。
六、最小货币单位不都是两位
USD 小数位 2;数据库里存的 1000 个最小单位 = 10.00 USD
JPY 小数位 0;数据库里存的 1000 个最小单位 = 1000 JPY
KWD 小数位 3;数据库里存的 1000 个最小单位 = 1.000 KWD「金额乘以 100 存成整数」隐含了小数位是 2。日元、韩元没有小数,科威特第纳尔、巴林第纳尔是三位。只做人民币业务时这不是问题;一旦接入多币种,写死的 100 和 setScale(2) 会散落在各处。金额与币种放在一起建模,小数位从 Currency.getDefaultFractionDigits() 取,见实体与值对象。
七、检查清单
- 金额不经过
double、float:接收、计算、存储都用BigDecimal(从字符串构造)或整数的最小单位。 - 舍入方式在系统里只有一种,并且写在一处;显示、导出、入库用同一个结果,不各自格式化。
- 除法写明小数位和舍入方式。
- 明细与合计谁推导谁,有明确的约定。
- 拆分用最大余数法之类能保证合计的算法,余数的归属是明确的业务规则。
- 分摊结果持久化,后续流程只读不算。
- 对账时比较的是存下来的数,并且对「差一分」有报警而不是容差。
配套实验
- codesphere-labs/java/money-rounding-and-allocation:JDK 25.0.4.1 上的确定性计算:
BigDecimal的构造、把 2.675 保留两位的几种写法、浮点金额转成分、七种舍入方式与累计偏差、除法与输出、舍入顺序、三种拆分做法、分次退款、货币小数位(验证记录)
参考资料