切换语言
切换主题

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——三种模式,三种适用场景。选错了要么缓存命中率低得可怜,要么安全性出问题。

3种
访问控制模式

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 不动,检查:

  1. chunkSize 是不是 6MB(必须精确)
  2. Token 是否过期(24 小时有效期)
  3. 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 秒的传播延迟有时候确实让人着急。

最佳实践

  1. 频繁更新的文件,上传到新路径
// 别这样:每次更新同一个文件
await storage.from('images').upload('logo.png', file, { upsert: true })

// 这样:每次生成新文件名
const version = Date.now()
await storage.from('images').upload(`logo-${version}.png`, file)
  1. 用 cacheNonce 强制绕过缓存
const { data } = supabase.storage
  .from('images')
  .getPublicUrl('logo.png', {
    cacheNonce: Date.now().toString() // 每次请求都不同
  })
  1. 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/GB10GB + 1M ops零出口费
AWS S3$0.023/GB$0.09/GB5GB/12 月生态最强
DigitalOcean Spaces$5/250GB固定费用
$0
Cloudflare R2 出口费

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 成本优化建议

  1. 生命周期策略:旧文件自动归档到 Glacier
  2. 图片压缩:上传前压缩,或者用 Supabase 的图片转换
  3. Public bucket 提升缓存命中率:能公开的尽量公开
  4. 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 策略没配好。

排查步骤

  1. 检查 bucket 是 Public 还是 Private
  2. 检查 storage.objects 表上的 RLS 策略
  3. 确保策略允许 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

    步骤1: 创建 Storage Bucket

    在 Supabase Dashboard 中创建 bucket:

    • 进入 Storage 页面,点击 "Create a new bucket"
    • 填写 bucket 名称(如 avatars、documents)
    • 选择 Public 或 Private 模式
    • Public bucket 文件可直接访问,Private 需要签名 URL
  2. 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

    步骤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

    步骤4: 配置 TUS 分片上传

    处理大文件(>5MB)使用 TUS 协议:

    • 安装依赖:npm install @uppy/tus tus-js-client
    • 设置 chunkSize: 6 * 1024 * 1024(必须 6MB)
    • 配置 Authorization header 传递 JWT token
    • 上传 URL 有效期 24 小时
  5. 5

    步骤5: 优化 CDN 缓存

    提升缓存命中率的关键技巧:

    • Public bucket 缓存命中率最高
    • 频繁更新文件使用新路径而非覆盖
    • 用 cacheNonce 强制刷新缓存
    • Signed URL 缓存复用,避免重复生成

常见问题

Supabase Storage 的 chunkSize 为什么必须是 6MB?
这是 Supabase 服务端的硬编码限制。如果设置成其他值(如 5MB),上传会卡住不动。确保代码中精确配置为:chunkSize: 6 * 1024 * 1024。
Public bucket 和 Private bucket 怎么选择?
根据文件访问权限选择:

• Public bucket:网站静态资源、公开图片、博客配图——任何人都能访问
• Private bucket:用户私有文件、会员内容、敏感文档——需要 Signed URL 或 RLS 策略控制访问
文件更新后为什么还是旧版本?
Smart CDN 的缓存失效需要最多 60 秒传播到全球节点。解决方案有三个:等待 60 秒、上传到新路径(推荐)、使用 cacheNonce 参数强制绕过缓存。
Supabase Storage 和 Cloudflare R2 哪个更便宜?
看你的使用场景:

• 高下载量:R2 零出口费更省钱(S3 出口费 $0.09/GB)
• 需要 Auth 集成:Supabase Storage 更方便(RLS 策略直接复用)
• 已用 Supabase:Storage 是顺滑的选择
• 纯对象存储:R2 成本更低
RLS 策略如何限制用户只能访问自己的文件?
使用 storage.foldername() 解析文件路径,配合 auth.uid() 匹配用户 ID:

```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 缓存命中率低怎么办?
Signed URL 每次生成都不同,会导致 CDN 缓存 MISS。优化方法:将 Signed URL 缓存在前端或 Redis,同一用户短时间内复用;或改用 RLS 策略控制访问,让文件走 Public bucket。

参考资料

10 分钟阅读 · 发布于: 2026年4月14日 · 修改于: 2026年4月14日

当前属于系列阅读 第 5 / 5 篇

Supabase 实战指南

如果你是从搜索进入这篇文章,建议顺手补上上一篇或继续下一篇,这样更容易把同一主题读完整。

查看系列总览

相关文章

BetterLink

想持续收到这个主题的更新?

你可以直接关注作者更新、订阅 RSS,或者继续沿着系列入口往下读,避免下次又回到搜索结果重新找。

关注公众号

评论

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