关于金融系统货币存储与计算的小Tips
前言:一个“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 的世界里,稳稳地托举起每一分钱的价值。
Share
If this article helped you, please share it with others!
Some content may be outdated