搞开发和设计评审这么多年,金额字段到底用BigDecimal还是Long,几乎每一次都能吵起来。支持BigDecimal的会说“钱不能用浮点,Long 算什么钱”;支持Long的会反驳“你去看看微信支付、Stripe 的接口文档,哪个不是拿最小货币单位整数在传”。两边都有道理,但真正落地的时候又都会踩到对方嘴里的坑。这个问题没有一个“永远正确”的答案,但可以有一套清晰的选型逻辑。下面我先把背后的原理和工程习惯讲透,再给出我自己在新项目里会怎么选。
先说结论:只要涉及金额,第一原则是“绝对不要用double或float”;第二原则是“把‘存储/传输’和‘业务计算’分开考虑”。计算复杂、需要控制舍入精度、币种和单位不统一,就用BigDecimal;业务模型简单、单位明确、以加减和对账为主,就统一成最小货币单位的Long,比如“分”。但要注意,Long并不是在所有场景下都能直接替代BigDecimal,一旦牵扯到除法、折扣分摊、跨币种和费率计算,光靠一个Long会把自己逼疯。
1. 先别急着选类型,搞懂“钱为什么会在程序里出错”
1.1 double 在金额上的“原罪”
聊BigDecimal和Long之前,先把最容易被误解的double为什么不行说清楚。你随便写一行 Java,System.out.println(0.1 + 0.2);输出不是0.3,而是0.30000000000000004。这不是 JDK 的 bug,是 IEEE 754 双精度浮点数的固有特性。
计算机内存里的double是用二进制科学计数法表达的,也就是把数字拆成“符号位 + 指数位 + 尾数位”。很多十进制小数,比如 0.1,在二进制里是无限循环的,没法像整数一样被精确存放。这就类似于你用十进制去写 1/3,永远只能写成 0.333333...,只是十进制浮点里 0.1 看起来干净,二进制浮点里就不干净了。
在普通图形学、物理引擎里,这种误差是可以接受的,因为最终显示的不是精确数值。可金额不一样,订单、账务、退款、对账系统里,误差会累积。你今天存 0.1,明天加 0.2,后天乘一个费率,月底对账差出几分钱,报表怎么都平不上,最后只能靠财务手工调平,这在技术团队里是非常丢人的事故。所以,金额字段只要还想做严肃业务,第一关就是把 float/double 直接拉黑,没有任何讨论空间。
1.2 Long 存“最小单位”到底是怎么流行起来的
既然浮点数不行,那最朴素的想法就是:我不用小数行不行?比如人民币精确到分,那我干脆把 10.00 元存成1000,单位是“分”。1000是一个整数,二进制可以精确表示。整数加减、整数比较、整数存数据库,都不会有精度问题,而且性能极快。
这套思路并不是拍脑袋,而是支付行业长期形成的通行标准。如果你对接过微信支付,订单金额字段total_fee的单位就是分,下单时传1000表示 10 元;Stripe 的接口文档也很明确,金额是整数,单位是币种的最小单位,美元是美分,日元因为本身没有小数位,金额就是 1 日元。前端做展示时再转换成分,服务端内部反倒不需要处理小数点。
这种方式天然避开了二进制浮点的坑,因为底层处理的是整数。但代价也很明显:它把“金额精度几位小数”这个复杂度,从类型层转移到了业务层。你必须约定清楚每一种币种的最小单位是多少,人民币和美元是 2 位,日元是 0 位,有些币种可能是 3 位甚至更多。单位一旦约定错,比如把日元当成人民币的“分”来存,瞬间放大 100 倍,订单金额会变得离谱。
1.3 BigDecimal 真正解决的核心问题
BigDecimal的底层思路并不是“用二进制去凑小数”,而是“把一个十进制数字拆成一个未缩放整数加上一个标度”。比如123.45,内部可以理解为无符号整数值12345加上标度2,也就是小数点左边移两位。这套设计与Long存最小单位殊途同归,但它把“标度”信息一起保存了下来,而且这个整数部分用的是可以无限扩展的大整数BigInteger,所以理论上的量级不会被 64 位上限卡死。
BigDecimal的长处在于:它可以精确表示任意十进制小数,也提供了完善的舍入控制。0.1 + 0.2用BigDecimal做,结果是精确的0.3,不会出现诡异的尾巴。它还带了很多业务上需要的舍入模式,比如四舍五入、银行家舍入、向上取整、向下取整,配合setScale可以严格控制每一步运算的精度。
但它不是没有短处。第一,计算开销比Long大得多,虽然大部分业务不在乎,可一旦你在高频循环里对凭证做聚合并行计算,差距就会显现出来;第二,它给了你一把很锋利的刀,用不好反而比Long更容易出问题,比如用错了构造方法、用错了 equals、除出了一个无限小数直接抛异常。
2. 两种方案的实用性对比:精度、性能与边界
2.1 Long 存“分”适合哪些业务,上限在哪里
如果你的系统满足这几个条件,Long存最小单位是非常舒服的:第一,目标币种的小数位是固定的,比如人民币、美元都是 2 位;第二,领域内主要是“存取、加减、比较、汇总”,不经常做复杂的除法和小数乘法;第三,你是直接对接支付平台,对方接口拿过来就是分,你不需要反复转换。
以分为单位,Long能表达的金额上限是Long.MAX_VALUE,也就是9223372036854775807。如果单位是分,大约是92233720368547758.07元。这个量级远远超过绝大多数互联网公司的交易量,不用担心单个字段存不下。
但是,要警惕计算过程中的溢出。两个金额做加法,Java 的+不会帮你检查溢出,算出来突然变成负数,排查起来非常痛苦。更危险的是乘法,比如“单价分 × 数量”这种操作,如果单价和数量都很大,乘积很容易越过 64 位边界。我在代码评审里会特别要求,凡是 long 类型金额做乘法和不可控的求和,必须使用Math.multiplyExact、Math.addExact这类溢出检查方法,或者改用BigInteger临时承接。
Long方案真正难受的地方是除法。总价 100 分,三个人均摊,结果是每个人 33.333... 分,这在整数世界里没法表达,只能约成每人 33 分,剩下 1 分归最后一个人。这种“尾差归谁”的业务规则,语言本身不替你决定,必须自己写逻辑,而且每个场景的规则可能不同,稍不留神就会产生对账差异。
2.2 BigDecimal 适合哪些业务,代价是什么
BigDecimal适合的场景,往往是那些“钱不只是一次性交易快照”的业务。比如订单里引入优惠券,优惠金额需要按比例分摊到多个商品行;又比如结算时涉及税率、手续费、分成比例,计算过程会出现大量的循环小数;再比如系统同时支持多币种,有的币种保留 2 位、有的保留 0 位、有的汇率中间价甚至保留 6 位以上。这些场景如果坚持用Long存分,到处都要写精确到“某个单位的四舍五入”,每一步都要小心翼翼,代码根本经不起复杂业务迭代。
BigDecimal的开发成本主要在“约定”上。团队里必须统一:金额默认保留几位小数,舍入模式用哪种,什么时候允许精度损失,什么时候必须使用UNNECESSARY阻止任何隐式舍入。没有一个约定,BigDecimal反而容易产生千奇百怪的写法。有人divide不传舍入模式直接抛异常,有人用equals比较金额导致1.0和1.00不相等,有人把BigDecimal直接塞进数据库却映射成浮点,这些都是我见过的真实事故。
2.3 一张表看完核心差异
我把常见的对比维度列成一张表,方便在团队评审时直接贴出去:
| 对比维度 | Long 存最小单位 | BigDecimal |
|---|---|---|
| 底层表示 | 64 位整数 | 未缩放整数 + 标度,基于 BigInteger |
| 小数精度 | 依赖约定,单位固定后天然精确 | 十进制小数可精确表示,精度可控 |
| 加减比较 | 极快,语义简单 | 较慢,但是否出问题依赖正确用法 |
| 乘除 | 乘法可能溢出,除法需自己处理尾差 | 内置舍入模式,可控制小数位 |
| 存储大小 | 数据库 bigint,8 字节 | 数据库 decimal/numeric,通常多一些空间 |
| 适用业务 | 支付接口、单一币种、简单订单 | 财务、费率、分摊、多币种、复杂账务 |
| 最大的坑 | 单位不统一、溢出、除法语义不明确 | equals/scale、舍入模式、配置错误 |
| 与 JSON/前端交互 | long 可能超 JS 安全整数,需小心 | 默认序列化可能出科学计数法,需转字符串 |
注意这里没有绝对的优劣。Long并不低级,BigDecimal也不代表严谨。一个只用下单付款的电商,如果硬把全部金额都搞成BigDecimal,性能不算什么大问题,但每个 DTO 字段都要小心序列化,反而增加了不必要的复杂度。反过来说,一个记账系统如果图省事全部用分去Long,遇到税费和分摊时会写出一堆非常难维护的整数公式。
3. 真实项目中的选型与代码落地
3.1 我的选型心法:存储和计算分开对待
很多团队在争论时,把“存储类型”和“计算类型”混为一谈。我现在的习惯是把它们拆开考虑:
- 对外部系统、前端页面传输金额时,我会倾向于使用字符串或最小单位整数,并明确标注单位。尤其和 JavaScript 打交道时,用
String最安全。 - 在业务领域对象内部,如果计算简单,我用带单位后缀的
Long,比如priceFen、amountCent;计算复杂,我在 Service 层临时转成BigDecimal,算完再转回Long或格式化成字符串。 - 在数据库层面,如果团队统一使用
DECIMAL(20,2),那 Java 侧映射成BigDecimal很自然;如果团队习惯 bigint 存分,那 Java 侧用Long也自然。最忌讳的是同一套系统里有的表用分、有的表用元,有的列叫price存的是分,有的列叫amount存的是元,还没注释,这种系统迟早往数据库里撒野。
这套心法听起来比较抽象,下面我用两种主流落地方式分别演示。
3.2 Long 方案落地细节:单位统一、溢出与分摊
假设我现在的场景是国内电商订单,金额统一用人民币分,支付接口也要求传分。我会这样设计实体字段:
public class OrderItem { private Long id; // 商品单价,单位:分 private Long unitPriceFen; private Integer quantity; // 实付金额,单位:分 private Long payAmountFen; }字段名带上Fen或Cent后缀,是避免单位混淆最便宜的手段。代码里如果出现BigDecimal.valueOf(amountFen).movePointLeft(2),其他同事一看就知道是要把分转成元。
Long 做加法时最容易翻车的是溢出,所以求和逻辑我建议这样写:
long a = 9223372036854775807L; long b = 1L; // 这里不会抛异常,结果直接变成负数 long wrong = a + b; // 使用 Math.addExact,溢出时抛 ArithmeticException long result = Math.addExact(a, b);这个例子虽然极端,但真实系统在积分赠送、累计流水等场景中不是完全不可能出现。最安全的习惯是:任何来自外部输入或经过多次累加的金额再做运算时,都套一层溢出检查。
如果订单要分摊优惠,比如整单优惠 2 元,要分给 3 个商品行,我的处理方式是先把总优惠额算成 200 分,然后按“均分 + 尾差给最后一项”的规则处理:
long totalDiscountFen = 200L; int itemCount = 3; long base = totalDiscountFen / itemCount; // 66 long remainder = totalDiscountFen % itemCount; // 2 long[] discounts = new long[itemCount]; for (int i = 0; i < itemCount; i++) { discounts[i] = base; if (i < remainder) { discounts[i]++; } } // 结果:67、67、66,合起来正好是 200这种方式比“每一行分别四舍五入”更能保证总和的闭合,缺点是每一行分配到的优惠不一定是最符合直觉的那个值,但工程上这种“先均摊、余数补到前面”的做法很常见。真实业务的规则可能更复杂,比如某类商品不参与优惠,那就得先排除再分摊。这些逻辑本质上不是数据类型的问题,而是业务规则问题,但选Long会逼你把这些规则显式写出来,反而提醒开发人员不能想当然。
如果业务里出现“金额 × 费率”的场景,我不建议直接拿 Long 分去做截断乘法。费率如果是 0.06,我会把金额和费率都先转成中间的定点十进制再算:
long amountFen = 1999L; BigDecimal amountYuan = BigDecimal.valueOf(amountFen).movePointLeft(2); BigDecimal feeYuan = amountYuan.multiply(new BigDecimal("0.06")) .setScale(2, RoundingMode.HALF_UP); long feeFen = feeYuan.movePointRight(2).longValueExact();这样你依然可以把“存储形态”保持成 Long,但在计算的那一步交给了能表达小数的类型,避开整数除法截断问题。
3.3 BigDecimal 方案落地细节:加减乘除与四舍五入
如果业务复杂度已经上来了,我建议直接让领域层离不开BigDecimal。首先约定一个统一精度,比如业务金额默认 2 位小数,用于汇率的字段可以 6 位以上,但最终入账时必须通过setScale(2, RoundingMode.HALF_UP)规整一次。
BigDecimal 的加减乘除用起来并不难,难的是每次都要想清楚舍入:
BigDecimal price = new BigDecimal("19.90"); BigDecimal quantity = new BigDecimal("3"); // 乘法结果如果需要控制小数位,显式 setScale BigDecimal total = price.multiply(quantity) .setScale(2, RoundingMode.HALF_UP); // 结果 59.70 // 除法必须指定精度和舍入模式 BigDecimal average = total.divide(BigDecimal.valueOf(3), 2, RoundingMode.HALF_EVEN); // 结果 19.90这里有一个很多人第一次会遇到的异常:如果你直接写new BigDecimal("1").divide(new BigDecimal("3")),JVM 不会自动四舍五入,而是抛ArithmeticException: Non-terminating decimal expansion。因为 1 / 3 是无限循环小数,在十进制里没有“精确表示”,而 BigDecimal 的默认行为是要求结果能够精确终止。所以在divide时养成传scale + RoundingMode的习惯,能省掉很多运行时的惊吓。
精度模式上,国内绝大多数业务默认HALF_UP,也就是四舍五入。而一些银行、财务系统更倾向HALF_EVEN,即“银行家舍入”,当舍入部分正好是 0.5 时,取最近的偶数。这种规则能减少大量数据在做统计时因四舍五入带来的系统性偏移。具体用哪种,需要和业务方、财务确认,不能自己拍脑袋。
3.4 对外接口与前端交互的额外建议
这部分单独拿出来说,是因为我见过不少项目内部类型选对了,却在接口层翻车。Java 后端把BigDecimal序列化成 JSON 数字返回,比如19.90可能变成19.9,看起来还好;但如果出现1E+5这种科学计数法,前端同学会一头雾水。更严重的是,如果把一个接近Long.MAX_VALUE的long类型分单位金额直接返回给浏览器,JavaScript 的Number超过安全整数范围后会丢精度,最终页面上显示的数字和数据库里的不一致。
所以,对外接口的钱包、订单等金额字段,我会建议统一使用字符串。比如 DTO 里这样定义:
public class OrderResponse { // 订单金额,单位:分,字符串避免精度丢失 private String amountFen; // 展示字符串,例如 "19.90" private String amountText; }或者让BigDecimal序列化器统一输出字符串。对内服务之间,如果也走了 JSON,同样要考虑精度,不能因为“Java 之间通信没事”就放松警惕。这个建议不是小题大做,真实发生过订单回调里金额变成科学计数法,回调签名校验一直失败,排查了半天的案例。
4. 避坑实录:这些坑我都帮你踩过了
4.1 用 new BigDecimal(double) 而不是 new BigDecimal(String)
这一条是新手最容易犯的:new BigDecimal(0.1)并不等于 0.1,底层会把二进制浮点数的真实值还原出来,结果是一长串0.1000000000000000055511151231257827...。正确写法是new BigDecimal("0.1"),或者用BigDecimal.valueOf(0.1),因为valueOf内部先调用了Double.toString,拿到的是一个可读的十进制字符串,再构造 BigDecimal,所以不会把二进制尾巴带进来。
这条规则适用于所有“外部传入的金额字符串”和“从数据库读取的 decimal 字符串”。我在项目里还专门加过静态检查,禁止直接new BigDecimal(double),一旦有人写出这种代码,编译阶段就被拦下来。
4.2 equals 比较导致的金额不相等
BigDecimal的equals方法比较的是数值和标度,不只是数学大小。所以new BigDecimal("1.0").equals(new BigDecimal("1.00"))返回false,但compareTo返回0。如果你在判断金额是否相等时用了 equals,很可能两个看起来一样的金额被判为不相等,导致风控、对账逻辑出错。
业务比较金额统一用compareTo,或者在做相等判断前先统一setScale。判断是否为零也不要写equals(BigDecimal.ZERO),因为0.00和0.0都可能不等于0,应该用signum() == 0或compareTo(BigDecimal.ZERO) == 0:
BigDecimal a = new BigDecimal("0.00"); boolean zero = a.compareTo(BigDecimal.ZERO) == 0; // true boolean zero2 = a.equals(BigDecimal.ZERO); // false由于 equals 还影响哈希值,不要把 BigDecimal 作为Map或Set的 key。否则同一个金额因为缩放不同在集合里可能找不到。