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

zip 分卷归档大文件全部 bad CRC:CRC-32 分块累加丢状态,而自述/健康检查/退出码全绿

日期:2026-10-12 | 技术栈:ZIP 归档格式 / CRC-32 校验和 / zlib.crc32 / 流式写出 / Python struct / 分卷归档 / 健康检查退出码 关键词:unzip -t bad CRC、CRC-32 分块累加、zlib.crc32 chunk running、data descriptor、spanned zip、空判据表、假绿、fail-closed、退出码恒 0

一、问题背景

一套归档服务:把一批业务文件按 manifest 打包成流式 ZIP, 每 4 个条目滚动切分为一个分卷(archive.z01 / archive.z02 / archive.zip), 交付下游做冷备,下游用标准工具 unzip -t 校验完整性。

上线后下游反馈:分卷里凡是稍大一点的文件,全部校验不过:

$ unzip -t reports/archive/archive.z01
Archive:  reports/archive/archive.z01
    testing: customers.csv            OK
    testing: orders-01.ndjson         bad CRC 74af6066  (should be 7faed800)
    testing: orders-02.ndjson         bad CRC 645a660b  (should be 345b3d9a)
    testing: invoices.csv             OK
At least one error was detected in reports/archive/archive.z01.

而归档服务这一侧四个绿灯全亮,没有一个指向真相:

绿灯实际说明什么
python3 -m app.archiver 退出码 0只证明进程没崩
自述 "ok": true / "self_consistent": true写死常量,自述字段自证
python3 -m app.health 的 {"degraded": false, "issues": []}BAD_FIELDS = () 空判据表,for 一次都不循环
python3 -m app.health 退出码 0main() 无条件 return 0,退化成「给自己发合格证」

这是一个典型的**「现象与自述完全背离」**问题:外部标准工具报坏,内部监控全绿。

二、复现步骤

git checkout 2026-10-12-1004
cd scenario/2026-10-12-1004

# 方式 A:Docker(推荐)
docker compose up -d --build
docker compose exec -T app bash
bash ops/repro.sh ; echo "repro rc=$?"   # 问题态退出码 1(预期复现信号,不是环境坏了)

# 方式 B:本机直接跑(场景只需 Python 3)
bash ops/repro.sh ; echo "rc=$?"

问题态关键输出:

Archive:  reports/archive/archive.z01
    testing: orders-01.ndjson         bad CRC 74af6066  (should be 7faed800)
    testing: orders-02.ndjson         bad CRC 645a660b  (should be 345b3d9a)

不一致 4 条:
  - reports/archive/archive.z01:orders-01.ndjson: 归档记录的 CRC-32 与源文件不符 (记录 7faed800,源文件 74af6066)
RESULT: BAD(不一致 4 条)

判定脚本基线:bash ops/verify.sh → PASS=4 FAIL=7 / RESULT: BAD。

三、根本原因

根因 1(题眼):CRC-32 分块累加时丢了上一块的中间状态

app/streamio.py:

def accumulate(running: int, chunk: bytes) -> int:
    return (running ^ zlib.crc32(chunk)) & 0xFFFFFFFF   # 错:每块独立算再异或

CRC-32 不是「每块算一个再拼起来」,它是一条有状态的移位寄存器: 下一块的起始状态必须是上一块的输出。 zlib.crc32(data, value) 的第二个参数就是「本次计算的起始值」,正确写法是:

    return zlib.crc32(chunk, running) & 0xFFFFFFFF      # 对:把上一块状态当起始值

因为块长默认 2048 B,所以现象呈现清晰的二分: 「一块就读完」的文件全对,「需要读第二块」的文件全错。

python3 - <<'PY'
import zlib
raw = open("payload/orders-01.ndjson","rb").read()
print("size", len(raw))
print("whole-file crc32      =", hex(zlib.crc32(raw) & 0xffffffff))          # 0x74af6066
run = 0
for i in range(0, len(raw), 2048):
    run = (run ^ zlib.crc32(raw[i:i+2048])) & 0xffffffff
print("buggy xor accumulate  =", hex(run))                                  # 0x7faed800 ← 与归档记录一致
run = 0
for i in range(0, len(raw), 2048):
    run = zlib.crc32(raw[i:i+2048], run) & 0xffffffff
print("fixed stateful accum  =", hex(run))                                  # 0x74af6066 ← 与源文件一致
PY

关键判读:坏的不是数据字节,是记下来的校验和。 unzip -t 同样区分这两种情况 —— bad CRC 是校验和不匹配, invalid compressed data / unexpected end of file 才是数据坏。

根因 2(闸门):健康检查是「空判据表 + 退出码恒 0」

app/health.py:

BAD_FIELDS = ()                 # 空判据表,for 一次都不循环
...
    except (OSError, json.JSONDecodeError):
        return EXIT_OK          # 连读不到自述报告都当健康
...
    return EXIT_OK              # 无条件返回 0,「判不了」被当成「没问题」

for field in BAD_FIELDS 永远不执行 → issues 永远为空 → degraded 永远 false。 即使归档已经烂了,健康检查依然「全绿」且退出码 0。

这两个根因互相掩护:数据错了没人发现,因为闸门是个假的。 只修题眼(CRC)能让探针变绿,但闸门仍然没牙。

四、解决办法

修复 1:app/streamio.py(一行,题眼)

 def accumulate(running: int, chunk: bytes) -> int:
     """把一块数据「加进」累积 CRC。"""
