PostgreSQL 全面速查

速查总览:这一页怎么查

本章是全页的索引:章节地图告诉你哪个话题在哪一章,定位与选型回答「什么时候该选 PostgreSQL」,版本年表与托管方案解决查资料时最费时的一件事——你要用的这个语法,从哪个大版本才有、你手上那个托管服务给的又是哪一版。

全页十九章,按「查得对 → 查得快 → 用得住 → 接进系统」排列。不必顺读——按关键词定位,翻该章第一张速查表。

章节地图

章查什么关键词
01装与连、psql 元命令全表、最小 SQL 骨架、按 SQLSTATE 反查报错psql · \d · 42P01
02SELECT 子句与执行顺序、过滤与模糊匹配、数据类型与约束、增删改与 ALTER TABLEWHERE · LIKE · CHECK
03NULL 与三值逻辑:比较、NOT IN 陷阱、聚合与排序里的 NULLIS NULL · COALESCE
04五种 JOIN、ON 与 WHERE 的区别、半连接与反连接怎么选LEFT JOIN · EXISTS
05聚合函数全表、GROUP BY 与 HAVING、FILTER、ROLLUP / GROUPING SETScount(*) · HAVING
06窗口函数全表、OVER 的三段、窗口帧 ROWS vs RANGErow_number · LAG
07子查询三种形式、WITH 与递归 CTE、LATERALWITH RECURSIVE
08排序的 tiebreaker、OFFSET 为什么越翻越慢、keyset 分页ORDER BY · seek
09六种索引类型怎么选、复合索引的列序、EXPLAIN 怎么读B-tree · GIN · buffers
10事务与 SAVEPOINT、四个隔离级别与各自放过什么、锁与死锁BEGIN · SERIALIZABLE
11JSONB 运算符与函数全表、数组操作、json 与 jsonb 的区别->> · @> · GIN
12视图与物化视图、自定义函数与存储过程MATERIALIZED · plpgsql
13参数化查询、连接池、事务与错误在代码里怎么写$1 · pool · retry
14pg_dump 与物理备份、迁移脚本、上线后看哪些指标pg_dump · WAL
15UPSERT 与 MERGE、全文搜索、角色权限与行级安全ON CONFLICT · RLS
16通用速查:任务反查、psql 元命令、数据类型、常用函数反查表
17psql 本身、图形客户端、自动生成的后台各把权限交给了谁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;
搜 SQL 答案时先确认两件事:是哪个数据库、哪个大版本。LIMIT ... OFFSET、ILIKE、|| 拼接在各家行为不同;MERGE 要 PG 15+、JSON_TABLE 要 17+、uuidv7() 要 18+。照抄 Stack Overflow 上的答案之前先看日期与标签(见本章「版本年表与托管方案速查」)。
本页以 PostgreSQL 18 为基准。SQL 标准之外,PG 有大量自己的扩展语法(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;  -- 各城市前三名
「PG 什么都能干」容易被误读成「什么都该用 PG 干」。JSONB、pgvector、PostGIS 各有边界:缓存与高频计数器该用 Redis,海量日志与全文检索到一定规模该上专用系统。判据是数据量级与访问模式,不是「能不能」。
把官方文档当第一手资料——PostgreSQL 文档是公认写得最好的开源文档之一,遇到问题先查文档再搜帖子,能少走一半弯路。

PostgreSQL 每年九月发一个大版本,每个版本维护五年。查语法前先确认版本——很多「PG 能做」的事其实是某一版之后才能做。

版本年表

版本年份带来了什么
9.42014jsonb、逻辑解码——PG 能当文档库用就是从这里开始的
9.52016INSERT … ON CONFLICT(UPSERT)、GROUPING SETS、行级安全
9.62016并行查询
102017逻辑复制、声明式分区;版本号从三段改成两段
112018分区大幅改进、JIT、存储过程(CALL,可在内部提交事务)
122019生成列、SQL/JSON path(jsonb_path_query)
132020B-tree 去重(索引显著变小)、增量排序
142021多范围类型、jsonb['key'] 下标语法
152022MERGE;public schema 默认不再允许所有人建表——升级后「权限突然不够」多半是这条
162023从备库做逻辑复制、并行与聚合改进
172024pg_basebackup 增量备份、JSON_TABLE、MERGE … RETURNING、VACUUM 内存管理重写
182025异步 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_statementsCREATE EXTENSION pg_stat_statements;(还要配 shared_preload_libraries)找出最慢 / 最频繁的查询——上线必装(14 章)
pgvectorCREATE EXTENSION vector;向量相似度检索,RAG 场景
PostGISCREATE EXTENSION postgis;地理空间——这一个就是很多团队选 PG 的全部理由
pg_trgmCREATE EXTENSION pg_trgm;模糊匹配与给 LIKE '%x%' 加索引(09 章)
pg_cronCREATE EXTENSION pg_cron;库内定时任务
查已装的\dx(psql)· SELECT * FROM pg_available_extensions;—
托管服务给的版本通常落后主线一到两年,而且能装哪些扩展由厂商白名单决定。设计阶段就依赖某个扩展(PostGIS、pgvector、pg_cron)时,先去目标平台的文档确认它在不在列表里——等迁移那天才发现装不上,改动面会非常大。
升级大版本时最值得先读的是官方发行说明的「Migration to Version NN」那一节——它逐条列出不兼容改动。PG 的向后兼容做得很好,但 15 的 public schema 权限、10 的版本号格式这类改动确实会让老脚本直接失败。

起步 · psql · 报错速查

本章覆盖:三条装 PG 的路与连接串写法、psql 元命令全表、建表到查询的最小骨架,以及按 SQLSTATE 与报错原文反查——PG 的每条错误都有一个五位错误码,认它比读英文句子快得多。

PG 的日常入口是 psql。它的元命令(反斜杠开头,不是 SQL)是查库结构最快的方式——下面第二张表值得记住一半。

装与起

方式怎么写适合
Dockerdocker 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/dbnameURI 形式,应用与 psql 都认
?sslmode=require拼在 URI 后面;连公网实例必加(verify-full 更严)
PGHOST PGPORT PGUSER PGPASSWORD PGDATABASE环境变量,省得每次敲参数
~/.pgpasshost: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 exist42P01表名拼错、没在同一个 schema、或者建表时用了双引号大写名。查 \dt 与 SHOW search_path;
column "userName" does not exist42703PG 把未加引号的标识符一律折成小写——建表写了 "userName" 就必须永远带双引号。一律用 snake_case
syntax error at or near "…"42601真正的错误常在报错位置之前:漏逗号、多逗号、关键字当了列名
duplicate key value violates unique constraint23505唯一键冲突。想「有就更新」用 ON CONFLICT(15 章)
insert or update … violates foreign key constraint23503引用的行不存在,或删父行时子行还在——看是否该加 ON DELETE CASCADE
null value in column "x" violates not-null constraint23502没给值且没有默认值
invalid input syntax for type integer: "abc"22P02类型不匹配;常见于字符串拼 SQL——改参数化(13 章)
operator does not exist: text = integer42883PG 不做隐式类型转换。显式写 ::int 或 ::text
permission denied for table users42501GRANT 没给;PG 15 起 public schema 默认不让普通用户建表(15 章)
deadlock detected40P01两个事务加锁顺序相反。统一按固定顺序访问行(10 章)
could not serialize access due to concurrent update40001可重复读 / 可串行化下的正常结果——应用必须准备重试(10、13 章)
current transaction is aborted, commands ignored until end of transaction block25P02事务里有一条失败了还在继续发。回滚到 SAVEPOINT 或整个 ROLLBACK(10 章)
sorry, too many clients already53300连接数打满——应用侧缺连接池(13 章)
canceling statement due to statement timeout57014超过 statement_timeout;先看执行计划而不是直接调大超时(09 章)
could not connect to server08006端口 / 防火墙 / 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:1449
一个事务里只要有一条语句失败,之后的所有语句都会直接报 25P02 并被忽略,直到你 ROLLBACK。这在批量导入脚本里最容易踩:第 3 行出错,后面 997 行全部「静默失败」,而每条的报错都是同一句「事务已中止」。要跳过个别失败就用 SAVEPOINT,或者干脆一条一个事务。
应用代码里一律按 SQLSTATE 判断错误,别匹配消息文本——消息会随版本变化,也会跟着 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 verbose
「语法错误」报的位置常常在真正的问题之后——PG 是读到读不下去时才报错,所以 syntax error at or near "email" 往往意味着问题出在 email 前面那一段(最常见的是漏了逗号)。
约束名是免费的线索,所以建约束时给它起个好名字。PG 自动生成的名字形如 表名_列名_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 的各段

子句怎么写要点
SELECTSELECT id, name AS 姓名, price * 2 AS doubled别名可省 AS;含空格或大小写敏感时用双引号
DISTINCTSELECT DISTINCT city FROM users;整行去重
DISTINCT ONSELECT DISTINCT ON (user_id) * FROM orders ORDER BY user_id, created_at DESC;PG 专有:每组取一行——ORDER BY 必须以 DISTINCT ON 的列开头
FROMFROM orders o JOIN users u ON u.id = o.user_id04 章
WHEREWHERE status = 'paid' AND amount > 100分组前过滤行;不能用聚合函数
GROUP BY / HAVINGGROUP BY user_id HAVING count(*) > 1HAVING 分组后过滤(05 章)
ORDER BYORDER BY created_at DESC NULLS LAST, id一定要有唯一列兜底,否则翻页会乱(08 章)
LIMIT / OFFSETLIMIT 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(标准写法)或 bigserialserial 是老写法,PG 10 起推荐 IDENTITY
金额numeric(12,2)绝不能用 float 存钱——0.1 + 0.2 <> 0.3
浮点real · double precision科学计算才用
文本textPG 里 text 与 varchar 性能相同;不需要长度限制就用 text,要限制用 CHECK
时间timestamptz · date · interval永远用带时区的 timestamptz;timestamp 不记时区,跨区必错
布尔boolean取值 true / false / NULL(03 章)
标识uuidPG 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 快得多但不走行级触发器,且隐式提交前的行锁不同
PG 把未加双引号的标识符一律折成小写: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;
NULL 陷阱: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');
外键列不会自动获得索引,这是 PG 与 MySQL 最容易踩差的一处。PG 只要求被引用的那一侧(通常是主键)有索引,引用方的外键列是裸的。后果:删父表一行时,PG 必须全表扫子表确认没有引用——父表删一行慢到不可理喻,就该去看子表的外键列有没有索引。给外键列建索引,几乎总是对的。

另外,ON DELETE 的四种行为差别很大,默认那个最容易出意外:NO ACTION(默认)和 RESTRICT 都是阻止删除;CASCADE 会连带删掉子行——用在订单明细上合理,用在「删用户连带删所有订单」上就是灾难;SET NULL 把外键置空,适合「分类被删,商品仍保留」。建外键时一定要显式想一遍这个选择,别让默认值替你决定。
选型经验:主键别一刀切用 uuid——随机的 UUIDv4 会破坏 B-tree 索引的写入局部性,大表下写放大明显;单库场景 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 章)
忘写 WHERE 是数据库事故第一名:不带 WHERE 的 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 = NULLNULL两个「不知道」是否相等,还是不知道
NULL <> 1NULL所以 WHERE status <> 'a' 会漏掉 status 为 NULL 的行
NULL AND falsefalse有一个假就是假
NULL AND trueNULL—
NULL OR truetrue有一个真就是真
NOT NULLNULL—
WHERE 只放行truefalse 与 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 的参数
反向:把某值变成 NULLNULLIF(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 BYPG 默认把 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 = NULLNULL两个不知道的值,没法断言相等
NULL <> NULLNULL同理,也没法断言不等
NULL IS NULLtrueIS NULL 才是判空的唯一正确写法
true OR NULLtrue已经有一边为真,另一边是什么都不影响
false OR NULLNULL结果取决于那个不知道的值
true AND NULLNULL同上
false AND NULLfalse已经有一边为假,整体必假
NOT NULLNULL不知道的反面还是不知道

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
拼字符串时一个 NULL 会污染整个表达式。'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」当成肌肉记忆,不要每次去想「这一列会不会有 NULL」——因为你想的是今天的数据,而 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,那正是反连接的写法。
JOIN 之后行数变多,八成是右表对同一个左行有多条匹配——这不是 bug 而是 JOIN 的定义。要「每个左行只出一行」,用 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 ...)。
RIGHT JOIN 较少使用——通常可以通过交换表的位置改写成更直观的 LEFT JOIN。

