
搞开发和设计评审这么多年金额字段到底用BigDecimal还是Long几乎每一次都能吵起来。支持BigDecimal的会说“钱不能用浮点Long 算什么钱”支持Long的会反驳“你去看看微信支付、Stripe 的接口文档哪个不是拿最小货币单位整数在传”。两边都有道理但真正落地的时候又都会踩到对方嘴里的坑。这个问题没有一个“永远正确”的答案但可以有一套清晰的选型逻辑。下面我先把背后的原理和工程习惯讲透再给出我自己在新项目里会怎么选。先说结论只要涉及金额第一原则是“绝对不要用double或float”第二原则是“把‘存储/传输’和‘业务计算’分开考虑”。计算复杂、需要控制舍入精度、币种和单位不统一就用BigDecimal业务模型简单、单位明确、以加减和对账为主就统一成最小货币单位的Long比如“分”。但要注意Long并不是在所有场景下都能直接替代BigDecimal一旦牵扯到除法、折扣分摊、跨币种和费率计算光靠一个Long会把自己逼疯。1. 先别急着选类型搞懂“钱为什么会在程序里出错”1.1 double 在金额上的“原罪”聊BigDecimal和Long之前先把最容易被误解的double为什么不行说清楚。你随便写一行 JavaSystem.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小数精度依赖约定单位固定后天然精确十进制小数可精确表示精度可控加减比较极快语义简单较慢但是否出问题依赖正确用法乘除乘法可能溢出除法需自己处理尾差内置舍入模式可控制小数位存储大小数据库 bigint8 字节数据库 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看起来还好但如果出现1E5这种科学计数法前端同学会一头雾水。更严重的是如果把一个接近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) 0BigDecimal 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。否则同一个金额因为缩放不同在集合里可能找不到。4.3 除