# DDD 实战:支付中锁定汇率如何修复四个关键 Bug
在支付系统中,汇率计算出错会导致财务不符和信任丧失。一个真实的 Go 项目案例展示了,在聚合根中固定汇率的领域模型如何解决那些标准 DDD 示例中不易察觉的问题。
一个支付中的三个汇率问题
原始架构从多个来源拉取汇率:checkout-api 从 Redis 获取数据,payment-service 通过 HTTP 从 fx-rate-service 获取,ledger-service 从 Postgres 快照获取。这导致了差异:客户看到 12,430.00 KZT,银行收取 12,435.27 KZT,客服在管理面板看到 12,428.91 KZT。每个服务在技术上都是正确的,但整个系统产生了不一致的数据。
临时修复——针对大额支付禁用 checkout-api 的缓存——将 p95 延迟从 120 ms 恶化到 500 ms。永久解决方案引入了领域实体 Quote,它将汇率、金额和有效期锁定为业务契约。现在支付通过 quote_id 创建,而不是动态数据。这消除了在扣款或账本记录期间重新计算汇率的可能性。
type Quote struct {
ID string
UserID string
From Money
To Money
Rate RateSnapshot
CreatedAt time.Time
}
func NewPayment(quote Quote, idemKey string) (*Payment, error) {
if time.Now().After(quote.Rate.ExpiresAt) {
return nil, ErrQuoteExpired
}
// ...
}
payments 表变得非规范化(存储汇率和金额的副本),但这确保了审计的数据原子性。一行现在包含分析事件所需的所有信息。
舍入误差:float64 与 decimal.Decimal
第二个关键 Bug 表现为隔夜 8,000 行数据累积差异。问题源于不一致的舍入:payment-service 使用 math.Round,billing-service 使用 decimal.NewFromFloat,报告使用 SQL 的 round() 函数。通过 NewFromFloat 将 float64 转换为 decimal 导致精度丢失。
临时修复是一个夜间作业 rounding_adjustments,累积高达 1 KZT 的差异。最终解决方案包括:
- 在领域结构体中禁用
float64 - 通过
decimal.NewFromString接受字符串形式的金额 - 在
Money.RoundByCurrency方法中集中处理舍入 - 将精度与货币绑定(例如,JPY 舍入到整数)
func (m Money) RoundByCurrency() Money {
scale := map[Currency]int32{
"USD": 2,
"JPY": 0,
}[m.currency]
return Money{
amount: m.amount.Round(scale),
currency: m.currency,
}
}
批处理性能从 200k 行的 1.8 秒降到 6.4 秒,但对于后台流程来说可以接受。准确性优先于速度。
幂等性 vs 外部 API 超时
第三个问题出现在支付提供商:acquirer-api 的 3 秒超时导致重复扣款。在 context deadline exceeded 时,系统重试,但第一个请求仍被提供商处理。结果:相同金额的双倍扣款。
第一个变通方法——禁用自动重试——增加了卡在 unknown 状态的支付。最终解决方案:
- 强制使用
idempotency_key,在(merchant_id, idempotency_key)上创建唯一索引 - 领域方法
Authorize,检查当前支付状态 - 对卡住的操作进行对账的 worker
create unique index payments_idem_uq
on payments (merchant_id, idempotency_key);
func (p *Payment) Authorize(acquirerID string) error {
switch p.Status {
case PaymentAuthorized:
return nil
case PaymentCreated, PaymentUnknown:
p.Status = PaymentAuthorized
// ...
}
}
PaymentUnknown 状态成为反映外部 API 不确定性的明确系统状态。对账 worker 通过查询提供商状态来处理超过 90 秒的支付。
读模型延迟:客服所需实时数据
实现 Quote 后,新问题出现:客服管理面板显示陈旧数据,因为 Kafka 延迟(3-4 分钟)。尽管支付正确,操作员看到旧金额,引发客户恐慌。
快速修复:
- 在管理面板显示
quote_id和rate_version - 对于 15 分钟以内的支付,直接从
payments表读取 - 将延迟警报阈值从 10 秒降低到 60 秒
最终解决方案拆分数据源:
- 最近支付:
payments表 - 历史数据:基于账本的读模型
这增加了管理面板的复杂性,但确保了关键场景下的数据新鲜度。
无教条的 DDD:聚合根作为领域核心
项目的成功不在于 domain 文件夹,而是重新思考汇率作为业务契约。Payment 聚合根现在:
- 创建时验证
Quote过期 - 确认后锁定金额
- 从领域操作中排除
float64 - 确保幂等扣款
- 正确处理
unknown不确定性
应用层变得精简:它仅协调步骤,将业务逻辑委托给聚合根。使用示例:
func (uc *UseCase) CreatePayment(ctx context.Context, cmd CreatePaymentCommand) (*Payment, error) {
quote, err := uc.quotes.Get(ctx, cmd.QuoteID)
if err != nil {
return nil, err
}
payment, err := NewPayment(*quote, cmd.IdempotencyKey)
// ...
}
关键启示:当领域规则融入模型中,而不是视为技术细节时,DDD 才有效。
关键启示
- 聚合根中的固定汇率 消除了服务间差异。汇率必须是支付契约的一部分,而不是动态参数。
- 精确的货币类型。使用带集中舍入的
decimal.Decimal;即使在测试中也避免float64。 - 显式的
unknown状态 诚实地反映外部系统不确定性。为手动解决添加对账 worker。 - 拆分数据源 对客服运维至关重要。最近操作直接从表读取,而不是读模型。
— Editorial Team
暂无评论。