凌晨一点半,对账群里突然冒出两张截图,财务小姐姐把系统账单和支付渠道账单拉了个差异表,其中一笔订单的实收金额比应收多了一分钱。开发同学的排查结论也很快:代码里用了 double 做金额累加,0.1 + 0.2 这类计算在浮点数里本来就是不精确的,累加了几次之后误差就暴露出来了。
其实这种问题在 Java 后端不算新鲜。但真正让我想写这篇文章的,是每次聊到"那到底用 Long 还是 BigDecimal"时,团队里总能吵出好几派。有人说金额一律 BigDecimal,有人说线上核心链路全部用 Long 存分,还有人拿 "数据库 DECIMAL 是标准答案" 来压场。
我跟过的支付、电商、账务类系统不少,这两种方案都踩过坑。如果你正在做涉及金额计算的后端接口、账务模块或支付对账,这篇文章值得花十几分钟看完。它不讲大道理,只讲我在真实项目里见过的坑、验证过的选型依据,以及最终我如何拍板。
1. 别再用 double 记账:一次对账失败背后的精度真相
1.1 为什么 0.1 + 0.2 不等于 0.3
先看一段人畜无害的代码:
java复制double a = 0.1;
double b = 0.2;
System.out.println(a + b);
输出是 0.30000000000000004。
这几乎是每个 Java 程序员都见过的"名场面"。原因不在 Java 本身,而在 IEEE 754 二进制浮点数的设计:计算机要用二进制去逼近十进制小数,而 0.1 在二进制里是一个无限循环小数,就像十进制里写不出精确的 1/3 一样。
浮点数能精确表示的是 0.5、0.25、0.125 这类可以拆成 2^-n 之和的数。0.1 没法拆成有限个这样的项,所以存储时只能取近似值。单次运算误差可能只有 1e-16 级别,看着无所谓,但金额计算是要累加的。一万笔订单每笔多一分钱,或者每笔少一厘钱,最终对出来的账就是不平的。
1.2 浮点误差在金额场景如何被放大
可能有人说:我只做加减法,不做乘除法,误差是不是就小?不是。加减法也建立在浮点数存储上,误差照样累积。更危险的是"四舍五入前先乘后加"这类写法:
java复制// 反例:先把元转成分,但中间用了 double
long feeFen = (long) (1.23 * 100);
System.out.println(feeFen);
某些机器上输出会是 122,因为 1.23 * 100 可能等于 122.99999999999999,直接强转成 long 就丢了 1 分钱。这种 bug 非常难排查,因为不是每次都会触发,跟具体数值有关,还跟 JDK 版本、CPU 浮点处理有关,属于"偶发性抽风"。
还有一处容易被忽略:金额除以数量计算客单价时,如果用了 double,再配合 BigDecimal.valueOf(double) 做转换,会把那个近似值原样带进去。转换这一步不是为了兜底,它兜不住已经丢失的精度。
所以,float 和 double 在金额领域应该直接被排除在候选项之外。剩下的候选者其实就两个:以最小货币单位为基准的 Long,和专门为十进制精度设计的 BigDecimal。接下来的问题就变成了:这两个之间怎么选,各自的代价是什么。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Long 存"分":整数账本的甜头与暗坑
2.1 用 Long 的前提:你能否定义最小货币单位
Long 方案的核心思想很简单:不在代码里出现"元",而是向下对齐到最小货币单位。人民币是"分",部分国际业务可能是"厘",加密币可能是"聪"。只要整条业务链路都认同同一个最小单位,金额运算就变成了整数运算。
这个方案的卖点很硬:
- 精度绝对精确,不存在二进制近似问题;
- 运算性能极高,Long 加法在 CPU 层面就是一条指令的事;
- 可以放心地做等值比较,
fenA == fenB直接判断; - 序列化、日志、数据库字段都非常简单;
- 很多外部支付渠道的接口文档本来就以"分"为单位,比如微信支付、支付宝的部分接口,字段名就叫
total_fee,底层就是整数。
我在账务系统和积分系统里长期使用过 Long 作为金额主类型,只要团队能守住"全链路用分"这个约定,它真的非常省心。数据库字段用 BIGINT 加注释 单位: 分,DTO 字段命名直接叫 amountFen,一眼就能看明白。
2.2 元转分这个看似无害的乘法
Long 方案最大的坑,普遍出在"元"和"分"之间的换算上。客户端或上游系统传入的是元(字符串或 BigDecimal),后端要存分,这时候如果图省事用 double 过渡,就踩了上一节说的 122 问题。
正确做法是:入参只要是元,就统一用 BigDecimal 或者字符串解析来换算,算完之后再转 long。比如:
java复制// 入参是 String 形式的元
BigDecimal yuan = new BigDecimal("1.23");
long fen = yuan.movePointRight(2).longValueExact();
System.out.println(fen); // 123
这里还有一层容易被忽略:如果入参是用户界面传上来的 double,比如前端 JSON 里写 1.23,反序列化成了 Double,再转 BigDecimal 时就必须用 BigDecimal.valueOf(double) 或者先把 double 转成字符串,绝对不能 new BigDecimal(doubleValue)。
更彻底的做法是:接口协议直接约定金额字段一律传"分",整数类型,后端不做元分换算。这个约定越早定越好,因为一旦前后端、服务端多处混用单位和精度,排查成本会直线上升。
2.3 除法、汇率与跨币种:Integer 思维解决不了的事
Long 方案在"加减"和"乘整数"场景下很舒服,但一旦遇到除法,问题就来了。
最简单的例子:10 元钱由 3 个人均摊,每人应付多少?如果用 long 分做计算,1000L / 3 结果是 333,余下的 1 分钱怎么处理?直接丢弃会让总账不平,需要引入"最大余额法"或者"最后一笔兜底"这类分摊规则。规则不是不能写,但每个业务都要单独设计,团队容易漏。
更麻烦的是折扣、税率、汇率这类场景。计算"含税价 = 原价 * 税率"时,税率本身可能是 0.06 这种小数。如果只有 Long,你必须把税率也变成整数(比如 6 / 100),然后再处理除不尽的问题。乘除混合之后,整数溢出和舍入规则会交织在一起,代码很快就变成一团浆糊。
所以我的经验是:如果金额计算只涉及加减、整数倍、累加,Long 很合适;一旦涉及小数乘法、除法、比例分摊、汇率转换,Long 就不是省心方案,而是负担。特别是跨境业务,不同币种的最小单位还不一样,USD 是 2 位小数,BHD(巴林第纳尔)是 3 位小数,用 Long 强行统一单位会让你在币种维度上写一堆特殊分支。
另外,Long 乘法溢出也是真实存在的风险。金额本身可能不大,但乘以一个大汇率或者一个大的折扣系数,就可能越过 Long.MAX_VALUE。Java 8 之后提供了 Math.multiplyExact,溢出时会抛异常,但前提是你记得用。如果你在代码里直接写 *,溢出后金额会变成一个奇怪的负数,对账时才发现,那就是灾难。
3. BigDecimal 的正确打开方式:从构造到舍入
3.1 new BigDecimal(0.1) 是陷阱
BigDecimal 是很多人眼中的"标准答案",但它只在你用对的情况下才是标准答案。第一个高频坑就是构造方式。
java复制BigDecimal a = new BigDecimal(0.1);
System.out.println(a);
// 0.1000000000000000055511151231257827021181583404541015625
BigDecimal b = new BigDecimal("0.1");
System.out.println(b);
// 0.1
new BigDecimal(double) 会把 double 的二进制近似值原样转化为十进制字符串,结果就是那串很长的小数。而 new BigDecimal(String) 解析的是我们肉眼看到的十进制字面量。
如果你确实只有一个 double 值,用它转 BigDecimal,请用 BigDecimal.valueOf(double),它内部先做了 Double.toString,等价于字符串构造:
java复制double d = 0.1;
BigDecimal safe = BigDecimal.valueOf(d);
但最好的习惯仍然是:金额的源头尽量是字符串、Long 或整数,不要让它以 double 形态流动。
3.2 compareTo 和 equals:一字之差
BigDecimal 比较大小也有一个经典陷阱:
java复制BigDecimal a = new BigDecimal("0.1");
BigDecimal b = new BigDecimal("0.10");
System.out.println(a.equals(b)); // false
System.out.println(a.compareTo(b)); // 0
equals 会比较数值和 scale(小数位数),所以 0.1 和 0.10 被认为不相等。但金额语义上它们显然是同一个数。因此,业务判断两个金额是否相等时,必须用 compareTo,不能用 equals。
这也是为什么在金额计算后,我通常会显式 setScale(2, RoundingMode.HALF_UP),而不是直接依赖运算结果。显式指定 scale 有两个好处:一是让后续的 equals 行为更可控,二是避免数据库 DECIMAL 字段和 Java 端 scale 不一致导致的意外。
3.3 scale 与舍入模式
scale 这个概念很多人一开始不敏感。它表示小数位数,new BigDecimal("10.00") 的 scale 是 2,new BigDecimal("10") 的 scale 是 0。金额计算结果必须有一个明确的 scale,否则会出现 10.0、10.00、10.000 混用的局面。
舍入模式的选择也很关键。最常见的是 RoundingMode.HALF_UP,即四舍五入。但真实业务里不一定总是四舍五入:
- 折扣金额有时要求向下取整
DOWN,保证用户不吃亏; - 费用扣减有时要求向上取整
CEILING,保证平台不亏; - 分摊剩余金额时,最后一项可能要
setScale补齐差额。
所以不要在代码里到处散落 RoundingMode.HALF_UP。应该把金额舍入规则收敛到一个工具类,并且为不同业务类型定义不同的舍入策略。团队如果每个人都在各自代码里写舍入,对账出问题只是时间问题。
3.4 除法、不可变性和异常
BigDecimal 的除法是另一个容易炸的地方。如果你直接写:
java复制BigDecimal a = new BigDecimal("1");
BigDecimal b = new BigDecimal("3");
BigDecimal c = a.divide(b);
运行时会抛 ArithmeticException: Non-terminating decimal expansion。因为 1/3 除不尽,而 BigDecimal 不知道该保留多少位、怎么舍入,索性报错。正确写法是:
java复制BigDecimal c = a.divide(b, 2, RoundingMode.HALF_UP);
任何涉及除法的金额计算,都必须显式指定 scale 和舍入模式。这条规则应该写进团队的代码审查清单。
另外,BigDecimal 是不可变对象,每次 add、multiply 都会生成新对象。高并发下不会出现线程安全问题,但大量创建对象会带来 GC 压力。这也引出后面性能实测的内容。
4. 架构层必须钉死的边界:数据库、序列化与接口协议
4.1 数据库字段:BIGINT vs DECIMAL 不是随意定的
金额类型的选择从来不只是 Java 代码里的事。你选了 Long,数据库字段最好就是 BIGINT,并且字段名或注释里必须写清楚单位。如果数据库字段叫 amount 但里面存的是分,半年后接手的人很可能会把它当元来用。
你选了 BigDecimal,数据库通常对应 DECIMAL。这里要特别注意精度和 scale 的定义。比如 DECIMAL(10,2) 表示最大 8 位整数和 2 位小数,金额超过 99999999.99 就会报错。如果业务有跨境或大额场景,建议一开始就留足余量,比如 DECIMAL(18,4),避免后期改表结构。
我见过不少项目,数据库里定义的是 DECIMAL(10,2),但 Java 端金额计算保留 4 位小数,插入前没有做 setScale(2),结果数据库帮你做了隐式舍入,应用层浑然不知。这种事一定要在写入前显式处理,不能指望数据库兜底。
4.2 JSON 序列化与前端 JavaScript 的精度暗雷
这一层最容易翻车。后端用 BigDecimal 算好了金额,返回 JSON 给前端时,默认 Jackson 会把 BigDecimal 序列化成数字。问题有两个:
- BigDecimal 的 scale 可能被丢弃,
10.00变成10; - 小数位过多时可能输出科学计数法,前端
parseFloat后拿到不直观的字符串。
更隐蔽的是 Long 类型的精度问题。JSON 数字解析成 JavaScript 的 Number,而 JavaScript 的 Number 安全整数上限是 9007199254740991。Java 的 Long 上限是 9223372036854775807,远大于这个值。如果接口返回的是一个很大的 Long 金额,前端拿到的可能已经是失真值。
所以我在对外接口的设计上,越来越倾向于:金额要么用字符串返回,要么用整数最小单位返回,而不是裸奔的 JSON 数字。Spring Boot 项目里可以给金额字段统一配置序列化器:
java复制@JsonSerialize(using = ToStringSerializer.class)
private BigDecimal amount;
这样接口输出的金额是字符串 "10.00",前端不会自动转成数字,精度就不会在序列化阶段丢失。
4.3 服务之间、外部 API 之间如何传递金额
内部微服务之间调用,类型系统是可控的,但仍要约定统一单位。我踩过一个很典型的坑:交易服务用 Long 存"分",营销服务用 BigDecimal 存"元",两个服务做优惠金额抵扣时,双方都觉得自己是对的,结果算出来的订单金额永远差一位小数。
正确做法是:在服务间 DTO 里直接使用业务语义明确的类型和命名。比如 Long amountInFen,或者 String amount 配合注释。RPC 框架的接口文档里写明单位,并在字段注释里标注。
外部 API 方面,很多第三方支付和银行接口都有明确要求:金额字段是整数分还是字符串元。接入时一定要读清楚文档,不要想当然。如果外部 API 要的是字符串金额,那后端必须有一个统一的转换层,把内部 BigDecimal 或 Long 转换成对外的字符串格式,转换规则放在一个包里,禁止在业务代码里各写各的。
5. 性能实测:一百万分钱的计算差距到底有多大
5.1 一个可复现的基准
关于性能,我一直强调要先量化再下结论。这里给你一个简单可复现的对比:10 万次金额累加。
java复制public class AmountBenchmark {
public static void main(String[] args) {
int times = 100_000;
long startLong = System.nanoTime();
long totalFen = 0L;
for (int i = 0; i < times; i++) {
totalFen += 1L;
}
long costLong = System.nanoTime() - startLong;
long startBd = System.nanoTime();
BigDecimal total = BigDecimal.ZERO;
BigDecimal oneCent = new BigDecimal("0.01");
for (int i = 0; i < times; i++) {
total = total.add(oneCent);
}
long costBd = System.nanoTime() - startBd;
System.out.println("Long cost: " + costLong / 1_000_000 + " ms");
System.out.println("BigDecimal cost: " + costBd / 1_000_000 + " ms");
}
}
这不是严谨的 JMH 基准,变量也没做热身后统计,但足以说明量级。我在普通开发机上跑出的结果通常是:Long 版本零点几毫秒,BigDecimal 版本 5 到 15 毫秒,差距接近两个数量级。
如果把场景换成 BigDecimal 构造 + 乘法 + setScale,性能差距会更大。因为每次运算都可能涉及 BigInteger 的内部数组操作和对象创建。
5.2 性能差距背后的工程判断
既然 BigDecimal 这么慢,是不是所有大数据量场景都应该用 Long?不一定。关键在于这个计算发生在哪个环节。
如果金额计算发生在数据库查询之后、单次请求之内,比如一个订单算一次优惠、算一次运费,BigDecimal 多出来的几毫秒根本感知不到。真正需要担心性能的是纯计算密集场景,比如:
- 计费引擎要批量为千万级用户计算账单;
- 实时风控系统要在毫秒级窗口内做大量金额聚合;
- 高频交易系统里每个事件都要做金额校验。
这些场景里,BigDecimal 不但慢,还会产生大量短生命周期对象,GC 压力可能比计算本身还大。这时候把金额统一成 Long 最小单位,性能收益是实打实的。
但架构选型不能只看性能。一个计算量不大的订单系统,性能根本不缺,缺的是团队理解和维护的一致性。选 BigDecimal,读代码的人会自然想到精度和舍入;选 Long,单位换算和除法规则又得额外约定。两种方案的维护成本并不体现在计算时间上,而是体现在团队共识上。
6. 最终拍板逻辑与我的防坑清单
6.1 我的选型决策树
每次有人问我"金额到底用 Long 还是 BigDecimal",我给的不是一个固定答案,而是一套判断流程:
- 金额是否需要参与小数乘法、除法、汇率换算、比例分摊?需要,优先 BigDecimal;不需要,继续下一步。
- 业务是否允许定义一个全局唯一的最小货币单位,并且全链路(前端、服务端、数据库、第三方)都能接受?能,Long 是一个可选方案。
- 是否存在超大整数金额,可能超过 Long 上限?几乎不存在,但如果你用 Long 存"厘"级别(相当于元的千分之一),且业务涉及万亿级以上金额,要估算一下。
- 下游调用方是否是跨语言、跨团队、外部系统?如果是,优先考虑字符串 BigDecmal 或整数最小单位,而不是让 JSON 数字裸奔。
- 团队对金额处理的规范度如何?如果团队对 BigDecimal 的构造、compareTo、scale 都有统一约定,BigDecimal 很安全。如果团队水平参差不齐,Long 反而因为"整数不会错"的直觉更容易被接受。
这套流程的核心不是"哪个类型更好",而是"哪种约定在你的系统里更容易被守住"。
6.2 防坑清单
最后,把我实际工作中验证过的关键点整理成一张清单,供你对照检查:
| 场景 | 推荐做法 | 原因 |
|---|---|---|
| 基础金额计算 | BigDecimal + String 构造 | 精度稳定,语义清晰 |
| 简单累加/余额 | Long 存最小单位 | 性能高,比较简单 |
| 折扣/税率/汇率 | BigDecimal | 涉及小数乘除,Long 难处理 |
| 金额比较 | BigDecimal.compareTo | equals 会因 scale 不同返回 false |
| 除法 | 显式指定 scale 和舍入模式 | 否则抛 ArithmeticException |
| 元转分 | BigDecimal.movePointRight(2) 再转 Long | 避免 double 过渡丢失精度 |
| 数据库存储 | BIGINT 注释单位 或 DECIMAL 指定精度 | 防止单位混用、隐式舍入 |
| JSON 返回 | 金额序列化为字符串 | 防止 JS 精度丢失和科学计数法 |
| 对外接口 | 明确金额单位,最好用整数分 | 避免解析歧义 |
6.3 一个小经验,作为收尾
我做了这么多年支付和账务相关系统,最终发现"用 Long 还是 BigDecimal"确实重要,但远没有"单位约定"和"舍入规则"重要。很多线上事故的根源不是选错了类型,而是选了类型之后,单位、精度、舍入方式各搞各的。
如果你现在还在纠结,我建议你先别急着定方案,而是拉上后端、前端、数据库负责人一起把三件事对齐:金额字段命名是否带单位、精度保留几位、舍入规则是哪一种。这三件事确定下来之后,Long 和 BigDecimal 的答案往往自己就浮出来了。我的习惯是:能吃透业务、能守住约定的团队,我会更多地用 Long;业务规则复杂、团队经常流动,我倾向用 BigDecimal + 字符串序列化。
金额不是普通的数字,它背后是钱,是账,是责任。代码里的每一分,都值得认真对待。
