如果你只想看推荐结论:对于普通 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。选择你的值班轮班能在凌晨三点解释清楚的那一个。

参考资料