一笔 19.90 美元的退款,变成了 19.899999
「刚好」是一个面向独立开发者的订阅工具。用户用美元付款,月费 19.90 美元,支持随时退款。某个周一,财务在退款流水里看到一笔 19.899999999999999 的退款,和原订单差了不到一厘钱。金额不大,但对账脚本按“退款合计等于原订单”做校验,这条记录直接把当天的对账任务卡住了。
研发先怀疑是支付渠道的问题,拉出渠道回执,渠道返回的退款金额是 1990 分,没有问题。问题出在自家服务:订单金额被存成了 float64,计算退款时先除以 30 再乘以剩余天数,浮点误差在中间步骤累积,最后写回数据库时就变成了 19.899999999999999。
同一天还有第二个投诉。一位在德国的用户把界面语言切成中文,看到价格显示为 $19.90,但他实际支付的是欧元账户扣款后的美元账单;客服解释说“你看到的是美元”,用户更困惑了:为什么中文界面不能显示人民币?这两个问题看起来都关于“国际化”,其实是三条不同的轴:语言、区域格式、货币。把它们混在一起,代码就会在某个边界上给出错误答案。
金额、locale 与币种是三个独立维度
先把概念拆开。语言决定文案,locale 决定数字和日期的呈现格式,币种决定金额本身的单位与精度。三者可以任意组合:中文界面可以标美元,英文界面可以标日元,德国 locale 可以用逗号做小数点。服务端不能假设“用户说中文就一定用人民币”。
| 做法 | 存储是否精确 | 跨币种是否安全 | 展示是否可控 | 典型问题 |
|---|---|---|---|---|
float64 存金额 | 否 | 否 | 否 | 0.1+0.2 不等于 0.3,退款出现长尾小数 |
字符串存 "19.90" | 是 | 部分 | 否 | 参与计算要反复解析,比较和汇总容易出错 |
| 最小单位整数 | 是 | 是 | 是 | 必须显式记录币种与精度 |
| 整数 + 固定两位 | 是 | 否 | 部分 | 日元没有小数位,固定两位会多出 100 倍 |
| 整数 + 币种精度表 | 是 | 是 | 是 | 新增币种要维护精度,未知币种要报错 |
表里最后一行才是本章采用的模型:金额存 int64 的最小单位,币种单独存三位代码,格式化时再查精度表。人民币和美元的最小单位是分,日元是元,这个差异不能靠“统一乘 100”糊弄过去。
时区是第四条轴,但它只影响展示。订单的创建时间应该以 UTC 或带偏移的时间戳存储,展示时再按用户时区渲染。把本地时间直接存进数据库,会在夏令时切换、跨区查询和导出报表时制造重复或缺失的小时。本章的代码不处理时区,但后面讨论多区域部署时会反复遇到它。
失败场景:浮点金额在退款链路里累积误差
回到开头的事故。订单服务用 float64 存金额,退款服务按比例计算:refund := amount / float64(totalDays) * float64(usedDays)。单看一次计算,误差在小数点后十五位,打印出来是 19.899999999999999。财务系统把它四舍五入到两位,显示成 19.90,看起来没问题;但对账脚本读的是原始值,和渠道回执的 1990 分比较时不相等,任务失败。
更隐蔽的是汇总。日报把一万笔退款相加,每笔差几十亿分之一,累计误差可能让总额差出几分钱。排查时没人会怀疑加法,只会怀疑渠道少退了。修复方式不是“显示时四舍五入”,而是从写入那一刻起就用整数最小单位:1990 就是 1990,退款按整数分配,余数显式落到某一方。
还有一类错误发生在币种边界。商品目录用美元定价,结算时按汇率换成人民币,代码把 1990 直接当成人民币分处理,结果用户被扣了 19.90 元而不是约 145 元。金额的数值和币种必须一起传递,不能只传一个 int64 让下游猜。
用 Money、MinorUnits 与 Format 固定口径
代码把“金额是什么”和“金额怎么显示”分开。Money 同时携带最小单位数值和币种;MinorUnits 是币种精度的唯一查询入口;Format 只负责把两者渲染成字符串,不做任何汇率换算。
// file: internal/i18n/locale.go
// tier: L1
// verify: go test ./internal/i18n/
package i18n
import (
"errors"
"fmt"
"strings"
)
// Money 表示一笔金额。AmountMinor 以最小货币单位存储:人民币和美元是分,
// 日元是元。金额永远不用 float64 表示,避免 0.1+0.2 之类的表示误差。
type Money struct {
AmountMinor int64
Currency string
}
var (
// ErrUnknownCurrency 表示币种不在支持列表内。
ErrUnknownCurrency = errors.New("i18n: unknown currency")
// ErrUnknownLocale 表示 locale 不在支持列表内。
ErrUnknownLocale = errors.New("i18n: unknown locale")
// ErrInvalidCurrency 表示币种不是三位大写字母。
ErrInvalidCurrency = errors.New("i18n: invalid currency code")
)
type currencyInfo struct {
minor int
symbol string
}
var currencies = map[string]currencyInfo{
"CNY": {minor: 2, symbol: "CN¥"},
"USD": {minor: 2, symbol: "$"},
"JPY": {minor: 0, symbol: "¥"},
}
// localeInfo 只描述“怎么显示”,不决定“显示什么货币”。
// 同一个 USD 金额可以在 en-US 和 de-DE 下显示成不同的数字格式。
type localeInfo struct {
suffix bool // 货币符号是否放在数字后面,例如 de-DE。
group string // 千位分隔符。
point string // 小数点。
}
var locales = map[string]localeInfo{
"zh-CN": {group: ",", point: "."},
"en-US": {group: ",", point: "."},
"ja-JP": {group: ",", point: "."},
"de-DE": {suffix: true, group: ".", point: ","},
}
// MinorUnits 返回币种的最小单位位数。CNY 和 USD 是 2(分),JPY 是 0(元)。
func MinorUnits(currency string) (int, error) {
if len(currency) != 3 {
return 0, fmt.Errorf("%w: %q", ErrInvalidCurrency, currency)
}
for _, r := range currency {
if r < 'A' || r > 'Z' {
return 0, fmt.Errorf("%w: %q", ErrInvalidCurrency, currency)
}
}
info, ok := currencies[currency]
if !ok {
return 0, fmt.Errorf("%w: %q", ErrUnknownCurrency, currency)
}
return info.minor, nil
}
// Format 把最小单位金额格式化成带符号与小数位的字符串。
// locale 只影响千位分隔符、小数点位置和货币符号前后;
// 币种决定小数位数,两者是彼此独立的两个维度。
func Format(m Money, locale string) (string, error) {
if _, err := MinorUnits(m.Currency); err != nil {
return "", err
}
loc, ok := locales[locale]
if !ok {
return "", fmt.Errorf("%w: %q", ErrUnknownLocale, locale)
}
info := currencies[m.Currency]
sign := ""
var amount uint64
if m.AmountMinor < 0 {
sign = "-"
// 先加一再取反,避免 math.MinInt64 直接取反溢出。
amount = uint64(-(m.AmountMinor + 1)) + 1
} else {
amount = uint64(m.AmountMinor)
}
scale := uint64(1)
for i := 0; i < info.minor; i++ {
scale *= 10
}
whole := amount / scale
frac := amount % scale
digits := groupDigits(whole, loc.group)
if info.minor > 0 {
digits += loc.point + leftPad(frac, info.minor)
}
if loc.suffix {
return sign + digits + " " + info.symbol, nil
}
return sign + info.symbol + digits, nil
}
func groupDigits(v uint64, sep string) string {
s := fmt.Sprintf("%d", v)
if sep == "" {
return s
}
var b strings.Builder
for i, r := range s {
if i > 0 && (len(s)-i)%3 == 0 {
b.WriteString(sep)
}
b.WriteRune(r)
}
return b.String()
}
func leftPad(v uint64, width int) string {
s := fmt.Sprintf("%d", v)
if len(s) >= width {
return s
}
return strings.Repeat("0", width-len(s)) + s
}
实现里有几个刻意的选择:
AmountMinor用int64,不用float64。最小单位是整数,加减和比较都精确;负数是合法输入,表示退款或冲正。MinorUnits先校验长度和字符,再查表。"cny"、"C1Y"、"CN"都会报ErrInvalidCurrency,不会悄悄转成大写后继续。- 币种表把符号和精度放在一起,新增币种只改一处。日元精度是 0,格式化时不会补
.00。 Format先校验币种再校验 locale。未知币种返回ErrUnknownCurrency,未知 locale 返回ErrUnknownLocale,调用方能区分是哪一类配置错误。- locale 只影响分组符、小数点位置和符号前后。
de-DE把符号放到数字后面,使用.分组、,作小数点;同一个美元金额因此有两种合法写法。 - 负数处理先把
MinInt64加一取反,避免直接取反溢出。这个边界平时遇不到,但账务系统里“全额退款”很容易撞上极端值。
Format 不接收汇率,也不接收时区。汇率是交易链路的事,时区是时间展示的事,把它们塞进格式化函数会让单元测试变得不可控。一个函数只做一件事,出错时才知道该查哪里。
测试:精度、负数与 locale 的交叉组合
测试覆盖三类输入:已知币种的精度、非法币种代码、以及同一金额在不同 locale 下的输出。金额用例里特意放了负数和零,因为退款和赠品都会走到这两个分支。
// file: internal/i18n/locale_test.go
// tier: L1
// verify: go test ./internal/i18n/
package i18n
import (
"errors"
"testing"
)
func TestMinorUnits(t *testing.T) {
cases := []struct {
currency string
want int
}{
{"CNY", 2},
{"USD", 2},
{"JPY", 0},
}
for _, c := range cases {
got, err := MinorUnits(c.currency)
if err != nil {
t.Fatalf("MinorUnits(%q) error = %v", c.currency, err)
}
if got != c.want {
t.Fatalf("MinorUnits(%q) = %d, want %d", c.currency, got, c.want)
}
}
}
func TestMinorUnitsRejectsBadCode(t *testing.T) {
cases := []struct {
currency string
want error
}{
{"EUR", ErrUnknownCurrency},
{"cny", ErrInvalidCurrency},
{"C1Y", ErrInvalidCurrency},
{"CN", ErrInvalidCurrency},
}
for _, c := range cases {
if _, err := MinorUnits(c.currency); !errors.Is(err, c.want) {
t.Fatalf("MinorUnits(%q) = %v, want %v", c.currency, err, c.want)
}
}
}
func TestFormat(t *testing.T) {
cases := []struct {
name string
money Money
locale string
want string
}{
{"cny", Money{AmountMinor: 123456, Currency: "CNY"}, "zh-CN", "CN¥1,234.56"},
{"usd", Money{AmountMinor: 5, Currency: "USD"}, "en-US", "$0.05"},
{"jpy-no-decimals", Money{AmountMinor: 1234, Currency: "JPY"}, "ja-JP", "¥1,234"},
{"refund", Money{AmountMinor: -987, Currency: "USD"}, "en-US", "-$9.87"},
{"suffix-locale", Money{AmountMinor: 123456, Currency: "USD"}, "de-DE", "1.234,56 $"},
{"zero", Money{AmountMinor: 0, Currency: "JPY"}, "en-US", "¥0"},
}
for _, c := range cases {
got, err := Format(c.money, c.locale)
if err != nil {
t.Fatalf("%s: Format error = %v", c.name, err)
}
if got != c.want {
t.Fatalf("%s: Format = %q, want %q", c.name, got, c.want)
}
}
}
func TestFormatRejectsUnknown(t *testing.T) {
if _, err := Format(Money{Currency: "EUR"}, "en-US"); !errors.Is(err, ErrUnknownCurrency) {
t.Fatalf("EUR error = %v, want ErrUnknownCurrency", err)
}
if _, err := Format(Money{Currency: "USD"}, "fr-FR"); !errors.Is(err, ErrUnknownLocale) {
t.Fatalf("fr-FR error = %v, want ErrUnknownLocale", err)
}
if _, err := Format(Money{Currency: "cny"}, "zh-CN"); !errors.Is(err, ErrInvalidCurrency) {
t.Fatalf("lowercase error = %v, want ErrInvalidCurrency", err)
}
}
func TestFormatKeepsFractionDigits(t *testing.T) {
got, err := Format(Money{AmountMinor: 100, Currency: "CNY"}, "zh-CN")
if err != nil {
t.Fatalf("Format error = %v", err)
}
if got != "CN¥1.00" {
t.Fatalf("Format = %q, want CN¥1.00", got)
}
}
TestMinorUnits 断言 CNY/USD 返回 2、JPY 返回 0。TestMinorUnitsRejectsBadCode 把未知币种和格式错误分开:EUR 是合法代码但不在支持列表,cny 和 C1Y 连格式都不合法。TestFormat 用六个用例覆盖人民币、美元、日元、退款、德语 locale 和零值。TestFormatRejectsUnknown 确认未知币种与未知 locale 各自返回可识别的错误。TestFormatKeepsFractionDigits 专门盯住 100 分必须显示成 CN¥1.00,而不是 CN¥1。
这些测试不依赖当前时区、汇率或系统 locale,因此在 CI 里可以稳定重复。真正需要集成测试的是“金额从下单到退款是否始终以最小单位传递”,那属于交易链路的测试,不在这个包的范围内。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/i18n/ -run 'TestMinorUnits|TestFormat' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestMinorUnits
--- PASS: TestMinorUnits (0.00s)
=== RUN TestMinorUnitsRejectsBadCode
--- PASS: TestMinorUnitsRejectsBadCode (0.00s)
=== RUN TestFormat
--- PASS: TestFormat (0.00s)
=== RUN TestFormatRejectsUnknown
--- PASS: TestFormatRejectsUnknown (0.00s)
=== RUN TestFormatKeepsFractionDigits
--- PASS: TestFormatKeepsFractionDigits (0.00s)
PASS
ok webapp/internal/i18n 0.226s
PASS 表示 5 个金额与格式化用例全部通过。它证明的是本章这套简化模型:整数存储、币种精度查表、locale 只做渲染。
小结
金额永远以最小单位整数存储,币种随金额一起传递,格式化时才查精度并套用 locale。语言、locale、币种、时区是四条独立的轴,任何一条被当成另一条的代理,都会在跨币种或跨区场景里出错。下一节讨论一个更强的约束:数据本身能不能离开某个区域。
阅读导航:上一节:目录与学习路径 · 下一节:11.2 数据驻留与合规 。
常见错误与边界
- 用
float64存金额。单笔看起来正常,汇总或按比例退款时会出现长尾小数,对账脚本首当其冲。 - 把金额统一乘 100。日元没有小数位,乘 100 后数值放大一百倍,展示和扣款都会错。
- 只传数值不传币种。下游只能猜,猜错就是把 19.90 美元当成 19.90 元人民币。
- 用 locale 推断币种。中文界面不等于人民币,德语界面也不等于欧元,locale 和 currency 必须分别传递。
- 未知币种静默回退到两位小数。新增币种时错误会被掩盖,直到财务发现金额差了两个数量级。
- 用本地时区存时间。夏令时切换会出现重复小时,跨区报表无法对齐。
- 只在展示层四舍五入。存储里的误差仍然存在,参与后续计算时会继续放大。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。