返回博客

批量写入接口返回 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.oktrue,异常没抛,队列清空。accepted:0 这个信号从来没有人读过。

这个 bug 有三个让它极难发现的特性:

  1. 完全静默:没有异常、没有 console error、没有日志文件增长
  2. 局部有效:用旧枚举值(runtime: "web")发送的记录照常落库,只有迁移后的新值被拒绝,spot-check 看不出来
  3. 客户端表现正常:logger 写环形缓冲区 + console,所以 devtools 里一切如常,只有 sink 端是空的
坑:只检查 res.ok,批量拒绝会被当成成功

批量写入接口返回 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 合约 = HTTP 状态 + accepted 计数双重校验

批量接口的 flush 成功条件:res.ok === true result.accepted > 0(或 accepted === sent)。只要在 flush 处理里加上这一行读取和上报,下次任何枚举漂移都会立刻在监控里暴露,而不是等两周后才发现数据丢失。

一句话外卖

批量写入接口的成功信号在 body 里,不在 HTTP 状态码里;res.ok 只是入场券,accepted > 0 才是真正的收据。


这个模式同样适用于:Elasticsearch _bulk、ClickHouse HTTP INSERT、BigQuery insertAll、Kafka REST Proxy——凡是返回每条记录状态的批量接口,都要读 body。