外部数据同步:高水位标记提前写入导致明细永久跳过
做外部系统对接(POS → 本地库、CRM → 本地库)时,大家几乎都会加一个性能优化:如果基础信息没变就跳过昂贵的明细接口调用。这个优化逻辑清晰、代码简单,但藏着一个一旦触发就完全静默的永久 bug。
问题的核心不是「跳过」本身,而是用来判断「要不要跳过」的字段,什么时候写进数据库。
现象
某后台管理系统接入第三方 POS 系统,定时同步会员基础信息和消费记录。运行一段时间后,部分会员的消费明细停止更新——但他们的基础档案(最近消费时间、消费总额)却一直显示最新值。同步任务日志全程无报错、全部成功。
手动翻 POS 数据,明细确实存在;翻本地库,消费记录表里就是少几条。
根因
看一下出问题的代码逻辑:
public function syncMember($memberCode)
{
$existing = $this->readLocal($memberCode);
$posData = $this->posApi->fetchBasicInfo($memberCode);
// BUG:把 last_shop_time / total_sale_amount 一起写进去了
$this->upsertBasicInfo($posData);
// 这个比较此时永远成立——本地值已经被上一行覆盖成新值了
if ($posData['lastShopTime'] === $existing['last_shop_time']) {
return ['changed' => false]; // 从此跳过明细,永不恢复
}
$orders = $this->posApi->fetchAllSaleRecords($memberCode); // 曾超时一次
if ($orders) {
$this->syncOrders($memberCode, $orders);
}
}故障时序如下:
- 第 1 次运行:
upsertBasicInfo把last_shop_time = 2026-04-10写进本地库;紧接着调fetchAllSaleRecords超时(POS 侧偶发故障),明细没写入。 - 第 2 次运行:
$existing['last_shop_time']读到的是第 1 次已经写进去的2026-04-10;POS 基础信息里lastShopTime也是2026-04-10(真实消费时间没变)。两边相等,直接 return。 - 此后每次运行:同上,永远相等,永远跳过。
用一张图说清楚这个时序问题:
[错误顺序]
写高水位 → 取明细 → (失败) → 写明细(never)
↓
高水位已推进,下次比较永远匹配,明细被永久跳过
[正确顺序]
取明细 → 写明细 → 写高水位
↓ (失败)
高水位未变 → 下次比较不匹配 → 自动重试
last_shop_time、total_sale_amount、sale_count 这类字段在代码里既是「展示给用户看的业务数据」,也是「判断要不要触发明细拉取的游标」。把它们塞进 upsertBasicInfo 一起写,意味着只要基础接口成功,游标就前进——不管后续的明细接口有没有成功。
这不是事务能保护的:两个 API 调用跨了网络边界,数据库事务回滚不了已经写入的 last_shop_time。
正解
把 upsert 拆成两个方法:非波动字段(姓名、手机号、等级、性别)可以随时覆盖;高水位字段(消费时间、消费金额、消费次数)只在明细接口成功后才写。
public function syncMember($memberCode)
{
$existing = $this->readLocal($memberCode);
$posData = $this->posApi->fetchBasicInfo($memberCode);
// Step 1:只写非波动字段,高水位字段显式排除
$this->upsertBasicInfoExcludingStats($posData);
// Step 2:比较高水位(此时本地值还没被覆盖)
if ($this->unchanged($posData, $existing)) {
return ['changed' => false];
}
// Step 3:拉明细
$orders = $this->posApi->fetchAllSaleRecords($memberCode);
if ($orders && empty($orders['_error'])) {
// Step 4a:明细成功 → 写明细,再推进高水位
$this->syncOrders($memberCode, $orders);
$this->upsertVolatileStats($memberCode, [
'last_shop_time' => $posData['lastShopTime'],
'total_sale_amount' => $posData['totalSaleAmount'],
'sale_count' => count($orders),
]);
} else {
// Step 4b:明细失败 → 不碰高水位,下次自动重试
Log::warning('fetchAllSaleRecords failed, preserving high-water mark', [
'memberCode' => $memberCode,
'error' => $orders['_error'] ?? 'null response',
]);
}
}两阶段 upsert:
upsertBasicInfoExcludingStats— 安全字段,随时可覆盖upsertVolatileStats— 高水位字段,只在if (success)分支内写
在 upsertBasicInfoExcludingStats 里显式列出要排除的列,不要依赖「API 响应里碰巧没有这个字段」——字段缺失是隐式行为,一旦上游加了字段就踩坑。
修复验证
部署后用以下 SQL 检查历史漂移(适配你自己的表名和字段名):
-- 找到高水位领先实际明细超过 7 天的账号(漂移受害者)
SELECT
u.member_code,
u.last_shop_time AS profile_last_shop,
MAX(r.consume_time) AS actual_last_order,
DATEDIFF(u.last_shop_time, MAX(r.consume_time)) AS drift_days
FROM member_profile u
LEFT JOIN purchase_records r ON r.member_code = u.member_code
WHERE u.last_shop_time IS NOT NULL
GROUP BY u.member_code, u.last_shop_time
HAVING drift_days > 7
ORDER BY drift_days DESC;对受影响的账号,把 last_shop_time 回滚到明细表中实际最大时间,下一次同步任务就会自动补拉缺失明细。
一句话外卖
凡是既作「用户可见业务数据」又作「同步游标 / 跳过判断依据」的字段,只能在它所依赖的下游数据全部落盘之后才能写入。