外部同步主表字段缺失,"懒回填"做完被同步任务一键洗空
某后台管理系统从第三方 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:
- 缺失分布:缺多少行、占比多少
- 辅助表覆盖率:缺失的行里,辅助表能命中几个
- 已有值 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);- 决策前验覆盖率,而不是相信"大部分用户都有数据"
- 只补 NULL/空值行,加 scope 限定,幂等可重跑
- 同步任务加空值守护,防止下次 upsert 把补好的值洗回去
一句话外卖
外部同步的字段,懒回填之前先验覆盖率;回填之后必须在同步引擎加空值守护,否则等于白补。