适用读者:正在让应用服务器中转上传字节的开发者;被大文件超时、内存上涨、文件损坏折腾过的人;准备把上传切到 S3 兼容对象存储,但不确定签名、分片和校验怎么配合的人 结论先行:上传的正确姿势不是「把应用服务器调大、超时调长」,而是让字节从客户端直接进入对象存储。服务端只做三件事:签发一张有范围、有时效的票,记录会话状态,在完成后校验对象。分片解决的是可恢复性,校验解决的是可信度,两者不能互相替代。 阅读时间:约 32 分钟 | 难度:进阶 | 收获:一条从浏览器到对象存储的完整上传链路、S3 multipart 的三个 API、multipart ETag 的真实含义、安全与访问控制的边界,以及一套能落地的观测指标目录
client_max_body_size 默认是 1 MB,我早期改到 50 MB,100 MB 的文件在入口就被 413 拒绝。读者看到的却是前端自己包装过的「网络异常」,于是他不断重试。
第二件,我把限制调到 200 MB,请求进了应用。gunicorn 用的是同步 worker,四个进程,timeout 是 30 秒。一个 100 MB 的文件在读者十几 Mbps 的家宽上行里,光传输就要一分钟以上。worker 从收到请求到写完临时文件,全程被这一个连接占着,30 秒一到就被主进程杀掉,连接断开,读者侧再次失败。
第三件,最致命的一处写在兜底分支里:为了避免「取不到文件」,代码先执行了 content = request.body。这个属性会把整个请求体读进内存,100 MB 的字节对象加上解析过程的副本,单个进程的内存曲线直接竖起来。四路并发重试之后,OOM killer 把进程杀掉,监控上看到的是「实例随机重启」。
第四件,是数据损坏。有一次请求在传输到 60% 时客户端断网,服务端的写循环没有校验最终长度,接口依然返回 200,数据库也落了记录。第二天读者下载回来,拿到的是一个只有前 60 MB 的 zip,解压时报「文件意外结束」。这比直接失败更糟糕,因为失败至少是诚实的。
这四件事指向同一个根因:应用服务器在代理字节。只要字节必须穿过 Django 进程,网络慢就会变成进程慢,客户端超时就会变成 worker 被杀,磁盘写一半就会变成数据损坏。调大内存、调长超时、加更多 worker,只是在给这个错误的架构续命。
FILE_UPLOAD_MAX_MEMORY_SIZE 的默认值是 2621440 字节,也就是 2.5 MB。小于它的文件会放在 InMemoryUploadedFile 里,大于它的会由 TemporaryFileUploadHandler 流式写进临时文件,得到一个 TemporaryUploadedFile。这一步本身是流式的,解析器按块读取请求体,并不会把 100 MB 一次性塞进内存。
问题出在三个地方。
其一是代码。很多「取文件」的写法会破坏流式:request.body 读整个请求体;uploaded.read() 不带参数时读全部;先 list(file.chunks()) 再处理;把文件内容塞进 JSON 字段。只要出现一次全量读取,2.5 MB 的阈值就失去意义,内存占用变成「并发数 × 文件大小」。
其二是临时磁盘。大文件会落到 FILE_UPLOAD_TEMP_DIR 或系统临时目录。一个 100 MB 的文件传进来,磁盘上先出现 100 MB;如果后面还要复制到最终位置、再上传到对象存储,峰值可能是两到三份。临时文件在请求结束前不会自动消失,worker 被强杀时还会残留。磁盘满的时候,失败的不是上传接口,而是同一台机器上的数据库连接、日志写入和会话文件。
其三是请求体上限与超时,这两项经常被混为一谈。DATA_UPLOAD_MAX_MEMORY_SIZE 限制的是非文件部分的请求体,默认也是 2.5 MB;它并不限制文件上传本身。真正卡住大文件的是网关的 client_max_body_size、反向代理的 proxy_read_timeout、应用服务器的 timeout,以及客户端自己的超时。四者只要有一个先到,请求就会在某个中间状态被切断。
把成本写成式子会更清楚。设文件大小为 S,客户端上行带宽为 B,忽略服务端处理时间,单个上传占用的时长约等于 S/B。如果每秒有 λ 个上传请求,根据 Little 定律,系统内同时进行的上传数约为 λ·S/B。四个同步 worker 意味着同时只能有四个请求在跑,超过的部分全部排队。100 MB 在 10 Mbps 上行下需要约 80 秒,λ 只要达到 0.06,也就是每分钟不到四个上传,平均并发就已经超过四个 worker。加 worker 并不会降低单请求时长,只会让更多进程一起等待网络。
还有一笔不显眼的账:流量走了两遍。客户端传到应用服务器是一遍,应用服务器再传到对象存储是第二遍。对象存储的入口流量通常不计费,但应用服务器的带宽、出口流量和 NAT 连接数都要付钱;更贵的是,这条链路上任何一个环节变慢,都会把压力反压回应用进程。
所以「朴素 multipart」不是不能用,它只适合小文件、低并发、且能接受同步处理的场景。头像、几十 KB 的配置、后台批量导入的小 CSV,走应用服务器完全没问题。一旦文件进入几十 MB、上百 MB,或者上传成为高频动作,代理模式的结构性成本就会盖过它的便利。
PutObject。服务端用 generate_presigned_url 生成一个带查询参数的 URL:
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,
)
X-Amz-Algorithm、X-Amz-Credential、X-Amz-Date、X-Amz-Expires、X-Amz-SignedHeaders 和 X-Amz-Signature。X-Amz-Expires 的上限按 S3 API docs 是 604800 秒,也就是七天,但生产里没人会用七天:票的有效期越长,泄漏后的窗口越长。上传票给五到十五分钟就够,客户端拿到后应当立刻开始传。
预签名 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 没有 policy 这一层,大小限制只能在签名前根据声明的 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、双向文本控制符;即使对象存储的 key 是扁平字符串,这些字符也会在 CDN 回源、本地缓存、日志和下载头里制造麻烦。可用的结构是:
raw/{user_id}/{yyyy}/{mm}/{uuid4}/{sanitized_name}
user_id 用来做租户隔离和配额统计,年月用来做生命周期扫描的前缀,uuid4 保证不可猜测,sanitized_name 只保留白名单字符,并且长度截断。原始文件名放进对象 metadata 或数据库,展示时再做转义,不要参与路径。
key 里也不建议放邮箱、手机号、身份证号这类信息。对象存储的 key 会出现在访问日志、CDN 日志、浏览器历史和外链里,一旦写进去就很难收回。需要人可读的展示名,放在数据库字段里,下载时通过 Content-Disposition 设置。
CreateMultipartUpload。客户端(或服务端)指定 bucket、key、Content-Type,S3 返回一个 UploadId。这个 ID 是后续所有分片操作的句柄,必须持久化,丢了就只能重来。
第二,UploadPart。每次带上 UploadId、PartNumber 和这一段的内容,S3 返回该分片的 ETag。分片号从 1 到 10000;除最后一片外,每片至少 5 MiB;单片最大 5 GiB;整个对象最大 5 TiB,这些约束来自 S3 API docs。最后一片可以小于 5 MiB,这是给「文件大小不是分片大小整数倍」留的口子。
第三,CompleteMultipartUpload。客户端提交一个按分片号升序排列的列表,每项包含 PartNumber 和对应的 ETag。S3 按这个列表拼装对象。列表里任何一项的 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 拉取已经传完的分片列表,拿到每个分片的号、大小和 ETag,再和本地的分片计划对比,只补缺失的号。同一个分片号重复上传是覆盖语义,不会产生两份数据,所以「不确定上次是否成功」时,重传那一片是安全的。
这里有一个必须提前想清楚的状态问题: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 和用户的取消会互相打架。
分片上传还有一个隐形成本:未完成的上传会一直占着已上传的分片空间,按对象存储的计费规则照常收存储费。用户传了一半就走人,这些分片不会自动消失。两道清理必须都做:用户主动取消时调用 AbortMultipartUpload;桶上配置生命周期规则 AbortIncompleteMultipartUpload,按 DaysAfterInitiation 在若干天后自动清掉。只做前者,一定会留下孤儿;只做后者,取消后的空间要等好几天才释放。
part_count = ceil(S / P),且 part_count ≤ 10000。反过来,P ≥ S / 10000。1 GB 的文件,理论最小分片约 105 KB,但实际不会这么切;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(客户端上行, 单连接可达吞吐 × 并发数, 对象存储侧限额)
Complete 的请求体大小与分片数成正比。10000 片的列表是几百 KB 的 JSON,虽然不算大,但已经接近一些网关的默认请求体限制。分片数过多时,Complete 本身也会变成需要重试的请求。
Content-MD5。它是一个 Base64 编码的 16 字节 MD5,放在 PutObject 或 UploadPart 的请求头里。S3 会计算收到的内容并比对,不匹配返回 BadDigest。优点是几乎所有 S3 兼容实现都支持,成本低;缺点是 MD5 已经不适合作为抗碰撞的安全摘要,只能用来发现传输错误,不能用来证明「这份文件没有被恶意替换」。
更强的做法是 SHA-256。S3 支持附加校验和,x-amz-checksum-sha256 的值是 Base64 编码的 SHA-256;分片上传可以对每个分片带校验和,完成时由服务端做组合校验。具体支持的头和组合方式按 S3 API docs 的 checksum 章节执行,不同兼容实现的覆盖度不一样,接入前要实测自己的存储端,而不是假设「S3 兼容」就等于「全部支持」。
这里必须拆穿一个流传很广的误解:multipart 的 ETag 不是整个对象的 MD5。
单次 PutObject 的 ETag,在未使用某些加密方式时,通常是对象内容的 MD5 十六进制串。但 multipart 的 ETag 形式是 "<一串十六进制>-<分片数>",它是对「每个分片 MD5 拼接后的结果」再取 MD5,不是对完整文件取 MD5。S3 API docs 明确写了这一点。所以拿客户端的 sha256(file) 或 md5(file) 去和 multipart ETag 比较,永远对不上;这不是文件坏了,而是你比较了两个不同的东西。
如果对象用了 SSE-KMS 之类的服务端加密,ETag 甚至不再是内容摘要,连单次 PUT 都不能当 MD5 用。把 ETag 当校验值的代码,在切换加密方式的那天会集体误报。
正确的双重校验是这样分工的:
客户端在读取文件时流式计算 SHA-256,一边上传一边算,不额外读一遍磁盘:
import hashlib
def sha256_of(path, chunk_size=1024 * 1024):
digest = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(chunk_size), b""):
digest.update(chunk)
return digest.hexdigest()
sha256 和 size 交给服务端。服务端不信任这个值,而是用 HeadObject 读对象存储记录的 ContentLength 和校验和:大小必须与声明一致;如果上传时带了 SHA-256 校验和,直接比对;如果没带,对高价值文件做一次服务端回读计算,普通文件则至少校验大小和可读性。
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,再删除已经写入的对象,保留失败原因和已上传分片信息供重试,最后告警。不要「先落库再校验」,否则数据库里会出现指向坏对象的记录;也不要「校验失败就静默重试三次」,坏文件往往不是偶发网络问题,重试只会放大存储成本。
../../etc/passwd、..\\..\\windows\\win.ini、带 NUL 字节的名字、超长名字、以及利用 Unicode 规范化产生视觉混淆的名字。防护不是「把 .. 替换掉」这种黑名单,而是白名单加重新生成:取 basename,做 NFKC 规范化,只保留字母、数字、点、下划线、短横线,把连续的点压缩,长度截到 100 字符以内,最后在前面拼 UUID。用于本地落盘的场景,还要再用 os.path.realpath 确认最终路径在允许的根目录内。
第二层是 Content-Type 欺骗。浏览器发来的 Content-Type 只是声明,攻击者可以把一个 HTML 文件声明成 image/png。如果这个对象随后从你的域名以 image/png 返回,浏览器可能仍然按内容嗅探,造成存储型 XSS。防线有三道:用 magic bytes 识别真实类型,不只看扩展名和声明头;对不可信来源强制 Content-Disposition: attachment 并加 X-Content-Type-Options: nosniff;把用户上传内容放在独立域名或独立桶上,避免与主站共享 Cookie 和同源策略。
import magic
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")
python-magic 依赖 libmagic,容器里要装系统库;它对 polyglot 文件也不是万能的,所以高风控场景还要结合解析器实际打开一次。
第三层是图片解压炸弹。一个几十 KB 的 PNG 可以声明 20000×20000 的像素,解码后是上 GB 的位图。Pillow 有 Image.MAX_IMAGE_PIXELS 保护,默认大约是 8900 万像素,超过会告警,超过两倍会抛 DecompressionBombError;但很多服务把它关掉了,或者在解码前没有检查尺寸。正确顺序是先读头部拿宽高,按 width * height 和单像素字节数估算内存,超过阈值直接拒绝,再谈解码。解码后重新编码一遍,顺便去掉 EXIF 里的 GPS 和设备信息。
第四层是病毒与恶意文档。面向公众的文件共享必须做异步扫描,而不是同步阻塞上传。对象先进入 quarantine/ 前缀,扫描器(例如 ClamAV)拉取、扫描、打标签,干净后才允许生成下载链接;命中病毒的对象移到隔离区并记录。扫描要有超时和大小上限,压缩包要限制递归层数和解压后总大小,否则扫描器自己会被压缩炸弹打垮。
第五层是「从 URL 导入」带来的 SSRF。这个功能看起来只是帮用户下载一张图,实际上应用服务器会带着自己的网络身份去访问用户给的地址。攻击者可以填 http://127.0.0.1:8000/admin、http://169.254.169.254/latest/meta-data/、内网网段的数据库地址。防护要同时做:只允许 http 和 https;解析域名后检查所有 A/AAAA 记录,拒绝回环、私网、链路本地、保留地址;禁止重定向,或对每一跳重新解析并重新检查;设置连接超时、读取超时和最大字节数;最好把抓取放在没有内网权限的独立 worker 或独立网络命名空间里。只做字符串黑名单挡不住 DNS 重绑定。
第六层是签名 URL 的范围。一张上传票应当只允许一个方法、一个 key、一个有效期,必要时再加内容类型和大小范围。不要签「整个前缀可写」的票,也不要用同一张票既允许上传又允许读取。票是 bearer 凭证,拿到就能用,所以不能放进会被 Referer 带走的页面,也不能写进日志。
GET /api/files?key=raw/u_1/2026/11/xxx/photo.zip,服务端不查归属,直接对传入的 key 签名。攻击者只要知道别人的 key,就能拿到下载链接。key 不是权限,key 只是地址;不可猜测的 UUID 降低了枚举概率,但不能替代授权检查。正确做法是客户端只传业务 ID,服务端由 ID 查到记录、查到 owner,再决定是否签名。
CDN 场景还有两个细节。第一,私有内容的缓存键必须包含签名参数,或者使用签名 Cookie 并设置 Cache-Control: private,否则一个用户的签名响应可能被边缘节点缓存给另一个用户。第二,签名有效期要短于或等于缓存 TTL;权限被撤销后,已经签发的 URL 在到期前仍然有效,这是签名机制的固有窗口,只能靠缩短 TTL 和必要时刷新签名密钥来控制。
下载响应还要处理 Content-Disposition。用户上传的文件名可能包含引号、换行、分号,直接拼进头里会造成响应头注入。用 RFC 5987 的 filename* 编码,或者干脆只用服务端生成的 ASCII 文件名,展示名放在响应体或前端。
最后是日志。预签名 URL 里的 X-Amz-Signature 就是凭证,完整打印到访问日志等于把票公开。日志只记录 bucket、key、会话 ID 和结果码,不记录完整 URL;错误上报同理。
(tenant_id, sha256, size) 建唯一约束。命中已有记录时,不再上传字节,直接创建一条新的引用,返回已有的对象 key。
引用计数可以用一张关系表,也可以在文件表上加 ref_count。关键是删除路径不能直接删对象:先把引用减一,减到零时标记 deleted_at,留七到三十天的宽限期,再由生命周期规则或清理任务物理删除。这样用户误删可以恢复,并发引用也不会出现「A 删除时把 B 还在用的对象删掉」。
去重有一个隐私边界:跨租户共享同一个对象会形成「存在性预言机」。攻击者上传一个已知文件,如果系统告诉他「秒传成功」,他就知道这个平台上有别人存过这份文件。对敏感业务,去重范围要限制在租户内,或者用 HMAC(tenant_key, sha256) 作为去重键,让不同租户的相同内容落到不同对象上。
生命周期规则至少覆盖四类对象:未完成的分片上传,按 AbortIncompleteMultipartUpload 清理;临时上传和未引用的 raw/ 对象,按天数过期;长期不访问的归档文件,转低频或归档存储;旧版本对象,按非当前版本过期。规则要按前缀设计,key 结构里的 raw/{user}/{yyyy}/{mm}/ 就是为了让规则能精确命中,而不是全桶扫描。
孤儿有两种。一种是「对象在、数据库没有」:上传成功但完成回调丢失,或者客户端传完就跑。另一种是「数据库有、对象没有」:对象被外部误删,或者生命周期规则提前命中。前者用对象存储的清单(S3 Inventory)定期和数据库对账,不要用 ListObjects 全量扫,百万对象会把请求费用和耗时都拉满;后者在生成下载链接时做一次 HeadObject,发现缺失就把记录标成 missing 并告警。
FILE_UPLOAD_MAX_MEMORY_SIZE 决定文件在内存还是临时文件里,默认 2.5 MB。调大它不会让大文件变快,只会让每个并发请求多占内存;调小它会让更多文件落到磁盘,增加临时盘 I/O。FILE_UPLOAD_TEMP_DIR 应该指向一个有容量上限、和应用日志分开的磁盘。FILE_UPLOAD_PERMISSIONS 控制落盘文件的权限,默认 0o644,多用户部署时要确认 umask 不会把它变宽。
DATA_UPLOAD_MAX_MEMORY_SIZE 限制的是非文件请求体,默认同样 2.5 MB;它不是文件大小限制。很多人把它调到 200 MB 以为能传大文件,实际只是放大了 JSON 表单被吃进内存的风险。文件大小要在中间件层看 Content-Length 先拒一次,再在解析后用 uploaded.size 复核一次。
DATA_UPLOAD_MAX_NUMBER_FIELDS 默认 1000,防止一个请求带几万个表单字段把解析器拖死。批量导入场景如果确实要很多字段,应该改成分片提交,而不是把这个值调到无限。
模型层有两种存法。用 FileField 配对象存储后端(例如 django-storages 的 S3 后端)时,Django 会把文件交给 storage 的 save,具体是否流式、是否自动分片取决于后端实现;用 CharField 只存 key,把对象存储当成外部系统,模型里保留 key、size、sha256、content_type、owner、status,职责更清晰,也不依赖 storage 的隐式行为。后一种在直传架构里更常见。
upload_to 如果要用,必须传一个函数而不是把用户文件名拼进字符串:
def upload_to(instance, filename):
ext = os.path.splitext(filename)[1].lower()
if ext not in {".jpg", ".png", ".pdf", ".zip"}:
raise ValueError("unsupported extension")
return f"raw/u_{instance.owner_id}/{timezone.now():%Y/%m}/{uuid4().hex}{ext}"
read() 再写。正确姿势是按块消费:
def save_stream(uploaded, dest_path, chunk_size=64 * 1024):
with open(dest_path, "xb") as out:
for chunk in uploaded.chunks(chunk_size):
out.write(chunk)
chunks() 对 TemporaryUploadedFile 是从磁盘读,对 InMemoryUploadedFile 是从内存切;无论哪种,都不会一次性构造完整副本。写完以后用 os.fsync 或依赖文件系统的持久化语义,再做大小校验。如果进程在中途被杀,临时文件可能残留,启动时的清理任务要按修改时间清掉超过一定时长的临时文件。
POST 到上传端点,带 Upload-Length 和可选的 Upload-Metadata,服务端返回 Location,指向这个上传资源。查询进度用 HEAD,响应里的 Upload-Offset 表示服务端已经收到多少字节。续传用 PATCH,请求头带 Upload-Offset 和 Content-Type: application/offset+octet-stream,响应返回新的偏移量。偏移不匹配时返回 409,客户端必须重新 HEAD 对齐,而不能按本地记录硬传。
tus 的价值在于语义统一:创建、查询、追加、终止(DELETE)都有明确的状态码和头,客户端库可以直接处理暂停、恢复、跨刷新恢复。它的代价是需要一个能存分片状态的服务端;如果最终存储是 S3,这个服务端要么把分片暂存本地再合并上传,要么把 tus 的偏移映射到 S3 multipart 的分片号,两套状态要能对上。
无论用哪种协议,客户端重试都要遵守几条纪律。只对网络错误、超时、5xx 和 429 重试,4xx 里的签名过期、权限不足、校验失败不要盲目重试。退避用指数加抖动,设置上限,避免所有客户端在同一时刻重试打垮入口。续传偏移以服务端返回为准,本地记录只是提示;每次恢复前先查一次进度。
幂等要落在服务端。创建会话用 Idempotency-Key 或客户端生成的 UUID,数据库唯一约束保证重复创建不会产生两个 UploadId。完成操作要有状态机保护:只有 uploading 能进入 completing,进入后加行锁或条件更新,重复的完成请求直接返回当前状态。CompleteMultipartUpload 如果返回 NoSuchUpload,不要立刻判失败,先 HeadObject 看对象是否已经存在且大小正确:存在就视为成功,不存在才重开会话。
分片上传本身是幂等的:同一个 PartNumber 重复上传会覆盖,不会追加。利用这一点,客户端可以在不确定上一次是否成功时安全重传,而不需要先查询。
月存储费 ≈ Σ 对象字节数 × 单价 + 未完成分片字节数 × 单价
月请求费 ≈ 上传请求数 + 分片请求数 + 完成请求数 + 下载请求数
月流量费 ≈ 下载字节数 × 出口单价
ceil(S/P) + 2(创建和完成各一次)。把分片大小从 8 MiB 调到 64 MiB,1 GB 文件的请求数从 130 降到 18,但单次失败的重传成本从 8 MiB 升到 64 MiB。这个取舍要按网络质量做,不是单纯追求请求数最少。
告警建议设在「成功率连续五分钟低于阈值」「P95 超过目标的两倍」「孤儿分片字节数一天内增长超过阈值」「403 中签名过期占比突然升高」。最后一条往往是服务端时钟漂移或签名 TTL 配错,属于配置问题,不该让用户来承担。
日志里记录会话 ID、key、分片号、ETag、错误码和耗时,不记录完整签名 URL。跨服务追踪用同一个 trace ID,从申请签名一路带到完成校验,排查时才能把「客户端说传完了」和「服务端说没收到」对上。
| 问题 | 结论 |
|---|---|
| 大文件为什么要直传 | 应用服务器代理字节会把网络时长变成进程占用,超时、内存、损坏都由此而来 |
| 预签名 PUT 和 POST 怎么选 | 需要 policy 强约束大小和前缀用 POST;单对象、已有大小校验用 PUT |
| 分片上传解决什么 | 可恢复性和并行吞吐,不解决内容正确性 |
| multipart ETag 能当 MD5 吗 | 不能,它是分片 MD5 拼接后的摘要再加分片数后缀 |
| 完整性怎么保证 | 客户端流式 SHA-256 加服务端大小与校验和复核,做双重校验 |
| 上传票的边界 | 一个方法、一个 key、短 TTL、必要时锁 Content-Type 和大小 |
| key 等于权限吗 | 不等于,授权必须在签名之前查归属 |
| 孤儿分片怎么清 | 主动 Abort 加生命周期 AbortIncompleteMultipartUpload,两道都要 |
| 断点续传以谁为准 | 服务端返回的偏移和分片列表,客户端记录只是提示 |
| 观测看什么 | 分阶段成功率、按大小分桶的 P95、孤儿分片字节数、4xx 分解 |
专题:后端架构 — Django、并发、限流、灰度与线上部署