返回首页

Go 中的 DDD:如何避免支付中汇率错误

Go 支付系统中四个关键 bug 的分析。它展示了如何在领域聚合中实现 DDD 并固定汇率来解决差异问题、舍入误差和双重借记。具体的技术解决方案附代码示例。

一个支付如何获得三个汇率:真实支付系统中的 DDD 经验教训
Advertisement 728x90

# 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 创建,而不是动态数据。这消除了在扣款或账本记录期间重新计算汇率的可能性。

Google AdInline article slot
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() 函数。通过 NewFromFloatfloat64 转换为 decimal 导致精度丢失。

临时修复是一个夜间作业 rounding_adjustments,累积高达 1 KZT 的差异。最终解决方案包括:

Google AdInline article slot
  • 在领域结构体中禁用 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 状态的支付。最终解决方案:

Google AdInline article slot
  • 强制使用 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_idrate_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

Advertisement 728x90

继续阅读