Skip to content

对账差了一分钱 ​

月底对账,订单系统与支付渠道的金额差了 0.01 元。往下查,是一笔用了优惠券的订单分三次退款,三次的退款金额加起来比用户实际付的多一分。代码里用的是 BigDecimal,每一步都写了 setScale(2, RoundingMode.HALF_UP)。

用了 BigDecimal、每一步都四舍五入,金额仍然会差。原因分三层:小数是怎么构造出来的,按什么规则舍入,以及多个舍入过的数加在一起还等不等于原来的总数。前两层是单个数值的问题,第三层是多个数值之间的问题,也是最容易漏掉的。

实验见文末,全部是确定性计算。

把 0.05 元平分给 10 方(每份精确值 0.005)各自四舍五入0.010.010.010.010.010.010.010.010.010.01合计 0.10最后一份拿余数0.010.010.010.010.010.010.010.010.01-0.04合计 0.05最大余数法0.010.010.010.010.010.000.000.000.000.00合计 0.053 件 33.33 元的商品用了 10.00 元优惠,实付 89.99 元:每件退多少退款时按比例现算30.0030.0030.00三件都退:90.00,比实付多 0.01下单时分好并保存30.0030.0029.99三件都退:89.99舍入规则只能保证单个数值的误差不超过半个最小单位,不保证多个数值的和需要「各份之和等于总额」时,这个约束要由分摊算法保证,并把分摊结果存下来之后的退款、对账都读保存的分摊结果,不重新计算
图 1 · 实测:把一笔钱按比例拆给多方,每一份单独舍入之后,各份的和不一定等于原来的金额。让最后一份拿余数能保证合计,但误差全压在一份上,极端时这一份是负数。最大余数法先各自向下取到最小单位,再把剩下的几个最小单位按余数从大到小每份补一个,合计守恒,每份与精确值的差不超过一个最小单位。

一、小数从哪里来 ​

text
new BigDecimal(0.1) = 0.1000000000000000055511151231257827021181583404541015625
new BigDecimal("0.1") = 0.1
BigDecimal.valueOf(0.1) = 0.1

new 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.02.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:

text
(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.53.5-2.52.1-2.1
HALF_UP34-32-2
HALF_EVEN24-22-2
HALF_DOWN23-22-2
UP34-33-3
DOWN23-22-2
CEILING34-23-2
FLOOR23-32-3
  • HALF_UP 是通常说的四舍五入,对正负数对称。
  • HALF_EVEN 在正好一半时取偶数,也叫银行家舍入。
  • CEILING 与 FLOOR 对正负数不对称。退款、冲正用负数表示时,FLOOR 把 -2.1 变成 -3,方向与正数相反。

HALF_UP 遇到一半总是往上,大量数据累加时有系统性的偏差:

text
1000 笔恰好落在半分上的金额:精确合计 5000.000,逐笔 HALF_UP 后合计 5005.00(多 5.000),逐笔 HALF_EVEN 后合计 5000.00(多 0.000)

这是构造出来的极端情况,每一笔都正好落在半分上。真实数据里落在一半上的比例小得多,偏差也小得多,但方向是固定的。利息、手续费这类按比例算出来又大量累加的金额,采用哪种舍入方式通常由合同或监管规则规定;规则没有规定时,也要在系统里统一成一种,并且写下来。

除法与输出 ​

text
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 里是一个。

三、先舍入还是先求和 ​

text
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_UP0.01 × 10 = 0.10,多了 0.05
0.05 按 5:3:2 拆,各自 HALF_UP0.03 + 0.02 + 0.01 = 0.06,多了 0.01

舍入只保证每一份与精确值的差不超过半个最小单位,份数多了,差的总和可以是好几个最小单位。

一个常见的修补是「前面几份各自舍入,最后一份用总额减去前面的和」。它保证了合计,但误差全压在最后一份上:

text
0.05 平分给 10 方,前 9 份各自舍入、最后一份拿余数:最后一份 = -0.04

前 9 份各拿了 0.01,已经是 0.09,最后一份只能是负的。金额大、份数少时看不出问题,金额小、份数多时(小额红包、按件分摊的运费、积分抵扣)就会出现负数或者明显偏大的一份。

最大余数法能同时保证两点:合计等于总额,每一份与精确值的差不超过一个最小单位。做法是在最小单位上用整数计算:

  1. 每一份先按比例向下取整。
  2. 总额减去各份之和,剩下的是若干个最小单位,个数一定小于份数。
  3. 把这几个最小单位按余数从大到小,每份补一个。
java
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;
}
text
最大余数法,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 元。

text
退款时按比例现算每件 = 30.00 30.00 30.00,三件都退合计 90.00
下单时用最大余数法把实付分到每件并保存 = [30.00, 30.00, 29.99],合计 89.99

每件商品摊到的实付金额精确值是 29.9967 元。退款时现算,每次都舍入成 30.00,退三次就是 90.00,比用户付的多一分。每一次计算单独看都没有错。

实付 89.993 × 33.33 − 优惠 10.00每件精确值 29.9967退款时按比例现算30.0030.0030.00合计 90.00多退 0.01每一次单独看都没错,三次独立舍入,没有人保证和等于实付下单时分摊并保存(最大余数法)30.0030.0029.99合计 89.99与实付一致退款、开票、对账、分账都读明细上存下来的金额,只读不算凡是需要「各部分之和等于整体」的金额,在产生的那一刻分摊并持久化
图 2 · 实测:3 件 33.33 元的商品用了 10.00 元优惠,实付 89.99 元,每件摊到的精确值是 29.9967 元。退款时现算,每次都舍入成 30.00,三次退款合计 90.00,比实付多一分;下单时用最大余数法分好并存进明细,退款、开票、对账都读这个字段,合计 89.99

问题在于「每件值多少」被算了三次,每次独立舍入,没有任何一处保证三次的和等于实付。下单时一次性分好,把每件摊到的金额存进订单明细,之后的退款、开票、对账、分账都读这个字段。部分退款同理:退掉的是明细上记录的金额,不是按比例重新算出来的。

推而广之:凡是需要「各部分之和等于整体」的金额,在产生的那一刻分摊并持久化,之后只读不算。

六、最小货币单位不都是两位 ​

text
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 保留两位的几种写法、浮点金额转成分、七种舍入方式与累计偏差、除法与输出、舍入顺序、三种拆分做法、分次退款、货币小数位(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权