PostgreSQL 核心知识体系交互讲解

全景: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 什么都能干」容易被误读成「什么都该用 PG 干」。JSONB、pgvector、PostGIS 各有边界:缓存与高频计数器该用 Redis,海量日志与全文检索到一定规模该上专用系统。判据是数据量级与访问模式,不是「能不能」。
把官方文档当第一手资料——PostgreSQL 文档是公认写得最好的开源文档之一,遇到问题先查文档再搜帖子,能少走一半弯路。

上手:装 PG 并跑通第一条查询

在学任何 SQL 之前,先让机器把库跑起来:装上、连上、建一张表、插几行、查出来,再学会读报错。后面每一章都默认你手边有一个能立刻验证想法的库——本章的命令建议照着敲一遍。

「装数据库」听起来重,今天最快的路径不到一分钟。本地学习选 Docker 或云,都不用碰系统配置

三条路怎么选

  • Docker——一行起库,数据随容器走,玩坏了删掉重建。适合学习、跑实验、每个项目一个独立库;
  • 官方安装包——macOS 用 Postgres.app,Windows 用官网 EDB 安装包,Linux 用发行版的 postgresql 包。好处是开机自启、数据持久、附带 psqlpg_dump 全套工具;
  • 云上 Serverless——NeonSupabase 都有免费额度,注册完直接给一条连接串。适合不想在本机装东西。

连接串:所有工具都认它

无论哪条路最终都归结到一条连接串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"   -- 跑单条并退出
PG 会把不加引号的标识符全部转成小写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 verbose
「语法错误」报的位置常常在真正的问题之后——PG 是读到读不下去时才报错,所以 syntax error at or near "email" 往往意味着问题出在 email 前面那一段(最常见的是漏了逗号)。
约束名是免费的线索,所以建约束时给它起个好名字。PG 自动生成的名字形如 表名_列名_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 NULLILIKE 是 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 里 textvarchar(n) 存储与性能完全相同,varchar(n) 唯一的作用是加一条长度上限,超了直接报 22001 value too long;真要限长,用 CHECK (length(x) <= n) 更好,因为改上限只是改约束,不必 ALTER TYPEchar(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、更新拿改后的值、删除拿被删的行,都不用再查一次——既省一次往返,又天然没有「查到的和改的不是同一份」的竞态。

批量插入用一条多值 INSERTVALUES (...), (...), (...))而不是循环单条:一万次单条插入是一万次网络往返,改成一批几百到几千行能快一个数量级;再大就该用 \copy

翻页时 ORDER BY 一定要带一个唯一列做 tiebreaker,否则会重复或漏行——连同深分页的代价,见 08 章

NULL 与三值逻辑

NULL 不是「空字符串」也不是「零」,而是「不知道」。这个区别看着哲学,后果却极其具体:它让 SQL 的布尔运算从两值变成三值,让 <> 悄悄漏行、让 NOT IN 一个结果都不返回、让 SUM 在空集上返回 NULL 而不是 0。这些都不报错,只是安静地给你一个错的答案——所以单独用一章讲清楚。

把 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 一行都留不下。

对照一下 INx 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 被聚合函数忽略
coalescenullif 是一对反操作,配合起来能解决绝大多数 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、当假处理,那些行就被过滤掉了——你以为「保留所有用户」,结果只剩下有订单的。要筛右表就写进 ONLEFT 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 JOINWHERE 里出现右表字段,先问一句:是想做反连接,还是不小心把 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 → 1LEFT 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」。

聚合与分组

JOIN 把明细行拼齐后,下一步常常是「汇总成数字」——GROUP BY 将行分组、用聚合函数算出每组的和/均/计数。关键区别记牢:WHERE 在分组前过滤行,HAVING 在分组后过滤聚合结果。

聚合把多行压成一行,而 WHEREHAVING 的分工,正是 02 章那条执行顺序链的直接推论。

聚合、分组与两种过滤

常用聚合函数:COUNTSUMAVGMINMAXHAVING 用于过滤分组后的聚合结果(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 1ORDER BY 1 同理。这在按月/按天汇总时特别顺手。

条件聚合优先用 FILTERcount(*) 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) = 40avg(dept_id) = 13.33——注意 avg 是除以 3 不是 4。「平均值比预期高」十有八九是这个原因。

空集:count 是 0,sum 却是 NULL

  • WHERE 1=0 时:count(*)0sum(salary)NULL(不是 0)。
  • 这在「把汇总结果直接拿去做算术」时会传染——sum(x) * 2 得到 NULL,再存进非空列就报错。要 0 就写 coalesce(sum(x), 0)
  • max / min / avg 在空集上同样返回 NULL,只有 count 返回 0。

GROUP BY 把所有 NULL 归成一组

  • dept_id 分组,结果是 10→220→1NULL→1 ——NULL 自成一组,而不是被丢弃。
  • 这和 WHERENULL = 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西 70NULL 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 更直白。

窗口函数

上一章的 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」为什么总要套一层的原因。
三个排名函数的区别只在「并列之后」:值为 5、10、10、30 时——row_number1,2,3,4(强行编号,并列也分先后)、rank1,2,2,4(并列同名次,之后跳号)、dense_rank1,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 ROW1, 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

查询越写越长、一层套一层时,就该拆解了。子查询在 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;
关联子查询(引用外层查询的列)会对每行执行一次,大表上性能很差。通常可改写成 LEFT JOIN + GROUP BY。
判断「存在与否」用 EXISTS,不要用 count(*) > 0EXISTS 找到第一行就能返回,而 count 必须数完全部。

