PostgreSQL:设计哲学与快速上手

PostgreSQL:设计哲学与快速上手

Supabase 选择 PostgreSQL 不是偶然。理解 PG 的设计哲学,你才能真正用好 Supabase,而不是把它当成一个"能写 SQL 的 Firebase"。

PostgreSQL 的设计哲学

1. 正确性优先于速度

PG 的第一优先级永远是:数据不能错。

这意味着 PG 会在你犯错时拦住你,而不是默默吞掉错误。刚开始可能觉得"严格",但在生产环境里这是救命的。

2. 可扩展性是核心架构

PG 不是一个"固定功能的数据库",它是一个数据库构建平台。几乎所有东西都可以扩展:

可扩展的维度:
├── 数据类型 → 自定义类型、域类型
├── 函数 → SQL / PL/pgSQL / Python / JavaScript
├── 运算符 → 自定义运算符
├── 索引方法 → GiST / GIN / BRIN / 自定义
├── 外部数据 → FDW(Foreign Data Wrapper)连接外部数据源
└── 扩展 → 插件化安装新能力

Supabase 用到的关键扩展:

扩展 能力
pgvector 向量存储和相似度搜索(AI/RAG)
PostGIS 地理空间数据
pg_cron 定时任务
pg_net 从数据库内部发 HTTP 请求
pgjwt JWT 生成和验证
pg_stat_statements 查询性能分析

3. SQL 是接口,不是实现细节

PG 把 SQL 当作一等公民。不是"支持 SQL",而是"SQL 就是你和数据库交互的唯一正确方式"。

这和 MongoDB 的 "用代码操作数据" 或 Firebase 的 "用 SDK 方法操作数据" 是完全不同的哲学。SQL 是声明式的——你说"要什么",PG 决定"怎么拿"。

4. 多版本并发控制(MVCC)

PG 用 MVCC 处理并发:每个事务看到的是数据的一个快照,读不阻塞写,写不阻塞读。

这意味着:

5. "做好一件事"的 Unix 哲学

PG 只管数据存储和查询,不内置 HTTP 服务、不内置认证、不内置文件存储。Supabase 的架构正是基于这个哲学——用独立的服务(PostgREST、GoTrue、Storage API)各司其职,PG 做它最擅长的事。

快速上手:开发者最常用的 PostgreSQL 知识

以下内容覆盖你在 Supabase 开发中 90% 会用到的 PG 知识。

数据类型选择

别纠结,记住这张表就够了:

场景 用什么类型 不要用
主键 bigint generated always as identity 或 uuid serial(已过时)
短文本 text varchar(n)(PG 里 text 和 varchar 性能一样)
布尔 boolean int 存 0/1
时间 timestamptz(带时区) timestamp(不带时区,坑很多)
金额 numeric(12,2) float(精度丢失)
JSON 数据 jsonb json(不能建索引)
数组 text[]、int[] 建子表(简单场景下数组更方便)
枚举 create type 或 text + check 用数字代表状态
UUID uuid(配合 gen_random_uuid()) text 存 UUID 字符串
-- 实际建表示例
create table products (
  id bigint generated always as identity primary key,
  name text not null,
  price numeric(12, 2) not null check (price >= 0),
  tags text[] default '{}',
  metadata jsonb default '{}',
  is_active boolean default true,
  created_at timestamptz default now()
);

索引:什么时候加,加什么

PG 自动为主键和 unique 约束创建索引。其他情况的经验法则:

-- 经常出现在 WHERE 条件里的列
create index idx_products_is_active on products (is_active) where is_active = true;
-- ↑ 部分索引:只索引 active 的行,更小更快

-- 经常排序的列
create index idx_products_created_at on products (created_at desc);

-- jsonb 字段的查询
create index idx_products_metadata on products using gin (metadata);

-- 全文搜索
alter table products add column fts tsvector
  generated always as (to_tsvector('english', name)) stored;
create index idx_products_fts on products using gin (fts);

-- 查看查询是否用到了索引
explain analyze select * from products where is_active = true;

不要过度索引。每个索引都会拖慢写入速度。先跑起来,用 explain analyze 发现慢查询再加。

关联查询(JOIN)

这是 PG 相比文档数据库的最大优势:

-- 一对多:用户和文章
select
  u.display_name,
  p.title,
  p.created_at
from profiles u
join posts p on p.author_id = u.id
where u.id = auth.uid();

-- 在 Supabase SDK 中等价写法
const { data } = await supabase
  .from('posts')
  .select('title, created_at, author:profiles(display_name)')
-- 多对多:文章和标签
create table tags (
  id bigint generated always as identity primary key,
  name text unique not null
);

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)
);

-- 查询某篇文章的所有标签
select t.name
from tags t
join post_tags pt on pt.tag_id = t.id
where pt.post_id = 1;

数据库函数

当 PostgREST 的自动 API 不够用时,写数据库函数:

-- 简单函数:获取用户的文章统计
create function get_my_stats()
returns json as $
  select json_build_object(
    'total_posts', count(*),
    'latest_post', max(created_at)
  )
  from posts
  where author_id = auth.uid();
$ language sql security definer;
// 前端调用
const { data } = await supabase.rpc('get_my_stats')
// { total_posts: 42, latest_post: "2026-04-13T..." }

security definer vs security invoker:

触发器:数据变更时自动执行逻辑

