返回探索
指南VibeFix 编辑部更新于 2026年10月15日

别让用户上传的第一张图拖垮你的服务器:vibe 项目的文件存储与 CDN 实战

头像存在 ./uploads 里,上线第一周就翻车?本文讲预签名 URL 直传、公开/私有/图片变体三种存法、R2 零流出费选型,以及孤儿文件清理、环境隔离、存量迁移的全套实战,附上线 checklist。

文件存储与 CDN 实战指南封面:预签名直传、三层存法、零流出费选型

站内有一篇《备份之后的事:vibe 项目的灾备实战》,讲的是运维侧的冷数据——数据库备份往哪里存、丢了怎么恢复。本文讲的是另一面:产品侧的热文件——用户上传的头像、文章配图、附件、导出的报表,这些文件怎么存、怎么发、怎么不被账单吓到。备份是"丢了能找回来",文件存储是"天天在被访问",两套完全不同的打法,本文只讲后者。

第一张图的教训:文件千万别存服务器本地

几乎每个 vibe coding 项目都会走到这一步:用户说"我想换个头像",你让 AI 加了个上传功能,AI 很贴心地把文件写进了服务器的 ./uploads 目录。本地跑,一切正常。上线第一周,用户量涨了,然后——

  • 磁盘满了:Vercel、Railway 这类平台的实例磁盘是临时的,重启就丢;就算你用 VPS,几百个用户传几张原图,磁盘报警比用户投诉先到。
  • 多实例不同步:平台一扩容就是两个实例,用户这次上传到 A 实例,下次请求打到 B 实例,头像"丢了"。
  • 带宽和内存被吃光:文件经过你的 API 中转,一次 50MB 的上传就能占满一个 Node 进程的内存,顺带把 API 的超时时间也耗尽。

结论一句话:应用服务器是用来跑逻辑的,不是用来当硬盘的。用户文件的归宿是对象存储(Object Storage),分发靠 CDN。这套组合是行业标准答案,不是因为它"高级",而是因为它是唯一经得起"用户量涨 10 倍"考验的便宜方案。更关键的是,它把"存"和"算"解耦之后,你的应用服务器可以随意扩缩容、随意换平台,而用户文件纹丝不动——这才是云原生真正的红利。

选型:先看流出费用,再看生态

对象存储选型,独立开发者最容易踩的坑不是功能不够,而是账单结构没看懂。对象存储的费用有三块:存储费(存了多少)、请求费(读写了多少次)、流出费(egress,数据流出去多少)。对文件类产品,流出费往往是最大头——用户每看一次图,你就付一次钱。

对象存储选型对照:AWS S3、Cloudflare R2、Supabase Storage、Vercel Blob 在流出费用、协议兼容、图片变换、免费额度上的对比图 3:选型对照。流出费用是文件类产品的账单大头——R2 的"零流出费"是它最大的杀手锏;已在用 Supabase/Vercel 全家桶的,就近用配套存储,别为省几块钱多接一套 SDK。

给出一人项目的经验法则:

  • 图片/视频流量大、出海 → 优先 Cloudflare R2,零流出费在流量起来后是真省钱。
  • 已经在用 Supabase → 直接用 Supabase Storage,自带图片变换 API,一套鉴权走到底。
  • 已经在用 Vercel → Vercel Blob 最顺手,SDK 几行代码。
  • 已经在 AWS 体系里 → S3 + CloudFront,不用多说。

一个反直觉的观点:选型首先看"你已经在哪",其次看"流量结构",最后看"价格表"。多接一套 SDK、多管一套密钥的运维成本,对一人团队来说比每月多几美元贵得多。

讲个真实世界的账单故事你就明白流出费的杀伤力了:某独立开发者的壁纸站,图片存在 S3 上,前期每月账单十几美元很滋润。某天一张壁纸在社交媒体爆了,单日流出 2TB,当月账单直接干到 180 多美元——存储费只占零头,90% 是流出费。迁到 R2 之后,同样的流量,账单回到两位数。所以记住这个公式:文件类产品的成本 = 流出费主导,存储费只是零头。凡是"用户会反复看"的文件(头像、配图、商品图),选型时把流出费放在第一位;凡是"存了很少看"的文件(备份、归档),把存储费放第一位,甚至可以用低频访问存储层再省一半。

