
做后端开发的谁没在表设计评审会上吵过架吵架概率最高的字段一个是状态用 int 还是 String另一个就是金额。金额字段到底用 Long 还是 BigDecimal我已经看过太多讨论帖也亲眼见过不少同事在这上面栽跟头包括我自己。先说结论大部分业务场景下用 Long以“分”为单位存储是更稳的选择但这句结论不是所有场景都适用下面我把这些年折腾过的经验分几个角度一次讲透。我自己最早做订单系统时团队拍脑袋用了 BigDecimal理由是“规范、口径清晰”。结果第一版线上报表就对不平订单表里三笔明细加起来 59.7 元订单头合计却是 59.699999999 元。排查半天不是运算逻辑写错而是某个同事用了new BigDecimal(0.1)这种构造方式把浮点数的“脏尾巴”带进了金额链。后来整个团队把所有金额改成 Long 以“分”存储这类精度问题直接从根上消失了。这段经历让我对“Long 还是 BigDecimal”这个选择题有了非常具体的答案路径。1. 先看清楚金额计算为什么会成为“高危区”1.1 浮点数在币值计算中的天生缺陷要理解金额字段为什么这么敏感得先回到二进制世界。计算机里的double和float是二进制浮点数它们在表示 0.1、0.2、0.3 这类十进制小数时本来就是近似值就像你用十进制小数 0.333... 去表示 1/3 一样永远差一点。所以在 Java 里跑0.1 0.2结果不是 0.3而是0.30000000000000004。这个现象在纯粹的计算题里可以忍但在订单、账单、积分、佣金里忍不了。财务同学拿计算器一按跟系统对不上就是事故。这就是为什么几乎所有涉及钱的领域都会强调一个原则禁止使用double和float直接表示金额。剩下能选的就两个方向一个是十进制精确计算的BigDecimal另一个是把金额换算成最小货币单位比如“分”用整数long来算。1.2 Long 和 BigDecimal 在金额问题里的位置这两者解决精度问题的思路不一样。BigDecimal走的是“用十进制字符串精确还原数值”的路子。它内部用BigInteger存储无标度值配合scale表示小数点位数所以new BigDecimal(19.90)能精确表示 19.90。问题在于它的 API 用起来规矩多不守规矩就会掉进精度坑比如用new BigDecimal(0.1)而不是new BigDecimal(0.1)。long走的是“避开小数”的路子。既然小数容易出问题那我干脆不用小数所有金额统一以“分”为整数单位。100 元就是10000L19.9 元就是1990L加减乘除全是整数运算天生不会出现0.10.2这种问题。这两种思路没有绝对的对错但它们的适用场景、团队协作成本、踩坑概率差别很大。下一节我逐项拆开对比。2. 两种方案的硬核对比精度、性能、可维护性2.1 精度对比Long 靠机制取胜BigDecimal 靠纪律取胜论“绝对精度”Long以分为单位是天然安全的。整数运算在 Java 里没有任何精度损失1990L 10L永远是2000L不存在两个long相加等于1999.999999的情况。这个优势是机制层面的不需要程序员额外注意什么。而 BigDecimal 的精度取决于你会不会用。正确用法是BigDecimal a new BigDecimal(19.90); BigDecimal b new BigDecimal(0.10); BigDecimal sum a.add(b); // 20.00但如果有人这么写BigDecimal c new BigDecimal(19.90); BigDecimal d new BigDecimal(0.10); BigDecimal sum2 c.add(d); // 20.000000000000002486...结果就又开始飘了。所以说 BigDecimal 的精度是“纪律性”的团队里只要有一个人不守规矩整个链路的精度就会被污染。另一个精度相关的问题是除法。BigDecimal 做除法时必须指定保留位数和舍入模式否则会抛ArithmeticException// 会抛异常Non-terminating decimal expansion BigDecimal result new BigDecimal(1).divide(new BigDecimal(3));正确写法必须带上scale和舍入策略BigDecimal result new BigDecimal(1) .divide(new BigDecimal(3), 2, RoundingMode.HALF_UP); // 0.33Long 做除法也有舍入问题但更直白。199L / 100在 Java 里结果是1余数被丢掉。你想四舍五入得自己写long fen 199L; long yuan fen / 100; // 1 long rounded (fen 50) / 100; // 2先加 50 再做整除等价于四舍五入2.2 性能对比Long 完胜但场景决定价值性能差异在大量计算时非常明显。long是 JVM 原生的 64 位整数加法就是一条 CPU 指令BigDecimal内部是BigInteger配合scale一次加法会经历对象创建、数组运算、无标度值处理等一串步骤慢一个数量级完全不夸张。我做过一个简单的本地基准测试一亿次累加long累加耗时在几十毫秒内BigDecimal累加耗时通常在数秒级别。不过在绝大多数业务系统里单个事务涉及金额计算的次数很少订单金额、账务汇总这种量级不会成为性能瓶颈。真要说性能敏感反而是报表统计、批量复核、积分结算这类海量数据聚合的场景更有参考价值。在这种场景里Long 方案可以让计算少掉大量对象创建开销省下来的时间非常可观。2.3 可维护性对比口径一致 vs 单位转换Long 最大的缺点是“阅读不直观”。日志里打印一个1990你得心算一下才知道是 19.9 元接口文档里写amount: 1990前端同事得先跟你确认单位是“分”还是“元”。这个问题本质上是团队口头约定问题解决靠两点字段命名带后缀amountFen、totalAmountFen以及出入参转换层统一封装。BigDecimal 的可读性优势就在这里19.90就是 19.90数据库一看就懂。但它的隐性成本是团队里每个人都要熟练掌握 scale、RoundingMode、compareTo 与 equals 的区别等知识点。我把两者的关键差异整理成一张表对比维度Long以分为单位BigDecimal以元为单位精度保障整数机制天然安全依赖正确构造和舍入规则计算性能极高明显偏低代码可读性需要转换和注释直观清晰数据库存储BIGINT 紧凑DECIMAL 直观团队出错概率低规则简单中高API 规矩多金融级大额场景够用分足够精确更贴近会计语义前端交互需要转换直接显示 19.903. 实操落地两种方案的核心代码与细节3.1 BigDecimal 方案的正确打开方式如果你所在项目必须用 BigDecimal比如财务系统需要精确到多位小数、会计凭证需要保留原始金额语义至少要做到下面几点。第一金额字段一律用字符串构造不写new BigDecimal(double)BigDecimal amount new BigDecimal(19.90); // 推荐 BigDecimal badAmount new BigDecimal(19.90); // 禁止如果真要从 double 转 BigDecimal请用BigDecimal.valueOf(double)这个方法内部会调用Double.toString能安全地把 double 转成十进制字符串再构造double raw 19.90; BigDecimal safe BigDecimal.valueOf(raw); // 19.9安全第二比较大小用compareTo不用equals。原因是equals要求数值和位数都一致19.9和19.90在 equals 眼里是不同的对象而在金额比较里它们显然相等BigDecimal a new BigDecimal(19.9); BigDecimal b new BigDecimal(19.90); System.out.println(a.equals(b)); // false坑 System.out.println(a.compareTo(b) 0); // true正确第三除法必须显式指定精度和舍入模式。舍入模式通常用RoundingMode.HALF_UP四舍五入但金融场景有时要求HALF_EVEN银行家舍入这个要跟财务确认不要自己拍板。第四所有金额计算统一控制 scale。如果不统一计算过程中会出现19.900和19.9混在一起的结果虽然 compareTo 不受影响但显示、序列化、入库都会不统一。建议封装一个计算工具类强制所有计算入口统一 scale 和 roundingpublic class MoneyCalculator { private static final int SCALE 2; private static final RoundingMode ROUNDING RoundingMode.HALF_UP; public static BigDecimal add(BigDecimal a, BigDecimal b) { return a.add(b).setScale(SCALE, ROUNDING); } public static BigDecimal multiply(BigDecimal a, BigDecimal b) { return a.multiply(b).setScale(SCALE, ROUNDING); } public static BigDecimal divide(BigDecimal a, BigDecimal b) { return a.divide(b, SCALE, ROUNDING); } }3.2 Long 方案的正确打开方式以“分”为单位Long 方案核心规则只有三条存分、算分、分进元出。写代码时可以这么约定public class MoneyUtils { private static final int FEN_PER_YUAN 100; public static long yuanToFen(String yuan) { BigDecimal yuanValue new BigDecimal(yuan); return yuanValue.movePointRight(2).longValueExact(); } public static BigDecimal fenToYuan(long fen) { return BigDecimal.valueOf(fen, 2); } public static String fenToYuanString(long fen) { return fenToYuan(fen).toPlainString(); } }内部业务计算全部以long累加、比较不掺任何浮点运算long priceFen 1990L; // 19.9 元 long quantity 3L; long totalFen priceFen * quantity; // 5970 分即 59.7 元这里要特别提醒一个小学级别的坑两个 int 相乘可能溢出。如果priceFen和quantity都是 int1990 * 1000000可能已经超出 int 范围。解决方案很简单——运算时确保至少一个是 long 类型或者在变量声明时就用 long。我自己习惯所有金额字段从数据库到 Java 全程用long同时命名上带Fen后缀这样别人看代码时不用猜单位。Long 方案里有一个执行顺序问题需要格外注意乘法除法尽量避免先除后乘。比如计算折扣后金额正确的是先算扩展后舍入还是先舍入再扩展取决于业务规则。以折扣 8.5 折、单价 1990 分为例long priceFen 1990L; long discountPercent 85L; // 85% // 方式一乘完再除保留最终整数分 long resultFen priceFen * discountPercent / 100; // 1691 分 // 方式二除完再乘中间结果丢失精度 long resultFen2 (priceFen / 100) * discountPercent; // (19) * 85 1615 分错结论很简单能最后除就最后除中间步骤不要用整数除法制造余数。3.3 热点场景合计字段计算公式变化时怎么触发重算很多系统里有“累计金额”“合计字段”这种冗余存储的字段比如订单头存一个订单总金额明细表里存每个商品的小计。这类字段最怕一件事业务规则变了比如原来不打折现在引入阶梯折扣原来不含运费现在要加运费。规则一变历史数据里的合计字段全部可能出错。结合热词里提到的“触发合计字段计算公式变化”我分享一个我实际经历过的处理方式。当时业务方要求把所有订单的折扣规则从“无条件满减”改成“阶梯折扣”这意味着存量的订单头合计金额要按新公式重新算一遍。我的做法是写一个重算服务统一从明细反推合计而不是在上有层改公式public OrderAmount calculateOrderAmount(ListOrderItem items, OrderDiscount discount) { long totalFen 0L; for (OrderItem item : items) { long itemAmount item.getPriceFen() * item.getQuantity(); long discountAmount discount.apply(itemAmount); totalFen itemAmount - discountAmount; } // 重算后回写订单头冗余字段 order.setTotalAmountFen(totalFen); return order; }这个服务被两个入口调用一是下单时实时计算二是跑批重算脚本调用把历史订单全部过一遍。跑批时我习惯用 stream 对订单明细做分组汇总这在热词里也出现了。比如按订单 ID 分组汇总金额MapLong, Long groupSumMap orderItems.stream() .collect(Collectors.groupingBy(OrderItem::getOrderId, Collectors.summingLong(OrderItem::getAmountFen)));再看另一个热词相关的写法MapLong, User idLatestMap list.stream().collect(Collectors.toMap(...))。这种写法我在做“按用户取最新一条金额数据”时很常用以用户 ID 为 key用toMap的第三个参数处理重复 key保留最新一条MapLong, User latestUserMap userAmountList.stream() .collect(Collectors.toMap( User::getId, Function.identity(), (a, b) - a.getUpdateTime().compareTo(b.getUpdateTime()) 0 ? a : b ));金额字段如果用的是 Long这类groupingBysummingLong/toMap的聚合操作非常顺手完全不用考虑BigDecimal的reduce或者Collectors.reducing复杂度。这也是我内部聚合计算偏 Long 的一个现实原因。4. 数据库字段类型从 Decimal 改 BIGINT 的实际迁移脚本4.1 sqlite 修改字段类型怎么做热词里出现了“sqlite修改字段的类型”这确实是个典型麻烦点。SQLite 对 ALTER TABLE 的支持很有限官方只支持改表名、加列不支持直接修改现有列的类型和约束。SQLite 是弱类型存储DECIMAL在它内部几乎等同于NUMERIC亲和性当你需要把一个金额列从DECIMAL(10,2)改成BIGINT时光靠一条ALTER TABLE是做不到的。标准套路是“新建表 迁移数据 删除旧表 重命名”最好全部放在一个事务里BEGIN; -- 1. 创建新表金额列改为 BIGINT单位分为约定口径 CREATE TABLE t_order_new ( id INTEGER PRIMARY KEY, order_no TEXT NOT NULL, amount BIGINT NOT NULL DEFAULT 0 ); -- 2. 复制旧数据把单位从元转成分CAST(amount * 100 AS INTEGER) INSERT INTO t_order_new (id, order_no, amount) SELECT id, order_no, CAST(amount * 100 AS INTEGER) FROM t_order; -- 3. 删除旧表 DROP TABLE t_order; -- 4. 重命名新表 ALTER TABLE t_order_new RENAME TO t_order; COMMIT;这里有几个注意点迁移前先备份SQLite 文件复制一份很简单CAST(amount * 100 AS INTEGER)里的amount是浮点或 DECIMAL 时乘法可能产生 19.899999 这种值强制 CAST 到 INTEGER 会丢精度。稳妥做法是先用ROUND(amount * 100)再 CASTCAST(ROUND(amount * 100) AS INTEGER)如果有外键关联旧表重命名后关联关系可能失效需要一并处理。4.2 MySQL/Oracle 迁移字段类型的一个参考脚本MySQL 里修改字段类型很直接但有一个顺序问题先更新数据再改字段类型避免类型转换异常和数据被截断。假设旧字段是DECIMAL(10,2)新字段要变成BIGINT-- 第一步先更新现有数据把元转成“分”。注意这里建议用 ROUND 避免精度问题 UPDATE t_order SET amount ROUND(amount * 100); -- 第二步修改列类型 ALTER TABLE t_order MODIFY COLUMN amount BIGINT NOT NULL DEFAULT 0 COMMENT 金额单位分;这里有一个很容易踩的坑如果表里数据量很大一条UPDATE会锁全表。建议分批更新比如按主键 ID 扫锚每次处理几千条避免在高峰期卡死业务。另外在 Java 实体里也要把对应的字段类型改成Long注意原有代码里凡是直接读取这个字段并当作“元”的地方全部要跟着改这一步最容易漏。如果是 Oracle语法略有不同-- Oracle 中更新和修改类型 UPDATE t_order SET amount ROUND(amount * 100); ALTER TABLE t_order MODIFY (amount NUMBER(18,0));4.3 泛微 OA 明细表下拉框类型与金额计算的配置联想热词里有一句“泛微oa设置明细表中更改下拉框类型”单看这句话是在说 OA 表单里的下拉框控件的值类型设置。它和金额字段类型有什么关系我见过不少集成场景OA 的差旅报销单、采购申请单里金额字段会被设计成“文本”或“下拉框选项”而不是真正的数字控件导致后续合计公式、转财务凭证时全变成字符串拼接算出 199001、199020 这种离谱数字。在处理这类问题时经验是凡是参与金额计算的表单控件控件类型必须设置成可被计算的数值类型如果是下拉框关联的值必须是数值而不是文本标签。比如“付款方式”下拉框可以显示文字“微信”、“支付宝”但绑定值要是1、2而“报销金额”这种直接参与合计的字段不建议用下拉框实现应该用文本输入框并设置格式为数字或者直接使用数量金额组件。这在本质上其实和 Long / BigDecimal 的选择是同一个问题——字段类型决定了下游计算能否安全进行。如果表单层就拿字符串存金额到数据库层不管用 Long 还是 DECIMAL转换都会很痛苦。换个类型都得让合计公式、接口参数、历史数据全部联动一遍。5. 常见问题速查表与避坑清单5.1 高频问题速查表我做了一张速查表都是实际遇到频率最高的问题问题原因解决方案new BigDecimal(0.1)得到的不是 0.1double 本身是近似值构造器直接把近似值搬进来改用new BigDecimal(0.1)或BigDecimal.valueOf(0.1)BigDecimal.equals比较不相等equals 同时比较数值和位数用compareTo比较BigDecimal.divide抛非终止小数异常没指定 scale 和舍入模式显式写divide(a, 2, RoundingMode.HALF_UP)两个 long 相加还是丢精度大概率是其中一个数是 int 或 float 转型导致全程保持 long 字段尤其在 stream 聚合里注意自动拆箱数据库改 BIGINT 后历史数据变成 1990 而不是 199000迁移脚本没把元转成“分”更新脚本加ROUND(amount * 100)合计字段和明细金额对不上合计字段是冗余存储公式变化后没有触发重算用重算服务统一从明细重新汇总前端显示 1990 分用户以为是 1990 元出参没有把分转成元在 DTO/VO 层统一转换单位用字段命名或注解标示new BigDecimal(19.9).setScale(2)结果不是 19.90构造器源头带入了脏值先保证构造正确再 setScale5.2 金额工具类参考最后给一份我长期在用的 Long 方案工具类把分和元的转换、计算统一收口public final class Money { private Money() {} private static final int SCALE 2; private static final long FEN_PER_YUAN 100L; private static final RoundingMode ROUNDING RoundingMode.HALF_UP; public static long yuanToFen(BigDecimal yuan) { if (yuan null) { return 0L; } return yuan.movePointRight(2).setScale(0, ROUNDING).longValueExact(); } public static long yuanToFen(String yuan) { return yuanToFen(new BigDecimal(yuan)); } public static BigDecimal fenToYuan(long fen) { return BigDecimal.valueOf(fen, 0).movePointLeft(2).setScale(SCALE, ROUNDING); } public static long add(long a, long b) { return a b; } public static long multiply(long a, long b) { return a * b; } public static long divideWithRounding(long numerator, long denominator) { if (denominator 0) { throw new ArithmeticException(除以零异常); } // 先加分母一半实现四舍五入效果 return (numerator denominator / 2) / denominator; } }这套工具类看起来简单但能把团队里的金额计算行为统一收口。比如所有新同事接手代码时不需要去理解 BigDecimal 的规则只需要记住“金额一律用 Long 分 Money 工具类”这一个约定就行。5.3 场景化选型建议说了这么多最后落到选型建议上。我的判断依据不是“哪个更高级”而是“这个场景出错代价多大、团队能不能守住规则”。场景推荐类型电商订单、支付、充值、退款、佣金结算Long以分为单位大数据量报表统计、每日汇总Long性能优势明显财务系统、会计凭证、审计系统BigDecimal 或 Long 分 专门转换层主要看团队习惯对外 OpenAPI 接口参数传输层建议用 Long分避免浮点也可以传输字符串 BigDecimal需要保留超过两位小数的场景如油价、汇率BigDecimalLong 的整数单位无法直接表达分布式系统之间调用接口协议里明确单位内部建议 Long我个人现在的习惯是对外接口和需要展示给用户看的地方语义上用 BigDecimal转成元但内部存储和核心计算一律用 Long分中间由转换层收口。这样既保住了显示层的可读性又保住了计算层的精度和性能团队协作的出错率也压到了最低。很多人在这个选择题上纠结本质上不是在纠结精度而是在纠结“单位”和“团队约束”。Long 方案稍微牺牲一点语义上的直观换来的是简单的规则、清晰的性能和几乎不可能出错的整数运算BigDecimal 方案虽然 API 灵活、语义世界友好但每次代码评审时都要多问一句“你构造 BigDecimal 用的什么参数、除法带不带舍入模式”。踩过几次坑之后我越来越相信一件事在团队协作的项目里选型不是选一个理论最优的方案而是选一个大家不容易用错的方案。