从一次汇率退款事故看代购系统实时汇率缓存方案设计

本文适合正在处理多币种结算、汇率缓存策略的后端开发者,以及代购平台的技术负责人。如果只关注业务逻辑,可以跳过代码部分直接看方案思路。

事故:一次退款引发的汇率风波

去年黑五期间,某代购平台(后迁移到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起(同类问题) |

三个铁律

  1. 锁定快照,不依赖实时:任何涉及金额计算的场景(下单、退款、运费估算),必须记录当时的汇率快照。实时汇率只用于展示和预估。
  2. 缓存TTL要短,但要有缓冲:30秒是代购场景下的经验值,缓冲区间防止汇率跳变时系统"反应过度"。
  3. 退款逻辑与下单逻辑对称:退款使用的汇率计算方式必须与下单时完全一致。退款金额 = 支付金额 / 锁定汇率,而不是重新查询。