返回博客

外部数据同步:高水位标记提前写入导致明细永久跳过

做外部系统对接(POS → 本地库、CRM → 本地库)时,大家几乎都会加一个性能优化:如果基础信息没变就跳过昂贵的明细接口调用。这个优化逻辑清晰、代码简单,但藏着一个一旦触发就完全静默的永久 bug。

问题的核心不是「跳过」本身,而是用来判断「要不要跳过」的字段,什么时候写进数据库

现象

某后台管理系统接入第三方 POS 系统,定时同步会员基础信息和消费记录。运行一段时间后,部分会员的消费明细停止更新——但他们的基础档案(最近消费时间、消费总额)却一直显示最新值。同步任务日志全程无报错、全部成功。

手动翻 POS 数据,明细确实存在;翻本地库,消费记录表里就是少几条。

根因

看一下出问题的代码逻辑:

public function syncMember($memberCode)
{
    $existing = $this->readLocal($memberCode);
    $posData  = $this->posApi->fetchBasicInfo($memberCode);
 
    // BUG:把 last_shop_time / total_sale_amount 一起写进去了
    $this->upsertBasicInfo($posData);
 
    // 这个比较此时永远成立——本地值已经被上一行覆盖成新值了
    if ($posData['lastShopTime'] === $existing['last_shop_time']) {
        return ['changed' => false]; // 从此跳过明细,永不恢复
    }
 
    $orders = $this->posApi->fetchAllSaleRecords($memberCode); // 曾超时一次
    if ($orders) {
        $this->syncOrders($memberCode, $orders);
    }
}

故障时序如下:

  1. 第 1 次运行upsertBasicInfolast_shop_time = 2026-04-10 写进本地库;紧接着调 fetchAllSaleRecords 超时(POS 侧偶发故障),明细没写入。
  2. 第 2 次运行$existing['last_shop_time'] 读到的是第 1 次已经写进去的 2026-04-10;POS 基础信息里 lastShopTime 也是 2026-04-10(真实消费时间没变)。两边相等,直接 return。
  3. 此后每次运行:同上,永远相等,永远跳过。

用一张图说清楚这个时序问题:

[错误顺序]
写高水位 → 取明细 → (失败) → 写明细(never)
              ↓
         高水位已推进,下次比较永远匹配,明细被永久跳过

[正确顺序]
取明细 → 写明细 → 写高水位
   ↓ (失败)
 高水位未变 → 下次比较不匹配 → 自动重试
坑:高水位字段和明细接口共用同一个 upsert

last_shop_timetotal_sale_amountsale_count 这类字段在代码里既是「展示给用户看的业务数据」,也是「判断要不要触发明细拉取的游标」。把它们塞进 upsertBasicInfo 一起写,意味着只要基础接口成功,游标就前进——不管后续的明细接口有没有成功。

这不是事务能保护的:两个 API 调用跨了网络边界,数据库事务回滚不了已经写入的 last_shop_time

正解

把 upsert 拆成两个方法:非波动字段(姓名、手机号、等级、性别)可以随时覆盖;高水位字段(消费时间、消费金额、消费次数)只在明细接口成功后才写。

public function syncMember($memberCode)
{
    $existing = $this->readLocal($memberCode);
    $posData  = $this->posApi->fetchBasicInfo($memberCode);
 
    // Step 1:只写非波动字段,高水位字段显式排除
    $this->upsertBasicInfoExcludingStats($posData);
 
    // Step 2:比较高水位(此时本地值还没被覆盖)
    if ($this->unchanged($posData, $existing)) {
        return ['changed' => false];
    }
 
    // Step 3:拉明细
    $orders = $this->posApi->fetchAllSaleRecords($memberCode);
 
    if ($orders && empty($orders['_error'])) {
        // Step 4a:明细成功 → 写明细,再推进高水位
        $this->syncOrders($memberCode, $orders);
        $this->upsertVolatileStats($memberCode, [
            'last_shop_time'    => $posData['lastShopTime'],
            'total_sale_amount' => $posData['totalSaleAmount'],
            'sale_count'        => count($orders),
        ]);
    } else {
        // Step 4b:明细失败 → 不碰高水位,下次自动重试
        Log::warning('fetchAllSaleRecords failed, preserving high-water mark', [
            'memberCode' => $memberCode,
            'error'      => $orders['_error'] ?? 'null response',
        ]);
    }
}
正解:高水位只在依赖它的数据落盘之后才推进

两阶段 upsert:

  1. upsertBasicInfoExcludingStats — 安全字段,随时可覆盖
  2. upsertVolatileStats — 高水位字段,只在 if (success) 分支内写

upsertBasicInfoExcludingStats显式列出要排除的列,不要依赖「API 响应里碰巧没有这个字段」——字段缺失是隐式行为,一旦上游加了字段就踩坑。

修复验证

部署后用以下 SQL 检查历史漂移(适配你自己的表名和字段名):

-- 找到高水位领先实际明细超过 7 天的账号(漂移受害者)
SELECT
    u.member_code,
    u.last_shop_time          AS profile_last_shop,
    MAX(r.consume_time)       AS actual_last_order,
    DATEDIFF(u.last_shop_time, MAX(r.consume_time)) AS drift_days
FROM member_profile u
LEFT JOIN purchase_records r ON r.member_code = u.member_code
WHERE u.last_shop_time IS NOT NULL
GROUP BY u.member_code, u.last_shop_time
HAVING drift_days > 7
ORDER BY drift_days DESC;

对受影响的账号,把 last_shop_time 回滚到明细表中实际最大时间,下一次同步任务就会自动补拉缺失明细。

一句话外卖

凡是既作「用户可见业务数据」又作「同步游标 / 跳过判断依据」的字段,只能在它所依赖的下游数据全部落盘之后才能写入。