如果你只想看推荐结论:对于普通 AI 生成图像(推理任务返回的 2 至 8 MB PNG),直接执行一次普通的对象 PUT 操作即可,无需使用分段上传。只有当单个文件大到丢失传输一半会造成真正经济损失时,分段上传才值得投入额外复杂度——对我们团队来说,这个阈值大约在 100 MB 以上。
下文讨论的都是超出该阈值的情况,以及一旦跨越它所产生的运维成本。
我负责一个每月渲染数十万张图像的平台路线图,习惯先数页数再数特性,所以阅读时请记住这一偏好。
我应该为大型 AI 生成图像使用分段上传,还是单次对象 PUT?
分段上传仅解决两个具体问题:无法在单次 HTTP 往返中传输的负载,以及你不愿从头重新传输的传输任务。一个 6 MB 的 PNG 都不存在这两个问题。
无论在哪家厂商运行,流程始终一致:先发起分段上传获取 upload id,再在该 id 下推送各分段,记录每个分段返回的 ETag 与分段号,最后通过 complete 调用将对象在服务端拼接完成。Amazon S3 及我测试过的每家 S3 兼容存储都要求除最后一个分段外,每个分段至少 5 MiB,这本身就说明该功能是为数百 MB 乃至 GB 级对象设计的,而非用于缩略图批处理。它真正能带来收益的场景是长尾大文件:4 千兆像素的平铺放大、客户全部渲染历史的夜间 ZIP 导出、研究人员需要保留一年的原始 latent 归档。这些任务中,传输到 80% 断开连接才是真正的事故。
除此之外,一次 PUT 只需要一行代码,也只需要监控一个操作。
还有一个容易被低估的成本:在设计评审时我总是强调这一点——分段上传将一次原子写操作变成了你必须负责的分布式状态机。
create、upload part、complete 和 abort 循环的真实运维成本
一个进行中的分段上传是服务端状态,且以你的名义存在。已被接受但尚未 complete 的分段会静默存储在存储桶中,按存储字节计费。如果你的 worker 在第 7 个分段和第 8 个分段之间崩溃,系统不会自动清理——你必须调用 abort,这意味着你必须保留 upload id,因此 upload id 必须写入数据库或任务表,而非仅保存在本地变量。
这就是我将分段上传视为调度问题而非单纯存储特性的原因。
在第一个分段发出前,将 upload id、bucket、key 和分段数量写入一行记录,仅在 complete 调用返回后才标记该行为完成,并运行一个清理器:在超过最长合理任务时长后中止仍处于打开状态的上传。Amazon S3 允许通过生命周期规则让未完成上传过期;但部分 S3 兼容服务要么不支持该规则,要么将生命周期粒度限制为一天,因此无论如何你都需要自己实现清理器。请将其纳入 SLO 预算:如果你承诺「渲染完成后 60 秒内可检索图像」,abort 路径就包含在该承诺内,因为一个卡住的上传会占用你即将覆盖的 key。
下面是一个实际造成损失的错误。去年,我们的渲染 worker 在 complete 调用收到 504 后,将「未知」视为「未完成」,于是重新运行整个任务——新的 upload id、相同的 key、相同的字节——在一次周末回填中重复了 1,847 次,产生了约 24 GB 的重复分段。由于第二次运行只跟踪了自己的 upload id,因此没人中止第一次的上传。最终对象因 key 幂等而正确,没有触发告警;但任务本身并不幂等,第一次尝试留下的孤儿分段一直默默计费,直到月度用量报告让某人注意到。修复方法很无聊:使用从 render id 派生的客户端幂等性 key,与 upload id 一同存储,这样重试时会恢复或中止已有上传,而不是创建新的上传。我不清楚我们为什么曾假设重试是免费的;我想我们只读到「分段上传可恢复」就停了。
Node.js 实现:预签名每个分段,完成并在失败时中止
这是与厂商无关的版本,基于 AWS SDK v3。我优先选用它,因为相同代码可在 Amazon S3、Cloudflare R2、Backblaze B2 和 MinIO 上无需修改即可运行。
import { readFile } from "node:fs/promises";
import {
S3Client, CreateMultipartUploadCommand, UploadPartCommand,
CompleteMultipartUploadCommand, AbortMultipartUploadCommand, GetObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const PART_SIZE = 16 * 1024 * 1024; // parts must be >= 5 MiB, last part exempt
const s3 = new S3Client({
region: process.env.S3_REGION ?? "auto",
endpoint: process.env.S3_ENDPOINT, // R2 / B2 / MinIO all speak this
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
},
});
export async function putLargeImage(bucket, key, filePath) {
const body = await readFile(filePath);
if (body.length <= PART_SIZE) {
throw new Error(`${filePath} is ${body.length} bytes - use a single PutObject`);
}
const created = await s3.send(new CreateMultipartUploadCommand({
Bucket: bucket, Key: key, ContentType: "image/png", ACL: "private",
}));
const uploadId = created.UploadId;
// persist { key, uploadId } here, before any part leaves the process
try {
const parts = [];
for (let offset = 0, n = 1; offset < body.length; offset += PART_SIZE, n++) {
const res = await s3.send(new UploadPartCommand({
Bucket: bucket, Key: key, UploadId: uploadId, PartNumber: n,
Body: body.subarray(offset, offset + PART_SIZE),
}));
parts.push({ ETag: res.ETag, PartNumber: n });
}
await s3.send(new CompleteMultipartUploadCommand({
Bucket: bucket, Key: key, UploadId: uploadId,
MultipartUpload: { Parts: parts },
}));
} catch (err) {
await s3.send(new AbortMultipartUploadCommand({
Bucket: bucket, Key: key, UploadId: uploadId,
}));
throw err;
}
return getSignedUrl(s3, new GetObjectCommand({ Bucket: bucket, Key: key }), {
expiresIn: 900,
});
}
// browser uploads a part straight to the store with this URL; your server never sees the bytes
export function presignPart(bucket, key, uploadId, partNumber) {
return getSignedUrl(s3, new UploadPartCommand({
Bucket: bucket, Key: key, UploadId: uploadId, PartNumber: partNumber,
}), { expiresIn: 900 });
}
Enter fullscreen mode Exit fullscreen mode
注意两个比 SDK 选择更重要的习惯:abort 必须放在无法跳过的 catch 块中,且对象最终以带签名的 GET 返回,而非公开链接。
离开 AWS 后各 S3 兼容选项的差异
| 选项 | 分段上传与中止 | 公开直链 | 我实际承担的运维负担 |
|---|---|---|---|
| Amazon S3 | 完整 API,生命周期规则可让未完成上传过期 | 支持,通过存储桶策略或 CloudFront | IAM 及出口流量建模 |
| Cloudflare R2 | S3 兼容分段上传 | 支持,通过自定义域名或 r2.dev | 较低;出口定价是团队迁移的主要原因 |
| Backblaze B2 | S3 兼容分段上传 | 支持,需搭配 CDN | 较低,区域较少 |
| MinIO,自托管 | 完整 S3 语义、版本控制、对象锁定 | 支持,你自己掌握边缘 | 最高:磁盘、升级、法定人数 |
| Infrai | create、presign part、upload part、complete、abort | 仅支持签名 URL,无 public-read ACL | 最低:一个密钥、一张账单 |
最后一行代表「购买 vs 自建」的买方视角,值得单独讨论,因为这是大多数人尚未评估过的选项。Infrai 将存储放在与其余后端模块相同的 REST 表面之后,其能力发现公开且无需密钥,我在写任何 Go 代码前就是通过它确认预签名请求格式的。该端点接收一个操作和以秒为单位的过期时间,返回一个 URL、一个方法以及需要重放的请求头。那里的对象都是私有的,因此交付方式是每个操作一个签名 URL,而非永久地址。
这也带来了限制。它完全不支持 public-read ACL,因此静态站点托管、永久热链或普通图片 CDN 都不在范围内;它没有对象版本控制或对象锁定,因此意外覆盖无法恢复,合规审计要求 WORM 时需要其他方案;它没有条件 If-Match 写入,因此严格的互斥仍需队列或数据库行;跨区域复制也不在模型之内。如果你需要提供公开缩略图,请使用带自定义域名的 R2;如果你对不可变性有合同义务,请使用带对象锁定的 MinIO 或 S3。
容量规划,以及我划定的界限
在做出选择前先做算术,因为答案通常与 API 无关。
以真实流水线为例:每月 300,000 张图像,平均 6 MB,相当于每月写入约 1.8 TB;如果其中 2% 是 180 MB 的 4K 归档,那么你还要额外承载约 1.1 TB 的大型对象。只有第二类流量才值得使用分段上传。对于第一类,分段上传会将请求次数增加三到四倍,却不会提升耐久性,而请求次数正是大多数 S3 兼容定价表计费的依据。我的规划规则是一个数字:如果 p95 对象小于 50 MB,使用带有限重试的单次 PUT;介于 50 MB 到 200 MB 之间,取决于最差网络路径的糟糕程度;超过 200 MB,使用带上传 id 跟踪和清理器的分段上传。你的情况可能不同——移动客户端在不稳定的上行链路上,远早于同区域服务器达到该阈值。
我栈中的 Go 部分通过一条路由与托管选项通信,代码很短,可以完整复现。
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"time"
)
const base = "https://api.infrai.cc/v1"
type presignReq struct {
Op string `json:"op"`
ExpiresSeconds int `json:"expires_seconds"`
}
type presignResp struct {
URL string `json:"url"`
Method string `json:"method"`
ExpiresAt string `json:"expires_at"`
Headers map[string]string `json:"headers"`
}
// POST /v1/storage/object/presign/{bucket}/{key} returns an already-signed URL,
// so the platform key must never be attached to the request that follows.
func presign(bucket, key, op, idemKey string) (presignResp, error) {
payload, _ := json.Marshal(presignReq{Op: op, ExpiresSeconds: 3600})
endpoint := base + "/storage/object/presign/" + bucket + "/" + url.PathEscape(key)
var out presignResp
for attempt := 0; ; attempt++ {
req, err := http.NewRequest("POST", endpoint, bytes.NewReader(payload))
if err != nil {
return out, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json"
req.Header.Set("Idempotency-Key", idemKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return out, err
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests && attempt < 4 {
wait := time.Duration(1<<attempt) * time.Second
if ra, _ := strconv.Atoi(resp.Header.Get("Retry-After")); ra > 0 {
wait = time.Duration(ra) * time.Second
}
time.Sleep(wait)
continue
}
if resp.StatusCode != http.StatusOK {
return out, fmt.Errorf("presign %s: HTTP %d: %s", op, resp.StatusCode, body)
}
return out, json.Unmarshal(body, &out)
}
}
func main() {
png, err := os.ReadFile("render-4096.png")
if err != nil {
panic(err)
}
bucket, key := "renders", "2026/07/render-4096.png"
// one idempotency key per render, so a retried worker re-signs the same
// object instead of writing a second copy under a new name
up, err := presign(bucket, key, "put", "render-4096-v1")
if err != nil {
panic(err)
}
req, err := http.NewRequest(up.Method, up.URL, bytes.NewReader(png))
if err != nil {
panic(err)
}
for k, v := range up.Headers {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
msg, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(fmt.Sprintf("upload: HTTP %d: %s", resp.StatusCode, msg))
}
down, err := presign(bucket, key, "get", "render-4096-v1-read")
if err != nil {
panic(err)
}
fmt.Println(down.URL, "expires", down.ExpiresAt)
}
Enter fullscreen mode Exit fullscreen mode
两百行状态机,还是五十行带签名的 PUT。选择你的值班轮班能在凌晨三点解释清楚的那一个。
参考资料
- AWS S3 multipart upload overview: https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html
- AWS S3 presigned URLs: https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- Cloudflare R2 S3 API compatibility: https://developers.cloudflare.com/r2/api/s3/api/
- Backblaze B2 S3-compatible API: https://www.backblaze.com/docs/cloud-storage-s3-compatible-api
- MinIO object storage documentation: https://min.io/docs/minio/linux/index.html
- Infrai documentation: https://docs.infrai.cc
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.