对象存储分片上传网关:重复分片静默丢掉最新内容、最后一片判定拿编号当数量、对象 ETag 口径错(逐片 vs 整体),而自述 ok:true / 健康检查空判据表 / 退出码恒 0
关键词:S3 multipart upload、分片上传、Multipart Upload、part_number 覆盖语义、重复分片覆盖、 最后一片判定 last part、ETag 口径、逐分片 ETag、对象 ETag、sha256 whole object、 空判据表假绿、健康检查 fail-open、退出码恒 0、断点续传、乱序到达、对象存储网关
一、问题背景
uploader 是对象存储前的一道分片上传网关:客户端把大对象切成分片
(part_number 编号、可乱序到达、可重复上传覆盖),网关负责判断每个 put_part
该不该接受、complete 时把已接受的分片按编号升序拼成对象、算对象 ETag、出回执报告。
部署契约 conf/uploader.env(只读考卷)关键几行:
UPLOAD_MIN_PART_BYTES=4096 # 小于本值的分片一律拒绝,唯一例外是「最后一个分片」
UPLOAD_MAX_PART_BYTES=8192 # 大于本值一律拒绝
UPLOAD_MAX_PART_NUMBER=10000 # 编号必须落在 [1, 10000]
UPLOAD_MIN_PARTS=2 # 完成上传的最小分片数
UPLOAD_ETAG_ALGO=sha256_hex16_per_part # 每片 ETag = 该片自身内容的 sha256 前 16 位
症状:一轮 18 条动作里 3 条被误拒、1 个对象没落盘,报告却写 ok:true,退出码 0,
健康检查 {"degraded": false, "issues": []} —— 一片祥和。外部独立探针说 BAD(不一致 25 条)。
典型的四个绿灯(rc=0 / ok:true / degraded:false / 空判据表)没有一件指向真相。
二、复现步骤
git clone <repo> && cd <repo>
git checkout 2026-10-16-1601
cd scenario/2026-10-16-1601
docker compose up -d --build
docker compose exec -T uploader bash -lc 'cd /srv && bash ops/repro.sh; echo rc=$?'
问题态输出:
{"counts": {"actions_accepted": 12, "actions_rejected": 6, "actions_total": 18,
"objects_completed": 3, "parts_accepted": 8},
"accounts": {"aborted": 1, "completed": 2, "open": 1, "sessions_total": 4},
"ok": true}
service rc=0
health -> {"degraded": false, "engine": "multipart-upload-gateway", "issues": []} rc=0
不一致:分片 up-a1/2.size:期望 7168 实际 6144
不一致:分片 up-a1/2.etag:期望 '85b385023b17286d' 实际 'eef2228f7043d1f4'
不一致:动作 #3 up-a1/put_part.accepted:期望 True 实际 False
不一致:对象 up-a1/photos/2026/summer-a.jpg.object_etag:期望 '1f358a7c651ead19' 实际 'cdd23554e1ed0aa5'
...
RESULT: BAD(不一致 25 条)
probe rc=1
坑:
repro.sh以探针退出码为脚本退出码,问题态返回 1。这是预期的复现信号。 必须用; echo rc=$?接住,别用&&,否则后续命令全被中断。
三、根本原因
3.1 重复 part_number 被 list.append,get() 又取第一条 → 生效的是旧内容
# 问题态 app/parts.py
def accept(self, part_number, size, etag, path):
self._const += 1
self._parts.setdefault(part_number, []).append({...}) # append 而非覆盖
def get(self, part_number):
rows = self._parts.get(part_number)
return rows[0] if rows else None # 拿到的永远是旧那份
契约明写「同一 part_number 重复 put_part = 覆盖旧内容」,代码里却对得上
dict[k].append(v) 而不是 dict[k] = v。同一编号在表里出现两次,
numbers() 去重后旧内容胜出 → 报告里 up-a1/2 size 仍是 6144 而不是最新的 7168。
3.2 「最后一片」拿编号当数量
# 问题态 app/uploader.py
def is_last_part(actions, index, upload_id):
nums = [a.get("part_number") for a in actions
if a.get("upload_id") == upload_id and a.get("action") == "put_part"]
this = actions[index].get("part_number")
return this == len(nums) # 拿「编号」当「数量」用
契约要求看「同一会话在下一次 complete/abort 之前是否还有 put_part」, 即批次内的到达先后。上面这行比的是「编号 == 该会话 put_part 总条数」, 两者根本不是一回事:
up-a1编号不连续 + 有重传 →part-3(本批最后一片,应豁免下限)被判「不是最后一片」→ 误拒up-c3恰好2 == 2→ 该拒的part-2(1024 B,后面还有 part-3)被 误放行- 还跨批次看(
complete之后的重传会影响前一批次的判定)
3.3 拼接顺序用了「接受顺序」
items = sorted(table._parts.items(), key=lambda kv: kv[1]["accepted_order"]) # 错
乱序到达时对象内容错乱。契约要求 part_number 升序。
3.4 对象 ETag 口径错:整体哈希 vs 逐片哈希
object_etag = hashlib.sha256(body).hexdigest()[:16] # body = 所有分片内容直接拼接
契约要求 object_etag = sha256("<p1_etag>-<p2_etag>-...-<pN_etag>")[0:16],
是逐分片 ETag 串起来再哈希,不是「所有分片内容直接拼起来哈希」。
这等于对外报了一个别的口径的 ETag,与 S3 风格的分片 ETag 完全不是一回事。
3.5 一层假绿:空判据表 + 无条件 0
# 问题态 app/health.py
BAD_FIELDS = () # 空元组 → for 一次都不循环 → issues 恒空 → degraded 恒 false
def main():
print(...); return EXIT_OK # 连「契约不可用」「拿不到报告」都判健康
BAD_FIELDS = () 时 for field in BAD_FIELDS 循环体一次都不执行 ——
代码看着像有检查,其实在空转。这叫「给自己发体检合格证」。
3.6 ok 只看「有没有对象落盘」
ok = len(completed) > 0 # 3 条被拒、1 个会话停在 open 照样 True
契约:ok = (被拒动作数 == 0) and (所有 complete 都成功落盘) and (会话账目自洽)。
四、解决办法(只改 app/ 下 4 个文件)
4.1 app/parts.py —— 覆盖语义
def accept(self, part_number, size, etag, path):
self._parts[part_number] = {"size": size, "etag": etag, "path": path}
去掉列表与 accepted_order,get() 直接返回字典值。
4.2 app/uploader.py —— 最后一片按批次内到达先后
def is_last_part(actions, index, upload_id):
for later in actions[index + 1:]:
if later.get("upload_id") != upload_id:
continue
if later.get("action") in ("complete", "abort"):
return True # 本批次到此结束 → 本条就是最后一片
if later.get("action") == "put_part":
return False
return True
关键:遇到 complete/abort 即停下(不跨批次),遇到后续同会话 put_part 即非最后一片。
4.3 app/uploader.py —— 升序拼接 + 逐片 ETag 哈希 + ok 同源
order = table.numbers() # part_number 升序
etags = [table.get(pn)["etag"] for pn in order]
size_total = sum(table.get(pn)["size"] for pn in order)
object_etag = hashlib.sha256(("-".join(etags)).encode("utf-8")).hexdigest()[:16]
counts = {..., "parts_accepted": sum(len(t) for t in tables.values()), ...}
ok = (counts["actions_rejected"] == 0
and accounts["sessions_total"] == (accounts["completed"]
+ accounts["aborted"] + accounts["open"]))
parts_accepted 改成由分片表重算(sum(len(t) ...)),保证汇总与逐条事实同源。
4.4 app/health.py —— 非空判据表 + 事实层复核 + 分层退出码
BAD_FIELDS填入 13 个字段(非空),再加一条「判据表自身非空」防止再被挖空。- 新增
_reference(root, conf):在 health 里独立按契约重算一轮 (故意不复用app/uploader.py,避免「自己治自己」、判据退化成自述自证), 再逐项对照报告:会话态 / 分片表最终内容 / 对象 ETag / 台账 / 汇总 / 账目 /ok。 - 退出码分层:契约不可用 → 3;拿不到报告 → 2;有 issues → 2;干净 → 0。
main()返回result["exit"]。
4.5 app/service.py —— 去掉假绿退出码
if not conf.get("contract_available"):
return EXIT_CONTRACT # 3
if not report.get("ok"):
return EXIT_PROBLEM # 2
return EXIT_OK # 0
五、验证
docker compose up -d --build
docker compose exec -T uploader bash -lc 'cd /srv && bash ops/repro.sh; echo rc=$?'
# -> RESULT: OK / probe rc=0
docker compose exec -T uploader bash -lc 'cd /srv && bash ops/verify.sh; echo rc=$?'
# -> PASS=37 FAIL=0 / RESULT: OK
docker compose exec -T uploader bash -lc 'cd /srv && bash ops/negative.sh; echo rc=$?'
# -> PASS=15 FAIL=0 / RESULT: OK(闸门有牙)
修复后关键数字:actions_rejected: 3(原 6)、objects_completed: 4(原 3)、
ok: false(原 true)、service rc=2(原 0)。
对象 ETag 从错的 cdd23554e1ed0aa5 变为契约口径的 1f358a7c651ead19。
三件闸门(外部探针 / 37 条验收断言 / 15 条负向断言)全绿,且 git diff --name-only
只有 app/ 下 4 个文件 —— conf/、docs/、client/、input/、parts/、ops/ 一字未动。
六、经验与避坑
- 「编号」不是「数量」。
part_number是分片身份标签,可以乱序、可以不连续、 可以被重传;「分片总数」是已到达分片的计数。用part_number == count当「最后一片」 判据,只要编号有跳号或有重传就必错。判「最后一片」只应看本次批次内的到达先后: 向后扫描,遇同会话put_part→ 不是最后一片;遇complete/abort或清单结束 → 是。 dict[k] = v还是dict[k].append(v)? 契约里写「重复上传 = 覆盖」, 代码里就必须是赋值。用 list 存多版本 +get()取第一条,等于静默丢掉了最新内容 —— 而且报告里看起来还「有这一片」,非常隐蔽。- ETag 的口径是两层的:分片 ETag = 该片内容哈希;对象 ETag = 逐片 ETag 串起来再哈希。 用「所有分片内容直接拼接」的哈希当对象 ETag,是一个别的口径的值, 对外返回会与所有按契约实现的客户端对不上。写这种网关时,先把口径写在契约里, 再让代码逐字对上。
BAD_FIELDS = ()是空转的检查。空元组 / 空列表的for循环体一次都不执行, 代码「看起来有检查」。这类空判据表假绿比没有检查更危险,因为它会让人放心。 自查方式:把判据表临时清空,闸门还绿不绿?- 健康检查要读事实,不要读自述。读
state/upload_report.json里的ok字段 等于自己比自己。要在 health 里独立按契约重算一遍(哪怕重复一份代码), 再去比对报告 —— 判据才落在可外部复核的事实上。 - 「判不了」 ≠ 「没问题」。拿不到数据、契约不可用时不能返回「健康」。
退出码要分层:干净 → 0 / 有真问题或拿不到事实 → 2 / 契约不可用 → 3。
main()里无条件return 0是假绿的来源。 ok必须由逐条事实统计而来。ok = len(completed) > 0这种写法在 「有对象落盘」时恒真,哪怕 6 条动作被拒、还有会话停在 open。ok的正确口径是被拒动作数 == 0 and 会话账目自洽。- 跑
negative.sh会覆盖你的app/。这类负向验证脚本收尾常调apply_reference()把app/切成参考解,用来证明「闸门有牙」(在参考解上打 patch 回滚题眼)。 如果你还想用自己手写的修复做最终验收,先备份app/*.py,跑完再拷回来并重建镜像。 - 状态产物是追加写的,验证脚本必须每轮
clear_round(删掉 report/objects.log/rejected.log), 否则直接连跑两遍python3 -m app.service会看到台账条数翻倍 —— 不是 bug,是没清场。