自连接处理「表内行与行之间的关系」,集合操作处理「两个结果集之间的关系」——两件常被混为一谈的事。

自连接与四个集合运算

自连接用于表内行之间的关系查找(如员工→经理)。集合操作合并/对比两个查询的结果: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;
集合操作要求两边列数相同、类型兼容,而 PG 的报错点常常出人意料。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 的分组」——统计报表里少一行不会报错,往往是业务方发现的。
自查这类 bug 有个快办法:把 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);
这个坑在开发期几乎不可能被发现:测试数据通常没有 NULL,查询跑得好好的;上线后某天业务方在那一列存了个 NULL,这个查询就开始稳定返回空结果,既不报错也不慢。等有人报「这个列表怎么空了」时,你多半会先去查权限和过滤条件,很难想到是 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 要写哪些列。

聚合函数

函数怎么写要点
countcount(*) · count(col) · count(DISTINCT col)三者数的东西不同:行数 / 非 NULL 数 / 去重非 NULL 数
sum / avgsum(amount)空集返回 NULL——套 COALESCE(sum(x), 0)
min / maxmax(created_at)跳过 NULL
string_aggstring_agg(name, ', ' ORDER BY name)把多行拼成一串,可带排序
array_agg · jsonb_aggarray_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 HAVINGWHERE 分组前过滤行,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)、(总计) 三级汇总
WHERE vs HAVING:WHERE 过滤原始行,HAVING 过滤聚合结果。「总额大于1000的用户」必须用 HAVING,因为 SUM 是分组后才算出的。
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;
最常见的表现是两个数对不上但都「没错」:报表上「订单数 1000」(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), ());
别在应用层用「region 是不是 NULL」来判断汇总行——上面验证过,数据里的真实 NULL 会被误判成总计。一定要用 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+),介于两者之间

