编程 浏览器直传对象存储:multipart ETag 不是整文件 MD5,分片和清理怎么配

2026-11-13 20:03:37

浏览器直传 S3 兼容存储:签名、分片与清理的落地细节

应用服务器不该搬运字节

文件字节穿过应用进程时,worker 被占满整个上传时长,内存和入口带宽随文件大小线性增长,超过网关超时还要处理半截请求。直传把角色拆开:应用先校验身份、配额与元数据,再用对象存储密钥签出一张有时效、有范围的票;客户端拿票把字节直接发给对象存储;完成后客户端通知应用,应用再去存储侧确认对象存在、大小与校验值匹配,最后落库。应用服务器从头到尾不接触文件内容,worker 占用从分钟级降到毫秒级。

预签名 PUT 与预签名 POST 的语义差别

预签名 PUT 对应单次 PutObject:

import boto3
s3 = boto3.client("s3", region_name="ap-east-1")
url = s3.generate_presigned_url(
    ClientMethod="put_object",
    Params={
        "Bucket": "blog-uploads",
        "Key": "raw/u_1024/2026/11/9f3c2f6e/photo.zip",
        "ContentType": "application/zip",
    },
    ExpiresIn=900,
)

URL 里带 X-Amz-Algorithm、X-Amz-Credential、X-Amz-Date、X-Amz-Expires、X-Amz-SignedHeaders、X-Amz-Signature。X-Amz-Expires 上限 604800 秒,生产给 5–15 分钟即可。

PUT 的边界在签名头:签名只覆盖 X-Amz-SignedHeaders 列出的头。Content-Type 不进签名,客户端就能改成 text/html,对象存储照样接受。要锁死类型,把 ContentType 放进 Params 让它进签名头,并在完成回调里再查一次对象的实际类型。

预签名 POST 签的不是 URL,是一份 policy:

post = s3.generate_presigned_post(
    Bucket="blog-uploads",
    Key="raw/u_1024/2026/11/${filename}",
    Fields={"Content-Type": "application/zip"},
    Conditions=[
        {"Content-Type": "application/zip"},
        ["content-length-range", 1, 200 * 1024 * 1024],
        ["starts-with", "$key", "raw/u_1024/2026/11/"],
    ],
    ExpiresIn=900,
)

条件由对象存储强制执行:content-length-range 在服务端拦掉超大文件,starts-with 把 key 锁在用户前缀内,客户端改表单字段直接 403。PUT 没有这一层,大小限制只能在签名前按声明的 Content-Length 判断,或完成后用 HeadObject 复核。

CORS 的两个必配项

对象存储默认不允许跨域 PUT,预检失败控制台往往只给一句含糊的 CORS error。桶的 CORS 至少放行来源、方法和必要请求头:

[
  {
    "AllowedOrigins": ["https://blog.zhoujun.dev"],
    "AllowedMethods": ["PUT", "POST"],
    "AllowedHeaders": ["content-type", "x-amz-*"],
    "ExposeHeaders": ["ETag", "x-amz-version-id"],
    "MaxAgeSeconds": 3600
  }
]

AllowedHeaders 里的 x-amz-* 是给分片上传用的,缺了它,带校验头的 UploadPart 在预检阶段就被拒。ExposeHeaders 里的 ETag 必须显式暴露,否则浏览器读不到响应头,拿不到每个分片的 ETag,Complete 无从拼装。

key 设计

永远不要把用户提供的文件名直接当 key:原始文件名可能带 ../、反斜杠、控制字符、超长 Unicode、双向文本控制符。可用结构是 raw/{user_id}/{yyyy}/{mm}/{uuid4}/{sanitized_name}。user_id 承担租户隔离与配额统计,年月作为生命周期扫描的前缀,uuid4 保证不可猜测,sanitized_name 只保留白名单字符并截断。key 会出现在访问日志、CDN 日志、浏览器历史和外链里,不要放邮箱、手机号、身份证号。

分片上传的三个 API 与 UploadId

  1. CreateMultipartUpload:指定 bucket、key、Content-Type,返回 UploadId,必须持久化,丢了只能从头重传。
  2. UploadPart:带 UploadId、PartNumber 和内容,返回该片 ETag。分片号 1–10000;除最后一片外每片至少 5 MiB;单片最大 5 GiB;对象最大 5 TiB。
  3. CompleteMultipartUpload:提交按分片号升序的列表,每项含 PartNumber 与 ETag。ETag 对不上返回 InvalidPart;分片号不连续或顺序错误返回 InvalidPartOrder。
mpu = s3.create_multipart_upload(Bucket="blog-uploads", Key=key, ContentType="application/zip")
upload_id = mpu["UploadId"]
parts = []
with open(local_path, "rb") as f:
    for number in range(1, total_parts + 1):
        chunk = f.read(part_size)
        resp = s3.upload_part(Bucket="blog-uploads", Key=key, UploadId=upload_id,
                              PartNumber=number, Body=chunk)
        parts.append({"PartNumber": number, "ETag": resp["ETag"]})
s3.complete_multipart_upload(Bucket="blog-uploads", Key=key, UploadId=upload_id,
                             MultipartUpload={"Parts": parts})

断点续传靠 ListParts:断线后按 UploadId 拉已传分片列表,与本地计划对比,只补缺失的号。同号重复上传是覆盖语义,不确定上次是否成功时重传是安全的。

