2024 年我们财务侧反馈:某个营销活动结算时,平台总账和商户分账总是对不上,每天差几毛到几块钱不等,金额不大,但财务一分钱都不能差。排查了两天,根因小得让人哭笑不得 —— 一段计算分账金额的代码,用 double 在算钱。投了几天做"金额计算"专项治理,本文复盘这次浮点精度踩坑实战。
问题背景
业务:营销活动分账,平台按比例给商户结算佣金
事故现象:
- 每日对账:平台支出总额 ≠ 各商户到账总额之和
- 差额不固定,几毛到几块,且随订单量增大而变大
- 单笔看都"差不多对",汇总起来就是不对
现场复现:
# 在 jshell 里跑一下出问题的计算
jshell> 0.1 + 0.2
$1 ==> 0.30000000000000004 <- 不是 0.3!
jshell> 1.0 - 0.9
$2 ==> 0.09999999999999998 <- 不是 0.1!
jshell> 0.3 * 100
$3 ==> 30.000000000000004 <- 算佣金时这个误差被放大
# 出问题的分账代码:
double orderAmount = 99.9; // 订单金额
double rate = 0.06; // 佣金比例 6%
double commission = orderAmount * rate; // = 5.994000000000001
// 每笔都带一点点误差,几十万笔订单累加,差额就出来了
根因:
1. float / double 是【二进制浮点数】,无法精确表示大多数十进制小数
2. 0.1 在二进制里是无限循环小数,只能存一个近似值
3. 金额计算涉及大量加减乘除,每步的微小误差会不断累积
4. 团队习惯性用 double 存金额、算金额 —— 这是金融计算的大忌
修复 1:认清浮点数为什么算不准
// === 为什么 0.1 + 0.2 != 0.3 ===
// double 遵循 IEEE 754,用【二进制】科学计数法表示数字。
// 十进制的 0.1,换算成二进制是:
// 0.0001100110011001100110011...(1001 无限循环)
// double 只有 52 位尾数,存不下无限循环,只能【截断】成近似值。
// 所以你存进去的 0.1,其实是一个非常接近 0.1 但不等于 0.1 的数。
System.out.println(0.1 + 0.2); // 0.30000000000000004
System.out.println(0.1 + 0.2 == 0.3); // false
// === 用 BigDecimal 可以看清这个"近似值"到底是多少 ===
System.out.println(new BigDecimal(0.1));
// 0.1000000000000000055511151231257827021181583404541015625
// 看,double 里的 0.1 根本不是 0.1。
// === 整数运算为什么没事 ===
// double 能精确表示整数(在 2^53 范围内),
// 所以"用分(整数)算钱"不会有这个问题 —— 这是后面方案之一。
// === 结论 ===
// 任何涉及钱、涉及精确小数的计算,绝对不能用 float / double。
// 它们是为科学计算设计的,追求的是"范围大",不是"绝对精确"。
// 金额计算只有两个正确选择:BigDecimal,或者用整数(分)来算。
修复 2:用 BigDecimal,但别踩构造器的坑
// === 坑:用 double 构造 BigDecimal,等于把误差也带进来了 ===
BigDecimal bad = new BigDecimal(0.1);
// bad = 0.1000000000000000055511151231257827021181583404541015625
// 你以为存了 0.1,其实存了 double 那个不精确的近似值。
// === 正确:用 String 构造,或用 BigDecimal.valueOf ===
BigDecimal good1 = new BigDecimal("0.1"); // 精确的 0.1
BigDecimal good2 = BigDecimal.valueOf(0.1); // 内部走 Double.toString,也是 0.1
// 推荐统一用字符串构造,最直观、最不会错。
// === 重新写分账计算 ===
BigDecimal orderAmount = new BigDecimal("99.9");
BigDecimal rate = new BigDecimal("0.06");
BigDecimal commission = orderAmount.multiply(rate);
// commission = 5.994,精确,没有那个 ...001 的尾巴
// === BigDecimal 是不可变的,运算返回新对象 ===
BigDecimal a = new BigDecimal("10.00");
a.add(new BigDecimal("5.00")); // 错!返回值没接住,a 还是 10.00
BigDecimal b = a.add(new BigDecimal("5.00")); // 对,b = 15.00
// 和 String 一样,所有运算方法都返回新对象,原对象不变。
// === 加减乘是精确的,不用操心精度;除法是另一回事(见修复 3)===
BigDecimal sum = a.add(new BigDecimal("0.01")); // 精确
BigDecimal diff = a.subtract(new BigDecimal("0.01")); // 精确
BigDecimal prod = a.multiply(new BigDecimal("3")); // 精确
修复 3:除法必须指定精度和舍入模式
// === 坑:divide 不带参数,除不尽直接抛异常 ===
BigDecimal x = new BigDecimal("10");
BigDecimal y = new BigDecimal("3");
BigDecimal z = x.divide(y);
// 抛 ArithmeticException: Non-terminating decimal expansion;
// no exact representable decimal result.
// 10/3 = 3.333... 无限循环,BigDecimal 不知道你要保留几位,只能报错。
// === 正确:divide 必须指定【保留位数】和【舍入模式】 ===
BigDecimal z2 = x.divide(y, 2, RoundingMode.HALF_UP);
// z2 = 3.33,保留 2 位,四舍五入
// === 常用舍入模式 ===
// RoundingMode.HALF_UP 四舍五入(最常用,2.5 -> 3)
// RoundingMode.HALF_EVEN 银行家舍入(2.5 -> 2,3.5 -> 4,减小统计偏差)
// RoundingMode.DOWN 直接截断(2.9 -> 2)
// RoundingMode.UP 远离零进位(2.1 -> 3)
// RoundingMode.FLOOR 向下取整 / CEILING 向上取整
// 金额对外结算多用 HALF_UP;大批量统计场景可考虑 HALF_EVEN。
// === 分账里"除不尽的那一分钱"怎么办 —— 必须有人兜底 ===
// 100 元按 3 个商户均分:每人 33.33,3*33.33 = 99.99,少了 1 分。
BigDecimal total = new BigDecimal("100.00");
int n = 3;
BigDecimal each = total.divide(new BigDecimal(n), 2, RoundingMode.DOWN);
// each = 33.33
BigDecimal allocated = each.multiply(new BigDecimal(n)); // 99.99
BigDecimal remainder = total.subtract(allocated); // 0.01
// 把这 1 分钱的尾差,补给最后一个(或第一个)商户:
// 商户1: 33.33 商户2: 33.33 商户3: 33.33 + 0.01 = 33.34
// 关键:分账后各方金额之和【必须等于】总额,尾差不能凭空消失。
修复 4:比较大小用 compareTo,不要用 equals
// === 坑:BigDecimal 的 equals 会比较【精度(scale)】 ===
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.00");
System.out.println(a.equals(b)); // false! 值相等但 scale 不同
// a 的 scale=1,b 的 scale=2,equals 认为它们不相等。
// === 正确:用 compareTo,只比值,不比精度 ===
System.out.println(a.compareTo(b) == 0); // true,值相等
// === 判断是否为 0,也用 compareTo ===
BigDecimal zero = new BigDecimal("0.00");
// if (zero.equals(BigDecimal.ZERO)) ... // 错,scale 不同会 false
if (zero.compareTo(BigDecimal.ZERO) == 0) { // 对
log.info("金额为 0");
}
// === 判断正负、取绝对值 ===
BigDecimal amount = new BigDecimal("-5.00");
amount.signum(); // 返回 -1/0/1,判正负
amount.abs(); // 取绝对值
amount.negate(); // 取相反数
// === 统一精度,避免到处 scale 不一致 ===
// 金额入库 / 对外输出前,统一 setScale 到 2 位
BigDecimal normalized = amount.setScale(2, RoundingMode.HALF_UP);
// 团队约定:所有金额字段统一 2 位小数,从源头消除 scale 分歧。
修复 5:金额的存储与建模
// === 方案 A:数据库用 DECIMAL,Java 用 BigDecimal(推荐,可读性好)===
// 建表:
// amount DECIMAL(15,2) NOT NULL DEFAULT 0.00
// -- 千万别用 FLOAT/DOUBLE 存金额!数据库浮点同样不精确
// 实体类:
// private BigDecimal amount;
// MyBatis 的 jdbcType=DECIMAL,会精确映射,不丢精度。
// === 方案 B:用最小货币单位(分)存,类型用 long(性能好,不会错)===
// 数据库:amount_fen BIGINT -- 99.90 元存成 9990
// 所有计算都用整数(long)做,加减乘绝对精确,不存在浮点问题。
public class Money {
private final long cents; // 内部以"分"存储
public static Money ofYuan(String yuan) {
BigDecimal fen = new BigDecimal(yuan)
.movePointRight(2) // 元 -> 分
.setScale(0, RoundingMode.HALF_UP);
return new Money(fen.longValueExact());
}
public Money add(Money o) { return new Money(this.cents + o.cents); }
public Money subtract(Money o){ return new Money(this.cents - o.cents); }
// 展示时再转回元
public String toYuanString() {
return BigDecimal.valueOf(cents, 2).toPlainString(); // 9990 -> 99.90
}
}
// 大型支付系统多用方案 B:long 运算快、天然精确,
// 只在展示和对外接口层做"分 ↔ 元"的转换。
// === 序列化的坑:BigDecimal 转 JSON 可能变科学计数法 ===
// new BigDecimal("0.0000001").toString() -> "1E-7"
// 对外输出务必用 toPlainString(),避免前端拿到 "1E-7" 这种值。
// Jackson 可配 WRITE_BIGDECIMAL_AS_PLAIN 全局规避。
修复 6:金额计算的防御与对账
// === 关键计算加断言:分账后总额必须守恒 ===
public void splitCommission(BigDecimal total, List<Merchant> merchants) {
BigDecimal allocated = BigDecimal.ZERO;
for (Merchant m : merchants) {
BigDecimal part = calcPart(total, m);
m.setCommission(part);
allocated = allocated.add(part);
}
// 兜底断言:分出去的钱之和,必须严格等于总额
if (allocated.compareTo(total) != 0) {
// 这里绝不能放过,宁可让流程失败,也不能让账对不上
throw new IllegalStateException(
"分账金额不守恒: total=" + total + " allocated=" + allocated);
}
}
// 金额计算最危险的不是报错,而是"悄悄算错还入库了"。
// 在关键节点加金额守恒校验,让错误【立刻暴露】而不是流到对账日。
# 金额 / 对账监控告警
groups:
- name: money-reconcile
rules:
# 1. 每日对账差额(平台支出 vs 商户到账)
- alert: ReconcileAmountMismatch
expr: abs(platform_payout_total - merchant_received_total) > 0
for: 1m
annotations:
summary: "对账差额 {{ $value }} 元,立即排查金额计算"
# 2. 分账金额不守恒异常计数(代码里抛出的断言)
- alert: SplitAmountNotConserved
expr: increase(split_amount_error_total[10m]) > 0
annotations:
summary: "出现分账金额不守恒,有计算逻辑错误"
# 3. 出现负数金额(多数业务金额不该为负)
- alert: NegativeAmount
expr: increase(negative_amount_total[5m]) > 0
annotations:
summary: "检测到负数金额,排查计算或数据来源"
优化效果
指标 治理前 治理后
=============================================================
每日对账差额 几毛到几块 0
金额计算类型 double BigDecimal / long(分)
BigDecimal 构造 new BigDecimal(double) 字符串构造
除法 未指定精度(隐患) 指定位数 + HALF_UP
金额比较 equals(精度敏感) compareTo
分账尾差 凭空消失/多出 显式补给一方,守恒
金额守恒校验 无 关键节点断言
数据库金额字段 部分用 FLOAT 统一 DECIMAL(15,2)
治理过程:
- 全量排查金额相关 double 用法(37 处):2 天
- 改 BigDecimal + 字符串构造 + 除法精度:2 天
- 封装 Money 值对象,核心链路改用分(long):2 天
- 分账守恒断言 + 对账监控:1 天
- 历史数据修正与回归对账:1 天
避坑清单
- float / double 是二进制浮点数,无法精确表示十进制小数,绝不能用来算钱
- 0.1 + 0.2 != 0.3,误差会随加减乘除不断累积,订单量越大差额越明显
- 金额计算只用 BigDecimal,或用最小货币单位(分)以整数 long 计算
- 构造 BigDecimal 用字符串,new BigDecimal(0.1) 会把 double 的误差带进来
- BigDecimal 不可变,所有运算返回新对象,必须接住返回值
- divide 除不尽不指定精度会抛异常,必须传保留位数和 RoundingMode
- 分账除不尽产生的尾差要显式补给某一方,保证各方之和等于总额
- 比较大小用 compareTo,equals 会连 scale 一起比,1.0 不等于 1.00
- 数据库金额字段用 DECIMAL,绝不能用 FLOAT/DOUBLE,同样会丢精度
- 关键计算加金额守恒断言,让错误立刻暴露,不要等到对账日才发现
总结
这次对账差几毛钱的事故,技术上其实非常"小",根因就是一句"不要用 double 算钱",但它带给我的反思并不小。事情的迷惑性在于:用 double 算出来的金额,单笔去看几乎都是"对的"或者"差不多对的",99.9 乘以 0.06,你得到 5.994000000000001,肉眼一扫就觉得没问题,谁会去较真那个小数点后第十几位的尾巴呢?可金融计算最不能容忍的恰恰是"差不多"——这个微不足道的尾巴,在几十万笔订单的累加里慢慢攒起来,最后就变成了对账单上那个刺眼的、对不上的差额。所以第一条铁律必须刻在脑子里:任何和钱、和精确小数有关的计算,float 和 double 都是禁区,它们是为科学计算设计的、追求的是表示范围而不是绝对精确,十进制的 0.1 在二进制世界里压根就是个无限循环小数,double 存进去的从来都只是一个近似值。正确的路只有两条:要么用 BigDecimal,要么干脆把金额换算成最小单位"分",用整数 long 去算。如果选 BigDecimal,还有一连串细节不能掉以轻心——构造时一定要用字符串而不是 double,否则你只是把 double 的误差原封不动搬了进来;除法除不尽时必须显式告诉它保留几位、怎么舍入,否则它会直接抛异常;比较大小要用 compareTo 而不是 equals,因为 equals 连精度都要比,1.0 和 1.00 在它眼里是两个不同的值。如果选"用分算",那就把金额封装成一个 Money 值对象,内部全程用 long,只在展示和对外接口层做元和分的转换,这样运算既快又天然精确。但比具体用哪种类型更重要的,是这次复盘让我真正记住的一个工程习惯:金额计算最可怕的从来不是抛异常——抛异常你立刻就知道了——最可怕的是它悄无声息地算错了一点点,然后心安理得地写进了数据库,一直潜伏到月底对账才暴露。对付这种"沉默的错误",唯一的办法就是在每一个关键节点上加校验,比如分账之后立刻断言"分出去的每一份之和必须严格等于总额",一旦不守恒就让整个流程当场失败。让错误在发生的那一刻就尖叫出来,而不是等它酿成一张对不上的账单,这才是处理金钱这种零容错数据时,真正该有的敬畏心。
—— 别看了 · 2026