文件存储是 SaaS 产品中用户生成内容 (UGC) 的归宿——头像、文档、图片、视频、导出文件。不同于数据库存结构化数据,文件存储解决的是:
文件作为「对象」存入「桶 (Bucket)」,每个对象有唯一 Key。无目录层级限制,无限扩展。典型:S3、R2、MinIO。
EBS、EFS、NFS——挂载到服务器,像本地磁盘一样用。适合临时文件、日志,不适合 UGC(无 CDN、无签名 URL、扩展性差)。
| 类型 | 代表 | 适用场景 | CDN | 签名 URL |
|---|---|---|---|---|
| 对象存储 | S3, R2, MinIO | UGC、静态资源、备份 | ✅ | ✅ |
| 块存储 | EBS, 云硬盘 | 数据库数据目录、临时文件 | ❌ | ❌ |
| 文件存储 | EFS, NFS | 共享配置、日志聚合 | ❌ | ❌ |
| CDN 边缘存储 | R2, KV, D1 | 全球低延迟读取 | ✅ 内置 | ✅ |
| 服务商 | 存储费/GB/月 | 出口费 | 请求费 | CDN | S3 兼容 | 自托管 |
|---|---|---|---|---|---|---|
| AWS S3 | $0.023 | $0.09/GB | $0.0005/1K | CloudFront 另计 | — | ❌ |
| Cloudflare R2 | $0.015 | $0 (零出口费) | $0.36/M | 内置 (Free) | ✅ | ❌ |
| MinIO | 自备硬件 | 自备带宽 | 无 | 需自建 | ✅ | ✅ |
| Google GCS | $0.020 | $0.12/GB | $0.005/10K | Cloud CDN 另计 | ❌ | ❌ |
| Azure Blob | $0.018 | $0.087/GB | $0.005/10K | Azure CDN 另计 | ❌ | ❌ |
| Backblaze B2 | $0.006 | $0.01/GB | Free ≤2500/天 | Cloudflare 联盟 | ✅ | ❌ |
| Wasabi | $0.0069 | $0 | $0 | 需自建 | ✅ | ❌ |
| Tencent COS | ¥0.118 | ¥0.50/GB | ¥0.01/万 | CDN 回源免费 | ❌ | ❌ |
| Alibaba OSS | ¥0.120 | ¥0.50/GB | ¥0.01/万 | CDN 回源免费 | ❌ | ❌ |
R2 > S3+CloudFront > Backblaze B2
零出口费 + 内置 CDN 是杀手锏。S3 生态最全但出口费恐怖。
腾讯 COS / 阿里 OSS > 七牛 > 又拍云
国内 CDN 回源免费,备案域名加速。R2 在国内访问慢。
MinIO 自托管 > S3 (GovCloud) > R2
医疗、金融数据不出机房。MinIO 完全掌控。
Backblaze B2 > R2 > Wasabi
B2 $0.006/GB + Cloudflare 联盟免费 CDN = 极低起步。
S3 是对象存储的事实标准,几乎所有竞品都兼容 S3 API。理解 S3 就是理解整个对象存储生态。
| 概念 | 说明 | 类比 |
|---|---|---|
| Bucket | 顶层命名空间,全局唯一名称 | 磁盘分区 |
| Object | 实际存储的文件 + 元数据 | 文件 |
| Key | 对象在桶内的唯一标识 (含前缀) | 文件路径 |
| Region | 桶所在的 AWS 区域 | 数据中心 |
| Storage Class | 存储层级:Standard / IA / Glacier | 热/温/冷盘 |
| Versioning | 同一 Key 多版本共存 | Git 历史 |
生命周期规则自动迁移,零代码:
# S3 Lifecycle Configuration
{
"Rules": [{
"ID": "auto-tier",
"Status": "Enabled",
"Transitions": [
{ "Days": 30, "StorageClass": "STANDARD_IA" },
{ "Days": 90, "StorageClass": "GLACIER_INSTANT" }
],
"Expiration": { "Days": 365 }
}]
}
R2 是 Cloudflare 推出的 S3 兼容对象存储,杀手锏 = 零出口费。基于 Cloudflare 全球网络,内置 CDN。
| 场景 | S3 + CloudFront | R2 | 节省 |
|---|---|---|---|
| 1TB 存储 + 500GB 下载/月 | $23.5 + $45 = $68.5 | $15 + $0 = $15 | 78% |
| 1TB 存储 + 5TB 下载/月 | $23.5 + $450 = $473.5 | $15 + $0 = $15 | 97% |
| 100GB 存储 + 1TB 下载/月 | $2.35 + $90 = $92.35 | $1.5 + $0 = $1.5 | 98% |
R2 实现 S3 API 子集,可用 AWS SDK 直接访问:
// 用 AWS SDK v3 连接 R2
import { S3Client } from '@aws-sdk/client-s3';
const r2 = new S3Client({
region: 'auto',
endpoint: `https://<ACCOUNT_ID>.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
},
});
// 之后所有 S3 操作照常使用
// PutObject, GetObject, DeleteObject, ListObjectsV2 ...
// Cloudflare Worker: 图片 Resize On-the-fly
export default {
async fetch(request, env) {
const url = new URL(request.url);
const key = url.pathname.slice(1); // /avatars/123.jpg → avatars/123.jpg
const size = url.searchParams.get('w') || '200';
// 从 R2 取原图
const obj = await env.MY_BUCKET.get(key);
if (!obj) return new Response('Not Found', { status: 404 });
// 用 Cloudflare Image Resizing (需开通)
const imageRequest = new Request(
`https://cdn.example.com/cdn-cgi/image/width=${size}/${key}`
);
return fetch(imageRequest);
}
};
MinIO 是用 Go 写的高性能自托管对象存储,100% S3 API 兼容。单二进制文件,启动即用。
# docker-compose.yml — MinIO 单节点 + Console
version: "3.8"
services:
minio:
image: minio/minio:latest
ports:
- "9000:9000" # S3 API
- "9001:9001" # Console
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
command: server /data --console-address ":9001"
volumes:
- minio-data:/data
volumes:
minio-data:
MinIO 用纠删码 (Erasure Coding) 而非副本实现数据冗余。4 块盘可以丢 2 块,16 块盘可以丢 8 块——比 3 副本节省 50% 存储空间。
# 分布式 MinIO — 4 节点 x 4 盘 = 16 盘纠删码
MINIO_VOLUMES="https://node{1...4}.example.com/mnt/disk{1...4}"
# 数据条带: 8 数据 + 8 校验 = 可丢 8 盘
在标准硬件上,MinIO 可达 325 GiB/s GET / 165 GiB/s PUT(32 节点集群)。单节点 NVMe 可轻松 10Gbps+。对于 SaaS 应用,单节点 MinIO 足够支撑 10 万+ QPS。
这是文件存储设计中最关键的架构决策。
| 维度 | 客户端直传 | 服务端中转 |
|---|---|---|
| 文件流 | Client → S3 | Client → Server → S3 |
| 服务器负载 | 零 | 双倍带宽 |
| 权限控制 | Pre-signed URL / STS | 服务端鉴权后转发 |
| 文件大小限制 | 5TB (S3) | 受服务器内存/超时限制 |
| 实现复杂度 | 中等(需前端 SDK) | 简单 |
| 安全审查 | 需预校验(类型/大小) | 服务端完全控制 |
// 服务端:生成预签名上传 URL
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({ region: 'auto', endpoint: '...' });
async function getUploadUrl(userId, fileName, contentType) {
// 1. 校验用户权限
const allowed = await canUpload(userId);
if (!allowed) throw new Error('Forbidden');
// 2. 生成安全 Key(防止路径遍历)
const ext = fileName.split('.').pop();
const key = `uploads/${userId}/${crypto.randomUUID()}.${ext}`;
// 3. 生成预签名 URL (5 分钟有效)
const url = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: process.env.BUCKET_NAME,
Key: key,
ContentType: contentType,
// 限制大小:Content-Length 范围 (S3 不直接支持,需客户端配合)
}),
{ expiresIn: 300 }
);
return { url, key };
}
// 前端:使用预签名 URL 直传
async function uploadFile(file) {
// 1. 获取预签名 URL
const { url, key } = await fetch('/api/upload-url', {
method: 'POST',
body: JSON.stringify({
fileName: file.name,
contentType: file.type,
}),
}).then(r => r.json());
// 2. 直传 S3/R2
const res = await fetch(url, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
});
if (!res.ok) throw new Error('Upload failed');
// 3. 通知服务端上传完成
await fetch('/api/upload-confirm', {
method: 'POST',
body: JSON.stringify({ key }),
});
}
Content-Length 校验 + 服务端事后校验S3/R2 单次 PUT 限制 5GB。超过 5GB 必须分片上传 (Multipart Upload)。实际上,超过 100MB 就应该用分片上传。
// 前端大文件分片上传(S3 Multipart)
import { Upload } from '@aws-sdk/lib-storage';
async function uploadLargeFile(file) {
const upload = new Upload({
client: s3Client,
params: {
Bucket: 'my-bucket',
Key: `uploads/${crypto.randomUUID()}/${file.name}`,
Body: file,
ContentType: file.type,
},
// 分片配置
partSize: 10 * 1024 * 1024, // 10MB per part
queueSize: 4, // 并行 4 个分片
leavePartsOnError: true, // 失败保留已传分片(可断点续传)
});
// 进度回调
upload.on('httpUploadProgress', (progress) => {
const pct = ((progress.loaded / progress.total) * 100).toFixed(1);
console.log(`Upload: ${pct}%`);
});
await upload.done();
}
// 断点续传核心逻辑
const uploadState = {
uploadId: null, // S3 返回的 Upload ID
completedParts: [], // 已完成的 Part 列表 [{PartNumber, ETag}]
file: null,
key: null,
};
// 恢复上传
async function resumeUpload() {
if (!uploadState.uploadId) return;
// 查询已上传的分片
const { Parts } = await s3.send(new ListPartsCommand({
Bucket: bucket,
Key: uploadState.key,
UploadId: uploadState.uploadId,
}));
// 只上传缺失的分片
const completedNums = new Set(Parts.map(p => p.PartNumber));
const totalParts = Math.ceil(uploadState.file.size / PART_SIZE);
for (let i = 1; i <= totalParts; i++) {
if (completedNums.has(i)) continue;
// 上传该分片...
}
}
SaaS 产品中图片是最常见的 UGC 类型。直接存原图会浪费带宽和加载时间——一个 5MB 的手机照片,缩略图只需 20KB。
| 方案 | 实现 | 实时 | 成本 | 灵活性 |
|---|---|---|---|---|
| Cloudflare Image Resizing | CDN 边缘处理 | ✅ | $0.50/10K | 高(参数化) |
| imgproxy | 自托管 Go 服务 | ✅ | 服务器成本 | 极高 |
| Sharp (Node.js) | 应用层处理 | ✅ | 服务器成本 | 极高 |
| 上传时预处理 | Lambda/Worker 触发 | ❌ 异步 | 按调用量 | 固定尺寸 |
| SaaS: Cloudinary | 托管图片 CDN | ✅ | $89+/月 | 极高 |
imgproxy 是最快的开源图片处理服务,用 libvips C 库,比 Sharp 快 5-10 倍。支持签名 URL 安全访问。
# imgproxy Docker 部署
docker run -d \
--name imgproxy \
-p 8080:8080 \
-e IMGPROXY_KEY=$(openssl rand -hex 16) \
-e IMGPROXY_SALT=$(openssl rand -hex 16) \
-e IMGPROXY_ENABLE_URL_SIGNATURE=true \
-e IMGPROXY_MAX_SRC_RESOLUTION=20 \
-e IMGPROXY_USE_ETAG=true \
-e IMGPROXY_ENABLE_WEBP_DETECTION=true \
darthsim/imgproxy
// imgproxy URL 签名 + 生成缩略图 URL
import crypto from 'crypto';
function generateImageUrl(s3Url, width, height) {
const path = `/rs:fit:${width}:${height}/g:sm/${Buffer.from(s3Url).toString('base64')}`;
const signature = crypto
.createHmac('sha256', IMGPROXY_KEY + IMGPROXY_SALT)
.update(path)
.digest('base64url');
return `https://img.example.com/${signature}${path}`;
}
// 使用:
// https://img.example.com/{sig}/rs:fit:200:200/g:sm/{base64_s3_url}
// 自动返回 WebP 格式(浏览器支持时)
对象存储解决「存」,CDN 解决「快」。全球用户访问 S3 美东区域 = 200-400ms 延迟,经过 CDN = 10-50ms。
| CDN | 流量费 | 与存储集成 | 特色 |
|---|---|---|---|
| Cloudflare | Free(R2)/ $0.05-0.12/GB | R2 无缝 | 全球节点最多、Workers 边缘计算 |
| AWS CloudFront | $0.02-0.14/GB | S3 无缝 | Lambda@Edge、S3 Transfer |
| bunny.net | $0.01/GB | 需自定义源 | 极低价、边缘存储 |
| 国内:腾讯/阿里 CDN | ¥0.20/GB | COS/OSS 回源免费 | 国内节点多、ICP 备案 |
// R2 自定义域名 + Cache-Control
// 上传时设置 HTTP 头
await s3.send(new PutObjectCommand({
Bucket: 'my-bucket',
Key: key,
Body: fileBuffer,
ContentType: 'image/webp',
CacheControl: 'public, max-age=31536000, immutable', // 1 年,内容不可变
}));
// 用户头像等可变内容
CacheControl: 'public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800'
// 浏览器缓存 1 小时,CDN 缓存 1 天,过期后 7 天内可返回旧内容同时后台刷新
| 参数 | 作用 | 场景 |
|---|---|---|
max-age=31536000 | 缓存 1 年 | 不可变资源(hash 文件名) |
immutable | 告诉浏览器不重新验证 | hash 文件名 |
s-maxage=86400 | CDN 缓存 1 天(覆盖 max-age) | CDN 独立 TTL |
stale-while-revalidate | 过期后返回旧内容 + 后台刷新 | 允许短时间过期的场景 |
no-cache | 每次都验证(非不缓存) | 频繁更新的资源 |
private | 不允许 CDN 缓存 | 签名 URL 的私有内容 |
Key 包含 hash:/avatars/{hash}.webp
更新 = 新 Key,旧 Key 永不过期。缓存 1 年。
零缓存失效问题。
Key 含版本号:/avatars/123/v2.jpg
更新 = 新版本路径,数据库记录当前版本。
简单有效,略增存储。
预签名 URL = 把临时凭证嵌入 URL。任何拿到 URL 的人都能在有效期内操作。
// 生成下载签名 URL (只读,1 小时有效)
import { GetObjectCommand } from '@aws-sdk/client-s3';
async function getDownloadUrl(key) {
return await getSignedUrl(
s3,
new GetObjectCommand({
Bucket: 'my-bucket',
Key: key,
ResponseContentType: 'application/octet-stream',
ResponseContentDisposition: 'attachment; filename="download"',
}),
{ expiresIn: 3600 }
);
}
// S3 Bucket CORS 配置 — 允许前端直传
[
{
"AllowedHeaders": ["Content-Type", "x-amz-*"],
"AllowedMethods": ["PUT", "GET"],
"AllowedOrigins": ["https://app.example.com"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
AllowedOrigins: ["*"] + 签名 URL = 任何网站都能用你的签名 URL 上传/下载["https://app.example.com"]| 加密方式 | 说明 | 性能影响 | 密钥管理 |
|---|---|---|---|
| SSE-S3 | S3 自动加密,AWS 管密钥 | 零 | 零配置 |
| SSE-KMS | 用 AWS KMS 密钥加密 | 极小 | KMS 策略控制 |
| SSE-C | 客户自管密钥,每次请求传密钥 | 极小 | 完全自控 |
| 客户端加密 | 上传前在客户端加密 | 取决于实现 | 完全自控 |
推荐:默认 SSE-S3(零配置零成本),合规需求用 SSE-KMS(审计轨迹),极端安全用客户端加密(密钥永远不出应用)。
假设:10 万用户,每人上传 20 个文件(平均 2MB),30% 日活,每天查看 50 个文件。
| 项目 | S3 + CloudFront | R2 | MinIO (自托管) |
|---|---|---|---|
| 存储 (4TB) | $92/月 | $60/月 | $0 (含在服务器) |
| 出口 (6TB/月) | $120/月 | $0 | 自备带宽 |
| 请求 (5M PUT + 50M GET) | $27.5/月 | $18/月 | $0 |
| CDN | $50/月 | Free (内置) | 需自建 |
| 总计 | $289.5/月 | $78/月 | 服务器 $80-200/月 |
你的 SaaS 文件存储选型 │ ├─ 数据必须留在自己机房? │ ├─ 是 → MinIO (自托管, S3 兼容, 纠删码) │ └─ 否 ↓ │ ├─ 主要用户在中国大陆? │ ├─ 是 → 腾讯 COS / 阿里 OSS + 国内 CDN │ └─ 否 ↓ │ ├─ 出口流量大 (用户频繁查看/下载文件)? │ ├─ 是 → Cloudflare R2 (零出口费 + 内置 CDN) │ └─ 否 ↓ │ ├─ 已深度绑定 AWS 生态? │ ├─ 是 → S3 + CloudFront (无缝集成, 功能最全) │ └─ 否 ↓ │ ├─ 预算极低 / 早期 MVP? │ ├─ 是 → Backblaze B2 + Cloudflare CDN ($0.006/GB) │ └─ 否 → R2 (性价比最优解) │ 💡 通用推荐:R2 是 2024-2026 最优默认选择 零出口费 + S3 兼容 + 内置 CDN = 三杀
# main.py — FastAPI 文件上传服务
from fastapi import FastAPI, HTTPException, Depends
from pydantic import BaseModel
from botocore.config import Config
import boto3, uuid, hashlib
app = FastAPI()
# R2 客户端 (S3 兼容)
s3 = boto3.client(
's3',
endpoint_url=f'https://<ACCOUNT_ID>.r2.cloudflarestorage.com',
aws_access_key_id='...',
aws_secret_access_key='...',
config=Config(signature_version='s3v4'),
)
BUCKET = 'my-saas-uploads'
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
ALLOWED_TYPES = {'image/jpeg', 'image/png', 'image/webp', 'application/pdf'}
class UploadRequest(BaseModel):
fileName: str
contentType: str
fileSize: int
@app.post('/api/upload-url')
async def get_upload_url(req: UploadRequest, user=Depends(get_current_user)):
# 1. 校验
if req.contentType not in ALLOWED_TYPES:
raise HTTPException(400, f'Unsupported type: {req.contentType}')
if req.fileSize > MAX_FILE_SIZE:
raise HTTPException(400, f'File too large (max {MAX_FILE_SIZE//1024//1024}MB)')
# 2. 生成安全 Key
ext = req.fileName.rsplit('.', 1)[-1].lower()
key = f'uploads/{user.id}/{uuid.uuid4()}.{ext}'
# 3. 生成预签名 URL
url = s3.generate_presigned_url(
'put_object',
Params={
'Bucket': BUCKET,
'Key': key,
'ContentType': req.contentType,
},
ExpiresIn=300, # 5 分钟
)
return {'url': url, 'key': key}
@app.post('/api/upload-confirm')
async def confirm_upload(key: str, user=Depends(get_current_user)):
# 校验文件确实存在且属于该用户
try:
obj = s3.head_object(Bucket=BUCKET, Key=key)
except:
raise HTTPException(404, 'File not found in storage')
# 校验 Key 归属(防止用户确认别人的文件)
if not key.startswith(f'uploads/{user.id}/'):
raise HTTPException(403, 'Not your file')
# 入库
file_record = await db.files.create(
key=key,
size=obj['ContentLength'],
content_type=obj['ContentType'],
user_id=user.id,
)
# 返回下载 URL
download_url = s3.generate_presigned_url(
'get_object',
Params={'Bucket': BUCKET, 'Key': key},
ExpiresIn=3600,
)
return {'id': file_record.id, 'downloadUrl': download_url}
# dedup.py — 上传前文件去重
import hashlib
async def check_duplicate(file_content: bytes, user_id: str) -> dict | None:
# 计算文件 SHA-256
file_hash = hashlib.sha256(file_content).hexdigest()
# 查数据库是否已有相同 hash
existing = await db.files.find_unique(where={'hash': file_hash})
if existing:
# 已存在:增加引用计数,不重复存储
await db.file_refs.create(
data={'file_id': existing.id, 'user_id': user_id}
)
return {'id': existing.id, 'duplicate': True}
return None # 不重复,继续上传
小型文件(< 1MB)如头像缩略图可以直接存数据库 BLOB。PostgreSQL 的 BYTEA 性能不错,但有上限(1GB)且不能 CDN 加速。仅适合低频访问的元数据附件。
NFT/链上应用用 IPFS 存元数据,Arweave 做永久存储。延迟高、检索难、成本不可预测。不推荐传统 SaaS 使用。
Supabase 封装了 S3 + RLS 策略 + CDN,开箱即用。底层是 S3。适合快速 MVP,但成本比直接用 R2 高 2-3 倍。
// Supabase Storage — 最简上传
const { data, error } = await supabase.storage
.from('avatars')
.upload(`user-${userId}/avatar.jpg`, file, {
cacheControl: '3600',
upsert: true,
});
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 视频流 (HLS/DASH) | S3 + CloudFront + MediaConvert | 转码流式分发 |
| 文档协作 | R2 + CRDT (Yjs) + WebSocket | 实时同步 |
| 数据备份 | S3 Glacier / B2 | 极低成本归档 |
| 日志归档 | S3 + Athena / R2 + Worker 分析 | SQL 查询冷数据 |
| 邮件附件 | R2 + Worker 代理 | 签名 URL 控制有效期 |
| 用户导出 (CSV/Excel) | R2 + 异步生成 + 签名下载 | 大文件异步生成,签名 URL 限时下载 |
在 Agent 架构研究 中,多个 Agent 项目涉及文件存储的独特设计:
几乎所有 Agent 都需要文件操作能力:
build-memory 分析揭示了一个有趣模式:Agent 的「记忆」本质上是特殊结构的文件存储:
启示:Agent 记忆系统 = 文件存储 + 向量索引 + 压缩/过期策略,本质上和 SaaS 文件存储是同一问题域。
启示:SaaS 文件存储安全可以借鉴 Agent 的多层防护——签名 URL 是第一层,服务端校验是第二层,沙箱隔离是第三层。
📁 文件存储 — SaaS 全栈知识库 | 返回目录 | Agent 架构研究