朕喜技术(ZhenxiJS)
2026.10.02
1 分钟阅读

归档分片解析错位:尾部半截记录被当成完整记录、分片标识 / 对不上、报告字段「抄输入」导致全绿

日期:2026-10-02 16:00 | 分支:2026-10-02-1600 | 第98题 技术栈:NDJSON 追加日志 / 崩溃安全写入 / 尾部半截记录(torn write)/ 固定长度反推偏移 / 分片标识 <session>/<shard-id> / 报告中「抄输入」的字段 / 全绿健康检查 / Python

一、问题背景

一个「边缘归档」链路把现场事件按分片(shard)追加落盘,写入侧用单次 write 追加 NDJSON(一行一条记录),天然崩溃安全 —— 崩在写记录中间时,文件尾部只会留下半截记录。

现场表现是「归档成功、报告 ok、健康检查全绿」,但:

  • 后台回放器读不出最新记录;
  • 外部独立探针报 BAD;
  • 把分片文件逐行解析,最后一行压根不是完整 JSON。

bash ops/repro.sh 拿到的现场证据:

[producer] 分片文件: state/archive/2.log  size=826
[supervisor] 报告 = {"shard_file":"2.log","archived_sections":[...],"records":6,"ok":true,"errors":[]}
[supervisor] round OK
[replayer] 最后一条记录 offset=680(按固定长度 146 × 6 条反推)
[replayer] 偏移 680 处不是完整 JSON(85 字节),按契约补一条记录
[replayer] 回放成功 -> session=YYYY-MM-DD-section-2 section=2 event=replayed@680
[health] degraded = False issues = []
[expect] 结论: BAD

三个数字互相打架:

  • 真实字节账:826 = 5 条完整记录(765) + 61 字节残留(单条记录 153 字节);
  • 报告 records = ceil(826 / 146) = 6 —— 把半截那行也算成一条;
  • tail_remainder_hint = 826 % 146 = 96 —— 按「记录等长」反推出来的数字,与事实无关。

二、复现步骤

# 方式 A:本机直接跑(只需 python3)
git checkout 2026-10-02-1600
cd scenario/2026-10-02-1600
bash ops/repro.sh                 # 完整走一遍:写入 → 报告 → 解析 → 取证 → 回放 → 健康检查 → 外部探针
bash ops/verify.sh                # 问题态:PASS=2 FAIL=4 / RESULT: BAD

# 方式 B:docker 一键
docker compose up -d --build
docker compose exec archive bash ops/repro.sh
docker compose exec archive bash ops/verify.sh
docker compose down

复原问题态(现场代码留档在 ops/problem-state/):

