返回博客

外部 API 数据同步的 12 个无声陷阱

把一个外部平台(POS / CRM / ERP)的数据定期拉进本地 MySQL,听起来是一个无聊的 CRUD 任务。但这件事有一类特别折磨人的 bug 模式:日志显示同步成功,没有任何报错,数据库里的值却悄悄地错了、少了、或者永远停在了初始值

下面精选其中最反直觉的几个坑,每一个都在生产环境真实爆发过。

坑一:?? 运算符把 null 改写成空字符串

现象

某字段在本次 API 响应里是 null(代表「平台未记录」),但写进数据库后变成了空字符串 '',把上次同步进来的有效值覆盖掉了。

根因

// 看起来很正常——其实有问题
$dbData['last_visit_time'] = $apiData['lastVisitTime'] ?? '';

?? 对两种情况都触发 fallback:键不存在、以及键存在但值为 null。当 API 明确返回 null 时,?? 会把它替换为 '',UPDATE 语句随即用空字符串覆盖掉数据库里原有的非空值。

坑:isset() 和 ?? 都不区分「键缺失」与「值为 null」

PHP 里 isset($arr['key'])$arr['key'] ?? fallback 对以下两种情况的行为完全相同

  • 键根本不存在于数组
  • 键存在,但值是 null

API 响应里的 null 往往是有语义的(字段未填 vs 字段不存在是两回事),?? 会无声地抹平这个区别。

正解

array_key_exists() + 静态字段映射,让 null 保持为 null 进库:

$fieldMap = [
    'lastVisitTime' => 'last_visit_time',
    'memberName'    => 'member_name',
    'mobile'        => 'mobile_phone',
];
 
$dbData = [];
foreach ($fieldMap as $apiField => $dbColumn) {
    if (array_key_exists($apiField, $apiData)) {
        $dbData[$dbColumn] = $apiData[$apiField]; // null 就是 null,不替换
    }
    // 键不存在 → 不写入,保留数据库原值
}
正解:array_key_exists + 显式字段映射

把所有 API 字段和 DB 列的对应关系集中声明在一个 $fieldMap 数组里,既做到了字段白名单,又让 null 语义得以保全。


坑二:比较在写入之后做,导致详情步骤永远被跳过

现象

优化了同步逻辑:如果成员数据没变就跳过耗时的详情拉取。上线后发现,所有成员的详情表(订单、积分)停止更新,但错误日志为零。

根因

// WRONG 顺序
$this->upsertMember($apiData);                   // 先写入新数据
$dbRecord = $db->where('code', $code)->find();   // 再读回来对比
if ($dbRecord == $apiData) { continue; }         // 永远相等!永远跳过!

upsert 之后数据库里已经是最新值,读回来自然和 API 数据一模一样,skip 条件恒成立,详情同步从此再也不执行。日志里全是「跳过」,没有一条报错。

坑:「写前读」和「写后读」是完全不同的语义

「比较新旧差异」必须在写入之前读取旧值。写入之后再读,读到的是你刚写进去的值,对比永远相等,任何基于变更检测的 skip 逻辑都会失效。这类 bug 特别难察觉:代码读起来逻辑通顺,每一行单独看都是正确的,问题只在顺序上。

正解

// CORRECT 顺序:先读旧值,再写,再用旧值做判断
$oldRecord = $db->where('code', $code)->find();   // 读旧值
$this->upsertMember($apiData);                     // 写新值
if ($this->hasChanged($oldRecord, $apiData)) {
    $this->syncDetails($code);                     // 只在真正有变更时才执行
}
正解:读旧 → 写新 → 用旧值判断变更

变更检测的读操作必须发生在写操作之前。另外建议在 skip 分支打日志,定期确认「跳过率」是否合理(不能是 100%,也不该是 0%)。


坑三:高水位线写得太早,详情同步永久卡死

这是 12 个坑里危害最隐蔽、修复最紧迫的一个。

现象

某个成员的订单记录突然停止同步,而且只要同步任务跑多少次都没用——这个成员的订单始终是某个日期之前的旧数据。没有错误日志,没有任何警告。

根因

// WRONG:高水位线(last_shop_time)随基础信息一起写入
$oldRecord = $db->where('code', $code)->find();
upsertBasicInfo($apiData);          // 顺带写入了 last_shop_time ← 高水位线!
 
// 检查是否需要同步详情
if ($apiData['lastShopTime'] === $oldRecord['last_shop_time']) {
    continue;  // 「没变」→ 跳过详情
}
 
// 拉取订单详情(如果上面这行曾经失败过一次……)
$orders = fetchAllOrders($code);

时序如下:

  1. 第一次同步:upsertBasicInfolast_shop_time 写成 2024-06-01
  2. 同一次同步:fetchAllOrders 因网络抖动失败,订单没写进去
  3. 第二次同步:读出 last_shop_time = 2024-06-01,API 返回同样是 2024-06-01
  4. 判断「没变」→ 跳过,详情永远不再同步

一次失败,永久漏单。

坑:高水位线写早了 = 一次失败变永久失败

如果某个字段同时扮演「同步游标」和「基础信息字段」两个角色,并且它在详情拉取之前就被写入数据库,那么详情拉取一旦失败,下次运行时游标已经推进,系统会认为「没有新数据」而永远跳过这条记录。

正解

把字段拆成两次写入:非游标字段无条件写,高水位线字段只在详情拉取成功后才写

// Step 1:写非游标字段(姓名、等级、手机号等)
upsertProfileFields($apiData);   // 不含 last_shop_time / total_amount
 
// Step 2:变更检测(此时游标字段还未更新)
$oldRecord = $db->where('code', $code)->find();
if ($apiData['lastShopTime'] === $oldRecord['last_shop_time']) {
    continue; // 真的没变,跳过
}
 
// Step 3:拉取订单详情
$orders = fetchAllOrders($code);
if ($orders !== false) {
    syncOrders($orders);
    // Step 4:详情写成功后,才推进高水位线
    upsertCursorFields($code, [
        'last_shop_time'  => $apiData['lastShopTime'],
        'total_amount'    => $apiData['totalAmount'],
    ]);
} else {
    Log::warning("detail fetch failed, cursor preserved for retry: {$code}");
}
正解:高水位线只在详情写成功后才推进

失败路径必须保留旧的游标值,让下一次同步有机会重试。「成功才推进游标」是分布式系统的通用原则,在单机批处理里同样适用。


坑四:insertAll() 里有一行重复就全批失败

现象

同步日志显示「批量写入完成」,但目标表里一条新记录都没有。

根因

ThinkPHP 的 insertAll() 会把所有行打包成一条 INSERT INTO ... VALUES (...), (...), (...) 语句。只要其中任意一行违反了唯一索引,整条语句回滚,零行写入。没有单行级别的错误,只有整批的静默失败。

正解

用 try-catch 捕获批量失败,降级为逐行插入:

try {
    $db->table('members')->insertAll($batchRows);
} catch (\Exception $e) {
    foreach ($batchRows as $row) {
        try {
            $db->table('members')->insert($row);
        } catch (\Exception $e2) {
            Log::warning('Skipped duplicate: ' . ($row['code'] ?? 'unknown'));
        }
    }
}

或者直接用 INSERT IGNORE / ON DUPLICATE KEY UPDATE 语法绕过这个问题。


一句话外卖

数据同步最危险的 bug 不报错——同步日志全绿,数据库里的值却在悄悄漂移;凡是涉及游标、变更检测、批量写入的代码,先假设它在某个极端时序下会静默失败,再回来设计。