上传链路:预签名 URL 直传,文件不经过你的服务器

这是全文最关键的一张图,也是最值得背下来的架构:

预签名 URL 直传流程:浏览器先向 API 请求上传凭证,拿到预签名 URL 后直传对象存储,完成后回调确认,CDN 负责分发图 1:预签名直传五步。关键洞察:大文件流量只发生在"浏览器 ↔ 对象存储"之间,你的服务器只签发一张几百字节的 URL——带宽、内存、超时问题一次性消失。

流程拆开看,每一步都在解决一个具体问题:

  1. 浏览器向你的 API 请求上传凭证:这一步做鉴权(谁在传)和校验(传什么类型、多大)。不合法的请求在这里就被拦下,根本碰不到存储。
  2. API 返回预签名 URL:这是一张"限时通行证"——规定了能往哪个 key 写、几分钟内有效、文件类型必须是什么。密钥永远不出你的服务器。
  3. 浏览器直传对象存储:大文件流量不经过你的服务器,上传速度只取决于用户和对象存储之间的网络,还能做分片断点续传。
  4. 上传完成回调确认:浏览器告诉你的 API"传完了",你把文件记录落库(谁的、存在哪、什么类型)。只有落库的文件才是"你的文件",没回调的就是孤儿文件,靠生命周期规则定期清理。
  5. CDN 分发:读流量走 CDN,用户在全球访问都快,你的源站只被 CDN 回源打一次。

下面是可直接落地的实现(Next.js + AWS S3 兼容存储,R2/Supabase 同理,把 endpoint 换掉即可):

// app/api/uploads/presign/route.ts —— 签发预签名 URL(跑在你的服务器上)
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { randomUUID } from "crypto";

const s3 = new S3Client({
  region: "auto",
  endpoint: process.env.STORAGE_ENDPOINT, // R2 / S3 / 兼容存储按需替换
  credentials: {
    accessKeyId: process.env.STORAGE_ACCESS_KEY!,
    secretAccessKey: process.env.STORAGE_SECRET_KEY!,
  },
});

const ALLOWED = new Map([
  ["image/jpeg", "jpg"],
  ["image/png", "png"],
  ["image/webp", "webp"],
]);
const MAX_SIZE = 5 * 1024 * 1024; // 5MB,按产品调

export async function POST(req: Request) {
  const user = await requireUser(req); // 你的鉴权,先鉴权再签发
  const { contentType, size } = await req.json();

  // ① 校验:类型白名单 + 大小上限,拦在签发之前
  if (!ALLOWED.has(contentType)) {
    return Response.json({ error: "unsupported_type" }, { status: 400 });
  }
  if (typeof size !== "number" || size <= 0 || size > MAX_SIZE) {
    return Response.json({ error: "size_limit" }, { status: 400 });
  }

  // ② key 规范:用户隔离 + 日期分区 + 随机名,避免覆盖和枚举
  const key = `u/${user.id}/${new Date().toISOString().slice(0, 10)}/${randomUUID()}.${ALLOWED.get(contentType)}`;

  const url = await getSignedUrl(
    s3,
    new PutObjectCommand({
      Bucket: process.env.STORAGE_BUCKET!,
      Key: key,
      ContentType: contentType,
      ContentLength: size,
    }),
    { expiresIn: 300 } // 5 分钟有效,越短越安全
  );
  return Response.json({ uploadUrl: url, key });
}
// 前端:拿到 URL 后直传(fetch 一把梭,不需要 SDK)
async function uploadFile(file: File) {
  const presign = await fetch("/api/uploads/presign", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ contentType: file.type, size: file.size }),
  }).then((r) => r.json());
  if (presign.error) throw new Error(presign.error);

  await fetch(presign.uploadUrl, {
    method: "PUT",
    headers: { "Content-Type": file.type },
    body: file,
  });

  // 上传完成回调:落库,这一步决定了"这个文件归谁"
  await fetch("/api/uploads/confirm", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ key: presign.key, size: file.size, contentType: file.type }),
  });
  return presign.key;
}

