外部 API 数据同步的 12 个无声陷阱
把一个外部平台(POS / CRM / ERP)的数据定期拉进本地 MySQL,听起来是一个无聊的 CRUD 任务。但这件事有一类特别折磨人的 bug 模式:日志显示同步成功,没有任何报错,数据库里的值却悄悄地错了、少了、或者永远停在了初始值。
下面精选其中最反直觉的几个坑,每一个都在生产环境真实爆发过。
坑一:?? 运算符把 null 改写成空字符串
现象
某字段在本次 API 响应里是 null(代表「平台未记录」),但写进数据库后变成了空字符串 '',把上次同步进来的有效值覆盖掉了。
根因
// 看起来很正常——其实有问题
$dbData['last_visit_time'] = $apiData['lastVisitTime'] ?? '';?? 对两种情况都触发 fallback:键不存在、以及键存在但值为 null。当 API 明确返回 null 时,?? 会把它替换为 '',UPDATE 语句随即用空字符串覆盖掉数据库里原有的非空值。
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,不替换
}
// 键不存在 → 不写入,保留数据库原值
}把所有 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);时序如下:
- 第一次同步:
upsertBasicInfo把last_shop_time写成2024-06-01 - 同一次同步:
fetchAllOrders因网络抖动失败,订单没写进去 - 第二次同步:读出
last_shop_time = 2024-06-01,API 返回同样是2024-06-01 - 判断「没变」→ 跳过,详情永远不再同步
一次失败,永久漏单。
如果某个字段同时扮演「同步游标」和「基础信息字段」两个角色,并且它在详情拉取之前就被写入数据库,那么详情拉取一旦失败,下次运行时游标已经推进,系统会认为「没有新数据」而永远跳过这条记录。
正解
把字段拆成两次写入:非游标字段无条件写,高水位线字段只在详情拉取成功后才写:
// 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 不报错——同步日志全绿,数据库里的值却在悄悄漂移;凡是涉及游标、变更检测、批量写入的代码,先假设它在某个极端时序下会静默失败,再回来设计。