返回博客

外部 API 同步回写:?? 静默吞 null、insertAll 整批失败

对接某第三方开放平台的会员数据时,我需要将 API 返回的实时数据写入本地库,同时保留跨成员聚合排名的能力。架构上很直觉:定时同步批次 + 请求时实时补写。但连踩两个反直觉的坑,数据悄悄烂掉,还没有任何报错。

现象

  • 数据库里某些字段明明传了值,存进去却变成了空字符串 ""
  • 重试同步后,某一批记录完全没写入,日志也没有异常
  • 实时补写偶发"明明 API 返回 50 条,入库只有 43 条"

根因

坑一:PHP ?? 运算符把 null 变成空字符串

做字段映射时,很多人习惯写:

$data['remark'] = $apiData['remark'] ?? '';

这在 API 字段不存在时确实能给默认值,但如果 API 明确返回了 null(表示"该字段无值",与"未传递"语义不同),?? 会把它转成 '',彻底丢失了 null 的语义。

坑:?? 把 null 转成了空字符串

$val ?? '' 在 key 存在但值为 null 时,同样触发右侧默认值。结果:数据库里存了 "",不是 NULL。下游的 WHERE remark IS NULL 查不到,排名计算偏差,没有任何 warning。

正确区分"key 不存在"和"key 存在但为 null"要用 array_key_exists

坑二:insertAll 遇到唯一索引冲突,整批回滚

第二个坑更隐蔽。幂等检查通过后,我用 insertAll 批量插入同一笔订单的多条明细:

$rows = $this->mapOrderToRows($order);
$db->table('detail_table')->insertAll($rows);

只要这一批 $rows 里有任意一条触发了唯一索引冲突,整个 insertAll 就回滚,一条都不写。外层捕不到异常(或静默吞掉了),下次同步时幂等检查的外层 key(oid)又跳过了这笔订单,导致 N 条明细永久缺失。

坑:insertAll 整批失败,外层幂等检查遮蔽了问题

insertAll 内部是单条 SQL INSERT ... VALUES (...),(...),...,任意一行冲突即整批失败。外层如果用订单 ID 做幂等 key,已判定"订单存在就跳过",就再也不会重试这批明细。数据洞无声无息形成。

正解

字段映射:用 array_key_exists,保留 null 语义

private static $fieldMap = [
    'nickName'    => 'nick_name',
    'weChatNickNme'  => 'we_chat_nick_nme',  // 接口拼写 A
    'weChatNickName' => 'we_chat_nick_nme',  // 接口拼写 B(两个变体映同一列)
];
 
private function mapFields(array $apiData): array {
    $data = [];
    foreach (self::$fieldMap as $apiKey => $dbKey) {
        if (array_key_exists($apiKey, $apiData)) {
            $data[$dbKey] = $apiData[$apiKey]; // null 原样保留
        }
        // key 不存在则不赋值,让 DB 用 DEFAULT
    }
    return $data;
}
正解:array_key_exists 区分 key 缺失与值为 null

array_key_exists 只在 key 根本不存在时跳过,key 存在但值为 null 时照样写入。数据库里 NULL 就是 NULL,不会变成 ""

insertAll 降级为逐行 insert,幂等检查下沉到行级

private function syncOrderDetails(array $rows): void {
    foreach ($rows as $row) {
        $exists = $db->table('detail_table')
            ->where('oid', $row['oid'])
            ->where('seq', $row['seq'])
            ->limit(1)->count();
        if ($exists > 0) continue;
        try {
            $db->table('detail_table')->insert($row);
        } catch (\Throwable $e) {
            Log::warning('detail insert skip', ['row' => $row, 'err' => $e->getMessage()]);
        }
    }
}

幂等检查的唯一 key(oid + seq)必须建联合索引,让 count 是 O(1) 而非全表扫描。

架构要点:API-first + 幂等回写双轨
  • 实时准确性:每次请求都从外部 API 拿新鲜数据,计算当前实体的统计数字
  • 跨实体排名:本地库(每次请求幂等回写,已经足够完整)跑 SQL 窗口函数
  • 回写非阻塞:wrap 在 try-catch 里,回写失败只记日志,不影响主响应

一句话外卖

?? 做字段映射是隐形数据污染;insertAll 的幂等粒度必须和外层跳过逻辑的粒度保持一致,否则任何一层的误判都会把数据永久掩埋。