选择口诀:要取值用标量子查询或 JOIN;只判断存在EXISTS / NOT EXISTS取反一律用 NOT EXISTSNOT 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 是处理「无限层级」数据的标准方案——评论嵌套、分类树、组织架构图,都用同一套模式。

排序与分页

分页几乎是每个列表接口都要做的事,也几乎是每个项目都会出错的地方——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 id 就没事了」——只在 id 确实唯一时成立。如果你按一个业务字段排序(比如 ORDER BY priority DESC),而 priority 只有「高/中/低」三个值,那这个排序对绝大多数行都是并列的,问题一点没解决。判据是:排序键的组合能否唯一确定每一行。不能,就追加主键。
「加个 tiebreaker」这条规则对任何依赖顺序的场景都成立,不只是分页:导出 CSV、生成报表、做前后两次结果的 diff,只要排序键可能有重复,就该追加主键。

反过来,没有 ORDER BYLIMIT 是完全没有意义的——「随便给我 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(*) 全表扫。数据量大时常见的折中是:不给精确总数,只回答「还有没有下一页」(多取一行看看够不够)。

索引与性能

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);
「有索引却走了全表」多数时候不是索引坏了,是规划器算出全表更便宜。索引扫描要先读索引再回表取行,命中行一多,随机 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 数字很大 → 该列需要索引。

事务与并发

查询讲完了,可真实系统里多个操作往往要么一起成功、要么一起失败,还得扛住并发。事务把多个操作打包成「全部成功或全部失败」的原子单元——它是数据一致性的基础,也是本页从「单条查询」迈向「可靠系统」的门槛。

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 一句话同时完成了「检查余额」和「扣减」,没有任何竞态;拆成先 SELECTUPDATE 反而要靠事务和锁来补救。

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 与数组

前面都在关系模型(规整的行与列)里打转,但现实数据常常是半结构化的。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' 比。
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) = 0arr = '{}'
= 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}'::jsonbtrue(键序无关);换成 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——后面想建索引、想按内容查时,改类型比改业务代码容易。

视图与函数

会写查询之后,下一步是把它们「收纳」起来复用。视图封装查询逻辑、函数封装业务逻辑,让复杂 SQL 藏进一个名字背后;物化视图更进一步缓存结果,适合数据允许稍微滞后的仪表盘场景。

普通视图不存数据,物化视图——一字之差,性能与新鲜度的取舍完全相反。

两种视图的取舍

普通视图是保存的查询(不存储数据),像虚拟表一样使用。物化视图实际存储查询结果,查询快但数据不会自动更新,需要手动/定时 REFRESHCONCURRENTLY 刷新时不锁表(需唯一索引)。

-- 普通视图
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

前面所有 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 不安全,别用它拼用户输入)
不同驱动的占位符语法不一样,抄错了不会安全降级、而是直接报错或更糟。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」逃生口($queryRawUnsafetext() 之类),名字里带 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。一万次单条插入是一万次网络往返,慢得无法接受。改成一条多值 INSERTVALUES (...), (...), (...),一批几百到几千行)或者用 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 而不是「常量还是函数」IMMUTABLESTABLE(含 now()current_timestamp)都走快速路径,只有 VOLATILErandom()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 真正拉开差距的地方。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 内置不支持,需要 zhparserpg_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 章
分组后再过滤HAVINGWHERE 里放不了聚合)05 章
取每组前 NROW_NUMBER() OVER (PARTITION BY ...) 套子查询06 章
算环比 / 移动平均LAG / LEADROWS 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_statementstotal_exec_time14 章
备份 / 恢复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);
timestamptimestamptz 只差三个字母,用错了要很久才会发现。timestamptz 存的是绝对时刻(内部按 UTC 存),显示时按当前会话时区转换;timestamp 存的是一个没有时区含义的读数——它没法回答「这是全球哪一刻」,一旦服务器时区变了、或者用户在别的时区,数据就全错了,而且历史数据无法修复(因为你不知道当初那个读数是哪个时区的)。

默认一律用 timestamptz。只有在存「每天 9:00 上班」这类与具体时区无关的挂钟时间时才用 timestamptime
\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.sql
在生产库上开着 psql 却忘了自己在哪,是真实发生过无数次的事故。典型场景:同时开了测试与生产两个终端标签页,DELETE 敲在了错的那个。三道成本极低的防线:① 提示符带上库名与主机名并染色(上方 PROMPT1);② 生产库用只读角色登录,需要写时才显式切换;③ 破坏性语句先写成 BEGIN; ... ; 看影响行数再决定 COMMIT 还是 ROLLBACK——PG 的 DDL 也在事务里,这一招对建表删列同样有效(10 章)。
\copyCOPY 差一个反斜杠,含义差很远: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,能挡住这条技术路线上最常见、后果也最严重的一类事故——「加了张表忘了配策略」不是会不会发生的问题,是什么时候发生的问题

从这里到精通:路线图

地图铺完了,剩下的路要亲手写出来。最后这一章给出收尾路线:难度递进的动手项目、按阶段的资料,以及一条自测标准。

数据库能力只能在真实数据和真实慢查询里长出来。按下面的顺序走,每一步都有明确产出物,走完你就拥有一套能带去任何岗位的 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 章的内容从「记住了」变成「理解了」。
一条自测标准:给你一条 500ms 的慢查询,你能否用 EXPLAIN ANALYZE 读懂执行计划、指出瓶颈节点,并给出索引或改写方案把它压到 10ms 以内——能做到,这一页就毕业了。