导读(3行收益):金额计算最常见的三个事故:浮点误差让账单差一分、把总额分摊到明细后对不上账、各端舍入规则不统一结果不一致。本文给出分单位存储、统一舍入、分摊兜底三条规范,附对照表和可直接复制的代码。看完能直接落地到订单、结算等模块。
一、金额为什么不能直接用浮点存
二进制浮点无法精确表示十进制小数,经典例子:
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(19.9 * 100); // 1989.9999999999998
单个误差看起来很小,但金额会经历累计、比较、舍入三步放大:累加 1000 笔明细误差累积;amount === 0 判断失败;四舍五入时 0.00499999 与 0.005 结果不同。所以金额必须走「整数 + 定点」路线,而不是依赖浮点精度。
二、规范一:分单位存储,全程用整数
金额从入库到计算到出参,统一用「分」这个最小单位:
CREATE TABLE `order_item` (
`id` BIGINT PRIMARY KEY,
`order_no` VARCHAR(32) NOT NULL,
`unit_amount` BIGINT NOT NULL COMMENT '单价,单位:分',
`quantity` INT NOT NULL,
`total_amount` BIGINT NOT NULL COMMENT '小计,单位:分',
`discount_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '优惠,单位:分',
KEY `idx_order` (`order_no`)
) COMMENT='订单明细(金额一律以分存储)';
配套约定:
- 数据库字段用
BIGINT,注释里写明「单位:分」; - 接口传输用整数或字符串,避免 JSON 数字精度丢失(
{"amount": 1990}而不是19.9); - 只有展示层做「分转元」,转换逻辑收敛到一个工具函数,禁止各端各自换算;
- 前端输入「元」,提交前在前端或服务端统一转分,转分用
Math.round(元 * 100)而不是parseInt。
三、规范二:统一舍入规则,只在边界舍入
舍入方式对照:
| 规则 | 示例(2 位) | 适用场景 |
|---|---|---|
| 四舍五入 | 2.345 → 2.35 | 面向用户的最终展示金额 |
| 银行家舍入 | 2.345 → 2.34 | 统计、对账,误差更均匀 |
| 向上取整 | 2.341 → 2.35 | 运费、手续费下限 |
| 截断 | 2.349 → 2.34 | 一般不建议用于金额 |
关键原则:中间过程不舍入,只在入账和最终展示时舍入一次。先乘后除、先加后舍,避免每步舍入累积误差:
// 错误:每件商品先单独舍入再相加
const bad = items.reduce((s, it) => s + Math.round(it.amount * 0.9), 0);
// 正确:先汇总后统一舍入
const good = Math.round(items.reduce((s, it) => s + it.amount * 0.9, 0));
四、规范三:分摊误差有兜底,最后一件补差
把总额分摊到明细是误差重灾区:100 元分成 3 件,每件 33.33,三件合计 99.99,差 1 分。处理方式:先按分整除分摊,最后一件用总额减已分摊部分补齐:
function splitAmount(totalCents, count) {
const base = Math.floor(totalCents / count);
const results = new Array(count).fill(base);
let sum = base * count;
for (let i = 0; i < count && sum < totalCents; i++) {
results[i] += 1; // 余数逐件 +1 分,直到补平
sum += 1;
}
return results; // 合计恒等于 totalCents
}
配套兜底:
- 分摊结果做一次断言:
sum === totalCents,不一致直接抛错而不是静默; - 分摊比例与明细金额留日志,方便对账时定位「差一分」的来源;
- 优惠分摊(满减、折扣按比例摊到明细)同样先算总额再摊,不要逐件单独算。
优惠分摊举个实际例子:订单总额 100 元、满足满 100 减 10,三件商品金额 50/30/20。正确做法是先算优惠后总额 90,再按 50:30:20 的比例把 90 分摊到三件,余数补到首件;错误做法是每件商品单独算「原价 9 折」再四舍五入,三件分别 45/27/18,合计 90 恰好,但换成 99.99 元拆三件的场景就必然差 1 分。所以「先汇总后分摊」必须写成分摊函数的固定顺序,不能靠各端自觉。
五、单元测试用例清单
金额模块建议用以下用例做回归,缺一不可:
| 用例 | 输入 | 期望 |
|---|---|---|
| 浮点经典 | 0.1+0.2 转分 | 30,而非 30.000000000000004 |
| 大额安全 | 90071992547409.91 元转分 | 不溢出(用字符串/大整数) |
| 负数金额 | -19.99 元转分 | -1999 |
| 舍入边界 | 2.345 与 2.3450 | 结果一致(避免精度抖动) |
| 分摊恒等 | 10000 分拆 3 件 | 合计恒等于 10000 |
| 分摊余数 | 10 分拆 3 件 | 4/3/3,合计 10 |
六、踩坑清单
- [ ] 数据库用
DOUBLE/FLOAT存金额; - [ ] 前端直接
parseFloat拼接计算; - [ ] 各端舍入规则不一致(一端四舍五入一端截断);
- [ ] 每步计算都舍入,误差层层累积;
- [ ] 分摊余数直接丢掉或随机分配,导致合计不等;
- [ ] JSON 传输大额金额用数字类型,超出安全整数范围丢失精度。
七、工程落地建议
金额计算规范建议从「建表分单位 + 统一舍入工具函数 + 分摊断言」三件套开始,先覆盖订单、结算等核心模块再逐步铺开。若团队没有现成交易底座,可基于成型平台(如乔拓云商城)的金额处理能力快速起步,重点核对单位、舍入与分摊策略是否符合上述规范。
八、复盘清单(可直接抄走)
- [ ] 全链路是否只有「分」一种金额表达;
- [ ] 舍入是否只发生在入账/展示边界;
- [ ] 分摊是否有「合计恒等」断言与日志;
- [ ] 单元测试是否覆盖 0.1+0.2、大额、负数、分摊余数场景。
结语
金额计算的坑不在算法复杂,而在「浮点 + 随意舍入 + 无兜底」三个习惯。分单位存储、边界舍入、分摊断言三条规范落地后,账单对不上这类问题基本绝迹。本文仅作技术分享,具体功能以各平台官方实时信息为准。