先交代这个站是什么、我在里面写了什么、没写什么——「没写的部分」就是 Supabase 的作用。
mingyuyang.com 是我的个人资料站:AI 学习笔记、公众号文章、每日简报,访客可以浏览搜索,我登录后能管理内容,登录用户还能给每篇文章打「待读/已读」标记。技术栈是 TanStack Start(React 19)跑在 Cloudflare Workers 上——但这只是「壳」,负责渲染页面。真正的后端三件事:存数据、认身份、管权限,我一行都没写。
换句话说:如果把 Supabase 从我的站里抽走,剩下的只是一个漂亮但空心的前端。这正是讲解它的最好角度——不讲概念,讲「它替我干了哪些我本来要自己干的活」。
一张图看清我的站和 Supabase 的分工——后面每一节展开图里的一块。
记住这张图的三个要点,后面全是展开:① 所有数据(包括用户、文件清单)都是 Postgres 里的表;② 前端不经过任何自建服务器,直接和 Supabase 的三个服务对话;③ 权限不写在代码里,写在数据库的 RLS policy 里。
Supabase 的第一重身份:一个我不用运维的完整 PostgreSQL。
我的站首页每张资料卡、搜索出的每条结果、文章页的每个标题,都是 resources 表里的一行。这张表长这样(摘自我仓库的 supabase/schema.sql,原文照录):
CREATE TABLE IF NOT EXISTS public.resources (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
slug TEXT UNIQUE,
type TEXT NOT NULL CHECK (type IN ('article','video','link','file','note')),
title TEXT,
summary TEXT,
content TEXT,
url TEXT,
cover_url TEXT,
category TEXT,
subcategory TEXT,
tags TEXT[] NOT NULL DEFAULT '{}',
owner_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
...
);
来源:my-shared-memories 仓库 supabase/schema.sql(节选,省略部分列)
几个值得停下来看的细节:
tags TEXT[] 是 Postgres 原生数组类型——NoSQL 时代要么存 JSON 字符串要么建关联表,这里一列搞定,还能按元素查询。这是「底座是真 Postgres,不是阉割版」的直接证据。owner_id REFERENCES auth.users(id)——业务表直接外键关联认证系统的用户表。因为用户表就是同一个库里的普通表,这种关联在 Firebase 里做不到(用户在另一个系统里)。CHECK (type IN (...))——约束写在数据库层,任何入口写入非法类型都会被拒,不依赖前端自觉。这张表现在有 78 行(下文探针实测),来源有三路,也顺便展示了「往 Supabase 写数据」的三种姿势:
| 来源 | 写入方式 | 用的钥匙 |
|---|---|---|
| 静态学习文档 | 部署时 sync 脚本扫 public/ 目录,REST upsert(只新增不覆盖) | service_role(本机) |
| 微信公众号文章 | 导入脚本抓取后写库 | service_role(本机) |
| 后台手动添加/修改 | /admin 页面里登录操作,走 supabase-js | publishable + 我的 JWT(admin 角色) |
「建了表就有 API」听起来像魔法——看我站里的真实调用就祛魅了。
首页加载资料列表的代码,在 src/lib/resources.ts 里,原文如下:
// 首页拉资料列表(节选自 src/lib/resources.ts)
let q = supabase
.from("resources")
.select("*")
.order("published_at", { ascending: false });
if (opts?.type) q = q.eq("type", opts.type);
if (opts?.limit) q = q.limit(opts.limit);
const { data, error } = await q;
这段代码在访客的浏览器里执行。它没有请求我的任何服务器——supabase-js 把这个链式调用翻译成一个 HTTP 请求,直接打到 PostgREST 自动生成的端点上。翻译出来大概是:
GET https://<项目ref>.supabase.co/rest/v1/resources
?select=*&order=published_at.desc&type=eq.article
apikey: sb_publishable_...
为什么这个端点存在?因为 PostgREST 读了我的表结构:resources 表存在,于是 /rest/v1/resources 就存在;表里有 type 列,于是 ?type=eq.article 这个过滤器就能用。我加一列,API 立刻多一个可查询字段;我删一列,对应查询立刻 400。schema 即接口,没有中间商。
给已入库的文章改标题、加标签时,我不开后台页面,直接在终端 PATCH REST 端点(这是我真实干过的事:给 W4 六篇文章补「CS146S Week4」标签,6 条 PATCH 全部成功):
# 真实运维操作:直接 PATCH 一行数据(service_role key 存在仓库外)
curl -X PATCH "https://<项目ref>.supabase.co/rest/v1/resources?slug=eq.ai-notes-xxx" \
-H "apikey: $SERVICE_KEY" -H "Authorization: Bearer $SERVICE_KEY" \
-H "Content-Type: application/json" \
-d '{"tags": ["CS146S Week4", "AI", "Claude Code"]}'
updated_at 自动更新触发器,于是那六篇文章页面上都显示了「更新于 7月7日」——数据库触发器不管你从哪个入口写,一视同仁。这个「副作用」反过来证明了:REST API、后台页面、脚本写入,底下是同一张表、同一套触发器、同一套规则。我的站有完整的 Google 账号登录,而我写的相关代码,全文如下。
// src/routes/auth.tsx —— Google 登录的全部业务代码,原文照录
const signInGoogle = async () => {
const { error } = await supabase.auth.signInWithOAuth({
provider: "google",
options: { redirectTo: `${window.location.origin}/admin` },
});
if (error) toast.error(error.message || "Google 登录失败");
};
这六行背后,GoTrue(Supabase 的认证服务)替我做了整条链:跳转 Google 授权页 → 接住回调 → 和 Google 换取用户信息 → 在 auth.users 表建立/匹配用户 → 签发一张 JWT 交还给浏览器 → 之后 supabase-js 自动在每个请求上带着它、并在过期前自动续期。我没有存过密码,没有写过 session 管理,没有碰过 OAuth 的 token 交换细节。
sub(我在 auth.users 里的用户 ID)、role(数据库角色,登录后是 authenticated)、exp(过期时间)等。任何 Supabase 服务拿到它验个签名就知道「这是谁、有什么身份」,不用回数据库查。auth.uid() 读的就是这张 JWT 里的 sub。登录系统和数据库权限系统就是在这里接上头的——这是 Supabase 安全模型最精妙的一环。顺带一提:我的站还有「管理员」概念——admin 能在后台管理所有资料。这不是写在 JWT 里的,而是一张 user_roles 表 + 一个 has_role() 数据库函数,RLS policy 里直接查(下一节能看到它出场)。角色数据放数据库而不是放 token,好处是改角色立刻生效,不用等 token 刷新。
我的站有两张业务表,恰好是 RLS 的两种典型配方。先看「公开读、受控写」的这张。
resources 表的诉求很朴素:谁都能看,但不能乱写。它的 policy 原文(schema.sql + 后来的 admin 补丁):
-- 读:对所有人放行(所以我的资料库不登录也能浏览)
CREATE POLICY "Resources are readable by everyone"
ON public.resources FOR SELECT USING (true);
-- 写:本人或管理员(20260702-admin-manage-resources.sql 补丁原文)
CREATE POLICY "Owners or admins update resources"
ON public.resources FOR UPDATE TO authenticated
USING (auth.uid() = owner_id OR public.has_role(auth.uid(), 'admin'))
WITH CHECK (auth.uid() = owner_id OR public.has_role(auth.uid(), 'admin'));
这里有一个我真实踩过的坑,比任何教程都说明问题:
auth.uid() = owner_id(本人)。但脚本导入的文章 owner_id 是空的——于是这些行连我这个管理员登录后都删不掉:policy 条件 auth.uid() = NULL 永远不为真,数据库默默过滤,后台删除按钮点了没反应、还不报错。修复就是上面的补丁:条件里加 OR has_role(auth.uid(), 'admin')。这个坑教会我两件事:① RLS 拒绝的方式是「装作没这行」而不是报错,排查时要想到它;② NULL 参与比较永远不为真——这正是理论篇里 auth.uid() 陷阱的现实版本。还有一个细节:policy 拒绝写入时,REST 返回的可能是「成功,影响 0 行」。所以我后台的删除代码专门核对返回行数来防「RLS 假成功」——这也是实战里才会长出来的防御。
「待读/已读」功能是我 2026 年 7 月自己上线的,它的 SQL 是一份可以背下来的 RLS 全套模板。
auth.uid() 读的都是 JWT 里的用户 ID需求:登录用户可以给任何文章打「待读」或「已读」标记,标记只有自己能看见、能修改。表结构 + 全部四条 policy,原文照录自我仓库的 supabase/patches/2026-07-13-reading-status.sql:
CREATE TABLE IF NOT EXISTS public.reading_status (
id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
resource_id uuid NOT NULL REFERENCES public.resources(id) ON DELETE CASCADE,
status text NOT NULL CHECK (status IN ('read', 'to_read')),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, resource_id)
);
ALTER TABLE public.reading_status ENABLE ROW LEVEL SECURITY;
-- ① 看:只能看自己的行
CREATE POLICY "Users can view their own reading status" ON public.reading_status
FOR SELECT TO authenticated USING (auth.uid() = user_id);
-- ② 增:只能以自己的身份写入
CREATE POLICY "Users can add their own reading status" ON public.reading_status
FOR INSERT TO authenticated WITH CHECK (auth.uid() = user_id);
-- ③ 改:旧行得是自己的(USING),改完还得是自己的(WITH CHECK)
CREATE POLICY "Users can update their own reading status" ON public.reading_status
FOR UPDATE TO authenticated
USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
-- ④ 删:只能删自己的行
CREATE POLICY "Users can remove their own reading status" ON public.reading_status
FOR DELETE TO authenticated USING (auth.uid() = user_id);
CREATE INDEX IF NOT EXISTS idx_reading_status_user
ON public.reading_status(user_id, status, updated_at DESC);
逐条拆开,这份 SQL 里藏着理论篇讲过的四个知识点的实物:
TO authenticated:四条 policy 全部显式限定角色——匿名请求在角色这一关就被短路,不用逐行算条件。这是官方性能优化六招之一,写的时候顺手,收益免费。idx_reading_status_user 把 policy 要比对的 user_id 放在索引首位——官方基准里这一招对大表提速两个数量级。UNIQUE (user_id, resource_id) 保证一人一文一条标记,前端的 upsert 逻辑因此可以放心写;CHECK (status IN ...) 挡住非法状态。而前端那边,「看不见别人的标记」这件事零代码:组件里就是普通的 select,返回的天然只有自己的行。权限逻辑从 UI 层彻底消失了——这就是「安全下沉到数据库」的直观体感。
口说无凭。下面两条命令我在写这篇文档时(2026-07-19)真实跑过,输出原样贴上。
用的就是前端公开的那把 sb_publishable_ key,不带任何登录态,模拟「任何一个陌生人」的访问:
# 探针 1:匿名读公开表 resources —— 应该有数据
curl -s "https://<项目ref>.supabase.co/rest/v1/resources?select=title&order=published_at.desc&limit=3" \
-H "apikey: $PUBLISHABLE_KEY"
[{"title":"Supabase 面试备考指南:架构 · RLS · Auth · 一周学习路径"},
{"title":"多模型路由 Model Routing:把每个请求送给「最便宜的够用模型」"},
{"title":"MCP Apps:让 MCP 服务器「长出界面」的交互 UI 扩展"}]
# 顺带看总量(响应头 content-range)
content-range: 0-77/78 # 78 行
# 探针 2:匿名读私有表 reading_status —— 应该什么都看不到
curl -s "https://<项目ref>.supabase.co/rest/v1/reading_status?select=*" \
-H "apikey: $PUBLISHABLE_KEY"
[]
注意第二条:不是 401、不是 403,是空数组。请求是合法的、表是存在的、查询执行了——只是 RLS 把每一行都过滤掉了,匿名用户「看不见任何行」。同一把钥匙、同一套 API,一张表全量放行、一张表滴水不漏,差别只在 policy。这两条命令就是「安全写在数据库层」的最短证明,面试时可以直接口述这个实验。
「key 能不能公开」不取决于藏得多深,取决于它对应什么数据库角色。我的站两把钥匙待遇天差地别。
为什么 publishable key 敢公开?因为公开它等于什么都没泄露:拿到它的人只能以 anon 角色发请求,而 anon 能看什么、不能看什么,上一节的探针已经演示过了——全由 RLS 决定。反过来,service_role key 绕过所有 RLS,所以我的仓库规范里它一条红线写死:永不进 .env、永不进 git、永不打包,只存在部署机的一个 600 权限文件里。
我的站用的是 2025 年后的新版 key 体系(sb_publishable_ 前缀)。新 key 有个官方文档里写明的坑:它是不透明字符串、不是 JWT,不能放在 Authorization: Bearer 头里。而我仓库的 src/integrations/supabase/client.ts 里恰好有这么一段(原文照录):
// src/integrations/supabase/client.ts(节选)
function isNewSupabaseApiKey(value: string): boolean {
return value.startsWith('sb_publishable_') || value.startsWith('sb_secret_');
}
// New Supabase API keys are opaque strings, not bearer JWTs.
if (isNewSupabaseApiKey(supabaseKey) && headers.get('Authorization') === `Bearer ${supabaseKey}`) {
headers.delete('Authorization'); // 把误放进 Bearer 头的新 key 摘出去
}
headers.set('apikey', supabaseKey);
理论篇里那句「新 key 不能走 Bearer 头」的迁移细节,在我自己的代码库里就有现成实现。读文档时觉得琐碎的知识点,原来早就在生产代码里替我干活了——这种「原来如此」的时刻,就是拿自己项目复习的最大红利。
封面图和附件传到哪去了?权限怎么管?答案还是「表 + policy」。
我的站有一个叫 resources 的存储桶(bucket),后台上传的封面图、文件附件都进这里。文件本体存在对象存储里,但每个文件的元数据是 storage.objects 表里的一行——所以文件权限就是往这张表上写 RLS policy,和管业务表一模一样(schema.sql 原文):
-- 谁都能看这个桶里的文件(封面图要公开展示)
CREATE POLICY "Public can read resources bucket"
ON storage.objects FOR SELECT USING (bucket_id = 'resources');
-- 登录用户只能上传属于自己的文件
CREATE POLICY "Authenticated users can upload to resources"
ON storage.objects FOR INSERT TO authenticated
WITH CHECK (bucket_id = 'resources' AND owner = auth.uid());
对比一下 Firebase:它的文件权限要用单独的 Security Rules 语言再学一遍。这里我把管 reading_status 的那套思路原封不动搬过来就行——少学一门方言,这是「一切皆 Postgres」设计的又一次分红。顺便:这个桶不是自动出现的,是我在 schema.sql 里用 INSERT INTO storage.buckets 幂等建的——桶本身也是表里的一行,贯彻到底。
讲清「为什么不用」和讲清「为什么用」同样重要——这是选型能力的体现。
我的站是内容站:文章更新频率以「天」计,访客没有「盯着别人实时改动」的场景。为一个不存在的需求维持 WebSocket 长连接,纯属浪费。
什么时候会用:如果以后做「多人同时给文章写批注、互相实时可见」,那就是 Postgres Changes(或 Broadcast)的活。
我的服务端逻辑(SSR 渲染、AI 自动分类调 Claude API)跑在 Cloudflare Workers 上——站本身部署在那里,逻辑放同处更顺。同类能力二选一即可。
什么时候会用:如果没有 Workers 这层壳,「收 Stripe webhook」「服务端调 LLM」这类活就该放 Edge Functions。
这件事本身就是 Supabase 的一个设计原则的体现:官方架构文档说每个组件「可作为独立工具运行」。我实际取用的是数据库 + 自动 API + 认证 + 存储四件,Realtime 和 Edge Functions 一根线都没连——平台没有强迫我全家桶,这才是「组件化」的诚意。
把前面所有零件串起来:我在文章页点一下「已读」,这 200 毫秒里发生了什么。
reading_status 表 upsert 一行 { resource_id, status: 'read' };SDK 自动附上我的 JWT 和公开 key。注意请求体里不用传 user_id——身份由 JWT 说了算,前端连伪造的机会都没有。role: authenticated 和 sub(我的用户 ID),以 authenticated 角色执行 SQL。素材都有了,最后组装成一段两分钟的项目陈述。斜体处换成你面试当下的自然表达。
「我的个人网站用 Supabase 做全部后端,前端是 TanStack Start 部署在 Cloudflare Workers。选它的核心原因是 database-centric 的设计:内容表、用户表、文件元数据都是同一个 Postgres 里的表,权限全部用 RLS 收在数据库层——我一行后端接口代码都没写,PostgREST 把表结构直接映射成 REST API。
权限上我写过两种典型配方:公开内容表是『人人可读、owner 或 admin 可写』,管理员走 user_roles 表加 has_role 函数;用户私有的阅读状态表是四条 policy 全套,USING 管旧行、WITH CHECK 管新行,策略列建了索引、显式限定 TO authenticated。上线验收时我专门用匿名身份 curl 过 REST 端点,确认私有表返回空数组——绕过前端直接打 API 拿不到数据,才算安全。
密钥管理是两把钥匙:publishable key 内联在前端产物里公开无妨,因为 RLS 兜底;service_role key 绕过 RLS,只存在部署机的 600 权限文件里,永不进 git。Realtime 和 Edge Functions 我评估过但没用——站里没有实时需求,服务端逻辑已经在 Workers 上,没必要为用而用。」
配合追问储备:RLS 假成功(影响 0 行)踩坑、NULL 比较陷阱、updated_at 触发器副作用、新版 key 不走 Bearer 头
这段话的结构是刻意的:选型理由 → 具体实现(带术语)→ 安全验证(带实验)→ 边界判断(不用什么)。四层递进,每一层都能接住追问——追问落点就是本文档的对应章节。
Supabase 的作用,用我的站一句话总结:它让「一个人 + 一个前端仓库」等于「一支配齐了 DBA、后端和安全工程师的团队」——数据库它托管,API 它生成,身份它认证,权限我只写 policy。
理论篇(概念、面试题、一周计划):Supabase 面试备考指南