返回博客

外部 API 返回 success,但数据压根没写进去——幽灵写入的排查与防御

对接某个老牌第三方开放平台的会员信息同步接口,批量调用写入接口,每一行都返回 code:10000 success,跑批统计显示 ok=530 fail=0,一切看起来很完美。

但过了几天,运营反馈某些会员的信息在外部系统里「还是老数据」。再一查,500 多行里有将近 20 行,写接口虽然返回成功,但调读接口用同一个主键反查,返回的是空列表。

这就是「幽灵写入(Ghost Write)」——写接口接受了你的请求,但副作用被静默丢掉了。

现象

  • 批量调用写接口,服务端返回全量 success,无任何报错
  • 跑批日志显示 0 fail,ok 计数 = 总行数
  • 业务方反馈「改了没用,还是老数据」
  • 用同一主键调读接口,返回空列表或 404
  • 换手机号等候补主键反查,部分能找到(主键被合并),部分完全查不到(记录已注销/归档)

根因

很多老牌企业 POS / CRM 系统的写接口,内部实现是这样的:

客户端  →  HTTP 网关  →  消息队列  →  异步 Worker  →  DB
                ↑
        在这里返回 success(仅代表「请求已入队」)
  • HTTP 网关只做 schema 校验、鉴权、限流,立刻返回 success
  • 主键存在性校验在异步 Worker 里做,找不到就丢日志,不通知调用方
  • 调用方拿到的 code:10000 success 实际语义是「请求已被系统记录」,而不是「业务副作用已落库」

这是「at-least-once delivery」和「API success 语义」之间的经典歧义。Martin Kleppmann 在《DDIA》第 11 章讲过:MQ 入队成功 ≠ 消费者处理成功,在外部系统边界上这个坑尤其隐蔽。

坑:enterprise 写接口的 success 可能只代表「入队成功」

老牌 POS/CRM/ERP 系统为了高吞吐量,普遍把 HTTP 响应和业务落库解耦。code:200 / 10000 / success 不等于数据真正写进去了。被注销、合并、归档的主键不会在同步写入路径里触发 4xx——它们会在异步 Worker 里被静默丢弃。

排查方法:写后立即读,再用候补主键验

不要只信写接口的返回值,加一个「双向验证」步骤:

// 1. 调写接口
$writeResp = externalApiUpdate(['memberId' => $id, 'field' => $value]);
$writeOk = ($writeResp['code'] == 10000);
 
// 2. 立即用主键调读接口验证是否真正写入
$readResp = externalApiQuery(['memberId' => $id]);
$list = $readResp['data']['list'] ?? [];
$readBackOk = !empty($list);
 
// 3. 如果主键查不到,用候补主键(如手机号)反查,区分「主键被合并」和「真孤儿」
$orphanType = null;
if ($writeOk && !$readBackOk) {
    $altResp = externalApiQueryByPhone($mobile);
    $altList = $altResp['data']['list'] ?? [];
    $orphanType = empty($altList) ? 'orphan' : 'merged';
}
 
// 最终状态判定
if ($writeOk && $readBackOk) {
    // 真正写入,更新本地记录
} elseif ($orphanType === 'merged') {
    // 外部系统做了主键合并,用新 ID 重写一次
} elseif ($orphanType === 'orphan') {
    // 真孤儿:主键在外部系统已不存在,标记后跳过
}

正解

正解:双向验证 + 三态处理,日志表加 [orphan] 标记

不要把「外部写接口返回 success」等价于「数据已生效」。写完之后立即调读接口验证,把结果分成三态处理:

  • truly_written:正常,本地 upsert
  • merged:外部主键合并,用候补主键反查到新 ID,重写一次
  • orphan:真孤儿,日志 return_msg 追加 [orphan],本地数据加 stale 标记,不阻断主流程,也不让 cron --resume 重试(避免死循环)

几个实施细节:

  • 写日志表的 status 仍然可以写 success——写接口确实返回成功了,这不是 dev bug,是外部系统的行为。区分靠 return_msg 字段里的 [orphan] 标记,对账时过滤用。
  • 时序问题:极少数情况下,读接口失败是因为写还在 MQ 中(write 后 sleep 1-2s 再 query 可以消除一部分)。但老牌企业系统的异步处理往往是分钟级,靠 sleep 解决不了根本。
  • 监控告警:跑批结束统计 write_success - readback_success,差值/总数 > 5% 时发告警,及时发现批量幽灵写入。
  • 节流:双向验证让每条记录的 RTT 翻倍,批量场景要搭配节流策略,避免压垮对方 API 网关。

一句话外卖

外部系统写接口的 success 是「请求已收到」的收据,不是「数据已落库」的回执——真正的写入验证需要一次读接口回查。