double 算钱算出误差:一笔分账对不上账的复盘

财务反馈营销活动结算时平台总账和商户分账总对不上,每天差几毛到几块。排查根因是一段分账代码用 double 在算钱。几天治理:认清浮点为何不精确、改用 BigDecimal 字符串构造、除法指定精度与舍入、比较用 compareTo、金额用 DECIMAL 或分 long 建模、加金额守恒断言。

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 天

避坑清单

  1. float / double 是二进制浮点数,无法精确表示十进制小数,绝不能用来算钱
  2. 0.1 + 0.2 != 0.3,误差会随加减乘除不断累积,订单量越大差额越明显
  3. 金额计算只用 BigDecimal,或用最小货币单位(分)以整数 long 计算
  4. 构造 BigDecimal 用字符串,new BigDecimal(0.1) 会把 double 的误差带进来
  5. BigDecimal 不可变,所有运算返回新对象,必须接住返回值
  6. divide 除不尽不指定精度会抛异常,必须传保留位数和 RoundingMode
  7. 分账除不尽产生的尾差要显式补给某一方,保证各方之和等于总额
  8. 比较大小用 compareTo,equals 会连 scale 一起比,1.0 不等于 1.00
  9. 数据库金额字段用 DECIMAL,绝不能用 FLOAT/DOUBLE,同样会丢精度
  10. 关键计算加金额守恒断言,让错误立刻暴露,不要等到对账日才发现

总结

这次对账差几毛钱的事故,技术上其实非常"小",根因就是一句"不要用 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

线程池踩坑:无界队列把堆撑爆,一次 OOM 宕机的复盘

2026-5-20 12:57:09

技术教程

Full GC 频繁:接口每隔几分钟卡顿一次的排查复盘

2026-5-20 13:02:21

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索