对应的文件记录表(Prisma 示意):

model UploadedFile {
  id          String   @id @default(cuid())
  key         String   @unique              // 对象存储中的 key
  ownerId     String                         // 归属用户,用户隔离删除就靠它
  contentType String
  size        Int
  purpose     String   // avatar | article_image | attachment,按用途区分策略
  createdAt   DateTime @default(now())

  @@index([ownerId])
}

三种文件,三种存法

新手最容易犯的错误是"所有文件一个桶、一个策略"。实际上用户文件按可见性分成三种,存法完全不同:

存储三层架构:公开文件走公开桶加 CDN 长缓存,私有文件走私有桶加短时效签名 URL,图片变体在上传时或按需生成图 2:三种文件三种存法。公开文件追求"快和便宜"(CDN 长缓存),私有文件追求"每次访问都鉴权"(短时效签名 URL),图片变体追求"原图只存一份"。
  • 公开文件(头像、文章配图、商品图):放公开桶,URL 直接拼 CDN 域名。Cache-Control: public, max-age=31536000, immutable 拉满——文件名带哈希,内容变了文件名就变,缓存永远不会脏。
  • 私有文件(合同、发票、用户导出的数据):放私有桶,每次访问都由你的 API 重新签发短时效 URL(比如 60 秒)。URL 泄露了也很快过期,想分享就分享,想撤回就改 key。
  • 图片变体(缩略图、多尺寸):原图只存一份,变体要么在上传时生成(简单可控),要么按需变换(省存储费)。Supabase Storage 自带变换 API;自建可以用 imgproxy,一行 URL 参数 ?width=300&format=webp 就出图。

这里有个观点值得单独拎出来:永远不要存用户上传的原图直接对外 serving。一张手机拍的 12MB HEIC 原图,直接挂在列表页,移动端用户要为你的"省事"付流量费。缩略图不是优化,是义务。

CDN 缓存:读流量的省钱开关

CDN 的价值对一人项目是两句话:快(用户就近访问)和便宜(回源一次,服务一万次)。配置得当,源站的流出费能降一个数量级。三个实战要点:

  1. 文件名带内容哈希:avatar.a3f9c2.png 这种,内容变文件名变,就可以放心设 immutable 一年缓存。AI 生成上传代码时,记得让它把哈希拼进文件名,这是最容易漏的一步。
  2. HTML 和 API 不走 CDN 长缓存:页面和接口数据设 no-cache 或短 TTL,只缓存静态资源。见过有人把整个站点 max-age=1天,用户改了头像第二天才生效,客服工单爆炸。
  3. 防盗链:公开桶的图被别人盗链,你付流出费。Referer 白名单或签名 URL 二选一,至少开一个。R2/CloudFront 都支持按 Referer 限制。

安全:文件上传是攻击者的最爱

上传接口是全站最危险的接口之一,因为它在"往你的基础设施里写东西"。四条底线:

  • 校验魔数,不只看扩展名:evil.jpg 里藏 PHP/JS 太经典了。读文件头几个字节判断真实类型(上面的 ALLOWED 白名单是第一步,服务端再验一次魔数更稳)。
  • 大小限制两处设:签发时校验声明大小,存储桶再设 Policy 上限。只设一处等于没设。
  • key 必须服务端生成:永远不要让客户端决定存到哪个 key,否则 ../../ 路径穿越和覆盖别人的文件只是时间问题。上面的代码里 key 是服务端拼的,这是故意的。
  • 用户隔离删除:GDPR/注销账号时要删用户数据,u/{userId}/ 前缀让"删一个用户的所有文件"变成一个前缀删除操作。没有这层设计,注销功能会逼你全桶扫描。
  • 被刷流量的熔断:公开桶的 URL 一旦被恶意刷(比如有人写脚本循环请求你的原图),流出费会爆炸。手段:CDN 层开速率限制 + 异常流量告警;极端情况下把桶切回私有、全站走签名 URL,先止血再排查。记住,流出费是没有上限的,熔断开关要提前准备好,而不是出事了再去找文档。

