一、用硬币的例子来理解问题
计算机的"硬币"只能不断对半分
想象你只有这些硬币:1元、5角、2角5分、1角25分、继续对半分...
现在问题来了:
凑2.5元:
1元 + 1元 + 0.5元 = 2.5元 ✓ 完美凑齐
凑0.1元(1角):
ini
试试 0.125(1角2分5)太大,不要
试试 0.0625(6分多) 太大,不要
试试 0.03125(3分多) 太小了
试试 0.0625 + 0.03125 = 0.09375 还差一点
试试 0.09375 + 0.00625 = 0.1 但0.00625又凑不出来...
无限循环,永远凑不齐 ✗ 只能近似 0.09999...
这就是计算机的处理方式
计算机内部只会"除以2"的运算(二进制),就像上面只能不断对半分的硬币。
结论:
- 能用"除以2"凑出来的数 → 可以精确存储
- 凑不出来的数 → 只能近似,永远有误差
二、看看真实的例子
能精确表示的数
| 十进制 | 为什么能精确? | 二进制 |
|---|---|---|
| 0.5 | 1 ÷ 2 | 0.1 |
| 0.25 | 1 ÷ 4(4=2×2) | 0.01 |
| 0.125 | 1 ÷ 8(8=2×2×2) | 0.001 |
| 2.5 | 5 ÷ 2 | 10.1 |
| 3.75 | 15 ÷ 4(4=2×2) | 11.11 |
不能精确表示的数
| 十进制 | 为什么不能? | 二进制 |
|---|---|---|
| 0.1 | 1 ÷ 10(10=2×5,含有5) | 0.000110011... 无限循环 |
| 0.2 | 2 ÷ 10(10=2×5,含有5) | 0.001100110... 无限循环 |
| 0.3 | 3 ÷ 10(10=2×5,含有5) | 0.010011001... 无限循环 |
所以会出现这种情况
javascript
// JavaScript 示例
0.1 + 0.2 = 0.30000000000000004 // 不等于 0.3
为什么? 因为0.1和0.2在计算机里根本就不是真正的0.1和0.2,只是近似值。两个近似值相加,当然不等于0.3。
三、实际场景中的灾难
场景1:电商订单计算
假设一个商品单价 0.1元,用户买了3个:
ini
错误做法(用 double):
price = 0.1
quantity = 3
total = price × quantity = 0.1 × 3 = 0.30000000000000004
用户看到:应付 0.30000000000000004 元
场景2:优惠券满减
满100元减10元,用户购物车刚好100元:
ini
错误做法:
商品1:33.33元
商品2:33.33元
商品3:33.34元
总计:33.33 + 33.33 + 33.34 = 100.00000000000001元
系统判断:100.00000000000001 > 100,满足条件,减10元
但实际可能因为精度问题,有时候判断不满足条件
正确的解决方案:最后一项用减法
java
// 错误做法:所有商品都独立计算
items[0].price = 33.33; // 商品1
items[1].price = 33.33; // 商品2
items[2].price = 33.34; // 商品3(人为凑整)
total = 33.33 + 33.33 + 33.34 = 100.00000000000001 // 有误差
// 正确做法:最后一项 = 总额 - 前面所有项
BigDecimal totalAmount = new BigDecimal("100.00"); // 目标总额
BigDecimal item1 = new BigDecimal("33.33");
BigDecimal item2 = new BigDecimal("33.33");
// 最后一项用减法计算,而不是单独赋值
BigDecimal item3 = totalAmount.subtract(item1).subtract(item2);
// item3 = 33.34,完全精确
// 验证:item1 + item2 + item3 = 100.00 完美!
为什么这样做?
假设订单总额是 100元,有3个商品:
ini
直接分配法(错误):
商品1 = 100 / 3 = 33.33333... → 存储为 33.33
商品2 = 100 / 3 = 33.33333... → 存储为 33.33
商品3 = 100 / 3 = 33.33333... → 存储为 33.33
合计 = 33.33 + 33.33 + 33.33 = 99.99 ✗ 少了0.01元
最后一项用减法(正确):
商品1 = 33.33
商品2 = 33.33
商品3 = 100 - 33.33 - 33.33 = 33.34 (把尾差归到最后一项)
合计 = 33.33 + 33.33 + 33.34 = 100.00 ✓ 完全精确
实际应用场景:
- 订单拆分:总价100元拆成3个子订单
- 优惠分摊:优惠10元要分摊到5个商品上
- 积分分配:100积分分配给多个用户
- 发票金额:发票总额要等于各明细之和
场景3:长期累加误差
python
错误做法:
sum = 0.0
for i in range(1000):
sum += 0.1
结果:sum ≈ 100.00000000000141 // 而不是 100.0
误差累积了 0.00000000000141
四、解决方案:两种主流做法
方案1:用整数存储(存"分")
把所有金额都乘以100,变成"分"来存储。
代码示例:
java
// 用 Long 存储分
Long priceInCent = 250L; // 2.50元
Long quantity = 3L;
Long totalInCent = priceInCent * quantity; // 750分
// 展示时转成元
BigDecimal totalYuan = new BigDecimal(totalInCent)
.divide(new BigDecimal(100), 2, RoundingMode.HALF_UP);
// 结果:7.50元
优点: 完全避免小数、运算速度快、永远不会有精度问题
缺点: 要记得单位是"分"、显示时要转换、不直观
方案2:用 BigDecimal(Java)
专门为精确计算设计的类。
java
// 创建 BigDecimal(注意:用字符串,不要用 double)
BigDecimal price = new BigDecimal("0.1"); // 正确
BigDecimal price = new BigDecimal(0.1); // 错误!还是会有精度问题
// 加法
BigDecimal total = new BigDecimal("0.1")
.add(new BigDecimal("0.2"));
// 结果:精确的 0.3
// 乘法
BigDecimal amount = new BigDecimal("0.1")
.multiply(new BigDecimal("3"));
// 结果:精确的 0.3
// 除法(必须指定精度和舍入规则)
BigDecimal result = new BigDecimal("10")
.divide(new BigDecimal("3"), 2, RoundingMode.HALF_UP);
// 结果:3.33(保留2位,四舍五入)
BigDecimal 为什么能解决问题?
原理:用十进制字符串存储,而不是二进制浮点数
arduino
double 存储方式:
0.1 → 转成二进制 → 0.00011001100110011...(无限循环)→ 截断存储 → 有误差
BigDecimal 存储方式:
"0.1" → 直接存储为 "1" + "小数点位置1" → 不转二进制 → 完全精确
BigDecimal 内部结构:
java
// BigDecimal 内部其实是这样存储的
public class BigDecimal {
private BigInteger intVal; // 整数部分:存储所有有效数字
private int scale; // 小数位数:小数点位置
// 比如 123.45 存储为:
// intVal = 12345
// scale = 2(小数点后2位)
// 实际值 = intVal / (10^scale) = 12345 / 100 = 123.45
}
举例说明:
ini
存储 0.1:
intVal = 1
scale = 1
实际值 = 1 / 10^1 = 0.1 ✓ 完全精确
存储 123.456:
intVal = 123456
scale = 3
实际值 = 123456 / 10^3 = 123.456 ✓ 完全精确
为什么加减乘除也精确?
java
// 0.1 + 0.2 的计算过程
BigDecimal a = new BigDecimal("0.1"); // intVal=1, scale=1
BigDecimal b = new BigDecimal("0.2"); // intVal=2, scale=1
// 加法运算:
// 1. 统一小数位数(已经都是1位)
// 2. 整数相加:1 + 2 = 3
// 3. 保持scale=1
// 结果:intVal=3, scale=1 → 3/10 = 0.3 ✓ 完全精确
// 对比 double:
double a = 0.1; // 0.1000000000000000055...
double b = 0.2; // 0.2000000000000000111...
// a + b = 0.3000000000000000444... ✗ 有误差
关键点:
- 不走二进制:BigDecimal 完全在十进制体系内运算
- 用整数存储:内部用 BigInteger(任意精度整数)存储有效数字
- 记录小数位:用 scale 记住小数点位置
- 手动控制精度:除法时必须指定 scale 和舍入模式
为什么创建时要用字符串?
java
// 错误做法
BigDecimal wrong = new BigDecimal(0.1);
// 0.1 先被转成 double(已经有误差了)
// 然后 BigDecimal 存储这个有误差的 double
// 结果:0.1000000000000000055... ✗
// 正确做法
BigDecimal right = new BigDecimal("0.1");
// 直接解析字符串 "0.1"
// 存储为 intVal=1, scale=1
// 结果:精确的 0.1 ✓
五、开源项目怎么做的?
从 mall、RuoYi、eladmin 等项目中总结出来的实战经验。
实战代码模式
1. 订单金额计算(mall项目)
java
// 计算订单应付金额 = 商品总额 + 运费 - 各种优惠
private BigDecimal calcPayAmount(OmsOrder order) {
BigDecimal payAmount = order.getTotalAmount() // 商品总额
.add(order.getFreightAmount()) // + 运费
.subtract(order.getPromotionAmount()) // - 促销优惠
.subtract(order.getCouponAmount()) // - 优惠券
.subtract(order.getIntegrationAmount()); // - 积分抵扣
return payAmount;
}
2. 购物车小计(mall项目)
java
// 计算商品总额 = Σ(单价 × 数量)
private BigDecimal calcTotalAmount(List<OmsOrderItem> orderItemList) {
BigDecimal totalAmount = new BigDecimal("0"); // 注意:用字符串 "0"
for (OmsOrderItem item : orderItemList) {
BigDecimal itemTotal = item.getProductPrice()
.multiply(new BigDecimal(item.getProductQuantity()));
totalAmount = totalAmount.add(itemTotal);
}
return totalAmount;
}
3. 通用计算工具(RuoYi项目)
java
// 封装好的精确运算工具
public class Arith {
// 加法
public static double add(double v1, double v2) {
BigDecimal b1 = new BigDecimal(Double.toString(v1));
BigDecimal b2 = new BigDecimal(Double.toString(v2));
return b1.add(b2).doubleValue();
}
// 除法(默认保留10位小数)
public static double div(double v1, double v2, int scale) {
BigDecimal b1 = new BigDecimal(Double.toString(v1));
BigDecimal b2 = new BigDecimal(Double.toString(v2));
return b1.divide(b2, scale, RoundingMode.HALF_UP).doubleValue();
}
}
// 使用
double result = Arith.add(0.1, 0.2); // 精确的 0.3
4. 元和分互转(eladmin项目)
java
// 分 → 元
public static BigDecimal centsToYuan(Object obj) {
BigDecimal cents = toBigDecimal(obj);
return cents.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
}
// 元 → 分
public static Long yuanToCents(Object obj) {
BigDecimal yuan = toBigDecimal(obj);
return yuan.multiply(BigDecimal.valueOf(100))
.setScale(0, RoundingMode.HALF_UP)
.longValue();
}
数据库设计
所有金额字段统一用 DECIMAL 类型:
sql
CREATE TABLE `order` (
`id` bigint(20) NOT NULL,
`total_amount` decimal(10,2) DEFAULT NULL COMMENT '商品总额',
`freight_amount` decimal(10,2) DEFAULT NULL COMMENT '运费',
`pay_amount` decimal(10,2) DEFAULT NULL COMMENT '应付金额',
PRIMARY KEY (`id`)
);
代码生成器自动映射:decimal → BigDecimal 、 numeric → BigDecimal
六、最佳实践总结
黄金规则(必须遵守)
| 层级 | 正确做法 | 错误做法 |
|---|---|---|
| 数据库 | DECIMAL(10,2) |
FLOAT / DOUBLE |
| Java实体 | BigDecimal 或 Long(分) |
double / float |
| 创建对象 | new BigDecimal("0.1") |
new BigDecimal(0.1) |
| 比较大小 | compareTo() |
equals() |
1. 数据类型选择
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 数据库字段 | DECIMAL(10,2) |
精确存储,不丢失精度 |
| Java实体类 | BigDecimal |
精确计算 |
| 高性能场景 | Long(存分) |
整数运算快,完全精确 |
| 绝对不要用 | double / float |
会有精度问题 |
2. 创建 BigDecimal 的正确姿势
java
// 正确做法
BigDecimal price1 = new BigDecimal("0.1"); // 用字符串 ✓
BigDecimal price2 = BigDecimal.valueOf(0.1); // 用 valueOf ✓
// 错误做法
BigDecimal price3 = new BigDecimal(0.1); // 直接用 double ✗
// 从其他类型转换
BigDecimal price4 = new BigDecimal(Double.toString(0.1)); // double先转字符串
3. 四则运算规范
java
// 初始化累加器
BigDecimal sum = new BigDecimal("0"); // 或 BigDecimal.ZERO
// 加法
sum = sum.add(new BigDecimal("0.1"));
// 减法
sum = sum.subtract(new BigDecimal("0.05"));
// 乘法
BigDecimal total = price.multiply(new BigDecimal(quantity));
// 除法(必须指定精度)
BigDecimal avg = total.divide(
new BigDecimal(count),
2, // 保留2位小数
RoundingMode.HALF_UP // 四舍五入
);
4. 精度控制(重要)
java
// 保留2位小数,四舍五入
BigDecimal result = value.setScale(2, RoundingMode.HALF_UP);
// 常用舍入模式
RoundingMode.HALF_UP // 四舍五入(最常用)
RoundingMode.DOWN // 直接舍去
RoundingMode.UP // 直接进位
RoundingMode.HALF_EVEN // 银行家舍入(遇到.5时,舍入到最近的偶数)
5. 比较大小
java
// 不要用 equals(会比较精度)
if (price1.equals(price2)) { } // 0.1 和 0.10 会返回 false
// 用 compareTo
if (price1.compareTo(price2) == 0) { } // 相等
if (price1.compareTo(price2) > 0) { } // price1 > price2
if (price1.compareTo(price2) < 0) { } // price1 < price2
6. 封装工具类(推荐)
java
public class MoneyUtils {
// 加法
public static BigDecimal add(Object... values) {
BigDecimal result = BigDecimal.ZERO;
for (Object value : values) {
result = result.add(toBigDecimal(value));
}
return result.setScale(2, RoundingMode.HALF_UP);
}
// 乘法
public static BigDecimal multiply(Object v1, Object v2) {
return toBigDecimal(v1)
.multiply(toBigDecimal(v2))
.setScale(2, RoundingMode.HALF_UP);
}
// 除法
public static BigDecimal divide(Object v1, Object v2) {
return toBigDecimal(v1)
.divide(toBigDecimal(v2), 2, RoundingMode.HALF_UP);
}
// 统一转换
private static BigDecimal toBigDecimal(Object value) {
if (value instanceof BigDecimal) {
return (BigDecimal) value;
}
if (value instanceof String) {
return new BigDecimal((String) value);
}
if (value instanceof Integer || value instanceof Long) {
return new BigDecimal(value.toString());
}
if (value instanceof Double) {
return BigDecimal.valueOf((Double) value);
}
throw new IllegalArgumentException("不支持的类型");
}
}
7. 完整的业务流程规范
前后端数据流转:
| 阶段 | 数据类型 | 说明 |
|---|---|---|
| 前端传入 | 字符串 "2.50" | JSON传输用字符串 |
| Controller | BigDecimal | 接收后立即转换 |
| Service | BigDecimal | 所有计算用BigDecimal |
| 计算工具 | MoneyUtils | 统一工具类 |
| 返回结果 | BigDecimal | setScale(2, HALF_UP) |
| Mapper | BigDecimal | 映射到数据库 |
| 数据库 | decimal(10,2) | 精确存储 |
| 返回前端 | 字符串 | JSON序列化 |
8. 常见场景检查清单
订单金额计算:
- 商品单价用 BigDecimal
- 数量乘以单价用 multiply
- 累加用 add,初始值用 BigDecimal.ZERO
- 最后 setScale(2, HALF_UP)
金额分摊(重要):
- 总金额要等于各项之和
- 最后一项用减法:lastItem = total - sum(前面所有项)
- 不要所有项都用除法,会累积尾差
优惠券满减:
- 门槛金额用 BigDecimal
- 比较大小用 compareTo
- 减免金额用 subtract
- 结果判断 ≥ 0
积分抵扣:
- 积分换算比例用 BigDecimal
- 最大抵扣金额限制用 min
- 实际抵扣金额保留2位小数
Excel导出金额:
- 导出前统一 setScale
- 格式化成字符串
- 避免科学计数法
9. 金额分摊的完整示例
场景:订单总额100元,有3个商品,需要分摊优惠10元
java
public class OrderSplitExample {
public static void main(String[] args) {
// 订单原始数据
BigDecimal totalAmount = new BigDecimal("100.00"); // 订单总额
BigDecimal discount = new BigDecimal("10.00"); // 优惠金额
BigDecimal payAmount = totalAmount.subtract(discount); // 实付金额 90元
// 3个商品的原价
List<BigDecimal> itemPrices = Arrays.asList(
new BigDecimal("30.00"),
new BigDecimal("40.00"),
new BigDecimal("30.00")
);
// 分摊优惠到各个商品
List<BigDecimal> itemDiscounts = splitDiscount(discount, itemPrices);
// 计算各商品实付金额
System.out.println("商品分摊结果:");
BigDecimal checkSum = BigDecimal.ZERO;
for (int i = 0; i < itemPrices.size(); i++) {
BigDecimal itemPay = itemPrices.get(i).subtract(itemDiscounts.get(i));
System.out.println("商品" + (i+1) + ": 原价=" + itemPrices.get(i)
+ ", 优惠=" + itemDiscounts.get(i)
+ ", 实付=" + itemPay);
checkSum = checkSum.add(itemPay);
}
System.out.println("实付合计:" + checkSum);
System.out.println("验证:" + checkSum.compareTo(payAmount) + " (0表示相等)");
}
/**
* 分摊优惠金额到各个商品
* 最后一项用减法,确保合计精确
*/
public static List<BigDecimal> splitDiscount(
BigDecimal totalDiscount,
List<BigDecimal> itemPrices) {
List<BigDecimal> result = new ArrayList<>();
// 计算商品总价
BigDecimal totalPrice = BigDecimal.ZERO;
for (BigDecimal price : itemPrices) {
totalPrice = totalPrice.add(price);
}
// 前 n-1 项:按比例计算优惠
BigDecimal allocated = BigDecimal.ZERO;
for (int i = 0; i < itemPrices.size() - 1; i++) {
// 该商品优惠 = 总优惠 × (该商品价格 / 总价)
BigDecimal itemDiscount = totalDiscount
.multiply(itemPrices.get(i))
.divide(totalPrice, 2, RoundingMode.HALF_UP);
result.add(itemDiscount);
allocated = allocated.add(itemDiscount);
}
// 最后一项:用减法(关键)
BigDecimal lastDiscount = totalDiscount.subtract(allocated);
result.add(lastDiscount);
return result;
}
}
/* 输出结果:
商品1: 原价=30.00, 优惠=3.00, 实付=27.00
商品2: 原价=40.00, 优惠=4.00, 实付=36.00
商品3: 原价=30.00, 优惠=3.00, 实付=27.00
实付合计:90.00
验证:0 (0表示相等)
完美!分摊后的金额完全精确
*/
核心技巧总结:
- 前n-1项用公式计算(可能有尾差)
- 最后一项用减法兜底(把尾差归集到最后)
- 确保总和完全精确(不会出现差1分钱的情况)
适用场景:
- 订单金额拆分
- 优惠金额分摊
- 退款金额分配
- 佣金比例分配
- 发票金额明细
- 任何需要"总额 = 明细之和"的场景
七、总结
一句话总结
计算机的"硬币"只能不断对半分,凑不出0.1这样的数,所以算钱千万别用 double,要用 BigDecimal 或存"分"。
记住三个核心
- 为什么有问题:二进制只能表示"能被2整除"的小数,0.1、0.2、0.3这些凑不出来
- 用什么解决:BigDecimal(Java)或 Long存分
- 怎么使用:字符串创建、显式精度、封装工具类
最关键的一条铁律
arduino
涉及金额的地方,绝对不要用 double 和 float
如果你现在项目里还在用 double price,赶紧改成 BigDecimal price,否则迟早会出现用户投诉"订单金额不对"的问题。