前言:一个“0.1 + 0.2”引发的血案
在任何一个程序员的职业生涯中,几乎都踩过浮点数精度的坑。当你写下 fmt.Println(0.1 + 0.2),期待得到 0.3 时,屏幕上却赫然显示着 0.30000000000000004。在日常业务中,这点误差或许可以忽略一笑,但在金融系统里,这微小的偏差就是“地雷”。
想象一下,一个日交易量百万笔的支付系统,每笔交易都因为精度问题丢失或多出万分之一的金额,一天下来就是一笔巨大的资损,足以让任何一家公司陷入审计和合规的噩梦。
因此在金融系统开发中,我们必须确立一条铁律:永远不要使用浮点数(如 float64****)来存储和计算货币金额。
那我们该如何做选择呢?当我们用 Go 作为后端语言,配合 PostgreSQL 或 MySQL 作为数据库时,该如何规范、安全、高效地处理 USD、CNY、BTC、ETH 这些形态各异的货币呢?本文将彻底厘清这个问题,给你一份拿来即用的实战指南。
内容:两条正道与 Go 的实现
抛开浮点数这个“伪选项”,我们面前有两条康庄大道:数据库层面的定点数类型,以及以最小单位为基准的整数存储。
方案一:DECIMAL/****NUMERIC - 直观与精确的平衡
这是最符合人类直觉的方案。它将金额以我们熟悉的小数形式精确地存储在数据库中,从根本上杜绝了二进制浮点数的精度问题。
在数据库中如何设计?
无论是 PostgreSQL 还是 MySQL,都提供了标准的定点数类型。
- PostgreSQL: 使用
NUMERIC(precision, scale)或DECIMAL(precision, scale)。 - MySQL: 使用
DECIMAL(precision, scale)(NUMERIC是其同义词)。
precision 是总位数,scale 是小数位数。为了兼容从只有 2 位小数的法币到有 18 位小数的 ETH,我们可以设计一个足够大的字段,如 DECIMAL(36, 18)。
表结构对比:
| 特性 | PostgreSQL | MySQL |
|---|---|---|
| 主键 | BIGSERIAL PRIMARY KEY | BIGINT AUTO_INCREMENT PRIMARY KEY |
| 金额字段 | balance NUMERIC(36, 18) | balance DECIMAL(36, 18) |
| 更新时间 | updated_at TIMESTAMPTZ | updated_at TIMESTAMP |
| 唯一索引 | UNIQUE(user_id, currency_code) | UNIQUE KEY uniq_user_currency (user_id, currency_code) |
PostgreSQL 建表示例:
CREATE TABLE user_wallets (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
currency_code VARCHAR(10) NOT NULL,
balance NUMERIC(36, 18) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, currency_code)
);
MySQL 建表示例:
CREATE TABLE user_wallets (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
currency_code VARCHAR(10) NOT NULL,
balance DECIMAL(36, 18) NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_user_currency (user_id, currency_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Go 语言如何适配?
Go 的原生类型中没有与 DECIMAL 直接对应的,但社区为我们提供了完美的解决方案:shopspring/decimal 库。
go get github.com/shopspring/decimal
这个库可以与 database/sql 无缝集成,让你在 Go 代码中直接操作高精度的十进制数。
核心转账逻辑示例:
package main
import (
"database/sql"
"fmt"
"log"
"github.com/shopspring/decimal"
// 根据数据库选择驱动
_ "github.com/lib/pq" // PostgreSQL
// _ "github.com/go-sql-driver/mysql" // MySQL
)
// ... 数据库连接代码 ...
func addBalance(db *sql.DB, userID int64, currency string, amountStr string) error {
addAmount, err := decimal.NewFromString(amountStr)
if err != nil {
return fmt.Errorf("invalid amount: %w", err)
}
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback() // 安全起见
var currentBalance decimal.Decimal
// 注意占位符的差异: PostgreSQL ($1, $2) vs MySQL (?, ?)
query := "SELECT balance FROM user_wallets WHERE user_id = $1 AND currency_code = $2 FOR UPDATE"
// query := "SELECT balance FROM user_wallets WHERE user_id = ? AND currency_code = ? FOR UPDATE" // MySQL
err = tx.QueryRow(query, userID, currency).Scan(¤tBalance)
if err != nil {
return err
}
newBalance := currentBalance.Add(addAmount)
updateQuery := "UPDATE user_wallets SET balance = $1 WHERE user_id = $2 AND currency_code = $3"
// updateQuery := "UPDATE user_wallets SET balance = ? WHERE user_id = ? AND currency_code = ?" // MySQL
_, err = tx.Exec(updateQuery, newBalance, userID, currency)
if err != nil {
return err
}
return tx.Commit()
}
方案二:****BIGINT - 极致性能的最小单位法
这是高频交易和加密货币领域非常流行的方案。核心思想是:放弃小数,所有金额都以货币的最小单位作为整数存储。
100.50USD ->10050(单位:分)1.23456789BTC ->123456789(单位:聪)10.123456789012345678ETH ->10123456789012345678(单位:Wei)
数据库设计:
将金额字段就设计为简单的 BIGINT。
CREATE TABLE user_wallets_minimal_unit (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
currency_code VARCHAR(10) NOT NULL,
balance BIGINT NOT NULL, -- 核心变化:整数
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_user_currency (user_id, currency_code)
);
Go 语言如何配合?
在 Go 中,我们直接使用 int64。但为了代码的可维护性和可读性,强烈建议封装一个 Money 结构体。
package main
import (
"fmt"
"math/big"
)
// 定义每种货币的精度(小数位数)
var currencyExponents = map[string]int{
"USD": 2,
"CNY": 2,
"BTC": 8,
"ETH": 18,
}
// Money 封装了金额和货币类型
type Money struct {
Amount int64 // 最小单位的金额
Currency string // 货币代码
}
// NewMoney 从字符串创建 Money,确保转换过程无损
func NewMoney(amountStr string, currency string) (*Money, error) {
exp, ok := currencyExponents[currency]
if !ok {
return nil, fmt.Errorf("unsupported currency: %s", currency)
}
// 使用 big.Rat 避免转换过程中的精度问题
rat := new(big.Rat)
_, ok = rat.SetString(amountStr)
if !ok {
return nil, fmt.Errorf("invalid amount string: %s", amountStr)
}
multiplier := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(exp)), nil)
rat.Mul(rat, new(big.Rat).SetInt(multiplier))
if !rat.IsInt() {
return nil, fmt.Errorf("amount %s has too many decimal places for %s", amountStr, currency)
}
return &Money{
Amount: rat.Num().Int64(),
Currency: currency,
}, nil
}
// ToString 格式化为易读字符串
func (m *Money) ToString() string {
exp := currencyExponents[m.Currency]
divisor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(exp)), nil)
rat := new(big.Rat).SetFrac(big.NewInt(m.Amount), divisor)
return rat.FloatString(exp)
}
func main() {
btc, _ := NewMoney("0.00000001", "BTC")
fmt.Printf("Created: %s\\n", btc.ToString()) // 输出: Created: 0.00000001
add, _ := NewMoney("0.5", "BTC")
newBtc, _ := btc.Add(add)
fmt.Printf("New balance: %s\\n", newBtc.ToString()) // 输出: New balance: 0.50000001
}
查询、计算与统计
- 查询:两种方案都非常直接。
- 计算:黄金法则:绝不在 SQL 中进行跨货币计算! 正确做法是在应用层选定一个基准货币(如 USD),获取汇率,将所有涉及的金额统一转换为基准货币后,再进行加减运算。
- 统计:
SUM(),AVG()等聚合函数对两种方案都完美支持。DECIMAL方案直接得到可读结果。BIGINT方案得到的是最小单位的总和,需要应用层用Money结构体格式化后展示。
结论
回到最初的问题,在金融系统中处理货币,我们有两个强大的武器库:
DECIMAL+shopspring/decimal:这是普适性最强、开发效率最高的方案。它在精度、直观性和可维护性上取得了绝佳平衡,是绝大多数金融应用的首选。BIGINT+int64+ 自定义结构体:这是追求极致性能的方案,尤其适合加密货币交易所这类对计算速度要求极高的场景。它以牺牲部分直观性为代价,换取了最快的整数运算性能。
无论在产品种选择哪种方式,请牢记以下最佳实践:
- 铁律:永不使用浮点数。
- 事务:所有金额变更操作必须在数据库事务中完成,并使用
SELECT ... FOR UPDATE防止并发冲突。 - 审计:建立详细的交易流水表,记录每一笔资金的来龙去脉,这是对账和追溯的基石。
- API:前后端交互时,金额字段统一使用字符串类型,避免 JavaScript 等客户端的精度问题。
- MySQL:如果你用 MySQL,请立刻检查并开启严格模式。
构建金融系统,责任重大。对金钱的敬畏,应始于对数据类型的正确选择。希望这篇指南,能帮助你在 Go 的世界里,稳稳地托举起每一分钱的价值。
