去年上线一个订单服务,客户反馈某个订单实付金额是 199.99 元,但明细页里赫然写着一串199.98999999999998。查日志发现整条链路都是 Golang 写的,金额字段一路用了strconv.ParseFloat加float64计算,最后再FormatFloat输出时,二进制浮点的尾巴被原样打印了出来。那次之后我才真正意识到,Golang 数值处理这条看似简单实则水深的路,选型错了真的会出事故:标准库strconv是把双刃剑,用好了是瑞士军刀,用不好直接让线上金额对不上账;而decimal这类精度优先的库,才是账务场景的定海神针。这篇文章我就从选型和实战两条线,把strconv与decimal彻底讲透,适合写后端接口、做订单/账务系统,或者正在啃 Golang 八股文的同学。
1. 从一次线上金额事故说起:浮点数的二进制本质
1.1 事故回放与第一轮排查
问题最初不是肉眼看到的,是财务对账脚本先报警的。运营人员的后台订单列表里,大部分金额显示正常,唯独参与"满 200 减 1"的商品,实付金额变成了 199.98999999999998 这样的形式。前端同学说数据是后端给的,后端同学说数据库存的就是这个数,最后定位到:下单逻辑里把 19.99 这个单价做了乘法、加法和多次格式化。日志里抓到的现场是这样的:
func main() { price := strconv.ParseFloat("19.99", 64) // 单价 count := float64(10) // 数量 total := price * count // 199.9,但二进制不是精确的 // 输出看到了什么? fmt.Println(strconv.FormatFloat(total, 'f', -1, 64)) // 实际输出:199.89999999999998 }第一轮排查时我还天真地以为是对账脚本的精度问题,还想过用math.Round(total*100)/100来"修正",结果只是掩盖了表象。因为问题根源不在四舍五入,而在于float64这个类型本身。这个案例特别典型:strconv.ParseFloat把一个十进制字符串转成二进制浮点数,strconv.FormatFloat再把二进制浮点数转回十进制字符串,看起来是对称操作,实际上经过二进制的一进一出,数值已经不是原先那个数了。
1.2 为什么 0.1 在计算机里不是 0.1
float64是 IEEE 754 双精度浮点数,内部由 1 位符号位、11 位指数位和 52 位尾数位组成,总共只能表达 53 位有效二进制位。折算成十进制,大约是 15~17 位有效数字。也就是说,任何十进制小数如果不能用 2 的整数次幂之和精确表达,它在内存里就是"最接近的一个二进制近似值"。
用一个生活化类比:十进制里我们没法用有限位数精确写出 1/3,只能写 0.333333...;二进制里 0.1 同样是个无限循环小数,二进制表示是 0.0001100110011001100110011...,计算机只能截断到某个逼近值。所以经典的0.1 + 0.2在 Go 里执行时会得到:
package main import "fmt" func main() { sum := 0.0 for i := 0; i < 10; i++ { sum += 0.1 } fmt.Println(sum) // 输出:0.9999999999999999(期望是 1.0) }这个例子能解释很多线上问题:不是"Go 的数学算错了",而是浮点数的表示能力有限。float64正确舍入到离 0.1 最近的二进制值,但十次累加之后误差被放大,最终呈现为一个"看起来不对"的十进制值。
1.3 Go 项目里浮点数陷阱的高发区域
我对团队代码做了一次扫描,发现浮点坑基本集中在四类位置:
| 陷阱场景 | 典型写法 | 潜在后果 |
|---|---|---|
| JSON 反序列化数字 | json.Unmarshal到interface{} | 数字被解析为float64,大整数丢精度 |
| map 取值类型断言 | m["price"].(float64) | 金额被 float64 化 |
| 累加/折扣计算 | 折扣率、百分比直接 float64 运算 | 误差逐层累积 |
| 精度比较 | if a == b | 浮点相等判断几乎不可靠 |
第三类在我上一家公司的订单折扣里特别常见:先算原价 * 0.9,再算优惠券抵扣,最后再加配送费,每一步都可能产生微小误差,最后一格式化就露出马脚。这也是为什么 Golang 面试八股里总喜欢问0.1+0.2不等于0.3——因为它是每个后端程序员大概率会踩到的现实问题,而不是纯粹的理论题。
2. strconv:标准库这把快刀的锋利与危险
2.1 核心 API 与性能表现
strconv是 Go 标准库里我最喜欢也最警惕的包。它处理的是字符串与基本数值类型之间的转换,常用入口不多,但每个都值得清楚边界:
// 字符串 -> 数值 n, err := strconv.Atoi("42") // 等价 ParseInt(s, 10, 0) i, err := strconv.ParseInt("ff", 16, 64) // 进制可选 f, err := strconv.ParseFloat("19.99", 64) // 32/64 位 // 数值 -> 字符串 s := strconv.Itoa(42) s2 := strconv.FormatInt(255, 16) s3 := strconv.FormatFloat(3.1415926, 'f', 2, 64)性能方面,strconv系列是这个星球上被优化得最狠的代码之一。它可以做到零内存分配,底层是汇编级的整数快路径。我用 50 万次Atoi做过简单基准,耗时大约在 15ms 量级;ParseFloat稍贵但也可忽略不计。所以在普通业务里,"用strconv慢"从来不是问题,问题只在于转换之后精度是否还能满足业务。
注意Atoi只在十进制整数范围内可用,遇到"1_000"下划线分隔符之类的字符串会直接报错。如果你要处理带进制的字符串,ParseInt更灵活。
2.2 ParseFloat 的精度边界:转换后的数值不再是原来的数值
strconv.ParseFloat("19.99", 64)返回值是float64。你可能会觉得它"很准",因为打印出来常常就是19.99。原因是 Go 的ParseFloat实现了正确的舍入:它找到离 19.99 最近的二进制浮点数。但"最近的二进制浮点数"并不等于 19.99 本身,它实际上是:
19.99000000000000198951966012828052043914794921875这就是为什么后续做乘法、累加时误差会暴露。最危险的不是读入的那一瞬间,而是"读入之后参与运算"。你从接口、配置文件或者数据库读到一个"19.99",如果只是原样展示,FormatFloat的-1精度会输出最短的、能唯一定位到该浮点值的十进制串,往往看起来就是"19.99"。但一旦参与加减乘除,结果可能就不再"干净"。
我一个朋友在写聚合统计时,把一堆ParseFloat出来的数值做累加,最后差了几毛钱对不上账。排查时发现其中一个数值来自ParseFloat("0.1", 64),累加一百次后误差被放大。这不是 bug,是数学。
2.3 FormatFloat 的格式策略:一图看懂格式化陷阱
FormatFloat的格式参数有'f'、'e'、'E'、'g'、'G'等,其中'f'表示十进制小数形式,'g'表示根据指数自动选择%e或%f。精度prec传-1时使用最短表示。下面这段代码可以直接验证:
func main() { f := 0.30000000000000004 // 这是 0.1 + 0.2 的 float64 结果 fmt.Println(strconv.FormatFloat(f, 'f', -1, 64)) // 0.30000000000000004 fmt.Println(strconv.FormatFloat(f, 'f', 2, 64)) // 0.30 fmt.Println(strconv.FormatFloat(f, 'g', -1, 64)) // 0.30000000000000004 }prec=-1的结果是"诚实"的:它把这个二进制浮点数的十进制尾数全部展示出来。很多同学用prec=-1写日志,结果把浮点误差暴露得明明白白。反过来,prec=2做了舍入,看起来"正常"了,但它只是改变显示,不会改变内部数值。这就是"格式化不能治病"的原因:显示层四舍五入,底层误差仍在传播。
很少有人注意到strconv末尾的 bitSize 参数。ParseFloat(s, 32)返回的依然是float64,但底层按float32精度舍入;FormatFloat也一样,bitSize 决定的是舍入参照。如果我按float32精度解析了大金额,有效数字只有 7 位左右,百万以上的金额就会开始出现明显误差。我的建议是:金额类一律用 64 bit,但最好根本别用 float。
2.4 用 strconv 处理数值的三个安全边界
结合我自己的项目经验,我给strconv划定三条安全边界:
- 字符串到整数是它最舒适的区域。用户 ID、订单号、分页页码、数据库自增主键,用
Atoi或ParseInt完全没问题。 - 浮点只做"一次性读取与展示"。需要打印一个从外部读来的浮点指标时,
ParseFloat+FormatFloat可以接受,因为中间不参与运算。 - 金额、余额、费率、库存绝对值,一律不要经过
ParseFloat。只要涉及多次运算、比较、JSON 传输,浮点都会放大误差。
边界之外,strconv 不该被指望去解决十进制精度问题。它解决的是"字符串和数值的语法转换",不是"数值精度的数学保障"。理解这点,就能明白标题里"双刃剑"的含义:strconv 很锋利,但用错地方就会伤人。
3. decimal:精度优先的"重武器"
3.1 shopspring/decimal 的核心原理与正确打开方式
github.com/shopspring/decimal是目前 Go 社区使用最广泛的十进制运算库。它的核心设计并不复杂:内部用一个*big.Int保存有效数字,再用一个int32保存小数位数(指数),即用value × 10^exp的形式精确表达十进制数。相比float64的二进制近似,它从根上避免了二进制尾数问题。
但这里有个非常隐蔽的坑:decimal.NewFromFloat。如果你传入一个float64,精度其实已经在传入前损失了:
d := decimal.NewFromFloat(0.29) fmt.Println(d.String()) // 输出可能是 0.29000000000000000346,而不是 0.29在 CLI 里跑出来有时看起来是 0.29,具体取决于版本和格式化逻辑,但风险在于:你无法保证传入的float64一定具有足够的十进制精度。正确姿势是尽量从字符串或整数构造:
d1, _ := decimal.NewFromString("0.29") // 推荐:字符串入口 d2 := decimal.NewFromInt(29) // 推荐:整数入口 d3 := decimal.NewFromFloat(0.29) // 不推荐:可能失真我们团队后来立了规矩:所有外部数据(请求参数、数据库 DECIMAL 字段、第三方接口返回)进入decimal时,必须走NewFromString或NewFromInt,禁止NewFromFloat。
3.2 算术运算 API:正确的地基
decimal的基本运算与float64语法不同,但思路不难。它遵循不可变设计,每个运算返回新值,不修改原值,这也让并发安全有天然保障:
price, _ := decimal.NewFromString("19.99") count := decimal.NewFromInt(10) total := price.Mul(count) // 199.90 total = total.Round(2) // 显式保留两位 rate, _ := decimal.NewFromString("0.85") afterDiscount := total.Mul(rate).Round(2)最需要警惕的是除法。浮点除法无所谓,decimal的除法必须指定商的小数位数,否则会panic或返回错误。推荐使用DivRound:
a, _ := decimal.NewFromString("10") b, _ := decimal.NewFromString("3") fmt.Println(a.DivRound(b, 4)) // 3.3333,四舍五入 fmt.Println(a.DivRound(b, 2)) // 3.33DivRound的第二个参数是保留位数,第三个可选参数是舍入模式。默认ROUND_HALF_UP是四舍五入,这是中国财务系统最常见的模式。如果你做银行类业务,可能要用ROUND_HALF_EVEN(银行家舍入)。这个细节在面试里也是进阶考点——很多八股文只会问"decimal 是什么",进阶会问"除法怎么处理精度"。
3.3 与 JSON、数据库、Gin 框架的集成
shopspring/decimal实现了encoding/json的MarshalJSON和UnmarshalJSON,因此可以直接在结构体里使用,JSON 输出是数字而非字符串:
type Order struct { ID int64 `json:"id"` Amount decimal.Decimal `json:"amount"` } o := Order{ID: 1, Amount: decimal.RequireFromString("199.99")} b, _ := json.Marshal(o) fmt.Println(string(b)) // {"id":1,"amount":"199.99"} -- 注意:输出格式取决于版本,有的版本带引号,有的不带这里我有必要多说一句:不同版本行为有差异。老版本MarshalJSON可能输出带引号的字符串,新版本趋向输出纯数字。如果你的前端对类型敏感,一定要做一次端到端测试。它的反序列化同时接受 JSON 数字和 JSON 字符串,兼容性不错。
数据库层面,decimal.Decimal实现了driver.Valuer和sql.Scanner,所以可以直接作为字段写入 MySQL 的DECIMAL(10,2):
type OrderModel struct { ID int64 Amount decimal.Decimal } // 写入时直接作为参数传 db.Exec("INSERT INTO orders (id, amount) VALUES (?, ?)", 1, order.Amount) // 读取时直接扫描 var amount decimal.Decimal rows.Scan(&amount)如果是 GORM,decimal.Decimal也能直接映射,但为了保险建议自定义类型(见后文实战部分)。Gin 的c.JSON与标准库json.Marshal行为一致,所以前面说的输出格式问题在 Gin 里同样存在。
3.4 性能代价与优化姿势:慢,但没那么可怕
decimal的代价确实存在。我用一个简单基准测试对比过float64加法和decimal加法:在 100 万次循环里,float64加法大约 1ms,decimal.Decimal加法大约 30~60ms,差距几十倍。如果是DivRound,因为内部涉及大整数除法,差距可能会拉到百倍以上。
但对绝大多数业务接口来说,这个差距完全不构成瓶颈。一个订单接口可能涉及几十次金额运算,多消耗几百微秒,用户根本感知不到。真正需要注意的是不要在大循环里反复创建decimal对象:
// 不推荐:循环内转换字符串 for _, item := range items { price, _ := decimal.NewFromString(item.PriceStr) sum = sum.Add(price.Mul(count)) } // 推荐:能预先转换就预先转换,循环内只做运算 prices := make([]decimal.Decimal, len(items)) for i, item := range items { prices[i], _ = decimal.NewFromString(item.PriceStr) }另一个实用技巧:如果业务只需要精确到"分",可以全部换算成整数int64存储和运算,只在展示层转换。但这种方案遇到打折、汇率、积分比例时会很痛苦,所以我的建议是——纯整分计数用int64,复杂金额运算用decimal,各管一段。
4. 选型决策:什么场景该用 strconv,什么场景该上 decimal
4.1 一张决策矩阵
同样一个需求,选错工具不是不能跑,而是给未来埋雷。我整理了下面这张表格,基本覆盖日常后端开发的高频场景:
| 场景 | 推荐工具 | 理由 |
|---|---|---|
| 字符串转订单号/用户ID/页码 | strconv.Atoi/ParseInt | 整数没有精度问题,性能最高 |
| 读入数据显示统计图 | strconv.ParseFloat+FormatFloat | 一次性读取,不参与复杂运算 |
| 商品单价、订单金额、退款金额 | decimal | 精度必须可控 |
| 折扣率、税率、积分比例 | decimal | 百分比乘法容易产生无限小数 |
| 库存扣减场景 | 建议int64(按最小库存单位) | 避免浮点累计误差,同时高效 |
| 外部 API 传入的数字 | decimal.NewFromString | 无法信任对方 JSON 数字精度 |
| 科学计算、图形坐标 | float64 | 精度要求低于性能要求 |
注意"库存扣减"我特意写了建议int64而不是decimal。因为库存通常是整数,按最小单位(比如个/克)用整数做加减最自然,也最快。只有某些特殊库存(比如按长度计价的布匹)才需要decimal。
4.2 混合使用:存储、展示、统计各取所需
选型不是非此即彼,成熟的工程会分层混用。以我做过的一个订单系统为例:
- 存储层:数据库
DECIMAL(10,2),Go 结构体用decimal.Decimal。 - 计算层:金额、折扣一律
decimal,所有舍入用Round(2)统一策略。 - 展示层:把
decimal转成string或保留两位小数后输出,前端拿到的永远是干净的199.99。 - 统计层:如果只是取大概量级的 PV、UV、平均值,直接用
float64,没人在意 3.333333 和 3.33 的差别。
但这里有个关键约束:decimal转float64可以(用于统计),float64转decimal要小心(经NewFromFloat会失真)。所以链路方向是单向的:外部数据 -> decimal -> 展示/存储 -> 必要时转 float 统计,而不是反过来。
4.3 四个常见误区,逐个击破
误区一:"我用 fmt.Sprintf("%.2f", amount) 格式化一下就安全了。"
格式化只影响输出,不影响内部数值。如果你把格式化后的字符串再 parse 回 float64 继续运算,误差依旧存在。
误区二:"decimal 太慢,生产环境不能用。"
真实业务接口里,几十次 decimal 运算的开销在毫秒级以下。除非你在做高频量化交易或者每请求百万级运算,否则完全可用。省下的精度事故排查时间,远比这点性能值钱。
误区三:"decimal.NewFromFloat(19.99) 就是 19.99。"
前面验证过,NewFromFloat(19.99)可能得到19.9900000000000019...。这不是库的问题,是float64在入口处就已经失真了。用字符串构造才最稳。
误区四:"JSON 解析出来的数字都是精确的。"
encoding/json解析数字到interface{}时默认使用float64。一个 20 位的雪花 ID 或大金额,解析完可能就变成1.2345678901234567e+19了。这也是面试里判断 map 值类型考点背后真正的现实意义。
5. 实战落地:一个订单金额链路的改造记录
5.1 需求与改造目标
业务线要求重构一个下单接口:原价、折扣价、配送费、实付金额四类字段全部统一为两位小数,数据库字段是DECIMAL(10,2),前端需要 JSON 数字输出。改造前代码里混用float64和strconv,已经出现过至少两次对账异常。
改造目标很明确:请求进入后所有金额一律decimal化,计算过程中禁止出现float64,入库使用 MySQL DECIMAL,出参使用 JSON 数字。下面我会按步骤走完整条链路。
5.2 从请求参数开始:一律字符串进入
第一步是约束输入。前端传过来的金额字段,在 DTO 里直接定义为string,或者直接定义成decimal.Decimal并靠反序列化处理。我更推荐后者,因为decimal.Decimal能直接处理 JSON 数字和 JSON 字符串两种格式:
type CreateOrderReq struct { ProductID int64 `json:"product_id"` Price decimal.Decimal `json:"price"` Count int `json:"count"` }注意Count这种整数项继续用int,只有金额项用decimal。如果担心前端传了奇怪格式导致反序列化失败,也可以先收成string,再用NewFromString手动转换并返回明确错误。二选一,但别用float64接收。
第二步是内部计算。折扣率可以用decimal常量定义,避免魔法数字:
var discount = decimal.RequireFromString("0.85") func CalcActualAmount(price decimal.Decimal, count int) decimal.Decimal { total := price.Mul(decimal.NewFromInt(int64(count))) afterDiscount := total.Mul(discount) // 加上 5 元配送费 final := afterDiscount.Add(decimal.NewFromInt(5)) return final.Round(2) }每步都调用Round(2)可以保证中间值不会出现超过两位的小数,这在财务对账时非常重要。如果不加 Round,0.85 * 19.99的结果可能是16.9915,最后虽然Round(2)会变成16.99,但中间计算里多出的位数可能在后续运算里再次放大。
5.3 数据库读写与 Gin 输出
数据库直接放decimal.Decimal字段,利用它自带的Scan/Value能力:
type Order struct { ID int64 `gorm:"primaryKey"` Amount decimal.Decimal `gorm:"type:decimal(10,2);column:amount"` }如果遇到 NULL 值,decimal.Decimal扫描会报错。我的方案是使用sql.NullString做中转,或者使用decimal.NullDecimal(库自带):
type Order struct { Amount decimal.NullDecimal `gorm:"type:decimal(10,2);column:amount"` }NullDecimal是新版本提供的,兼顾 NULL 语义和精度,强烈建议使用。这一点在真实项目里很容易被忽略,我就在改造时踩过一次 NULL 导致的扫描崩溃。
Gin 输出方面,直接c.JSON即可:
c.JSON(http.StatusOK, gin.H{ "order_id": order.ID, "amount": order.Amount, })但要确认你使用的shopspring/decimal版本对MarshalJSON的行为。我们生产环境最终升级到了最新版,测试确认输出为纯数字199.99,前端可以直接使用。
5.4 回归测试:精度断言怎么写
数值改造最怕偷偷摸摸引入行为变化,所以回归测试格外重要。我习惯用表驱动测试,断言比较一律用decimal的Cmp或Equal,而不是转成 float64 再比较:
func TestCalcActualAmount(t *testing.T) { price := decimal.RequireFromString("19.99") cases := []struct { name string count int want string }{ {"single", 1, "21.99"}, // 19.99*0.85 + 5 = 21.99 {"ten", 10, "174.92"}, // 169.9150 -> 174.9150 -> Round -> 174.92 } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := CalcActualAmount(price, tc.count) want := decimal.RequireFromString(tc.want) if !got.Equal(want) { t.Fatalf("got %s, want %s", got, want) } }) } }这里有个很关键的习惯:want也用字符串构造,不要写want := decimal.NewFromFloat(21.99),否则测试本身就可能带了浮点误差。所有断言入口统一从字符串转decimal,整个链路的精度才能自洽。
5.5 改造中真实踩过的三个坑
第一个坑:NewFromFloat(0.29)得到0.29000000000000000346。当时有位同事用它做优惠金额,入库后看起来是 0.29,但和财务系统对账时差了 3e-18 元级别的最小误差。虽然很小,但对账程序严格按 decimal 比较,直接报错。解决方式是把所有金额初始化改为NewFromString。
第二个坑:decimal.Decimal扫描数据库NULL时直接报错。因为Decimal的Scan方法遇到nil会返回错误。改成NullDecimal后问题消失,但 API 出参需要额外做.Decimal取值,别忘记判空。
第三个坑:Gin 输出超长数字时变成科学计数法。某个订单历史金额 1234567890123.45,MarshalJSON输出成了1.23456789012345e+12这种形式。虽然不是非法 JSON,但前端老版本格式化组件不认。最终我们限制展示字段在出参前Round(2),并做了一次字符串转换,确保输出始终是常规小数形式。这个坑提醒我:decimal 输出层的格式也需要测试覆盖。
6. 高频考点与未来:类型断言、新版本与团队规范
6.1 面试高频题:如何判断 map[string]interface{} 中的值类型
这个话题几乎出现在所有 Golang 八股文清单里,但它真的不只是面试题。json.Unmarshal到map[string]interface{}时,所有数字都默认解析为float64,如果你直接用类型断言取金额:
m := map[string]interface{}{} json.Unmarshal([]byte(`{"price": 19.99}`), &m) // 常见的错误写法 price, ok := m["price"].(float64) fmt.Println(price) // 19.99 但底层有浮点误差 // 更稳的做法:判断类型 switch v := m["price"].(type) { case float64: d := decimal.NewFromFloat(v) // 但这里已经失真,最好用 strconv.FormatFloat 再转 fmt.Println(d) case string: d, _ := decimal.NewFromString(v) fmt.Println(d) }如果接口可能返回字符串形式的金额(很多第三方平台会这样设计),case string分支就是必要的。若是纯数字,float64分支里我会先用strconv.FormatFloat(v, 'f', -1, 64)转成字符串,再decimal.NewFromString,这样至少把原始二进制值完整保留下来再转十进制,比直接用NewFromFloat更安全。
这个考点背后真正想考察的,是你是否理解 Go 接口的动态类型机制,以及是否意识到 JSON 数值隐含的精度风险。能回答到"float64 不是精确值"这一层,面试官通常就满意了。
6.2 Go 新版本对数值处理的影响与团队基建
Go 1.24 及近几个版本在性能上持续优化了strconv的转换路径,标准库的math/rand/v2也改进了随机数实现。但对业务程序员的数值处理逻辑来说,核心原则并没有因为版本变化而改变:浮点是近似值,十进制业务用 decimal。版本更新带来的更多是工具链体验和性能红利,而不是语义变化。
我最近在做的一件小事,是在团队公共库里封装两个函数,把整个数值处理入口统一起来。一个负责"字符串安全转 decimal",另一个负责"decimal 安全输出字符串"。这样新人不会随手NewFromFloat,代码评审也只需要盯少数几个文件:
func MustDecimalFromString(s string) decimal.Decimal { d, err := decimal.NewFromString(s) if err != nil { panic("invalid decimal string: " + s) } return d } func DecimalToString(d decimal.Decimal) string { return d.Round(2).StringFixed(2) }这套基础设施虽然简单,却能在多个服务里复用,也天然规避了最常见的 NewFromFloat 误用。加上统一的 Docker 镜像 Go 版本,团队内部不会再出现"我本地是 1.24 没毛病,上线 1.21 输出怎么变了"之类的诡异问题。
如果让我给团队定一条铁律,我会写:金额字段从进水到出水,只走 decimal;strconv 只处理整数和纯展示型的浮点指标。这条规则执行了一年,财务对账异常单数量直接归零。数值处理的双刃剑从来不是工具本身的问题,而是使用者在哪个场景抽出了哪把刀刃。strconv 锋利、轻便、无处不在,适合边界转换;decimal 厚重、精确、略慢,适合核心账务。真正成熟的工程师,不是只会背 API,而是能在每次动手前先回答一个问题:这个数字,允许误差吗?不允许,就老老实实用 decimal——这是我踩过无数次坑之后,最想说的一句话。