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