最佳实践
本章节总结 Supabase 在生产环境中的最佳实践,涵盖安全、性能、架构设计等方面。
安全最佳实践
API 密钥管理
永远不要暴露 service_role 密钥
// 错误:在客户端使用 service_role 密钥
const supabase = createClient(url, 'service-role-key') // 危险!
// 正确:在客户端使用 anon 密钥
const supabase = createClient(url, 'anon-key')
// 正确:在服务端使用 service_role 密钥
// 服务端代码
const supabase = createClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY)
使用环境变量
# .env.local (不要提交到 Git)
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
# 服务端专用(不要暴露给客户端)
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
RLS 策略设计
默认拒绝原则
-- 启用 RLS 后,默认拒绝所有访问
alter table posts enable row level security;
-- 然后逐步添加允许的策略
create policy "用户查看自己的数据"
on posts for select
to authenticated
using (auth.uid() = author_id);
分离读写策略
-- 读取策略
create policy "读取已发布内容"
on posts for select
using (status = 'published' or auth.uid() = author_id);
-- 写入策略
create policy "作者编辑自己的文章"
on posts for all
using (auth.uid() = author_id)
with check (auth.uid() = author_id);
验证用户输入
-- 使用 CHECK 约束
create table posts (
id bigint primary key,
title text not null check (length(title) >= 3 and length(title) <= 200),
status text default 'draft' check (status in ('draft', 'published', 'archived'))
);
存储安全
限制文件类型
create policy "只允许图片"
on storage.objects for insert
with check (
bucket_id = 'avatars'
and lower(storage.extension(name)) in ('jpg', 'jpeg', 'png', 'gif', 'webp')
);
限制文件大小
create policy "限制文件大小"
on storage.objects for insert
with check (
bucket_id = 'avatars'
and storage.size(name) <= 2 * 1024 * 1024 -- 2MB
);
性能优化
数据库索引
为常用查询创建索引
-- 单列索引
create index idx_posts_author on posts(author_id);
create index idx_posts_status on posts(status);
-- 复合索引(注意顺序)
create index idx_posts_author_status on posts(author_id, status);
-- 部分索引
create index idx_posts_published on posts(created_at desc)
where status = 'published';
分析查询性能
-- 查看执行计划
explain analyze
select * from posts where author_id = 1 and status = 'published';
-- 检查是否使用索引
-- Seq Scan = 全表扫描(通常需要优化)
-- Index Scan = 索引扫描(好)
查询优化
只查询需要的字段
// 不推荐
.select('*')
// 推荐
.select('id, title, created_at')
使用分页
// 使用 range 分页
.select('*')
.range(0, 19) // 第一页,每页 20 条
// 或使用 cursor 分页(更高效)
.select('*')
.lt('id', lastId) // 获取 ID 小于 lastId 的记录
.order('id', { ascending: false })
.limit(20)
避免 N+1 查询
// 不推荐:循环查询
for (const post of posts) {
const { data } = await supabase
.from('users')
.select('*')
.eq('id', post.author_id)
.single()
}
// 推荐:使用关联查询
const { data } = await supabase
.from('posts')
.select(`
id,
title,
author:users (id, name)
`)
连接池
使用连接池端口
// 标准连接(适合长连接)
const dbUrl = 'postgresql://postgres:[email protected]:5432/postgres'
// 连接池连接(适合 Serverless)
const poolerUrl = 'postgresql://postgres.project:[email protected]:6543/postgres'
架构设计
数据模型设计
合理使用关系
-- 一对多关系
create table users (
id uuid primary key default gen_random_uuid(),
name text
);
create table posts (
id bigint primary key generated always as identity,
author_id uuid references users(id) on delete cascade,
title text
);
-- 多对多关系
create table tags (
id bigint primary key generated always as identity,
name text unique
);
create table post_tags (
post_id bigint references posts(id) on delete cascade,
tag_id bigint references tags(id) on delete cascade,
primary key (post_id, tag_id)
);
使用视图简化查询
-- 创建视图
create view published_posts_with_author as
select
posts.id,
posts.title,
posts.content,
posts.created_at,
users.name as author_name
from posts
join users on posts.author_id = users.id
where posts.status = 'published';
-- 使用视图
select * from published_posts_with_author order by created_at desc;
用户配置文件模式
-- 创建用户配置表
create table profiles (
id uuid primary key references auth.users(id) on delete cascade,
name text,
avatar_url text,
bio text,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- 自动创建配置
create or replace function public.handle_new_user()
returns trigger as $$
begin
insert into public.profiles (id, name)
values (new.id, new.raw_user_meta_data->>'name');
return new;
end;
$$ language plpgsql security definer;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
软删除模式
-- 添加删除标记
alter table posts add column deleted_at timestamptz;
-- 创建视图过滤已删除数据
create view active_posts as
select * from posts where deleted_at is null;
-- 软删除函数
create or replace function soft_delete(post_id bigint)
returns void as $$
begin
update posts set deleted_at = now() where id = post_id;
end;
$$ language plpgsql;
错误处理
统一错误处理
async function handleSupabaseError(promise) {
const { data, error } = await promise
if (error) {
// 记录错误
console.error('Supabase Error:', {
code: error.code,
message: error.message,
details: error.details,
hint: error.hint
})
// 转换为用户友好的错误
const userMessage = getUserFriendlyMessage(error)
throw new Error(userMessage)
}
return data
}
function getUserFriendlyMessage(error) {
const messages = {
'23505': '该数据已存在',
'23503': '关联数据不存在',
'42501': '没有权限执行此操作',
'PGRST116': '未找到数据'
}
return messages[error.code] || '操作失败,请稍后重试'
}
// 使用
try {
const posts = await handleSupabaseError(
supabase.from('posts').select('*')
)
} catch (error) {
alert(error.message)
}
重试机制
async function withRetry(fn, maxRetries = 3) {
let lastError
for (let i = 0; i < maxRetries; i++) {
try {
return await fn()
} catch (error) {
lastError = error
// 指数退避
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, i) * 1000)
)
}
}
throw lastError
}
// 使用
const data = await withRetry(() =>
supabase.from('posts').select('*')
)
监控和日志
查询日志
-- 启用查询日志(需要超级用户权限)
alter system set log_statement = 'all';
select pg_reload_conf();
性能监控
-- 查看慢查询
select
query,
calls,
total_time,
mean_time,
max_time
from pg_stat_statements
order by mean_time desc
limit 10;
-- 查看表统计
select
schemaname,
tablename,
seq_scan,
idx_scan,
n_live_tup
from pg_stat_user_tables
order by seq_scan desc;
应用监控
// 记录操作时间
async function monitoredQuery(name, query) {
const start = performance.now()
try {
const result = await query()
const duration = performance.now() - start
console.log(`[Supabase] ${name}: ${duration.toFixed(2)}ms`)
// 发送到监控服务
if (duration > 1000) {
trackSlowQuery(name, duration)
}
return result
} catch (error) {
trackError(name, error)
throw error
}
}
// 使用
const posts = await monitoredQuery('getPosts', () =>
supabase.from('posts').select('*')
)
部署建议
环境配置
// 不同环境使用不同配置
const config = {
development: {
url: 'http://localhost:54321',
anonKey: 'local-anon-key'
},
staging: {
url: 'https://staging-project.supabase.co',
anonKey: process.env.STAGING_ANON_KEY
},
production: {
url: 'https://prod-project.supabase.co',
anonKey: process.env.PROD_ANON_KEY
}
}
const env = process.env.NODE_ENV || 'development'
export const supabase = createClient(config[env].url, config[env].anonKey)
数据库迁移
# 创建迁移
supabase migration new add_posts_table
# 本地测试
supabase db reset
# 推送到远程
supabase db push
# 生产环境使用 CI/CD 自动部署
备份策略
# 手动备份
pg_dump $DATABASE_URL > backup_$(date +%Y%m%d).sql
# 自动备份(Supabase Pro 自动每日备份)
# 在 Dashboard → Database → Backups 查看
成本优化
免费套餐限制
Supabase 免费套餐包括:
- 500MB 数据库存储
- 1GB 文件存储
- 2GB 出站流量/月
- 50,000 月活用户
- 500MB Edge Functions 执行时间
优化建议
- 压缩数据:使用合适的数据类型
- 清理旧数据:定期删除不需要的数据
- 使用 CDN:静态资源使用 CDN
- 优化查询:减少不必要的数据传输
-- 清理旧数据
delete from logs where created_at < now() - interval '30 days';
-- 压缩表
vacuum analyze posts;
常见陷阱
1. 忘记启用 RLS
-- 创建表后立即启用 RLS
create table sensitive_data (...);
alter table sensitive_data enable row level security;
2. 过度依赖客户端
敏感逻辑应该在 Edge Functions 或后端执行:
// 不推荐:在客户端处理支付
// 推荐:在 Edge Function 处理支付
3. 忽略错误处理
// 不推荐
const { data } = await supabase.from('posts').select('*')
// 推荐
const { data, error } = await supabase.from('posts').select('*')
if (error) {
// 处理错误
}
4. 不使用事务
// 需要原子操作时使用事务
const { error } = await supabase.rpc('transfer_credits', {
from_user: 'user-1',
to_user: 'user-2',
amount: 100
})
create or replace function transfer_credits(
from_user uuid,
to_user uuid,
amount int
)
returns void as $$
begin
update users set credits = credits - amount where id = from_user;
update users set credits = credits + amount where id = to_user;
end;
$$ language plpgsql;
下一步
- 速查表 - 常用代码片段
- Supabase 官方文档 - 详细文档