Derick
1849 words
9 minutes
关于金融系统货币存储与计算的小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)

表结构对比:

特性PostgreSQLMySQL
主键BIGSERIAL PRIMARY KEYBIGINT AUTO_INCREMENT PRIMARY KEY
金额字段balance NUMERIC(36, 18)balance DECIMAL(36, 18)
更新时间updated_at TIMESTAMPTZupdated_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(&currentBalance)
    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.50 USD -> 10050 (单位:分)
  • 1.23456789 BTC -> 123456789 (单位:聪)
  • 10.123456789012345678 ETH -> 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 结构体格式化后展示。

结论#

回到最初的问题,在金融系统中处理货币,我们有两个强大的武器库:

  1. DECIMAL + shopspring/decimal:这是普适性最强、开发效率最高的方案。它在精度、直观性和可维护性上取得了绝佳平衡,是绝大多数金融应用的首选。
  2. BIGINT + int64 + 自定义结构体:这是追求极致性能的方案,尤其适合加密货币交易所这类对计算速度要求极高的场景。它以牺牲部分直观性为代价,换取了最快的整数运算性能。

无论在产品种选择哪种方式,请牢记以下最佳实践:

  • 铁律:永不使用浮点数。
  • 事务:所有金额变更操作必须在数据库事务中完成,并使用 SELECT ... FOR UPDATE 防止并发冲突。
  • 审计:建立详细的交易流水表,记录每一笔资金的来龙去脉,这是对账和追溯的基石。
  • API:前后端交互时,金额字段统一使用字符串类型,避免 JavaScript 等客户端的精度问题。
  • MySQL:如果你用 MySQL,请立刻检查并开启严格模式

构建金融系统,责任重大。对金钱的敬畏,应始于对数据类型的正确选择。希望这篇指南,能帮助你在 Go 的世界里,稳稳地托举起每一分钱的价值。

关于金融系统货币存储与计算的小Tips
https://blog.ithuo.net/posts/golang-financial-currency-storage-guide/
Author
Derick
Published at
2025-01-13