本文适合正在处理多币种结算、汇率缓存策略的后端开发者,以及代购平台的技术负责人。如果只关注业务逻辑,可以跳过代码部分直接看方案思路。
事故:一次退款引发的汇率风波
去年黑五期间,某代购平台(后迁移到Taocarts)遇到一起典型事故:客户下单时日元汇率为100 JPY ≈ 5.0 CNY,三天后申请退款时日元升值到100 JPY ≈ 5.3 CNY。退款按当前汇率计算,客户收到的金额比原支付少了近6%。
根源在于:系统没有做汇率锁定与退款时的汇率一致性处理。每次汇率转换都实时调用外部API,报价在分钟级内就可能波动超过2%。接口超时或限流时,系统直接降级为缓存的过期数据------缓存TTL设为10分钟,而10分钟内汇率可能已变化0.5%以上。
三个设计缺陷
1. 缓存策略过于简单
原系统使用Redis缓存汇率,TTL固定为10分钟。但第三方汇率接口(如exchangerate-api.com)在交易时段更新频率极高,10分钟内的累积偏差可能达到0.8%~1.2%。代购利润本身只有3%~5%,一次退款就能吃掉全部利润。
2. 没有汇率锁定机制
下单时只记录了订单金额,没有记录当时的汇率快照。退款时重新计算,导致汇率不一致。这是典型的"用当前汇率覆盖历史汇率"问题。
3. 没有缓冲区间
汇率实时波动时,系统直接使用最新值,没有做平滑或缓冲。客户在不同时间点看到的同一商品价格可能不同,引发信任危机。
解决方案:Taocarts的实时汇率缓存方案
针对上述问题,Taocarts在v3.2版本中重构了汇率模块。核心思路是:用"带缓冲区的分层缓存"代替"单一TTL缓存",并强制锁定订单时的汇率快照。
方案架构
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 第三方汇率API │────▶│ Redis缓存 │────▶│ 业务层 │
│ (exchangerate) │ │ (TTL=30s) │ │ (汇率锁定) │
└─────────────┘ └─────────────┘ └─────────────┘
│
▼
┌─────────────┐
│ 缓冲区间 │
│ (±2% ~ ±3%) │
└─────────────┘
- 缓存TTL设为30秒:平衡实时性与API调用成本。30秒内汇率波动通常不超过0.1%,对代购业务可接受。
- 缓冲区间(buffer zone):运营可在后台配置一个百分比(如 ±2%),当实时汇率与缓存值的偏差超过该区间时,强制刷新缓存。这避免了汇率跳变时系统直接使用过期数据。
- 汇率快照锁定:下单时不仅记录订单金额,还记录当时的汇率快照(包括中间价、代购汇率、时间戳)。退款时直接使用该快照计算,而不是重新查询。
核心代码示意(PHP)
php
class ExchangeRateService
{
private $cache;
private $apiClient;
private $bufferPercent = 2.0; // 可配置
public function getRate(string $from, string $to, ?float $lockedRate = null): float
{
// 如果有锁定汇率,直接返回
if ($lockedRate !== null) {
return $lockedRate;
}
$cacheKey = "rate:{$from}_{$to}";
$cached = $this->cache->get($cacheKey);
if ($cached && $this->isWithinBuffer($cached, $from, $to)) {
return $cached;
}
// 刷新缓存
$fresh = $this->apiClient->fetchRate($from, $to);
$this->cache->set($cacheKey, $fresh, 30); // TTL 30秒
return $fresh;
}
private function isWithinBuffer(float $cached, string $from, string $to): bool
{
// 实际项目中这里会异步检查偏差,此处简化
return true; // 假设缓存有效
}
}
php
class RefundService
{
public function calculateRefundAmount(Order $order): float
{
// 使用订单锁定的汇率快照
$rateSnapshot = $order->rate_snapshot; // JSON: {rate: 0.05, timestamp: ...}
$refundAmount = $order->paid_amount / $rateSnapshot['rate'];
return $refundAmount;
}
}
数据对比
| 指标 | 改造前 | 改造后 |
||||
| 缓存TTL | 10分钟 | 30秒 |
| 汇率接口超时率 | 约3% | 低于0.01%(Redis缓存命中) |
| 缓存获取耗时 | 300ms(裸调用) | <1ms |
| 退款汇率偏差 | 可达3% 以上 | 锁定汇率,偏差为0 |
| 客户投诉率 | 每月约2起 | 0起(同类问题) |
三个铁律
- 锁定快照,不依赖实时:任何涉及金额计算的场景(下单、退款、运费估算),必须记录当时的汇率快照。实时汇率只用于展示和预估。
- 缓存TTL要短,但要有缓冲:30秒是代购场景下的经验值,缓冲区间防止汇率跳变时系统"反应过度"。
- 退款逻辑与下单逻辑对称:退款使用的汇率计算方式必须与下单时完全一致。退款金额 = 支付金额 / 锁定汇率,而不是重新查询。