cp ops/problem-state/*.py app/ && bash ops/verify.sh     # PASS=2 FAIL=4 / BAD
cp ops/expected-fixed/*.py app/  && bash ops/verify.sh   # PASS=6 FAIL=0 / OK

三、根本原因

1. 分片标识对不上(conf.py + archive.py)

契约里分片的逻辑标识是 <session>/<shard-id>:

conf/archive.env      ARCHIVE_WINDOW=YYYY-MM-DD-section-2
                      ARCHIVE_SHARD_ID=20261002T160002-b0f17c2e
conf/archive.expected YYYY-MM-DD-section-2/20261002T160002-b0f17c2e

现场代码 conf.load_contract() 压根没读 ARCHIVE_SHARD_ID,shard_path() 只能取窗口名最后一段拼 2.log。 写入侧记录里却写的是契约真值 —— 同一份数据里两套标识并存,落盘文件名与报告字段都与契约对不上。

2. 解析侧假设「记录等长」(archive.py + replayer.py)

RECORD_BYTES = 146                          # 猜的,真实记录 153 字节
def count_records(size): return ceil(size/146)
def last_offset(size):   return size - 146

用 146 反推偏移,落在半截记录内部(offset 680 在一个残缺 JSON 中段)。 replayer 解析失败后不是拒绝,而是「抄」一条 replayed@680 顶上,于是「回放成功」。

3. 报告「抄输入」+ health 只看存在(supervisor.py + health.py)

"archived_sections": list(contract["expected_sections"]),   # 恒等于输入
"ok": True,                                                  # 手写常量
"errors": [],                                                # 永远为空

health 的判据只有「文件存在 + 报告 ok」—— 报告的 ok 是常量,两个条件互相背书, 健康检查成了自己证明自己,尾部残留、跨轮次记录一概看不见。

四、解决办法

对照 ops/expected-fixed/(即修好后的 app/),六处改动:

  1. conf.py:把 ARCHIVE_SHARD_ID 纳入契约,暴露 shard_id。
  2. archive.py:
    • shard_path() = <session>-<shard-id>.log,写入方与读取方共用同一函数,文件名由契约唯一确定;
    • 删除 tail_remainder() / count_records() 这两个「按固定长度反推」的函数;
    • 新增 scan():按内容扫描,行尾有 \n 且可 json.loads 才算完整记录。
  3. producer.py:默认完整收尾,显式传 part 才制造半截记录(写入侧本身没坏,不背锅)。
  4. replayer.py:按内容定位最后一条完整记录的偏移;尾部有残留就明确拒绝回放。
  5. supervisor.py:报告字段全部改成观测值 —— archived_shard 可为 None、tail_bytes 是实测值; ok = (archived == want) and not errors,由判据算出来。
  6. health.py:判据升级为「契约路径可解析 + 每行都是完整合法 JSON + 记录属于本轮次 + 报告 ok」。

修复后同样的现场(写到 61 字节被打断),报告变成:

{"shard_file":"YYYY-MM-DD-section-2-20261002T160002-b0f17c2e.log",
 "archived_shard":"YYYY-MM-DD-section-2/20261002T160002-b0f17c2e",
 "records":5, "tail_bytes":61, "ok":false,
 "errors":["tail_record_incomplete: 61 bytes"]}

五、验证

cd scenario/2026-10-02-1600

bash ops/verify.sh                    # PASS=6 FAIL=0 / RESULT: OK
bash ops/verify.sh none               # 完整收尾场景同样 OK

cp ops/problem-state/*.py app/ && bash ops/verify.sh   # PASS=2 FAIL=4 / RESULT: BAD(对照)
cp ops/expected-fixed/*.py app/                        # 恢复修复态

修复前后对照:

项问题态修复态
落盘文件state/archive/2.logYYYY-MM-DD-section-2-20261002T160002-b0f17c2e.log
报告 records65(观测值)
报告残留字节tail_remainder_hint=96(猜的)tail_bytes=61(观测值)
报告 ok手写 True由判据算出 False
replayer读不出就「抄」一条,假成功明确拒绝回放(rc=1)
health永远全绿(rc=0)尾部残留即转红(rc=1)

六、经验与避坑

  1. 「文件能被打开、行数看着对」不等于「记录边界是对的」。 NDJSON 追加日志崩溃后尾部必然可能有半截记录,解析侧不能假设记录等长, 必须按内容(行尾换行 + JSON 可解析)确认边界。
  2. <session>/<shard-id> 这类复合标识必须由契约唯一推导, 落盘路径与报告字段共用同一个函数;取名字最后一段拼 <n>.log 是迟早要对不上的。
  3. 报告里不能有「恒等于输入」的字段(archived_sections == expected_sections), 也不能有手写常量 ok。报告字段必须是观测值(可为 None),ok 必须是算出来的。
  4. 同一份数据里出现两套标识最要命:写入侧记录里是契约真值,文件命名却用另一套值, 谁都没报错,只有外部探针能戳穿。
  5. 「拿不到数据」≠「没问题」:replayer 解析不通时「按契约补一条」等于把故障伪装成成功, 健康检查必须 fail-closed(拒绝回放 / 转红),而不是宽容兜底。
  6. 字节推算类数字(残留长度、记录条数)必须是观测值,不能按「假定记录长度」反推 —— 本题里 146 是猜的、153 才是真的,反推出来的 records=6 / hint=96 与事实毫无关系。
  7. 验收要覆盖「问题态」与「修复态」两端:ops/problem-state/(现场代码)+ ops/expected-fixed/ (参考实现)都留档,cp 一下即可来回对照,避免「修好了但没证据证明修的是什么」。
  8. 原始现场代码未入库是个真坑:本仓库该次提交时 app/ 直接就是参考实现 (与 ops/expected-fixed/*.py 逐字节相同),Issue 声称的「问题态 PASS=4 FAIL=2」无法复现。 已重建问题态并留档 ops/problem-state/,实测 PASS=2 FAIL=4(红点覆盖文件名 / 报告抄输入 / 半截记录未识别 / health 不转红四宗罪),更严格;README 与 verify.sh 注释已同步为实测值。 教训:场景入库时务必确认「问题态」也进了版本库,否则题目就只剩答案。

一句话自查:日志类文件「最后一行」是不是完整 JSON?报告里的字段有没有一个能恒等于输入? 健康检查的判据有没有一条真的看数据?

同类影响面:所有 NDJSON / JSONL 追加日志(崩溃后尾部半截)、WAL / write-ahead log 解析、 fsync + 单次 write 的崩溃安全写入、日志采集 tail 的偏移恢复、任何「按固定记录长度 seek」的读取器、 以及所有「报告字段抄输入 / ok 写常量 / 健康检查自查自证」的监控链路。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-02-1600

分享这篇文章