速查总览:这一页怎么查
本章是全页的索引:章节地图告诉你哪个话题在哪一章,定位与选型回答「什么时候该选 PostgreSQL」,版本年表与托管方案解决查资料时最费时的一件事——你要用的这个语法,从哪个大版本才有、你手上那个托管服务给的又是哪一版。
全页十九章,按「查得对 → 查得快 → 用得住 → 接进系统」排列。不必顺读——按关键词定位,翻该章第一张速查表。
章节地图
| 章 | 查什么 | 关键词 |
|---|---|---|
| 01 | 装与连、psql 元命令全表、最小 SQL 骨架、按 SQLSTATE 反查报错 | psql · \d · 42P01 |
| 02 | SELECT 子句与执行顺序、过滤与模糊匹配、数据类型与约束、增删改与 ALTER TABLE | WHERE · LIKE · CHECK |
| 03 | NULL 与三值逻辑:比较、NOT IN 陷阱、聚合与排序里的 NULL | IS NULL · COALESCE |
| 04 | 五种 JOIN、ON 与 WHERE 的区别、半连接与反连接怎么选 | LEFT JOIN · EXISTS |
| 05 | 聚合函数全表、GROUP BY 与 HAVING、FILTER、ROLLUP / GROUPING SETS | count(*) · HAVING |
| 06 | 窗口函数全表、OVER 的三段、窗口帧 ROWS vs RANGE | row_number · LAG |
| 07 | 子查询三种形式、WITH 与递归 CTE、LATERAL | WITH RECURSIVE |
| 08 | 排序的 tiebreaker、OFFSET 为什么越翻越慢、keyset 分页 | ORDER BY · seek |
| 09 | 六种索引类型怎么选、复合索引的列序、EXPLAIN 怎么读 | B-tree · GIN · buffers |
| 10 | 事务与 SAVEPOINT、四个隔离级别与各自放过什么、锁与死锁 | BEGIN · SERIALIZABLE |
| 11 | JSONB 运算符与函数全表、数组操作、json 与 jsonb 的区别 | ->> · @> · GIN |
| 12 | 视图与物化视图、自定义函数与存储过程 | MATERIALIZED · plpgsql |
| 13 | 参数化查询、连接池、事务与错误在代码里怎么写 | $1 · pool · retry |
| 14 | pg_dump 与物理备份、迁移脚本、上线后看哪些指标 | pg_dump · WAL |
| 15 | UPSERT 与 MERGE、全文搜索、角色权限与行级安全 | ON CONFLICT · RLS |
| 16 | 通用速查:任务反查、psql 元命令、数据类型、常用函数 | 反查表 |
| 17 | psql 本身、图形客户端、自动生成的后台各把权限交给了谁 | pgAdmin · PostgREST |
| 18 | 动手项目、资料索引、自测标准 | 官方文档 · 练手库 |
三条最常见的查法
| 你手上有的 | 去哪 |
|---|---|
一条报错(relation "x" does not exist、42P01…) | 01 章的 SQLSTATE 反查表 |
| 「这个查询为什么慢」 | 09 章:先 EXPLAIN (ANALYZE, BUFFERS),再对照索引选型表;分页慢单独看 08 章 |
| 「结果比预期少了几行 / 多了几行」 | 03 章 NULL 与 04 章 JOIN——九成的「数据不对」出在这两处 |
-- 一条查询里能同时用到本页大半章节
WITH recent AS ( -- 07 章 CTE
SELECT user_id, amount, created_at
FROM orders
WHERE created_at >= now() - '30 days'::interval -- 02 章
)
SELECT u.name,
count(*) AS orders, -- 05 章聚合
sum(r.amount) AS total,
rank() OVER (ORDER BY sum(r.amount) DESC) AS rk -- 06 章窗口
FROM users u
JOIN recent r ON r.user_id = u.id -- 04 章 JOIN
GROUP BY u.id, u.name
HAVING count(*) > 1
ORDER BY total DESC, u.id -- 08 章:带 tiebreaker
LIMIT 20;LIMIT ... OFFSET、ILIKE、|| 拼接在各家行为不同;MERGE 要 PG 15+、JSON_TABLE 要 17+、uuidv7() 要 18+。照抄 Stack Overflow 上的答案之前先看日期与标签(见本章「版本年表与托管方案速查」)。RETURNING、ON CONFLICT、DISTINCT ON、:: 转换、数组与 JSONB 运算符)——这些在 MySQL / SQLite 上多半不能用,跨库的 SQL 要另外挑写法。PostgreSQL 是开源关系数据库的「默认之选」:可靠性(WAL + MVCC)、SQL 标准符合度、可扩展性三者兼得——类型、索引、扩展三个层面全部开放,数据库本身就是一个平台。
今天的版图
- 扩展把一个库变成多个库:pgvector(向量检索)、PostGIS(地理空间的行业标准)、TimescaleDB(时序)——很多「专用数据库」的场景一个 PG 就够;
- 它凭什么可靠:WAL 保证崩溃可恢复、MVCC 让读写互不阻塞,连建表加列都能回滚(事务性 DDL),迁移脚本出错不留半成品。
和邻居怎么选
- vs MySQL:PG 胜在功能深度,MySQL 胜在存量生态与运维人才密度——新项目没有历史包袱时优先 PG;
- vs SQLite:这是服务端与嵌入式的分工,不是竞争;
- vs NoSQL:先问「真需要放弃事务吗」,JSONB 已覆盖大半文档库场景。
-- 一段很「PG」的查询:CTE + JSONB + 窗口函数同框
-- (先睹为快,现在看不懂没关系:CTE 见 07 章、窗口函数见 06 章、JSONB 见 11 章)
WITH ranked AS (
SELECT name,
profile->>'city' AS city, -- JSONB 取字段
rank() OVER (
PARTITION BY profile->>'city'
ORDER BY score DESC
) AS rnk
FROM players
)
SELECT * FROM ranked WHERE rnk <= 3; -- 各城市前三名PostgreSQL 每年九月发一个大版本,每个版本维护五年。查语法前先确认版本——很多「PG 能做」的事其实是某一版之后才能做。
版本年表
| 版本 | 年份 | 带来了什么 |
|---|---|---|
| 9.4 | 2014 | jsonb、逻辑解码——PG 能当文档库用就是从这里开始的 |
| 9.5 | 2016 | INSERT … ON CONFLICT(UPSERT)、GROUPING SETS、行级安全 |
| 9.6 | 2016 | 并行查询 |
| 10 | 2017 | 逻辑复制、声明式分区;版本号从三段改成两段 |
| 11 | 2018 | 分区大幅改进、JIT、存储过程(CALL,可在内部提交事务) |
| 12 | 2019 | 生成列、SQL/JSON path(jsonb_path_query) |
| 13 | 2020 | B-tree 去重(索引显著变小)、增量排序 |
| 14 | 2021 | 多范围类型、jsonb['key'] 下标语法 |
| 15 | 2022 | MERGE;public schema 默认不再允许所有人建表——升级后「权限突然不够」多半是这条 |
| 16 | 2023 | 从备库做逻辑复制、并行与聚合改进 |
| 17 | 2024 | pg_basebackup 增量备份、JSON_TABLE、MERGE … RETURNING、VACUUM 内存管理重写 |
| 18 | 2025 | 异步 I/O、uuidv7()、虚拟生成列、B-tree skip scan、OAuth 认证 |
问版本:SELECT version(); 或 psql 里 \conninfo;只要数字用 SHOW server_version;。
自建还是托管
| 方案 | 你还要管什么 | 要注意 |
|---|---|---|
| 自建(裸机 / 容器) | 备份、升级、监控、高可用全归你 | 本地开发用 Docker 一行起(01 章) |
| RDS / Cloud SQL / Azure Database | 只管 schema 与查询 | 多数不给超级用户,装扩展要看白名单;大版本升级有停机窗口 |
| Aurora PostgreSQL | 同上 | 是兼容实现而不是 PG 本体,少数行为与版本特性有出入 |
| Supabase / Neon | 同上 | 自带 REST / 认证 / 分支;Neon 的存算分离可以按分支开库(17 章) |
| Citus / TimescaleDB | — | 扩展而非分叉:分布式 / 时序能力,SQL 仍是 PG 的 |
| CockroachDB / YugabyteDB | — | 只兼容协议与大部分语法,不是 PG,扩展与部分函数不可用 |
值得知道的扩展
| 扩展 | 装法 | 做什么 |
|---|---|---|
pg_stat_statements | CREATE EXTENSION pg_stat_statements;(还要配 shared_preload_libraries) | 找出最慢 / 最频繁的查询——上线必装(14 章) |
pgvector | CREATE EXTENSION vector; | 向量相似度检索,RAG 场景 |
PostGIS | CREATE EXTENSION postgis; | 地理空间——这一个就是很多团队选 PG 的全部理由 |
pg_trgm | CREATE EXTENSION pg_trgm; | 模糊匹配与给 LIKE '%x%' 加索引(09 章) |
pg_cron | CREATE EXTENSION pg_cron; | 库内定时任务 |
| 查已装的 | \dx(psql)· SELECT * FROM pg_available_extensions; | — |
public schema 权限、10 的版本号格式这类改动确实会让老脚本直接失败。起步 · psql · 报错速查
本章覆盖:三条装 PG 的路与连接串写法、psql 元命令全表、建表到查询的最小骨架,以及按 SQLSTATE 与报错原文反查——PG 的每条错误都有一个五位错误码,认它比读英文句子快得多。
PG 的日常入口是 psql。它的元命令(反斜杠开头,不是 SQL)是查库结构最快的方式——下面第二张表值得记住一半。
装与起
| 方式 | 怎么写 | 适合 |
|---|---|---|
| Docker | docker run -d --name pg -e POSTGRES_PASSWORD=pw -p 5432:5432 postgres:18 | 本地开发的默认选择:版本随便换,删了重来零成本 |
| 包管理器 | apt install postgresql · brew install postgresql@18 | 要开机自启、当常驻服务 |
| 托管 | 厂商控制台开一个实例 | 生产;版本与可装扩展由厂商定(00 章) |
| 连上 | psql -h localhost -U postgres -d mydb | 没有 -d 时默认连同名库 |
| Docker 内连 | docker exec -it pg psql -U postgres | 不用在宿主装客户端 |
连接串与环境变量
| 写法 | 说明 |
|---|---|
postgresql://user:pw@host:5432/dbname | URI 形式,应用与 psql 都认 |
?sslmode=require | 拼在 URI 后面;连公网实例必加(verify-full 更严) |
PGHOST PGPORT PGUSER PGPASSWORD PGDATABASE | 环境变量,省得每次敲参数 |
~/.pgpass | host:port:db:user:password,权限必须是 600 否则被忽略 |
psql "$DATABASE_URL" | 直接把 URI 当参数,最省事 |
| 特殊字符 | 密码里有 @ / : 时必须 URL 编码,否则解析出错 |
psql 元命令
| 命令 | 做什么 |
|---|---|
\l | 列出所有数据库 |
\c dbname | 切换数据库 |
\dt · \dt+ | 列出表;+ 版带大小与描述 |
\d tablename | 看一张表的列、类型、索引、约束、外键——最常用的一条 |
\d+ tablename | 再加上存储、统计目标与注释 |
\di · \dv · \df · \dn · \du · \dx | 索引 / 视图 / 函数 / schema / 角色 / 扩展 |
\dt schema.* | 只看某个 schema 的表 |
\x · \x auto | 竖排显示——列多到换行时救命 |
\timing | 打开每条语句的耗时 |
\e | 用编辑器改上一条 SQL |
\i file.sql · \o out.txt | 执行脚本 / 把输出重定向到文件 |
\copy t FROM 'a.csv' CSV HEADER | 客户端侧导入——不需要服务端文件权限,与 SQL 的 COPY 不同 |
\set AUTOCOMMIT off | 关掉自动提交 |
\? · \h SELECT | 元命令帮助 / 某条 SQL 的语法 |
\q | 退出(也可 Ctrl-D) |
最小骨架
| 要做的事 | 怎么写 |
|---|---|
| 建库 | CREATE DATABASE shop;——不能在事务里执行 |
| 建表 | CREATE TABLE users (id bigserial PRIMARY KEY, name text NOT NULL, created_at timestamptz DEFAULT now()); |
| 插入并拿回自增主键 | INSERT INTO users (name) VALUES ('a') RETURNING id;——RETURNING 是 PG 的招牌 |
| 批量插入 | INSERT INTO users (name) VALUES ('a'), ('b'), ('c'); |
| 查询 | SELECT * FROM users WHERE name LIKE 'a%' ORDER BY id LIMIT 10; |
| 改与删 | UPDATE users SET name = 'b' WHERE id = 1; · DELETE FROM users WHERE id = 1; |
| 看耗时与计划 | EXPLAIN (ANALYZE, BUFFERS) SELECT …;(09 章) |
| 结尾分号 | psql 里不写分号就不会执行——提示符从 => 变成 -> 就是在等你补 |
-- 从零到第一条查询:五条命令
$ docker run -d --name pg -e POSTGRES_PASSWORD=pw -p 5432:5432 postgres:18
$ docker exec -it pg psql -U postgres
postgres=# CREATE DATABASE shop;
postgres=# \c shop
shop=# CREATE TABLE users (
id bigserial PRIMARY KEY,
name text NOT NULL,
created_at timestamptz DEFAULT now()
);
shop=# INSERT INTO users (name) VALUES ('alice'), ('bob') RETURNING id;
id
----
1
2
shop=# \d users -- 看结构,比 SELECT 更常用
shop=# SELECT * FROM users ORDER BY id;\copy 和 COPY 是两个东西:前者是 psql 元命令,读你本机的文件;后者是 SQL 语句,读服务端的文件、而且需要超级用户或 pg_read_server_files 权限。连托管实例导 CSV 时只有 \copy 能用——报 must be superuser 就是写成了后者。\d 系列支持通配:\dt user* 列出所有以 user 开头的表。配上 \x auto(列多时自动竖排)与 \timing(每条都报耗时),psql 的日常体验比多数图形客户端更快(17 章)。PG 的每条错误都带一个五位 SQLSTATE。前两位是大类,认了大类就知道该往哪个方向查——应用代码里也该按码判断而不是按消息文本。
大类速记
| 前缀 | 大类 | 典型 |
|---|---|---|
23 | 完整性约束冲突 | 23505 唯一键重复 · 23503 外键 · 23502 非空 · 23514 CHECK |
42 | 语法错误或访问规则冲突 | 42P01 表不存在 · 42703 列不存在 · 42601 语法错误 · 42501 权限不足 |
22 | 数据异常 | 22P02 输入语法非法 · 22001 字符串超长 · 22012 除零 |
40 | 事务回滚 | 40001 序列化失败(应用要重试)· 40P01 死锁 |
08 | 连接异常 | 08006 连接失败 · 08P01 协议违规 |
53 | 资源不足 | 53300 连接数满 · 53100 磁盘满 |
57 | 操作干预 | 57014 查询被取消 / 超时 |
25 | 事务状态非法 | 25P02 当前事务已中止,后续语句全被忽略 |
报错反查
| 报错原文 | 码 | 原因与改法 |
|---|---|---|
relation "users" does not exist | 42P01 | 表名拼错、没在同一个 schema、或者建表时用了双引号大写名。查 \dt 与 SHOW search_path; |
column "userName" does not exist | 42703 | PG 把未加引号的标识符一律折成小写——建表写了 "userName" 就必须永远带双引号。一律用 snake_case |
syntax error at or near "…" | 42601 | 真正的错误常在报错位置之前:漏逗号、多逗号、关键字当了列名 |
duplicate key value violates unique constraint | 23505 | 唯一键冲突。想「有就更新」用 ON CONFLICT(15 章) |
insert or update … violates foreign key constraint | 23503 | 引用的行不存在,或删父行时子行还在——看是否该加 ON DELETE CASCADE |
null value in column "x" violates not-null constraint | 23502 | 没给值且没有默认值 |
invalid input syntax for type integer: "abc" | 22P02 | 类型不匹配;常见于字符串拼 SQL——改参数化(13 章) |
operator does not exist: text = integer | 42883 | PG 不做隐式类型转换。显式写 ::int 或 ::text |
permission denied for table users | 42501 | GRANT 没给;PG 15 起 public schema 默认不让普通用户建表(15 章) |
deadlock detected | 40P01 | 两个事务加锁顺序相反。统一按固定顺序访问行(10 章) |
could not serialize access due to concurrent update | 40001 | 可重复读 / 可串行化下的正常结果——应用必须准备重试(10、13 章) |
current transaction is aborted, commands ignored until end of transaction block | 25P02 | 事务里有一条失败了还在继续发。回滚到 SAVEPOINT 或整个 ROLLBACK(10 章) |
sorry, too many clients already | 53300 | 连接数打满——应用侧缺连接池(13 章) |
canceling statement due to statement timeout | 57014 | 超过 statement_timeout;先看执行计划而不是直接调大超时(09 章) |
could not connect to server | 08006 | 端口 / 防火墙 / pg_hba.conf 没放行 / 容器没映射端口 |
查不动时看哪里
| 手段 | 怎么做 |
|---|---|
| 看完整错误 | psql 里 \set VERBOSITY verbose——会连 SQLSTATE 与源码位置一起打出来 |
| 查语法 | \h INSERT,比翻文档快 |
| 看当前在跑什么 | SELECT pid, state, wait_event_type, query FROM pg_stat_activity WHERE state != 'idle'; |
| 被谁锁住了 | SELECT * FROM pg_locks WHERE NOT granted;(10 章) |
| 杀掉一个查询 | SELECT pg_cancel_backend(pid);,不行再 pg_terminate_backend(pid) |
| 服务端日志 | 容器里 docker logs pg;自建看 log_directory。连不上的问题只有日志说得清 |
| 慢查询 | pg_stat_statements 按总耗时排序(00、14 章) |
-- 应用里按错误码判断,而不是按消息文本(消息会随版本与语言变)
DO $$
BEGIN
INSERT INTO users (id, name) VALUES (1, 'dup');
EXCEPTION
WHEN unique_violation THEN -- 等价于 SQLSTATE '23505'
RAISE NOTICE '已存在,跳过';
END $$;
-- psql 里把错误码也打出来
shop=# \set VERBOSITY verbose
shop=# SELECT * FROM nope;
ERROR: 42P01: relation "nope" does not exist
LINE 1: SELECT * FROM nope;
^
LOCATION: parserOpenTable, parse_relation.c:144925P02 并被忽略,直到你 ROLLBACK。这在批量导入脚本里最容易踩:第 3 行出错,后面 997 行全部「静默失败」,而每条的报错都是同一句「事务已中止」。要跳过个别失败就用 SAVEPOINT,或者干脆一条一个事务。lc_messages 变成别的语言。各语言驱动都把错误码暴露成字段(Python e.pgcode、Node err.code、Go pgErr.Code)。PG 的报错质量很高——它几乎总会告诉你哪一行、哪个约束、期望什么。学会读它比记住语法更省时间。
一条报错的三个部分
- 错误码(SQLSTATE):五位码如
23505。搜索时带上它比搜中文描述准得多,因为它标准化、不随语言和版本变; - 消息主体通常带上具体的约束名,而约束名里就含着表名和列名;
- DETAIL / HINT——PG 经常直接告诉你怎么改,很多人忽略 HINT 那行,其实答案往往就在里面。
看首两位就能定性
42xxx = SQL 写错了(语法、表名列名不存在、权限)→ 改 SQL;23xxx = 违反约束(主键重复、外键找不到、非空)→ 数据有问题,或业务逻辑该处理这种情况;22xxx = 数据或类型有问题(转换失败、除零、超长);40xxx = 事务冲突(死锁、序列化失败)→ 这类应该重试,不是 bug。
-- 七个最常撞的,照着认即可
-- 42P01 relation "userz" does not exist
-- 表名拼错,或建表时加了双引号带大写
-- 42703 column "nosuch" does not exist
-- 列名拼错;也可能是把字符串写成了双引号,"Alice" 被当成列名
-- 42601 syntax error at or near "email"
-- 语法错。看 at or near 指的词,问题通常在它前面(比如漏了逗号)
-- 23505 duplicate key value violates unique constraint "users_email_key"
-- 唯一约束冲突。约束名直接告诉你是 users 表的 email 列
-- 23503 insert or update on table "orders" violates foreign key constraint
-- 外键指向的行不存在,或删父行时还有子行引用着
-- 23502 null value in column "name" of relation "users" violates not-null constraint
-- 插入时漏了必填列
-- 22P02 invalid input syntax for type integer: "abc"
-- 类型不匹配,把字符串塞给了 int 列
-- 让 psql 显示错误码本身(默认不显示)
\set VERBOSITY verbosesyntax error at or near "email" 往往意味着问题出在 email 前面那一段(最常见的是漏了逗号)。表名_列名_key / _pkey / _fkey,已经很有用;自己命名时沿用这个风格。基础查询
本章覆盖:SELECT 各子句与执行顺序(FROM → WHERE → GROUP BY → HAVING → 窗口 → SELECT → DISTINCT → ORDER BY → LIMIT)、过滤运算符全表、常用数据类型与选型、约束与 ALTER TABLE。执行顺序这条链解释了后面很多「为什么别名在这里不能用」。
先记牢一条贯穿全页的铁律:SQL 的执行顺序不是书写顺序——FROM → WHERE → GROUP BY → HAVING → 窗口函数 → SELECT → DISTINCT → ORDER BY → LIMIT。SELECT 里起的别名在 WHERE 里用不了,就是因为它还没执行到。
SELECT 的各段
| 子句 | 怎么写 | 要点 |
|---|---|---|
SELECT | SELECT id, name AS 姓名, price * 2 AS doubled | 别名可省 AS;含空格或大小写敏感时用双引号 |
DISTINCT | SELECT DISTINCT city FROM users; | 整行去重 |
DISTINCT ON | SELECT DISTINCT ON (user_id) * FROM orders ORDER BY user_id, created_at DESC; | PG 专有:每组取一行——ORDER BY 必须以 DISTINCT ON 的列开头 |
FROM | FROM orders o JOIN users u ON u.id = o.user_id | 04 章 |
WHERE | WHERE status = 'paid' AND amount > 100 | 分组前过滤行;不能用聚合函数 |
GROUP BY / HAVING | GROUP BY user_id HAVING count(*) > 1 | HAVING 分组后过滤(05 章) |
ORDER BY | ORDER BY created_at DESC NULLS LAST, id | 一定要有唯一列兜底,否则翻页会乱(08 章) |
LIMIT / OFFSET | LIMIT 20 OFFSET 40 | 深翻页会越来越慢(08 章) |
过滤运算符
| 要做的事 | 怎么写 |
|---|---|
| 比较 | = <>(或 !=) < > <= >= |
| 区间(含两端) | WHERE age BETWEEN 18 AND 65 |
| 枚举 | WHERE status IN ('paid', 'shipped')——值里可能有 NULL 时慎用 NOT IN(03 章) |
| 模糊匹配 | WHERE name LIKE 'a%' · 不区分大小写用 ILIKE(PG 专有) |
| 正则 | WHERE name ~ '^a.*z$' · ~* 不区分大小写 · !~ 取反 |
| 判空 | WHERE deleted_at IS NULL——永远不能写 = NULL(03 章) |
| 两值都可能为空的相等 | WHERE a IS NOT DISTINCT FROM b——把 NULL 当普通值比 |
| 存在性 | WHERE EXISTS (SELECT 1 FROM …)(04、07 章) |
| 数组包含 | WHERE tags @> ARRAY['a'](11 章) |
| 时间范围 | WHERE created_at >= now() - interval '7 days' |
常用数据类型
| 用途 | 类型 | 要点 |
|---|---|---|
| 整数 | int · bigint · smallint | 主键默认上 bigint,回头改很贵 |
| 自增主键 | bigint GENERATED ALWAYS AS IDENTITY(标准写法)或 bigserial | serial 是老写法,PG 10 起推荐 IDENTITY |
| 金额 | numeric(12,2) | 绝不能用 float 存钱——0.1 + 0.2 <> 0.3 |
| 浮点 | real · double precision | 科学计算才用 |
| 文本 | text | PG 里 text 与 varchar 性能相同;不需要长度限制就用 text,要限制用 CHECK |
| 时间 | timestamptz · date · interval | 永远用带时区的 timestamptz;timestamp 不记时区,跨区必错 |
| 布尔 | boolean | 取值 true / false / NULL(03 章) |
| 标识 | uuid | PG 18 起有 uuidv7()——按时间有序,比 v4 对索引友好 |
| 半结构化 | jsonb · text[] | 11 章 |
| 枚举 | CREATE TYPE status AS ENUM ('a','b'); | 加值容易删值难;候选少且稳定才用,否则用 text + CHECK |
| 区间 | tstzrange · int4range | 配 EXCLUDE 约束可做「时间段不重叠」 |
约束与改表
| 要做的事 | 怎么写 |
|---|---|
| 主键 / 唯一 / 非空 | PRIMARY KEY · UNIQUE · NOT NULL |
| 默认值 | DEFAULT now() · DEFAULT gen_random_uuid() |
| 取值校验 | CHECK (amount >= 0) · CHECK (status IN ('a','b')) |
| 外键 | REFERENCES users(id) ON DELETE CASCADE——选项还有 RESTRICT / SET NULL |
| 生成列 | total numeric GENERATED ALWAYS AS (price * qty) STORED |
| 加列 | ALTER TABLE t ADD COLUMN c text;——PG 11 起加带默认值的列不再重写全表 |
| 改类型 | ALTER TABLE t ALTER COLUMN c TYPE bigint;——会重写表并加排他锁,大表要挑窗口(14 章) |
| 加约束不锁表 | ADD CONSTRAINT … CHECK (…) NOT VALID; 再 VALIDATE CONSTRAINT——两步走 |
| 重命名 | ALTER TABLE t RENAME COLUMN a TO b; |
| 清空 | TRUNCATE t;——比 DELETE 快得多但不走行级触发器,且隐式提交前的行锁不同 |
CREATE TABLE Users 建出来的表名其实是 users,而 CREATE TABLE "Users" 之后每一次引用都必须带双引号,漏一次就是 relation "Users" does not exist。规则:表名列名一律 snake_case,永远别加双引号。bigint、时间用 timestamptz、金额用 numeric。这三处选错都不会立刻出问题,而是在数据量或用户地域扩大之后才爆——那时候改要停机。查询的基本骨架只有四件事,但其中两个坑(NULL 不能用 = 比、别名在 WHERE 里用不了)会跟着你到最后一章。
四个子句与两个坑
查询的基本骨架。WHERE 过滤行,ORDER BY 排序,LIMIT/OFFSET 分页。注意 NULL 不能用 = 判断,必须用 IS NULL。ILIKE 是 PostgreSQL 特有的不区分大小写匹配。
SELECT id, name, email, created_at
FROM users
WHERE created_at >= '2025-01-01'
AND status = 'active'
AND (role = 'admin' OR role = 'editor')
ORDER BY created_at DESC
LIMIT 20 OFFSET 0; -- 分页:第1页
-- 常用过滤操作符
WHERE age BETWEEN 18 AND 65
WHERE status IN ('active', 'pending')
WHERE email LIKE '%@gmail.com' -- 区分大小写
WHERE name ILIKE '%alice%' -- 不区分大小写(PG特有)
WHERE deleted_at IS NULL -- 不能用 = NULL
-- DISTINCT / DISTINCT ON
SELECT DISTINCT country FROM users;
SELECT DISTINCT ON (user_id) * FROM orders
ORDER BY user_id, created_at DESC;
-- ↑ 每个用户只取最新一条订单(PG 特有便利写法)
-- CASE 表达式
SELECT name,
CASE
WHEN age < 18 THEN '未成年'
WHEN age < 65 THEN '成年'
ELSE '老年'
END AS age_group
FROM users;WHERE status != 'banned' 不会返回 status 为 NULL 的行!NULL 参与任何比较运算结果都是 NULL(非 true),需要额外写 OR status IS NULL,或者用 NULL 安全的 IS DISTINCT FROM。三值逻辑的完整规则、以及「NOT IN 遇 NULL 返回空集」这个更贵的坑,见 03 章。SELECT * 只适合手敲探索,写进代码要显式列出列名。三个理由:加列时结果形状会悄悄变化、传输了用不到的列(宽表上很可观)、以及它让「索引覆盖」失效——只查索引里已有的那几列时 PG 可以走 Index Only Scan 完全不回表,一旦 SELECT * 就必须回表拿完整行。ILIKE 是 PG 扩展(大小写不敏感的 LIKE),方便但用不上普通索引;高频的大小写不敏感查找应该建 lower(col) 表达式索引,或者改用 citext 类型。建表时选错类型,后面所有查询都要为它买单——尤其是金额用不用 numeric、时间带不带时区这两个决定。
选类型的四条硬规矩
定义表结构时选择正确的数据类型和约束是数据建模的基础。货币用 numeric,不要用 float;时间用 timestamptz 避免时区 bug;字符串一律用 text——PG 里 text 和 varchar(n) 存储与性能完全相同,varchar(n) 唯一的作用是加一条长度上限,超了直接报 22001 value too long;真要限长,用 CHECK (length(x) <= n) 更好,因为改上限只是改约束,不必 ALTER TYPE。char(n) 则任何时候都别用,它会把值补空格到定长。数组(text[])与 jsonb 这两个「非标量」类型的查询与索引,第 11 章专门展开。
CREATE TABLE products (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
sku varchar(50) UNIQUE NOT NULL,
name text NOT NULL,
price numeric(10,2) NOT NULL CHECK (price >= 0),
stock integer DEFAULT 0,
category_id int REFERENCES categories(id) ON DELETE SET NULL,
tags text[], -- 数组类型
metadata jsonb DEFAULT '{}', -- JSON类型
is_active boolean DEFAULT TRUE,
created_at timestamptz DEFAULT now(), -- 带时区时间戳(推荐)
CONSTRAINT valid_sku CHECK (sku ~ '^[A-Z0-9-]+$')
);
-- 常用类型速查
-- text / varchar(n) 字符串(PG 中性能无差)
-- integer / bigint 整数
-- numeric(p,s) 精确小数(货币必用)
-- boolean true/false
-- timestamptz 带时区时间(强烈推荐)
-- uuid 全局唯一标识符
-- jsonb 二进制JSON(支持索引)
-- text[] 数组
CREATE TYPE order_status AS ENUM ('pending', 'paid', 'shipped', 'cancelled');另外,
ON DELETE 的四种行为差别很大,默认那个最容易出意外:NO ACTION(默认)和 RESTRICT 都是阻止删除;CASCADE 会连带删掉子行——用在订单明细上合理,用在「删用户连带删所有订单」上就是灾难;SET NULL 把外键置空,适合「分类被删,商品仍保留」。建外键时一定要显式想一遍这个选择,别让默认值替你决定。bigint GENERATED ALWAYS AS IDENTITY 更稳,确需全局唯一时优先时间有序的 UUIDv7。时间永远用 timestamptz,字符串直接用 text(除非有明确长度业务约束)。三个写操作加一个改表结构。PG 的 RETURNING 值得单独记住:它让「写完再查一次」这一步彻底消失。
三个写操作与 RETURNING
INSERT 写入新行,UPDATE 修改已有行,DELETE 删除行。三者都支持 PG 的 RETURNING 子句——直接返回受影响的行,省去一次回查。ALTER TABLE 修改已有表结构(加列、改名、加约束)。
-- INSERT:单行 / 多行插入
INSERT INTO users (name, email)
VALUES ('Alice', 'alice@example.com'),
('Bob', 'bob@example.com')
RETURNING id, created_at; -- 直接拿回生成的主键等字段
-- UPDATE:修改已有行(WHERE 决定改哪些行)
UPDATE products
SET price = price * 0.9, updated_at = now()
WHERE category_id = 3
RETURNING id, price;
-- DELETE:删除行
DELETE FROM sessions
WHERE expires_at < now()
RETURNING id;
-- ALTER TABLE:修改表结构
ALTER TABLE users ADD COLUMN phone text;
ALTER TABLE users RENAME COLUMN phone TO mobile;
ALTER TABLE users ALTER COLUMN mobile SET NOT NULL;
ALTER TABLE users DROP COLUMN mobile;
-- PG 的 DDL 是事务性的:以上语句都可以放进事务回滚(见 10 章)UPDATE/DELETE 会作用于全表,执行前没有任何确认。习惯做法:先用同样的 WHERE 条件跑一遍 SELECT 预览受影响的行,或包在事务里(BEGIN → 执行 → 核对行数 → COMMIT/ROLLBACK)。RETURNING 是 PG 的一大便利,三种写操作都支持。插入拿自增 id、更新拿改后的值、删除拿被删的行,都不用再查一次——既省一次往返,又天然没有「查到的和改的不是同一份」的竞态。批量插入用一条多值
INSERT(VALUES (...), (...), (...))而不是循环单条:一万次单条插入是一万次网络往返,改成一批几百到几千行能快一个数量级;再大就该用 \copy。翻页时
ORDER BY 一定要带一个唯一列做 tiebreaker,否则会重复或漏行——连同深分页的代价,见 08 章。NULL 与三值逻辑
本章覆盖:三值逻辑真值表、判空与比较的写法、NOT IN 为什么会返回空集、聚合 / 排序 / 唯一约束 / 索引里 NULL 各自的行为。NULL 是「不知道」而不是空串或零——「查出来的行数不对」九成能在本章找到原因。
NULL 是「不知道」,不是空串也不是零。由此派生出的规则散落在 SQL 的每个角落——下面按位置一次列齐,「查出来的行数不对」九成能在这张表里找到原因。
三值逻辑
| 表达式 | 结果 | 为什么 |
|---|---|---|
NULL = NULL | NULL | 两个「不知道」是否相等,还是不知道 |
NULL <> 1 | NULL | 所以 WHERE status <> 'a' 会漏掉 status 为 NULL 的行 |
NULL AND false | false | 有一个假就是假 |
NULL AND true | NULL | — |
NULL OR true | true | 有一个真就是真 |
NOT NULL | NULL | — |
WHERE 只放行 | true | false 与 NULL 都被过滤掉——这是「少行」的总根源 |
怎么判、怎么比
| 要做的事 | 怎么写 |
|---|---|
| 判空 | x IS NULL · x IS NOT NULL |
| 把 NULL 当普通值比较 | a IS NOT DISTINCT FROM b(相等,含两边都 NULL)· IS DISTINCT FROM |
包含 NULL 的 <> | WHERE status IS DISTINCT FROM 'a'——这才会带上 NULL 的行 |
| 给默认值 | COALESCE(x, 0)——返回第一个非 NULL 的参数 |
| 反向:把某值变成 NULL | NULLIF(x, '')——清洗空串的常用手法 |
| 条件表达式 | CASE WHEN x IS NULL THEN '未知' ELSE x END |
| 拼接 | 'a' || NULL 得 NULL;要安全拼用 concat('a', NULL)(它跳过 NULL) |
NOT IN 的陷阱
| 写法 | 子查询含 NULL 时 |
|---|---|
WHERE id NOT IN (SELECT user_id FROM orders) | 返回空集——因为 id <> NULL 永远是 NULL |
WHERE id IN (…) | 不受影响(只是匹配不上) |
| 正确写法 | WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id) |
| 或者 | NOT IN (SELECT user_id FROM orders WHERE user_id IS NOT NULL) |
规则:反连接一律写 NOT EXISTS,不写 NOT IN——既没有 NULL 陷阱,执行计划通常也更好(04 章)。
聚合、排序与约束里的 NULL
| 位置 | 行为 |
|---|---|
count(*) | 数行数,NULL 也算 |
count(col) | 只数非 NULL——两者差多少就是有多少 NULL |
count(DISTINCT col) | 不同的非 NULL 值个数 |
sum / avg / max | 跳过 NULL;avg 的分母也不含它们 |
空集上的 sum | 返回 NULL 而不是 0——外面套 COALESCE(sum(x), 0) |
空集上的 count | 返回 0(唯一的例外) |
GROUP BY | 所有 NULL 归为同一组 |
ORDER BY | PG 默认把 NULL 排在最大值一侧:升序在最后、降序在最前。要改写 NULLS FIRST / NULLS LAST |
UNIQUE 约束 | 多个 NULL 不算重复,可以共存。PG 15 起可写 UNIQUE NULLS NOT DISTINCT 改掉这行为 |
DISTINCT | 多个 NULL 只保留一个(与 UNIQUE 相反) |
| 索引 | B-tree 会存 NULL,IS NULL 能走索引 |
WHERE col <> 'x' 静默漏掉了 NULL 行,二是 avg(col) 的分母没算上 NULL 行、比 sum / count(*) 大。两者都不报错,只是数字不对——核对总数时先跑一句 SELECT count(*), count(col) FROM t;。NOT NULL 就加:一列只要允许 NULL,上面这十几条规则就全都要在每次查询时重新想一遍。「暂时不知道」用一个业务上的哨兵值(空串、0、'unknown')往往比 NULL 更省心——前提是那个值不会与真实数据撞车。把 NULL 读作「不知道」,几乎所有反直觉行为立刻就讲得通了:两个都不知道的东西,你没法说它们相等——所以 NULL = NULL 的结果既不是真也不是假,而是第三种值:未知。
三值逻辑真值表
| 表达式 | 结果 | 为什么 |
|---|---|---|
NULL = NULL | NULL | 两个不知道的值,没法断言相等 |
NULL <> NULL | NULL | 同理,也没法断言不等 |
NULL IS NULL | true | IS NULL 才是判空的唯一正确写法 |
true OR NULL | true | 已经有一边为真,另一边是什么都不影响 |
false OR NULL | NULL | 结果取决于那个不知道的值 |
true AND NULL | NULL | 同上 |
false AND NULL | false | 已经有一边为假,整体必假 |
NOT NULL | NULL | 不知道的反面还是不知道 |
WHERE 只留下「真」,未知和假一样被丢掉
这是所有 NULL 问题的总根源:WHERE 保留的是结果为 true 的行,结果为 NULL 的行和结果为 false 的行一样被过滤掉。所以 WHERE dept <> 'x' 不会返回 dept 为 NULL 的行——那些行的判断结果是「不知道」,不是「真」。
想「把 NULL 也算进来」有两种写法:显式加 OR dept IS NULL,或者用 IS DISTINCT FROM——它是「NULL 安全」的比较,把 NULL 当成一个普通值来比,结果永远是 true 或 false,绝不会是 NULL。
-- 建三行:两行 dept='x',一行 dept 为 NULL
-- id | name | dept
-- 1 | a | x
-- 2 | b | x
-- 3 | c | NULL
SELECT count(*) FROM emp; -- 3
SELECT count(*) FROM emp WHERE dept = 'x'; -- 2
SELECT count(*) FROM emp WHERE dept <> 'x'; -- 0 ← 那第三行呢?
-- 第三行的判断是 NULL <> 'x' → NULL,不是 true,所以被 WHERE 丢掉了
-- 两种把它捞回来的写法:
SELECT count(*) FROM emp WHERE dept <> 'x' OR dept IS NULL; -- 1
SELECT count(*) FROM emp WHERE dept IS DISTINCT FROM 'x'; -- 1
-- IS DISTINCT FROM:把 NULL 当普通值比,结果永不为 NULL
SELECT null IS DISTINCT FROM null; -- false(两边都是 NULL,"不相异")
SELECT 1 IS DISTINCT FROM null; -- true'a' || NULL 的结果是 NULL,不是 'a'。所以 SELECT first_name || ' ' || last_name 在 last_name 为空时,整个姓名字段直接变成 NULL——不是「只少一半」,是全没了。这个坑在全文检索里尤其致命:
to_tsvector('english', title || ' ' || body) 遇到 body 为 NULL,整行的检索向量变成 NULL,这条记录从此永远搜不到,而且不报任何错。两个解法:用
concat()(它会把 NULL 当空串跳过,concat('a', null) 得到 'a'),或者显式 coalesce(x, '') 包住每一个可能为空的列。NOT NULL 就写上——这是成本最低的一道防线。绝大多数列其实并不需要「不知道」这个状态,一旦允许 NULL,后面每一个涉及它的查询都要多想一层。真的需要「暂无值」时,也优先考虑用一个明确的默认值(空字符串、0、'unknown')代替 NULL,前提是这个值在业务上不会和真实数据混淆。顺带一提:
UNIQUE 约束允许多个 NULL 并存(插三行 NULL 全部成功)——因为两个 NULL 互相「不相等」,唯一性检查放行。想让「最多一个空值」,得用部分唯一索引。这是 SQL 里最贵的一个 NULL 坑:写法看着完全正常、语法完全合法、不报任何错,但一行都不返回。而且它常常在上线很久之后才发作——因为只要子查询里出现一个 NULL 就会触发。
为什么会这样
x NOT IN (1, NULL) 会被展开成 x <> 1 AND x <> NULL。后半截 x <> NULL 永远是 NULL(不知道),而「真 AND 未知」= 未知——于是整个条件对任何 x 都不可能为真,WHERE 一行都留不下。
对照一下 IN:x IN (1, NULL) 展开成 x = 1 OR x = NULL,当 x 真的等于 1 时「真 OR 未知」= 真,所以 IN 照常工作。这个不对称正是它难被发现的原因——正向查询一切正常,只有取反时才出事。
结论:子查询取反一律用 NOT EXISTS
NOT EXISTS 判断的是「有没有匹配的行」,完全不参与值的比较,所以对 NULL 免疫。写法只是稍长一点,但它在任何情况下都给出你期望的结果——把它当成默认写法,不要等踩了坑再改。
-- 直接写常量就能复现
SELECT count(*) FROM emp WHERE id NOT IN (1, 2); -- 1 正常
SELECT count(*) FROM emp WHERE id NOT IN (1, null); -- 0 一行都没有!
SELECT count(*) FROM emp WHERE id IN (1, null); -- 1 IN 反而正常
-- 真实场景:子查询的列可空,你根本看不出来
-- 「找出所有不是别人上级的员工」——emp.mgr 允许为 NULL
SELECT count(*) FROM emp
WHERE id NOT IN (SELECT mgr FROM emp); -- 0 ← 错,因为 mgr 里有 NULL
-- 改成 NOT EXISTS,对 NULL 免疫
SELECT count(*) FROM emp e
WHERE NOT EXISTS (SELECT 1 FROM emp m WHERE m.mgr = e.id); -- 2 ← 对
-- 非要用 NOT IN,就得自己把 NULL 挡掉
SELECT count(*) FROM emp
WHERE id NOT IN (SELECT mgr FROM emp WHERE mgr IS NOT NULL);NOT IN (SELECT mgr FROM emp) 的那天,如果 mgr 列碰巧没有 NULL,查询结果完全正确、测试全过。直到某天有人插了一行 mgr 为空的记录——这个查询从此永远返回空集,而且不报错、日志里什么都看不到,表现为「功能突然不工作了」。排查时的识别特征:一个原本正常的查询突然返回 0 行,而单独跑子查询有结果——先看子查询那一列有没有 NULL。
NOT EXISTS 在 PG 里的执行计划通常也不比 NOT IN 差(规划器会把它转成 anti-join),所以这个选择没有性能代价。同理,
LEFT JOIN ... WHERE 右表主键 IS NULL(反连接写法)也是安全的,它同样不做值比较。聚合函数对 NULL 有一套统一规则:忽略它。规则本身简单,但由此派生出的两个后果经常被误判。
后果一:COUNT(*) 和 COUNT(列) 不是一回事
COUNT(*)数的是行数,跟列值无关;COUNT(salary)数的是 salary 非 NULL 的行数;- 所以
AVG(salary)的分母是非 NULL 的个数,不是总行数——「平均工资」在有人工资未录入时会偏高。想按总人数平均,得写SUM(salary) / COUNT(*)或先coalesce(salary, 0)。
要数行数就写 COUNT(*),别写 COUNT(某列) 图省事——除非你确实想排除空值。
后果二:空结果集上 SUM 返回 NULL,不是 0
没有任何行时,SUM / AVG / MAX / MIN 全都返回 NULL;只有 COUNT 返回 0。这个差别会一路传到应用层:金额字段拿到 null 而不是 0,前端显示成空白,或者参与计算时变成 NaN。凡是要展示或参与计算的聚合值,一律用 coalesce(SUM(x), 0) 包一层。
排序时 NULL 排在哪一头
PG 把 NULL 视为比任何值都大:ORDER BY x(升序)时排最后,ORDER BY x DESC 时排最前。「按金额倒序取前 10」这类查询,那些金额为空的记录会霸占榜首——用 NULLS LAST 显式指定即可。
-- emp 有 3 行,其中 1 行 salary 为 NULL(100、200、NULL)
SELECT count(*) AS 行数, count(salary) AS 有工资的 FROM emp;
-- 3 | 2
SELECT sum(salary), count(salary), avg(salary) FROM emp;
-- 300 | 2 | 150 ← 平均值的分母是 2 不是 3
-- 空结果集:只有 count 给 0,其余给 NULL
SELECT sum(salary) FROM emp WHERE false; -- NULL
SELECT count(*) FROM emp WHERE false; -- 0
SELECT coalesce(sum(salary), 0) FROM emp WHERE false; -- 0 ← 该这么写
-- 排序:NULL 被当作最大值
SELECT x FROM (VALUES(2),(null),(1)) v(x) ORDER BY x;
-- 1, 2, NULL 升序时 NULL 在最后
SELECT x FROM (VALUES(2),(null),(1)) v(x) ORDER BY x DESC;
-- NULL, 2, 1 倒序时 NULL 抢到最前面!
SELECT x FROM (VALUES(2),(null),(1)) v(x) ORDER BY x DESC NULLS LAST;
-- 2, 1, NULL 这才是「排行榜」想要的
-- NULLIF:反过来把某个值变成 NULL,最常见的用法是防除零
SELECT total / nullif(cnt, 0) FROM stats; -- cnt 为 0 时得 NULL,不报错FILTER 比在 CASE WHEN 里绕更清楚。想「只统计已支付的订单数」,很多人写 COUNT(CASE WHEN status='paid' THEN 1 END)——这能工作(靠的正是「COUNT 忽略 NULL」,因为 CASE 没有 ELSE 时不匹配就返回 NULL),但绕了一圈。PG 支持标准的 FILTER 子句:COUNT(*) FILTER (WHERE status='paid'),意思一目了然,还能和其它聚合并列写在同一行里做多维统计。反过来说,如果你看不懂别人写的
COUNT(CASE WHEN ... THEN 1 END) 为什么能对,答案就是这一章的主题——它依赖的正是 NULL 被聚合函数忽略。coalesce 和 nullif 是一对反操作,配合起来能解决绝大多数 NULL 问题:coalesce(x, 0) 把 NULL 变成默认值,nullif(x, 0) 把某个值变成 NULL。后者最常用于防除零——a / nullif(b, 0) 在 b 为 0 时安静地返回 NULL,而不是抛 22012 division by zero 让整条查询失败。coalesce 可以接多个参数,依次取第一个非 NULL:coalesce(nickname, real_name, '匿名')。JOIN 与关系
本章覆盖:五种 JOIN 各保留什么行、LATERAL、ON 与 WHERE 的区别(LEFT JOIN 最容易退化的地方)、半连接与反连接的四种写法怎么选,以及 UNION 系列与自连接。
JOIN 的全部语义就是一句话:先按条件配对,再决定配不上的行留不留。留哪一边的孤儿行,就是五种 JOIN 的唯一区别。
五种 JOIN
| 写法 | 保留什么 | 什么时候用 |
|---|---|---|
INNER JOIN(JOIN) | 只保留两边都配上的 | 默认;九成场景 |
LEFT JOIN | 左表全保留,右边配不上补 NULL | 「每个用户有几个订单,没订单的也要出现」 |
RIGHT JOIN | 右表全保留 | 很少用——把表换个位置写成 LEFT 更好读 |
FULL OUTER JOIN | 两边孤儿都保留 | 对账、找差异 |
CROSS JOIN | 笛卡儿积 | 生成日期×分类这类维度组合 |
LATERAL | 右侧子查询能引用左侧的列 | 「每个用户最近 3 条订单」:LEFT JOIN LATERAL (SELECT … WHERE o.user_id = u.id ORDER BY … LIMIT 3) x ON true |
USING (id) | ON a.id = b.id 的简写 | 两边列名相同时;结果里那一列只出现一次 |
NATURAL JOIN | 按所有同名列自动配对 | 别用——加一列同名字段就会悄悄改变语义 |
ON 与 WHERE:LEFT JOIN 最容易退化的地方
| 写法 | 实际语义 |
|---|---|
LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid' | 条件参与配对:没有已付订单的用户仍然出现,右侧为 NULL ✓ |
LEFT JOIN orders o ON o.user_id = u.id WHERE o.status = 'paid' | 配完之后再过滤:补出来的 NULL 行被 WHERE 滤掉,LEFT JOIN 退化成 INNER JOIN |
| 想保留孤儿又要过滤 | 条件写进 ON;或 WHERE (o.status = 'paid' OR o.id IS NULL) |
| 判据 | 对右表的条件写 ON,对左表的条件写 WHERE |
半连接与反连接:只问「有没有」
| 写法 | 语义 | 取舍 |
|---|---|---|
WHERE EXISTS (SELECT 1 FROM o WHERE o.user_id = u.id) | 半连接:有就留 | 首选:不会因右表多行而重复,找到一条就短路 |
WHERE id IN (SELECT user_id FROM o) | 同上 | 可读;子查询结果集小时和 EXISTS 计划常常一样 |
JOIN + DISTINCT | 同上 | 别这么写:先制造重复再去重,白做一遍 |
WHERE NOT EXISTS (…) | 反连接:没有才留 | 反连接唯一该用的写法 |
WHERE id NOT IN (…) | 同上 | 子查询有 NULL 就返回空集(03 章) |
LEFT JOIN … WHERE b.id IS NULL | 同上 | 老写法,计划通常不如 NOT EXISTS |
集合操作与自连接
| 写法 | 说明 |
|---|---|
UNION | 合并并去重——去重有代价 |
UNION ALL | 不去重,明显更快;确定没有重复时一律用它 |
INTERSECT · EXCEPT | 交集 / 差集;两边列数与类型必须对齐 |
| 自连接 | FROM employees e JOIN employees m ON m.id = e.manager_id——同一张表取两个别名 |
| 多表 JOIN 顺序 | 写的顺序不决定执行顺序,规划器会重排;表非常多时看 join_collapse_limit |
LEFT JOIN 之后在 WHERE 里写右表的条件,等于把它变回 INNER JOIN——包括 WHERE o.status = 'paid' 这种看起来人畜无害的一句。症状是「明明用了 LEFT JOIN,没有订单的用户却不见了」。唯一的例外是 WHERE o.id IS NULL,那正是反连接的写法。EXISTS(只问有没有)、DISTINCT ON(取一行,02 章)或先聚合再 JOIN,而不是在外面套 DISTINCT。JOIN 的选择只取决于一句话:没匹配上的那些行,你要不要。
五种 JOIN 与选择口诀
INNER JOIN:只返回两表都匹配的行。LEFT JOIN:保留左表全部行,右表无匹配则为 NULL。选择口诀:「只要两边都有的」 → INNER;「以左表为主,右表是补充」 → LEFT。
-- INNER JOIN:只返回两表都匹配的行
SELECT o.id, u.name, o.total
FROM orders o
INNER JOIN users u ON o.user_id = u.id;
-- LEFT JOIN:保留左表全部行
SELECT u.name, o.id AS order_id
FROM users u
LEFT JOIN orders o ON o.user_id = u.id;
-- 查找"没有订单的用户"(LEFT JOIN + IS NULL 反连接)
SELECT u.* FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.id IS NULL;
-- FULL OUTER JOIN:两边都保留
SELECT * FROM table_a
FULL OUTER JOIN table_b ON table_a.id = table_b.a_id;
-- CROSS JOIN:笛卡尔积
SELECT s.name, c.name FROM sizes s CROSS JOIN colors c;
-- 多表 JOIN
SELECT o.id, u.name AS customer, p.name AS product, oi.quantity
FROM orders o
JOIN users u ON o.user_id = u.id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON oi.product_id = p.id;WHERE,会把 LEFT JOIN 悄悄退化成 INNER JOIN。因为没匹配上的行右表列全是 NULL,而 WHERE o.status = 'paid' 对 NULL 求值得到 NULL、当假处理,那些行就被过滤掉了——你以为「保留所有用户」,结果只剩下有订单的。要筛右表就写进 ON:LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid'。判据:ON 决定「怎么配对」,WHERE 决定「配完之后留谁」。另一个高频坑:JOIN 之后再聚合会重复计数。一个用户有 3 个订单,
JOIN 后该用户就有 3 行,此时 SUM(u.balance) 会把余额加 3 遍。要么先在子查询里聚合再 JOIN,要么用 COUNT(DISTINCT ...)。自连接处理「表内行与行之间的关系」,集合操作处理「两个结果集之间的关系」——两件常被混为一谈的事。
自连接与四个集合运算
自连接用于表内行之间的关系查找(如员工→经理)。集合操作合并/对比两个查询的结果:UNION ALL 不去重性能更好,优先使用。
-- 自连接:员工和经理在同一张表
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
-- UNION ALL:合并但不去重(性能更好)
SELECT id FROM active_users
UNION ALL
SELECT id FROM archived_users;
-- INTERSECT:交集
SELECT user_id FROM bought_product_a
INTERSECT
SELECT user_id FROM bought_product_b;
-- EXCEPT:差集
SELECT user_id FROM all_users
EXCEPT
SELECT user_id FROM users_who_purchased;SELECT 1 UNION SELECT 'a' 报的是 22P02 invalid input syntax for type integer: "a"——它按第一个查询定了类型,再拿第二个去转换,所以错误看着像是数据问题而不是结构问题。还要注意:
ORDER BY 只能写在最后一个查询之后,它作用于整个合并结果,不是最后那一段。想给某一段单独排序得把它包进子查询。UNION 会去重,UNION ALL 不会——而去重是有代价的(要排序或哈希整个结果集)。确定两边不会重复时一律用 UNION ALL,这是免费的性能。反过来,如果你写 UNION 只是「顺手」,那可能在为一个不需要的去重付钱。INTERSECT(交集)和 EXCEPT(差集)同样默认去重,也各有 ALL 版本。「过滤条件写在 ON 里还是 WHERE 里」,在 INNER JOIN 下结果一样,在 LEFT JOIN 下完全不同——这是 JOIN 里最高频、也最不容易自查出来的一个错。
三行变两行
- 三个部门(工程 / 销售 / 空部门),员工表里空部门一个人都没有。查「每个部门有几个薪资 > 150 的人」:
- 条件写在 ON 里——结果 3 行:
工程 1、空部门 0、销售 1。 - 条件写在 WHERE 里——结果只剩 2 行:
工程 1、销售 1。「空部门」整行消失了。
为什么:两者作用在不同阶段
ON决定「右表的哪些行能被匹配上」——匹配不上就补 NULL,但左表的行一定保留。这是 LEFT JOIN 的承诺。WHERE在 JOIN 做完之后才过滤。而没匹配上的那些行右表字段全是 NULL,e.salary > 150对 NULL 求值是 NULL(不是 true),于是整行被滤掉——LEFT JOIN就此退化成了 INNER JOIN(NULL 的三值逻辑见 03 章)。- 一句话判据:对右表的过滤放
ON,对结果的过滤放WHERE。
唯一的例外:WHERE 里判 NULL 是故意的
where e.id is null是个特例——它专门捞「没匹配上的那些左表行」,这正是本章后面讲的反连接。- 所以看到
LEFT JOIN的WHERE里出现右表字段,先问一句:是想做反连接,还是不小心把 LEFT 写成了 INNER?
-- ✓ 条件在 ON:保留「空部门 0」这一行(3 行)
SELECT d.name, count(e.id)
FROM dept d
LEFT JOIN emp e ON e.dept_id = d.id AND e.salary > 150
GROUP BY d.name;
-- ✗ 条件在 WHERE:空部门整行消失(2 行)
SELECT d.name, count(e.id)
FROM dept d
LEFT JOIN emp e ON e.dept_id = d.id
WHERE e.salary > 150 -- NULL > 150 是 NULL
GROUP BY d.name;
-- 例外:这个 WHERE 是故意的(反连接,见本章后面)
WHERE e.id IS NULL;WHERE 的一长串 AND 里、或者藏在一个视图/CTE 的定义中,肉眼很难关联到那个 LEFT JOIN。症状是「明明用了 LEFT JOIN,报表里还是少了一批本该显示为 0 的分组」——统计报表里少一行不会报错,往往是业务方发现的。LEFT JOIN 临时改成 INNER JOIN,如果结果行数没变,那这个 LEFT 就是白写的——说明某个 WHERE 已经把它退化掉了。这比逐条读 SQL 快得多。很多时候你不需要右表的字段,只想问「有没有」。这类查询叫半连接(有则留)和反连接(无则留)。写法有三套,其中一套在遇到 NULL 时会静默返回空结果。
NOT IN 的 NULL 陷阱
- 员工表里有一行
dept_id是 NULL。查「部门号不在某个含 NULL 的集合里的员工数」: NOT IN→ 0。一条都没有,而且不报错。NOT EXISTS→ 1、LEFT JOIN ... IS NULL→ 1。这两个才是对的。- 更阴的是不对称:同一批数据下
IN照常返回 3,只有NOT IN出问题。所以「我试过 IN 是好的」这句话不构成任何保证。
为什么 NOT IN 会这样
x NOT IN (1, NULL)展开成x <> 1 AND x <> NULL。后半截永远是 NULL,整个AND因此永远不为 true——WHERE只留 true,于是一行不剩(03 章的三值逻辑)。NOT EXISTS走的是「有没有匹配行」的判断,不做值比较,NULL 影响不到它。
三种写法怎么选
EXISTS/NOT EXISTS——默认选它。语义最准,对 NULL 免疫,优化器通常也能改写成半/反连接,性能不输另外两种。IN:子查询确定不含 NULL(比如查的是主键)时可用,可读性好。NOT IN除非你能保证无 NULL,否则别用。LEFT JOIN ... WHERE 右表主键 IS NULL:反连接的经典写法,正确但啰嗦;真需要右表字段时才用它。
-- ✗ 子查询里只要有一个 NULL,结果就是空(0 行)
SELECT * FROM emp
WHERE dept_id NOT IN (SELECT dept_id FROM emp);
-- ✓ 反连接:对 NULL 免疫(1 行)
SELECT * FROM emp e
WHERE NOT EXISTS (
SELECT 1 FROM emp x WHERE x.dept_id = e.dept_id
);
-- ✓ 反连接的另一种写法(同样 1 行)
SELECT d.* FROM dept d
LEFT JOIN emp e ON e.dept_id = d.id
WHERE e.id IS NULL;
-- 半连接:只判断存在,不取右表字段
SELECT * FROM dept d
WHERE EXISTS (SELECT 1 FROM emp e WHERE e.dept_id = d.id);NOT IN。NOT IN,在子查询里加一句 WHERE 列 IS NOT NULL 就能挡住这个坑。但更省心的做法是把 NOT IN 从习惯里删掉,一律写 NOT EXISTS——两者在 PG 里性能相当,而后者不需要你每次都去确认「这一列会不会有 NULL」。聚合与分组
本章覆盖:聚合函数全表(含 string_agg、分位数、有序集聚合)、GROUP BY 的规则与 HAVING、FILTER 一次扫描算多个口径、ROLLUP / CUBE / GROUPING SETS 与 GROUPING()。
聚合把多行压成一行。规则本身不多,坑集中在两处:NULL 怎么算(03 章)和 GROUP BY 要写哪些列。
聚合函数
| 函数 | 怎么写 | 要点 |
|---|---|---|
count | count(*) · count(col) · count(DISTINCT col) | 三者数的东西不同:行数 / 非 NULL 数 / 去重非 NULL 数 |
sum / avg | sum(amount) | 空集返回 NULL——套 COALESCE(sum(x), 0) |
min / max | max(created_at) | 跳过 NULL |
string_agg | string_agg(name, ', ' ORDER BY name) | 把多行拼成一串,可带排序 |
array_agg · jsonb_agg | array_agg(id ORDER BY id) · jsonb_agg(row_to_json(t)) | 聚成数组 / JSON(11 章) |
| 统计 | stddev(x) · variance(x) · corr(x, y) | — |
| 中位数与分位 | percentile_cont(0.5) WITHIN GROUP (ORDER BY x) | 有序集聚合,语法特殊;percentile_disc 取实际存在的值 |
| 众数 | mode() WITHIN GROUP (ORDER BY x) | — |
| 布尔聚合 | bool_and(x) · bool_or(x) | 「是不是全部 / 至少一个」 |
GROUP BY 的规则
| 规则 | 说明 |
|---|---|
| SELECT 里的非聚合列 | 必须出现在 GROUP BY 里,否则报 column must appear in the GROUP BY clause |
| 按主键分组时的例外 | GROUP BY u.id 之后可以直接 SELECT u.name——PG 知道主键决定了其余列 |
| 按序号分组 | GROUP BY 1, 2(指 SELECT 的第 1、2 个表达式);能写但可读性差 |
| 按别名分组 | GROUP BY 可以用 SELECT 的别名(WHERE 不行) |
WHERE vs HAVING | WHERE 分组前过滤行,HAVING 分组后过滤组;能放 WHERE 的条件别放 HAVING,前者先执行、扫得更少 |
没有 GROUP BY 的聚合 | 整张表算一组;空表时 count 给 0、sum 给 NULL,而且仍返回一行 |
加了 GROUP BY 的空表 | 返回零行——这与上一条的差别常常导致「报表少了一行」 |
FILTER:一次扫描算多个口径
| 要做的事 | 怎么写 |
|---|---|
| 条件计数 | count(*) FILTER (WHERE status = 'paid') |
| 老写法 | count(CASE WHEN status = 'paid' THEN 1 END)——等价,FILTER 更好读 |
| 多口径一次出 | SELECT count(*) AS 全部, count(*) FILTER (WHERE paid) AS 已付, sum(amount) FILTER (WHERE paid) AS 金额 FROM orders; |
| 为什么值得 | 一次扫表算出所有口径,比几个子查询各扫一遍快得多 |
多维汇总
| 写法 | 产生什么 |
|---|---|
GROUP BY ROLLUP (a, b) | 逐级小计:(a,b) · (a) · () 三层 |
GROUP BY CUBE (a, b) | 所有组合:(a,b) · (a) · (b) · () |
GROUP BY GROUPING SETS ((a), (b)) | 手动指定要哪几组 |
| 区分小计行 | GROUPING(a) 返回 1 表示这一行在 a 上做了汇总——否则分不清「小计」和「a 为 NULL」 |
| 典型写法 | SELECT COALESCE(region, '合计') AS region, sum(amount) FROM t GROUP BY ROLLUP (region); |
avg(col) 的分母不含 NULL 行。一张 100 行的表里有 30 行 score 为 NULL 时,avg(score) 除的是 70 而不是 100——如果业务上「没打分算 0 分」,这个平均分会明显偏高。想按 100 算要写 avg(COALESCE(score, 0)),而这两种口径都对,错的是没想清楚要哪一种。sum(x) OVER (PARTITION BY g) 保留每一行并附上组内合计(06 章)。判据很简单:需要压缩行数用 GROUP BY,需要保留行数用 OVER。聚合把多行压成一行,而 WHERE 与 HAVING 的分工,正是 02 章那条执行顺序链的直接推论。
聚合、分组与两种过滤
常用聚合函数:COUNT、SUM、AVG、MIN、MAX。HAVING 用于过滤分组后的聚合结果(WHERE 不能使用聚合函数)。ROLLUP / CUBE 可生成多维度汇总报表。
SELECT
COUNT(*) AS total_orders,
COUNT(DISTINCT user_id) AS unique_users,
SUM(total) AS revenue,
ROUND(AVG(total)::numeric, 2) AS avg_order_value,
MIN(total), MAX(total)
FROM orders;
-- 按月分组
SELECT
date_trunc('month', created_at) AS month,
COUNT(*) AS order_count,
SUM(total) AS monthly_revenue
FROM orders
GROUP BY 1
ORDER BY month;
-- HAVING:对聚合结果再过滤
SELECT user_id, COUNT(*) AS cnt, SUM(total) AS total_spent
FROM orders
WHERE status = 'completed' -- 先按行过滤
GROUP BY user_id
HAVING SUM(total) > 1000 -- 再按聚合结果过滤
ORDER BY total_spent DESC;
-- ROLLUP:多层级汇总
SELECT category, region, SUM(sales)
FROM sales_data
GROUP BY ROLLUP (category, region);
-- 生成 (category,region)、(category)、(总计) 三级汇总GROUP BY 1, 2 可以按 SELECT 列表的位置分组,省去重复写一遍长表达式——GROUP BY date_trunc('month', created) 可以简写成 GROUP BY 1。ORDER BY 1 同理。这在按月/按天汇总时特别顺手。条件聚合优先用
FILTER:count(*) FILTER (WHERE status='paid') 比 count(CASE WHEN ... THEN 1 END) 清楚得多,还能几个并排写在同一行做多维统计(见 03 章)。聚合函数集体跳过 NULL,唯独 count(*) 例外。这条规则本身很简单,麻烦的是它会让「行数」和「有值的行数」悄悄对不上,而 SQL 不会提醒你。
同一张 4 行的表,三个 count 三个数
- 表里 4 行,
dept_id分别是 10、10、20、NULL: count(*)→ 4(数行,跟值无关)count(dept_id)→ 3(跳过 NULL)count(distinct dept_id)→ 2(去重后还跳 NULL)- 同一批数据
sum(dept_id)= 40、avg(dept_id)= 13.33——注意 avg 是除以 3 不是 4。「平均值比预期高」十有八九是这个原因。
空集:count 是 0,sum 却是 NULL
WHERE 1=0时:count(*)→ 0,sum(salary)→ NULL(不是 0)。- 这在「把汇总结果直接拿去做算术」时会传染——
sum(x) * 2得到 NULL,再存进非空列就报错。要 0 就写coalesce(sum(x), 0)。 max/min/avg在空集上同样返回 NULL,只有count返回 0。
GROUP BY 把所有 NULL 归成一组
- 按
dept_id分组,结果是10→2、20→1、NULL→1——NULL 自成一组,而不是被丢弃。 - 这和
WHERE里NULL = NULL为 NULL 的规则不一致:分组时 PG 把 NULL 当成「彼此相等」。同样的规则也适用于DISTINCT和唯一索引(03 章)。 - 排序时 NULL 默认排在最后(升序),要挪到前面用
ORDER BY col NULLS FIRST。
-- 4 行,其中 dept_id 有一个 NULL
SELECT count(*), -- 4
count(dept_id), -- 3 跳过 NULL
count(DISTINCT dept_id), -- 2
sum(dept_id), -- 40
avg(dept_id) -- 13.33(除以 3)
FROM emp;
-- 空集:count 给 0,sum 给 NULL
SELECT count(*), -- 0
sum(salary), -- NULL,不是 0
coalesce(sum(salary), 0) -- 想要 0 就这么写
FROM emp WHERE 1=0;
-- GROUP BY:NULL 自成一组,不会消失
SELECT dept_id, count(*) FROM emp
GROUP BY dept_id ORDER BY dept_id NULLS LAST;count(*))和「有效金额笔数 940」(count(amount))差了 60,没人知道那 60 是漏了还是本来就该为空。规避办法是在建表时就想清楚这一列到底允不允许 NULL——能加 NOT NULL 的列就加上,比事后在查询里到处 coalesce 省心得多。count(列),需要「一共多少行」时用 count(*)——把这个选择写进代码时顺手在注释里说明意图,因为半年后没人看得出当初是有意还是手滑。另外 count(1) 和 count(*) 在 PG 里完全等价,不存在谁更快的说法。报表常要「各分区小计 + 全局总计」。用 UNION ALL 拼几段查询能做,但要扫好几遍表。ROLLUP / GROUPING SETS 让一次扫描产出多个层级的汇总。
ROLLUP:加一行总计
GROUP BY ROLLUP(region)在正常分组之上多给一行——得到东 30、西 70、NULL 100,最后那行 region 是 NULL,代表「所有区合计」。- 多列时
ROLLUP(a, b)产出「a+b 明细 → a 小计 → 总计」这条层级链,正好对应报表的逐级折叠。 - 要「所有维度的两两组合」用
CUBE;要精确指定哪几种组合用GROUPING SETS ((a),(b),())——最后那个空括号就是总计行。
关键陷阱:两种 NULL 长得一模一样
- 汇总行的 NULL 表示「这一维被汇总掉了」,而数据里本来就可能有 NULL。一个含 NULL 产品名的表跑
GROUPING SETS,结果里出现两行region=NULL, product=NULL:一行是s=100(总计),另一行是s=40(product 本身就是 NULL 的那条数据)。 - 肉眼分不出来,程序更分不出来——直接拿去渲染报表就是两行同名数据。
- 解法是
GROUPING(列):它对「被汇总掉的维度」返回 1,对真实值(含真实 NULL)返回 0。ROLLUP 那三行的grouping(region)依次是0, 0, 1。
怎么用
- 渲染时按
grouping(col) = 1判断这行是不是汇总行,把 NULL 换成「合计」之类的标签。 - 排序也靠它——
ORDER BY grouping(region), region能保证总计行稳定落在末尾,而不是跟着 NULL 的排序规则跑。
-- ROLLUP:多出一行总计(东30 / 西70 / NULL100)
SELECT region, sum(amt)
FROM sale GROUP BY ROLLUP(region);
-- 用 GROUPING 区分「汇总行的 NULL」与「数据里的 NULL」
SELECT CASE WHEN grouping(region) = 1
THEN '合计' ELSE region END AS region,
sum(amt)
FROM sale
GROUP BY ROLLUP(region)
ORDER BY grouping(region), region; -- 总计稳定在末尾
-- 精确指定要哪几种组合;() 就是总计行
SELECT region, product, sum(amt)
FROM sale
GROUP BY GROUPING SETS ((region), (product), ());GROUPING()。另外汇总行的其它聚合列也是整体重算的:合计行的 avg 是全体平均,不是各组平均值的平均,两者一般不相等,别在前端拿小计行自己再平均一次。UNION ALL 去拼小计,就该换成 ROLLUP——少扫一遍表,而且加维度时不用再复制粘贴一段。反过来只有单层分组、不需要总计行时,普通 GROUP BY 更直白。窗口函数
本章覆盖:窗口函数全表(排名 / 取值 / 聚合三类)、OVER 的三段、窗口帧 ROWS 与 RANGE 的区别,以及每组前 N、环比、累计和、组内占比这几个常见配方。一句话分清:GROUP BY 压缩行,窗口函数保留每一行。
一句话分清:GROUP BY 压缩行,窗口函数保留每一行并附加一列。写法固定为 函数() OVER (PARTITION BY … ORDER BY … 帧),三段都可省。
函数全表
| 类别 | 函数 | 怎么写 / 给什么 |
|---|---|---|
| 排名 | row_number() | row_number() OVER (PARTITION BY dept ORDER BY salary DESC)——1,2,3,永不重复 |
| 排名 | rank() | 并列同名次,之后跳号(1,1,3) |
| 排名 | dense_rank() | 并列同名次,不跳号(1,1,2) |
| 排名 | ntile(4) | 均分成 4 桶,返回桶号——分位统计 |
| 排名 | percent_rank() · cume_dist() | 百分位 / 累积分布 |
| 取值 | lag(x, 1) · lead(x, 1) | 上一行 / 下一行的值——环比就靠它:x - lag(x) OVER (ORDER BY d) |
| 取值 | first_value(x) · last_value(x) | last_value 要配帧,默认帧下它等于当前行 |
| 取值 | nth_value(x, 2) | 窗口里第 n 行 |
| 聚合 | sum(x) OVER (…) · avg · count · max | 任何聚合函数都能当窗口函数用 |
| 聚合 | 累计和 | sum(x) OVER (ORDER BY d)——加了 ORDER BY 就是累计 |
| 聚合 | 组内占比 | x / sum(x) OVER (PARTITION BY g)——不加 ORDER BY 就是整组合计 |
OVER 的三段
| 段 | 作用 | 省略时 |
|---|---|---|
PARTITION BY g | 把行分成若干窗口,各自独立计算 | 整个结果集是一个窗口 |
ORDER BY d | 窗口内排序 | 无序;此时聚合函数算的是整个窗口 |
| 帧 | 限定「当前行看得见哪些行」 | 见下表——默认值不直观 |
| 复用 | WINDOW w AS (PARTITION BY g ORDER BY d) 然后 sum(x) OVER w | 多个函数共用同一窗口时省事 |
| 位置 | 窗口函数在 SELECT 与 ORDER BY 里能用,WHERE / GROUP BY / HAVING 里不能——要过滤排名结果得套一层子查询或 CTE(07 章) | |
窗口帧:默认的不是你以为的那个
| 写法 | 含义 |
|---|---|
有 ORDER BY 时的默认 | RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW |
RANGE 的坑 | 把「排序值相同的行」全部算作当前行——有并列时累计和会一次跳一大截,而不是逐行递增 |
| 想要逐行累计 | 显式写 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW |
| 移动平均(前后各 2 行) | avg(x) OVER (ORDER BY d ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING) |
| 整个分区 | ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING |
| 按时间窗口 | RANGE BETWEEN '7 days' PRECEDING AND CURRENT ROW——这才是 RANGE 真正有用的地方 |
GROUPS | 按「并列组」计数(PG 11+),介于两者之间 |
常见配方
| 要做的事 | 怎么写 |
|---|---|
| 每组前 N | WITH r AS (SELECT *, row_number() OVER (PARTITION BY g ORDER BY x DESC) rn FROM t) SELECT * FROM r WHERE rn <= 3; |
| 环比 / 同比 | x - lag(x) OVER (ORDER BY month) |
| 累计和 | sum(x) OVER (ORDER BY d ROWS UNBOUNDED PRECEDING) |
| 组内占比 | round(100.0 * x / sum(x) OVER (PARTITION BY g), 2) |
| 去重取最新一条 | 用 DISTINCT ON 更短(02 章) |
| 相邻行间隔 | d - lag(d) OVER (PARTITION BY user_id ORDER BY d)——做会话切分 |
WHERE 里。WHERE row_number() OVER (…) <= 3 会直接报错——因为窗口函数在 WHERE 之后才执行(02 章执行顺序)。必须先在子查询或 CTE 里算出来,再在外层过滤。DISTINCT ON(只要 1 条时更短更快,02 章)。数据量大时还有第三种——LATERAL 子查询配上合适的索引(04、09 章),它能让每组只扫 N 行。窗口函数与聚合的根本区别:聚合会把行压掉,窗口函数不会——每一行都还在,只是多了一列。
三个排名函数怎么选
基本语法:function() OVER (PARTITION BY ... ORDER BY ...)。ROW_NUMBER 生成不重复序号;RANK 并列同排名并跳号;DENSE_RANK 并列不跳号。「取每组前 N 条」是最常见的窗口函数应用场景。
-- 每个用户的订单 + 该用户的汇总
SELECT
user_id, id AS order_id, total,
COUNT(*) OVER (PARTITION BY user_id) AS user_order_count,
SUM(total) OVER (PARTITION BY user_id) AS user_total_spent
FROM orders;
-- 排名函数
SELECT name, score,
ROW_NUMBER() OVER (ORDER BY score DESC) AS row_num, -- 1,2,3,4
RANK() OVER (ORDER BY score DESC) AS rank, -- 1,2,2,4
DENSE_RANK() OVER (ORDER BY score DESC) AS dense_rank -- 1,2,2,3
FROM leaderboard;
-- 每组前 N(每个分类销量前3)
WITH ranked AS (
SELECT *, ROW_NUMBER() OVER (
PARTITION BY category ORDER BY sales DESC
) AS rn
FROM products
)
SELECT * FROM ranked WHERE rn <= 3;WHERE 里用不了,因为它的计算发生在 WHERE 之后(见 02 章执行顺序)。写 WHERE rank() OVER (...) <= 3 会直接报 42P20 window functions are not allowed in WHERE。要按排名筛选,必须先在子查询/CTE 里把排名算出来,外层再过滤——这就是「取每组前 N」为什么总要套一层的原因。row_number 给 1,2,3,4(强行编号,并列也分先后)、rank 给 1,2,2,4(并列同名次,之后跳号)、dense_rank 给 1,2,2,3(并列同名次,不跳号)。选择依据:要唯一编号(比如取每组第一行)用
row_number;要体育比赛式名次用 rank;要连续的等级用 dense_rank。用 row_number 取每组前 N 时,并列的那几个谁进谁出是不确定的——在 ORDER BY 里追加主键做 tiebreaker(同 08 章)。能取到「上一行」和「下一行」之后,环比、同比、移动平均这些分析就不再需要把数据拉到应用层去算。
跨行取值与窗口帧
LAG/LEAD 访问上一行/下一行的值,是环比/同比分析的核心。ROWS BETWEEN 定义滑动窗口帧,用于移动平均和累计计算。
-- 月度环比增长
SELECT
month, revenue,
LAG(revenue) OVER (ORDER BY month) AS prev_month,
ROUND(
(revenue - LAG(revenue) OVER (ORDER BY month))
/ LAG(revenue) OVER (ORDER BY month) * 100, 2
) AS growth_pct
FROM monthly_revenue;
-- FIRST_VALUE:分组内第一个值
SELECT user_id, order_date, total,
FIRST_VALUE(total) OVER (
PARTITION BY user_id ORDER BY order_date
) AS first_order_total
FROM orders;
-- 7天移动平均
SELECT date, revenue,
AVG(revenue) OVER (
ORDER BY date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS moving_avg_7d
FROM daily_revenue;
-- 累计求和
SELECT date, revenue,
SUM(revenue) OVER (ORDER BY date) AS cumulative
FROM daily_revenue;ORDER BY 的窗口默认帧是「从头到当前行」,所以 sum(v) OVER (ORDER BY id) 得到的是累计和(10, 20, 50, 55),不是总和;不带 ORDER BY 时默认帧是整个分区,sum(v) OVER () 才是总和(每行都是 55)。想要「前 7 行的移动平均」必须显式写帧:
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW。看到窗口聚合的结果不对,先检查有没有 ORDER BY 和帧定义。另外
LAG 在第一行没有前一行,返回 NULL——直接拿去做减法会得到 NULL。第三个参数可以给默认值:lag(v, 1, 0)。写 sum(x) OVER (ORDER BY x) 想要「累计和」,多数人以为它是「从第一行加到当前行」。实际不是——不写帧子句时默认是 RANGE,它会把所有与当前行排序值相同的行一起算进来。
四行数据,两种帧两种答案
- 数据是
1, 1, 2, 3(注意有两个并列的 1): - 默认(
RANGE):累计列是2, 2, 4, 7——第一行就直接是 2,因为它把另一个 1 也算进来了。 - 显式
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW:1, 2, 4, 7——这才是逐行累计。 - 差别只在有并列值时出现,而且只差前面几行。测试数据恰好没有重复值时,两种写法结果完全一样,坑就这么被藏过去了。
三条帧规则
- 有
ORDER BY、无帧子句 → 默认RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,按值算边界,并列行互为对方的一部分。 - 无
ORDER BY→ 帧是整个分区。sum(v) OVER ()每一行都得到 7(全表总和),这倒是符合直觉。 - 要逐行 → 必须显式写
ROWS。移动平均(ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)尤其如此,用 RANGE 会因并列而滑窗大小不定。
什么时候 RANGE 才是对的
- 「同一天的所有订单算作一个时间点」这类按值聚合的语义,RANGE 正合适——按日期排序时,同一天的多笔订单本来就该一起进累计。
- 换句话说:问题出在默认值和多数人的意图不一致,不是 RANGE 本身有错。所以规则是「写窗口函数就显式写帧」,别依赖默认。
-- 数据:1, 1, 2, 3
-- 默认帧是 RANGE:2, 2, 4, 7
SELECT v, sum(v) OVER (ORDER BY v) FROM s;
-- 显式 ROWS 才是逐行累计:1, 2, 4, 7
SELECT v, sum(v) OVER (
ORDER BY v
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) FROM s;
-- 无 ORDER BY:帧是整个分区,每行都是总和 7
SELECT v, sum(v) OVER () FROM s;
-- 7 日移动平均:必须用 ROWS
avg(amt) OVER (ORDER BY d ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)ORDER BY 的窗口函数,就把 ROWS 帧一起写上,哪怕它和默认行为一致。ROW_NUMBER() + 外层过滤,还有个更直接的写法:DISTINCT ON(取每组第 1 条,PG 专属)和 CROSS JOIN LATERAL (… ORDER BY … LIMIT n)(取每组前 n 条)。三者在同一份数据上结果一致;组少、每组取的条数少时 LATERAL 往往更快,因为它对每组只取 n 行而不是给全表排号。子查询与 CTE
本章覆盖:子查询的三种位置与各自的形状要求、相关子查询的代价、WITH 的写法与物化行为(PG 12 起默认可内联)、可写 CTE,以及递归 CTE 的骨架与防死循环。
查询长到读不懂时就该拆。子查询嵌在表达式里,CTE 把它提出来起个名字——后者几乎总是更好读。
子查询的三种位置
| 位置 | 要求 | 写法 |
|---|---|---|
标量(SELECT 或 WHERE 里当一个值) | 必须只返回一行一列,多了报 more than one row returned | SELECT name, (SELECT count(*) FROM orders o WHERE o.user_id = u.id) AS cnt FROM users u |
列表(IN / ANY / ALL) | 一列多行 | WHERE id IN (SELECT user_id FROM orders) · WHERE x > ALL (SELECT …) |
派生表(FROM 里) | 必须起别名 | FROM (SELECT user_id, sum(amount) s FROM orders GROUP BY user_id) t |
| 存在性 | 只问有没有 | WHERE EXISTS (SELECT 1 FROM …)——里面写 SELECT 1 就够(04 章) |
| 相关子查询 | 引用了外层的列 | 逐行执行,行多时很慢——多数能改写成 JOIN 或窗口函数 |
LATERAL | FROM 里的子查询要引用左侧 | 04 章 |
CTE
| 写法 | 说明 |
|---|---|
| 基本 | WITH recent AS (SELECT … ) SELECT * FROM recent; |
| 多个 | WITH a AS (…), b AS (SELECT * FROM a …) SELECT …——后面的能引用前面的 |
| 物化行为 | PG 12 起 CTE 默认可以被内联优化(像子查询一样);12 之前总是物化,是很多老查询变慢的原因 |
| 强制物化 / 禁止 | WITH x AS MATERIALIZED (…) · AS NOT MATERIALIZED (…) |
| 什么时候该强制物化 | 同一个 CTE 被引用多次且计算很贵时 |
| 可写 CTE | WITH del AS (DELETE FROM t WHERE … RETURNING *) INSERT INTO archive SELECT * FROM del;——PG 专有,归档模式的标准写法 |
| 注意 | 同一语句里的多个可写 CTE 看到的是同一个快照,彼此看不到对方的改动 |
递归 CTE
| 部分 | 写法 |
|---|---|
| 骨架 | WITH RECURSIVE t AS (锚点查询 UNION ALL 递归查询) SELECT * FROM t; |
| 树形展开 | WITH RECURSIVE sub AS (SELECT id, parent_id, 1 AS depth FROM cat WHERE id = 1 UNION ALL SELECT c.id, c.parent_id, s.depth + 1 FROM cat c JOIN sub s ON c.parent_id = s.id) SELECT * FROM sub; |
| 生成序列 | generate_series(1, 10) 更简单——别拿递归造数列 |
| 防死循环 | 数据有环时必须加深度上限 WHERE depth < 10,或用数组记录走过的节点 NOT (c.id = ANY(s.path)) |
UNION vs UNION ALL | 前者会去重也顺带截断环,但每轮都要排序;数据无环时用 UNION ALL |
| 典型用途 | 组织架构、分类树、依赖图、物料清单 |
parent_id 指回祖先就足够触发。写递归查询时把深度上限当成必选项而不是优化项——先加 WHERE depth < 20,确认数据确实无环之后再决定去不去掉。paid_orders、active_users),再把主查询写成这些名字的组合。可读性提升远大于任何格式化,而且 PG 12 之后基本不用担心性能代价。三种子查询出现在三个不同位置,能力与代价也完全不同——其中一种(关联子查询)常常该被改写成 JOIN。
三种形式与 NOT IN 的陷阱
标量子查询返回单个值;IN/EXISTS 用于 WHERE 过滤(现代规划器下二者通常等价,不必纠结选谁;但 NOT IN 碰到 NULL 会返回意外的空结果,应改用 NOT EXISTS);派生表在 FROM 中作为临时表(必须起别名)。关联子查询会对每行执行一次,性能较差,通常可改写成 JOIN。
-- 标量子查询
SELECT name, price,
price - (SELECT AVG(price) FROM products) AS diff_from_avg
FROM products;
-- IN 子查询
SELECT * FROM users
WHERE id IN (SELECT user_id FROM orders WHERE total > 1000);
-- EXISTS(与 IN 常等价;NOT EXISTS 可避开 NOT IN 的 NULL 陷阱)
SELECT * FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.total > 1000
);
-- 派生表(子查询作 FROM,必须起别名)
SELECT category, avg_price FROM (
SELECT category, AVG(price) AS avg_price
FROM products GROUP BY category
) AS category_avg
WHERE avg_price > 100;EXISTS,不要用 count(*) > 0。EXISTS 找到第一行就能返回,而 count 必须数完全部。选择口诀:要取值用标量子查询或 JOIN;只判断存在用
EXISTS / NOT EXISTS;取反一律用 NOT EXISTS(NOT IN 遇 NULL 会返回空集,见 03 章)。WITH 让一段复杂查询能被拆成几个有名字的步骤;递归 CTE 则是 SQL 里处理树形结构的唯一标准手段。
命名步骤与递归
CTE 用 WITH 给子查询命名,多个 CTE 可链式引用。递归 CTE 处理树形数据(组织架构、分类树、评论嵌套),由基准条件 + UNION ALL + 递归部分组成。PG12+ 默认会内联 CTE,需要强制物化用 MATERIALIZED。
-- CTE:命名子查询,可读性大幅提升
WITH monthly_sales AS (
SELECT date_trunc('month', created_at) AS month,
SUM(total) AS revenue
FROM orders GROUP BY 1
),
top_months AS (
SELECT * FROM monthly_sales ORDER BY revenue DESC LIMIT 3
)
SELECT * FROM top_months;
-- 递归 CTE:遍历组织架构树
WITH RECURSIVE org_chart AS (
-- 基准:根节点
SELECT id, name, manager_id, 1 AS level
FROM employees WHERE manager_id IS NULL
UNION ALL
-- 递归:找下属
SELECT e.id, e.name, e.manager_id, oc.level + 1
FROM employees e
JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT REPEAT(' ', level - 1) || name AS org_tree
FROM org_chart ORDER BY level;
-- 强制物化(CTE 被多次引用时避免重复计算)
WITH expensive_calc AS MATERIALIZED (
SELECT * FROM big_table WHERE complex_condition
)
SELECT * FROM expensive_calc a JOIN expensive_calc b ON ...;WITH 总是先算完再用(「优化栅栏」),有人靠这个特性手工控制执行顺序;12 起规划器会把简单 CTE 展开、把外层条件下推进去——通常更快,但依赖旧行为的查询可能变慢或变快得莫名其妙。需要强制物化(比如 CTE 里有副作用、或被引用多次且计算昂贵)就写
AS MATERIALIZED;反过来用 AS NOT MATERIALIZED 强制内联。WITH RECURSIVE 还有个必须注意的点:写错终止条件会无限递归。图数据里有环时尤其容易——要么用 UNION(自动去重)而不是 UNION ALL,要么在递归项里带一个深度计数并加上限。排序与分页
本章覆盖:排序选项(NULLS FIRST/LAST、COLLATE、表达式排序)、为什么 ORDER BY 必须带唯一列兜底、OFFSET 与 keyset 两种分页的取舍,以及总数怎么算(精确 vs 估算)。
分页出错的两种形态:翻页时重复或漏行(排序不唯一)和越翻越慢(OFFSET 要先扫过前面所有行)。两者的修法完全不同。
排序选项
| 写法 | 说明 |
|---|---|
ORDER BY created_at DESC | 默认 ASC |
NULLS FIRST / NULLS LAST | PG 默认把 NULL 当最大值:升序在最后、降序在最前(03 章) |
| tiebreaker | ORDER BY created_at DESC, id DESC——末尾必须有唯一列 |
| 按表达式 | ORDER BY lower(name)——要走索引就得建表达式索引(09 章) |
| 自定义顺序 | ORDER BY CASE status WHEN 'urgent' THEN 0 ELSE 1 END, created_at |
| 按序号 | ORDER BY 2 DESC——指 SELECT 的第 2 列 |
| 中文排序 | ORDER BY name COLLATE "zh-x-icu"——默认排序规则按字节,中文结果不符合直觉 |
| 随机 | ORDER BY random() LIMIT 1——全表排序,大表上极慢;改用 TABLESAMPLE 或按主键随机取 |
三种分页
| 方式 | 怎么写 | 取舍 |
|---|---|---|
LIMIT / OFFSET | ORDER BY id LIMIT 20 OFFSET 40 | 能跳页;第 N 页要先扫 N×20 行,深翻页线性变慢 |
| keyset(游标 / seek) | WHERE (created_at, id) < (:last_at, :last_id) ORDER BY created_at DESC, id DESC LIMIT 20 | 每页都走索引、耗时恒定;代价是不能跳页、只能上下翻 |
| 服务端游标 | DECLARE c CURSOR FOR …; FETCH 100 FROM c; | 同一事务内导出大结果集;跨请求的分页用不了 |
| 行值比较 | (a, b) < (1, 2) | PG 原生支持元组比较,keyset 分页靠它一句写完,不用展开成 a < 1 OR (a = 1 AND b < 2) |
| 配套索引 | CREATE INDEX ON t (created_at DESC, id DESC); | 索引列序与 ORDER BY 一致才能免排序(09 章) |
总数与跳页
| 需求 | 怎么办 |
|---|---|
| 精确总数 | count(*) 要扫全表——大表上比查数据本身还慢 |
| 估算总数 | SELECT reltuples::bigint FROM pg_class WHERE relname = 't';——统计信息里的估计值,毫秒级 |
| 带条件的估算 | EXPLAIN 输出里的 rows= 就是规划器的估计 |
| 「还有更多吗」 | 取 LIMIT 21 判断是否有第 21 行——绝大多数列表界面只需要这个 |
| 确实要跳到第 500 页 | 先问业务上是否真需要;需要就加缓存或预计算页边界 |
OFFSET 100000 并不是「跳过」,而是「取出来再扔掉」:数据库仍要读完前 10 万行。症状是前几页飞快、第 50 页要几秒,且加索引也救不了。无限滚动与「加载更多」一律该用 keyset 分页——只有需要随意跳页的后台列表才值得忍受 OFFSET。ORDER BY 的一部分来写:只要这条查询会分页,末尾就补上主键。排序键有并列值时,两次查询返回的顺序不保证一致——PG 没有义务稳定排序,翻页重复就是这么来的,而且数据量小的时候根本复现不出来。SQL 的一条硬规则:没有 ORDER BY 就没有顺序保证;而排序键有重复值时,那些行之间的先后同样没有保证。分页正好把这个不保证放大成了可见的 bug。
问题是怎么发生的
假设按 created 倒序分页,而有 50 条记录的 created 完全相同(批量导入很容易造成)。第 1 页取前 20 条,第 2 页 OFFSET 20 再取 20 条——这是两次独立的查询,PG 没有义务让这 50 条在两次查询里保持同样的内部顺序。于是:
- 某条记录在第 1 页和第 2 页各出现一次(重复);
- 另一条记录两页都没出现(漏行)。
这类 bug 的特征是「偶尔」出现、无法稳定复现,因为顺序变化取决于执行计划、并发写入、甚至 autovacuum 是否刚跑过。
解法:给 ORDER BY 加一个唯一的兜底列
在排序键后面追加主键(tiebreaker),让整体排序全序化——任意两行的先后都被唯一确定,分页结果就稳定了。这条几乎没有代价,应该成为写分页查询的默认习惯。
-- ❌ created 有重复值时,翻页会重复/漏行
SELECT * FROM orders
ORDER BY created DESC
LIMIT 20 OFFSET 20;
-- ✅ 追加主键作为 tiebreaker,排序全序化
SELECT * FROM orders
ORDER BY created DESC, id DESC
LIMIT 20 OFFSET 20;
-- 排序列可空时,还要显式指定 NULL 的位置
-- PG 把 NULL 当作最大值:DESC 时它会抢到最前面(见 03 章)
SELECT * FROM orders
ORDER BY amount DESC NULLS LAST, id DESC
LIMIT 10;
-- 想让索引完全服务于这个排序,索引的列序与方向要对上:
CREATE INDEX idx_orders_created_id ON orders (created DESC, id DESC);ORDER BY priority DESC),而 priority 只有「高/中/低」三个值,那这个排序对绝大多数行都是并列的,问题一点没解决。判据是:排序键的组合能否唯一确定每一行。不能,就追加主键。反过来,没有
ORDER BY 的 LIMIT 是完全没有意义的——「随便给我 10 行」在语义上就是不确定的,而且 PG 完全可能在不同时刻给你不同的 10 行。看到 SELECT ... LIMIT 10 却没有 ORDER BY,那多半是个 bug。OFFSET n 的代价常被误解——它不是「跳过」前 n 行,而是把前 n 行全部取出来再丢掉。所以翻到第 10000 页时,为了返回 20 行,数据库实实在在地读了 200020 行。前几页飞快、越往后越慢,正是这个原因。
EXPLAIN 会直接把它暴露出来
在一张 20 万行的表上跑 ORDER BY id LIMIT 20 OFFSET 199980,执行计划里的 Limit 节点下面是:
-> Index Scan using orders_pkey on orders (actual rows=200000 loops=1)
为了给你 20 行,它扫了 20 万行。这就是深分页的全部真相——不是索引没建对,而是 OFFSET 的语义决定了它必须这么做。
解法:keyset 分页(也叫游标分页 / seek method)
不说「跳过前 n 行」,而说「从上一页最后一行之后继续」——把上一页最后一条的排序键值带上,用 WHERE 直接定位。这样索引能一步跳到起点,翻到第几页都是同样的代价。
代价是不能随机跳页(没法直接跳到第 500 页),只能「下一页 / 上一页」。但这恰好符合绝大多数产品形态——无限滚动、时间线、消息列表本来就不需要跳页。真需要页码的后台管理界面,数据量通常也不大,OFFSET 够用。
-- ❌ 深分页:越往后越慢(20 万行的表上,OFFSET 0 约 2ms,
-- OFFSET 199980 约 23ms;表越大、翻得越深,差距越夸张)
SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 199980;
-- ✅ keyset 分页:第一页照常
SELECT * FROM orders ORDER BY id DESC LIMIT 20;
-- 后续页:带上上一页最后一行的 id,直接定位(约 1ms,且不随页数增长)
SELECT * FROM orders
WHERE id < 199980 -- 上一页最后一行的 id
ORDER BY id DESC
LIMIT 20;
-- 多列排序时用行值比较,语义正好是「字典序的下一个」
SELECT * FROM orders
WHERE (created, id) < ('2026-07-01 10:00:00+08', 12345)
ORDER BY created DESC, id DESC
LIMIT 20;
-- 注意:(a,b) < (x,y) 是行值比较,不等于 a<x AND b<y,后者是错的
-- 配套索引(列序与方向要和 ORDER BY 完全一致)
CREATE INDEX ON orders (created DESC, id DESC);(a, b) < (x, y) 不等于 a < x AND b < y。前者是字典序:先比 a,a 相等时再比 b——这正是多列 keyset 分页需要的语义。后者要求两个条件同时成立,会漏掉「a 更小但 b 更大」的那些行。手写成 created < ? AND id < ? 是这个坑最常见的形态,症状是翻页时莫名其妙少数据。另一个容易忽略的点:keyset 分页要求排序键的组合唯一,否则和本章前面说的问题一样——边界上那几条并列的记录会被跳过或重复。所以 keyset 的排序键必须以主键收尾。
(created, id) 编码进去),而不是让前端传 page=3。这样服务端以后想换排序方式或换分页策略,都不用改协议。GitHub、Slack 这些 API 的 next_cursor 就是这么做的。另外,「总页数」本身也是深分页的一部分代价——算总数要
COUNT(*) 全表扫。数据量大时常见的折中是:不给精确总数,只回答「还有没有下一页」(多取一行看看够不够)。索引与性能
本章覆盖:六种索引类型的选型、复合索引的列序规则、部分 / 表达式 / 覆盖索引、CONCURRENTLY、索引用不上的七种原因,以及 EXPLAIN (ANALYZE, BUFFERS) 的节点对照表。索引是读与写之间的权衡,不是越多越好。
索引是读与写之间的权衡:每个索引都让 INSERT / UPDATE 更慢、占更多磁盘。先用 EXPLAIN 确认瓶颈,再决定加哪一个。
索引类型
| 类型 | 怎么建 | 支持什么 |
|---|---|---|
| B-tree(默认) | CREATE INDEX ON t (col); | 等值、范围、排序、前缀 LIKE 'a%'——九成情况就是它 |
| GIN | CREATE INDEX ON t USING gin (tags); | 多值列:jsonb 的 @>、数组的 &&、全文搜索 tsvector(11、15 章) |
| GiST | USING gist (geom) | 几何、范围类型、最近邻、EXCLUDE 约束 |
| BRIN | USING brin (created_at) | 超大表 + 物理有序(日志按时间追加):索引极小,代价是精度低 |
| Hash | USING hash (col) | 只支持 =;PG 10 起才可靠,但 B-tree 基本都不差 |
| SP-GiST | USING spgist (…) | 非平衡结构:IP 前缀、四叉树 |
给 LIKE '%x%' 加索引 | CREATE EXTENSION pg_trgm; CREATE INDEX ON t USING gin (name gin_trgm_ops); | 中间匹配唯一的索引办法 |
建索引的几种形态
| 形态 | 怎么写 | 用在哪 |
|---|---|---|
| 复合索引 | CREATE INDEX ON t (a, b); | 列序=最左前缀:能服务 a 与 (a,b),不能只服务 b。等值列在前、范围列在后 |
| 唯一索引 | CREATE UNIQUE INDEX ON t (email); | 约束 + 加速 |
| 部分索引 | CREATE INDEX ON t (created_at) WHERE deleted_at IS NULL; | 只索引关心的那部分行,索引小很多 |
| 表达式索引 | CREATE INDEX ON t (lower(email)); | 查询里也必须写成同样的表达式才会命中 |
| 覆盖索引 | CREATE INDEX ON t (a) INCLUDE (b); | 让查询只读索引不回表(Index Only Scan) |
| 不锁表地建 | CREATE INDEX CONCURRENTLY ON t (col); | 线上必须加 CONCURRENTLY;不能在事务块里执行,失败会留下无效索引 |
| 删 | DROP INDEX CONCURRENTLY idx; | 同理 |
| 查现有索引 | \d tablename · SELECT * FROM pg_indexes WHERE tablename = 't'; | — |
| 找没用的索引 | SELECT relname, idx_scan FROM pg_stat_user_indexes ORDER BY idx_scan; | idx_scan = 0 的就是白占写入开销 |
索引用不上的常见原因
| 写法 | 为什么不走索引 | 改法 |
|---|---|---|
WHERE lower(email) = 'a' | 列上套了函数 | 建表达式索引 |
WHERE name LIKE '%abc%' | 没有前缀可定位 | pg_trgm + GIN |
WHERE a::text = '1' | 类型转换发生在列上 | 转换常量那一侧 |
WHERE b = 1(索引是 (a, b)) | 跳过了最左列 | 建 (b) 索引;PG 18 的 skip scan 部分缓解 |
| 表很小 | 全表扫本来就更快——这不是问题 | 不用管 |
| 返回行占比高 | 规划器认为顺序扫更划算 | 同上;确实误判时看统计信息是否过期 |
| 统计信息过期 | 估算行数离谱 | ANALYZE t; |
EXPLAIN 怎么读
| 怎么跑 | 说明 |
|---|---|
EXPLAIN SELECT … | 只出计划,不执行 |
EXPLAIN (ANALYZE, BUFFERS) SELECT … | 真跑一遍,给出实际耗时与读了多少块——日常就用这一条 |
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) | 喂给可视化工具 |
| 注意 | ANALYZE 会真的执行——对 UPDATE / DELETE 要包在事务里再回滚 |
| 节点 | 含义 | 看到它想什么 |
|---|---|---|
Seq Scan | 全表扫 | 小表正常;大表 + 高选择性条件就该有索引 |
Index Scan | 走索引再回表取行 | 正常 |
Index Only Scan | 只读索引不回表 | 最快;Heap Fetches 高说明表需要 VACUUM |
Bitmap Heap Scan | 先收集行号再批量读 | 命中行较多时的正常选择 |
Nested Loop | 逐行探查 | 外层行数估得很小却实际很大时,这里会爆炸 |
Hash Join / Merge Join | 建哈希表 / 归并 | 大表关联的正常选择 |
Sort + Sort Method: external merge | 排序溢出到磁盘 | 调 work_mem,或让索引提供顺序 |
rows= 估计 vs actual rows= | 规划器的估计与实际 | 差一个数量级以上=统计信息不准,先 ANALYZE——这是读计划最该先看的一处 |
Filter + Rows Removed by Filter | 读出来又扔掉的行 | 数字大说明索引没覆盖住条件 |
CONCURRENTLY 会锁住整张表的写入,大表上可能锁几分钟到几十分钟。加了它则不阻塞读写,代价是要扫两遍表、更慢,而且不能放在事务块里;中途失败会留下一个 INVALID 索引,必须先 DROP 再重建——记得建完 \d 确认一下状态。EXPLAIN (ANALYZE, BUFFERS) 找最耗时的节点 → ② 看估计行数与实际行数差多少(差得多先 ANALYZE)→ ③ 再考虑加索引或改写查询。跳过前两步直接加索引,多数时候只是多了一个拖慢写入的索引。索引不是「加上就快」——类型选错、列顺序排错,索引会原地躺着不被用到。
四类索引与复合索引的列顺序
B-tree(默认)适合 =、>、<、BETWEEN、ORDER BY。GIN 适合数组、JSONB、全文搜索。部分索引只索引满足条件的行(更小更快)。表达式索引索引计算结果。复合索引的列顺序决定它好不好用:条件命中最左列时最有效,跳过最左列则要看选择性——PG 不像 MySQL 那样「必须最左匹配」,它可能整段扫索引再过滤,但那已经比全表快不了多少,所以设计索引时仍应按最左匹配来安排列顺序。索引类似书末目录:先按键在目录中定位(走索引),再翻到对应数据页读取完整行(回表);若索引本身已包含所需全部列,则无需回表,称为索引覆盖,效率最高。
-- B-tree 索引(默认)
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_created ON orders(created_at DESC);
-- 复合索引:列顺序决定可用性
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- ✅ WHERE user_id = ? 用得上(命中最左列)
-- ✅ WHERE user_id = ? AND status = ? 最理想
-- ⚠️ WHERE status = ? 跳过最左列:能不能用要看选择性,
-- 命中行少时 PG 可能整段扫索引再过滤,多了就直接全表——别指望
-- 唯一索引
CREATE UNIQUE INDEX idx_users_email ON users(email);
-- 部分索引:只索引未删除的行
CREATE INDEX idx_active_users ON users(email)
WHERE deleted_at IS NULL;
-- 表达式索引
CREATE INDEX idx_lower_email ON users(LOWER(email));
-- GIN 索引:数组、JSONB、全文搜索
CREATE INDEX idx_tags ON products USING GIN(tags);
CREATE INDEX idx_meta ON products USING GIN(metadata);· 函数包住了列:
WHERE lower(email) = ? 用不了 email 上的普通索引——要么建表达式索引 ON t(lower(email)),要么改写条件;· 列被隐式转型:
WHERE id::text = '1' 同理失效,让参数类型对上列类型;· 前导通配符:
LIKE '%x' B-tree 无能为力(LIKE 'x%' 前缀匹配则可以)——需要子串搜索就上 pg_trgm 的 GIN 索引。判断方法只有一个:
EXPLAIN 跑一下看是 Index Scan 还是 Seq Scan,别靠猜。优化的第一步永远是看计划,而不是凭感觉加索引。EXPLAIN ANALYZE 给的是真实执行的耗时与行数。
怎么读一份计划
EXPLAIN ANALYZE 实际执行查询并显示真实耗时和扫描方式。看到 Seq Scan + 大量 Rows Removed by Filter,几乎肯定意味着缺索引。Index Only Scan(索引覆盖)是最快的扫描方式。
-- 查看执行计划
EXPLAIN SELECT * FROM orders WHERE user_id = 123;
-- 实际执行并显示真实耗时(注意:会真正执行!)
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE user_id = 123;
-- 常见扫描方式:
-- Seq Scan 全表扫描(大表出现 → 可能缺索引)
-- Index Scan 走索引 + 回表
-- Index Only Scan 索引覆盖,无需回表(最快)
-- Bitmap Heap Scan 索引位图 + 批量读取
-- Hash Join 大表 JOIN 常见
-- 更新统计信息 + 清理死元组(死元组 = MVCC 留下的旧行版本,见 10 章)
VACUUM ANALYZE orders;
-- 日常由 autovacuum 自动做,不用手动跑;下面两种情况才需要你出手:
-- ① 刚批量导入/大改一批数据,想立刻让规划器拿到新统计 → ANALYZE
-- ② 表明显膨胀(见下方 pitfall 的查法)→ 先查清 autovacuum 为何没跟上查法:
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC;——死元组占比长期居高、last_autovacuum 迟迟不更新,就是它没跟上。对症的做法是给这张表单独调低触发阈值(ALTER TABLE ... SET (autovacuum_vacuum_scale_factor = 0.05)),而不是拿 VACUUM FULL 硬来——后者会全程持有排他锁、锁住整张表,线上等于停服。EXPLAIN ANALYZE,不是猜。Seq Scan + Rows Removed 数字很大 → 该列需要索引。事务与并发
本章覆盖:事务语法与 SAVEPOINT(PG 的 DDL 可以回滚)、四个隔离级别各放过什么、行锁与咨询锁的七种写法、以及死锁与 idle in transaction 两个高频事故。
事务解决两件事:要么全成要么全败,以及并发时别互相看到半截状态。前者语法很少,后者全在隔离级别那张表里。
语法
| 要做的事 | 怎么写 |
|---|---|
| 开始 / 提交 / 回滚 | BEGIN; … COMMIT; · ROLLBACK; |
| 部分回滚 | SAVEPOINT sp1; … ROLLBACK TO sp1; · RELEASE sp1; |
| 设隔离级别 | BEGIN ISOLATION LEVEL REPEATABLE READ; |
| 只读事务 | BEGIN READ ONLY;——报表查询加上,防止误写 |
| 自动提交 | psql 与多数驱动默认每条语句一个事务;关掉写 \set AUTOCOMMIT off |
| DDL 也能回滚 | PG 的 CREATE TABLE / ALTER TABLE 可以放在事务里回滚——迁移脚本的关键能力(14 章) |
| 例外 | CREATE DATABASE · CREATE INDEX CONCURRENTLY · VACUUM 不能在事务块里 |
| 超时保护 | SET LOCAL statement_timeout = '5s'; · idle_in_transaction_session_timeout |
四个隔离级别
| 级别 | 脏读 | 不可重复读 | 幻读 | PG 的实际行为 |
|---|---|---|---|---|
READ UNCOMMITTED | 否 | 会 | 会 | PG 里等同于 READ COMMITTED,没有脏读 |
READ COMMITTED(默认) | 否 | 会 | 会 | 每条语句各取一次快照——同一事务里两次查同一行可能不同 |
REPEATABLE READ | 否 | 否 | 否 | 事务级快照;PG 这一级已经挡住幻读,代价是写冲突时抛 40001 |
SERIALIZABLE | 否 | 否 | 否 | 再加串行化异常检测;应用必须准备重试 40001 |
选型:默认的 READ COMMITTED 够用;「读出来算一下再写回去」这类逻辑要么上 REPEATABLE READ 并重试,要么用 SELECT … FOR UPDATE 锁住行,要么干脆写成一条 UPDATE … SET x = x + 1 让数据库自己算。
锁
| 写法 | 锁什么 | 用在哪 |
|---|---|---|
SELECT … FOR UPDATE | 行级排他 | 读出来准备改:库存扣减、余额变更 |
FOR NO KEY UPDATE | 弱一些的行锁 | 不改主键 / 唯一键时,与外键检查冲突更少 |
FOR SHARE | 行级共享 | 读期间不许别人改 |
FOR UPDATE SKIP LOCKED | 跳过已被锁的行 | 用表做任务队列的标准写法——多个 worker 各取各的 |
FOR UPDATE NOWAIT | 拿不到就立刻报错 | 不想干等 |
LOCK TABLE t IN … | 表级 | 很少需要手写 |
| 咨询锁 | pg_advisory_xact_lock(key) | 跨会话的互斥量,与具体行无关:定时任务防重跑 |
| 看谁被锁住 | SELECT * FROM pg_locks WHERE NOT granted; · pg_blocking_pids(pid) | 01 章 |
两个高频事故
| 现象 | 原因 | 改法 |
|---|---|---|
deadlock detected(40P01) | 两个事务以相反顺序锁同样两行 | 统一按固定顺序访问(比如永远按主键升序);缩短事务 |
连接卡在 idle in transaction | 应用开了事务却忘了提交 | 它会一直持有锁、还会阻塞 VACUUM;设 idle_in_transaction_session_timeout |
| 并发更新丢失 | 「读-算-写」三步之间被别人插了一脚 | FOR UPDATE、更高隔离级别 + 重试、或改成单条原子 UPDATE |
| 长事务拖垮清理 | 老快照让 VACUUM 无法回收死元组 | 事务要短;别在事务里做 HTTP 调用(13 章) |
READ COMMITTED 下,同一个事务里两次执行同一条 SELECT 可能得到不同结果——每条语句都取新快照。「先 SELECT 检查库存够不够,再 UPDATE 扣减」这个经典写法在并发下会超卖,而且低并发时永远测不出来。UPDATE accounts SET balance = balance - 100 WHERE id = 1 AND balance >= 100 RETURNING balance; 一句里同时完成了检查、扣减与取回结果,天然没有并发窗口,也不需要显式加锁。ACID 四个字母各自解决一类问题,而 SAVEPOINT 让「一个事务里的局部失败」不必整个回滚。
四个保证与局部回滚
Atomicity(原子性):全部成功或全部失败。Consistency(一致性):满足所有约束。Isolation(隔离性):并发事务互不干扰。Durability(持久性):提交后断电不丢。SAVEPOINT 允许事务内局部回滚。
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 'A';
UPDATE accounts SET balance = balance + 100 WHERE id = 'B';
COMMIT; -- 两条都成功才生效
-- 出错时回滚
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 'A';
ROLLBACK; -- 撤销,余额恢复
-- SAVEPOINT:事务内部分回滚
BEGIN;
INSERT INTO orders ...;
SAVEPOINT before_payment;
UPDATE inventory SET stock = stock - 1;
-- 支付失败!
ROLLBACK TO before_payment; -- 只撤销库存,订单保留
COMMIT;25P02 current transaction is aborted, commands ignored until end of transaction block,直到你 ROLLBACK。这和某些数据库「错一条继续跑下一条」的行为完全不同,在 psql 里手敲事务时最容易撞上。想让某一步失败不炸掉整个事务,就得先 SAVEPOINT,出错后 ROLLBACK TO 那个点。另一个更贵的坑:忘记提交。开了
BEGIN 却没 COMMIT 也没 ROLLBACK,这个连接就停在 idle in transaction 状态——它一直持有锁、并阻止 VACUUM 回收这段时间之后产生的死元组,一个挂了几小时的空闲事务足以让整个库开始膨胀。用 idle_in_transaction_session_timeout 兜底(见 14 章)。UPDATE accounts SET balance = balance - 100 WHERE id = $1 AND balance >= 100 一句话同时完成了「检查余额」和「扣减」,没有任何竞态;拆成先 SELECT 再 UPDATE 反而要靠事务和锁来补救。SAVEPOINT 的价值是让事务里的一部分失败不必整体重来——批量导入时每行一个 savepoint,坏数据跳过、好数据保留。代价是每个 savepoint 都有开销,别在几万行的循环里用。隔离级别决定的是「并发事务之间能看见彼此多少」——级别越高越安全,也越容易撞上串行化失败。
三个级别与两种加锁思路
PG 默认 READ COMMITTED:每条语句看到该语句开始时已提交的数据。REPEATABLE READ:整个事务内数据快照一致。SERIALIZABLE:最严格,等同串行执行。PG 不会发生脏读。行级锁 FOR UPDATE 防止并发修改,乐观锁(版本号检查)避免长时间持有锁。
深入理解:这一切靠 MVCC 撑着(第 00 章说的「读写互不阻塞」在此兑现)
- 不覆盖,只追加新版本:PG 的 UPDATE 从不就地改行,而是插入一个新版本、把旧版本标记为过期。每个事务按自己的快照只读到「对它可见的那个版本」——于是有了 PG 的招牌性质:读不阻塞写、写不阻塞读,SELECT 永远不必等 UPDATE。真正会互相阻塞的只有「两个事务写同一行」。
- 隔离级别 = 用哪个快照:READ COMMITTED 每条语句取一次新快照(所以能看到别人刚提交的),REPEATABLE READ 整个事务共用开始时的一个快照(所以前后读到的一致)。「PG 天然不脏读」正因为你只会看到已提交的版本。
- 代价是死元组:过期的旧版本会堆积成 dead tuple,占空间还拖慢扫描——这正是第 09 章
VACUUM要清理的东西(autovacuum 平时自动做)。表更新频繁却 vacuum 不及时 → 表膨胀(bloat),是 PG 运维绕不开的一件事。
-- 设隔离级别:必须在事务内,最省事的是直接写进 BEGIN
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- ... 本事务内所有语句看同一份快照 ...
COMMIT;
-- 等价写法:先 BEGIN 再 SET(SET 必须紧跟 BEGIN,且在任何查询之前)
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
COMMIT;
-- 改本连接的默认(之后每个事务都用它),PG 默认是 READ COMMITTED
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 行级锁:FOR UPDATE
BEGIN;
SELECT * FROM inventory WHERE product_id = 1 FOR UPDATE;
-- 其他事务的 UPDATE/DELETE/FOR UPDATE 会被阻塞
UPDATE inventory SET stock = stock - 1 WHERE product_id = 1;
COMMIT;
-- 乐观锁(避免长时间持有锁)
UPDATE inventory
SET stock = stock - 1, version = version + 1
WHERE product_id = 1 AND version = 5;
-- 影响行数为0 → 数据已被修改,需重试SET TRANSACTION ISOLATION LEVEL 单独一行执行是没有效果的,而且不会报错。它要求处在事务块内;裸执行时 psql 只给一句 warning,随后你 BEGIN 开的事务仍然是默认的 READ COMMITTED——你以为设上了,其实没有。所以要么写成 BEGIN ISOLATION LEVEL ...(最不容易错),要么 BEGIN 之后紧接着 SET、且必须在本事务的任何查询之前。另外 REPEATABLE READ 和 SERIALIZABLE 都可能让事务直接失败:并发冲突时 PG 会抛
40001 serialization_failure 中止其中一个。这不是 bug 而是设计——用这两个级别就必须在应用侧写重试循环,否则等于把偶发报错甩给了用户。SET stock = stock - 1),而不是先 SELECT 再 UPDATE。JSON 与数组
本章覆盖:JSONB 的取值 / 判断 / 修改三套运算符与函数、数组操作全表、GIN 索引与表达式索引怎么选,以及 json 与 jsonb 的区别。配上索引,很多场景一个库就够,不必再引入第二种存储。
PG 的 jsonb 与数组让「半结构化数据」不必再引入第二个数据库。运算符很多但分得很清:取值一套、判断一套、修改一套。
JSONB 取值
| 运算符 | 怎么写 | 返回 |
|---|---|---|
-> | data -> 'user' · data -> 0 | jsonb——还能继续往下取 |
->> | data ->> 'name' | text——要和字符串比较就用它 |
#> · #>> | data #>> '{user,address,city}' | 按路径深取,后者给 text |
| 下标语法 | data['user']['name'](PG 14+) | jsonb |
| 转类型 | (data ->> 'age')::int | 取出来永远是文本,比较数值前必须转 |
JSONB 判断与查询
| 运算符 | 含义 | 例子 |
|---|---|---|
@> | 包含 | WHERE data @> '{"status":"paid"}'——能走 GIN 索引,最常用 |
<@ | 被包含 | — |
? | 顶层有这个键 | WHERE data ? 'email' |
?| · ?& | 有任一 / 全部键 | data ?| array['a','b'] |
@? · @@ | JSON path 存在 / 为真 | data @? '$.items[*] ? (@.qty > 3)' |
| 展开数组 | jsonb_array_elements(data -> 'items') | 配 CROSS JOIN LATERAL 把 JSON 数组变成行(04 章) |
| 展开对象 | jsonb_each(data) · jsonb_object_keys(data) | — |
| 表格化 | JSON_TABLE(…)(PG 17+)· jsonb_to_recordset | 一次把 JSON 数组摊成多列 |
JSONB 修改与构造
| 要做的事 | 怎么写 |
|---|---|
| 合并 / 覆盖 | data || '{"a":1}'::jsonb——浅合并 |
| 删键 | data - 'a' · 删路径 data #- '{user,tmp}' |
| 设置深层值 | jsonb_set(data, '{user,age}', '30')——第四参数 true 表示键不存在时创建 |
| UPDATE 里用 | UPDATE t SET data = jsonb_set(data, '{n}', to_jsonb(5)) WHERE …; |
| 构造 | jsonb_build_object('a', 1, 'b', x) · jsonb_build_array(…) |
| 行转 JSON | to_jsonb(t) · row_to_json(t) |
| 聚合成 JSON | jsonb_agg(jsonb_build_object('id', id, 'n', name))(05 章) |
| 格式化输出 | jsonb_pretty(data) |
数组
| 要做的事 | 怎么写 |
|---|---|
| 声明与字面量 | tags text[] · ARRAY['a','b'] · '{a,b}'::text[] |
| 取元素 | tags[1]——下标从 1 开始;越界返回 NULL 而不报错 |
| 切片 | tags[1:2] |
| 长度 | array_length(tags, 1) · cardinality(tags) |
| 包含 | tags @> ARRAY['a']——走 GIN 索引 |
| 有交集 | tags && ARRAY['a','b'] |
| 某值在不在 | 'a' = ANY(tags)——可读但不走 GIN |
| 追加 / 去重 | array_append(tags,'c') · tags || 'c' · SELECT array_agg(DISTINCT x) |
| 展开成行 | unnest(tags)——配 CROSS JOIN 用 |
| 行聚成数组 | array_agg(x ORDER BY x) |
| 拼成字符串 | array_to_string(tags, ',') · 反向 string_to_array |
索引与选型
| 要加速什么 | 怎么建 |
|---|---|
data @> '{…}' 与 ? | CREATE INDEX ON t USING gin (data); |
只用 @>,想要更小的索引 | USING gin (data jsonb_path_ops);——更小更快,但不支持 ? |
| 某个固定字段的等值 / 范围 | CREATE INDEX ON t ((data ->> 'status'));——表达式索引,比 GIN 更有针对性 |
| 数组包含 | CREATE INDEX ON t USING gin (tags); |
json 还是 jsonb | 一律 jsonb:可索引、去重键、比较快;json 只在需要原样保留文本与键序时用 |
| 什么时候不该用 JSONB | 字段结构稳定、要做约束和外键、要 JOIN——那就是普通列该干的活 |
-> 和 ->> 差一个字符,结果类型完全不同。data -> 'name' = 'alice' 会报 operator does not exist: jsonb = text,而 data ->> 'name' = 'alice' 才对。链式取值时中间一律用 ->、最后一步才用 ->>。jsonb 字段」。全表只有一个 data jsonb 的设计看起来灵活,实际上放弃了类型检查、非空约束、外键和统计信息——规划器对 JSONB 内部的选择性估计也差得多。jsonb 让 PG 能装半结构化数据,而且能建索引——这正是「PG 一个人顶半个 NoSQL」说法的来源。
取值、更新与走索引的写法
-> 取值返回 jsonb,->> 取值返回 text。@> 包含操作符可走 GIN 索引。jsonb_set 更新字段,|| 合并对象,- 删除字段。
-- 取值:-> 返回 jsonb,->> 返回 text
SELECT
settings -> 'theme' AS theme_json, -- "dark"(jsonb)
settings ->> 'theme' AS theme_text, -- dark(text)
settings -> 'notifications' ->> 'email' AS email_pref
FROM users;
-- 路径访问
SELECT settings #>> '{notifications,email}' FROM users;
-- 查询:@> 包含操作符(可走 GIN 索引)
SELECT * FROM users WHERE settings @> '{"theme": "dark"}';
-- 更新
UPDATE users SET settings = jsonb_set(settings, '{theme}', '"light"');
-- 合并
UPDATE users SET settings = settings || '{"lang": "zh-CN"}'::jsonb;
-- 删除
UPDATE users SET settings = settings - 'theme';
-- 展开 JSON 数组为行
SELECT id, elem.value AS tag
FROM products, jsonb_array_elements_text(metadata -> 'tags') AS elem(value);
-- 注意展开的是 jsonb 列里的数组;products.tags 是 text[],那个要用 unnest(tags)
-- 聚合成 JSON
SELECT jsonb_agg(jsonb_build_object('id', id, 'name', name))
FROM products;jsonb 会重排键顺序、去掉重复键、也不保留空白——它存的是解析后的二进制结构而不是原文。'{"b":1,"a":2,"a":3}'::jsonb 得到的是 {"a": 3, "b": 1}(键排序了,重复的 a 只留最后一个)。如果你需要原样保存用户提交的 JSON 文本,那得用 json 类型或干脆存 text——但 json 不能建 GIN 索引,也没有 @> 这些运算符。日常九成场景仍应选 jsonb。-> 和 ->> 的区别是最高频的写错点:-> 返回 jsonb,->> 返回 text。链式取值时中间用 ->、最后一层用 ->>。用错了的典型症状是比较时莫名其妙不相等——因为 jsonb 的字符串是带引号的 "x",而你在拿它和 'x' 比。数组是 PG 的原生类型,不是把 JSON 当数组用——它有自己的操作符、自己的索引支持,还从 1 开始编号。
常用操作符与展开聚合
的四个边界:下标从 1 开始,arr[0] 不报错、返回 NULL;越界同样返回 NULL 而不是报错——所以「取第一个元素」写错成 [0] 时是静默失败。空数组上 array_length(arr, 1) 返回 NULL,而 cardinality(arr) 返回 0,判空用后者省一层 coalesce。最容易漏的是三值逻辑:3 = ANY(ARRAY[1, NULL]) 的结果是 NULL 不是 false(03 章),放进 WHERE 就等于不匹配,而 1 = ANY(ARRAY[1, NULL]) 照常是 true——又是一个只在含 NULL 时才发作的不对称行为。PG 数组从 1 开始索引。ANY 检查元素是否在数组中,@> 包含查询可走 GIN 索引,unnest 展开为行,array_agg 聚合为数组。
-- 基本操作
SELECT ARRAY['a', 'b', 'c'];
SELECT tags[1]; -- 第一个元素(从1开始!)
SELECT array_length(tags, 1);
-- 包含查询
SELECT * FROM products WHERE 'sale' = ANY(tags);
SELECT * FROM products WHERE tags @> ARRAY['sale']; -- 可走GIN
SELECT * FROM products WHERE tags && ARRAY['sale','new']; -- 有交集
-- 展开 / 聚合
SELECT id, unnest(tags) AS tag FROM products;
SELECT category, array_agg(name) FROM products GROUP BY category;
-- 追加 / 移除
UPDATE products SET tags = array_append(tags, 'new');
UPDATE products SET tags = array_remove(tags, 'old');
-- STRING_AGG:多行拼成字符串
SELECT category, string_agg(name, ', ' ORDER BY name)
FROM products GROUP BY category;arr[1] 才是第一个元素。这是 SQL 与大多数编程语言最容易搞混的差异之一,字符串的 substring 同理。而且越界不报错,返回
NULL:(array[1,2,3])[9] 得到 NULL 而不是异常——所以下标算错时不会当场发现,而是让 NULL 一路飘下去。array_length 还必须传维度参数:array_length(arr, 1),漏掉第二个参数会报错(因为 PG 支持多维数组)。空数组的 array_length 返回 NULL 而不是 0,判空要用 cardinality(arr) = 0 或 arr = '{}'。= ANY(array) 等价于 IN,但它能接一个参数。这在应用代码里很有用:WHERE id = ANY($1) 可以把一个数组作为单个参数传进去,而 IN ($1, $2, $3...) 需要按元素个数动态拼占位符。传 id 列表查询时,前者省事得多。数组适合存少量、不需要单独查询的同类值(标签、权限位)。一旦需要「按某个元素关联、统计、加约束」,就该老老实实建一张关联表——数组不是用来替代关系建模的。
PG 有两个 JSON 类型,名字只差一个字母,行为差别很大:json 存的是原始文本,jsonb 存的是解析后的二进制结构。九成场景该选 jsonb,但要知道它对你的数据做了什么。
同一段文本,存完不一样了
- 存入
{"b":1,"a":2,"a":3}(键乱序,且a重复): json读回来是{"b":1,"a":2,"a":3}——一个字节都没动,重复键都留着。jsonb读回来是{"a": 3, "b": 1}——键按顺序重排、重复键只留最后一个、空白被规范化。- 所以 jsonb 不能用来存「需要原样还原」的文档(比如要重新计算签名的报文)。那种场景要么用 json,要么直接存 text。
比较与索引:jsonb 的两个决定性优势
- 能按语义比较:
'{"a":1,"b":2}'::jsonb = '{"b":2,"a":1}'::jsonb为 true(键序无关);换成 json 只能比文本,同样两个值比出来是 false。 - 能建索引:GIN 索引只支持 jsonb,
@>包含查询才走得了索引。json类型上想查内容只能整行扫。 - 代价是写入时要解析一遍,插入略慢、体积略大——除非你在做纯日志式的高频写入,否则这点代价不值一提。
-> 和 ->> 差在哪
pg_typeof:->返回 jsonb,->>返回 text。- 取字符串字段时差别很直观:
'{"a":"x"}'::jsonb->'a'得到的是带引号的"x",->>得到的才是裸的x。拼进字符串或和 text 列比较时忘了这一点,就会得到「明明相等却匹配不上」。 - 取不存在的键不报错,返回 NULL。要链式取深层字段用
#>/#>>加路径数组,比一串->好读。
-- 同一段文本,两种类型存完不一样
SELECT '{"b":1,"a":2,"a":3}'::json::text;
-- {"b":1,"a":2,"a":3} 原样保留
SELECT '{"b":1,"a":2,"a":3}'::jsonb::text;
-- {"a": 3, "b": 1} 重排 + 去重 + 规范空白
-- jsonb 按语义比较:true;json 只能比文本:false
SELECT '{"a":1,"b":2}'::jsonb = '{"b":2,"a":1}'::jsonb;
-- -> 给 jsonb(带引号),->> 给 text(裸值)
SELECT '{"a":"x"}'::jsonb -> 'a'; -- "x"
SELECT '{"a":"x"}'::jsonb ->> 'a'; -- x
-- 深层路径:比一串 -> 好读
SELECT doc #>> '{user,addr,city}' FROM t;jsonb,只有两种情况选 json:需要原样还原(签名校验、要保留键序或重复键的第三方报文),或者只写不查的归档日志(省下解析开销)。拿不准就用 jsonb——后面想建索引、想按内容查时,改类型比改业务代码容易。视图与函数
本章覆盖:视图与可更新视图、物化视图的刷新与并发刷新(要唯一索引)、自定义函数的三种易变性标注、存储过程与函数的区别、SECURITY DEFINER 的注意事项。
视图是存起来的查询(每次都重算),物化视图是存起来的结果(要手动刷新)。函数则把逻辑推到数据库里执行——省一次网络往返,代价是逻辑散落两处。
视图
| 要做的事 | 怎么写 |
|---|---|
| 建 | CREATE VIEW paid_orders AS SELECT * FROM orders WHERE status = 'paid'; |
| 改 | CREATE OR REPLACE VIEW v AS …——只能加列不能删列改类型,否则要先 DROP |
| 可更新视图 | 单表、无聚合无 DISTINCT 的简单视图可以直接 INSERT / UPDATE |
| 复杂视图要可写 | 写 INSTEAD OF 触发器 |
| 安全视图 | WITH (security_barrier) · PG 15+ 的 security_invoker——决定按视图创建者还是调用者的权限执行(15 章 RLS) |
| 看定义 | \d+ viewname · SELECT pg_get_viewdef('v', true); |
物化视图
| 要做的事 | 怎么写 |
|---|---|
| 建 | CREATE MATERIALIZED VIEW daily AS SELECT … ; |
| 建但先不算 | … WITH NO DATA;——之后必须刷新才能查 |
| 刷新 | REFRESH MATERIALIZED VIEW daily;——期间整表加排他锁,查询会被阻塞 |
| 并发刷新 | REFRESH MATERIALIZED VIEW CONCURRENTLY daily;——不阻塞读,但要求视图上有唯一索引 |
| 加唯一索引 | CREATE UNIQUE INDEX ON daily (d, region); |
| 定时刷新 | pg_cron,或应用侧定时任务(00 章) |
| 适合 | 数据允许滞后的仪表盘、报表;不适合要求实时的场景 |
函数
| 要点 | 怎么写 |
|---|---|
| SQL 函数 | CREATE FUNCTION f(a int) RETURNS int LANGUAGE sql IMMUTABLE AS $$ SELECT a * 2 $$; |
| PL/pgSQL | LANGUAGE plpgsql AS $$ DECLARE … BEGIN … RETURN x; END $$;——要流程控制时用它 |
| 返回多行 | RETURNS TABLE (id int, name text) 或 RETURNS SETOF t |
| 三种易变性 | IMMUTABLE(同参数永远同结果,可用于表达式索引)· STABLE(同一语句内不变,只读)· VOLATILE(默认,可能有副作用) |
| 为什么要标 | 标错会让规划器错失优化;标成 IMMUTABLE 却其实会变,索引会给出错误结果 |
| 安全定义者 | SECURITY DEFINER——按函数所有者的权限执行;用它时必须固定 SET search_path = pg_catalog, public |
| 存储过程 | CREATE PROCEDURE p() LANGUAGE plpgsql AS $$ … $$; → CALL p();——与函数的区别是它内部可以 COMMIT(PG 11+) |
| 触发器 | 先写 RETURNS trigger 的函数,再 CREATE TRIGGER … EXECUTE FUNCTION f(); |
| 列出 | \df · \sf funcname 看源码 |
REFRESH MATERIALIZED VIEW 不加 CONCURRENTLY 会在刷新期间锁住整个视图,所有查它的请求一起等——而刷新一张大视图可能要几分钟。加 CONCURRENTLY 的前提是视图上有唯一索引,这一条要在建视图时就想到,否则上线后才发现刷不动。SELECT * FROM 订单明细。但别把视图套视图套到三层以上——规划器会把它们全部展开,你看到的是一个几百行的执行计划,出问题时无从下手。普通视图不存数据,物化视图存——一字之差,性能与新鲜度的取舍完全相反。
两种视图的取舍
普通视图是保存的查询(不存储数据),像虚拟表一样使用。物化视图实际存储查询结果,查询快但数据不会自动更新,需要手动/定时 REFRESH。CONCURRENTLY 刷新时不锁表(需唯一索引)。
-- 普通视图
CREATE VIEW active_user_summary AS
SELECT u.id, u.name,
COUNT(o.id) AS order_count,
COALESCE(SUM(o.total), 0) AS total_spent
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.deleted_at IS NULL
GROUP BY u.id, u.name;
SELECT * FROM active_user_summary WHERE total_spent > 1000;
-- 用途:隐藏敏感列 / 简化复杂查询 / 向后兼容旧接口
-- 物化视图
CREATE MATERIALIZED VIEW daily_sales AS
SELECT date_trunc('day', created_at) AS day,
SUM(total) AS revenue
FROM orders GROUP BY 1;
REFRESH MATERIALIZED VIEW daily_sales;
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_sales;
-- CONCURRENTLY 不锁表,但需要唯一索引EXPLAIN 里只看到最终展开的结果。视图嵌套超过两层就该警惕。物化视图则真的存数据,但它不会自动更新——建完那一刻的数据会一直停在那里,直到你手动
REFRESH。这是最常见的困惑来源:「为什么我改了源表,视图里还是旧数据」。REFRESH 默认会全程锁住这个视图,期间查不了。加 CONCURRENTLY 可以不阻塞读,但它要求视图上有唯一索引,否则报 55000 cannot refresh materialized view concurrently。建物化视图时顺手加一个唯一索引,省得以后返工。pg_cron 定时刷新 = 低成本仪表盘方案。写函数不难,难的是给对易变性标记——它直接决定优化器敢不敢缓存你的结果。
两种语言与三个易变性标记
SQL 函数简单逻辑,可被优化器内联。PL/pgSQL 函数支持 IF/LOOP/异常处理。易变性标记影响优化器决策:IMMUTABLE(纯函数,可缓存)、STABLE(同一查询内结果不变)、VOLATILE(每次可能不同,默认)。
-- SQL 函数(可被优化器内联)
CREATE FUNCTION full_name(first text, last text)
RETURNS text LANGUAGE sql IMMUTABLE AS $$
SELECT first || ' ' || last;
$$;
-- PL/pgSQL 函数(流程控制 + 异常处理)
CREATE FUNCTION transfer_funds(
from_acc uuid, to_acc uuid, amount numeric
) RETURNS void AS $$
DECLARE
bal numeric;
BEGIN
SELECT balance INTO bal
FROM accounts WHERE id = from_acc FOR UPDATE;
IF bal < amount THEN
RAISE EXCEPTION '余额不足: % < %', bal, amount;
END IF;
UPDATE accounts SET balance = balance - amount WHERE id = from_acc;
UPDATE accounts SET balance = balance + amount WHERE id = to_acc;
END;
$$ LANGUAGE plpgsql;比较稳妥的分界:数据完整性约束(触发器维护
updated_at、审计日志)和明显该贴着数据做的批量操作放数据库;业务流程放应用。还要注意 PL/pgSQL 里
RAISE EXCEPTION 会回滚整个事务(除非外层有 savepoint 或 EXCEPTION 块接住)——这通常正是你要的,但要清楚它的影响范围不止当前函数。IMMUTABLE(同样输入永远同样输出,如纯计算)、STABLE(同一条语句内结果不变,如读表)、VOLATILE(默认,每次调用都可能不同,如 random())。关键差别是:只有
IMMUTABLE 的函数才能用在表达式索引里,也只有 IMMUTABLE / STABLE 能被规划器提到循环外只算一次。不写就是 VOLATILE——一个本可以只算一次的纯函数,会被每行调用一次。另外,简单逻辑优先用 SQL 函数而不是 PL/pgSQL:SQL 函数能被内联进主查询一起优化,PL/pgSQL 是黑盒。
从应用连 PG
本章覆盖三件在 psql 里遇不到、却每个项目都必须做对的事:参数化查询(各语言占位符写法与注入反例)、连接池(池多大、PgBouncer 的限制)、事务与重试(该重试哪些 SQLSTATE、超时三件套)。
从代码里连 PG,有三件在 psql 里遇不到、却每个项目都必须做对的事:参数化查询(否则就是 SQL 注入)、连接池(否则连接数很快见底)、事务与重试(否则并发下偶发失败)。
参数化查询
| 语言 / 驱动 | 怎么写 |
|---|---|
| PG 原生占位符 | $1 $2——按位置,不是 ? |
| Python psycopg | cur.execute("SELECT * FROM t WHERE id = %s", (uid,))——%s 不是字符串格式化,参数必须是元组 |
| Node pg | client.query('SELECT * FROM t WHERE id = $1', [uid]) |
| Go pgx | conn.Query(ctx, "… WHERE id = $1", uid) |
| Java JDBC | PreparedStatement + setLong(1, uid)(这里是 ?) |
| 绝不能这么写 | "… WHERE id = " + userInput · Python 的 f"… {x}" · % 格式化 |
| 标识符不能参数化 | 表名 / 列名不能用占位符。必须动态时用白名单校验,或 psycopg.sql.Identifier 这类专门 API |
IN 列表 | 别拼字符串:PG 里写 WHERE id = ANY($1) 并传一个数组 |
| 为什么安全 | 参数与 SQL 分开发送,值永远不会被当成语法解析——顺带还能复用执行计划 |
连接池
| 要点 | 说明 |
|---|---|
| 为什么必须有 | PG 的每个连接是一个操作系统进程,建连接很贵;max_connections 默认只有 100 左右 |
| 应用内池 | Node pg.Pool · Python psycopg_pool · Go pgxpool · Java HikariCP |
| 外部池 | PgBouncer(transaction 模式最常用)——多实例部署或 serverless 必备 |
| 池多大 | 经验值 核数 × 2 + 有效磁盘数,常见是 10–20。调大往往更慢——PG 内部争用会上升 |
| 算总量 | 实例数 × 每实例池大小 ≤ max_connections - 预留;忘了乘实例数就是 53300 的由来(01 章) |
| PgBouncer 的坑 | transaction 模式下不能用预备语句、会话级 SET、咨询锁、LISTEN——驱动要关掉预备语句缓存 |
| serverless | 每个函数实例都开池会瞬间打满;走 PgBouncer 或厂商的 HTTP 代理 |
事务与重试
| 要点 | 怎么做 |
|---|---|
| 事务边界 | 尽量短:进事务前把数据准备好,别在事务里调 HTTP 或等用户输入 |
| 一定要释放 | 用语言的 with / defer / try-finally 确保 COMMIT 或 ROLLBACK 一定执行 |
| 该重试的错误码 | 40001(序列化失败)与 40P01(死锁)——这两个是「重试就能过」的(01、10 章) |
| 不该重试的 | 23505 唯一冲突、23503 外键、42* 语法与权限——重试多少次都一样 |
| 怎么重试 | 指数退避 + 抖动,上限 3–5 次;整个事务重做,不是只重发失败那一条 |
| 超时三件套 | statement_timeout(单条语句)· lock_timeout(等锁)· idle_in_transaction_session_timeout(忘了提交) |
| 怎么设 | SET LOCAL statement_timeout = '5s'; 在事务内,或连接串里 options=-c%20statement_timeout%3D5s |
| 取消查询 | 驱动层传 context / cancel;服务端 pg_cancel_backend(pid) |
还值得知道的
| 要做的事 | 怎么写 |
|---|---|
| 批量插入 | COPY 最快(驱动多有 copy_from);其次是一条多 VALUES 的 INSERT;循环单条插入最慢 |
| 拿回自增主键 | INSERT … RETURNING id——不需要额外查一次 |
| 幂等写入 | INSERT … ON CONFLICT (k) DO NOTHING/UPDATE(15 章) |
| ORM 的 N+1 | 循环里逐条查关联——用 JOIN 或 WHERE id = ANY($1) 一次取回 |
| 健康检查 | SELECT 1;池通常内置 |
| 看连接来自哪 | SELECT application_name, count(*) FROM pg_stat_activity GROUP BY 1;——连接串里设 application_name,排查时能立刻定位是哪个服务 |
cur.execute(f"SELECT … WHERE name = '{name}'") 与 cur.execute("… = %s" % name) 都是字符串拼接,都能被注入。正确写法是把参数交给第二个实参:cur.execute("… = %s", (name,))——注意那个逗号,少了它就不是元组,psycopg 会报参数类型错。application_name(?application_name=order-service)。出问题时 pg_stat_activity 与慢查询日志里会带上它,「是哪个服务把连接打满的」从猜变成一眼可见——成本只有连接串里多几个字符。把用户输入拼进 SQL 字符串,是安全事故排行榜上常年第一的写法。正确做法只有一个:把 SQL 和数据分开发给数据库——SQL 里写占位符 $1,数据单独作为参数传。这样数据库先解析完 SQL 结构,再把参数当纯粹的值填进去,参数里写什么都不可能改变语句的含义。
拼接为什么危险:一次
表里有 2 行。查询意图是「按 id 查一行」,用户传入的 id 是字符串 1 OR 1=1:
- 拼接:
WHERE id = 1 OR 1=1—— 条件恒真,返回全部 2 行。真实场景里这就是「查自己的订单」变成「查所有人的订单」; - 参数化:整个
"1 OR 1=1"被当作一个值去转成 integer,直接报22P02 invalid input syntax for type integer——攻击在类型转换那一步就死了。
占位符不能用在表名列名上
$1 只能代表值,不能代表标识符——SELECT * FROM $1 会直接报 42601 syntax error at or near "$1"。所以「按用户选择的列排序」这类需求没法靠参数化解决,只有两条路:① 用白名单(把允许的列名写死在代码里,用户传的值只用于查表,这是首选);② 确实要动态拼时,用 PG 的 format() 配 %I,它会正确加引号并转义。
-- ❌ 绝对不要这样(无论哪种语言、哪个框架)
-- "SELECT * FROM users WHERE id = " + userInput
-- ✅ SQL 里写 $1 $2,值单独传
SELECT * FROM users WHERE id = $1 AND status = $2;
-- Node.js(node-postgres)
-- await pool.query('SELECT * FROM users WHERE id = $1', [userId]);
-- Python(psycopg 3)——注意占位符是 %s,不是 $1,且第二参必须是元组
-- cur.execute("SELECT * FROM users WHERE id = %s", (user_id,))
-- 表名/列名要动态:先白名单,别直接拼
-- const ALLOWED = { created: 'created', amount: 'amount' };
-- const col = ALLOWED[req.query.sort] ?? 'created'; // 查不到就用默认
-- 实在要在 SQL 内部拼标识符,用 format 的 %I(自动加引号并转义)
SELECT format('SELECT * FROM %I WHERE %I = $1', 'users', 'id');
-- %I = 标识符,%L = 字面量,%s = 原样插入(%s 不安全,别用它拼用户输入)$1 $2;node-postgres 用 $1;psycopg 用 %s;有些库用 ?。特别注意 psycopg 的 %s 不是 Python 的字符串格式化——写成 cur.execute("... id = %s" % user_id)(用了 % 运算符)就变回了字符串拼接,看着像参数化,实则完全没有防护。正确写法是把值作为第二个参数传给 execute。还有一个易错点:psycopg 传单个参数时必须写成元组
(user_id,),漏掉那个逗号就不是元组了。另外,ORM 和查询构造器默认就是参数化的——用 Prisma、SQLAlchemy、Ecto 写查询天然安全。要小心的是它们提供的「原始 SQL」逃生口(
$queryRawUnsafe、text() 之类),名字里带 raw / unsafe 的那些方法就是把责任交回给你了。PG 的每个连接都是一个独立的操作系统进程(不是线程),建立一个连接要 fork 进程、分配内存,成本远高于大多数人的直觉。max_connections 默认只有 100,而且把它调大并不能解决问题——进程越多,上下文切换和内存开销越大,吞吐反而下降。
不用连接池会发生什么
- 每个请求新建一个连接:连接建立的开销可能比查询本身还大,QPS 上不去;
- 并发一高就撞上限:报
53300 too many connections for role,此时整个服务是完全不可用的,而不是变慢; - 更隐蔽的是连接泄漏——出错路径上忘了释放连接,连接数缓慢爬升,服务运行几小时后突然全面崩溃。
池子该开多大
直觉上「越大越好」是错的。经验起点是 CPU 核数 × 2 到 4,几十就足够支撑很高的 QPS——因为查询大多是毫秒级,一个连接一秒能服务几百个请求。池子过大反而更糟:几百个连接同时争抢 CPU 和磁盘,每个都变慢,还可能把数据库内存耗尽。
算总量时别忘了乘以实例数:10 个应用实例每个开 20 连接,就是 200 个连接——已经是默认上限的两倍。这是 K8s 环境下最常见的出错方式。
-- 看当前连接情况
SHOW max_connections; -- 默认 100
SELECT count(*) FROM pg_stat_activity; -- 当前连接数
-- 按状态分组,排查连接是被谁占着的
SELECT state, count(*) FROM pg_stat_activity GROUP BY state;
-- idle 空闲,正常(池子里待命的连接)
-- active 正在跑查询
-- idle in transaction ⚠️ 开了事务却不提交——这个数字持续大于 0 就要查
-- 找出长时间挂着的事务(它们会阻止 VACUUM 回收空间)
SELECT pid, state, now() - xact_start AS 事务时长, query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY xact_start;
-- Node.js:用 Pool 而不是 Client,全进程共用一个
-- const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 20 });
-- const { rows } = await pool.query('SELECT 1'); // 自动借还,不用手动管SET 设的会话变量、LISTEN/NOTIFY、临时表、advisory lock 都不再可靠——它们的生命周期跟着「会话」,而你已经没有稳定的会话了。预备语句这一条要看版本:PgBouncer 1.21(2023-10)起支持在 transaction 模式下托管协议级预备语句,配上
max_prepared_statements 即可正常使用;更早的版本则会报出看起来毫无道理的 prepared statement "s1" does not exist,需要在驱动侧关掉预备语句(psycopg 用 prepare_threshold=None;node-postgres 默认就不做,只有显式给 query 传 name 时才用)。用 PgBouncer 之前先确认它的版本和你的驱动怎么配。另一个独立的坑:连接泄漏。凡是手动
connect() 拿连接的地方,释放都必须写在 finally 里,否则一条异常路径就会永久占住一个连接。能用 pool.query() 就别手动借连接。PgBouncer 的
transaction 池化模式最常用:连接只在事务期间被独占,事务一结束就还回池子,复用率极高。在 psql 里手敲 BEGIN / COMMIT 很直观,但在代码里有两条额外的纪律:同一个事务的所有语句必须走同一个连接,以及任何异常路径都必须回滚。漏掉后者,那个连接会一直挂着未完成的事务(就是本章前面里的 idle in transaction),既占着连接又阻止 VACUUM 回收空间。
用连接池时最容易犯的错
pool.query() 每次调用都可能拿到不同的连接。所以下面这样写事务是完全无效的:
await pool.query('BEGIN'); await pool.query('UPDATE ...'); await pool.query('COMMIT');
三条语句可能落在三个不同连接上——BEGIN 开在 A 连接、UPDATE 跑在 B 连接(自动提交,无法回滚)、COMMIT 提交了 C 连接上什么都没有的空事务。必须先从池子里显式借一个连接,全程用它。
哪些错误该重试
40001serialization_failure /40P01deadlock_detected —— 应该重试。这是 PG 在并发下的正常行为,不是 bug;用 REPEATABLE READ 或 SERIALIZABLE 就必须写重试循环;23505唯一冲突 —— 通常不该重试,而是转成业务错误(「邮箱已被注册」);42xxx 语法/字段错 —— 重试多少次都一样,直接报错。
重试要带指数退避加随机抖动,否则几个冲突的事务会同时重试、继续互相撞。
-- 代码里事务的标准骨架(Node.js / node-postgres):
-- const client = await pool.connect(); // 从池子借一个连接
-- try {
-- await client.query('BEGIN');
-- await client.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [amt, from]);
-- await client.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [amt, to]);
-- await client.query('COMMIT');
-- } catch (e) {
-- await client.query('ROLLBACK'); // 任何异常都要回滚
-- throw e;
-- } finally {
-- client.release(); // 无论如何都要还回去
-- }
-- 按错误码分流,别去匹配消息文本(消息会随版本和语言变)
-- if (e.code === '23505') throw new BusinessError('邮箱已被注册');
-- if (e.code === '40001' || e.code === '40P01') return retryWithBackoff();
-- 顺带:能用一条语句表达的就别开事务(10 章讲过),上面这套骨架
-- 只在「多条语句必须同生共死」时才需要
-- 幂等写入:重试安全的插入
INSERT INTO events (idempotency_key, payload) VALUES ($1, $2)
ON CONFLICT (idempotency_key) DO NOTHING;另一个高频问题:在事务里做了很多次单条
INSERT。一万次单条插入是一万次网络往返,慢得无法接受。改成一条多值 INSERT(VALUES (...), (...), (...),一批几百到几千行)或者用 COPY ... FROM STDIN(应用侧走的是这条,不是读服务端文件的那个 COPY,见 01 章)——它是批量导入最快的路径,能快出一个数量级。INSERT 到底有没有生效——重试可能重复下单,不重试可能丢单。让客户端生成一个唯一的 idempotency_key 随请求带上,服务端用它做唯一约束配 ON CONFLICT DO NOTHING,重复请求就自然变成空操作。这也是支付类 API 的通用做法。另外,事务要尽可能短:绝不要在事务里调用外部 HTTP 接口或等待用户输入。一个挂了 30 秒的事务会持有锁、阻塞别人、还让 VACUUM 无法回收这段时间内的死元组。
运维、备份与迁移
本章覆盖:pg_dump 与物理备份 / PITR 的选型与命令、迁移脚本的规矩、哪些 DDL 会锁表以及安全做法、上线后该看的八个系统视图。没恢复过的备份等于没有备份。
运维的这四件事迟早都会遇到,而且每一件第一次做的时候都会踩同一个坑。
备份与恢复
| 要做的事 | 怎么写 |
|---|---|
| 逻辑备份(单库) | pg_dump -Fc -d mydb -f mydb.dump——-Fc 自定义格式,可并行恢复、可选择性恢复 |
| 纯 SQL 备份 | pg_dump -d mydb -f mydb.sql——可读,但恢复只能整份跑 |
| 只要结构 / 只要数据 | --schema-only · --data-only |
| 指定表 | -t public.users -t public.orders |
| 全实例(含角色) | pg_dumpall——pg_dump 不含角色与表空间,这是最常见的遗漏 |
| 恢复 | pg_restore -d newdb -j 4 mydb.dump——-j 并行 |
| 恢复 SQL 版 | psql -d newdb -f mydb.sql |
| 物理备份 | pg_basebackup -D /backup -Fp -Xs -P;PG 17 起支持增量备份(--incremental) |
| 时间点恢复(PITR) | 物理基准备份 + WAL 归档(archive_mode / archive_command),恢复时设 recovery_target_time |
| 选哪个 | 小库、跨版本、只要几张表 → 逻辑;大库、要秒级 RPO → 物理 + WAL |
| 验证 | 没恢复过的备份等于没有备份——定期拿备份在另一台机器上还原一次 |
迁移(schema 演进)
| 规矩 | 说明 |
|---|---|
| 工具 | Flyway · Liquibase · Alembic · golang-migrate · Prisma Migrate——选哪个不重要,有一个就行 |
| 一次一个文件 | V001__create_users.sql,只追加不修改已发布的文件 |
| 放进事务 | PG 的 DDL 可以回滚,所以一个迁移文件应当整体成功或整体失败(10 章) |
| 例外 | CREATE INDEX CONCURRENTLY 不能在事务里——单独一个文件,并让工具跳过事务包裹 |
| 可回滚 | 每个 up 配一个 down;但破坏性操作(删列)的 down 找不回数据——先改成不用再删 |
| 删列的安全节奏 | ① 代码不再读写它 → ② 上线观察 → ③ 再 DROP COLUMN |
| 改列名同理 | 加新列 → 双写 → 回填 → 切读 → 删旧列 |
线上 DDL:哪些会锁表
| 操作 | 锁 | 安全做法 |
|---|---|---|
ADD COLUMN(无默认值 / PG 11+ 有默认值) | 瞬时 | 可以直接做 |
ALTER COLUMN TYPE | 重写全表 + 排他锁 | 加新列 → 回填 → 切换 |
ADD CONSTRAINT … CHECK | 全表校验期间锁 | NOT VALID 加上,再 VALIDATE CONSTRAINT(02 章) |
ADD FOREIGN KEY | 同上 | 同上 |
CREATE INDEX | 阻塞写 | 必须 CONCURRENTLY(09 章) |
SET NOT NULL | 全表扫 | PG 12+ 先加等价的 CHECK … NOT VALID 并校验,再设 NOT NULL 即可跳过扫描 |
| 任何 DDL | — | 先 SET lock_timeout = '3s':拿不到锁就失败,而不是排在长查询后面把所有请求一起堵死 |
上线之后看什么
| 要看的 | 怎么查 |
|---|---|
| 慢查询 | pg_stat_statements 按 total_exec_time 排序——先装它(00 章) |
| 当前在跑什么 | SELECT pid, now()-query_start AS dur, state, query FROM pg_stat_activity ORDER BY dur DESC; |
| 表大小 | SELECT relname, pg_size_pretty(pg_total_relation_size(oid)) FROM pg_class ORDER BY pg_total_relation_size(oid) DESC LIMIT 10; |
| 膨胀与清理 | SELECT relname, n_dead_tup, last_autovacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC; |
| 缓存命中率 | pg_stat_database 的 blks_hit / (blks_hit + blks_read)——长期低于 0.99 说明内存不够 |
| 没用的索引 | pg_stat_user_indexes 里 idx_scan = 0(09 章) |
| 复制延迟 | pg_stat_replication 的 replay_lag |
| 连接数 | SELECT count(*) FROM pg_stat_activity; 对比 SHOW max_connections;(13 章) |
| 事务 ID 回卷 | SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY 2 DESC;——接近 20 亿就是急事 |
pg_dump 不备份角色、权限和表空间——它只导一个库的内容。只用它做备份的团队,在真正需要恢复那天会发现用户和权限全没了。完整备份要配 pg_dumpall --globals-only,或者干脆走物理备份。pg_stat_statements(慢查询从「靠猜」变成「按耗时排序」),给所有 DDL 加 SET lock_timeout(改结构失败总比把线上堵死好)。备份分两种,解决的问题不同,正经的生产环境两种都要有。
逻辑备份:pg_dump
- 导出的是重建数据所需的 SQL 语句(或自定义压缩格式),跟具体 PG 版本、操作系统、硬件都无关;
- 优点:可跨版本、跨平台恢复,能只导单表、能人工阅读;
- 缺点:大库很慢,恢复时要重建索引,几百 GB 以上就不实用了;
- 适合:中小库的日常备份、迁移到新版本、把生产数据拉一份到本地。
物理备份:pg_basebackup + WAL 归档
- 复制的是数据目录的字节,再配合持续归档的 WAL(预写日志);
- 优点:大库也快,而且支持 PITR(时间点恢复)——可以恢复到「昨天下午 3 点 05 分」这样的任意时刻,误删数据时这是唯一的救命稻草;
- 缺点:只能恢复到同版本同平台,不能跨大版本;
- 适合:生产环境的主力备份方案。云托管数据库(RDS/Neon/Supabase)的自动备份基本都是这一类。
没演练过的备份等于没有备份
备份脚本跑成功 ≠ 能恢复。定期真的恢复一次到测试环境,是唯一能验证备份有效的办法——常见的失败情形包括:备份文件损坏但脚本没检查、漏备了大对象或某个 schema、恢复时才发现扩展没装、恢复耗时远超预期(RTO 不达标)。
# —— 逻辑备份 ——
# 自定义格式(推荐):已压缩,可并行恢复,可选择性恢复单表
pg_dump -Fc -d "$DATABASE_URL" -f backup.dump
# 纯 SQL 文本(可读,可直接用 psql 灌回去)
pg_dump -d "$DATABASE_URL" -f backup.sql
# 只导结构 / 只导数据 / 只导一张表
pg_dump --schema-only -d "$DATABASE_URL" -f schema.sql
pg_dump --data-only -d "$DATABASE_URL" -f data.sql
pg_dump -t orders -d "$DATABASE_URL" -f orders.dump -Fc
# 恢复(-j 4 并行,大库能快很多)
pg_restore -d "$DATABASE_URL" -j 4 backup.dump
psql -d "$DATABASE_URL" -f backup.sql # 纯 SQL 用这个
# 整个实例的所有库 + 角色(pg_dump 不含角色,容易漏)
pg_dumpall -f all.sql
# —— 物理备份 ——
pg_basebackup -D /backup/base -Ft -z -P
# 再配合 postgresql.conf 里的 archive_mode / archive_command 持续归档 WAL,
# 才能做时间点恢复(PITR)。云托管数据库一般已经替你配好了。pg_dump 要用目标版本的,不是源版本的。从 PG 15 迁到 PG 18,应该用 18 的 pg_dump 去导 15 的库——新版工具懂旧版格式,反过来不成立。用旧版工具导新版库会报 server version mismatch。还有个特别容易忽略的点:恢复到一个已有数据的库上,默认不会先清空。
pg_restore 会尝试建表,撞上已存在的表就报错,最后留下一个半新半旧的数据库。要么恢复到全新的空库,要么显式加 --clean --if-exists。pg_dump 不会阻塞读写——它在一个可重复读的快照里工作,所以可以在线上直接跑,只是会增加 I/O 压力,建议挑低峰期。但它确实会持有一个长事务,从而在导出期间阻止 VACUUM 回收死元组,超大库导一晚上可能带来明显膨胀。另外记住
pg_dump 不包含角色和权限(那是实例级的东西),只导库内对象。完整迁移要配上 pg_dumpall --roles-only,否则恢复后会发现用户全没了。表结构会一直变。手动在生产库上敲 ALTER TABLE 是最糟的做法——没有记录、无法重放、各环境很快就不一致。正确做法是把每次结构变更写成带版本号的迁移脚本,进 Git,由工具按顺序执行。
PG 的一个独门优势:DDL 也能回滚
大多数数据库的 ALTER TABLE 是隐式提交、无法撤销的,PG 不是——建表、加列、加约束全都能放进事务里回滚(事务内加一列然后 ROLLBACK,那一列确实不存在)。这意味着一个迁移脚本要么全部生效,要么什么都不留,绝不会卡在中间状态。这是 PG 做迁移比别的库省心的根本原因。
但有一类 DDL 会锁表
在小表上瞬间完成的操作,到了亿行大表上可能锁死几分钟——而 ALTER TABLE 要的是排他锁,期间连读都被阻塞。要点:
- 加列带默认值:PG 11 起不再重写全表,很快——判据是默认值表达式的 volatility 而不是「常量还是函数」:
IMMUTABLE与STABLE(含now()、current_timestamp)都走快速路径,只有VOLATILE(random()、clock_timestamp())才重写整张表; - 改列类型:几乎总是重写全表,大表上要用「加新列 → 双写 → 回填 → 切换」的分步方案;
- 加索引:普通
CREATE INDEX会锁写,线上必须用CONCURRENTLY; - 加 NOT NULL / 外键:要全表校验。可以先加
NOT VALID的约束(立即生效、只对新数据校验),再择机VALIDATE CONSTRAINT(只加 ShareUpdateExclusiveLock,不阻塞读写,但会挡住并发 DDL 与 VACUUM)。
-- PG 的 DDL 是事务性的:整个迁移要么全成,要么什么都不留
BEGIN;
ALTER TABLE users ADD COLUMN phone text;
CREATE INDEX idx_users_phone ON users(phone);
UPDATE schema_version SET v = 42;
COMMIT; -- 中途任何一步失败,ROLLBACK 后一切如初
-- 线上加索引必须用 CONCURRENTLY:不阻塞读写
CREATE INDEX CONCURRENTLY idx_orders_created ON orders(created);
-- ⚠️ 但它不能在事务里跑,报错:
-- 25001 CREATE INDEX CONCURRENTLY cannot run inside a transaction block
-- 而绝大多数迁移工具默认把每个迁移包在事务里 —— 要单独标注
-- 加外键/NOT NULL 时避免全表校验阻塞:分两步
ALTER TABLE orders ADD CONSTRAINT fk_user
FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID; -- 立即返回
ALTER TABLE orders VALIDATE CONSTRAINT fk_user; -- 慢,但不阻塞读写
-- 给长时间 DDL 加个保险,别让它无限期地卡住整张表
SET lock_timeout = '5s'; -- 拿不到锁就放弃,而不是排队堵死所有人CREATE INDEX CONCURRENTLY 和迁移工具默认行为直接冲突。它不能在事务块里执行(报 25001),而迁移工具几乎都默认把每个迁移文件包进事务。绝大多数工具提供了关闭开关(Flyway 的 executeInTransaction=false、Alembic 的 autocommit_block、golang-migrate 的 -x 注释),写含 CONCURRENTLY 的迁移前先查一下你的工具怎么关。还要知道:
CONCURRENTLY 建索引可能失败,并留下一个 INVALID 状态的废索引——它不会被查询使用,却照常占空间、拖慢写入。用 \d 表名 能看到 INVALID 标记,发现了就 DROP INDEX 掉重建。建完索引后顺手确认一下状态,这一步很多人不知道。两条通用纪律:① 迁移只往前,不改历史——已经跑过的脚本绝不修改,要撤销就新写一个反向迁移;② 先加后删,分两次发布——要删列或改名时,先发一版让代码不再用它,确认无碍后再发一版真正删掉。这样任何一步都能安全回滚。
性能问题的排查顺序永远是:先找出哪条查询慢,再用 EXPLAIN 看它为什么慢。跳过第一步直接猜,是最常见的时间浪费。
四个应该定期看一眼的地方
pg_stat_statements——记录每条 SQL 的累计耗时与调用次数。装上它是运维 PG 的第一件事。它最大的价值是能发现「单次只要 5ms、但一秒被调用两千次」这类查询——这种问题看慢查询日志永远发现不了;- 慢查询日志——设
log_min_duration_statement = 200ms,超过就记录。适合抓偶发的、有具体上下文的慢查询; - 表膨胀——
pg_stat_user_tables里的n_dead_tup长期高、last_autovacuum迟迟不更新,说明 autovacuum 没跟上(见 09 章); - 没被用过的索引——
idx_scan = 0的索引只有成本没有收益:占磁盘、拖慢每一次写入。删掉前先确认统计周期够长,别把「季度报表才用一次」的索引误删了。
-- 装上 pg_stat_statements(需要在 postgresql.conf 里加到
-- shared_preload_libraries 并重启,云托管一般已经开好了)
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- 按总耗时排:谁吃掉了数据库的时间
SELECT left(query, 60) AS q, calls,
round(total_exec_time::numeric) AS 总毫秒,
round(mean_exec_time::numeric, 2) AS 平均毫秒
FROM pg_stat_statements
ORDER BY total_exec_time DESC LIMIT 10;
-- 按 total 排而不是 mean —— 高频的小查询才是隐形大头
-- 死元组与 autovacuum 是否跟得上
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC LIMIT 10;
-- 从没被用过的索引(跑够一个业务周期再判断)
SELECT relname, indexrelname, idx_scan,
pg_size_pretty(pg_relation_size(indexrelid)) AS 大小
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;
-- 表和库有多大
SELECT pg_size_pretty(pg_total_relation_size('orders')); -- 含索引
SELECT pg_size_pretty(pg_database_size(current_database()));
-- 谁在等锁(查询卡住时先看这个)
SELECT pid, state, wait_event_type, left(query, 60)
FROM pg_stat_activity WHERE wait_event_type = 'Lock';INSERT / UPDATE / DELETE 都要多维护一份数据结构。写多读少的表上堆七八个索引,写入性能会明显下滑,而其中大半可能从没被用过——这正是要定期查 idx_scan = 0 的原因。反过来,也有一类索引是该建却常被漏掉的:外键列不会自动获得索引,它带来的「删父行慢到不可理喻」在 02 章讲过——排查写入慢时值得回头查一遍所有外键列。
statement_timeout 让跑飞的查询自己死掉而不是拖垮整库;idle_in_transaction_session_timeout 清理掉忘记提交的事务(它们会一直阻止 VACUUM 回收空间)。两个都可以按角色设:ALTER ROLE app SET statement_timeout = '30s';ALTER ROLE app SET idle_in_transaction_session_timeout = '60s';给后台批处理单独建一个角色、配更宽松的超时,是很常见的做法。
现代 PG 特性
本章覆盖 PG 相对其它数据库真正拉开差距的三处:ON CONFLICT 与 MERGE、全文搜索三件套(tsvector / tsquery / GIN,含中文的处理)、角色权限与行级安全。一个库同时解决了写入去重、搜索与多租户隔离。
这一章是 PG 相对其它数据库真正拉开差距的地方:一个库同时把「写入去重」「搜索」「多租户隔离」都解决了。
UPSERT 与高级写入
| 要做的事 | 怎么写 |
|---|---|
| 有就跳过 | INSERT INTO t (k, v) VALUES ($1,$2) ON CONFLICT (k) DO NOTHING; |
| 有就更新 | … ON CONFLICT (k) DO UPDATE SET v = EXCLUDED.v, updated_at = now();——EXCLUDED 指本来要插入的那一行 |
| 带条件更新 | … DO UPDATE SET v = EXCLUDED.v WHERE t.updated_at < EXCLUDED.updated_at; |
| 冲突目标 | 必须是唯一索引或约束的列;也可写 ON CONFLICT ON CONSTRAINT c_name |
| 部分唯一索引 | ON CONFLICT (k) WHERE deleted_at IS NULL DO … |
| 拿回结果 | 加 RETURNING *——DO NOTHING 跳过时不返回任何行,这点常被忽略 |
MERGE(PG 15+) | MERGE INTO t USING src ON t.k = src.k WHEN MATCHED THEN UPDATE … WHEN NOT MATCHED THEN INSERT …; |
MERGE vs ON CONFLICT | 批量同步用 MERGE(能表达删除分支);单行幂等写入用 ON CONFLICT(并发下更稳,MERGE 不保证无并发异常) |
| 批量导入 | COPY t FROM STDIN——比 INSERT 快一个数量级(13 章) |
全文搜索
| 要点 | 怎么写 |
|---|---|
| 分词 | to_tsvector('english', body)——第一个参数是分词配置 |
| 查询 | to_tsquery('english', 'cat & dog') · plainto_tsquery(用户输入)· websearch_to_tsquery(支持引号与 - 排除,最适合搜索框) |
| 匹配 | WHERE to_tsvector('english', body) @@ websearch_to_tsquery('english', $1) |
| 索引 | 加一列 tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED,再 CREATE INDEX ON t USING gin (tsv); |
| 排序 | ORDER BY ts_rank(tsv, q) DESC |
| 高亮 | ts_headline('english', body, q) |
| 中文 | 内置分词不支持中文——要装 zhparser / pg_jieba,或用 pg_trgm 做子串匹配(09 章) |
| 什么时候该换专门的搜索引擎 | 要打分调优、同义词、聚合分面、跨库搜索时;否则 PG 全文搜索足够,还省一个系统 |
角色与权限
| 要做的事 | 怎么写 |
|---|---|
| 建角色 | CREATE ROLE app LOGIN PASSWORD 'x';——「用户」和「组」在 PG 里都是角色 |
| 给库 / schema | GRANT CONNECT ON DATABASE d TO app; · GRANT USAGE ON SCHEMA public TO app; |
| 给表 | GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO app; |
| 以后建的表也要给 | ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO app;——漏了这条,新表对 app 不可见 |
| 只读角色 | GRANT SELECT + ALTER DEFAULT PRIVILEGES … GRANT SELECT |
| 收回 | REVOKE …;查权限 \dp tablename |
| PG 15 的改动 | public schema 默认不再允许所有人建表——升级后「权限突然不够」多半是这条(00 章) |
行级安全(RLS)
| 步骤 | 怎么写 |
|---|---|
| 开启 | ALTER TABLE docs ENABLE ROW LEVEL SECURITY; |
| 建策略 | CREATE POLICY p ON docs USING (owner_id = current_setting('app.user_id')::bigint); |
| 区分读写 | USING (…) 管能看到哪些行,WITH CHECK (…) 管能写入哪些行 |
| 按动作 | FOR SELECT / INSERT / UPDATE / DELETE / ALL |
| 传租户 | 每个请求开头 SET LOCAL app.user_id = '42';——LOCAL 很关键,否则会随连接池串到别的请求 |
| 表所有者绕过 | 默认 RLS 对表所有者不生效;要一视同仁写 ALTER TABLE … FORCE ROW LEVEL SECURITY; |
| 典型用途 | 多租户 SaaS——把隔离规则放进数据库,应用层漏一个 WHERE 也不会串数据 |
| 代价 | 每条查询都要带上策略条件,策略里的表达式必须能走索引,否则全表扫 |
SET 而不是 SET LOCAL 传租户 ID,在连接池下会串数据。SET 的作用域是整个会话,而连接用完会还回池子给下一个请求——于是 B 请求拿到了 A 的租户身份。SET LOCAL 只在当前事务内有效,这就是它必须配事务使用的原因。ON CONFLICT DO UPDATE 里的 EXCLUDED 是个专用别名,指「本来要插入但被挡下的那一行」。写幂等接口时这一句就够:INSERT … ON CONFLICT (external_id) DO UPDATE SET … RETURNING *——重复调用不会产生第二行,也不需要先查一次。ON CONFLICT 把「先查再决定插还是更新」这一整套竞态逻辑压成了一条原子语句。
两种冲突处理与 MERGE
ON CONFLICT DO UPDATE 实现「有则更新、无则插入」;DO NOTHING 用于幂等插入;RETURNING 插入后立即返回生成的值;MERGE(PG15+)是标准 SQL 的合并语句。
-- UPSERT:插入或更新
INSERT INTO user_settings (user_id, theme, updated_at)
VALUES (1, 'dark', now())
ON CONFLICT (user_id)
DO UPDATE SET
theme = EXCLUDED.theme, -- EXCLUDED = 被拒绝的新值
updated_at = EXCLUDED.updated_at;
-- 幂等插入
INSERT INTO event_log (event_id, data)
VALUES ('evt_123', '{...}')
ON CONFLICT (event_id) DO NOTHING;
-- RETURNING:插入后立即返回
INSERT INTO posts (title, author_id)
VALUES ('Hello', 1) RETURNING id, created_at;
-- MERGE(PG15+)
MERGE INTO inventory AS target
USING incoming_stock AS source
ON target.product_id = source.product_id
WHEN MATCHED THEN
UPDATE SET quantity = target.quantity + source.quantity
WHEN NOT MATCHED THEN
INSERT (product_id, quantity)
VALUES (source.product_id, source.quantity);ON CONFLICT (列) 要求那一列上真的有唯一约束或唯一索引,否则报 42P10 there is no unique or exclusion constraint matching the ON CONFLICT specification。它靠的是索引来检测冲突,不是自己去查一遍。MERGE(PG 15+)和 ON CONFLICT 不是同一个东西,别混着选。MERGE 是 SQL 标准语法、能表达更复杂的多分支逻辑(匹配上做什么、没匹配做什么、源里没有做什么),但它不具备 ON CONFLICT 那种基于唯一索引的原子冲突处理——并发下两个 MERGE 仍可能撞唯一约束而报错。单纯的「有则更新无则插入」用 ON CONFLICT;需要多分支的数据同步才用 MERGE。ON CONFLICT DO NOTHING 是实现幂等写入最省事的方式。让客户端带一个唯一的 idempotency_key,服务端建唯一约束,重复请求就自然变成空操作——网络超时重试时不会重复下单。这比在应用层「先查再插」可靠得多,后者有竞态。DO UPDATE 里用 EXCLUDED 引用本来要插入的那一行:DO UPDATE SET hits = stats.hits + EXCLUDED.hits 就是「已存在则累加」。还可以加 WHERE 做条件更新:DO UPDATE SET ... WHERE stats.updated < EXCLUDED.updated(只在数据更新时才覆盖)。PG 的扩展生态是它最被低估的部分:全文搜索、模糊匹配、地理查询、向量检索都不必再引入一个新数据库。
内置全文搜索与扩展生态
PG 内置全文搜索(tsvector/tsquery),配合生成列 + GIN 索引即可投入生产。pg_trgm 提供模糊匹配/容错搜索。PG 扩展生态强大:全文搜索不需要 Elasticsearch,地理查询不需要 MongoDB,向量搜索不需要专用向量库。
-- 全文搜索
SELECT * FROM articles
WHERE to_tsvector('english', coalesce(title,'') || ' ' || coalesce(body,''))
@@ to_tsquery('english', 'postgres & database');
-- 生产用法:生成列 + GIN 索引
ALTER TABLE articles ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('english', coalesce(title,'') || ' ' || coalesce(body,''))
) STORED;
CREATE INDEX idx_search ON articles USING GIN(search_vector);
-- 搜索排名
SELECT title, ts_rank(search_vector, query) AS rank
FROM articles, to_tsquery('english', 'database') query
WHERE search_vector @@ query
ORDER BY rank DESC;
-- 生成列(PG12+)
ALTER TABLE orders ADD COLUMN total_with_tax numeric
GENERATED ALWAYS AS (total * 1.1) STORED;
-- 常用扩展
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- 模糊匹配
CREATE EXTENSION IF NOT EXISTS postgis; -- 地理空间
CREATE EXTENSION IF NOT EXISTS vector; -- 向量搜索(AI/RAG)
CREATE EXTENSION IF NOT EXISTS pg_cron; -- 定时任务
-- pg_trgm 模糊搜索(容错拼写错误)
SELECT name, similarity(name, 'jonh') AS sim
FROM users WHERE name % 'jonh'
ORDER BY sim DESC;to_tsquery 不能直接接用户输入。它要求的是已经写成查询语法的字符串('quick & brown'),直接把用户搜的「quick brown」丢进去会报 42601 syntax error in tsquery。接用户输入应该用 plainto_tsquery(把空格当 AND)或 websearch_to_tsquery(支持引号短语和 - 排除,最接近搜索引擎的习惯)。另外,拼接列做检索向量时任何一列为 NULL 都会让整行搜不到——
to_tsvector('english', title || ' ' || body) 在 body 为空时结果是 NULL,这条记录从此永远搜不出来且不报错。每一列都要 coalesce(x, '') 包住(见 03 章)。还要注意:中文分词 PG 内置不支持,需要
zhparser 或 pg_jieba 这类扩展,托管数据库上不一定装得了。权限有三层:连得进来(pg_hba.conf)、动得了这张表(GRANT)、看得见这一行(RLS)——多租户系统三层都要管。
三层准入
PG 用角色(ROLE)统一表示用户和用户组,GRANT / REVOKE 控制对象权限(库、表、列)。行级安全(RLS)再进一步——按行控制可见性,在多租户 SaaS 里让每个租户只看到自己的数据。连接层的准入则由 pg_hba.conf 决定:谁、从哪连、用什么方式认证。
-- 角色:可登录的用户 + 不可登录的“组”角色
CREATE ROLE app_user LOGIN PASSWORD 'secret';
CREATE ROLE readonly; -- 组角色,用于打包权限
GRANT readonly TO app_user; -- 把组权限授予用户
-- GRANT / REVOKE:按对象授权
GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly;
GRANT INSERT, UPDATE ON orders TO app_user;
REVOKE DELETE ON orders FROM app_user;
-- 行级安全(RLS):多租户按行隔离
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- 之后每个连接先 SET app.tenant_id = '...',就只能看到本租户的行FORCE ROW LEVEL SECURITY 让所有者也受约束。还有:启用了 RLS 却一条策略都没建,等于禁止所有访问(默认拒绝)。顺序应该是先建策略再
ENABLE,或者做好这期间查询返回空的准备。最后,应用连数据库不要用超级用户。这不只是安全洁癖——超级用户会绕过 RLS 和所有权限检查,让你精心设计的策略全部失效。给应用建一个最小权限的角色,是让这一章的内容真正生效的前提。
速查
本章是随手可翻的对照表:先用任务反查按「你想干什么」定位到该用的东西和所在章,再翻后面三张按主题排的表——psql 元命令、数据类型、常用函数。所有条目都指回展开处,不在这里重复解释。
SQL 的函数名常常不直观(要「取月初」得用 date_trunc,要「空值兜底」得用 coalesce),而且散落在各章。这张表按你想干的事来找,右边给出该用的写法和展开的章号。
查询 · 过滤 · 聚合
| 想做什么 | 用这个 | 在哪 |
|---|---|---|
| 判断是否为空 | x IS NULL——绝不能用 = NULL | 03 章 |
| 空值兜底 | coalesce(x, 0);多备选依次取第一个非空 | 03 章 |
| 防除零 | a / nullif(b, 0) | 03 章 |
| 「不等于」且要包含空值行 | x IS DISTINCT FROM 'y' | 03 章 |
| 子查询取反 | NOT EXISTS——别用 NOT IN | 03 章 |
| 条件计数 | count(*) FILTER (WHERE ...) | 03 章 |
| 去重 | DISTINCT;每组留一行用 DISTINCT ON | 02 章 |
| 分组后再过滤 | HAVING(WHERE 里放不了聚合) | 05 章 |
| 取每组前 N | ROW_NUMBER() OVER (PARTITION BY ...) 套子查询 | 06 章 |
| 算环比 / 移动平均 | LAG / LEAD;ROWS BETWEEN ... PRECEDING | 06 章 |
| 把查询拆成几段 | WITH(CTE);树形数据用 WITH RECURSIVE | 07 章 |
| 分页 | 小数据 LIMIT/OFFSET;大数据用 keyset | 08 章 |
| 排序时把空值放最后 | ORDER BY x DESC NULLS LAST | 08 章 |
| 中文按拼音排 | ORDER BY name COLLATE "zh-x-icu"——需要 PG 编译时带 ICU,先用 SELECT collname FROM pg_collation 确认有没有 | 本章 |
写入 · 结构 · 性能
| 想做什么 | 用这个 | 在哪 |
|---|---|---|
| 插入并拿回生成的 id | INSERT ... RETURNING id | 01 章 |
| 有则更新无则插入 | INSERT ... ON CONFLICT (k) DO UPDATE | 15 章 |
| 重复请求不重复写 | ON CONFLICT DO NOTHING + 幂等键 | 13 章 |
| 批量导入 | \copy(快);造测试数据用 generate_series | 01 章 |
| 自增主键 | bigint GENERATED ALWAYS AS IDENTITY,别用 serial | 01 章 |
| 看表结构 | \d 表名 | 01 章 |
| 看查询为什么慢 | EXPLAIN (ANALYZE, BUFFERS) | 09 章 |
| 线上加索引 | CREATE INDEX CONCURRENTLY(不能在事务内) | 14 章 |
| 大小写不敏感查找 | 建 lower(col) 表达式索引,或用 citext | 09 章 |
子串搜索 LIKE '%x%' | pg_trgm + GIN 索引(B-tree 用不上) | 09 章 |
| 找出谁最慢 | pg_stat_statements 按 total_exec_time 排 | 14 章 |
| 备份 / 恢复 | pg_dump -Fc / pg_restore -j 4 | 14 章 |
-- 五个最常抄的写法,查到名字后照着改
-- ① 每个用户的最新一条订单(DISTINCT ON 是 PG 特有,比窗口函数简洁)
SELECT DISTINCT ON (user_id) *
FROM orders ORDER BY user_id, created DESC;
-- ② 按月汇总(date_trunc 把时间截断到月初)
SELECT date_trunc('month', created) AS 月份,
count(*) AS 单数,
coalesce(sum(amount), 0) AS 金额,
count(*) FILTER (WHERE status = 'paid') AS 已支付
FROM orders GROUP BY 1 ORDER BY 1;
-- ③ UPSERT:插入或更新,并拿回结果
INSERT INTO stats (day, hits) VALUES (current_date, 1)
ON CONFLICT (day) DO UPDATE SET hits = stats.hits + EXCLUDED.hits
RETURNING *;
-- ④ 反连接:找出「没有订单的用户」
SELECT u.* FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
-- ⑤ keyset 分页:翻到多深都一样快
SELECT * FROM orders
WHERE (created, id) < ($1, $2)
ORDER BY created DESC, id DESC LIMIT 20;= NULL 永远不为真(该用 IS NULL)、NOT IN 碰上 NULL 返回空集(该用 NOT EXISTS)、serial 被显式赋值后序列不同步(该用 IDENTITY)。它们的共同点是不报错——测试数据里往往正常,上线后遇到真实数据才出问题。忘了某个 SQL 命令的完整语法时,psql 里
\h 命令名 直接给出官方语法摘要(如 \h INSERT),比查网页快得多。元命令以反斜杠开头,不是 SQL,只在 psql 里有效。加 + 显示更详细(如 \dt+ 带表大小),加 S 包含系统对象。
psql 元命令
| 命令 | 作用 |
|---|---|
\l | 列出所有数据库 |
\c 库名 | 切换数据库 |
\dt / \dt+ | 列出表 / 带大小 |
\d 表名 | 最常用:列、类型、索引、约束、外键 |
\di / \dv / \df | 列出索引 / 视图 / 函数 |
\dn / \du | 列出 schema / 角色 |
\dx | 列出已安装的扩展 |
\x | 竖排显示(列很多时必开) |
\timing | 显示每条语句耗时 |
\i 文件.sql | 执行脚本文件 |
\copy 表 FROM '本机文件' | 导入 CSV(注意不是 COPY) |
\e | 用编辑器写这条查询(长 SQL 救星) |
\h SELECT / \? | SQL 语法帮助 / 元命令帮助 |
\set VERBOSITY verbose | 让报错显示 SQLSTATE 错误码 |
数据类型:日常只需记这一列
| 要存什么 | 用这个 | 别用 |
|---|---|---|
| 文本 | text | varchar(n) 无必要;char(n) 会补空格 |
| 整数 | int;主键或可能超 21 亿用 bigint | — |
| 金额 | numeric(12,2)——精确十进制 | float / real(有精度误差) |
| 科学计算的小数 | double precision | — |
| 时间点 | timestamptz——几乎总是它 | timestamp(不带时区,易错) |
| 纯日期 / 时长 | date / interval | — |
| 真假 | boolean | — |
| 主键 | bigint GENERATED ALWAYS AS IDENTITY | serial(非标准、易失步) |
| 分布式主键 | uuid(PG 18 起有内置 uuidv7()) | — |
| 半结构化数据 | jsonb——可索引 | json(只存文本,不能索引) |
| 一组同类值 | text[] 等数组类型 | — |
| 固定取值集合 | text + CHECK 约束(改起来容易) | enum(加值要 ALTER TYPE) |
-- timestamptz 与 timestamp 的实际区别(这是最值得记住的一条)
SET timezone = 'Asia/Shanghai';
SELECT '2026-01-01 12:00:00+00'::timestamptz; -- 2026-01-01 20:00:00+08
SET timezone = 'UTC';
SELECT '2026-01-01 12:00:00+00'::timestamptz; -- 2026-01-01 12:00:00+00
-- 同一个时刻,按当前时区显示 —— 存的始终是 UTC,只是显示随时区变
-- timestamp 则不带时区信息,是一个「裸墙钟读数」,换时区不会变,
-- 也无法回答「这到底是哪一刻」。所以默认一律用 timestamptz。
-- 显式转换时区
SELECT created AT TIME ZONE 'Asia/Shanghai' FROM orders;
-- 类型转换:两种写法等价,:: 更常用
SELECT '42'::int, CAST('42' AS int);timestamp 和 timestamptz 只差三个字母,用错了要很久才会发现。timestamptz 存的是绝对时刻(内部按 UTC 存),显示时按当前会话时区转换;timestamp 存的是一个没有时区含义的读数——它没法回答「这是全球哪一刻」,一旦服务器时区变了、或者用户在别的时区,数据就全错了,而且历史数据无法修复(因为你不知道当初那个读数是哪个时区的)。默认一律用
timestamptz。只有在存「每天 9:00 上班」这类与具体时区无关的挂钟时间时才用 timestamp 或 time。\d 系列支持模式匹配:\dt user* 只列出 user 开头的表,库里表多时很有用。\e 是被严重低估的命令——它打开 $EDITOR 让你编辑当前查询,存盘退出后自动执行。写多行 SQL 时比在 psql 里一行行敲方便太多,改一个字重跑也只需再敲一次 \e。按用途分四组,每行一个函数、右边一句说明。拿不准某个函数的参数顺序时,\df 函数名 直接给出签名。
字符串
| 函数 | 作用 |
|---|---|
length(s) | 字符数(不是字节数,字节数用 octet_length) |
lower / upper / initcap | 大小写转换 |
trim / ltrim / rtrim | 去空白,可指定去掉哪些字符 |
substring(s from 2 for 3) | 取子串(下标从 1 开始) |
split_part(s, ',', 2) | 按分隔符取第 n 段——比正则简单得多 |
replace(s, a, b) | 字面替换 |
regexp_replace(s, p, r, 'g') | 正则替换('g' = 全部) |
s ~ '^a' / s ~* '^a' | 正则匹配 / 忽略大小写 |
s LIKE 'a%' / ILIKE | 通配匹配(% 任意串,_ 单字符) |
concat_ws('-', a, b) | 带分隔符拼接,自动跳过 NULL |
format('%I / %L', a, b) | 拼 SQL 时安全转义标识符 / 字面量 |
日期与时间
| 函数 | 作用 |
|---|---|
now() | 当前时刻(事务开始时刻,整个事务内不变) |
clock_timestamp() | 真正的「此刻」,每次调用都不同 |
current_date / current_time | 今天 / 当前时间 |
date_trunc('month', t) | 截断到月初/日初——按月汇总全靠它 |
extract(year from t) | 取出年/月/日/dow 等分量 |
t + interval '7 days' | 加减时长 |
age(a, b) | 两个时间的差,返回可读的 interval |
to_char(t, 'YYYY-MM-DD') | 格式化成字符串 |
to_timestamp(s, 'YYYY-MM-DD') | 字符串转时间 |
t AT TIME ZONE 'UTC' | 时区转换 |
generate_series(a, b, step) | 生成序列,造数据与补齐日期空洞都靠它 |
聚合与 JSON
| 函数 | 作用 |
|---|---|
count(*) / count(col) | 行数 / 非空值个数(见 03 章) |
sum / avg / min / max | 空集时返回 NULL,记得 coalesce |
count(*) FILTER (WHERE ...) | 条件聚合,比 CASE WHEN 清楚 |
string_agg(x, ',') | 拼成一个字符串,可加 ORDER BY |
array_agg(x) / jsonb_agg(x) | 聚成数组 / JSON 数组 |
j -> 'k' / j ->> 'k' | 取 JSON 值(返回 jsonb / 返回 text) |
j #>> '{a,b}' | 按路径取值 |
j @> '{"k":1}' | 包含判断——GIN 索引能加速的就是它 |
jsonb_set(j, '{k}', '1') | 更新某个键 |
jsonb_array_elements(j) | 展开 JSON 数组成行(text[] 用 unnest) |
-- 三个最常抄的组合
-- ① 按天汇总并补齐没有数据的日期(用 generate_series 生成完整日历再左连)
SELECT d::date AS 日期, count(o.id) AS 单数
FROM generate_series(
current_date - '29 days'::interval, current_date, '1 day'
) d
LEFT JOIN orders o ON date_trunc('day', o.created) = d
GROUP BY 1 ORDER BY 1;
-- ② 把一对多聚成一行(每个用户的标签列表)
SELECT u.name, string_agg(t.name, ', ' ORDER BY t.name) AS 标签
FROM users u
JOIN user_tags ut ON ut.user_id = u.id
JOIN tags t ON t.id = ut.tag_id
GROUP BY u.id, u.name;
-- ③ 取邮箱域名并统计(split_part 比正则好读)
SELECT split_part(email, '@', 2) AS 域名, count(*)
FROM users GROUP BY 1 ORDER BY 2 DESC;now() 返回的是事务开始的时刻,不是当前时刻。在同一个事务里连续调用两次 now(),得到的是完全相同的值——这通常正是你要的(同一批写入有一致的时间戳),但如果你想测量一段 SQL 的耗时,就会得到 0。要真正的「此刻」用 clock_timestamp()。字符串下标从 1 开始,不是 0。
substring(s from 1 for 3) 取的是前三个字符。数组下标同样从 1 开始——arr[1] 是第一个元素。这是 SQL 与大多数编程语言最容易搞混的差异之一。->> 和 -> 的区别值得单独记住:-> 返回 jsonb(可以继续往下取),->> 返回 text(可以直接比较、转类型)。所以链式取值时中间用 ->、最后一层用 ->>:data -> 'user' ->> 'name'。写错的典型症状是比较时莫名其妙不相等——因为 jsonb 的字符串带着引号。concat_ws 比 || 安全:拼接时任何一个 NULL 都会让 || 的结果整个变成 NULL,而 concat_ws 会跳过 NULL(见 03 章)。数据库之上的界面层
本章覆盖:psql、图形客户端、自动生成后台三条路的横向对照,重点是各自把权限交给了谁——这决定了安全边界画在哪、出事能出多大;末尾另有一组被低估的 psql 用法(CSV 导出、ON_ERROR_STOP、\watch、~/.psqlrc)。
数据库自己没有界面。三条路的真正差别不在好不好看,而在权限边界画在哪——这决定了出事时能出多大。
方案对照
| 方案 | 代表 | 权限交给谁 | 适合 |
|---|---|---|---|
| psql | 官方自带 | 连接串里那个角色 | 日常查询与运维;脚本化、可版本管理、零安装(01 章) |
| 图形客户端 | pgAdmin · DBeaver · TablePlus · DataGrip | 同上,但点错按钮的成本更高 | 看结构、拖数据、不熟 SQL 的同事 |
| 自动生成后台 | PostgREST · Supabase · Hasura | 数据库自己——靠角色 + RLS 决定谁能看什么 | 内部后台、原型、前端直连 |
| 自己写后端 | — | 应用层代码 | 业务逻辑复杂、需要事务编排时 |
| BI / 报表 | Metabase · Superset | 建议单独建只读角色 | 给非技术同事看数 |
各自的风险
| 方案 | 风险 | 对策 |
|---|---|---|
| 图形客户端 | 连着生产库改数据;有些客户端会自动开事务却不提交(10 章 idle in transaction) | 生产库只给只读角色;改数走迁移脚本(14 章) |
| 图形客户端 | 「导出全表」把几千万行拉到本地 | 先 LIMIT;大批量走 \copy |
| 自动后台 / 前端直连 | 权限全靠 RLS,写错一条策略就是数据泄露 | RLS 必须写测试;FORCE ROW LEVEL SECURITY(15 章) |
| 自动后台 | 把表结构直接暴露成 API,以后改列名就是破坏性变更 | 对外只暴露视图而不是表(12 章) |
| 共用超级用户 | 审计不出是谁干的 | 一人一角色,或至少按服务分角色 + application_name(13 章) |
psql 的几个被低估的用法
| 要做的事 | 怎么写 |
|---|---|
| 输出成 CSV | \copy (SELECT …) TO 'out.csv' CSV HEADER |
| 输出成 HTML / Markdown | \pset format html · \pset format markdown |
| 非交互执行 | psql -At -c "SELECT count(*) FROM t"——-A 不对齐、-t 去表头,输出可直接喂给脚本 |
| 批处理遇错即停 | psql -v ON_ERROR_STOP=1 -f script.sql——CI 里必加,否则出错也返回 0 |
| 变量 | \set uid 42 后用 :uid;字符串用 :'uid'、标识符用 :"uid" |
| 反复执行看变化 | \watch 2——每 2 秒重跑上一条,盯监控查询很好用 |
| 配置持久化 | 写进 ~/.psqlrc:\set AUTOCOMMIT off、\timing on、\x auto |
~/.psqlrc 里加一句把生产库的提示符改成红色(\set PROMPT1 '%[%033[1;31m%]%/%R%#%[%033[0m%] '),「我以为连的是测试库」这类事故能挡掉大半。很多人把 psql 当成「只能敲 SQL 的黑框」,于是转头去装图形客户端。实际上它有一整套元命令,把「探索数据库」这件事做得比多数 GUI 更快——尤其是配合输出格式开关之后。
最值回票价的一批元命令
- 看结构:
\d列出所有关系、\d 表名看列与索引与约束、\d+ 表名additionally 看存储与统计、\df函数、\dn模式、\du角色; - 换输出格式:
\x切换竖排显示(宽表必备,一行变成一段键值对)、\pset format csv直接出 CSV、\pset null '(null)'让 NULL 不再和空串长得一样(03 章那套三值逻辑的坑,肉眼分辨全靠这一行); - 反复观察:
SELECT ...; \watch 2每两秒重跑一次——盯pg_stat_activity看有没有长事务、盯复制延迟、盯队列长度,全靠它; - 省时间:
\e用编辑器改上一条查询、\timing on显示耗时、\copy在客户端侧导入导出(不需要服务器文件权限)。
它同时是最好的脚本载体
psql -v ON_ERROR_STOP=1 -f migrate.sql:出错立刻停并返回非零退出码——不加这个变量时,脚本会跳过错误继续执行,迁移做到一半只成功一部分,这是运维脚本的头号事故来源;-t -A -F,组合可以让输出变成纯粹的数据(无表头、无对齐、逗号分隔),直接喂给管道;- 它在服务器上永远都在:不需要装任何东西、不需要网络转发端口、SSH 进去就能用。凌晨排查故障时,这一点胜过一切界面。
把常用设置写进 ~/.psqlrc
- 每次手敲
\timing on和\pset null很快就会放弃——写进配置文件一劳永逸; - 给生产库的提示符加上醒目标记(见下方 code),这是防止「在生产上跑了本该在测试上跑的语句」最便宜的一道防线。
-- ~/.psqlrc:这几行值得每台机器都配
\timing on
\pset null '(null)'
\set ON_ERROR_STOP on
\set PROMPT1 '%[%033[1;31m%]%/@%M%[%033[0m%]%R%# ' -- 库名+主机名,生产库一眼认出
-- 宽表看不清?一个 \x 就够
=> \x
Expanded display is on.
=> SELECT * FROM orders LIMIT 1;
-[ RECORD 1 ]-+----------------------
id | 1024
customer_id | 77
total_cents | 12800
created_at | 2026-07-29 09:12:03+08
-- 每 2 秒刷新一次:盯长事务、盯队列
=> SELECT pid, state, now()-xact_start AS age, query
FROM pg_stat_activity WHERE state <> 'idle' ORDER BY age DESC; \watch 2
# 脚本里一定要加 ON_ERROR_STOP,否则出错也会继续往下跑
$ psql -v ON_ERROR_STOP=1 -f migrate.sqlDELETE 敲在了错的那个。三道成本极低的防线:① 提示符带上库名与主机名并染色(上方 PROMPT1);② 生产库用只读角色登录,需要写时才显式切换;③ 破坏性语句先写成 BEGIN; ... ; 看影响行数再决定 COMMIT 还是 ROLLBACK——PG 的 DDL 也在事务里,这一招对建表删列同样有效(10 章)。\copy 与 COPY 差一个反斜杠,含义差很远:COPY 在服务器上读写文件(需要超级用户或 pg_read_server_files 角色,路径是服务器的路径),\copy 是 psql 在客户端读写(用你自己的权限与本地路径)。导入导出日常用的应当是 \copy——很多「权限不足」和「文件找不到」的问题,答案就是漏了那个反斜杠。图形客户端的价值很实在:浏览结构、跨表点查、看执行计划的可视化、导出数据都比命令行直观。风险同样实在,而且集中在一处——它让「误操作」的门槛低到只需要点错一个按钮。
常见的三类
- pgAdmin:官方出品、功能全(含服务器监控与备份界面),Web 形态,团队共享部署时要注意它自己也是一个需要保护的服务;
- DBeaver:跨数据库通用,插件生态好,适合同时要连 MySQL / ClickHouse / PG 的团队;
- TablePlus / Postico 一类原生客户端:轻快、界面精致,日常查询体验最好,多为商业软件。
连生产库的五条规矩
- ① 用只读账号连生产:日常查询根本不需要写权限。真要改,用另一套凭据显式登录;
- ② 关掉「自动提交」:多数客户端默认 autocommit,一个误点的
DELETE立刻落盘。改成手动提交后,至少还有ROLLBACK的机会; - ③ 关掉「自动展开表数据」:某些客户端点开表就
SELECT *,在亿行大表上直接把连接打满; - ④ 给生产连接改颜色/加标记:所有主流客户端都支持给连接配色,红色代表生产。这和 psql 染色提示符是同一道防线;
- ⑤ 别用客户端做 DDL 迁移:结构变更必须走版本化的迁移脚本(14 章),在 GUI 里点出来的改动没有记录、无法回放、也不会出现在 code review 里。
一个容易忽略的连接问题
- 图形客户端往往为每个标签页各开一条连接,还会开后台连接做元数据刷新——几个人同时用,连接数很快堆上去;
- PG 的连接不是廉价资源(每条连接一个进程),
max_connections被占满时新连接直接被拒,应用先挂; - 13 章讲的连接池对应用生效,但对图形客户端不生效——它们直连。给分析/查询用途单独准备一个受限的账号与连接上限(
ALTER ROLE analyst CONNECTION LIMIT 5)是简单有效的做法。
-- 给「人用」的账号做好限制,比事后追责有用
CREATE ROLE analyst LOGIN PASSWORD '...' CONNECTION LIMIT 5;
GRANT CONNECT ON DATABASE app TO analyst;
GRANT USAGE ON SCHEMA public TO analyst;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO analyst;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO analyst;
-- 顺手加一条超时,防止有人开着一个忘了关的事务
ALTER ROLE analyst SET idle_in_transaction_session_timeout = '5min';
ALTER ROLE analyst SET statement_timeout = '30s';
-- 看看现在谁连着、开着什么
SELECT usename, application_name, client_addr, state, count(*)
FROM pg_stat_activity GROUP BY 1,2,3,4 ORDER BY count DESC;\copy ... TO ... CSV 走流式,或在服务端用 COPY 直接落盘。判据很简单:行数超过百万就别用 GUI 导。idle_in_transaction_session_timeout 是给「人类客户端」准备的保险丝:有人在 GUI 里执行了一条语句后去开会,事务一直开着,它持有的锁会挡住迁移、它的旧快照会让 autovacuum 无法回收(10 章讲的膨胀就是这么来的)。给交互式账号统一设成几分钟,成本为零,收益很大。这几年有一类工具很有意思:它们直接读数据库的结构,自动生成 API 或后台界面,中间层几乎不写代码。它们能成立的前提只有一个——把授权真正下沉到数据库里,也就是 15 章讲的角色权限与行级安全(RLS)。
四类工具
- PostgREST:把表和视图直接映射成 REST API,权限完全由 PG 的角色与 RLS 决定。它自己不做任何鉴权逻辑,只是按请求携带的 JWT 切换数据库角色;
- Supabase:在 PostgREST 之上加了认证、存储、实时订阅与一个 Web 控制台,是「PG 即后端」这条路最完整的产品化形态;
- Directus / NocoDB 这类无代码后台:读结构生成增删改查界面,适合给运营/内容团队用;
- Metabase / Superset 这类 BI:面向查询与图表,给业务方自助分析用,通常只需要只读账号。
安全模型:这条路唯一的要害
- 没有 RLS 的自动后台等于把整库暴露出去:既然 API 是按表生成的,凡是角色能读的行,请求方就能读到;
- RLS 必须逐表启用(
ALTER TABLE ... ENABLE ROW LEVEL SECURITY)——新建的表默认没有,这是这条路上最常见的漏洞:加了张表忘了配策略,整张表对所有人可见; - 15 章那条结论在这里格外关键:RLS 对表所有者与超级用户默认不生效。用建表的那个账号去测策略,会得出「策略没生效」或「策略生效了」的错误结论——必须用真正的受限角色去验(
SET ROLE之后再查一遍); - 前端直连数据库时,匿名 key 是公开的:它天然会出现在浏览器里,安全性完全依赖 RLS 策略写得对不对,而不是「别人不知道这个 key」。
什么时候适合、什么时候别用
- 适合:内部工具、原型、内容管理、给运营的数据后台、以及「读多写少且规则简单」的业务;
- 不适合:业务规则复杂(跨表校验、多步事务、外部系统联动)——这些逻辑塞进 RLS 与视图会变得极难维护;
- 折中做法:读走自动生成的接口,写走自己写的服务。这样既省掉了大量 CRUD 样板,又保住了业务规则的可读性。
-- 自动后台这条路的安全地基:逐表启用 + 显式策略
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY own_orders ON orders
FOR SELECT
USING (customer_id = current_setting('request.jwt.claims', true)::json->>'sub');
-- 验证策略:必须切到受限角色,否则表所有者会绕过 RLS
SET ROLE web_anon;
SELECT count(*) FROM orders; -- 应该只看到自己的行
RESET ROLE;
-- 检查有没有「开了 RLS 但一条策略都没写」或「根本没开」的表
SELECT c.relname,
c.relrowsecurity AS rls_enabled,
count(p.polname) AS policies
FROM pg_class c
LEFT JOIN pg_policy p ON p.polrelid = c.oid
WHERE c.relkind = 'r' AND c.relnamespace = 'public'::regnamespace
GROUP BY 1,2 ORDER BY rls_enabled, policies;资料索引:项目 · 读物 · 自测
速查表能告诉你「怎么写」,但判断力只能靠真数据长出来。最后这一章给出收尾用的三样东西:难度递进的动手项目、按用途排的资料,以及一条自测标准。
SQL 的语法两周能读完,判断力只能靠真数据长出来。下面四个项目按难度排,每个都逼你用上前面某几章。
动手项目
| 项目 | 做什么 | 练到的 |
|---|---|---|
| ① 建一个真 schema | 给一个小业务(订单 / 博客 / 记账)设计表:主键、外键、约束、索引、时间字段全用对 | 02 章类型与约束、09 章索引;先想清楚再建表,比事后 ALTER 省十倍力气 |
| ② 写十条报表查询 | 日活、留存、每组前 N、环比、分位数——全部用一条 SQL 完成,不在应用层算 | 04–07 章:JOIN、聚合、窗口、CTE |
| ③ 造慢查询再调快 | 灌 500 万行,写一条几秒的查询,用 EXPLAIN (ANALYZE, BUFFERS) 找出原因,加索引或改写到几十毫秒 | 09 章——这是全页最值得动手的一节;顺带练 08 章 keyset 分页 |
| ④ 接进应用并扛住并发 | 用任意语言写一个扣库存接口:参数化、连接池、事务、40001 重试,再用压测工具打并发验证不超卖 | 10、13 章 |
资料
| 要什么 | 去哪 | 注意 |
|---|---|---|
| 查语法与函数 | 官方文档(postgresql.org/docs) | PG 的官方文档是同类里最好的一份,函数章节直接当手册用;注意左上角切到你的版本 |
| 某版本带来了什么 | 官方 Release Notes 的「Migration to Version NN」 | 升级前必读(00 章) |
| 执行计划怎么读 | explain.depesz.com · explain.dalibo.com | 把 FORMAT JSON 的输出粘进去,瓶颈节点自动标红 |
| 练手数据 | pagila(DVD 租赁)· dvdrental · chinook | 项目 ②③ 直接拿它们练 |
| 造数据 | generate_series + random() | INSERT INTO t SELECT i, md5(random()::text) FROM generate_series(1, 5000000) i; |
| 系统视图速查 | pg_stat_activity · pg_stat_statements · pg_stat_user_tables · pg_locks | 14 章 |
| 社区 | pgsql-general 邮件列表、planet.postgresql.org | — |
自测:会了没有
| 题 | 答在哪 |
|---|---|
为什么 WHERE status <> 'a' 会漏行,怎么改 | 03 章 |
LEFT JOIN 的条件写 ON 和写 WHERE 差在哪 | 04 章 |
count(*)、count(col)、count(DISTINCT col) 各数什么 | 05 章 |
窗口帧默认的 RANGE 在有并列值时会发生什么 | 06 章 |
OFFSET 100000 为什么慢,keyset 分页怎么写 | 08 章 |
复合索引 (a, b) 能不能服务只按 b 的查询 | 09 章 |
| 默认隔离级别下「先查库存再扣减」为什么会超卖 | 10 章 |
给一条 3 秒的查询做优化:读懂 EXPLAIN (ANALYZE, BUFFERS),说出瓶颈在哪、为什么加这个索引能解决 | 09 章——做得到,这一页就毕业了 |
GROUP BY 慢几个数量级,还占满带宽和内存。判据很简单:凡是「查出来之后要遍历一遍算个数」的代码,多半该是一条 SQL。