病毒扫描要不要做?看产品:如果允许用户上传可执行内容且会被其他用户下载(比如附件分享站),接 ClamAV 或云厂商的恶意软件扫描是负责任的做法;如果只是头像和文章配图(图片格式白名单 + 魔数校验),扫描的投入产出比不高。一句话:扫描强度和"文件会被谁消费"成正比。

大文件:分片上传与断点续传

上面的直传方案对 5MB 以内的图片绰绰有余,但一旦产品涉及视频、设计源文件、数据包,单次 PUT 就会频繁失败——移动网络一切换,50MB 的上传就得重来。标准解法是分片上传(multipart upload):

  1. 浏览器把文件切成 5MB 一片(file.slice() 几行代码),每片独立请求一个预签名 URL,并行上传 3-4 片。
  2. 哪片失败重传哪片,页面刷新后从 localStorage 读已传分片列表继续——这就是"断点续传",本质是"分片状态可恢复"。
  3. 全部分片传完,调用一次"合并"接口(CompleteMultipartUpload),对象存储在服务端拼成完整文件。

一人团队的务实建议:先判断产品是否真需要。纯图片/文档类产品,单次 PUT + 5MB 限制足够,别为"万一用户传视频"提前造轮子。真需要时再上分片,S3/R2 的分片 API 都是标准协议,AI 生成的代码稍加 review 就能用。过早优化是 vibe coding 最常见的死法——但"知道标准解法长什么样"和"现在就实现"是两回事,前者必须有。

视频:别自己转码

视频是文件存储里的"重工业":用户传一个 4K MOV,你要转出 720p/1080p 多清晰度、切 HLS 切片、抽封面帧。自建转码集群对一人团队是灾难,正确的姿势是买转码服务:

  • Cloudflare Stream / Mux / api.video:上传一个 API,播放、转码、CDN 全包,按分钟计费。你的代码里视频只是一个 playbackId,连存储桶都不用碰。
  • 只存不播的场景(用户备份视频):直接当大文件存对象存储,不转码,省掉转码费。

判断标准很简单:视频是"内容"(要给别人看)就买转码服务,是"数据"(存着就行)就当文件存。把"内容"当"数据"存,用户看到的是转圈圈;把"数据"当"内容"转,账单会教你做人。

环境隔离:dev 和 prod 别共用一个桶

这是 AI 生成项目里最高频的低级事故:开发环境上传的测试图和生产环境混在一个桶里,某天"清理测试文件"删掉了用户的真实头像。铁律:

  • dev / staging / prod 三个桶(或三个前缀,桶更彻底),密钥也分开。dev 的密钥泄露了, blast radius 仅限测试数据。
  • CDN 域名也分开:cdn-dev.example.com 和 cdn.example.com,避免开发环境的脏缓存污染线上。
  • 环境变量 STORAGE_BUCKET 按环境注入,代码里永远不要 hardcode 桶名。

存量迁移:已经存本地了,怎么搬

如果你看到这篇文章时,./uploads 里已经躺了几千个文件,别慌,迁移是个周末能做完的活:

  1. 双写一周:新上传走对象存储,同时写一条迁移记录;读的时候优先读对象存储,miss 了回退读本地。
  2. 批量搬运:写个一次性脚本遍历本地目录,按新的 key 规范(用户隔离前缀)上传,落库 UploadedFile 记录。几千个文件就是几次 API 调用的事。
  3. 切读流量:确认对象存储里的文件数和数据库记录对上后,把读路径全切到 CDN。
  4. 删本地、下线双写:保留本地目录备份一周,无异常再删。

关键点:迁移期间读写双路径,但"数据库里的文件记录"是唯一的真相来源。只要记录是对的,文件存在哪只是个 key 的问题。

费用:两个定时任务,省掉 90% 的浪费

文件存储的浪费主要来自两处,都有标准解法:

  • 孤儿文件:用户点了上传但没点保存,文件躺在桶里没人认领。解法:UploadedFile 表里加 confirmedAt,上传回调才落库;定时任务每天删"超过 24 小时未确认"的文件。这是性价比最高的省钱手段。
  • 过期文件:临时导出、验证码图、7 天有效的分享。解法:对象存储的生命周期规则(Lifecycle),tmp/ 前缀 7 天自动删,不用写一行代码。

