批量写入接口返回 200 却悄悄丢数据:res.ok 陷阱
做桌面应用迁移时(Tauri → Electron),我把日志模块的 runtime 枚举值从 tauri-webview 改成了 electron-webview。客户端改好了,Flush 队列看起来一切正常,控制台打印也没有报错。但两周后查可观测性数据时发现:Electron 运行时的日志记录为零。那段时间发生了什么,完全是黑洞。
现象
- 客户端本地队列(ring buffer)持续 flush,
console.log打印正常 - 服务端 nginx access log 显示 200,没有任何 4xx/5xx
- 但 sink 里用
runtime=electron-webview过滤:零条记录 - 用
curl直接往接入端点 POST 一条测试数据,数据能落库 - 只有来自真实客户端路径的数据全部丢失
根因
批量写入接口(日志 sink、事件 bus、bulk insert)几乎都采用部分成功语义:只要请求合法,就返回 HTTP 200,同时在 body 里告诉你哪些记录被接受了、哪些被拒绝了:
HTTP 200 OK
{ "accepted": 0, "rejected": 5, "errors": [{"error": "invalid runtime: electron-webview"}] }这是正确的接口设计——如果整批拒绝,好的记录也白发了。
问题出在客户端的 flush 逻辑:
const res = await fetch('/api/ingest', { method: 'POST', body: ndjson })
if (!res.ok) throw new Error('flush failed') // ← 只看 HTTP 状态码
// 清空本地队列
queue.clear()res.ok 是 true,异常没抛,队列清空。accepted:0 这个信号从来没有人读过。
这个 bug 有三个让它极难发现的特性:
- 完全静默:没有异常、没有 console error、没有日志文件增长
- 局部有效:用旧枚举值(
runtime: "web")发送的记录照常落库,只有迁移后的新值被拒绝,spot-check 看不出来 - 客户端表现正常:logger 写环形缓冲区 + console,所以 devtools 里一切如常,只有 sink 端是空的
批量写入接口返回 HTTP 200 不代表数据落库了。服务端校验器的枚举白名单(Set、Zod z.enum、JSON Schema enum)一旦与客户端产生漂移,就会返回 accepted:0。客户端 res.ok === true → 队列清空 → 数据永久丢失,无法追回。
技术栈迁移(框架、运行时、模块命名)后,服务端校验器白名单往往是最容易被遗漏的更新点。
排查
五分钟定位:绕过客户端队列,用 curl 直接 POST 一条新枚举值的记录,并读响应 body:
curl -sX POST \
-H "content-type: application/x-ndjson" \
-H "X-Sink-Key: $YOUR_KEY" \
--data-binary '{"ts":1700000000000,"runtime":"electron-webview","level":"info","msg":"probe"}' \
https://your-sink/ingest- 正常:
{"accepted":1,"rejected":0,"errors":[]} - 出问题:
{"accepted":0,"rejected":1,"errors":[{"error":"invalid runtime: electron-webview"}]}
errors[].error 直接告诉你哪个字段、哪个值被拒了。
正解
双端都要修,缺一不可。
服务端——更新校验器白名单,保留旧值(旧值的历史记录还在库里,删掉会导致历史查询异常):
-const ALLOWED_RUNTIMES = new Set(['web', 'tauri-webview', 'tauri-rust'])
+// 'tauri-*' 保留以兼容迁移前历史数据(2026-05 前)
+const ALLOWED_RUNTIMES = new Set([
+ 'web',
+ 'electron-webview', 'electron-main',
+ 'tauri-webview', 'tauri-rust',
+])客户端——flush 必须读 accepted 计数,否则下次漂移还会静默丢数据:
const res = await fetch('/api/ingest', { method: 'POST', body })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const result = await res.json()
if (result.rejected > 0) {
// 不要重试(服务端已接受它能接受的),但要上报,让漂移可见
console.error('[sink] partial reject:', result.errors)
reportMetric('sink.partial_reject', result.rejected)
}变更预防——改客户端枚举时,PR 里同步 grep 服务端白名单:
# 找所有可能受影响的白名单模式
rg -i "ALLOWED_(RUNTIMES?|LEVELS?|TYPES?|STATES?)" --type ts --type js
rg -i "z\.enum\(\[" --type ts # Zod
rg -i "enum:\s*\[" --type json # JSON Schema批量接口的 flush 成功条件:res.ok === true 且 result.accepted > 0(或 accepted === sent)。只要在 flush 处理里加上这一行读取和上报,下次任何枚举漂移都会立刻在监控里暴露,而不是等两周后才发现数据丢失。
一句话外卖
批量写入接口的成功信号在 body 里,不在 HTTP 状态码里;
res.ok只是入场券,accepted > 0才是真正的收据。
这个模式同样适用于:Elasticsearch
_bulk、ClickHouse HTTP INSERT、BigQueryinsertAll、Kafka REST Proxy——凡是返回每条记录状态的批量接口,都要读 body。