Supabase Storage 实战:文件上传、CDN 与访问控制
上周有个读者问我:“用户上传头像这个功能,用 S3 还是 Cloudflare R2 好?”
我愣了一下。说实话,这个问题我在两个项目里都踩过坑。S3 配置权限策略时脑子都快炸了,R2 虽然便宜但得自己搞一套认证系统。后来我把项目迁到了 Supabase Storage——不是因为它是”银弹”,而是如果你已经在用 Supabase 的 Auth 和数据库,Storage 这个组合拳打起来是真的顺手。
这篇文章我会把 Supabase Storage 的核心机制、三种访问控制模式、大文件上传的坑、CDN 优化技巧,还有跟 R2/S3 的成本对比,全部摊开来讲。代码都是能直接跑的。
一、Supabase Storage 核心架构
先说清楚一件事:Supabase Storage 底层是 AWS S3。
但它给你包了一层很薄的封装——薄到你可以用 JavaScript SDK 直接操作,不用管 AWS 那套复杂的凭证体系和 IAM 策略。
和 Auth 自动绑定
这是我最喜欢的一点。你在 Supabase 里创建一个 bucket,然后直接用 supabase.auth.getUser() 拿到的 JWT token 控制谁能上传、谁能下载。不需要单独搭一套权限系统。
// 上传时自动带上用户身份
const { data, error } = await supabase.storage
.from('avatars')
.upload('user-123/profile.jpg', file)
底层它会检查你的 RLS(Row Level Security)策略——后面会详细讲怎么配置。
全球 CDN 自动加持
上传的文件会自动走 Cloudflare CDN 分发。你不需要自己配置 CloudFront 或者 Cloudflare Workers。
打开浏览器开发者工具,看看响应头里的 cf-cache-status:
cf-cache-status: HIT
HIT 表示命中缓存,MISS 表示没有命中。Public bucket 的命中率通常会比 Private bucket 高不少,因为 Private bucket 的缓存策略更严格。
Smart CDN:缓存的”自动失效”机制
这是 Supabase 的一个亮点。传统 CDN 你更新文件后得手动 purge 缓存,或者等 TTL 过期。Supabase 的 Smart CDN 会自动把文件的 metadata 同步到边缘节点,文件一更新,最多 60 秒全球生效。
但也别高兴太早——60 秒在某些实时性要求高的场景下还是挺长的。如果你需要即时生效,得用后面提到的 cacheNonce 参数。
二、三种访问控制模式对比
这是很多人搞不清楚的地方。Public bucket、Private bucket、Signed URL——三种模式,三种适用场景。选错了要么缓存命中率低得可怜,要么安全性出问题。
2.1 Public Bucket:公开资源首选
如果你的文件本来就是给所有人看的——网站 logo、博客配图、公开的文档——直接用 Public bucket。
优势:
- URL 最简洁:
https://xxx.supabase.co/storage/v1/object/public/bucket-name/file.jpg - 缓存命中率最高,CDN 直接响应,不经过 Auth 验证
- 代码最简单,一行搞定
// 获取 Public URL
const { data } = supabase.storage
.from('public-images')
.getPublicUrl('hero-banner.jpg')
console.log(data.publicUrl)
// https://xxx.supabase.co/storage/v1/object/public/public-images/hero-banner.jpg
适合场景:
- 用户头像(公开显示的)
- 博客配图
- 网站静态资源
- 公开文档
2.2 Private Bucket + Signed URL:私有文件的标准方案
有些文件不能公开——用户上传的合同、会员专属内容、敏感文档。这时候用 Private bucket,然后生成一个有时效的 Signed URL。
// 生成一个 1 小时有效的访问链接
const { data, error } = await supabase.storage
.from('private-docs')
.createSignedUrl('contracts/user-123.pdf', 3600) // 3600 秒 = 1 小时
console.log(data.signedUrl)
// https://xxx.supabase.co/storage/v1/object/sign/private-docs/contracts/user-123.pdf?token=xxx
注意:Signed URL 每次生成都不同,这会影响缓存命中率。如果你频繁生成新的 Signed URL,CDN 会一直 MISS。
优化技巧:同一个用户短时间内多次访问同一个文件,可以把 Signed URL 缓在前端或者 Redis 里,不要每次都重新生成。
2.3 RLS 策略:细粒度权限控制
这是最强大但也最容易被忽视的部分。你可以在 storage.objects 表上定义 RLS 策略,精确控制谁能操作哪些文件。
场景一:用户只能上传到自己的文件夹
-- 在 storage.objects 表上创建策略
CREATE POLICY "Users can upload to own folder"
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'avatars'
AND auth.uid()::text = (storage.foldername(name))[1]
);
-- 解释:name 是文件完整路径,比如 'user-123/avatar.jpg'
-- storage.foldername(name)[1] 取第一个文件夹名,即 'user-123'
-- auth.uid() 是当前登录用户的 ID
-- 两者匹配才允许上传
场景二:管理员可以访问所有文件
CREATE POLICY "Admins can access all"
ON storage.objects FOR ALL
USING (
auth.jwt() ->> 'role' = 'admin'
);
场景三:会员才能下载特定内容
CREATE POLICY "Members can download premium content"
ON storage.objects FOR SELECT
USING (
bucket_id = 'premium-content'
AND EXISTS (
SELECT 1 FROM user_subscriptions
WHERE user_id = auth.uid()
AND status = 'active'
)
);
这三个策略组合起来,基本能覆盖大部分业务场景。
三、文件上传实战
终于到动手环节了。
3.1 标准上传:小文件快速搞定
小于 5MB 的文件,直接用 upload 方法就行。
// React 上传组件
import { useState } from 'react'
import { supabase } from './supabase-client'
export function AvatarUpload() {
const [uploading, setUploading] = useState(false)
const [avatarUrl, setAvatarUrl] = useState<string | null>(null)
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
const fileExt = file.name.split('.').pop()
const fileName = `${Date.now()}.${fileExt}`
const filePath = `avatars/${fileName}`
const { error } = await supabase.storage
.from('public-images')
.upload(filePath, file, {
cacheControl: '3600', // 浏览器缓存 1 小时
upsert: false // 不覆盖已存在文件
})
if (error) {
alert('上传失败:' + error.message)
} else {
const { data } = supabase.storage
.from('public-images')
.getPublicUrl(filePath)
setAvatarUrl(data.publicUrl)
}
setUploading(false)
}
return (
<div>
<input
type="file"
accept="image/*"
onChange={handleUpload}
disabled={uploading}
/>
{avatarUrl && <img src={avatarUrl} alt="avatar" />}
{uploading && <p>上传中...</p>}
</div>
)
}
几个细节:
cacheControl设置浏览器缓存时间,跟 CDN 缓存是两回事upsert: false防止意外覆盖,想要覆盖改成true- 文件名用时间戳避免重名,也可以用 UUID
3.2 TUS 分片上传:大文件稳定方案
上传超过 5MB 的文件,或者网络不稳定的环境,用 TUS 分片上传。
关键限制:chunkSize 必须是 6MB,不能改。这是 Supabase 的硬编码限制。
有效期:上传 URL 24 小时有效,超时了得重新生成。
先装依赖:
npm install tus-js-client uppy @uppy/core @uppy/dashboard @uppy/tus
完整代码:
import Uppy from '@uppy/core'
import { Dashboard } from '@uppy/react'
import Tus from '@uppy/tus'
import { supabase } from './supabase-client'
import '@uppy/core/dist/style.css'
import '@uppy/dashboard/dist/style.css'
export function LargeFileUploader() {
const uppy = new Uppy({
restrictions: {
maxFileSize: 100 * 1024 * 1024, // 100MB
allowedFileTypes: ['video/*', 'image/*']
}
})
// 获取 Supabase 的 session token
const getSession = async () => {
const { data: { session } } = await supabase.auth.getSession()
return session?.access_token || ''
}
uppy.use(Tus, {
endpoint: 'https://xxx.supabase.co/storage/v1/upload/resumable',
chunkSize: 6 * 1024 * 1024, // 必须 6MB
async onBeforeRequest(req) {
const token = await getSession()
req.setHeader('Authorization', `Bearer ${token}`)
},
onAfterResponse(req, res) {
// 上传完成后获取文件路径
const location = res.getHeader('Location')
console.log('File uploaded to:', location)
}
})
return (
<div style={{ maxWidth: '600px', margin: '0 auto' }}>
<Dashboard uppy={uppy} />
</div>
)
}
问题排查:如果你的分片上传卡在 6MB 不动,检查:
chunkSize是不是 6MB(必须精确)- Token 是否过期(24 小时有效期)
- RLS 策略是否允许 INSERT(参考 GitHub Issue #563)
3.3 Presigned Upload URL:第三方上传授权
有时候你需要让用户直接上传,但不想暴露 service_role key。用 createSignedUploadUrl 生成一个预签名上传地址。
// 服务端生成上传 URL
const { data, error } = await supabase.storage
.from('user-uploads')
.createSignedUploadUrl('documents/report.pdf')
// data.signedUrl 可以传给前端直接上传
// 前端不需要知道 service_role key
四、CDN 与图片优化
4.1 Smart CDN 机制详解
前面提过,Smart CDN 会在文件更新后自动失效缓存。但 60 秒的传播延迟有时候确实让人着急。
最佳实践:
- 频繁更新的文件,上传到新路径
// 别这样:每次更新同一个文件
await storage.from('images').upload('logo.png', file, { upsert: true })
// 这样:每次生成新文件名
const version = Date.now()
await storage.from('images').upload(`logo-${version}.png`, file)
- 用 cacheNonce 强制绕过缓存
const { data } = supabase.storage
.from('images')
.getPublicUrl('logo.png', {
cacheNonce: Date.now().toString() // 每次请求都不同
})
- Signed URL 复用
同一个用户访问同一个文件,把 Signed URL 存起来,别每次都重新生成。
4.2 图片转换与自动优化
Supabase 支持实时图片转换——宽度、高度、质量、格式都能调。
限制:
- 宽高:1-2500px
- 文件大小:≤25MB
- 分辨率:≤50MP
// 获取缩略图
const { data } = supabase.storage
.from('images')
.getPublicUrl('hero.jpg', {
transform: {
width: 300,
height: 200,
resize: 'cover', // 或 'contain'、'fill'
quality: 80,
format: 'webp' // 自动转 WebP
}
})
Next.js 图片 Loader 集成:
// next.config.js
module.exports = {
images: {
loader: 'custom',
loaderFile: './lib/supabase-image-loader.js'
}
}
// lib/supabase-image-loader.js
export default function supabaseLoader({ src, width, quality }) {
const url = new URL(src)
url.searchParams.set('width', width.toString())
url.searchParams.set('quality', (quality || 75).toString())
url.searchParams.set('format', 'webp')
return url.toString()
}
计费:图片转换是 $5/1000 张 origin images。如果你的图片被大量转换(比如响应式图片的多种尺寸),这笔钱要算清楚。
五、成本对比与选型建议
这部分很多人关心。我把主流方案列出来对比一下。
5.1 价格对比表
| 服务 | 存储费 | 出口费 | 免费额度 | 特点 |
|---|---|---|---|---|
| Supabase Storage | 基于 S3 定价 | CDN 另计费 | Pro 计划含 | Auth 集成、RLS |
| Cloudflare R2 | $0.015/GB | 零 | 10GB + 1M ops | 零出口费 |
| AWS S3 | $0.023/GB | $0.09/GB | 5GB/12 月 | 生态最强 |
| DigitalOcean Spaces | $5/250GB | 含 | 无 | 固定费用 |
5.2 选型决策
高下载量场景 → R2
如果你的文件会被频繁下载(比如图片分享站、视频托管),R2 的零出口费能省一大笔钱。S3 的出口费每 GB 近 10 美分,流量一大肉疼。
需要 Auth 集成 → Supabase Storage
如果你已经在用 Supabase 的 Auth 和数据库,Storage 的集成是顺滑的。用户权限控制、RLS 策略都能直接复用。
AWS 生态深度用户 → S3
Lambda、CloudFront、S3 Select、S3 Glacier——如果你的架构已经绑在 AWS 上,换方案的成本可能比省下的钱还多。
固定预算、流量可预测 → DigitalOcean Spaces
每月固定费用,适合不想操心用量计费的小项目。
5.3 成本优化建议
- 生命周期策略:旧文件自动归档到 Glacier
- 图片压缩:上传前压缩,或者用 Supabase 的图片转换
- Public bucket 提升缓存命中率:能公开的尽量公开
- Signed URL 复用:减少重复生成带来的缓存 MISS
六、常见问题排查
6.1 文件更新后还是旧版本
原因:Smart CDN 的 60 秒传播延迟。
解决方案:
- 等 60 秒
- 上传到新路径
- 用
cacheNonce绕过缓存
6.2 分片上传卡在 6MB
原因:chunkSize 配置错误。
解决方案:确保 chunkSize: 6 * 1024 * 1024,精确到字节。
// 错误:写成 5MB
chunkSize: 5 * 1024 * 1024 // 会卡住
// 正确:必须是 6MB
chunkSize: 6 * 1024 * 1024
6.3 上传返回 403 Forbidden
原因:RLS 策略没配好。
排查步骤:
- 检查 bucket 是 Public 还是 Private
- 检查
storage.objects表上的 RLS 策略 - 确保策略允许 INSERT 操作
-- 查看现有策略
SELECT * FROM pg_policies WHERE tablename = 'objects';
-- 添加允许上传的策略
CREATE POLICY "Allow upload"
ON storage.objects FOR INSERT
WITH CHECK (bucket_id = 'your-bucket');
6.4 Signed URL 无法访问
原因:URL 过期或 token 无效。
解决方案:
- 检查过期时间是否合理
- 确保 token 没有被截断
- 测试时先生成一个长时效的 URL(比如 24 小时)
总结
把要点捋一下:
- Public bucket 适合公开资源,缓存命中率最高
- Private bucket + Signed URL 适合私有文件,注意缓存策略
- RLS 策略 提供细粒度权限控制,别忽视这个功能
- TUS 分片上传 处理大文件,chunkSize 必须是 6MB
- Smart CDN 自动失效缓存,但有 60 秒延迟
- 选型:高下载量选 R2,Auth 集成选 Supabase Storage,AWS 生态选 S3
如果你已经在用 Supabase 的数据库和认证,Storage 是顺理成章的选择。但如果你只是需要对象存储,而且流量大、预算敏感,R2 的零出口费确实诱人。
关于 Supabase Storage,你还有什么问题或者踩过的坑?欢迎在评论区交流。
Supabase Storage 文件上传完整流程
从创建 bucket 到配置 RLS 策略,实现安全可控的文件上传
⏱️ 预计耗时: 30 分钟
- 1
步骤1: 创建 Storage Bucket
在 Supabase Dashboard 中创建 bucket:
• 进入 Storage 页面,点击 "Create a new bucket"
• 填写 bucket 名称(如 avatars、documents)
• 选择 Public 或 Private 模式
• Public bucket 文件可直接访问,Private 需要签名 URL - 2
步骤2: 配置 RLS 策略
在 storage.objects 表上定义访问控制:
```sql
-- 用户只能操作自己的文件
CREATE POLICY "Users manage own files"
ON storage.objects FOR ALL
USING (auth.uid()::text = (storage.foldername(name))[1]);
```
• bucket_id 匹配目标 bucket
• auth.uid() 获取当前用户 ID
• storage.foldername() 解析文件路径 - 3
步骤3: 实现标准上传
使用 upload 方法上传小文件(<5MB):
```typescript
const { error } = await supabase.storage
.from('bucket-name')
.upload('path/file.jpg', file, {
cacheControl: '3600',
upsert: false
});
```
• cacheControl 设置浏览器缓存时长
• upsert: false 防止覆盖已有文件 - 4
步骤4: 配置 TUS 分片上传
处理大文件(>5MB)使用 TUS 协议:
• 安装依赖:npm install @uppy/tus tus-js-client
• 设置 chunkSize: 6 * 1024 * 1024(必须 6MB)
• 配置 Authorization header 传递 JWT token
• 上传 URL 有效期 24 小时 - 5
步骤5: 优化 CDN 缓存
提升缓存命中率的关键技巧:
• Public bucket 缓存命中率最高
• 频繁更新文件使用新路径而非覆盖
• 用 cacheNonce 强制刷新缓存
• Signed URL 缓存复用,避免重复生成
常见问题
Supabase Storage 的 chunkSize 为什么必须是 6MB?
Public bucket 和 Private bucket 怎么选择?
• Public bucket:网站静态资源、公开图片、博客配图——任何人都能访问
• Private bucket:用户私有文件、会员内容、敏感文档——需要 Signed URL 或 RLS 策略控制访问
文件更新后为什么还是旧版本?
Supabase Storage 和 Cloudflare R2 哪个更便宜?
• 高下载量:R2 零出口费更省钱(S3 出口费 $0.09/GB)
• 需要 Auth 集成:Supabase Storage 更方便(RLS 策略直接复用)
• 已用 Supabase:Storage 是顺滑的选择
• 纯对象存储:R2 成本更低
RLS 策略如何限制用户只能访问自己的文件?
```sql
CREATE POLICY "Users own files"
ON storage.objects FOR ALL
USING (
bucket_id = 'avatars'
AND auth.uid()::text = (storage.foldername(name))[1]
);
```
这样用户只能操作路径第一段为自己的文件。
Signed URL 缓存命中率低怎么办?
参考资料
- Storage CDN | Supabase Docs
- Storage Access Control | Supabase Docs
- Resumable Uploads | Supabase Docs
- Image Transformations | Supabase Docs
- Smart CDN | Supabase Docs
- Cloud Storage Pricing | BuildMVPFast
- Supabase Storage v3: Resumable Uploads
- GitHub Issue #563 - TUS Upload Stalling
10 分钟阅读 · 发布于: 2026年4月14日 · 修改于: 2026年4月14日
相关文章
Supabase 入门:PostgreSQL + Auth + Storage 一站式后端
Supabase 入门:PostgreSQL + Auth + Storage 一站式后端
Supabase 数据库设计:表结构、关系与 Row Level Security 完全指南
Supabase 数据库设计:表结构、关系与 Row Level Security 完全指南
Supabase Auth 实战:邮箱验证、OAuth 与会话管理

评论
使用 GitHub 账号登录后即可评论