再补一个监控习惯:给存储桶的流出流量设告警。某天流量突然涨 10 倍,要么是上热门了(好事),要么是被盗链/被刷了(坏事),无论哪种你都想第一时间知道,而不是月底看账单时知道。

上线 Checklist

  • 上传走预签名直传,文件不经过应用服务器
  • 签发前做鉴权 + 类型白名单 + 大小上限
  • key 由服务端生成,带用户隔离前缀和日期分区
  • 公开/私有/图片变体三种文件分三种策略
  • 对外 serving 的是压缩后的变体,不是原图
  • 静态资源文件名带哈希 + immutable 长缓存
  • 开了防盗链(Referer 白名单或签名 URL)
  • 孤儿文件定时清理 + tmp/ 生命周期自动删
  • 流出流量告警已配置
  • 注销账号能按用户前缀删干净文件

最后说句大实话:文件存储这套东西,AI 一次就能给你生成"能跑"的版本,但"能扛住用户量"的版本差就差在上面的 checklist 里。vibe coding 不是不写代码,而是把 code review 的精力花在刀刃上——上传链路,就是这样的刀刃之一。

附:30 秒选型决策树

如果你看到这里还是不知道选哪个,按这个顺序问自己三个问题,答案会自动浮现:

  1. 你已经在用什么后端?Supabase → Supabase Storage;Vercel → Vercel Blob;AWS → S3。别为了"理论上更便宜"跳出舒适区,一致性本身就是成本。
  2. 文件会被反复看吗?会(头像/配图/商品图)→ 把流出费放第一位,R2 的零流出费值得单独接;不会(备份/归档)→ 哪个便宜用哪个,甚至用低频层。
  3. 有视频要"播"吗?有 → 别折腾对象存储 + 自建转码,直接买 Mux/Cloudflare Stream;没有 → 对象存储一把梭。

三问下来,90% 的一人项目都会收敛到唯一答案。剩下的 10% 才是需要认真做 POC 的——而你能把 90% 的决策压缩到 30 秒,靠的就是理解了本文的几张图和几个公式。

阅读 0评论 0

评论 (0)

ME
0/1000
评论加载中...
浏览项目广场发布你的项目

相关文章

数据生命周期状态机图:正常使用进入回收站开始倒计时,可一键恢复或到期彻底删除
指南
别让删除成为单行道:vibe 项目的软删除与回收站实战

备份是最后的防线,回收站是第一道。这篇实战指南讲应用层的删除语义:三种删除语义决策表、deleted_at 数据模型、partial 唯一索引、Prisma 查询层防线、回收站保留期分级、GDPR 与个保法删除权对账,以及 8 项上线 checklist。

PostgreSQL数据库后端工程
管理后台系统架构图:前台应用、Admin 面板、鉴权网关、审计日志库与生产库的隔离关系
指南
上线后别再手敲 SQL:vibe 项目的 Admin 管理后台实战

没有后台的上线项目,第一次运维请求就会把你逼回 ssh 手敲 SQL。这篇指南从“上线第 1 天”的功能 MVP 清单讲起:用户/订单/内容/配置四类实体的后台功能矩阵,super_admin/operator/support 三档权限与三层隔离,高危动作的人审二次确认,追加写不可篡改的审计日志,impersonate 的限时 token 安全链路,以及 Retool/自建低代码选型对照和 8 项上线 checklist。

PostgreSQL部署上线后端工程
Passkey 注册与登录时序图:浏览器、服务端与 Authenticator 之间的 12 步 WebAuthn 最小闭环,含 challenge 生成与服务端校验两个安全检查点
指南
告别短信验证码账单:vibe 项目的 Passkey(WebAuthn)无密码登录实战

短信验证码按条烧钱、还防不住劫持。Passkey(WebAuthn)用设备端私钥签名替代短信:7 步跑通最小闭环,5 条安全红线一次讲透,外加多设备管理、丢失恢复链路与 8 项上线 checklist——把登录成本和整类攻击一起归零。

应用安全后端工程独立开发