-- 更新时自动设置 updated_at
create function set_updated_at()
returns trigger as $
begin
  new.updated_at = now();
  return new;
end;
$ language plpgsql;

create trigger trigger_set_updated_at
  before update on posts
  for each row execute function set_updated_at();
-- 插入文章时自动通知(配合 pg_net 发 webhook)
create function notify_new_post()
returns trigger as $
begin
  perform net.http_post(
    url := 'https://your-app.com/api/webhooks/new-post',
    body := json_build_object('post_id', new.id, 'title', new.title)::jsonb
  );
  return new;
end;
$ language plpgsql security definer;

create trigger trigger_notify_new_post
  after insert on posts
  for each row execute function notify_new_post();

视图:简化复杂查询

把常用的复杂查询封装成视图,PostgREST 会自动为视图生成 API 端点:

create view post_details as
select
  p.id,
  p.title,
  p.created_at,
  pr.display_name as author_name,
  coalesce(array_agg(t.name) filter (where t.name is not null), '{}') as tags
from posts p
left join profiles pr on pr.id = p.author_id
left join post_tags pt on pt.post_id = p.id
left join tags t on t.id = pt.tag_id
group by p.id, pr.display_name;
// 前端直接查视图
const { data } = await supabase.from('post_details').select('*')

CTE(公用表表达式):写可读的复杂查询

-- 用 WITH 把复杂查询拆成可读的步骤
with monthly_stats as (
  select
    date_trunc('month', created_at) as month,
    count(*) as post_count
  from posts
  where author_id = auth.uid()
  group by 1
),
running_total as (
  select
    month,
    post_count,
    sum(post_count) over (order by month) as cumulative
  from monthly_stats
)
select * from running_total order by month desc;

常用的内置函数

-- 时间处理
now()                                    -- 当前时间
age(now(), created_at)                   -- 时间差
date_trunc('month', created_at)          -- 截断到月
created_at + interval '7 days'           -- 加7天

-- 文本处理
lower(name)                              -- 转小写
concat(first_name, ' ', last_name)       -- 拼接
split_part(email, '@', 2)                -- 按分隔符取部分
left(content, 200)                       -- 截取前200字符

-- JSON 操作
metadata->>'key'                         -- 取 jsonb 字段的文本值
metadata->'nested'->'deep'               -- 取嵌套值
jsonb_set(metadata, '{key}', '"value"')  -- 设置值
metadata ? 'key'                         -- 检查 key 是否存在

-- 聚合
count(*), sum(amount), avg(score)
array_agg(name)                          -- 聚合成数组
string_agg(name, ', ')                   -- 聚合成逗号分隔字符串
json_agg(row_to_json(t))                -- 聚合成 JSON 数组

性能排查三板斧

-- 1. 看查询计划
explain analyze
select * from posts where author_id = 'xxx';
-- 关注:Seq Scan(全表扫描)vs Index Scan(索引扫描)

-- 2. 看慢查询(需要开启 pg_stat_statements 扩展)
select query, calls, mean_exec_time, total_exec_time
from pg_stat_statements
order by mean_exec_time desc
limit 10;

-- 3. 看表大小和索引大小
select
  relname as table_name,
  pg_size_pretty(pg_total_relation_size(relid)) as total_size
from pg_catalog.pg_statio_user_tables
order by pg_total_relation_size(relid) desc;

PG 思维 vs NoSQL 思维:转换指南

如果你从 MongoDB/Firebase 过来,需要转换一些思维习惯:

NoSQL 思维 PG 思维
把关联数据嵌套在文档里 拆成独立表,用外键关联
为每个查询模式设计数据结构 设计一次范式化结构,用 SQL 灵活查询
数据冗余换读取性能 用 JOIN 和索引换读取性能
schema 随意变更 用迁移文件管理 schema 变更
应用层保证数据一致性 数据库层用约束和事务保证
读多写少优化 读写都能优化

一个实际例子——电商订单:

MongoDB 做法:
{
order_id: "xxx",
user: { name: "张三", email: "..." }, // 冗余嵌入用户信息
items: [
{ product: { name: "键盘", price: 299 }, qty: 1 }, // 冗余嵌入商品信息
],
total: 299
}

PostgreSQL 做法:
orders (id, user_id → users, total, created_at)
order_items (id, order_id → orders, product_id → products, qty, unit_price)

PG 的做法看起来"麻烦",但好处是:

速查:Supabase 开发中最常用的 SQL 模式

-- 分页
select * from posts order by created_at desc limit 20 offset 40;

-- 模糊搜索
select * from posts where title ilike '%关键词%';

-- 全文搜索(更高效)
select * from posts where fts @@ to_tsquery('english', 'keyword');

-- 软删除
alter table posts add column deleted_at timestamptz;
create policy "隐藏已删除" on posts for select using (deleted_at is null);

-- 乐观锁
update posts set title = '新标题', version = version + 1
where id = 1 and version = 3;  -- version 不匹配则更新 0 行

-- 批量 upsert
insert into products (id, name, price)
values (1, 'A', 10), (2, 'B', 20)
on conflict (id) do update set
  name = excluded.name,
  price = excluded.price;

掌握这些 PostgreSQL 基础,你在 Supabase 上的开发会顺畅很多。记住核心原则:信任数据库,把逻辑下沉,让 PG 做它最擅长的事。