常见配方

要做的事怎么写
每组前 NWITH 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 里算出来,再在外层过滤。
「每组前 N 条」有两种写法:窗口函数 + 外层过滤(通用、N 可变),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」为什么总要套一层的原因。
三个排名函数的区别只在「并列之后」:值为 5、10、10、30 时——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)。
窗口函数是数据分析最强力的 SQL 特性。环比/同比、排行榜、移动平均、累计值——几乎全依赖它。

写 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 帧一起写上,哪怕它和默认行为一致。
「每组取前 N 条」除了本章的 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 returnedSELECT 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 或窗口函数
LATERALFROM 里的子查询要引用左侧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 被引用多次且计算很贵时
可写 CTEWITH 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
典型用途组织架构、分类树、依赖图、物料清单
递归 CTE 在数据有环时会无限跑下去,症状是查询卡死、内存一路涨。分类表里一条 parent_id 指回祖先就足够触发。写递归查询时把深度上限当成必选项而不是优化项——先加 WHERE depth < 20,确认数据确实无环之后再决定去不去掉。
一条长查询的拆解顺序:先把每个「中间结果」抽成一个 CTE 并起个业务名字(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;
关联子查询(引用外层查询的列)会对每行执行一次,大表上性能很差。通常可改写成 LEFT JOIN + GROUP BY。
判断「存在与否」用 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 ...;
PG 12 起 CTE 默认会被内联到主查询里优化,这是个行为变化。PG 11 及以前,WITH 总是先算完再用(「优化栅栏」),有人靠这个特性手工控制执行顺序;12 起规划器会把简单 CTE 展开、把外层条件下推进去——通常更快,但依赖旧行为的查询可能变慢或变快得莫名其妙。

需要强制物化(比如 CTE 里有副作用、或被引用多次且计算昂贵)就写 AS MATERIALIZED;反过来用 AS NOT MATERIALIZED 强制内联。

WITH RECURSIVE 还有个必须注意的点:写错终止条件会无限递归。图数据里有环时尤其容易——要么用 UNION(自动去重)而不是 UNION ALL,要么在递归项里带一个深度计数并加上限。
递归 CTE 是处理「无限层级」数据的标准方案——评论嵌套、分类树、组织架构图,都用同一套模式。

排序与分页

本章覆盖:排序选项(NULLS FIRST/LAST、COLLATE、表达式排序)、为什么 ORDER BY 必须带唯一列兜底、OFFSET 与 keyset 两种分页的取舍,以及总数怎么算(精确 vs 估算)。

分页出错的两种形态:翻页时重复或漏行(排序不唯一)和越翻越慢(OFFSET 要先扫过前面所有行)。两者的修法完全不同。

排序选项

写法说明
ORDER BY created_at DESC默认 ASC
NULLS FIRST / NULLS LASTPG 默认把 NULL 当最大值:升序在最后、降序在最前(03 章)
tiebreakerORDER 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 / OFFSETORDER 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。
把 tiebreaker 当成 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 id 就没事了」——只在 id 确实唯一时成立。如果你按一个业务字段排序(比如 ORDER BY priority DESC),而 priority 只有「高/中/低」三个值,那这个排序对绝大多数行都是并列的,问题一点没解决。判据是:排序键的组合能否唯一确定每一行。不能,就追加主键。
「加个 tiebreaker」这条规则对任何依赖顺序的场景都成立,不只是分页:导出 CSV、生成报表、做前后两次结果的 diff,只要排序键可能有重复,就该追加主键。

反过来,没有 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 的排序键必须以主键收尾。
接口设计上,把「下一页」表达成一个不透明的 cursor 字符串(把 (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%'——九成情况就是它
GINCREATE INDEX ON t USING gin (tags);多值列:jsonb 的 @>、数组的 &&、全文搜索 tsvector(11、15 章)
GiSTUSING gist (geom)几何、范围类型、最近邻、EXCLUDE 约束
BRINUSING brin (created_at)超大表 + 物理有序(日志按时间追加):索引极小,代价是精度低
HashUSING hash (col)只支持 =;PG 10 起才可靠,但 B-tree 基本都不差
SP-GiSTUSING 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);
「有索引却走了全表」多数时候不是索引坏了,是规划器算出全表更便宜。索引扫描要先读索引再回表取行,命中行一多,随机 I/O 反而比顺序扫全表贵——所以选择性低的条件本来就不该指望索引。真正需要你改的是另外三种「写法让索引用不上」:
· 函数包住了列: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,别靠猜。
不需要索引的场景:表很小(几百行);列频繁写入但很少查询;值分布极不均(如 boolean 列大部分是 true)。

优化的第一步永远是看计划,而不是凭感觉加索引。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 为何没跟上
「autovacuum 会自动处理」不等于「你永远不用管」。autovacuum 是按阈值触发的(默认约「死元组超过表行数的 20%」),所以它跟不上的场景真实存在:持续高频 UPDATE/DELETE 的表、长事务或废弃的复制槽把旧版本「钉」住不让回收。后果就是表膨胀——磁盘涨、缓存命中率掉、全表扫越来越慢,而行数并没变多。

查法: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 而是设计——用这两个级别就必须在应用侧写重试循环,否则等于把偶发报错甩给了用户。
大多数应用用默认 READ COMMITTED 即可。「读取-计算-写回」场景优先用一条 UPDATE 原子完成(如 SET stock = stock - 1),而不是先 SELECT 再 UPDATE。

JSON 与数组

本章覆盖:JSONB 的取值 / 判断 / 修改三套运算符与函数、数组操作全表、GIN 索引与表达式索引怎么选,以及 json 与 jsonb 的区别。配上索引,很多场景一个库就够,不必再引入第二种存储。

PG 的 jsonb 与数组让「半结构化数据」不必再引入第二个数据库。运算符很多但分得很清:取值一套、判断一套、修改一套。

JSONB 取值

运算符怎么写返回
->data -> 'user' · data -> 0jsonb——还能继续往下取
->>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(…)
行转 JSONto_jsonb(t) · row_to_json(t)
聚合成 JSONjsonb_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 的正确用法是「稳定的部分建成列,易变的部分塞进一个 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' 比。
JSONB 适合「结构不固定/经常变化」的数据(用户设置、表单提交),普通列+外键适合「结构固定且需要强约束」的数据。两者混合使用很常见。

数组是 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;
数组下标从 1 开始,不是 0——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 当成「万能的动态列」用。它适合形状不固定或稀疏的附加属性;那些每行都有、还要频繁过滤排序的字段,老老实实建成真正的列——列有类型检查、有统计信息、有更好的索引,而 jsonb 字段的选择性估算常常不准,容易让优化器选错计划。「先全塞 jsonb,以后再说」的表,一年后几乎都要拆。
默认选 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/pgSQLLANGUAGE 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 的前提是视图上有唯一索引,这一条要在建视图时就想到,否则上线后才发现刷不动。
视图最实在的用途是给复杂 JOIN 起个业务名字,让应用层只 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 不锁表,但需要唯一索引
普通视图不存数据,只是一段被命名的查询——每次查它都会实际执行那段 SQL。所以在视图上再套视图、再 JOIN 别的表,很容易叠出一个谁也看不懂的巨型查询,而 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;
把业务逻辑大量写进数据库函数,是个需要慎重的决定。它确实有优势——靠近数据、省往返、多个应用共享同一套规则。但代价很实在:难以做版本管理和 code review(不在应用仓库的常规流程里)、难以单元测试和调试、难以水平扩展(计算压在数据库这个最难扩容的组件上)。

比较稳妥的分界:数据完整性约束(触发器维护 updated_at、审计日志)和明显该贴着数据做的批量操作放数据库;业务流程放应用。

还要注意 PL/pgSQL 里 RAISE EXCEPTION 会回滚整个事务(除非外层有 savepoint 或 EXCEPTION 块接住)——这通常正是你要的,但要清楚它的影响范围不止当前函数。
函数的 volatility 标注会实打实影响性能,别漏写。三档: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 psycopgcur.execute("SELECT * FROM t WHERE id = %s", (uid,))——%s 不是字符串格式化,参数必须是元组
Node pgclient.query('SELECT * FROM t WHERE id = $1', [uid])
Go pgxconn.Query(ctx, "… WHERE id = $1", uid)
Java JDBCPreparedStatement + 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,排查时能立刻定位是哪个服务
Python 里 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 不安全,别用它拼用户输入)
不同驱动的占位符语法不一样,抄错了不会安全降级、而是直接报错或更糟。PG 原生是 $1 $2;node-postgres 用 $1;psycopg 用 %s;有些库用 ?。特别注意 psycopg 的 %s 不是 Python 的字符串格式化——写成 cur.execute("... id = %s" % user_id)(用了 % 运算符)就变回了字符串拼接,看着像参数化,实则完全没有防护。正确写法是把值作为第二个参数传给 execute。

还有一个易错点:psycopg 传单个参数时必须写成元组 (user_id,),漏掉那个逗号就不是元组了。
参数化不只是安全问题,也是性能问题。同一条带占位符的 SQL 可以被数据库复用执行计划;而每次拼出不同字符串的 SQL,在数据库看来都是一条全新语句,每次都要重新解析和规划。所以参数化「顺便」还更快。

另外,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');   // 自动借还,不用手动管
用了 PgBouncer 的 transaction 模式后,一部分「跨语句的会话状态」会失效。因为你下一条语句可能落在另一个真实连接上。具体来说: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() 就别手动借连接。
Serverless 环境(Lambda、Vercel、Cloud Run)要特别当心。每个实例都有自己的连接池,而实例数会随流量自动扩容——流量一涨,连接数就成倍涨,很容易瞬间打满数据库。应对办法:在数据库前面放一层 PgBouncer(或用 Neon / Supabase 自带的连接池端点),让成百上千个应用侧连接复用少量真实连接。

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 连接上什么都没有的空事务。必须先从池子里显式借一个连接,全程用它。

哪些错误该重试

  • 40001 serialization_failure / 40P01 deadlock_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;
ORM 的「自动事务」边界常常和你想的不一样。很多框架把每个请求包在一个事务里,或者反过来每条语句独立提交。在写关键写入逻辑前,先确认一下你的框架到底怎么划事务边界——最直接的验证方法是故意抛个异常,然后去数据库看数据有没有留下。

另一个高频问题:在事务里做了很多次单条 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 掉重建。建完索引后顺手确认一下状态,这一步很多人不知道。
迁移工具选一个就好,各语言生态都有成熟方案:Flyway / Liquibase(JVM,也可独立用)、Alembic(Python)、golang-migrate、Prisma Migrate、Rails Active Record。它们做的是同一件事:按版本号顺序执行、记录已执行到哪一版、保证每个环境状态一致。

两条通用纪律:① 迁移只往前,不改历史——已经跑过的脚本绝不修改,要撤销就新写一个反向迁移;② 先加后删,分两次发布——要删列或改名时,先发一版让代码不再用它,确认无碍后再发一版真正删掉。这样任何一步都能安全回滚。

性能问题的排查顺序永远是:先找出哪条查询慢,再用 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 里都是角色
给库 / schemaGRANT 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 也不会串数据
代价每条查询都要带上策略条件,策略里的表达式必须能走索引,否则全表扫
RLS 里用 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 的真正威力在于扩展生态——全文搜索、地理查询、向量搜索、定时任务,很多场景一个 Postgres 就够了。

权限有三层:连得进来(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 = '...',就只能看到本租户的行
行级安全(RLS)对表的所有者和超级用户默认不生效——这是测试时最容易被骗过去的一点。你用建表的那个账号验证策略,会发现「怎么全都能看到」,误以为策略没写对。必须用一个普通角色去验证,或者给表加上 FORCE ROW LEVEL SECURITY 让所有者也受约束。

还有:启用了 RLS 却一条策略都没建,等于禁止所有访问(默认拒绝)。顺序应该是先建策略再 ENABLE,或者做好这期间查询返回空的准备。

最后,应用连数据库不要用超级用户。这不只是安全洁癖——超级用户会绕过 RLS 和所有权限检查,让你精心设计的策略全部失效。给应用建一个最小权限的角色,是让这一章的内容真正生效的前提。
最小权限原则:应用连接用的角色只授予它真正需要的权限,别拿超级用户 postgres 跑业务。RLS 是多租户单库隔离的利器,但要记得给策略配套的列建索引。改动 pg_hba.conf 后需 reload 才生效。

速查

本章是随手可翻的对照表:先用任务反查按「你想干什么」定位到该用的东西和所在章,再翻后面三张按主题排的表——psql 元命令、数据类型、常用函数。所有条目都指回展开处,不在这里重复解释。

SQL 的函数名常常不直观(要「取月初」得用 date_trunc,要「空值兜底」得用 coalesce),而且散落在各章。这张表按你想干的事来找,右边给出该用的写法和展开的章号。

查询 · 过滤 · 聚合

想做什么用这个在哪
判断是否为空x IS NULL——绝不能用 = NULL03 章
空值兜底coalesce(x, 0);多备选依次取第一个非空03 章
防除零a / nullif(b, 0)03 章
「不等于」且要包含空值行x IS DISTINCT FROM 'y'03 章
子查询取反NOT EXISTS——别用 NOT IN03 章
条件计数count(*) FILTER (WHERE ...)03 章
去重DISTINCT;每组留一行用 DISTINCT ON02 章
分组后再过滤HAVING(WHERE 里放不了聚合)05 章
取每组前 NROW_NUMBER() OVER (PARTITION BY ...) 套子查询06 章
算环比 / 移动平均LAG / LEAD;ROWS BETWEEN ... PRECEDING06 章
把查询拆成几段WITH(CTE);树形数据用 WITH RECURSIVE07 章
分页小数据 LIMIT/OFFSET;大数据用 keyset08 章
排序时把空值放最后ORDER BY x DESC NULLS LAST08 章
中文按拼音排ORDER BY name COLLATE "zh-x-icu"——需要 PG 编译时带 ICU,先用 SELECT collname FROM pg_collation 确认有没有本章

写入 · 结构 · 性能

想做什么用这个在哪
插入并拿回生成的 idINSERT ... RETURNING id01 章
有则更新无则插入INSERT ... ON CONFLICT (k) DO UPDATE15 章
重复请求不重复写ON CONFLICT DO NOTHING + 幂等键13 章
批量导入\copy(快);造测试数据用 generate_series01 章
自增主键bigint GENERATED ALWAYS AS IDENTITY,别用 serial01 章
看表结构\d 表名01 章
看查询为什么慢EXPLAIN (ANALYZE, BUFFERS)09 章
线上加索引CREATE INDEX CONCURRENTLY(不能在事务内)14 章
大小写不敏感查找建 lower(col) 表达式索引,或用 citext09 章
子串搜索 LIKE '%x%'pg_trgm + GIN 索引(B-tree 用不上)09 章
找出谁最慢pg_stat_statements 按 total_exec_time 排14 章
备份 / 恢复pg_dump -Fc / pg_restore -j 414 章
-- 五个最常抄的写法,查到名字后照着改

-- ① 每个用户的最新一条订单(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 错误码

数据类型:日常只需记这一列

要存什么用这个别用
文本textvarchar(n) 无必要;char(n) 会补空格
整数int;主键或可能超 21 亿用 bigint—
金额numeric(12,2)——精确十进制float / real(有精度误差)
科学计算的小数double precision—
时间点timestamptz——几乎总是它timestamp(不带时区,易错)
纯日期 / 时长date / interval—
真假boolean—
主键bigint GENERATED ALWAYS AS IDENTITYserial(非标准、易失步)
分布式主键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
把表直接暴露成 API(PostgREST / Supabase 这类)之后,表结构就成了对外契约:改个列名、拆张表都会打断所有客户端。稳妥做法是只对外暴露视图——视图名与字段名由你控制,底下的表随便重构(12 章)。
给团队配两套连接串:日常用只读角色,需要写入时显式切到另一个。~/.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.sql
在生产库上开着 psql 却忘了自己在哪,是真实发生过无数次的事故。典型场景:同时开了测试与生产两个终端标签页,DELETE 敲在了错的那个。三道成本极低的防线:① 提示符带上库名与主机名并染色(上方 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;
图形客户端的「导出全部数据」按钮在大表上是个陷阱。它通常会把结果集全部读进客户端内存再写文件——几千万行足以让客户端直接卡死或 OOM,而服务端那条查询还在跑,占着连接与临时空间。大批量导出一律用 \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;
「反正是内部工具,先不做权限」在自动生成后台上的代价比手写后端大得多。手写后端时「没做权限」意味着某个接口没校验;自动生成时它意味着整个数据库的每张表都有了一个可用的读写接口。而且这类工具通常还会顺手暴露结构(表名、列名、外键),相当于把数据模型也一并公开了。判据很朴素:只要这个服务能被浏览器直接访问到,就必须先把 RLS 配完再上线。
把上面那条「检查 RLS 覆盖情况」的查询做成 CI 里的一个断言:新表如果没开 RLS 或没有任何策略,构建就失败。这条检查十行 SQL,能挡住这条技术路线上最常见、后果也最严重的一类事故——「加了张表忘了配策略」不是会不会发生的问题,是什么时候发生的问题。

资料索引:项目 · 读物 · 自测

速查表能告诉你「怎么写」,但判断力只能靠真数据长出来。最后这一章给出收尾用的三样东西:难度递进的动手项目、按用途排的资料,以及一条自测标准。

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_locks14 章
社区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 章——做得到,这一页就毕业了
别把 SQL 当成「取数的语言」而在应用层做聚合。把十万行拉回内存再用代码分组求和,比一条 GROUP BY 慢几个数量级,还占满带宽和内存。判据很简单:凡是「查出来之后要遍历一遍算个数」的代码,多半该是一条 SQL。
项目 ③ 的性价比最高。SQL 写对不难,难的是知道它为什么慢——而这件事只有在自己造出一条慢查询、盯着执行计划一步步改快之后才会变成直觉。没有这一步,索引知识永远停在「好像该加一个」。