外部 API 同步回写:?? 静默吞 null、insertAll 整批失败
对接某第三方开放平台的会员数据时,我需要将 API 返回的实时数据写入本地库,同时保留跨成员聚合排名的能力。架构上很直觉:定时同步批次 + 请求时实时补写。但连踩两个反直觉的坑,数据悄悄烂掉,还没有任何报错。
现象
- 数据库里某些字段明明传了值,存进去却变成了空字符串
"" - 重试同步后,某一批记录完全没写入,日志也没有异常
- 实时补写偶发"明明 API 返回 50 条,入库只有 43 条"
根因
坑一:PHP ?? 运算符把 null 变成空字符串
做字段映射时,很多人习惯写:
$data['remark'] = $apiData['remark'] ?? '';这在 API 字段不存在时确实能给默认值,但如果 API 明确返回了 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 内部是单条 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 根本不存在时跳过,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 拿新鲜数据,计算当前实体的统计数字
- 跨实体排名:本地库(每次请求幂等回写,已经足够完整)跑 SQL 窗口函数
- 回写非阻塞:wrap 在 try-catch 里,回写失败只记日志,不影响主响应
一句话外卖
用
??做字段映射是隐形数据污染;insertAll的幂等粒度必须和外层跳过逻辑的粒度保持一致,否则任何一层的误判都会把数据永久掩埋。