外部 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:正常,本地 upsertmerged:外部主键合并,用候补主键反查到新 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是「请求已收到」的收据,不是「数据已落库」的回执——真正的写入验证需要一次读接口回查。