UploadId 不能只存在浏览器内存里,刷新就丢。客户端只保存业务侧的 session_id,会话落库:

CREATE TABLE upload_sessions (
    id UUID PRIMARY KEY,
    user_id BIGINT NOT NULL,
    object_key TEXT NOT NULL,
    upload_id TEXT NOT NULL,
    total_size BIGINT NOT NULL,
    part_size BIGINT NOT NULL,
    status TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL,
    expires_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE upload_parts (
    session_id UUID NOT NULL REFERENCES upload_sessions(id),
    part_number INT NOT NULL,
    etag TEXT NOT NULL,
    size BIGINT NOT NULL,
    uploaded_at TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (session_id, part_number)
);

status 用明确状态机:uploading、completing、completed、aborted。没有它,重试的 Complete 和用户的取消会互相打架。

分片大小与并发

三个约束同时作用:

  • 分片数上限:part_count = ceil(S/P) ≤ 10000,即 P ≥ S/10000。5 TiB 对象最小分片约 524 MB,必须选 512 MB 或 1 GiB 这一档。
  • 重试粒度:片越小断点重传越省流量,但请求数、签名开销、Complete 列表长度都上升。1 GB 按 8 MiB 切是 128 片,按 64 MiB 切是 16 片,常见折中 8–64 MiB。
  • 内存:并发数乘分片大小就是峰值内存,四路并发、64 MiB 分片就是 256 MiB,在浏览器里已经要认真对待。

吞吐约等于 min(客户端上行, 单连接可达吞吐×并发数, 对象存储侧限额),并发不是越大越好,3–6 路是常见起点。

完整性校验

Content-MD5 是 Base64 编码的 16 字节 MD5,放在 PutObject 或 UploadPart 请求头,不匹配返回 BadDigest;它只能发现传输错误,不能证明文件未被替换。更强的是 x-amz-checksum-sha256,值为 Base64 编码的 SHA-256,分片上传可对每片带校验和,完成时由服务端做组合校验;不同兼容实现的覆盖度不一样,接入前要实测。

一个常见误解:multipart 的 ETag 不是整个对象的 MD5。单次 PutObject 的 ETag 通常是内容的 MD5 十六进制串,multipart 的 ETag 形式是 "<十六进制>-<分片数>",它是对「每个分片 MD5 拼接后的结果」再取 MD5。拿 md5(file) 或 sha256(file) 去比,永远对不上。对象用了 SSE-KMS 之类的服务端加密后,ETag 甚至不再是内容摘要。

服务端用 HeadObject 复核:

head = s3.head_object(Bucket="blog-uploads", Key=key)
assert head["ContentLength"] == declared_size
if head.get("ChecksumSHA256"):
    assert head["ChecksumSHA256"] == declared_sha256_base64

校验失败的处理顺序要定死:先把会话标成 failed,再删除已写入的对象,保留失败原因和已上传分片信息供重试,最后告警。不要「先落库再校验」。

安全边界

文件名用白名单加重新生成:取 basename、NFKC 归一化、只留字母数字点下划线短横线、压缩连续点、长度截断、前面拼 UUID。Content-Type 只是声明,真实类型用 magic bytes 判断:

mime = magic.from_buffer(head_bytes, mime=True)
if mime not in {"image/jpeg", "image/png", "application/pdf", "application/zip"}:
    raise ValueError("unsupported content type")

不可信来源强制 Content-Disposition: attachment 并加 X-Content-Type-Options: nosniff,用户上传内容放独立域名或独立桶。图片要防解压炸弹:先读头部拿宽高,按 width*height*单像素字节 估内存,超阈值直接拒。签名票只允许一个方法、一个 key、一个有效期,不要签「整个前缀可写」。

访问控制上,上传桶默认私有并阻止公开访问;读取用预签名 GET 或 CDN 签名 URL/Cookie,共同点是授权发生在签名之前。典型漏洞是 IDOR:GET /api/files?key=raw/u_1/... 服务端不查归属直接对传入 key 签名,攻击者知道别人的 key 就能下载。key 不是权限,只是地址。CDN 场景下私有内容的缓存键必须包含签名参数,或用 Cache-Control: private。日志只记 bucket、key、会话 ID 和结果码,不记完整 URL——X-Amz-Signature 本身就是凭证。

去重以 SHA-256 为基本单位,对 (tenant_id, sha256, size) 建唯一约束,命中时创建新引用而不重传字节;删除走引用计数减一,减到零标 deleted_at 留宽限期再物理删。跨租户共享对象会形成存在性预言机(上传已知文件得到「秒传成功」即证明文件存在),敏感业务限制在租户内,或用 HMAC(tenant_key, sha256) 作去重键。孤儿对账用 S3 Inventory,不要用 ListObjects 全量扫。

孤儿分片清理

未完成的上传会一直占着已上传分片的存储费,两道清理都要做:用户主动取消时调 AbortMultipartUpload;桶上配置生命周期规则 AbortIncompleteMultipartUpload,按 DaysAfterInitiation 自动清。只做前者一定留孤儿;只做后者,取消后的空间要等好几天才释放。

参考

复制全文 生成海报 对象存储 上传 后端 S3

推荐文章

程序员茄子在线接单