返回博客

外部同步主表字段缺失,"懒回填"做完被同步任务一键洗空

某后台管理系统从第三方 POS 平台同步会员主表,导出新会员明细时发现 first_shop_time(首次消费时间)有 8% 左右的行是空的。PM 提议:"本地有订单表,用 MIN(businessTime) 把空的补上不就行了?"听起来很合理——但这个方案埋着三个互相叠加的陷阱,任何一个单独拆开看都不明显,合在一起才能把数据搞乱。

现象

  • 主表(外部同步落地表)某时间字段约 5%~15% 行为 NULL 或空字符串
  • 业务方说"我们的用户基本都有消费记录,辅助表里肯定能找到"
  • 第一版修复:在导出接口前跑一条 UPDATE ... SET first_shop_time = MIN(...) 全量回填
  • 下次定时同步任务跑完,补的值全部归零,字段又变空了

根因

陷阱 1:辅助表覆盖率假设错误

"基本所有用户都有数据"这句话在外部同步场景下成立率极低。主表往往是历史全量(源系统 10 年的会员),辅助表只是近 2~3 年本地增量。实测一个典型案例:主表全量缺失 ~10 万条,本地辅助表能命中补上的只有 808 条(不到 1%)。用户没有消费记录不是数据缺失,是真实的业务状态(只入会、未消费)。

陷阱 2:全量替换会污染已有正确值

MIN(time_field) 全量覆盖(不加 WHERE target_field IS NULL),会把源系统里 2012 年的历史首购时间替换成本地订单表里最早的 2022 年值——时间晚了整整 10 年。辅助表的时间窗口是有限的,它只能覆盖"窗口内的缺失",不能代表历史全貌。

陷阱 3:同步任务反向覆盖

这是最隐蔽的一个。定时同步任务的字段映射通常用 array_key_exists($apiKey, $apiData) 模式——只要 API payload 里含有这个 key(哪怕值是 null''),就会被写入数据库。源系统对"未发生首笔消费"的用户,该字段返回 null 是完全正常的。结果:懒回填跑完,下次同步任务跑,把我们补的值洗回 null

坑:先验证覆盖率,再决定要不要补

接到"从辅助表补缺失字段"的需求,第一件事是跑三组 SQL:

  1. 缺失分布:缺多少行、占比多少
  2. 辅助表覆盖率:缺失的行里,辅助表能命中几个
  3. 已有值 vs 辅助表 MIN 的差异:若当前值普遍早于辅助表最早记录多年 → 全量替换不可行

若覆盖率 < 30%,大概率是"正常业务状态为空",强行补反而是错的。

正解

决策前跑覆盖率 SQL(禁止跳过)

-- 辅助表能补的命中率
SELECT
  COUNT(DISTINCT u.id) AS empty_users,
  COUNT(DISTINCT o.user_id) AS empty_users_with_aux_data
FROM main_table u
LEFT JOIN aux_table o ON o.user_id = u.id
WHERE u.del_flag = 0
  AND (u.target_field IS NULL OR u.target_field = '');
 
-- 已有值 vs 辅助表 MIN 的时间差(验证全量替换是否安全)
SELECT u.id, u.target_field AS current_value, MIN(o.time_field) AS aux_min
FROM main_table u
INNER JOIN aux_table o ON o.user_id = u.id
WHERE u.target_field IS NOT NULL AND u.target_field <> ''
GROUP BY u.id, u.target_field
LIMIT 10;

只补缺失(lazy on-read),不全量替换

在导出/查询入口的 SELECT 前插一条 scope 限定的 UPDATE:

// WHERE 必须含 IS NULL OR = '' —— 已有值不重写
// scope 与查询 SELECT 保持一致 —— 限制影响行数
$backfillSql = "
    UPDATE main_table u
    INNER JOIN (
        SELECT user_id, MIN(time_field) AS min_time
        FROM aux_table
        WHERE time_field IS NOT NULL AND time_field <> ''
        GROUP BY user_id
    ) o ON o.user_id = u.id
    SET u.target_field = o.min_time, u.update_at = NOW()
    WHERE u.del_flag = 0
      AND u.scope_field = :scope_value
      AND (u.target_field IS NULL OR u.target_field = '')
";

同步任务加空值守护(防反向覆盖)

在 SyncEngine 的 upsert 字段映射前,对该字段加空值拦截:

// 源 API 返回 null/'' 时,unset 掉,不写入 DB,保留本地回填值
if (array_key_exists('sourceFieldKey', $apiData)
    && ($apiData['sourceFieldKey'] === null || $apiData['sourceFieldKey'] === '')) {
    unset($apiData['sourceFieldKey']);
}
 
$data = [];
foreach (self::$fieldMap as $apiKey => $dbKey) {
    if (array_key_exists($apiKey, $apiData)) {
        $data[$dbKey] = $apiData[$apiKey];
    }
}

注意:不要改成 !empty()——那会把合法的 "0" / "0.00" 也拦掉。

镜像新查询时先检索引

懒回填上线后,若要把"按 A 字段过滤"的查询镜像成"按 B 字段过滤"的新查询,必须先确认 B 字段有等价复合索引。实测同结构 SQL 仅因索引覆盖差异,性能可以相差 645 倍(40 秒 vs 62 毫秒)。

-- 新查询上线前必跑 EXPLAIN,看 rows 是否远大于实际命中数
EXPLAIN SELECT ... WHERE b.new_field LIKE 'YYYY-MM%' ...;
 
-- 若无索引,加上(MySQL 8 INPLACE online DDL,不阻写)
ALTER TABLE main_table ADD INDEX idx_del_newfield (del_flag, new_field);
正解:三步缺一不可
  1. 决策前验覆盖率,而不是相信"大部分用户都有数据"
  2. 只补 NULL/空值行,加 scope 限定,幂等可重跑
  3. 同步任务加空值守护,防止下次 upsert 把补好的值洗回去

一句话外卖

外部同步的字段,懒回填之前先验覆盖率;回填之后必须在同步引擎加空值守护,否则等于白补。