Go 在金融系统中的实践——高精度计算、幂等设计、审计日志
Go 在金融系统中的实践——高精度计算、幂等设计、审计日志
适读人群:正在用 Go 开发金融、支付相关系统的工程师 | 阅读时长:约20分钟 | 核心价值:金融系统中那些"一旦出错就是严重事故"的技术细节,用 Go 怎么做对
那笔因为浮点数丢失的 0.01 元
我第一次接触金融系统相关业务,是接手一个积分兑换功能。系统已经上线了大概3个月,某天运营说积分账本对不上,有一批用户的积分总额和明细加起来差了几分钱。
排查了很久,最后发现是一个非常经典的问题:积分换算比例是 100积分 = 0.01 元,代码里用了 float64 做计算:
// 问题代码
func pointsToMoney(points int) float64 {
return float64(points) / 10000.0 // 100积分=0.01元,即1积分=0.0001元
}当积分数量很大(比如 1,234,567 积分)时,float64 的精度问题开始显现:
1234567 / 10000.0 = 123.45670000000001(不是 123.4567)这个多出来的 0.0000000001 在单次计算里可以忽略,但累计几万次之后,误差就积累起来了。
这是用 Go(或者任何语言)做金融系统的第一课:永远不要用浮点数处理货币金额。
高精度计算:用整数或 decimal
方案一:全部用整数(分为单位)
最简单也最可靠的方案:所有金额以"分"为单位存储和计算,界面展示时再除以100。
// Amount 金额类型,单位:分
type Amount int64
func (a Amount) ToYuan() string {
yuan := a / 100
fen := a % 100
if fen < 0 {
fen = -fen
}
return fmt.Sprintf("%d.%02d", yuan, fen)
}
func ParseAmount(s string) (Amount, error) {
// 将 "123.45" 解析为 12345 分
// 处理各种格式的金额字符串
parts := strings.Split(s, ".")
yuan, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid amount: %s", s)
}
var fen int64
if len(parts) == 2 {
fenStr := parts[1]
if len(fenStr) > 2 {
return 0, fmt.Errorf("too many decimal places: %s", s)
}
if len(fenStr) == 1 {
fenStr = fenStr + "0"
}
fen, err = strconv.ParseInt(fenStr, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid amount: %s", s)
}
}
if yuan >= 0 {
return Amount(yuan*100 + fen), nil
}
return Amount(yuan*100 - fen), nil
}方案二:用 shopspring/decimal
对于需要高精度小数运算的场景(汇率换算、利息计算),用 github.com/shopspring/decimal:
package finance
import (
"fmt"
"github.com/shopspring/decimal"
)
// CalculateInterest 计算利息
// principal: 本金(元)
// annualRate: 年利率(如 0.035 表示 3.5%)
// days: 存期(天)
func CalculateInterest(principal, annualRate decimal.Decimal, days int) decimal.Decimal {
// 日利率 = 年利率 / 365
dailyRate := annualRate.Div(decimal.NewFromInt(365))
// 利息 = 本金 × 日利率 × 天数
interest := principal.Mul(dailyRate).Mul(decimal.NewFromInt(int64(days)))
// 四舍五入到分(2位小数)
return interest.Round(2)
}
func main() {
principal := decimal.NewFromString("10000.00")
annualRate := decimal.NewFromString("0.035")
interest := CalculateInterest(principal, annualRate, 365)
fmt.Printf("利息: %s 元\n", interest.String()) // 350.00 元,精确
}幂等设计:支付不能重复扣款
幂等性是金融系统的生命线。同一笔支付请求,不管因为网络重试被提交多少次,最终只能扣款一次。
幂等键设计
package payment
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// ErrDuplicateRequest 幂等检测到重复请求
var ErrDuplicateRequest = errors.New("duplicate request")
// PaymentResult 支付结果
type PaymentResult struct {
TransactionID string
Amount Amount
Status string
CreatedAt time.Time
}
// PaymentRequest 支付请求
type PaymentRequest struct {
IdempotencyKey string // 幂等键,由客户端生成,UUID v4
UserID string
Amount Amount
Description string
}
// PaymentService 支付服务
type PaymentService struct {
db *sql.DB
}
// Pay 发起支付(幂等)
func (s *PaymentService) Pay(ctx context.Context, req PaymentRequest) (*PaymentResult, error) {
if req.IdempotencyKey == "" {
return nil, fmt.Errorf("idempotency_key is required")
}
// 幂等检查:先查 idempotency_key 是否已存在
existing, err := s.findByIdempotencyKey(ctx, req.IdempotencyKey)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("check idempotency: %w", err)
}
if existing != nil {
// 已经处理过,直接返回之前的结果
// 注意:这里要验证请求参数是否一致,防止同一个 key 被用于不同的请求
if existing.Amount != req.Amount {
return nil, fmt.Errorf("idempotency key %s was used with different amount", req.IdempotencyKey)
}
return existing, nil
}
// 在事务中处理支付
var result *PaymentResult
err = s.withTransaction(ctx, func(tx *sql.Tx) error {
// 锁定用户账户(行锁,防止并发扣款)
balance, err := s.lockUserBalance(ctx, tx, req.UserID)
if err != nil {
return fmt.Errorf("lock balance: %w", err)
}
if balance < req.Amount {
return fmt.Errorf("insufficient balance: have %s, need %s",
balance.ToYuan(), req.Amount.ToYuan())
}
// 扣款
if err := s.deductBalance(ctx, tx, req.UserID, req.Amount); err != nil {
return fmt.Errorf("deduct balance: %w", err)
}
// 记录交易(包含幂等键)
result = &PaymentResult{
TransactionID: generateTransactionID(),
Amount: req.Amount,
Status: "succeeded",
CreatedAt: time.Now(),
}
if err := s.insertTransaction(ctx, tx, req, result); err != nil {
return fmt.Errorf("insert transaction: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *PaymentService) withTransaction(ctx context.Context, fn func(*sql.Tx) error) error {
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable, // 金融场景用最高隔离级别
})
if err != nil {
return err
}
if err := fn(tx); err != nil {
tx.Rollback()
return err
}
return tx.Commit()
}
func (s *PaymentService) findByIdempotencyKey(ctx context.Context, key string) (*PaymentResult, error) {
// 查询数据库
var result PaymentResult
err := s.db.QueryRowContext(ctx,
"SELECT transaction_id, amount, status, created_at FROM transactions WHERE idempotency_key = $1",
key,
).Scan(&result.TransactionID, &result.Amount, &result.Status, &result.CreatedAt)
if err == sql.ErrNoRows {
return nil, sql.ErrNoRows
}
if err != nil {
return nil, err
}
return &result, nil
}
func (s *PaymentService) lockUserBalance(ctx context.Context, tx *sql.Tx, userID string) (Amount, error) {
var balance Amount
err := tx.QueryRowContext(ctx,
"SELECT balance FROM user_accounts WHERE user_id = $1 FOR UPDATE",
userID,
).Scan(&balance)
return balance, err
}
func (s *PaymentService) deductBalance(ctx context.Context, tx *sql.Tx, userID string, amount Amount) error {
result, err := tx.ExecContext(ctx,
"UPDATE user_accounts SET balance = balance - $1 WHERE user_id = $2 AND balance >= $1",
amount, userID,
)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("insufficient balance or account not found")
}
return nil
}
func (s *PaymentService) insertTransaction(ctx context.Context, tx *sql.Tx, req PaymentRequest, result *PaymentResult) error {
_, err := tx.ExecContext(ctx,
`INSERT INTO transactions (transaction_id, user_id, amount, description, idempotency_key, status, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
result.TransactionID, req.UserID, req.Amount, req.Description,
req.IdempotencyKey, result.Status, result.CreatedAt,
)
return err
}
func generateTransactionID() string {
return fmt.Sprintf("txn_%d", time.Now().UnixNano())
}踩坑实录
坑一:事务隔离级别设错,出现幻读
现象: 在高并发下,同一个用户的两次支付请求同时通过了余额检查,都认为余额足够,结果余额扣成了负数。
原因: 默认的 READ COMMITTED 隔离级别无法防止幻读。两个事务同时读到了同一个余额值,都认为可以扣款。
解法: 两种选择:
- 使用 SERIALIZABLE 隔离级别(性能代价)
- 使用乐观锁(version 字段 + WHERE version = $expected)或悲观锁(FOR UPDATE)
我们用的是 FOR UPDATE 行锁,在同一个事务里锁住账户行,防止并发修改。
坑二:审计日志被删了(是真的被物理删除了)
现象: 收到监管要求,需要提供某个时间段所有资金变动记录,但发现有一批记录"不见了"——原来是一个定时清理任务把超过1年的日志删掉了。
原因: 审计日志和普通业务日志用了同一张表,定时清理任务没有区分,全删了。
解法: 审计日志必须单独存放,且必须是追加模式(只允许 INSERT,不允许 UPDATE/DELETE)。可以在数据库层面通过行级安全策略(RLS)或触发器来强制执行。
坑三:金额四舍五入规则不统一导致对账差异
现象: 我们的账单总金额和银行对账单差了 0.01 元。
原因: 我们用的是"四舍五入",而银行某些场景用的是"银行家舍入法"(round half to even)。0.5 → 0 和 0.5 → 1 的规则不一样。
解法: 跟对接的银行/支付机构确认舍入规则,然后在代码里统一:
// 银行家舍入(Go 的 decimal 库支持)
result := amount.RoundBank(2) // 使用银行家舍入
// 普通四舍五入
result := amount.Round(2)
// 永远向上取整(保守策略,用于费用计算)
result := amount.RoundUp(2)审计日志
审计日志在金融系统里是法规要求,也是排查纠纷的重要依据:
package audit
import (
"context"
"encoding/json"
"time"
)
// AuditEvent 审计事件
type AuditEvent struct {
ID string `json:"id"`
EventType string `json:"event_type"` // "payment", "transfer", "refund" 等
UserID string `json:"user_id"`
ActorID string `json:"actor_id"` // 操作者(可能是管理员)
ResourceID string `json:"resource_id"` // 操作的资源
Before json.RawMessage `json:"before,omitempty"` // 操作前的状态
After json.RawMessage `json:"after,omitempty"` // 操作后的状态
Metadata map[string]interface{} `json:"metadata,omitempty"`
IPAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
Timestamp time.Time `json:"timestamp"`
TraceID string `json:"trace_id"` // 关联到 trace,方便排查
}
// AuditLogger 审计日志记录器
type AuditLogger struct {
db *sql.DB
}
// Log 记录审计事件(只写不改不删)
func (l *AuditLogger) Log(ctx context.Context, event AuditEvent) error {
event.Timestamp = time.Now()
data, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("marshal audit event: %w", err)
}
// 使用独立的数据库连接,即使业务事务回滚,审计日志也要保留
// 因此不使用从 context 传入的事务
_, err = l.db.ExecContext(ctx,
`INSERT INTO audit_log (id, event_type, user_id, actor_id, resource_id,
data, ip_address, timestamp, trace_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
event.ID, event.EventType, event.UserID, event.ActorID, event.ResourceID,
data, event.IPAddress, event.Timestamp, event.TraceID,
)
return err
}Go vs Java:金融系统的语言选择
Java 在金融行业有很深的根基,各大银行和支付机构的核心系统几乎都是 Java。Spring 生态、完善的事务管理、成熟的 decimal 库……这些都是 Java 的优势。
Go 在金融场景的优势:更低的延迟(GC 停顿更短)、更低的内存占用、更简单的并发模型。对于延迟敏感的交易系统,Go 比 Java 有优势。
但关键不在于语言,在于开发者对"金融系统不能出错"这件事的认知。浮点数精度、幂等性、审计日志、事务隔离级别——这些是金融系统的基本素养,跟用什么语言无关。
