别让用户上传的第一张图拖垮你的服务器:vibe 项目的文件存储与 CDN 实战
头像存在 ./uploads 里,上线第一周就翻车?本文讲预签名 URL 直传、公开/私有/图片变体三种存法、R2 零流出费选型,以及孤儿文件清理、环境隔离、存量迁移的全套实战,附上线 checklist。

站内有一篇《备份之后的事:vibe 项目的灾备实战》,讲的是运维侧的冷数据——数据库备份往哪里存、丢了怎么恢复。本文讲的是另一面:产品侧的热文件——用户上传的头像、文章配图、附件、导出的报表,这些文件怎么存、怎么发、怎么不被账单吓到。备份是"丢了能找回来",文件存储是"天天在被访问",两套完全不同的打法,本文只讲后者。
第一张图的教训:文件千万别存服务器本地
几乎每个 vibe coding 项目都会走到这一步:用户说"我想换个头像",你让 AI 加了个上传功能,AI 很贴心地把文件写进了服务器的 ./uploads 目录。本地跑,一切正常。上线第一周,用户量涨了,然后——
- 磁盘满了:Vercel、Railway 这类平台的实例磁盘是临时的,重启就丢;就算你用 VPS,几百个用户传几张原图,磁盘报警比用户投诉先到。
- 多实例不同步:平台一扩容就是两个实例,用户这次上传到 A 实例,下次请求打到 B 实例,头像"丢了"。
- 带宽和内存被吃光:文件经过你的 API 中转,一次 50MB 的上传就能占满一个 Node 进程的内存,顺带把 API 的超时时间也耗尽。
结论一句话:应用服务器是用来跑逻辑的,不是用来当硬盘的。用户文件的归宿是对象存储(Object Storage),分发靠 CDN。这套组合是行业标准答案,不是因为它"高级",而是因为它是唯一经得起"用户量涨 10 倍"考验的便宜方案。更关键的是,它把"存"和"算"解耦之后,你的应用服务器可以随意扩缩容、随意换平台,而用户文件纹丝不动——这才是云原生真正的红利。
选型:先看流出费用,再看生态
对象存储选型,独立开发者最容易踩的坑不是功能不够,而是账单结构没看懂。对象存储的费用有三块:存储费(存了多少)、请求费(读写了多少次)、流出费(egress,数据流出去多少)。对文件类产品,流出费往往是最大头——用户每看一次图,你就付一次钱。
图 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 直传,文件不经过你的服务器
这是全文最关键的一张图,也是最值得背下来的架构:
图 1:预签名直传五步。关键洞察:大文件流量只发生在"浏览器 ↔ 对象存储"之间,你的服务器只签发一张几百字节的 URL——带宽、内存、超时问题一次性消失。
流程拆开看,每一步都在解决一个具体问题:
- 浏览器向你的 API 请求上传凭证:这一步做鉴权(谁在传)和校验(传什么类型、多大)。不合法的请求在这里就被拦下,根本碰不到存储。
- API 返回预签名 URL:这是一张"限时通行证"——规定了能往哪个 key 写、几分钟内有效、文件类型必须是什么。密钥永远不出你的服务器。
- 浏览器直传对象存储:大文件流量不经过你的服务器,上传速度只取决于用户和对象存储之间的网络,还能做分片断点续传。
- 上传完成回调确认:浏览器告诉你的 API"传完了",你把文件记录落库(谁的、存在哪、什么类型)。只有落库的文件才是"你的文件",没回调的就是孤儿文件,靠生命周期规则定期清理。
- 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])
}
三种文件,三种存法
新手最容易犯的错误是"所有文件一个桶、一个策略"。实际上用户文件按可见性分成三种,存法完全不同:
图 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 的价值对一人项目是两句话:快(用户就近访问)和便宜(回源一次,服务一万次)。配置得当,源站的流出费能降一个数量级。三个实战要点:
- 文件名带内容哈希:
avatar.a3f9c2.png这种,内容变文件名变,就可以放心设immutable一年缓存。AI 生成上传代码时,记得让它把哈希拼进文件名,这是最容易漏的一步。 - HTML 和 API 不走 CDN 长缓存:页面和接口数据设
no-cache或短 TTL,只缓存静态资源。见过有人把整个站点max-age=1天,用户改了头像第二天才生效,客服工单爆炸。 - 防盗链:公开桶的图被别人盗链,你付流出费。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):
- 浏览器把文件切成 5MB 一片(
file.slice()几行代码),每片独立请求一个预签名 URL,并行上传 3-4 片。 - 哪片失败重传哪片,页面刷新后从 localStorage 读已传分片列表继续——这就是"断点续传",本质是"分片状态可恢复"。
- 全部分片传完,调用一次"合并"接口(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 里已经躺了几千个文件,别慌,迁移是个周末能做完的活:
- 双写一周:新上传走对象存储,同时写一条迁移记录;读的时候优先读对象存储,miss 了回退读本地。
- 批量搬运:写个一次性脚本遍历本地目录,按新的 key 规范(用户隔离前缀)上传,落库
UploadedFile记录。几千个文件就是几次 API 调用的事。 - 切读流量:确认对象存储里的文件数和数据库记录对上后,把读路径全切到 CDN。
- 删本地、下线双写:保留本地目录备份一周,无异常再删。
关键点:迁移期间读写双路径,但"数据库里的文件记录"是唯一的真相来源。只要记录是对的,文件存在哪只是个 key 的问题。
费用:两个定时任务,省掉 90% 的浪费
文件存储的浪费主要来自两处,都有标准解法:
- 孤儿文件:用户点了上传但没点保存,文件躺在桶里没人认领。解法:
UploadedFile表里加confirmedAt,上传回调才落库;定时任务每天删"超过 24 小时未确认"的文件。这是性价比最高的省钱手段。 - 过期文件:临时导出、验证码图、7 天有效的分享。解法:对象存储的生命周期规则(Lifecycle),
tmp/前缀 7 天自动删,不用写一行代码。
再补一个监控习惯:给存储桶的流出流量设告警。某天流量突然涨 10 倍,要么是上热门了(好事),要么是被盗链/被刷了(坏事),无论哪种你都想第一时间知道,而不是月底看账单时知道。
上线 Checklist
- 上传走预签名直传,文件不经过应用服务器
- 签发前做鉴权 + 类型白名单 + 大小上限
- key 由服务端生成,带用户隔离前缀和日期分区
- 公开/私有/图片变体三种文件分三种策略
- 对外 serving 的是压缩后的变体,不是原图
- 静态资源文件名带哈希 +
immutable长缓存 - 开了防盗链(Referer 白名单或签名 URL)
- 孤儿文件定时清理 +
tmp/生命周期自动删 - 流出流量告警已配置
- 注销账号能按用户前缀删干净文件
最后说句大实话:文件存储这套东西,AI 一次就能给你生成"能跑"的版本,但"能扛住用户量"的版本差就差在上面的 checklist 里。vibe coding 不是不写代码,而是把 code review 的精力花在刀刃上——上传链路,就是这样的刀刃之一。
附:30 秒选型决策树
如果你看到这里还是不知道选哪个,按这个顺序问自己三个问题,答案会自动浮现:
- 你已经在用什么后端?Supabase → Supabase Storage;Vercel → Vercel Blob;AWS → S3。别为了"理论上更便宜"跳出舒适区,一致性本身就是成本。
- 文件会被反复看吗?会(头像/配图/商品图)→ 把流出费放第一位,R2 的零流出费值得单独接;不会(备份/归档)→ 哪个便宜用哪个,甚至用低频层。
- 有视频要"播"吗?有 → 别折腾对象存储 + 自建转码,直接买 Mux/Cloudflare Stream;没有 → 对象存储一把梭。
三问下来,90% 的一人项目都会收敛到唯一答案。剩下的 10% 才是需要认真做 POC 的——而你能把 90% 的决策压缩到 30 秒,靠的就是理解了本文的几张图和几个公式。
评论 (0)
相关文章

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

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

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