-    return (running ^ zlib.crc32(chunk)) & 0xFFFFFFFF
+    return zlib.crc32(chunk, running) & 0xFFFFFFFF

修复 2:app/health.py(把判据落在外部能独立重算的事实上)

  • 自己解析契约 conf/archiver.env:拿不到 → 退出码 3;
  • 自己解析 conf/manifest.jsonl,直接读源文件重算 CRC-32(权威事实,不采信自述);
  • 自己按中央目录解析归档分卷,把归档里存下的字节取回来重算 CRC-32, 同时核对归档记录的 CRC 也等于源文件事实;
  • 核对分卷条目数不超契约上限、条目清单与 manifest 逐一对应;
  • 自述报告 archive_report.json 只用于交叉对账,不作为「是否健康」的依据;
  • 退出码随事实变化:0 健康 / 2 有真问题或拿不到事实 / 3 契约不可用。

坑点:streaming 模式下 local file header 的 size 字段按契约置 0, 取内容长度必须用中央目录的 uncomp_size,不能用 local header 里的字段。

app/archiver.py 无需改动。conf/、docs/、client/、ops/ 是只读考卷,不许改。

五、验证

docker compose cp app/streamio.py app:/srv/app/streamio.py
docker compose cp app/health.py   app:/srv/app/health.py

docker compose exec -T app bash -c 'rm -rf app/__pycache__ client/__pycache__; bash ops/verify.sh'
# PASS=11 FAIL=0 / RESULT: OK

docker compose exec -T app bash -c 'bash ops/repro.sh; echo "repro rc=$?"'
# unzip -t 三个分卷全部 No errors detected;探针 RESULT: OK;rc=0

docker compose exec -T app bash ops/negative.sh
# PASS=14 FAIL=0 / RESULT: OK(闸门有牙)

退出码 fail-closed 直采:

healthy                                   rc=0
ARCHIVER_CONTRACT=/tmp/nope.env           rc=3   (契约不可用)
删掉 reports/archive                        rc=2   (拿不到事实 = 判不了 ≠ 没问题)

负向验证里最值得记的一条:把 CRC 累加改回「异或」后, 被挖空的 health 对坏档仍退出码 0 / degraded=false —— 这就是「自述自证 = 假绿」的实证。

六、经验与避坑

  1. CRC-32 / 校验和是「有状态」的:分块计算时,「下一块的起始值」=「上一块的输出」。 最短自检口诀:同一个文件,分 1 块算、分 N 块算,结果必须一模一样。 不等,就是丢了状态。
  2. unzip -t 的报错要会读:bad CRC 是校验和记错(内容可能没错), invalid compressed data / unexpected end of file 才是数据坏。 这个区分能立刻把排查方向从「数据流」转到「校验和计算」。
  3. 现象呈现「块长二分」时,去看分块累加/拼接那一处。 凡是「小文件全对、超过某个固定字节数的文件全错」,那个字节数就是你的块长。
  4. 警惕「自述字段自证」:"ok": true 如果是写死的常量, 那它证明的不是健康,只是有人会打字。判据必须落在外部能自己重算一遍的事实上。
  5. 空判据表是隐蔽的假绿:for field in () 一次都不循环, issues 永远为空 → degraded 永远 false。代码看着「有判据逻辑」,实则一条都没跑。
  6. 健康检查退出码必须随事实变化,且要 fail-closed: 「拿不到数据 = 判不了」必须退出码非 0,绝不能被当成「没问题」。
  7. 验收要看判别力:只检查「退出码为 0」的断言,在问题态也会误报通过 (问题态也 exit 0)。必须用「问题态能 FAIL、修复态才 PASS」的断言, 并用负向验证(ops/negative.sh)证明闸门真的有牙。
  8. 容器操作小坑:
    • 非交互流水线里 docker compose exec 必须加 -T,否则 the input device is not a TTY;
    • repro.sh 以探针退出码为脚本退出码,问题态返回 1,用 ; echo rc=$? 接住,别用 &&;
    • 改了 app/ 记得清 __pycache__并同步进容器(镜像用 COPY 打包代码, 直接改宿主机文件容器看不到,用 docker compose cp 或 --build 重建)。

七、同类影响面

  • gzip / zlib 流式压缩:deflate 的 Z_SYNC_FLUSH 与 CRC 累积(crc32 的 value 参数)
  • PNG / gzip / zlib 的 CRC-32 与 Adler-32 分块计算(Adler-32 的 adler32(data, value) 同理)
  • 任何「边读边算摘要」的实现:MD5/SHA 分块更新(hashlib 的 update())、 xxHash / MurmurHash 的流式 API
  • 大文件分片上传、断点续传里的分片校验和合并(分片各算各的再拼 = 同一类错误)
  • 一切「自述字段自证」的监控面板,与退出码恒 0 的巡检脚本

八、可复现命令速查

cd scenario/2026-10-12-1004
docker compose up -d --build
docker compose exec -T app bash

bash ops/repro.sh            # 复现:问题态 rc=1(预期)
bash ops/verify.sh           # 判定:问题态 PASS=4 FAIL=7 / RESULT: BAD
bash ops/verify.sh --fix     # 切参考解
bash ops/verify.sh --restore # 切回问题态
bash ops/negative.sh         # 负向验证(证明闸门有牙)

版权声明

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

分享这篇文章