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

对象存储分片上传网关:重复分片静默丢掉最新内容、最后一片判定拿编号当数量、对象 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/ 一字未动。

六、经验与避坑

  1. 「编号」不是「数量」。part_number 是分片身份标签,可以乱序、可以不连续、 可以被重传;「分片总数」是已到达分片的计数。用 part_number == count 当「最后一片」 判据,只要编号有跳号或有重传就必错。判「最后一片」只应看本次批次内的到达先后: 向后扫描,遇同会话 put_part → 不是最后一片;遇 complete/abort 或清单结束 → 是。
  2. dict[k] = v 还是 dict[k].append(v)? 契约里写「重复上传 = 覆盖」, 代码里就必须是赋值。用 list 存多版本 + get() 取第一条,等于静默丢掉了最新内容 —— 而且报告里看起来还「有这一片」,非常隐蔽。
  3. ETag 的口径是两层的:分片 ETag = 该片内容哈希;对象 ETag = 逐片 ETag 串起来再哈希。 用「所有分片内容直接拼接」的哈希当对象 ETag,是一个别的口径的值, 对外返回会与所有按契约实现的客户端对不上。写这种网关时,先把口径写在契约里, 再让代码逐字对上。
  4. BAD_FIELDS = () 是空转的检查。空元组 / 空列表的 for 循环体一次都不执行, 代码「看起来有检查」。这类空判据表假绿比没有检查更危险,因为它会让人放心。 自查方式:把判据表临时清空,闸门还绿不绿?
  5. 健康检查要读事实,不要读自述。读 state/upload_report.json 里的 ok 字段 等于自己比自己。要在 health 里独立按契约重算一遍(哪怕重复一份代码), 再去比对报告 —— 判据才落在可外部复核的事实上。
  6. 「判不了」 ≠ 「没问题」。拿不到数据、契约不可用时不能返回「健康」。 退出码要分层:干净 → 0 / 有真问题或拿不到事实 → 2 / 契约不可用 → 3。 main() 里无条件 return 0 是假绿的来源。
  7. ok 必须由逐条事实统计而来。ok = len(completed) > 0 这种写法在 「有对象落盘」时恒真,哪怕 6 条动作被拒、还有会话停在 open。 ok 的正确口径是 被拒动作数 == 0 and 会话账目自洽。
  8. 跑 negative.sh 会覆盖你的 app/。这类负向验证脚本收尾常调 apply_reference() 把 app/ 切成参考解,用来证明「闸门有牙」(在参考解上打 patch 回滚题眼)。 如果你还想用自己手写的修复做最终验收,先备份 app/*.py,跑完再拷回来并重建镜像。
  9. 状态产物是追加写的,验证脚本必须每轮 clear_round(删掉 report/objects.log/rejected.log), 否则直接连跑两遍 python3 -m app.service 会看到台账条数翻倍 —— 不是 bug,是没清场。

版权声明

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

分享这篇文章