跳转到主要内容

PostgreSQL 实例教程

通过简短、可运行的示例动手学习 PostgreSQL。

Dariush Abbasi
简体中文版 · 面向 PostgreSQL 17

本书用 57 节短课,从第一次查询讲起,依次介绍数据建模、查询、索引、事务、存储逻辑、检索与日常运维。每章都附有 SQL 文件;备份一章介绍 Shell 命令,psql 参考一章则提供查询系统目录的替代示例。

开始阅读 · 整书打印 · 源码仓库

准备工作

本书面向 PostgreSQL 17,使用 psql 命令行客户端。更高版本才有的功能会明确标注。阅读时可以配合查阅官方文档。

下载完整 SQL 示例包,解压到一个新目录,然后在包含 source/ 的目录中运行命令。请使用可以随时丢弃的练习数据库:本书会创建、修改和删除示例表。示例中的数据库名为 postgres,运行时请替换成自己的练习数据库,例如 pbe:

createdb pbe
psql -X -v ON_ERROR_STOP=1 -f source/first-query.sql pbe

请按章序阅读。在学习第 6 章之前,先用第 16 章和第 24 章介绍的文件创建示例表:

psql -X -v ON_ERROR_STOP=1 -f source/create-table.sql -f source/insert.sql pbe

第 16 章会重新创建表,第 24 章会再次填入数据。后面的连接章节依赖第 29 章的示例,递归示例还会用到第 31 章。跳读时,请先完成该章要求的准备步骤。选做练习可能改变后续示例的结果。日期、UUID、系统目录列表、查询计划和执行耗时可能不同;终端记录中的省略号表示省去的输出。

两个特殊情况

第 46 章会故意触发重复键错误,请设置 ON_ERROR_STOP=off,以便继续执行后面的回滚和清理。第 55 章需要使用非超级用户、且没有 BYPASSRLS 属性的数据库所有者;即使启用了 FORCE ROW LEVEL SECURITY,超级用户也会绕过行级安全。

目录

示例

  1. 示例 1.1 — 第一次查询
  2. 示例 2.1 — psql 基础
  3. 示例 3.1 — SELECT 基础
  4. 示例 4.1 — WHERE
  5. 示例 5.1 — ORDER BY
  6. 示例 6.1 — 从表中查询数据
  7. 示例 7.1 — LIMIT 与 OFFSET
  8. 示例 8.1 — DISTINCT
  9. 示例 9.1 — 空值(NULL)
  10. 示例 10.1 — 表达式
  11. 示例 11.1 — 数值类型
  12. 示例 12.1 — 文本类型
  13. 示例 13.1 — 布尔值与日期
  14. 示例 14.1 — UUID 与 JSONB
  15. 示例 15.1 — 数组与 ENUM
  16. 示例 16.1 — CREATE TABLE
  17. 示例 17.1 — 列类型与约束
  18. 示例 18.1 — ALTER TABLE 与 DROP
  19. 示例 19.1 — 主键与唯一约束
  20. 示例 20.1 — 外键
  21. 示例 21.1 — NOT NULL 与 DEFAULT
  22. 示例 22.1 — 标识列与序列
  23. 示例 23.1 — 模式与 search_path
  24. 示例 24.1 — INSERT
  25. 示例 25.1 — UPSERT(ON CONFLICT)
  26. 示例 26.1 — UPDATE
  27. 示例 27.1 — DELETE
  28. 示例 28.1 — RETURNING
  29. 示例 29.1 — 内连接与左连接
  30. 示例 30.1 — 右连接与全连接
  31. 示例 31.1 — 自连接
  32. 示例 32.1 — UNION、INTERSECT 与 EXCEPT
  33. 示例 33.1 — COUNT、SUM 与 AVG
  34. 示例 34.1 — GROUP BY
  35. 示例 35.1 — HAVING
  36. 示例 36.1 — 标量子查询与 IN 子查询
  37. 示例 37.1 — EXISTS 与派生表
  38. 示例 38.1 — 公用表表达式(WITH)
  39. 示例 39.1 — 窗口函数
  40. 示例 40.1 — 字符串与数值函数
  41. 示例 41.1 — 日期函数与 COALESCE
  42. 示例 42.1 — CASE
  43. 示例 43.1 — 创建索引
  44. 示例 44.1 — 何时创建索引
  45. 示例 45.1 — EXPLAIN 与查询计划
  46. 示例 46.1 — BEGIN、COMMIT 与 ROLLBACK
  47. 示例 47.1 — 保存点
  48. 示例 48.1 — 创建视图
  49. 示例 49.1 — 物化视图
  50. 示例 50.1 — 函数与 PL/pgSQL
  51. 示例 51.1 — 触发器
  52. 示例 52.1 — 全文检索
  53. 示例 53.1 — VACUUM 与 ANALYZE
  54. 示例 54.1 — pg_dump 与 pg_restore
  55. 示例 55.1 — 角色与 GRANT
  56. 示例 56.1 — psql 元命令
  57. 示例 57.1 — COPY

关于本版

本中文版译自英文网页修订版。该英文版以 boringcollege/postgres-by-example 仓库的提交 ffde0d358722498e37982b57bdbd711871a61c43 为基础,将讲解与 SQL 示例放在一起,已核查的技术修订记录在编校说明中。原仓库采用 Creative Commons 署名 4.0 国际许可协议。

中文版由 Codex 翻译并分轮自检,未经独立人工审定。

出版与许可 · 技术勘误

1 第一次查询

运行第一次查询,查看 PostgreSQL 服务器与 psql 客户端的版本。

要确认 PostgreSQL 环境是否正常,最快的办法就是运行一个不需要表的查询。PostgreSQL 可以单独计算表达式,并不是每条 SELECT 都需要 FROM。我们将利用这一点,一次确认三件事:服务器正在运行,客户端能够连接到它,而且版本足够新,可以跟着本书练习。

本章要点

  • 如何用 psql -f 运行 SQL 文件
  • 不需要表的最简 SELECT
  • 如何读懂 PostgreSQL 版本字符串
  • 客户端版本与服务器版本的区别

示例

示例 1.1 第一次查询
-- Simplest query: no tables, just a literal
SELECT 1;

-- Arithmetic and string expressions work without a FROM
SELECT 2 + 3 AS sum, 'hello, postgres' AS greeting;

-- Check your PostgreSQL server version
SELECT version();

-- A more readable version, when you only need the number
SHOW server_version;

SELECT 1 返回一行一列,值为整数 1。表达式形式(SELECT 2 + 3, 'hello, postgres')表明,SELECT 中可以直接写任意表达式,由 PostgreSQL 求值并返回结果。version() 是一个内置函数,会返回完整的版本字符串,其中包括操作系统、编译器等信息;SHOW server_version 则只返回版本号。两者报告的都是当前所连接服务器的版本。psql --version 报告的是客户端版本,两者可能不同。

运行示例

在仓库根目录运行这个文件:

$ psql -f source/first-query.sql postgres
 ?column?
----------
        1
(1 row)

 sum | greeting
-----+-----------------
   5 | hello, postgres
(1 row)

                                                    version
------------------------------------------------------------------------------------------------------------------------------
 PostgreSQL 17.x on x86_64-pc-linux-gnu, compiled by gcc ...
(1 row)

 server_version
----------------
 17.x
(1 row)

你看到的版本字符串会有所不同。如果输出与上面类似,就说明连接已经打通了。

常见陷阱

  • “could not connect to server” —— 守护进程没有运行。可用 brew services start postgresql(macOS)、sudo systemctl start postgresql(Linux),或安装方式所对应的命令启动它。
  • “role ‘you’ does not exist” —— PostgreSQL 中没有与你的操作系统用户对应的数据库用户。请在能够访问该集群的 Shell 中运行 createuser --superuser $(whoami) 创建用户。
  • “database ‘postgres’ does not exist” —— 某些安装方式,尤其是 macOS 上的 homebrew,不会创建默认的 postgres 数据库。运行一次 createdb postgres,或用 createdb pbe 创建练习库,并在每条命令中将数据库名指定为 pbe。
提示

如果省略数据库名,psql 默认使用你的操作系统用户名。要明确使用 postgres 数据库,运行 psql -f source/first-query.sql postgres。如果想改用连接字符串,则运行 psql 'postgres://user:pass@host:5432/dbname'。

动手练习

运行 psql -f source/first-query.sql 并确认输出。然后用 psql postgres 建立交互式连接,输入 SELECT now(); 并按回车键,最后用 \q 退出。

下载

SQL 文件:first-query.sql · 全部示例文件 · 准备工作

2 psql 基础

运行 SQL 文件,以交互方式探索数据库,并获取 psql 的帮助。

psql 是 PostgreSQL 官方的命令行客户端,非常好用。大多数专业用户都会优先使用它,而不是图形界面工具:它速度快,支持脚本,而且每次安装 PostgreSQL 都会附带。它有两种用法:以批处理模式(psql -f file.sql)运行脚本,或者进入交互模式(psql),逐条输入 SQL 和元命令。本章介绍基础用法;后面的章节会介绍更多元命令。

本章要点

  • psql 的两种模式:文件模式与交互模式
  • 如何确认当前会话的身份与环境(当前用户、数据库、模式)
  • SQL 语句与 psql 元命令的区别
  • 如何获取帮助:用 \? 查看元命令帮助,用 \h 查看 SQL 帮助

示例

示例 2.1 psql 基础
-- List user databases. Equivalent to \l in interactive psql.
SELECT datname
FROM pg_database
WHERE datistemplate = false
ORDER BY datname
LIMIT 5;

-- Identify the session: who am I, where am I, what's on my search_path?
SELECT current_user, current_database(), current_schema();

-- A few server settings worth knowing about
SHOW timezone;
SHOW server_encoding;

pg_database 是众多系统目录之一,也就是 PostgreSQL 提供给用户查看的内部表。datistemplate = false 这个过滤条件会隐藏 template0 和 template1,新数据库都是从这两个模板克隆而来的。current_user、current_database() 和 current_schema() 是会话函数;排查连接或权限问题时,你经常会需要它们提供的信息。SHOW 显示运行时参数,这些参数可以在服务器、数据库、角色或会话级别修改。

交互式使用 psql 时,还可以输入由客户端在本地解释的元命令(也称反斜线命令):

\l         list databases
\c db      connect to database `db`
\dt        list visible tables in the current search_path
\d table   describe a table (columns, types, indexes)
\dn        list schemas
\df        list functions
\du        list roles (users/groups)
\timing    toggle showing query timing
\x         toggle expanded display (great for wide rows)
\?         help for meta-commands
\h SELECT  help for the SELECT command (or any SQL keyword)
\q         quit

无论是在交互模式中,还是在通过 psql -f 读取的文件中,元命令都由 psql 客户端解释。\dt 等命令会向服务器发送系统目录查询;反斜线语法本身并不是服务器端的 SQL。

运行示例

运行这个文件:

$ psql -f source/psql-basics.sql postgres
       datname
--------------------
 postgres
(1 row)

 current_user | current_database | current_schema
--------------+------------------+----------------
 your_user    | postgres         | public
(1 row)

 TimeZone
----------
 UTC
(1 row)

 server_encoding
-----------------
 UTF8
(1 row)

常见陷阱

  • 元命令需要 psql 客户端。psql -f 可以执行文件中的 \dt;如果驱动或图形界面工具直接把文件作为 SQL 发送,就不会解释这个命令。
  • 混淆 current_user 与 session_user:当 SET ROLE 生效时,current_user 反映当前生效的角色,而 session_user 仍是最初登录的用户。
  • 编码不匹配:如果终端不是 UTF-8 编码,而数据库是,就可能出现乱码。将 client_encoding 设为与终端一致的编码。
提示

将 \set PROMPT1 '%n@%/%x%R%# ' 加入 ~/.psqlrc,让提示符显示用户、数据库和事务状态。用 \e 在 $EDITOR 指定的编辑器中编辑上一条查询。调优查询时,可用 \timing on 开启计时。

动手练习

用 psql postgres 启动交互式会话,依次运行 \l、\dt、\d pg_database,最后用 \q 退出。用 \dt pg_catalog.* 比较不同模式下 \dt 显示的内容。

下载

SQL 文件: psql-basics.sql · 全部示例文件 · 准备工作

3 SELECT 基础

选择值与列,为查询结果命名,并查看系统目录。

SELECT 是 SQL 的核心,用来读取数据。最简单的形式是没有 FROM 的 SELECT expression,它对表达式求值并返回一行。更常见的形式 SELECT columns FROM table 则对表中每个匹配的行返回一行结果。无论哪种形式,结果都是一个关系,由行和列组成,列名可以用 AS 修改。

本章要点

  • 在 SELECT 中使用字面量与表达式(无需表)
  • 用 AS 为结果列命名
  • 在一次查询中选择多列
  • 读取系统目录表
  • 列别名与表别名的区别

示例

示例 3.1 SELECT 基础
-- Literals and expressions: no FROM required
SELECT 1 AS one;
SELECT 2 + 3 AS sum, 10 / 4 AS quotient, 10 / 4.0 AS real_quotient;
SELECT 'hello' AS greeting, length('hello') AS chars;

-- Multiple columns in one row
SELECT 1 AS a, 2 AS b, 'x' AS c;

-- Reading from a table (here, a system catalog)
SELECT relname, relkind
FROM pg_class
WHERE relname = 'pg_database'
LIMIT 1;

每条 SELECT 都会产生一个结果集。AS column_name 为输出列重新命名;如果省略它,PostgreSQL 会为字面量生成 ?column? 这样的名称,或沿用源列名。算术运算那一行值得再看一遍:10 / 4 返回整数 2,因为两个操作数都是整数;而 10 / 4.0 返回 2.5,因为其中一个操作数是 numeric 类型,不再使用整数除法。计算平均值和比率时,经常会遇到这个陷阱。

pg_class 是列出关系的系统目录,其中包括表、索引、视图、序列、物化视图等。relkind 列用于区分它们(r = 普通表,i = 索引,v = 视图,m = 物化视图,S = 序列)。查看系统目录是个值得养成的习惯,关于你所用模式的全部信息,都能在这里找到。

运行示例

运行方式:

$ psql -f source/select-basics.sql postgres
 one
-----
   1
(1 row)

 sum | quotient | real_quotient
-----+----------+---------------
   5 |        2 |            2.5
(1 row)

 greeting | chars
----------+-------
 hello    |     5
(1 row)

 a | b | c
---+---+---
 1 | 2 | x
(1 row)

   relname   | relkind
-------------+---------
 pg_database | r
(1 row)

常见陷阱

  • 忘记给表达式加 AS:SELECT count(*) 可以正常运行,但 SELECT count(*) FROM ... WHERE total > 0 GROUP BY id 如果没有 AS order_count 就会很难读懂。一定要为派生列设置别名。
  • 整数除法:1 / 2 返回 0。对其中一侧做类型转换:1::numeric / 2,或写成 1.0 / 2。
  • 双引号与单引号:'hello' 是字符串字面量,"hello" 是标识符(列名或表名)。混淆两者是 SQL 初学者最常犯的错误之一。
提示

各类列可以自由混用:SELECT id, name, id * 2 AS doubled, current_timestamp AS fetched_at FROM fruits; 完全合法。结果列按书写顺序排列,并使用别名作为名称;表达式的求值顺序则没有保证。

动手练习

运行 SELECT 10 * 2 AS twenty;。然后尝试 SELECT relname FROM pg_class WHERE relkind = 'r' LIMIT 5;,查看数据库中的五个表名。在 relname 后加上 , relkind,观察结果。

下载

SQL 文件: select-basics.sql · 全部示例文件 · 准备工作

4 WHERE

用比较、模式匹配、范围和 SQL 的三值逻辑过滤行。

WHERE 用于过滤行。这个子句位于 FROM 与 ORDER BY 之间,PostgreSQL 只保留条件求值为真的行。结果为假或 NULL 的行都会被排除。这种三值逻辑是 SQL 的一个特别之处,也是许多初学者出错的根源,最好尽早熟悉它。

本章要点

  • 用比较操作符(=、<>、<、>、<=、>=)过滤
  • 用 LIKE 做模式匹配(不区分大小写时用 ILIKE)
  • 用 AND、OR、NOT 组合条件
  • 成员判断与范围:IN、BETWEEN
  • 为什么 = NULL 不起作用,以及该用什么替代

示例

示例 4.1 WHERE
-- Equality and ordering
SELECT schemaname, tablename
FROM pg_tables
WHERE schemaname = 'pg_catalog'
ORDER BY tablename
LIMIT 3;

-- Pattern matching: % matches any sequence, _ matches one character
SELECT tablename
FROM pg_tables
WHERE tablename LIKE 'pg_a%'
ORDER BY tablename
LIMIT 5;

-- Case-insensitive: ILIKE (PostgreSQL extension)
SELECT tablename FROM pg_tables WHERE tablename ILIKE 'PG_Class' LIMIT 1;

-- Multiple conditions
SELECT tablename
FROM pg_tables
WHERE schemaname = 'pg_catalog'
  AND tablename LIKE 'pg_%'
  AND tablename NOT LIKE 'pg_stat_%'
ORDER BY tablename
LIMIT 5;

-- IN: membership test
SELECT tablename
FROM pg_tables
WHERE tablename IN ('pg_class', 'pg_database', 'pg_namespace')
ORDER BY tablename;

-- BETWEEN: inclusive range
SELECT n FROM generate_series(1, 10) AS n WHERE n BETWEEN 3 AND 7;

LIKE 是 SQL 标准中的模式匹配操作符:% 匹配任意字符串(包括空字符串),_ 恰好匹配一个字符。ILIKE 是 PostgreSQL 提供的扩展,用于不区分大小写的匹配。能否使用索引取决于匹配模式、排序规则和操作符类。字母前缀通常需要合适的表达式索引或三字符组(trigram)索引;对于以不受大小写影响的字符开头的某些匹配模式,B-树也能提供支持。若需要更强大的匹配能力,PostgreSQL 还支持 ~(正则表达式)、~*(不区分大小写的正则表达式),以及后面会介绍的全文检索。

AND 的优先级高于 OR,所以 A AND B OR C 的含义是 (A AND B) OR C。拿不准时就加括号,日后再读这条查询时,你会感谢现在的自己。IN (...) 是 = ANY (ARRAY[...]) 的简写,等价于一串用 OR 连接的条件。

运行示例

运行方式:

$ psql -f source/where.sql postgres
 schemaname |  tablename
------------+--------------
 pg_catalog | pg_aggregate
 pg_catalog | pg_am
 pg_catalog | pg_amop
(3 rows)
 ...

常见陷阱

  • = NULL 和 <> NULL 永远不会为真(结果是 NULL)。请用 IS NULL 和 IS NOT NULL。从其他各种语言转来的工程师都容易在这里栽跟头。
  • 如果子查询包含 NULL,NOT IN (subquery) 的行为就会变得难以理解:不匹配的值会得到 NULL,匹配的值会得到假,因此过滤后不会保留任何行。对于可能返回空值的子查询,优先使用 NOT EXISTS。
  • LIKE 'abc' 不会匹配 'ABC'。请用 ILIKE,或对两侧都应用 LOWER();不过,LOWER(col) = 'abc' 无法利用 col 上的普通索引(如果经常需要这样查询,可在 LOWER(col) 上建立表达式索引)。
  • 小心使用 IN ('a', 'b', NULL):列表中的 NULL 没有带来任何好处,却会悄悄让 NOT IN 的行为出乎意料。
提示

按前缀过滤文本时,WHERE name LIKE 'foo%' 可以使用具有合适排序规则或 text_pattern_ops 操作符类的 B-树索引。WHERE name LIKE '%foo'(后缀)或 '%foo%'(子串)则不行;这些情况可以考虑三字符组索引(pg_trgm 扩展)。

动手练习

将条件改为 WHERE schemaname = 'public';如果 public 中还没有表,就会得到零行。然后尝试 WHERE tablename ~ '^pg_a.*',看看正则表达式如何匹配。

下载

SQL 文件: where.sql · 全部示例文件 · 准备工作

5 ORDER BY

对查询结果排序,打破并列,并控制 NULL 值的位置。

ORDER BY 对结果集排序。如果没有它,行的顺序就是未定义的:PostgreSQL 可以按规划器认为方便的任意顺序返回行,而且每次运行的顺序都可能不同。如果应用关心顺序,就要明确指定。

本章要点

  • 用 ORDER BY column 排序
  • ASC(默认)与 DESC
  • 多列排序(打破并列)
  • 控制空值的位置(NULLS FIRST / NULLS LAST)
  • 按表达式和位置排序

示例

示例 5.1 ORDER BY
-- Default ascending
SELECT tablename
FROM pg_tables
WHERE schemaname = 'pg_catalog'
ORDER BY tablename
LIMIT 4;

-- Descending
SELECT tablename
FROM pg_tables
WHERE schemaname = 'pg_catalog'
ORDER BY tablename DESC
LIMIT 4;

-- Multi-column: sort by schema, then by name within schema
SELECT schemaname, tablename
FROM pg_tables
ORDER BY schemaname, tablename
LIMIT 5;

-- NULLs FIRST / LAST: default for ASC is NULLS LAST, for DESC is NULLS FIRST
SELECT * FROM (VALUES (1), (NULL), (2)) AS t(n) ORDER BY n NULLS FIRST;

-- Sort by an expression
SELECT tablename, length(tablename) AS len
FROM pg_tables
WHERE schemaname = 'pg_catalog'
ORDER BY length(tablename) DESC
LIMIT 3;

ORDER BY tablename 按升序排序,ASC 是默认方向;DESC 则反过来。当两行的首个排序键相同时,就用第二个排序键打破并列,以此类推。你可以按任意表达式排序,包括未出现在选择列表中的表达式,也可以按输出位置排序(ORDER BY 1, 2);不过,使用列名要好读得多。

空值的处理值得留意:ASC 把空值排在最后,DESC 则排在最前。用 NULLS FIRST 或 NULLS LAST 可以改变这一默认行为。如果应用无论按哪个方向排序,都希望空值放在最后,就应明确写出来;依赖默认行为,日后有人将 ASC 改为 DESC 时就容易出错。

运行示例

运行方式:

$ psql -f source/order-by.sql postgres
 tablename
--------------
 pg_aggregate
 pg_am
 pg_amop
 pg_amproc
(4 rows)

   tablename
-----------------
 pg_user_mapping
 pg_type
 pg_ts_template
 pg_ts_parser
(4 rows)
 ...

常见陷阱

  • 文本排序的结果可能出乎意料:文本排序遵循数据库的排序规则(例如 en_US.UTF-8),它可能不区分大小写,也可能对重音字符做特殊处理。要按字节顺序排序,可用 ORDER BY col COLLATE "C"。
  • 使用 LIMIT 时忘记 ORDER BY:没有 ORDER BY 的 LIMIT 10 返回的 10 行只是某些行,未必是最前或最小的那些。没有确定的顺序,分页就没有意义。
  • 首要排序键相同的两行可以按任意顺序出现。将主键作为最后一个排序键来打破并列,就能得到完全确定的结果(ORDER BY created_at DESC, id DESC)。
  • 对大型结果集排序可能代价很高。如果索引顺序与所需顺序一致,PostgreSQL 可以用索引避免排序。
提示

列出最近的活动时,按 created_at DESC, id DESC 排序。当多行拥有相同时间戳时,id 用于打破并列,让分页保持一致。

动手练习

添加第二个排序列:用 ORDER BY schemaname, tablename 把相同模式的行放在一起,再在各模式内排序。然后按 length(tablename) DESC 排序,看看哪个系统目录表的名字最长。

下载

SQL 文件: order-by.sql · 全部示例文件 · 准备工作

6 从表中查询数据

用明确的列名、别名和过滤条件读取示例表 fruits。

现在 fruits 已经有了数据,可以开始读取这些行了。SELECT * 返回所有列;列出具体列名,则只返回这些列,并按列名的书写顺序排列。除非加上 ORDER BY,否则结果没有确定的行序;即使今天看起来有序,明天也未必如此。

本章要点

  • 用 SELECT * FROM table 读取所有列
  • 选择特定列,并控制它们在结果中的顺序
  • 将 SELECT 与 WHERE、ORDER BY 组合使用
  • 为什么 SELECT * 适合临时查询,却在应用代码中存在风险
  • 结果列的名称,以及如何修改它们
准备示例表

开始本章前,先在练习数据库中运行 psql -f source/create-table.sql -f source/insert.sql postgres。这两个文件分别在第 16 章和第 24 章介绍;学习这些 SELECT 章节之前,表必须已经存在。

示例

示例 6.1 从表中查询数据
-- All columns. The order matches CREATE TABLE.
SELECT * FROM fruits ORDER BY id;

-- Specific columns, in the order you list them
SELECT name, id FROM fruits ORDER BY id;

-- Renaming output columns
SELECT id AS fruit_id, name AS fruit_name FROM fruits ORDER BY id LIMIT 3;

-- Combining a filter and an order
SELECT id, name
FROM fruits
WHERE id BETWEEN 2 AND 4
ORDER BY name;

-- Counting rows
SELECT count(*) AS total FROM fruits;

先运行 create-table.sql 和 insert.sql,再运行本章示例。SELECT * 按物理列顺序返回所有列。明确列出列名,就能完全控制结果的结构;当数据要交给按位置映射列的应用代码时,这一点尤其重要。AS 只修改输出中的列名,不会改变表中的任何内容。

* 和操作符两侧的空格可以省略;SQL 解析器对空白字符相当宽容。查询分成多行书写很常见,列数超过两列时也推荐这样做。

运行示例

运行方式:

$ psql -f source/select-from-table.sql postgres
 id  |   name
-----+-----------
   1 | apple
   2 | banana
   3 | cherry
   4 | date
   5 | elderberry
 101 | fruit_1
 102 | fruit_2
 103 | fruit_3
(8 rows)

    name    | id
------------+-----
 apple      |   1
 banana     |   2
 ...

常见陷阱

  • SELECT * FROM big_table; 会通过网络取回每一行的每一列,既慢又浪费资源。请列出实际需要的列。
  • 输出列名并不保证唯一。SELECT id, id FROM fruits 的两列都叫 id,应用使用的驱动可能无法妥善处理这种情况。
  • 在应用代码中,应优先明确指定列名,而不是使用 SELECT *。当表结构中新增一列时,SELECT * 的结果结构就会改变,可能使依赖特定列顺序或列数的代码出错。
提示

探索数据时,可以在 psql 中用 \x 切换扩展显示:一条记录的每一列各占一行。用这种方式阅读宽行会轻松得多。

动手练习

运行 SELECT * FROM fruits WHERE id > 100;,只查看生成的那些行。然后运行 SELECT name FROM fruits ORDER BY name;,让名称按字母顺序排列。再尝试 SELECT id || ': ' || name AS label FROM fruits;,用字符串拼接操作符 || 构造一个标签列。

下载

SQL 文件: select-from-table.sql · 全部示例文件 · 准备工作

7 LIMIT 与 OFFSET

限制结果大小,并比较偏移量分页与键集分页。

LIMIT n 至多返回 n 行。OFFSET m 跳过结果中的前 m 行。两者结合,就能实现传统的按页码分页:每页 p 行的第 k 页写作 LIMIT p OFFSET (k - 1) * p。没有 ORDER BY,“前”就没有意义;使用 LIMIT/OFFSET 时,一定要明确指定顺序。

本章要点

  • 用 LIMIT 限制结果大小
  • 用 OFFSET 跳过行
  • 分页方式及其代价
  • 为什么偏移量分页在大表上难以扩展
  • 更好的替代方式:键集分页(也称游标分页)

示例

示例 7.1 LIMIT 与 OFFSET
-- First two rows, ordered by id
SELECT * FROM fruits ORDER BY id LIMIT 2;

-- Skip one, then take two: rows 2 and 3
SELECT * FROM fruits ORDER BY id LIMIT 2 OFFSET 1;

-- Page 2 of 3 per page
SELECT * FROM fruits ORDER BY id LIMIT 3 OFFSET 3;

-- Keyset pagination: rows with id > last_seen_id
SELECT * FROM fruits WHERE id > 2 ORDER BY id LIMIT 2;

LIMIT 2 OFFSET 1 可以读作“跳过一行,再取两行”。在内部,PostgreSQL 仍然必须计算直到 OFFSET + LIMIT 为止的所有行,再丢弃偏移量对应的那些行。如果页码从 1 开始,每页 50 行,获取第 10,000 页就意味着要处理 500,000 行。偏移量分页用于前几页还不错,但用于深度分页就很糟糕。

键集分页(也称游标分页或 seek 分页)使用上次看到的值作为过滤条件:WHERE id > $last_id ORDER BY id LIMIT 50。无论已经翻到多后面的页,查询都只访问接下来的 50 行。这需要有索引的列(通常是主键,或 created_at, id),但也是生产系统中的标准做法。

运行示例

运行方式(需要前面章节的 fruits 表):

$ psql -f source/limit-offset.sql postgres
 id |  name
----+--------
  1 | apple
  2 | banana
(2 rows)

 id |  name
----+--------
  2 | banana
  3 | cherry
(2 rows)
 ...

常见陷阱

  • 没有 ORDER BY 的 LIMIT 10 返回的十行只是某些行,每次执行选中的行还可能不同。一定要排序。
  • 排序键相同的两行可能在不同页之间交换位置。增加一个用于打破并列的排序键(ORDER BY created_at DESC, id DESC),让分页保持稳定。
  • 很大的偏移量会很慢。对于“无限滚动”信息流的第 1,000 页,应改用键集分页。
  • OFFSET 也会出现在 SELECT ... FOR UPDATE 这类用法中,与行锁配合使用时可能产生问题;如果需要分批处理,基于主键的键集分页同样是更安全的方式。
提示

PostgreSQL 也支持 SQL 标准写法 FETCH FIRST n ROWS ONLY,它等价于 LIMIT n。如果边界处存在并列,FETCH FIRST n ROWS WITH TIES 返回的行数就会超过 n,很适合 top-N 查询。

动手练习

求出“第三大的水果 id”:SELECT id FROM fruits ORDER BY id DESC LIMIT 1 OFFSET 2;。然后尝试 OFFSET 0、OFFSET 1 等。再改写成键集分页:SELECT id FROM fruits WHERE id < (some_value) ORDER BY id DESC LIMIT 1。

下载

SQL 文件: limit-offset.sql · 全部示例文件 · 准备工作

8 DISTINCT

去除重复行,并用 DISTINCT ON 为每组选择一行。

DISTINCT 去除结果集中的重复行。去重时比较的是 SELECT 输出的整行:DISTINCT a, b 的含义是“(a, b) 的非重复组合”,而不是“非重复的 a,再随便取个 b”。PostgreSQL 还提供了它更强大的兄弟 DISTINCT ON (expr),为 expr 的每个非重复值保留一行,很适合“每个 Y 对应的最新 X”这类查询。

本章要点

  • 用 DISTINCT 去除重复项
  • DISTINCT 如何作用于 SELECT 选择列表中的所有列
  • PostgreSQL 特有的 DISTINCT ON (expr)
  • DISTINCT 如何处理 NULL
  • 何时应该改用 GROUP BY

示例

示例 8.1 DISTINCT
-- Unique values in a column
SELECT DISTINCT name FROM fruits ORDER BY name LIMIT 5;

-- Distinct combinations of multiple columns
SELECT DISTINCT id, name FROM fruits ORDER BY id LIMIT 3;

-- DISTINCT ON: keep one row per distinct expression value.
-- Here: one row per first letter of the name, picking the smallest id.
SELECT DISTINCT ON (substring(name, 1, 1)) substring(name, 1, 1) AS letter, id, name
FROM fruits
ORDER BY substring(name, 1, 1), id;

-- DISTINCT treats NULL as a single value
SELECT DISTINCT n FROM (VALUES (1), (NULL), (NULL), (2)) AS t(n) ORDER BY n;

在我们的 fruits 表中,每个名称本来就不重复,因此 DISTINCT name 与 SELECT name 返回相同的行。有多列时,只有列出的值全部相同,DISTINCT 才会把两行视为重复行。

要查询“每组的第一行”,DISTINCT ON (substring(name, 1, 1)) 是 PostgreSQL 的拿手好戏。ON (...) 中的表达式定义分组,ORDER BY 决定各组保留哪一行,也就是排序后的第一行。关键在于,ON 中的表达式必须出现在 ORDER BY 的开头,否则 PostgreSQL 无法保证你说的“第一行”是哪一行。

处理空值时,DISTINCT 把两个 NULL 视为相等。考虑到 NULL = NULL 的结果是未知,这有些违反直觉,但 SQL 标准就是这样规定的。

运行示例

运行方式:

$ psql -f source/distinct.sql postgres
    name
------------
 apple
 banana
 cherry
 date
 elderberry
(5 rows)
 ...

常见陷阱

  • SELECT DISTINCT a, b FROM t 与 SELECT DISTINCT a, (b) FROM t 含义相同,都是对 (a, b) 组合去重。给 b 加上括号,并不会让去重只作用于 a。
  • DISTINCT 与 ORDER BY 会相互影响:ORDER BY 中的任何列如果不在 DISTINCT 列表中,就会报错(不能按一个在同一去重分组内可能有多个值的列来排序)。
  • DISTINCT ON 不是标准 SQL。需要可移植性的代码应改用窗口函数(ROW_NUMBER() OVER (PARTITION BY ...)),后面的章节会介绍。
  • 在大表上使用 SELECT DISTINCT 必须做排序或哈希;如果只想知道“是否有重复项”,可用 GROUP BY ... HAVING COUNT(*) > 1 或聚合。
提示

count(DISTINCT col) 统计 col 中互不重复的非空值数量。因为要去重,它比 count(*) 慢,但无需子查询就能回答“有多少种不同的 X”。

动手练习

再插入一行,名称使用已有的值(例如 INSERT INTO fruits (id, name) VALUES (10, 'apple');),然后运行 SELECT DISTINCT name FROM fruits ORDER BY name;,返回的行数应该不变。接着用 DISTINCT ON (name) 保留每个名称中 id 最大的行(提示:ORDER BY name, id DESC)。

下载

SQL 文件: distinct.sql · 全部示例文件 · 准备工作

9 空值(NULL)

理解 NULL、结果为未知的比较,以及能正确处理 NULL 的表达式。

NULL 是 SQL 中表示未知或缺失的标记。它不是零,不是空字符串,也不等于任何值,甚至不等于另一个 NULL。这种三值逻辑(真、假、未知)引发的 SQL 错误比其他任何特性都多,不过一旦熟悉,规则其实很简单。

本章要点

  • NULL 表示什么
  • 用 IS NULL / IS NOT NULL 检查 NULL
  • NULL 如何在表达式和比较中传播
  • NULL 在 AND/OR 中的行为(三值逻辑)
  • 用 COALESCE 和 NULLIF 替换 NULL(译注:此处原文概括不够准确;前者用非空值替换空值,后者可将指定值转为空值。)

示例

示例 9.1 空值(NULL)
-- NULL is missing/unknown
SELECT 1 AS a, NULL AS b;

-- = NULL is NULL (not true), so this returns 0 rows
SELECT * FROM (VALUES (1, NULL)) AS t(id, name) WHERE name = NULL;

-- IS NULL / IS NOT NULL are the correct tests
SELECT * FROM (VALUES (1, NULL)) AS t(id, name) WHERE name IS NULL;
SELECT * FROM (VALUES (1, 'x')) AS t(id, name) WHERE name IS NOT NULL;

-- Any arithmetic with NULL is NULL
SELECT 1 + NULL AS one_plus_null, 'a' || NULL AS concat_null;

-- COALESCE returns the first non-null argument
SELECT COALESCE(NULL, NULL, 'default') AS coalesced;

-- NULLIF returns NULL if the two args are equal, else the first
SELECT NULLIF(0, 0) AS hides_zero, NULLIF(5, 0) AS keeps_five;

-- Three-valued logic
SELECT (TRUE AND NULL) AS t_and_n, (TRUE OR NULL) AS t_or_n, (FALSE AND NULL) AS f_and_n;

= NULL 不起作用,是因为比较结果是 NULL,不是 TRUE,所以 WHERE 会排除这一行。<>、<、>、IN、BETWEEN 等也是如此;几乎所有操作符都会在任一操作数为 NULL 时返回 NULL。例外是 IS NULL、IS NOT NULL、IS DISTINCT FROM 和 IS NOT DISTINCT FROM(后两者分别是能正确处理 NULL 的 <> 和 =)。

COALESCE(a, b, c) 返回第一个不是 NULL 的参数。这是提供默认值的标准做法,例如 COALESCE(nickname, first_name, 'Anonymous')。NULLIF(a, b) 在 a = b 时返回 NULL;如果想把用作标记的特殊值转回 NULL,它就很方便(例如 NULLIF(comment, ''))。

三值逻辑表很短,值得记住:TRUE OR NULL 是 TRUE(至少有一侧为真),但 TRUE AND NULL 是 NULL(不知道另一侧的值)。FALSE AND NULL 是 FALSE(有一侧为假就够了)。FALSE OR NULL 是 NULL。

运行示例

运行方式:

$ psql -f source/nulls.sql postgres
 a | b
---+---
 1 |
(1 row)
 ...

常见陷阱

  • 忘记聚合会跳过 NULL:SUM、AVG、MIN、MAX、COUNT(col) 都忽略 NULL 输入。COUNT(*) 则统计每一行,包括含有 NULL 的行。
  • 只要子查询返回一个 NULL,NOT IN (subquery) 就会得到空结果,因为 x <> NULL 的结果是 NULL。优先使用 NOT EXISTS。
  • NULL <> NULL 是未知(NULL),不是真。DISTINCT 把 NULL 归为一组;IS DISTINCT FROM 提供的比较则总是返回真或假。
  • 默认排序:ORDER BY col ASC 把 NULL 排在最后,DESC 把它们排在最前。明确指定 NULLS FIRST/NULLS LAST,让结果符合预期。
提示

如果确实需要能正确处理 NULL 的相等比较,请用 IS NOT DISTINCT FROM。例如,WHERE a IS NOT DISTINCT FROM b 在两者相等或两者都为 NULL 时为真。

动手练习

依次运行 SELECT 1 = NULL;、SELECT 1 IS NOT NULL;、SELECT NULL IS NOT DISTINCT FROM NULL;,看看结果有何不同。然后尝试 SELECT count(*) AS rows, count(b) AS non_null_b FROM (VALUES (1, NULL), (2, 'x')) AS t(a, b);。

下载

SQL 文件: nulls.sql · 全部示例文件 · 准备工作

10 表达式

在 SQL 表达式中组合算术运算、字符串、函数调用和类型转换。

表达式就是 PostgreSQL 能够求值为单个值的任何内容:字面量、列引用、算术运算、函数调用、CASE,甚至另一个返回单个值的 SELECT。表达式几乎无处不在:选择列表、WHERE、ORDER BY、GROUP BY 和索引定义中都能使用。掌握表达式,也就掌握了 SQL。

本章要点

  • 算术、字符串和布尔表达式
  • 在表达式中调用内置函数
  • 在 SELECT、WHERE 和 ORDER BY 中使用表达式
  • 用 :: 和 CAST(... AS ...) 做类型转换
  • 用 AS 为表达式结果命名

示例

示例 10.1 表达式
-- Arithmetic and concatenation
SELECT 10 + 2 AS sum, 10 * 2 AS product, 10 / 4 AS int_div, 10 / 4.0 AS real_div;
SELECT 'Hello ' || 'world' AS greeting;
SELECT 'fruit_' || 1 AS labeled;  -- numbers are coerced to text for ||

-- Function calls in expressions
SELECT upper(name), length(name), name || ' (#' || id || ')' AS label
FROM fruits ORDER BY id LIMIT 3;

-- Expression in WHERE
SELECT * FROM fruits WHERE id * 2 > 4 ORDER BY id LIMIT 3;

-- Expression in ORDER BY
SELECT id, name FROM fruits ORDER BY length(name) DESC LIMIT 3;

-- Casting
SELECT '42'::integer AS to_int, 42::text AS to_text, '2024-01-15'::date AS to_date;
SELECT CAST(3.7 AS integer) AS rounded_value;  -- SQL-standard cast syntax

|| 操作符用于拼接字符串。在 PostgreSQL 中,如果任一操作数不是 text 类型,就会将其转换为 text(所以 'id_' || 5 可以运行)。+、-、*、/、% 是常见的算术操作符;记住,整数除法会向零截断。

PostgreSQL 的类型转换有两种写法。value::type 是 PostgreSQL 特有的简写,也是常用写法。CAST(value AS type) 属于 SQL 标准,表达式复杂时读起来更清楚。两种写法最终表达的是同一种类型转换。

WHERE 和 ORDER BY 中的表达式会对每个候选行求值,因此在这里调用函数可能会妨碍使用索引。WHERE lower(email) = 'a@b.com' 无法使用 email 上的普通索引,但如果定义了 CREATE INDEX ON users (lower(email)) 这样的表达式索引,就可以使用它。

运行示例

运行方式:

$ psql -f source/expressions.sql postgres
 sum | product | int_div | real_div
-----+---------+---------+----------
  12 |      20 |       2 |      2.5
(1 row)

   greeting
-------------
 Hello world
(1 row)
 ...

常见陷阱

  • 整数除法返回 0:1 / 2 的结果是 0。可以改用非整数类型:1.0 / 2、1::numeric / 2,或使用 numeric 类型的列。
  • 对无法解析的字符串做类型转换,例如 'abc'::integer,会报错。与 MySQL 相比,PostgreSQL 对类型的要求更严格。
  • 如果表达式在 CASE 的不同分支中返回不同类型,就会引发类型不匹配错误;应转换为共同类型。(译注:不同类型并不一定报错;若能按类型解析规则隐式转换为共同类型,表达式仍可成立。)
  • 操作符具有优先级:1 + 2 * 3 的结果是 7。拿不准时就加括号。
提示

如果同一个表达式在一条查询中重复使用多次,就把它放进公用表表达式(CTE)或派生表中,只写一次,避免重复。PostgreSQL 也能折叠许多重复的表达式,不过这样写代码还是更整洁。

动手练习

运行 SELECT id, name, id || ': ' || name AS label FROM fruits ORDER BY id LIMIT 3;。然后尝试 SELECT name FROM fruits WHERE length(name) > 5;。最后做类型转换:SELECT '3.14'::numeric * 2 AS doubled;。

下载

SQL 文件: expressions.sql · 全部示例文件 · 准备工作

11 数值类型

在整数、精确小数和浮点数之间做选择。

PostgreSQL 提供三大类数值类型:整数、精确小数和浮点数。每一类都有自己的适用场景。整数适合计数器和 ID,速度快、占用空间小。numeric(也写作 decimal)提供精确运算,这对金额至关重要。浮点数(real、double precision)速度快、占用空间小,但可能产生微小的舍入误差,不应用于货币。

本章要点

  • 整数类型族:smallint、integer、bigint
  • 精确小数:numeric(p, s) 和不带约束的 numeric
  • 浮点数:real、double precision
  • 精度和标度如何发挥作用
  • 何时选择哪一种类型

示例

示例 11.1 数值类型
-- Integer family
SELECT 1::smallint AS s, 1::integer AS i, 1000000::bigint AS b;

-- Range limits (smallint -32768..32767, integer -2.1B..2.1B, bigint very large)
SELECT pg_typeof(1) AS literal_int_type;  -- 'integer' by default

-- Exact decimal: numeric(precision, scale)
-- precision = total digits, scale = digits after the decimal point
SELECT 3.14::numeric(4, 2) AS pi, 12345.67::numeric(7, 2) AS money;

-- Unconstrained numeric is exact within PostgreSQL's implementation limits
SELECT '0.1'::numeric + '0.2'::numeric AS exact;  -- 0.3, exactly

-- Floats are approximate but fast
SELECT 0.1::double precision + 0.2::double precision AS approximate;  -- 0.30000000000000004

-- Useful generators
SELECT generate_series(1, 5) AS n;

integer 是默认的整数类型,1 解析后就是这种类型。当数值可能超过 20 亿时(例如大型表中的行数、自纪元以来的毫秒数等),应改用 bigint。smallint 很少值得使用:它节省的存储空间很少,而超出其范围的值会报错。

numeric(p, s) 强制总位数最多为 p 位,其中 s 位在小数点后。numeric(10, 2) 存储范围从 -99,999,999.99 到 99,999,999.99。不带约束的 numeric(没有 p、s)在 PostgreSQL 的实现限制内存储精确的小数值,速度比定长整数慢,但结果精确。它适合金额、实验室测量值,以及任何不能接受舍入误差的场景。

real(4 字节,约 6 位有效数字)和 double precision(8 字节,约 15 位有效数字)都是 IEEE 754 浮点数。它们速度快、占用空间小,但 0.1 + 0.2 不会精确等于 0.3。如果可以容忍不精确,它们适合物理测量、机器学习特征和统计聚合。

运行示例

运行方式:

$ psql -f source/numeric-types.sql postgres
 s | i |    b
---+---+---------
 1 | 1 | 1000000
(1 row)
 ...
 exact
-------
 0.3
(1 row)

   approximate
------------------
 0.30000000000000004
(1 row)

常见陷阱

  • 用 double precision 存储金额——迟早会在分币舍入上出问题。请使用 numeric(p, 2)(或不带范围限制的 numeric),让数据库执行精确运算。
  • 在大型表中为 ID 列选择 integer——可能触及 21 亿的上限。对于预计会增长的主键,默认使用 bigint。
  • 对含有非数字字符的字符串做类型转换:'$5'::numeric 会报错。先去除这些字符,再做类型转换。
  • 整数相除可能让你感到意外:7 / 2 的结果是 3,而不是 3.5。可以做类型转换:7::numeric / 2。
提示

pg_typeof(expression) 会返回推断出的类型——不确定时很有用。例如,SELECT pg_typeof(1 / 2); 返回 integer,而 SELECT pg_typeof(1.0 / 2); 返回 numeric。

动手练习

创建一个小表,其中有一个 integer 类型的 id 列和一个 numeric(10, 2) 类型的 price 列,然后插入数据并查看:CREATE TEMP TABLE p(id integer, price numeric(10,2)); INSERT INTO p VALUES (1, 9.99), (2, 1234.5); SELECT * FROM p;。然后观察 SELECT 0.1::real + 0.2::real; 会发生什么。

下载

SQL 文件:numeric-types.sql · 全部示例文件 · 准备工作

12 文本类型

存储和比较文本,测量字符长度,并理解填充和排序规则。

PostgreSQL 有三种字符类型:text(无界)、varchar(n)(可变长度,最多 n 个字符)和 char(n)(固定 n 个字符,以空格填充)。对于几乎所有使用场景,text 都是正确的选择。PostgreSQL 的 text 和 varchar 在性能特征上完全相同——与 MySQL 不同,除非希望让数据库强制执行最大长度,否则限制长度没有任何好处。

本章要点

  • 三种文本类型,以及各自适用的场景
  • 为什么 text 是 PostgreSQL 中的默认选择
  • 长度函数:length、char_length、octet_length
  • 排序规则基础——文本如何排序和比较
  • 编码:PostgreSQL 使用数据库的编码存储文本(通常是 UTF-8)

示例

示例 12.1 文本类型
-- text: unbounded, recommended default
SELECT 'hello'::text AS t, length('hello'::text) AS chars;

-- varchar(n): same as text but enforces max length
SELECT 'hi'::varchar(10) AS v, length('hi'::varchar(10)) AS chars;

-- char(n): fixed length, padded with spaces
SELECT 'ab'::char(5) AS c, length('ab'::char(5)) AS chars, '|' || 'ab'::char(5) || '|' AS padded;

-- char_length vs octet_length on multibyte text
SELECT char_length('café') AS chars, octet_length('café') AS bytes;

-- A taste of collation: byte-order sort
SELECT * FROM (VALUES ('apple'), ('Banana'), ('cherry')) AS t(name)
ORDER BY name COLLATE "C";

text 是最常用的文本类型。它可以存储任意长度的字符串,也是大多数应用列应该使用的类型。在内部,PostgreSQL 以相同方式存储 text 和 varchar,对大值使用 TOAST(超长属性存储技术),从而让主表保持紧凑。

varchar(n) 就是在 text 上增加了长度检查。只有在确实有理由限制长度时才使用它(例如需要与某个要求使用这种类型的系统集成)。不带长度的 varchar 与 text 完全相同。

char(n) 是历史遗留的定长类型。它在存储时用空格将值填充到 n 的长度,并在某些操作中去掉这些空格,因此会带来意外。除非有非常具体的理由(例如需要与要求定长编码的系统对接),否则应避免使用 char(n)。

运行示例

运行方式:

$ psql -f source/text-types.sql postgres
   t   | chars
-------+-------
 hello |     5
(1 row)

 v  | chars
----+-------
 hi |     2
(1 row)

   c   | chars |  padded
-------+-------+----------
 ab    |     2 | |ab|
(1 row)
 ...

常见陷阱

  • 出于习惯选择 varchar(255):在 PostgreSQL 中它没有实际用处。请使用 text。如果应用确实需要限制长度,就在应用中或通过 CHECK 约束来强制执行。
  • char(n) 的填充会悄悄改变你的值。'foo'::char(10) 会存储为 'foo '。
  • 多字节字符:length('café') 返回 4(字符数),而 octet_length 返回 5(UTF-8 中的字节数)。根据你要回答的问题选择合适的函数。
  • 排序时的比较取决于数据库的 LC_COLLATE。要按字节顺序排序(通常更快,也更可预测),请使用 COLLATE "C"。
提示

对于不区分大小写的文本列,可以考虑使用 citext 扩展(CREATE EXTENSION citext)——它可以让列级别的 = 和 LIKE 比较不区分大小写,这样查询就不必到处调用 LOWER()。

动手练习

比较长度:SELECT length('x'::text), length('x'::char(3));。两者长度都是 1:length(character) 会忽略末尾填充的空格。使用 octet_length('x'::char(3)) 查看存储的三个字节。然后尝试 SELECT 'foo '::char(6) = 'foo'::char(3);——结果是 true,因为 char 比较会忽略末尾空格。

下载

SQL 文件:text-types.sql · 全部示例文件 · 准备工作

13 布尔值与日期

使用布尔值、日期、时间戳、时区和时间间隔。

PostgreSQL 的 boolean 类型存储 true、false 或 NULL。日期和时间有一组常用类型:date 表示日历日期,time 表示一天中的时间,timestamp 表示不带时区的日期和时间,而 timestamptz(timestamp with time zone)表示一个按会话时区显示的时间点(不会保留原始时区/偏移量)。对于几乎所有应用场景,应该使用 timestamptz。

本章要点

  • boolean 类型及其多种输入形式(true、't'、'yes'、1)
  • 四种核心日期/时间类型及各自适用的场景
  • 为什么优先选择 timestamptz 而不是 timestamp
  • current_date、current_timestamp、now()、localtime
  • 使用 interval 进行日期运算

示例

示例 13.1 布尔值与日期
-- Boolean: true, false, NULL
SELECT true AS t, false AS f, (1 = 1) AS same;

-- Boolean accepts many input forms
SELECT 'yes'::boolean AS yes, 'n'::boolean AS n, '1'::boolean AS one;

-- Dates and times
SELECT current_date AS today,
       current_timestamp AS now_tz,
       localtimestamp AS now_local,
       current_time AS time_now;

-- Casting date/time literals
SELECT '2024-01-15'::date AS d,
       '2024-01-15 10:30:00'::timestamp AS ts,
       '2024-01-15 10:30:00+00'::timestamptz AS ts_tz;

-- Arithmetic with interval
SELECT current_date + 7 AS in_a_week,
       current_timestamp - interval '1 hour' AS an_hour_ago,
       date '2024-12-31' - date '2024-01-01' AS days_between;

-- Extracting parts
SELECT extract(year FROM current_date) AS year,
       extract(dow  FROM current_date) AS day_of_week;

boolean 类型接受多种输入字符串:true、false、t、f、yes、no、y、n、1、0、on、off。在 psql 中,输出总是显示为 t 或 f。比较(=、<> 等)会产生布尔值;可以把比较结果存储在 boolean 列中。

timestamp without time zone(即 timestamp)存储挂钟时间值,不记录它在地球上的哪个位置。如果插入 2024-01-15 10:00:00,无论由谁读取,取回的都完全是这个值。timestamp with time zone(timestamptz)在内部以 UTC 存储同一个时间点,并按会话时区显示。处于不同时区的两个会话看到的 timestamptz 会是不同的挂钟时间,但底层的时间点相同。

对于几乎所有“这个事件是什么时候发生的?”的问题——下单、写入日志、创建用户——timestamptz 都是正确答案。普通 timestamp 适用于“食谱要求在当地时间上午 10 点烘焙,用户在哪个时区就按哪个当地时间执行”之类的场景,这种情况很少见。

运行示例

运行方式:

$ psql -f source/boolean-and-dates.sql postgres
 t | f | same
---+---+------
 t | f | t
(1 row)
 ...
   today
------------
 2024-xx-xx
(1 row)
 ...

常见陷阱

  • 把事件时间存为 timestamp 而不是 timestamptz:一年后,你无法判断它是 UTC、服务器本地时间还是用户本地时间。一律使用 timestamptz。
  • 将 date 或 timestamp without time zone 与 timestamptz 比较时,会使用会话时区来解释不带时区的值。重要时务必明确指定。
  • now() 返回的是事务开始时刻,而不是当前挂钟时间。在一个长事务中,重复调用会返回相同值。要获取真正的挂钟时间,请使用 clock_timestamp()。
  • interval '1 month' 加上日期会得到下个月的时间戳,有时会带来意外(January 31 + 1 month = February 28/29)。
提示

使用 SET TIME ZONE 'UTC'; 设置会话的时区。在生产环境中,让数据库以 UTC 运行,并在向用户显示时于应用层进行转换。

动手练习

运行 SELECT current_timestamp, now(), clock_timestamp();——current_timestamp 和 now() 相同;clock_timestamp() 即使在同一条语句内也可能已经不同。然后运行 BEGIN; SELECT now(); SELECT pg_sleep(1); SELECT now(); COMMIT; 并观察结果。

下载

SQL 文件:boolean-and-dates.sql · 全部示例文件 · 准备工作

14 UUID 与 JSONB

生成 UUID,并使用 jsonb 操作符查询结构化 JSON 文档。

PostgreSQL 中两种最受欢迎的非基础类型:uuid 用于不透明、全局唯一的标识符,jsonb 用于可建立索引并查询的二进制编码 JSON。UUID 是顺序整数 ID 的标准替代方案,适用于需要 ID 不可猜测、可在客户端生成或跨系统全局唯一的场景。对于半结构化数据,jsonb 让 PostgreSQL 足以与文档存储系统竞争。

本章要点

  • 使用 gen_random_uuid() 生成 UUID
  • 使用 jsonb 存储并查询 JSON
  • ->、->>、#>、#>> 操作符及其返回值
  • @> 包含关系操作符(以及它如何使用 GIN 索引)
  • json 与 jsonb 的区别

示例

示例 14.1 UUID 与 JSONB
-- UUID generation (built-in from PostgreSQL 13)
SELECT gen_random_uuid() AS id;

-- jsonb literal
SELECT '{"name": "Alice", "age": 30, "tags": ["admin", "beta"]}'::jsonb AS j;

-- -> returns jsonb (preserves type); ->> returns text
SELECT '{"name": "Alice"}'::jsonb -> 'name'  AS as_jsonb,
       '{"name": "Alice"}'::jsonb ->> 'name' AS as_text;

-- Array indexing (0-based)
SELECT '["a", "b", "c"]'::jsonb -> 1 AS second;

-- Path operators: #> returns jsonb, #>> returns text
SELECT '{"user": {"address": {"city": "Paris"}}}'::jsonb #>> '{user, address, city}' AS city;

-- Containment: @> checks if the right side is contained in the left
SELECT '{"name": "Alice", "age": 30}'::jsonb @> '{"name": "Alice"}'::jsonb AS contains_alice;

-- Querying jsonb columns in a real table
DROP TABLE IF EXISTS docs_example;
CREATE TABLE docs_example (id integer PRIMARY KEY, data jsonb);
INSERT INTO docs_example VALUES
  (1, '{"name": "Alice", "tags": ["admin"]}'),
  (2, '{"name": "Bob",   "tags": ["beta"]}');

SELECT id, data ->> 'name' AS name
FROM docs_example
WHERE data @> '{"tags": ["admin"]}';

gen_random_uuid() 会生成随机的版本 4 UUID。从 PostgreSQL 13 开始它就是内置函数,不需要 pgcrypto 扩展。PostgreSQL 17 没有内置的 UUIDv7 生成器;如果需要按时间排序的 UUID,请使用应用程序/库或扩展。PostgreSQL 18 新增 uuidv7()。bigint 标识列也是一种选择。

对于 JSON,优先使用 jsonb 而不是 json。json 会原样存储输入文本(包括空白和键顺序);jsonb 会解析、规范化并以二进制形式存储。通常对 jsonb 建立索引并进行高效查询,几乎所有应用都使用它。只有在需要逐字节保留输入(这种情况很少见)时,才使用普通 json。

-> 操作符提取并返回 jsonb。->> 提取并返回 text(去掉 JSON 引号)。#> 和 #>> 使用路径(由键/下标组成的数组)。@>(“包含”)测试左值是否包含右值——它是 JSON 查询的基础。

运行示例

运行方式:

$ psql -f source/uuid-jsonb.sql postgres
                  id
--------------------------------------
 xxxxxxxx-xxxx-4xxx-xxxx-xxxxxxxxxxxx
(1 row)
 ...
 contains_alice
----------------
 t
(1 row)
 ...

常见陷阱

  • 将 data -> 'name' 与字符串比较:data -> 'name' = '"Alice"' 可以工作(注意带 JSON 引号的字符串),但通常应该使用 data ->> 'name' = 'Alice'。
  • 忘记建立索引:对没有 GIN 索引的 jsonb 列进行过滤会执行全表扫描。CREATE INDEX ON docs_example USING gin (data); 让 @> 及类似查询能够使用索引。
  • 就地修改 jsonb:UPDATE t SET data = data || '{"k": "v"}'::jsonb 会添加或替换顶层键;jsonb_set 可以修改嵌套路径。
  • jsonb 内部的模式漂移:它很灵活,但你仍需知道其中有什么。记录预期结构,并在应用层或使用 CHECK 约束进行验证。
提示

只为你查询的 JSON 部分建立索引。与其给整个 data 列建立 GIN 索引,不如给单一路径建立索引:CREATE INDEX ON docs_example ((data ->> 'name'));。这是一个 B-树表达式索引——小巧、快速且精确。

动手练习

使用 ->> 以文本形式获取名称:SELECT '{"name": "Alice"}'::jsonb ->> 'name';。插入一条带有嵌套数据的记录,并使用 #>> 查询嵌套字段。然后在 data 上建立 GIN 索引,并使用 @> 进行包含查询。

下载

SQL 文件:uuid-jsonb.sql · 全部示例文件 · 准备工作

15 数组与 ENUM

存储数组、查询其元素,并定义有序的 ENUM 值集合。

另外两种值得尽早了解的 PostgreSQL 专用类型。数组允许任意列存放同一类型值组成的有序列表——有时,一对多关系的范围非常明确,单独建表就有些小题大做(例如文章上的标签、用户的角色名)。ENUM 类型为列提供一组固定的命名值,由数据库强制约束,并以紧凑形式存储。二者都不是标准类型,但支持良好,在实际 Postgres 应用中广泛使用。

本章要点

  • 声明和查询数组列
  • 数组字面量、数组下标(从 1 开始)及实用函数
  • 数组上的 ANY / ALL 操作符
  • 定义和使用 ENUM 类型
  • 何时适合使用数组/枚举,以及何时单独建表更好

示例

示例 15.1 数组与 ENUM
-- Arrays: any type can be turned into an array by appending []
DROP TABLE IF EXISTS posts_example;
CREATE TABLE posts_example (
  id   integer PRIMARY KEY,
  title text,
  tags  text[]                  -- array of text
);

INSERT INTO posts_example VALUES
  (1, 'Hello', ARRAY['intro', 'meta']),
  (2, 'SQL tips', '{tips, postgres}'),   -- shorthand literal
  (3, 'Untagged', ARRAY[]::text[]);

-- Indexing (1-based!) and length
SELECT id, title, tags[1] AS first_tag, array_length(tags, 1) AS tag_count
FROM posts_example
ORDER BY id;

-- ANY: row matches if ANY element of the array equals 'tips'
SELECT id, title FROM posts_example WHERE 'tips' = ANY(tags);

-- Array containment: @> "left contains all elements of right"
SELECT id, title FROM posts_example WHERE tags @> ARRAY['postgres'];

-- ENUM: defined type with a fixed set of allowed values
DROP TABLE IF EXISTS tickets_example;
DROP TYPE  IF EXISTS ticket_status;
CREATE TYPE ticket_status AS ENUM ('open', 'in_progress', 'closed');

CREATE TABLE tickets_example (
  id     integer PRIMARY KEY,
  title  text,
  status ticket_status NOT NULL DEFAULT 'open'
);
INSERT INTO tickets_example (id, title, status) VALUES (1, 'Bug 1', 'open'), (2, 'Bug 2', 'closed');

SELECT * FROM tickets_example ORDER BY id;

PostgreSQL 中的数组是一等公民:可以为它们建立索引,在 WHERE 子句中使用,用 ARRAY[] 构造,用 unnest() 将其展开成行,并用 @>、<@ 查询包含关系,用 && 查询重叠。它们以紧凑形式内联存储(非常大的数组则使用超长属性存储技术(TOAST))。

最重要的一个特殊之处是:数组下标默认从 1 开始。这里的 tags[1] 是数组中的第一个元素,不过 PostgreSQL 也支持显式指定下界。'{a, b, c}' 这种字面量形式是字符串形式的字面量简写;ARRAY['a', 'b', 'c'] 是构造器——两者生成相同的值。

ENUM 类型非常适合这样的场景:允许值集合已知且规模较小,并且应由数据库强制约束('open' | 'in_progress' | 'closed'、'small' | 'medium' | 'large')。无论标签长度如何,它们都以 4 字节存储,并且按声明顺序而不是字母顺序排序——对于状态工作流来说很有用,因为 'open' < 'in_progress' < 'closed' 正是自然顺序。

运行示例

运行方式:

$ psql -f source/arrays-and-enum.sql postgres
DROP TABLE
CREATE TABLE
INSERT 0 3
 id |  title   | first_tag | tag_count
----+----------+-----------+-----------
  1 | Hello    | intro     |         2
  2 | SQL tips | tips      |         2
  3 | Untagged |           |
(3 rows)
 ...

常见陷阱

  • 使用其他语言的开发者很容易在从 1 开始的数组下标上栽跟头。tags[0] 的结果是 NULL,而不是报错。
  • 修改 ENUM(添加一个值)需要使用 ALTER TYPE;不重建类型,删除一个值基本上不可能。对于经常变化的集合,优先使用通过外键连接的小型查找表。
  • 数组看起来很适合“我只需要一个列表”的场景——但只要你需要单独查询、连接、排序或分别约束其中的元素,另建一张带外键的表几乎总是更清晰。只有当元素属于该行、而不是独立实体时,才使用数组。
  • tag = 'x' 在数组列上不起作用——使用 'x' = ANY(tag) 或 tag @> ARRAY['x']。
提示

使用 GIN 为数组建立索引:CREATE INDEX ON posts_example USING gin (tags); 可让 @>、<@ 和 && 查询在 tags 列上快速执行。没有这个索引时,这些查询会扫描整张表。

动手练习

添加一行 tags = ARRAY['intro','postgres','tips'],然后运行 SELECT id, tag FROM posts_example, unnest(tags) AS tag;,将每个标签展开为一行。使用 ALTER TYPE ticket_status ADD VALUE 'reopened'; 添加一个新的 ENUM 值,再插入一条具有该状态的工单。

下载

SQL 文件:arrays-and-enum.sql · 全部示例文件 · 准备工作

16 CREATE TABLE

创建表、检查其列,并了解临时表和不记录 WAL 的表。

PostgreSQL 通过表存储数据。CREATE TABLE 通过列出列及其数据类型来定义新表。默认值、约束、索引和外键都可以在创建时添加,但最小的表只需要一个名称和列清单。本课保持简洁;约束会在专门的课程中介绍。

本章要点

  • 使用 CREATE TABLE 定义表
  • 为每列选择数据类型(integer、text 等)
  • 使用 DROP TABLE IF EXISTS 使建表脚本幂等
  • 检查 public 模式中的表
  • TEMP TABLE、普通表和 UNLOGGED TABLE 的区别

示例

示例 16.1 CREATE TABLE
-- Drop the table if it exists so the script is idempotent
DROP TABLE IF EXISTS fruits;

-- A minimal table: id and name
CREATE TABLE fruits (
  id   integer,
  name text
);

-- Confirm it exists in the current schema
SELECT schemaname, tablename
FROM pg_tables
WHERE tablename = 'fruits';

-- See the columns and their types
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'fruits'
ORDER BY ordinal_position;

fruits 有两列:id(32 位有符号整数)和 name(变长文本)。CREATE TABLE 中列的顺序,就是行的物理存储顺序——为了提高宽表的存储效率,应将定长列放在一起。对于小表,这并不重要。

DROP TABLE IF EXISTS 只在表存在时删除表;如果没有 IF EXISTS,该语句在全新的数据库中就会报错。将它与 CREATE TABLE 结合后,在迭代数据库模式的过程中可以安全地反复运行脚本。当然,在生产环境中不要盲目删除表——请使用迁移。

有几种 CREATE TABLE 的变体值得了解:

  • CREATE TEMP TABLE(或 CREATE TEMPORARY TABLE)创建的表只在当前会话中存在,并会自动删除。
  • CREATE UNLOGGED TABLE 不写入预写式日志——写入更快,但服务器崩溃时内容会被清空。适合存放可重建的派生数据或暂存数据。
  • CREATE TABLE ... (LIKE other_table INCLUDING ALL) 会复制另一个表的模式。
  • CREATE TABLE ... AS SELECT ... 会根据查询创建并填充一个表(称为 CTAS)。

运行示例

运行方式:

$ psql -f source/create-table.sql postgres
DROP TABLE
CREATE TABLE
 schemaname | tablename
------------+-----------
 public     | fruits
(1 row)

 column_name | data_type | is_nullable
-------------+-----------+-------------
 id          | integer   | YES
 name        | text      | YES
(2 rows)

常见陷阱

  • 不要因为在 MySQL 中学到 varchar(255) 就选择它:在 PostgreSQL 中,text 和 varchar(n) 的性能相同。除非你明确需要长度上限,否则使用 text。
  • 忘记在本不应为空的列上添加 NOT NULL——以后可以再加,但从一开始就正确设置更容易。
  • 将列命名为 user、order、select——这些都是保留字。可以改名(user_id、placed_at),也可以加引号("user"),但这样会一直带来麻烦。
提示

值得记住的标准列类型:integer(32 位)、bigint(64 位)、text、boolean、date、timestamp with time zone(几乎总是优先于 timestamp)、用于精确小数的 numeric(p,s),以及用于不透明 ID 的 uuid。每种类型都有专门的课程。

动手练习

添加第三列,例如 price numeric(10,2),然后再次运行脚本。接着将 integer 改为 bigint,再运行一次。在交互式 psql 会话中使用 \d fruits 检查。

下载

SQL 文件:create-table.sql · 全部示例文件 · 准备工作

17 列类型与约束

在实际的表定义中组合数据类型、默认值和约束。

实际的表会为每一列选择一个类型,通常还会添加约束——数据库对每一行强制执行的规则。最常见的约束包括 NOT NULL(列必须有值)、DEFAULT(未提供值时使用该值)、CHECK(必须为真的布尔表达式)、UNIQUE、PRIMARY KEY 和 REFERENCES(外键)。每种约束都会单独讲解;这里把其中几种组合到一个贴近实际的表中。

本章要点

  • 在一个 CREATE TABLE 中组合多种类型和约束
  • 内联 DEFAULT 和 NOT NULL
  • 用于业务规则的 CHECK 约束
  • 使用 timestamptz,并以 current_timestamp 作为默认值
  • 在系统目录中检查约束

示例

示例 17.1 列类型与约束
DROP TABLE IF EXISTS products;

CREATE TABLE products (
  id          integer PRIMARY KEY,
  name        text NOT NULL,
  price       numeric(10, 2) NOT NULL CHECK (price >= 0),
  in_stock    boolean NOT NULL DEFAULT true,
  category    text DEFAULT 'misc',
  created_at  timestamptz NOT NULL DEFAULT current_timestamp,
  CONSTRAINT name_not_blank CHECK (length(trim(name)) > 0)
);

INSERT INTO products (id, name, price)              VALUES (1, 'Widget',    19.99);
INSERT INTO products (id, name, price, in_stock)    VALUES (2, 'Gadget',    49.99, false);
INSERT INTO products (id, name, price, category)    VALUES (3, 'Sprocket',  4.95, 'parts');

SELECT * FROM products ORDER BY id;

-- Inspect the constraints on the table
SELECT conname, pg_get_constraintdef(oid) AS def
FROM pg_constraint
WHERE conrelid = 'products'::regclass
ORDER BY conname;

每个约束都是数据库对你的数据作出的一项承诺。NOT NULL 表示该值始终存在。DEFAULT 会在 INSERT 省略该列时提供一个值。CHECK (expr) 会拒绝 expr 为假的任何行(NULL 值会通过)。PRIMARY KEY 意味着 NOT NULL 和 UNIQUE,并会创建索引。

约束可以是内联的(位于列类型之后,如上),也可以是表级的(位于所有列之后)。复合的 PRIMARY KEY、UNIQUE 和外键约束必须使用表级语法。CHECK 在两种位置都可以引用多个列;列约束和表约束都可以用 CONSTRAINT name 命名,以便生成更清晰的错误信息。

timestamptz NOT NULL DEFAULT current_timestamp 是最常见的列定义形式之一——行的创建时间存储为 UTC,永不为空,并自动设置。

运行示例

运行方式:

$ psql -f source/column-types-and-constraints.sql postgres
DROP TABLE
CREATE TABLE
INSERT 0 1
INSERT 0 1
INSERT 0 1
 id |   name   | price | in_stock | category |          created_at
----+----------+-------+----------+----------+-------------------------------
  1 | Widget   | 19.99 | t        | misc     | 2024-xx-xx xx:xx:xx.xxxxxx+00
  2 | Gadget   | 49.99 | f        | misc     | 2024-xx-xx xx:xx:xx.xxxxxx+00
  3 | Sprocket |  4.95 | t        | parts    | 2024-xx-xx xx:xx:xx.xxxxxx+00
(3 rows)
 ...

常见陷阱

  • 忘记在本不应为空的列上添加 NOT NULL——以后添加需要先回填数据,而且在大表上可能需要长时间持有锁。
  • CHECK 在表达式为 NULL 时接受该行。若要拒绝 NULL 值,请将 NOT NULL 与 CHECK 结合使用。
  • 将 now() 用作 DEFAULT:它确实有效,但在长事务中,每个默认值都是事务开始时的时间。通常这没有问题;如果确实需要每一行的挂钟时间,请使用 clock_timestamp()。
  • 过去,在超大表上为新列添加 DEFAULT 和 NOT NULL 会重写整个表;现代 PostgreSQL(11+)在默认值为常量时会避免这种重写,但易变的默认值仍会触发表重写。
提示

为约束命名。PostgreSQL 生成的默认约束名虽然实用,却晦涩难懂。在生产环境的错误信息中出现 products_price_nonneg 这样的名称,会有很大帮助。

动手练习

尝试执行 INSERT INTO products (id, name, price) VALUES (4, ' ', 5);,观察 name_not_blank 检查约束拒绝该插入。再尝试执行 INSERT INTO products (id, name, price) VALUES (5, 'Negative', -1);,看看价格检查如何拒绝该行。然后在表创建之后添加一个约束:ALTER TABLE products ADD CONSTRAINT category_valid CHECK (category IN ('misc','parts','tools'));。

下载

SQL 文件:column-types-and-constraints.sql · 全部示例文件 · 准备工作

18 ALTER TABLE 与 DROP

修改表和约束,同时理解重写和加锁的代价。

表会不断演化。ALTER TABLE 用于修改已有表:添加或删除列,重命名列或表本身,添加或删除约束,修改列的类型,修改列的默认值。DROP TABLE 会删除表及其包含的每一行。PostgreSQL 中的模式变更具有事务性——ALTER TABLE 失败时会正常回滚,你还可以把多个 ALTER 放进 BEGIN ... COMMIT,以原子方式应用它们。

本章要点

  • 添加、重命名和删除列
  • 添加和删除约束
  • 使用 USING 修改列的类型
  • 重命名表
  • 常见修改操作对锁和表重写的影响

示例

示例 18.1 ALTER TABLE 与 DROP
-- ADD COLUMN: new columns appear at the end
ALTER TABLE products ADD COLUMN description text;

-- ADD COLUMN with a default (cheap in PG 11+ for constant defaults)
ALTER TABLE products ADD COLUMN sku text NOT NULL DEFAULT '';

-- Rename a column
ALTER TABLE products RENAME COLUMN description TO short_desc;

-- Change a column type (use USING when a cast is needed)
ALTER TABLE products ALTER COLUMN sku TYPE varchar(40);

-- Add a constraint after the fact
ALTER TABLE products ADD CONSTRAINT sku_format CHECK (sku ~ '^[A-Z0-9-]*$');

-- Drop a column
ALTER TABLE products DROP COLUMN short_desc;

-- Rename the table itself
ALTER TABLE products RENAME TO products_old;
ALTER TABLE products_old RENAME TO products;

-- DROP TABLE removes the table and all data
-- (commented out so the rest of the lessons can still use products)
-- DROP TABLE products;

SELECT tablename FROM pg_tables WHERE tablename = 'products';

每条 ALTER TABLE 都是一个独立的事务性语句。你也可以批量执行多个操作:ALTER TABLE t ADD COLUMN a int, ADD COLUMN b text, DROP COLUMN c;——把它们放在一条语句中运行会更快,因为表只需被访问一次。

有两种操作值得从代价角度理解:

  • 添加没有默认值(或默认值为常量)的列很快——PostgreSQL 只需记录新列的元数据。
  • 许多列类型变更都会重写表。 ALTER COLUMN x TYPE bigint 会读取并重写每一行。在大表上,这可能耗时数小时,并在整个过程中持有排他锁。有些二进制兼容的变更可以避免重写。对于重写量大的变更,应规划如下迁移:添加新列 → 回填 → 切换;pg_repack 不能执行任意类型转换。

ALTER TABLE ... ALTER COLUMN col TYPE new_type USING expr 允许你指定如何转换现有值:ALTER COLUMN price TYPE numeric(12,2) USING price::numeric(12,2)。

运行示例

运行方式:

$ psql -f source/alter-table-and-drop.sql postgres
ALTER TABLE
ALTER TABLE
ALTER TABLE
ALTER TABLE
ALTER TABLE
ALTER TABLE
ALTER TABLE
ALTER TABLE
 tablename
-----------
 products
(1 row)

常见陷阱

  • 向现有列添加 NOT NULL 时,要求该列中已经没有任何空值,并且会获取 ACCESS EXCLUSIVE 锁,一直持有到验证完成。先回填,再 SET NOT NULL。从 PostgreSQL 12 开始,如果存在匹配的 CHECK 约束,ALTER COLUMN x SET NOT NULL 可以更快完成。
  • 删除列不会立即回收磁盘空间——该列只会被标记为无效。运行 VACUUM FULL(重量级操作)或重新创建表,才能回收空间。
  • 重命名列会更新由系统目录跟踪的依赖关系,例如视图,但可能破坏应用程序 SQL 和以文本形式存储的函数体。重命名之前请检查这些用法。
  • 对 ENUM 执行 ALTER TYPE 时,不能移除已有值;添加值则没有问题。
提示

对于零停机的模式变更,标准做法是:添加新列 → 分批回填 → 让应用同时写入两列 → 切换读操作 → 删除旧列。Liquibase、Flyway 或 pg_repack 等工具可以提供帮助;数据库负责加锁,你负责编排各个步骤。

动手练习

先加回一列:ALTER TABLE products ADD COLUMN notes text;。然后重命名:ALTER TABLE products RENAME COLUMN notes TO remarks;。再删除:ALTER TABLE products DROP COLUMN remarks;。在 psql 中用 \d products 检查。

下载

SQL 文件:alter-table-and-drop.sql · 全部示例文件 · 准备工作

19 主键与唯一约束

选择主键和唯一约束,包括它们如何处理空值。

一个主键可以唯一标识一行。每张表都应该有一个主键——它为索引、连接、外键和应用程序代码提供了稳定的行定位依据。一张表至多有一个主键(主键可以由多个列组成)。UNIQUE 是更通用的“这里不能有重复值”约束;每张表可以有多个 UNIQUE 约束,其中可以包含允许空值的列。

本章要点

  • PRIMARY KEY 与 UNIQUE 的区别
  • 单列主键与复合(多列)主键
  • 代理键(生成的 id)与自然键(例如电子邮件地址)
  • UNIQUE 如何处理 NULL(每个 NULL 都不同,除非使用 NULLS NOT DISTINCT)
  • PK / UNIQUE 自动创建的索引

示例

示例 19.1 主键与唯一约束
DROP TABLE IF EXISTS users_example;

-- Inline primary key + unique
CREATE TABLE users_example (
  id    integer PRIMARY KEY,
  email text UNIQUE NOT NULL,
  name  text NOT NULL
);

INSERT INTO users_example (id, email, name) VALUES
  (1, 'alice@example.com', 'Alice'),
  (2, 'bob@example.com',   'Bob');

SELECT * FROM users_example ORDER BY id;

-- Composite primary key (across two columns)
DROP TABLE IF EXISTS enrollments_example;
CREATE TABLE enrollments_example (
  student_id integer,
  course_id  integer,
  enrolled_at timestamptz NOT NULL DEFAULT current_timestamp,
  PRIMARY KEY (student_id, course_id)
);

INSERT INTO enrollments_example (student_id, course_id) VALUES (1, 100), (1, 200), (2, 100);
SELECT * FROM enrollments_example ORDER BY student_id, course_id;

-- See indexes that the constraints created
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename IN ('users_example', 'enrollments_example')
ORDER BY indexname;

主键将 NOT NULL 与 UNIQUE 结合起来,并创建一个 B-树索引。PostgreSQL 使用主键索引执行连接、处理 WHERE id = ? 查找,并把它作为外键中的自然行标识符。每个设计良好的表都应该有一个主键。

UNIQUE 是更灵活的近亲。每张表可以有多个唯一约束——UNIQUE (email)、UNIQUE (handle)。UNIQUE 约束允许空值;按照标准,NULL 与其他每个 NULL 都不同,因此同一唯一列可以在多行中取 NULL。从 PostgreSQL 15 开始,如果希望把 NULL 视为重复值,可以用 UNIQUE NULLS NOT DISTINCT 覆盖这种行为。

复合键(PRIMARY KEY (a, b))常用于表示多对多关系的关联表(一个学生可以选修多门课程;一门课程可以有多名学生;这两个值的组合是唯一的)。

运行示例

运行方式:

$ psql -f source/primary-keys-and-unique.sql postgres
DROP TABLE
CREATE TABLE
INSERT 0 2
 id |       email       | name
----+-------------------+-------
  1 | alice@example.com | Alice
  2 | bob@example.com   | Bob
(2 rows)
 ...

常见陷阱

  • 允许空值的 UNIQUE 列:默认情况下,两个空值不算重复项。如果你的业务规则是“该列可以省略,但一旦设置就必须唯一”,默认行为正是如此;但如果规则要求“把空值视为相等”,请使用 UNIQUE NULLS NOT DISTINCT(PG 15+)。带有 WHERE col IS NOT NULL 的部分唯一索引仍允许多个空值,不能替代这种约束。
  • 选择 email 这样的自然键作为主键看似简洁——直到用户修改了自己的电子邮件地址。代理键(id + UNIQUE (email))通常更安全。
  • 复合主键会传递到外键中;引用复合主键的子表必须同时包含这两列。通常,更简单的设计是为父表设置一个代理 id。
  • PRIMARY KEY 和 UNIQUE 都会自动创建一个索引;不要在同一列上再创建另一个 B-树索引。
提示

对于任何预计会增长到几百万行以上的表,主键应使用 bigint(或 id bigint GENERATED ALWAYS AS IDENTITY)。以后再从 integer 改成 bigint 会很痛苦——拿不准时,应在一开始就选择更大的类型。

动手练习

尝试插入重复值:INSERT INTO users_example (id, email, name) VALUES (1, 'x@example.com', 'X');——主键会拒绝它。然后执行 INSERT ... VALUES (3, 'alice@example.com', 'Alice2');——email 列上的 UNIQUE 约束会拒绝它。最后在 psql 中运行 \d users_example,查看索引和约束。

下载

SQL 文件:primary-keys-and-unique.sql · 全部示例文件 · 准备工作

20 外键

使用外键连接表,并选择合适的更新与删除动作。

一个外键是一种约束,它规定“此列中的值必须已经作为某个其他表中的主键(或唯一键)存在”。关系型数据库正是通过它来实施引用完整性的——你不能让子行指向不存在的父行,从而成为孤儿行;(取决于你选择的规则)数据库还可以级联或限制对父行的更新。凡是涉及多个表的关系模型,都需要外键。

本章要点

  • 使用 REFERENCES 声明外键
  • ON DELETE 和 ON UPDATE 动作:NO ACTION、RESTRICT、CASCADE、SET NULL、SET DEFAULT
  • 为什么在外键列上建立索引几乎总是个好主意
  • DEFERRABLE 约束如何让你在一个事务中批量执行变更
  • 检查系统目录中的外键

示例

示例 20.1 外键
-- Parent table
DROP TABLE IF EXISTS comments_example;
DROP TABLE IF EXISTS authors_example;

CREATE TABLE authors_example (
  id   integer PRIMARY KEY,
  name text NOT NULL
);

-- Child table referencing parent
CREATE TABLE comments_example (
  id        integer PRIMARY KEY,
  author_id integer NOT NULL REFERENCES authors_example(id) ON DELETE CASCADE,
  body      text NOT NULL,
  parent_id integer REFERENCES comments_example(id) ON DELETE SET NULL  -- self-reference!
);

-- Index the FK column so joins and cascading deletes don't seq-scan
CREATE INDEX ON comments_example (author_id);
CREATE INDEX ON comments_example (parent_id);

INSERT INTO authors_example VALUES (1, 'Alice'), (2, 'Bob');
INSERT INTO comments_example (id, author_id, body) VALUES
  (1, 1, 'First!'),
  (2, 2, 'Reply'),
  (3, 1, 'Nice');
UPDATE comments_example SET parent_id = 1 WHERE id = 2;

-- Refused: author 99 does not exist
-- INSERT INTO comments_example (id, author_id, body) VALUES (4, 99, 'orphan');

-- Cascade: deleting Alice removes her comments (and Bob's reply loses its parent_id)
DELETE FROM authors_example WHERE id = 1;
SELECT * FROM comments_example ORDER BY id;

-- Inspect foreign keys
SELECT conname, pg_get_constraintdef(oid) AS def
FROM pg_constraint
WHERE contype = 'f' AND conrelid = 'comments_example'::regclass;

REFERENCES other_table(column) 用于声明外键。被引用的列必须是另一个表的主键或唯一约束。如果省略列名,就假定引用该表的主键:在这里,REFERENCES authors_example 等价于 REFERENCES authors_example(id)。

当父行被删除,或它的键发生变化时,ON DELETE 和 ON UPDATE 动作决定会发生什么:

  • NO ACTION(默认):如果仍有任何子行引用该父行,就报错;在语句结束时检查。
  • RESTRICT:类似 NO ACTION,但会立即检查(不能延迟)。
  • CASCADE:同时删除或更新子行。如果子行属于父行,这很有用。
  • SET NULL:将子表中的列设为 NULL(该列必须允许空值)。
  • SET DEFAULT:将子表中的列设为其默认值。

每条 INSERT/UPDATE/DELETE 都会检查外键——它们并非没有代价。外键也是模式中最重要的正确性保障之一;与避免数据损坏相比,这项代价几乎总是值得的。

运行示例

运行方式:

$ psql -f source/foreign-keys.sql postgres
DROP TABLE
DROP TABLE
CREATE TABLE
CREATE TABLE
CREATE INDEX
CREATE INDEX
INSERT 0 2
INSERT 0 3
UPDATE 1
DELETE 1
 id | author_id | body  | parent_id
----+-----------+-------+-----------
  2 |         2 | Reply |
(1 row)
 ...

常见陷阱

  • 忘记在外键列上建立索引:删除父行时,PostgreSQL 必须检查(并可能对其子行执行级联操作);如果外键列没有索引,这会执行一次顺序扫描。务必给外键列建立索引。
  • 过于轻率地选择 CASCADE:生产环境中的一次删除可能级联到数百万行。请审查你使用的 ON DELETE CASCADE 规则,尤其是面向用户的表。
  • 导入存在跨表依赖的数据:先装载父表,再装载子表。或者使用 DEFERRABLE INITIALLY DEFERRED,让外键检查在提交时进行,而不是逐行进行。
  • 外键之间的循环依赖要求使用可延迟约束。PostgreSQL 支持这种约束,但如果可能,设计时应围绕这一需求安排结构。
提示

DEFERRABLE INITIALLY DEFERRED 会让约束检查在 COMMIT 时进行,而不是在语句结束时进行。结合 SET CONSTRAINTS ALL DEFERRED,你就可以在一个事务中进行多项相互关联的变更,而不必把它们精确地按顺序排列。

动手练习

尝试插入一条作者不存在的评论:INSERT INTO comments_example (id, author_id, body) VALUES (10, 99, 'orphan');,观察违反外键约束的错误。然后添加第二位作者和一条评论,并对该作者执行 DELETE,观察 ON DELETE CASCADE 的实际效果。

下载

SQL 文件:foreign-keys.sql · 全部示例文件 · 准备工作

21 NOT NULL 与 DEFAULT

使用 NOT NULL 要求必须有值,并为新行提供有用的默认值。

NOT NULL 和 DEFAULT 是最有用的两种列级规则。NOT NULL 表示该列始终有值——每次 INSERT 和 UPDATE 都必须提供一个值(或依赖默认值)。省略该列时,DEFAULT expr 会提供这个值。两者结合后,模式更容易理解:如果列设为 NOT NULL,应用程序代码就不必到处写 IF column IS NULL。

本章要点

  • 使用 NOT NULL 强制要求有值
  • 使用 DEFAULT expr 提供默认值
  • 易变默认值与常量默认值的区别,以及这对大规模场景意味着什么
  • 组合使用 NOT NULL 和 DEFAULT,确保“始终有值”
  • 以后删除或修改默认值

示例

示例 21.1 NOT NULL 与 DEFAULT
DROP TABLE IF EXISTS tasks_example;

CREATE TABLE tasks_example (
  id          integer PRIMARY KEY,
  title       text NOT NULL,
  done        boolean NOT NULL DEFAULT false,
  priority    integer NOT NULL DEFAULT 3 CHECK (priority BETWEEN 1 AND 5),
  due_at      timestamptz,                              -- nullable
  created_at  timestamptz NOT NULL DEFAULT current_timestamp
);

-- Defaults fill in done, priority, created_at
INSERT INTO tasks_example (id, title) VALUES (1, 'Learn SQL');

-- Explicit values override the defaults
INSERT INTO tasks_example (id, title, done, priority, due_at)
VALUES (2, 'Ship lesson', true, 1, current_timestamp + interval '1 day');

SELECT * FROM tasks_example ORDER BY id;

-- Remove a default later (or change it)
ALTER TABLE tasks_example ALTER COLUMN priority SET DEFAULT 1;
ALTER TABLE tasks_example ALTER COLUMN priority DROP DEFAULT;
ALTER TABLE tasks_example ALTER COLUMN priority SET DEFAULT 3;

-- Adding NOT NULL to a column requires no NULLs to exist
-- ALTER TABLE tasks_example ALTER COLUMN due_at SET NOT NULL;  -- would fail if any due_at is NULL

NOT NULL 是代价最低、影响最大的约束之一——它消除了一整类错误(忘记检查空值、聚合结果错误、连接结果出人意料)。只要应用逻辑保证某列一定有值,就应将它标记为 NOT NULL。

DEFAULT 在插入时求值,而不是在创建表时求值。DEFAULT current_timestamp 会针对每个插入的行运行一次 current_timestamp,返回事务开始时间。DEFAULT gen_random_uuid() 会为每一行生成一个新的 UUID。

向已有表添加常量默认值(DEFAULT 0、DEFAULT 'pending')速度很快——PostgreSQL 11+ 会把这个常量默认值存入元数据,而无需重写表。包括 STABLE 表达式 current_timestamp 在内的非易变默认值也可以使用快速路径。random() 和 clock_timestamp() 等易变默认值需要为每一行已有数据提供一个值,因此会重写表。

运行示例

运行方式:

$ psql -f source/not-null-and-default.sql postgres
DROP TABLE
CREATE TABLE
INSERT 0 1
INSERT 0 1
 id |    title    | done | priority | due_at | created_at
----+-------------+------+----------+--------+-------------
  1 | Learn SQL   | f    |        3 |        | 2024-xx-xx
  2 | Ship lesson | t    |        1 | 2024-xx-xx | 2024-xx-xx
(2 rows)
 ...

常见陷阱

  • 把 ''(空字符串)当成“缺失”——请选择一种约定,并始终保持一致。大多数团队用 NULL 表示“没有值”,把 '' 留给“有意留空”的情况。混用两者,会让查询永远处于半好半坏的状态。
  • 默认值不会在 UPDATE 时自动重新应用。要“重置为默认值”,请使用 UPDATE t SET col = DEFAULT WHERE ...。
  • 向已有列添加 NOT NULL 时,如果该列中可能已有空值,操作会失败。先回填该列,再执行 SET NOT NULL。
  • 当每一列都有默认值时,INSERT INTO t DEFAULT VALUES 很有用——它让数据库填充所有值。
提示

常见模式是将 created_at timestamptz NOT NULL DEFAULT current_timestamp 与 updated_at timestamptz NOT NULL DEFAULT current_timestamp 配对使用。created_at 在 INSERT 时设置,此后不再变化;updated_at 则需要一个触发器(后文会讲)在每次 UPDATE 时将其更新。

动手练习

尝试执行 INSERT INTO tasks_example (id) VALUES (3);——title 列的 NOT NULL 应该拒绝这次插入。然后执行 INSERT INTO tasks_example (id, title, priority) VALUES (3, 'X', 9);,观察 CHECK 如何拒绝超出范围的优先级值。

下载

SQL 文件:not-null-and-default.sql · 全部示例文件 · 准备工作

22 标识列与序列

使用标识列生成标识符,并理解共享序列的行为。

你很少需要手动选择主键值。PostgreSQL 提供两种自动生成主键值的方式:现代的 SQL 标准语法 GENERATED ... AS IDENTITY,以及较旧的 serial / bigserial 简写。两者都由一个序列驱动——序列是一个独立对象,负责发放递增的数字。新代码推荐使用 IDENTITY;旧代码库中仍然常见 serial。

本章要点

  • GENERATED ALWAYS AS IDENTITY 语法
  • 与较旧的 serial 简写的区别
  • 序列是什么,以及如何检查序列
  • 为什么生成的 ID 可能出现间断
  • 批量装载后重置序列

示例

示例 22.1 标识列与序列
-- Modern style: GENERATED AS IDENTITY (recommended)
DROP TABLE IF EXISTS messages_example;
CREATE TABLE messages_example (
  id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  body text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT current_timestamp
);

INSERT INTO messages_example (body) VALUES ('first'), ('second'), ('third');
SELECT id, body FROM messages_example ORDER BY id;

-- Legacy serial shortcut (rewrites to a sequence + DEFAULT under the hood)
DROP TABLE IF EXISTS legacy_example;
CREATE TABLE legacy_example (
  id   serial PRIMARY KEY,
  name text NOT NULL
);

INSERT INTO legacy_example (name) VALUES ('a'), ('b');
SELECT id, name FROM legacy_example ORDER BY id;

-- Inspect sequences
SELECT sequencename, last_value, increment_by
FROM pg_sequences
WHERE sequencename IN ('messages_example_id_seq', 'legacy_example_id_seq');

-- Manually grab the next value (and consume it)
SELECT nextval('messages_example_id_seq') AS next_id;
SELECT currval('messages_example_id_seq') AS curr_id;

-- Reset a sequence (e.g. after a bulk import that supplied IDs)
SELECT setval('legacy_example_id_seq',
              (SELECT max(id) FROM legacy_example));

GENERATED ALWAYS AS IDENTITY 告诉 PostgreSQL,该列的值来自底层序列,INSERT 不能覆盖这个值(除非使用 OVERRIDING SYSTEM VALUE)。GENERATED BY DEFAULT AS IDENTITY 是限制更少的变体——如果 INSERT 提供了值,就使用该值;否则由序列提供。若要防止应用程序代码意外注入 ID,请使用 ALWAYS;如果需要为迁移和测试保留灵活性,则使用 BY DEFAULT。

较旧的 serial(integer + 序列)和 bigserial(bigint + 序列)是 integer NOT NULL DEFAULT nextval('seq') 的语法糖。它们仍然可用,但 IDENTITY 是 SQL 标准,与 pg_dump 配合得更好,而且对序列所有权的跟踪更清晰。

序列独立于事务:nextval() 不会回滚。这意味着,如果某个事务插入一行后又回滚,该 ID 就被消耗掉了——下一行会得到它之后的值。ID 可能出现间断,应用程序绝不能假设它们是连续的。

运行示例

运行方式:

$ psql -f source/identity-and-sequences.sql postgres
DROP TABLE
CREATE TABLE
INSERT 0 3
 id | body
----+--------
  1 | first
  2 | second
  3 | third
(3 rows)
 ...

常见陷阱

  • 使用另一个连接中的 currval() 读取“我刚插入的最后一个 ID”——这样行不通。序列是共享对象;只有 currval() 记住的值按会话分别保存。请改用 RETURNING id。
  • 批量装载并提供显式 ID 后,序列仍停留在旧值,因此下一个自动生成的 ID 会发生冲突。请重置它:SELECT setval('t_id_seq', (SELECT max(id) FROM t));。
  • 不要指望 ID 连续或反映插入顺序,尤其是在并发场景下。如果需要排序,请使用 created_at, id。
  • 在数据量很大的表上为标识列使用 integer(32 位)——你可能耗尽取值范围。请使用 bigint。
提示

从 MySQL 的 AUTO_INCREMENT 迁移时,最安全的直接等价写法是 bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY。它接受显式 ID(适合迁移),否则就自动生成。

动手练习

在 messages_example 中执行两次 INSERT,然后在一个事务中对其中一次执行 ROLLBACK(BEGIN; INSERT ...; ROLLBACK;),再执行一次 INSERT——你应该能看到 ID 出现间断。然后使用 setval() 重置序列,观察下一次 INSERT 从那里继续。

下载

SQL 文件:identity-and-sequences.sql · 全部示例文件 · 准备工作

23 模式与 search_path

将数据库对象组织到模式中,并使用 search_path 控制名称解析。

PostgreSQL 中的模式是数据库内部的命名空间——一种将表、视图、函数、序列和类型归到某个名称下的方式。每个数据库至少有一个模式(public),大多数生产部署会使用多个模式。模式可以帮助你组织大型项目、在一个数据库中承载多个租户,并将扩展与应用程序代码隔离开来。理解 search_path 后,也就理解了 PostgreSQL 如何解析非限定名。

本章要点

  • 创建和删除模式
  • schema.table 限定名语法
  • search_path 以及名称解析的工作方式
  • 按角色和按数据库设置的 search_path 默认值
  • 常见模式:app 模式、每个租户一个模式、将扩展分离出来

示例

示例 23.1 模式与 search_path
-- Create a schema and a table inside it
CREATE SCHEMA IF NOT EXISTS app;

DROP TABLE IF EXISTS app.events_example;
CREATE TABLE app.events_example (
  id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  kind text NOT NULL,
  at   timestamptz NOT NULL DEFAULT current_timestamp
);

INSERT INTO app.events_example (kind) VALUES ('login'), ('logout');

-- Fully qualified vs. unqualified access
SELECT * FROM app.events_example ORDER BY id;

-- Make the schema findable without prefixing every name
SET search_path TO app, public;
SELECT * FROM events_example ORDER BY id;   -- resolved as app.events_example

-- Inspect the current search path
SHOW search_path;

-- List schemas and the tables in each
SELECT schemaname, tablename
FROM pg_tables
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
ORDER BY schemaname, tablename
LIMIT 10;

-- Reset to default
SET search_path TO public;

当你写下 SELECT * FROM events 时,PostgreSQL 会按照 search_path 中列出的模式顺序,在每个模式中查找这个名称,并使用第一个匹配项。默认的 search_path 是 "$user", public——意思是“先查找一个以连接用户命名的模式,再查找 public”。如果存在一个与用户同名的模式,它就会优先匹配。

你可以按会话(SET search_path TO ...)、按角色(ALTER ROLE alice SET search_path TO ...)或按数据库(ALTER DATABASE app_db SET search_path TO ...)修改 search_path。后两种设置会在重新连接后继续生效。

模式隔离是将扩展对象(postgis、pg_trgm)放在 public 之外、为每个租户提供自己的命名空间,或按逻辑组织 audit.*、app.*、staging.* 表的标准方式。

运行示例

运行方式:

$ psql -f source/schemas.sql postgres
CREATE SCHEMA
DROP TABLE
CREATE TABLE
INSERT 0 2
 id |  kind  |              at
----+--------+-------------------------------
  1 | login  | 2024-xx-xx xx:xx:xx.xxxxxx+00
  2 | logout | 2024-xx-xx xx:xx:xx.xxxxxx+00
(2 rows)
 ...
 search_path
-------------
 app, public
(1 row)
 ...

常见陷阱

  • 在函数中忘记设置 search_path:SECURITY DEFINER 函数使用其所有者的权限,但如果没有函数级设置,它使用的是调用者的 search_path。请将路径限制为只包含受信任的模式,并把 pg_temp 放在最后(例如,在 app 受信任时,使用 SET search_path = pg_catalog, app, pg_temp),同时为敏感对象名称加上限定名。
  • 把扩展放进 public:扩展会将对象安装到当前所在的模式中。许多团队会执行 CREATE SCHEMA extensions; ALTER ROLE ... SET search_path = "$user", public, extensions;,并在那里创建扩展。
  • 删除存在依赖关系的模式会失败。使用 DROP SCHEMA name CASCADE 删除模式及其依赖对象。这次删除可以在事务中回滚;提交之后,恢复就需要备份或重新创建。
  • 别忘了 \dt 只显示当前 search_path 模式中的表。使用 \dt *.* 查看全部内容。
提示

对于多租户系统,每个租户一个模式在小规模场景(几十到几百个租户)中可行,但随着系统目录增长会变得难以维护。行级隔离(使用 tenant_id 列和 ROW SECURITY 策略)是更常见的大规模模式。

动手练习

在交互式 psql 中运行 SHOW search_path;,然后执行 SET search_path TO app, public; 和 \dt。在两个模式中创建同名表,看看哪个表会优先匹配。然后删除该模式:DROP SCHEMA app CASCADE;(只在可丢弃的演示模式中使用)。

下载

SQL 文件:schemas.sql · 全部示例文件 · 准备工作

24 INSERT

插入单行、批量数据和查询结果,并明确写出目标列。

INSERT 会向表中添加行。基本形式是 INSERT INTO table (columns) VALUES (values)。你可以插入一行、在一条语句中插入多行,或插入 SELECT 的结果。通过单条 INSERT ... VALUES (...), (...), ... 进行批量插入,比执行成千上万条独立语句快得多,因为后者每条语句都要承担一次网络往返和一次重新规划的代价。

本章要点

  • 单行和多行 INSERT
  • 为什么明确列出列名是正确的默认做法
  • 从查询插入(INSERT INTO ... SELECT)
  • 使用 DEFAULT 并省略列
  • 状态消息:INSERT 0 5 究竟意味着什么

示例

示例 24.1 INSERT
-- One row at a time (most explicit, often fine for app code)
INSERT INTO fruits (id, name) VALUES (1, 'apple');
INSERT INTO fruits (id, name) VALUES (2, 'banana');
INSERT INTO fruits (id, name) VALUES (3, 'cherry');

-- Many rows in one statement (much faster for bulk loads)
INSERT INTO fruits (id, name) VALUES
  (4, 'date'),
  (5, 'elderberry');

-- INSERT ... SELECT: copy rows from another query
-- (here a generator, to avoid duplicating fruits we just inserted)
INSERT INTO fruits (id, name)
SELECT n + 100, 'fruit_' || n
FROM generate_series(1, 3) AS n;

-- Confirm
SELECT count(*) AS row_count FROM fruits;

在 create-table.sql 之后运行此脚本——表必须已经存在。前 3 条语句各插入一行;第 4 条插入两行;第 5 条通过 generate_series 生成并插入三行。psql 输出的状态消息 INSERT 0 5 意味着已插入,旧 OID 为 0,影响 5 行——OID 属于遗留机制,可以忽略第一个数字;第二个数字才是行数。

为什么要列出列名?有两个原因。第一,如果表后来增加一列,那么你的 INSERT INTO fruits VALUES (...)(不带列清单)会失败,而 INSERT INTO fruits (id, name) VALUES (...) 仍然可以工作。译注:这里的“会失败”是原文的概括,并非对所有新增列情形都成立。PostgreSQL 在省略列清单且只提供 N 个值时,会按声明顺序将这些值映射到前 N 列,省略的列使用默认值或 NULL;因此,新增到末尾且可以省略的列未必导致失败,最终还取决于默认值和约束。第二,列出列名能让任何阅读代码的人都清楚意图——值不再是靠位置猜测的谜题。

从文件(CSV 等)批量装载时,优先使用 COPY(后文会介绍)——它的速度明显快于 INSERT。应用代码应将行批量组织到多行 INSERT 语句中,或使用驱动程序级的预备语句。

运行示例

运行方式:

$ psql -f source/insert.sql postgres
INSERT 0 1
INSERT 0 1
INSERT 0 1
INSERT 0 2
INSERT 0 3
 row_count
-----------
         8
(1 row)

常见陷阱

  • 表中已有行时重复运行脚本,会导致主键或唯一约束冲突(添加主键后)。解决办法取决于意图:删除并重新创建表、使用 TRUNCATE,或者使用 INSERT ... ON CONFLICT 执行插入或更新(UPSERT),详见下一课。
  • 按位置执行 INSERT 时列顺序错误,会悄悄破坏数据——只要类型兼容,数据库就会把错误的值插入错误的列。始终列出列名。
  • 在生产环境中,使用错误时区插入时间戳,是“时间差了 8 小时”的最常见原因。使用 timestamptz,让 PostgreSQL 处理转换。
提示

INSERT INTO t DEFAULT VALUES; 只使用列的默认值插入一行——当每一列都有 DEFAULT 时很有用(例如,id 的值来自序列,created_at 的值来自 now())。

动手练习

再用 (6, 'fig') 添加一行,然后再次运行文件。(一旦 id 具有唯一约束,这次运行就会失败——我们会在 UPSERT 这一课中修复它。)然后尝试 INSERT INTO fruits (id, name) SELECT n, 'gen_' || n FROM generate_series(200, 205) n;,看看查询驱动的插入方式。

下载

SQL 文件:insert.sql · 全部示例文件 · 准备工作

25 UPSERT(ON CONFLICT)

使用 ON CONFLICT、EXCLUDED 和 RETURNING,以原子方式完成插入或更新。

插入或更新(UPSERT)是指“如果不存在就插入,否则更新”的操作。PostgreSQL 将其写作 INSERT ... ON CONFLICT ... DO UPDATE(或 DO NOTHING)。它具有原子性、不会产生竞态,也是生产代码中最常用的模式之一。在这个子句出现之前,应用程序必须采用 SELECT-then-INSERT-then-UPDATE 的绕行方式,在并发场景下要么很慢,要么结果错误。

本章要点

  • 使用 INSERT ... ON CONFLICT DO NOTHING 跳过重复项
  • 使用 INSERT ... ON CONFLICT (col) DO UPDATE SET ... 合并新值与旧值
  • 通过 EXCLUDED 虚拟表引用待插入的值
  • 冲突目标:列、约束名称或表达式
  • RETURNING 与 ON CONFLICT 的配合,以及何时能返回该行

示例

示例 25.1 UPSERT(ON CONFLICT)
-- Setup a table with a unique key
DROP TABLE IF EXISTS visitors_example;
CREATE TABLE visitors_example (
  email      text PRIMARY KEY,
  name       text NOT NULL,
  visits     integer NOT NULL DEFAULT 1,
  last_seen  timestamptz NOT NULL DEFAULT current_timestamp
);

INSERT INTO visitors_example (email, name) VALUES ('alice@example.com', 'Alice');

-- DO NOTHING: insert if absent, otherwise skip silently
INSERT INTO visitors_example (email, name) VALUES ('alice@example.com', 'Alice2')
ON CONFLICT (email) DO NOTHING;

-- DO UPDATE: on conflict, merge new values into the existing row
INSERT INTO visitors_example (email, name) VALUES ('alice@example.com', 'Alice')
ON CONFLICT (email) DO UPDATE
  SET visits    = visitors_example.visits + 1,
      last_seen = current_timestamp;

-- EXCLUDED refers to the row that was *proposed* by the INSERT
INSERT INTO visitors_example (email, name) VALUES ('bob@example.com', 'Bob')
ON CONFLICT (email) DO UPDATE
  SET name = EXCLUDED.name,             -- use the new name
      visits = visitors_example.visits + 1
RETURNING *;

-- Conflict on a partial / expression unique index works too
-- CREATE UNIQUE INDEX ON visitors_example (lower(email));
-- INSERT ... ON CONFLICT ((lower(email))) DO UPDATE ...

SELECT * FROM visitors_example ORDER BY email;

冲突目标——ON CONFLICT 后的带括号子句——告诉 PostgreSQL 哪个唯一约束或索引定义了冲突。(email) 与 email 列上的唯一键匹配。你也可以指定约束名称:ON CONFLICT ON CONSTRAINT visitors_example_pkey DO UPDATE ...。对于表达式索引,重复写出该表达式:ON CONFLICT ((lower(email)))。部分索引还可能要求在冲突目标之后提供匹配的谓词。

EXCLUDED 是一个虚拟表,表示 INSERT 提出的那一行。visitors_example(或者实际表的名称)指的是已有行。把两者结合起来,就能表达递增(SET visits = visitors_example.visits + 1)、合并(SET name = COALESCE(EXCLUDED.name, visitors_example.name))等操作。

RETURNING 与 ON CONFLICT DO UPDATE 搭配时,会返回每一条受影响的行(插入或更新的行)。DO NOTHING 只返回真正插入的行——如果跳过了冲突,就什么也不会返回。

运行示例

运行方式:

$ psql -f source/upsert.sql postgres
DROP TABLE
CREATE TABLE
INSERT 0 1
INSERT 0 0       -- DO NOTHING skipped the duplicate
INSERT 0 1       -- DO UPDATE merged
INSERT 0 1
       email       | name  | visits |           last_seen
-------------------+-------+--------+-------------------------------
 bob@example.com   | Bob   |      1 | 2024-xx-xx xx:xx:xx.xxxxxx+00
(1 row)

       email       | name  | visits |           last_seen
-------------------+-------+--------+-------------------------------
 alice@example.com | Alice |      2 | 2024-xx-xx xx:xx:xx.xxxxxx+00
 bob@example.com   | Bob   |      1 | 2024-xx-xx xx:xx:xx.xxxxxx+00
(2 rows)

常见陷阱

  • ON CONFLICT 要求事先定义相匹配的唯一约束或索引。没有这样的约束或索引,PostgreSQL 无法知道哪一行会发生冲突,并会抛出错误。
  • 冲突目标必须与某个约束或索引完全匹配。只有在你为 lower(email) 建立了唯一的表达式索引时,ON CONFLICT (lower(email)) 才能工作。
  • DO NOTHING 适合你确实想要“尽力插入”的场景。如果你其实期待插入该行,它会悄无声息地吞掉真正的错误。
  • DO NOTHING 后的 RETURNING 不会为被跳过的插入返回一行。如果你需要“无论是插入了该行,还是该行原本就存在,都返回这行”,请使用 DO UPDATE,并设置一个无操作的 SET,例如 SET email = EXCLUDED.email,这样总会返回一行。
提示

从 PostgreSQL 15 开始,SQL 标准中的 MERGE 语句也可用——它更灵活(可以包含多个匹配和不匹配分支),但也更冗长。对于简单的插入或更新,INSERT ... ON CONFLICT 仍然是合适的工具。

动手练习

将第二条插入语句(DO NOTHING)运行两次——Alice 的访问次数不应增加。然后将第三条(DO UPDATE)运行两次——访问次数每次都应增加。给每条语句都加上 RETURNING *,观察哪些语句会返回行。

下载

SQL 文件:upsert.sql · 全部示例文件 · 准备工作

26 UPDATE

更新选定的行、连接数据源,并返回修改后的值。

UPDATE 会修改已有行。基本形式是 UPDATE table SET column = value WHERE condition。没有 WHERE 子句时,表中的每一行都会被更新——这种错误只能在自己的练习数据库里犯。始终先用 SELECT 检查,并考虑在确认无误前将有风险的更新包在 BEGIN ... ROLLBACK 中。

本章要点

  • 一次更新一列、多列和多行
  • 使用 WHERE 限定更新范围
  • 使用 UPDATE ... FROM 根据另一个表的数据更新一个表
  • 使用 RETURNING 返回修改后的行
  • UPDATE 的事务安全性(以及如何滥用它)

示例

示例 26.1 UPDATE
-- Single row, single column
UPDATE fruits SET name = 'apricot' WHERE id = 1;

-- Update another row
UPDATE fruits SET name = 'blueberry' WHERE id = 2;

-- Expression-based update: bump every id by 1000
-- (commented out so the rest of the lessons can find the original ids)
-- UPDATE fruits SET id = id + 1000;

-- UPDATE ... FROM: use another table as the source of values
DROP TABLE IF EXISTS price_changes_example;
CREATE TABLE price_changes_example (id integer, new_name text);
INSERT INTO price_changes_example VALUES (3, 'chestnut'), (4, 'damson');

UPDATE fruits AS f
SET    name = pc.new_name
FROM   price_changes_example pc
WHERE  f.id = pc.id;

DROP TABLE price_changes_example;

-- Returning the rows that changed
UPDATE fruits
SET    name = name        -- no-op, just for the demo
WHERE  id = 5
RETURNING id, name;

SELECT * FROM fruits ORDER BY id LIMIT 5;

UPDATE 完全受事务保护:在你 COMMIT 之前(取决于隔离级别),其他会话看不到你的修改,而 ROLLBACK 会撤销这些修改。SELECT 的同样 WHERE 规则也适用。

UPDATE ... FROM 是 PostgreSQL 提供的扩展,允许你使用另一个表的数据更新一个表中的行。FROM 子句引入其他表,而 WHERE 子句负责将它们连接起来。这通常比在 SET 列表中嵌套关联子查询更清晰(也更快)。

psql 的状态消息 UPDATE n 会告诉你修改了多少行。UPDATE 0(零行)通常说明 WHERE 没有匹配到任何行——这对发现应用代码中的拼写错误或过时假设很有用。

运行示例

运行方式(需要前面课程创建的 fruits):

$ psql -f source/update.sql postgres
UPDATE 1
UPDATE 1
CREATE TABLE
INSERT 0 2
UPDATE 2
DROP TABLE
 id |    name
----+------------
  5 | elderberry
(1 row)
UPDATE 1
 id |    name
----+------------
  1 | apricot
  2 | blueberry
  3 | chestnut
  4 | damson
  5 | elderberry
(5 rows)

常见陷阱

  • 忘记 WHERE 而更新整张表——没有备份或事务时无法撤销。拿不准时,先执行 BEGIN;,运行 UPDATE,检查行数,再执行 COMMIT; 或 ROLLBACK;。
  • 更新被外键引用的主键:外键的 ON UPDATE 动作会决定这次变更是向外传播、导致失败,还是将值设为 NULL。
  • 将列更新为它已有的值,仍然会重写该行、获取锁,并触发任何 BEFORE UPDATE 触发器。如果这点很重要,请添加 WHERE col IS DISTINCT FROM new_value,以进行能正确处理 NULL 的比较。
  • 在繁忙表上长时间运行的 UPDATE 会持有行锁;并发更新会等待。将大批量更新拆分成较小的事务。
提示

要安全地预览一次更新,可以使用公用表表达式(CTE):先运行 WITH affected AS (SELECT id FROM fruits WHERE ...) SELECT * FROM affected;。确认无误后,再把外层查询替换为 UPDATE。

动手练习

把 fruits 中一行的名称改成另一个名称。使用 UPDATE fruits SET name = name || '!' WHERE id BETWEEN 1 AND 2 RETURNING *;,看看哪些行发生了变化。然后运行 BEGIN; UPDATE fruits SET name = 'X'; SELECT * FROM fruits; ROLLBACK;,确认回滚会丢弃这次修改。

下载

SQL 文件:update.sql · 全部示例文件 · 准备工作

27 DELETE

删除选定的行,并理解 DELETE 与 TRUNCATE 的区别。

DELETE FROM table 会删除行。和 UPDATE 一样,WHERE 子句决定你是“删除这些特定的行”,还是“清空整张表”。和 UPDATE 一样,DELETE 也是事务性的——ROLLBACK 可以撤销它。DELETE 可以筛选子集,并触发行级删除触发器。DELETE 和 TRUNCATE 都会遵守外键限制;TRUNCATE 则支持自己的语句级触发器。

本章要点

  • 使用 DELETE FROM ... WHERE 删除行
  • 为什么除了 DELETE 还需要 TRUNCATE
  • 使用 DELETE ... USING 连接源表
  • 使用 RETURNING 返回已删除的行
  • 事务安全性、外键级联和触发器

示例

示例 27.1 DELETE
-- Demo on a temp table so we don't drain `fruits`
DROP TABLE IF EXISTS to_delete;
CREATE TABLE to_delete AS SELECT * FROM fruits;

-- Delete a single row
DELETE FROM to_delete WHERE id = 1;

-- Delete by membership
DELETE FROM to_delete WHERE name IN ('chestnut', 'damson');

-- Delete returning the removed rows
DELETE FROM to_delete WHERE id = 5 RETURNING id, name;

-- Delete using another table as a source (DELETE ... USING)
DROP TABLE IF EXISTS keep;
CREATE TABLE keep (id integer);
INSERT INTO keep SELECT id FROM to_delete LIMIT 1;

DELETE FROM to_delete AS d USING keep k WHERE d.id = k.id;

SELECT * FROM to_delete ORDER BY id;

-- Clean up
DROP TABLE to_delete;
DROP TABLE keep;

DELETE 会将行版本标记为已删除,并运行适用的触发器和外键检查。对记录日志的表执行修改会生成 WAL,用于崩溃恢复;多版本并发控制(MVCC)会保留旧版本,因为事务可能仍然需要它们。

TRUNCATE table 会移除所有行而不扫描它们,对于大表通常快得多。它是事务性的:ROLLBACK 会恢复数据。它会获取 ACCESS EXCLUSIVE 锁,执行外键限制检查,并触发 ON TRUNCATE 语句级触发器,而不会触发行级的 ON DELETE 触发器。对于较早的快照,它不具备 MVCC 安全性;请在练习数据或暂存数据上谨慎使用。

DELETE ... USING 是 UPDATE ... FROM 的对应形式:通过连接到 s,表达“删除 t 中与 s 中行匹配的行”。

运行示例

运行方式(需要前面课程创建的 fruits):

$ psql -f source/delete.sql postgres
DROP TABLE
SELECT 8
DELETE 1
DELETE 2
 id |    name
----+------------
  5 | elderberry
(1 row)

DELETE 1
DROP TABLE
CREATE TABLE
INSERT 0 1
DELETE 1
 id  |   name
-----+-----------
   2 | blueberry
 102 | fruit_2
 103 | fruit_3
(3 rows)

DROP TABLE

常见陷阱

  • 忘记 WHERE——与 UPDATE 一样需要警惕,而且后果更严重。始终先用 SELECT count(*) FROM t WHERE same_condition; 预览。
  • 对带有 ON DELETE CASCADE 外键的表执行 DELETE,可能会沿着多个子表产生扇出。运行前务必审查。
  • DELETE 不会回收磁盘空间——行会被标记为死亡行,稍后由 VACUUM 回收。大量删除后,运行 VACUUM (VERBOSE) t; 来回收空间,并运行 ANALYZE 刷新统计信息。
  • 软删除(用 deleted_at 列代替真正的 DELETE)在应用设计中很常见。选择一种策略并坚持使用。
提示

对于非常大的删除操作,请分批执行:DELETE FROM logs WHERE created_at < now() - interval '30 days' AND id IN (SELECT id FROM logs WHERE created_at < ... LIMIT 10000);。这样每个事务都较小,自动清理能够跟上,锁的持有时间也较短。

动手练习

在临时表中添加一行,然后将它删除。接着尝试 BEGIN; DELETE FROM fruits; SELECT count(*) FROM fruits; ROLLBACK; SELECT count(*) FROM fruits;——回滚应该恢复这些行。然后在临时表上尝试 TRUNCATE。

下载

SQL 文件:delete.sql · 全部示例文件 · 准备工作

28 RETURNING

从修改数据的语句中直接返回生成的标识符和已修改的行。

RETURNING 是 PostgreSQL 提供的扩展,它能把 INSERT、UPDATE 或 DELETE 变成查询:语句会返回刚刚处理过的行。这样就不必再进行第二次网络往返,去获取自动生成的 id、更新后的时间戳或刚刚删除的值。大多数专业代码库都高度依赖 RETURNING。

本章要点

  • RETURNING 与 INSERT、UPDATE、DELETE 配合使用
  • 返回特定列与使用 RETURNING * 的区别
  • 在一次调用中获取生成的值(标识列、默认值)
  • 在公用表表达式(CTE,WITH ... AS (...))中使用 RETURNING 串联操作
  • 为什么它几乎总能替代“先 INSERT,再调用 SELECT last_insert_id()”

示例

示例 28.1 RETURNING
-- Setup
DROP TABLE IF EXISTS logs_example;
CREATE TABLE logs_example (
  id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  msg  text NOT NULL,
  at   timestamptz NOT NULL DEFAULT current_timestamp
);

-- INSERT ... RETURNING: get the generated id and timestamp in one round trip
INSERT INTO logs_example (msg) VALUES ('first') RETURNING id, msg, at;
INSERT INTO logs_example (msg) VALUES ('second'), ('third') RETURNING id;

-- UPDATE ... RETURNING: see the new values of the modified rows
UPDATE logs_example
SET    msg = msg || ' (updated)'
WHERE  id = 1
RETURNING id, msg;

-- DELETE ... RETURNING: see what was removed
DELETE FROM logs_example
WHERE  id = 3
RETURNING *;

-- Chaining: insert into one table, then route the new ids into another
DROP TABLE IF EXISTS log_archive;
CREATE TABLE log_archive (id bigint PRIMARY KEY, msg text, archived_at timestamptz DEFAULT current_timestamp);

WITH moved AS (
  DELETE FROM logs_example
  WHERE id <= 2
  RETURNING id, msg
)
INSERT INTO log_archive (id, msg)
SELECT id, msg FROM moved
RETURNING id;

RETURNING 接受你可以放进 SELECT 列表中的同样表达式——列引用、表达式、函数调用,甚至 *。对于 INSERT,这些列反映插入后的值,包括默认值和生成的标识列值。对于 UPDATE,它们反映新值;在 PostgreSQL 17 中,OLD.col 可在触发器函数中使用,不能在 RETURNING 中使用。PostgreSQL 18 在 RETURNING 中加入了显式的旧值/新值。对于 DELETE,它们反映删除前的行。

串联模式(WITH del AS (DELETE ... RETURNING) INSERT INTO archive SELECT * FROM del)是 PostgreSQL 的强大之处之一。两条语句会在一次往返中作为一个原子操作执行——比在应用代码中完成同样的事情快得多,也简单得多。

运行示例

运行方式:

$ psql -f source/returning.sql postgres
DROP TABLE
CREATE TABLE
 id |  msg  |              at
----+-------+-------------------------------
  1 | first | 2024-xx-xx xx:xx:xx.xxxxxx+00
(1 row)
 id
----
  2
  3
(2 rows)

 id |       msg
----+-----------------
  1 | first (updated)
(1 row)
 ...

常见陷阱

  • 在 INSERT 时忘记使用 RETURNING,然后执行 SELECT max(id) FROM t;——这会产生竞态条件。始终使用 RETURNING id 获取新行的 id。
  • 多行 INSERT ... RETURNING 会按插入顺序返回行,但这只是因为执行器的处理方式如此。如果需要有保证的映射,请在 VALUES 和 RETURNING 中都包含一个稳定键。
  • INSERT ... ON CONFLICT DO NOTHING 中的 RETURNING 不会返回因冲突而跳过的行。请使用 DO UPDATE(编校说明:原文所说的“下一课”实际指第 25 章的 UPSERT)来确保返回某一行。
  • 驱动程序有时会把 INSERT ... RETURNING 当作查询(带结果)提供,而不是当作语句(返回行数)提供。请检查相应语言的 API。
提示

INSERT INTO t (...) VALUES (...) RETURNING id 是“创建一行并告诉我它的 id”的标准写法。它适用于 bigint GENERATED ALWAYS AS IDENTITY、serial、UUID 默认值,或数据库提供的任何其他值。请在所有地方使用它。

动手练习

用一条语句插入三行,并通过 RETURNING id 获取这三个 id。然后运行 UPDATE logs_example SET msg = msg WHERE id > 0 RETURNING id, msg;,看看所有被触碰的行(“无操作”更新仍然会触碰一行)。然后使用 CTE 串联模式,将行从 logs_example 移到 log_archive。

下载

SQL 文件:returning.sql · 全部示例文件 · 准备工作

29 内连接与左连接

连接匹配的行,并使用 INNER JOIN 和 LEFT JOIN 保留未匹配的行。

连接会根据匹配条件,把两个(或更多)表中的行组合起来。它可以把关系数据重建成应用程序所需的形状。INNER JOIN 只保留两侧都满足条件的行;LEFT JOIN(也称 LEFT OUTER JOIN)保留左侧的每一行,右侧没有匹配时用 NULL 填充。掌握连接,就是掌握 SQL。

本章要点

  • INNER JOIN 配合 ON:只保留匹配的行
  • LEFT JOIN:保留左侧全部行,未匹配时右侧为 NULL
  • 使用表别名(c、o)提高可读性
  • 了解在 JOIN 目标中筛选与在 WHERE 子句中筛选的区别
  • 使用 USING (col) 简写 ON a.col = b.col

示例

示例 29.1 内连接与左连接
-- Two small tables for joins
DROP TABLE IF EXISTS orders_example;
DROP TABLE IF EXISTS customers_example;

CREATE TABLE customers_example (
  id   integer PRIMARY KEY,
  name text NOT NULL
);

CREATE TABLE orders_example (
  id          integer PRIMARY KEY,
  customer_id integer,                      -- intentionally not a real FK so we can show unmatched rows
  total       numeric(10, 2) NOT NULL
);

INSERT INTO customers_example VALUES (1, 'Alice'), (2, 'Bob'), (3, 'Carol');
INSERT INTO orders_example   VALUES (1, 1, 50), (2, 1, 30), (3, 99, 10);  -- order 3 points at a non-existent customer

-- INNER JOIN: only rows that match on both sides
SELECT c.name, o.id AS order_id, o.total
FROM customers_example c
INNER JOIN orders_example o ON o.customer_id = c.id
ORDER BY o.id;

-- LEFT JOIN: every customer, even those without orders
SELECT c.name, o.id AS order_id, o.total
FROM customers_example c
LEFT JOIN orders_example o ON o.customer_id = c.id
ORDER BY c.id, o.id;

-- Filtering on the JOIN vs in WHERE matters for LEFT JOIN.
-- "Customers and their orders > 40, even if they have none":
SELECT c.name, o.id, o.total
FROM customers_example c
LEFT JOIN orders_example o ON o.customer_id = c.id AND o.total > 40
ORDER BY c.id;

-- vs "customers who have orders > 40" — the WHERE turns the LEFT into an INNER:
SELECT c.name, o.id, o.total
FROM customers_example c
LEFT JOIN orders_example o ON o.customer_id = c.id
WHERE o.total > 40
ORDER BY c.id;

ON 子句是连接条件。它可以是任意布尔表达式,不过通常写成 parent.id = child.parent_id。ON 中的条件属于连接本身——对于 LEFT JOIN,即使右侧没有满足 ON 的匹配行,左侧行也会保留下来。WHERE 中的条件会在连接之后应用,因此对 LEFT JOIN 使用 WHERE child.col = 'x' 会过滤掉未匹配的(NULL)行,实际上使连接表现为 INNER JOIN。

USING (col) 是在两侧列名相同时的简写:USING (customer_id) 等价于 ON a.customer_id = b.customer_id,而结果行中只会有一个 customer_id 列,不是两个。

第 3 个订单的 customer_id = 99(没有该 id 的客户)。INNER JOIN 会丢弃它;LEFT JOIN 会保留没有订单的客户(Bob、Carol)。两者结合起来才能看到完整情况。

运行示例

运行方式:

$ psql -f source/inner-and-left-join.sql postgres
...
 name  | order_id | total
-------+----------+-------
 Alice |        1 | 50.00
 Alice |        2 | 30.00
(2 rows)

 name  | order_id | total
-------+----------+-------
 Alice |        1 | 50.00
 Alice |        2 | 30.00
 Bob   |          |
 Carol |          |
(4 rows)
 ...

常见陷阱

  • 使用 WHERE right_table.col = 'x' 过滤 LEFT JOIN,会意外丢掉你原本想保留的未匹配行。如果条件属于匹配条件,就把它放在 ON 中;如果想在连接后再筛选,就放在 WHERE 中。
  • 在没有索引的列上连接时,规划器会改用哈希连接或归并连接,而这可能扫描整张表。请为外键列建立索引。
  • 笛卡尔积:CROSS JOIN,或不带连接谓词的逗号分隔 FROM 列表,会产生 m × n 行。普通的 INNER JOIN 需要 ON 或 USING;PostgreSQL 不会对合法的交叉连接发出警告。
  • LEFT JOIN ... GROUP BY 是“统计每个父项的子项数量,同时包括没有子项的父项”的自然写法。使用 count(child.id)(而不是 count(*)),这样未匹配的行才会得到 0。
提示

选择简短且有意义的别名。customers_example c 和 orders_example o 比 customers_example cs1 和 orders_example o1 更易读。许多风格指南建议使用由表名派生的 1–3 个字符的别名。

动手练习

把 INNER JOIN 改成 LEFT JOIN,看看 Carol 如何带着 NULL 出现。试试上面的两种筛选写法,确认只有其中一种会保留未匹配的客户。然后使用 USING:SELECT * FROM customers_example c JOIN orders_example o ON o.customer_id = c.id; 会变成 SELECT * FROM customers_example c JOIN orders_example o USING (id);——等等,这里两张表的列名不同,所以你需要先重命名其中一列。换用两张共享同一个列名的表试试。

下载

SQL 文件:inner-and-left-join.sql · 全部示例文件 · 准备工作

30 右连接与全连接

使用外连接保留任一侧,并找出未匹配的记录。

RIGHT JOIN 是 LEFT JOIN 的镜像:右侧每一行都会保留,即使左侧没有匹配。FULL OUTER JOIN 保留两侧的每一行,未匹配的列用 NULL 填充。实际使用中,LEFT JOIN 比 RIGHT JOIN 常见得多——把“主”表放在前面,再用 LEFT JOIN 引入补充数据,通常更自然。需要同时查看匹配项和两类孤立行时(例如对账报告),FULL JOIN 就很有用。

本章要点

  • RIGHT JOIN:保留右侧全部内容
  • FULL JOIN:保留两侧全部内容
  • 为什么 LEFT JOIN 通常更易读
  • 找出未匹配的行:WHERE other.id IS NULL
  • 两侧可能为 NULL 时如何排序

示例

示例 30.1 右连接与全连接
-- Uses customers_example and orders_example from the previous lesson
-- (run inner-and-left-join.sql first if you have not)

-- RIGHT JOIN: every order, even those without a matching customer
SELECT c.name, o.id AS order_id, o.total
FROM customers_example c
RIGHT JOIN orders_example o ON o.customer_id = c.id
ORDER BY o.id;

-- FULL OUTER JOIN: every customer and every order
SELECT c.name, o.id AS order_id, o.total
FROM customers_example c
FULL OUTER JOIN orders_example o ON o.customer_id = c.id
ORDER BY c.id NULLS LAST, o.id NULLS LAST;

-- "Orphans" — find unmatched rows on either side
-- Customers with no orders:
SELECT c.id, c.name
FROM customers_example c
LEFT JOIN orders_example o ON o.customer_id = c.id
WHERE o.id IS NULL;

-- Orders with no customer:
SELECT o.id, o.customer_id
FROM customers_example c
RIGHT JOIN orders_example o ON o.customer_id = c.id
WHERE c.id IS NULL;

RIGHT JOIN A ON ... 与交换表后的 LEFT JOIN A 完全等价——A LEFT JOIN B ON cond 与 B RIGHT JOIN A ON cond 相同。大多数风格指南建议把 RIGHT JOIN 改写为 LEFT JOIN,这样“主”表会出现在 FROM 子句的前面。

FULL OUTER JOIN 是对称的连接形式:两侧每一行都会出现,未匹配的列为 NULL。第 3 个订单(customer_id = 99)会以 NULL name 出现;Bob 和 Carol 会以 NULL 订单列出现。ORDER BY ... NULLS LAST 会让结果更易读。

经典的反连接模式——“A 中没有在 B 中匹配的行”——使用 LEFT JOIN ... WHERE B.id IS NULL。PostgreSQL 能识别这种写法,通常会把它规划成高效的反连接。

运行示例

运行方式(需要上一课创建的表):

$ psql -f source/right-and-full-join.sql postgres
 name  | order_id | total
-------+----------+-------
 Alice |        1 | 50.00
 Alice |        2 | 30.00
       |        3 | 10.00
(3 rows)

 name  | order_id | total
-------+----------+-------
 Alice |        1 | 50.00
 Alice |        2 | 30.00
 Bob   |          |
 Carol |          |
       |        3 | 10.00
(5 rows)
 ...

常见陷阱

  • 先写 RIGHT JOIN,然后还得在脑子里把列顺序翻过来。交换表的顺序,改用 LEFT JOIN,可读性更好。
  • FULL JOIN 的结果两侧都可能有 NULL;如果 ORDER BY 没有指定 NULLS LAST/FIRST,它们可能会散落在结果中,令人困惑。
  • 使用 WHERE other.id IS NULL 处理 LEFT JOIN 是反连接的惯用写法。NOT EXISTS (...) 和 NOT IN (...) 是替代方案——在含有 NULL 时,NOT EXISTS 通常最安全。
  • 用 FULL JOIN 连接多个表通常不是你想要的结果——类似笛卡尔积的行为会叠加。最好只在简单场景中使用。
提示

对于“显示两个集合的对称差”(只存在于其中一个集合的行),FULL OUTER JOIN ... WHERE a.id IS NULL OR b.id IS NULL 是惯用写法——对账两个数据快照时很方便。

动手练习

在 FULL JOIN 中加入 WHERE 子句,只显示未匹配的行:WHERE c.id IS NULL OR o.id IS NULL。然后交换表顺序,把上面的 RIGHT JOIN 改写成 LEFT JOIN——自己确认结果相同。

下载

SQL 文件:right-and-full-join.sql · 全部示例文件 · 准备工作

31 自连接

查询同一张表中的关系,并跟踪员工层级。

当一张表与自身连接时,就是自连接。你需要为它取两个别名,让 PostgreSQL 能分辨你指的是哪一个副本,并用一个条件把相关行连接起来。只要数据模型表示同类行之间的关系,就会用到自连接——员工与经理、评论与父评论、前驱与后继。对于任意深度的层级关系,合适的工具是递归 CTE(后面的课程会介绍);只有一层时,自连接简单而且快速。

本章要点

  • 使用两个别名把一张表与自身连接
  • 为自连接选择 INNER 还是 LEFT
  • 建模一层层级关系(员工 → 经理)
  • 初步了解任意深度的递归 CTE
  • 为列取名以提高清晰度

示例

示例 31.1 自连接
DROP TABLE IF EXISTS employees_example;
CREATE TABLE employees_example (
  id         integer PRIMARY KEY,
  name       text NOT NULL,
  manager_id integer REFERENCES employees_example(id)
);

INSERT INTO employees_example VALUES
  (1, 'Alice',  NULL),    -- top of the hierarchy
  (2, 'Bob',    1),       -- reports to Alice
  (3, 'Carol',  1),       -- reports to Alice
  (4, 'Dave',   2),       -- reports to Bob
  (5, 'Eve',    2);       -- reports to Bob

-- One level: employee and their direct manager
SELECT e.name AS employee, m.name AS manager
FROM employees_example e
LEFT JOIN employees_example m ON e.manager_id = m.id
ORDER BY e.id;

-- Count direct reports per manager
SELECT m.name AS manager, count(e.id) AS direct_reports
FROM employees_example m
LEFT JOIN employees_example e ON e.manager_id = m.id
GROUP BY m.id, m.name
ORDER BY m.id;

-- A taste of recursion: full chain to the top (covered fully in the CTE lesson)
WITH RECURSIVE chain AS (
  SELECT id, name, manager_id, 1 AS depth
  FROM employees_example
  WHERE id = 4                                       -- start at Dave
  UNION ALL
  SELECT e.id, e.name, e.manager_id, c.depth + 1
  FROM employees_example e
  JOIN chain c ON e.id = c.manager_id
)
SELECT * FROM chain;

在连接 FROM employees_example e LEFT JOIN employees_example m ON e.manager_id = m.id 中,e 是员工行,m 是经理行。两者都指向同一张物理表——PostgreSQL 把这些别名当作彼此独立的副本。使用 LEFT JOIN 意味着没有经理的员工(Alice)仍会出现,经理列为 NULL。

第二个查询中的 count(e.id)(而不是 count(*))是标准写法:count(*) 会把未匹配行产生的 NULL 也算进去,错误地让每个叶子经理至少拥有 1 个直接下属。count(e.id) 只统计非 NULL 的 id,即实际的下属。

结尾的递归 CTE 是对 CTE 课程 的预览。WITH RECURSIVE 让你可以遍历任意深度——锚点行(Dave)是起点;递归步骤重新连接到 chain,每次向上走一层。

运行示例

运行方式:

$ psql -f source/self-join.sql postgres
DROP TABLE
CREATE TABLE
INSERT 0 5
 employee | manager
----------+---------
 Alice    |
 Bob      | Alice
 Carol    | Alice
 Dave     | Bob
 Eve      | Bob
(5 rows)
 ...

常见陷阱

  • 用 INNER JOIN 而不是 LEFT JOIN,会悄悄丢掉层级顶端的行。顶层行没有经理,INNER JOIN 会将其丢弃。
  • 忘记 e 和 m 是同一张表。若条件可能允许同一行匹配,可以使用 WHERE e.id <> m.id 排除这种情况。
  • 想找出“没有子项的组”时,不要统计 *,而要统计连接进来的表的 id——count(child.id) 才是正确写法。
  • 对于任意深度,反复进行自连接(emp → mgr → mgr's mgr → ...)无法扩展。请改用 WITH RECURSIVE。
提示

对称关系(friend-of)通常使用单独的关联表,而不是自连接。非对称关系(manager-of、parent-of)很适合用自引用列表示。

动手练习

在 Carol 下面新增第六名员工。重新运行直接下属查询——现在 Carol 应该有一名直接下属。然后修改递归 CTE,让它从 Eve 而不是 Dave 开始,看看链条如何变化。

下载

SQL 文件:self-join.sql · 全部示例文件 · 准备工作

32 UNION、INTERSECT 与 EXCEPT

使用 UNION、INTERSECT 和 EXCEPT 组合查询结果,可以保留或去除重复项。

集合运算逐行组合两个结果集。三者都要求两侧查询返回相同数量的列,且类型兼容。UNION 返回任一侧出现的行(去除重复项)。INTERSECT 返回两侧都出现的行。EXCEPT 返回第一侧有而第二侧没有的行。每种运算都有 ALL 变体。UNION ALL 连接两个输入。INTERSECT ALL 保留每行在两侧出现次数中的较小值;EXCEPT ALL 从左侧次数中减去右侧次数,最小为零。后两种仍然需要匹配并统计行数。

本章要点

  • 比较 UNION 和 UNION ALL,了解各自适用的场景
  • INTERSECT 与 INTERSECT ALL
  • EXCEPT 与 EXCEPT ALL
  • 组合集合运算,以及运算优先级如何生效
  • 性能:什么时候 UNION ALL 会明显更快

示例

示例 32.1 UNION、INTERSECT 与 EXCEPT
-- UNION removes duplicates across the combined result
SELECT 1 AS n UNION SELECT 2 UNION SELECT 1;

-- UNION ALL keeps duplicates (cheaper, no sort/hash)
SELECT 1 AS n UNION ALL SELECT 1 UNION ALL SELECT 2;

-- INTERSECT: rows present in both
SELECT id FROM fruits WHERE id <= 3
INTERSECT
SELECT id FROM fruits WHERE id >= 2;

-- EXCEPT: rows in the first query but not the second
SELECT id FROM fruits WHERE id <= 4
EXCEPT
SELECT id FROM fruits WHERE id >= 3;

-- Order of evaluation: INTERSECT binds tighter than UNION / EXCEPT.
-- Use parentheses to be explicit:
(SELECT id FROM fruits WHERE id < 5)
UNION
(SELECT id FROM fruits WHERE id > 100);

-- ORDER BY applies to the combined result; column refs by position or alias
SELECT id AS n FROM fruits WHERE id < 3
UNION
SELECT id     FROM fruits WHERE id > 100
ORDER BY n;

UNION 会在完整行上进行排序或哈希,以去除重复项——这项工作确实存在,并会出现在 EXPLAIN 中。如果你知道输入不会产生重复项(例如谓词互不相交),优先使用 UNION ALL。对于大型查询,两者差异可能很大。

INTERSECT 和 EXCEPT 在应用代码中不太常见,但用于分析很方便:“点击了 A 和 B 的用户”“在目录 A 中而不在目录 B 中的产品”。

集合运算是对行集合的运算。各输入的列类型必须对齐——int 和 text 不能直接用集合运算合并。列名取自第一个查询。

运行示例

运行方式(需要 fruits):

$ psql -f source/union-intersect-except.sql postgres
 n
---
 1
 2
(2 rows)

 n
---
 1
 1
 2
(3 rows)
 ...

常见陷阱

  • 对于不会产生重复项的查询,使用 UNION 而不是 UNION ALL 会把 CPU 浪费在去重上。除非可能出现你不想保留的重复项,否则选择 UNION ALL。
  • 列数或类型不对齐:SELECT id, name FROM t UNION SELECT id FROM t2 会报错。用 NULL::text 补齐列,或对齐列。
  • 集合运算后的 ORDER BY 针对合并后的结果,不能使用带表限定的列名。使用位置(ORDER BY 1)或第一个查询中的别名。
  • EXCEPT 对 NULL 很敏感:EXCEPT 把两个 NULL 当作相等(就像 DISTINCT),所以左侧有 NULL、右侧也有 NULL 的行会被删除。这通常正是你想要的结果,但值得记住。
提示

如果你发现自己在写 SELECT ... WHERE x = 1 UNION ALL SELECT ... WHERE x = 2 UNION ALL ...,不妨考虑单个 WHERE x IN (1, 2, 3) 是否就够了。集合运算适用于确实不同、且需要合并结果的查询。

动手练习

写两个 SELECT 查询 fruits,每个使用不同的 WHERE 子句,并用 UNION 合并。然后改成 UNION ALL,观察重复项数量的变化(如果有)。再试试在两者之间使用 INTERSECT 和 EXCEPT。

下载

SQL 文件:union-intersect-except.sql · 全部示例文件 · 准备工作

33 COUNT、SUM 与 AVG

统计数量、求和、求平均值并收集值,同时处理 NULL 和空输入。

聚合函数会对多行计算出一个值。最常见的五种——count、sum、avg、min、max——是基础函数。它们处理 NULL 的方式略有不同:count(*) 统计每一行,包括含 NULL 的行;count(col) 统计 col 的非 NULL 值;sum/avg/min/max 完全忽略 NULL。还有几十种其他函数(stddev、string_agg、array_agg、bool_and、bool_or、jsonb_agg……),每种聚合函数都有自己的行为:例如,array_agg 和 jsonb_agg 会保留 NULL 输入。

本章要点

  • count(*)、count(col) 与 count(DISTINCT col) 的区别
  • sum、avg、min、max 处理数值及其他类型
  • 聚合函数如何处理 NULL
  • 使用 string_agg 和 array_agg 构建列表
  • 使用 FILTER (WHERE ...) 为每个聚合设置条件

示例

示例 33.1 COUNT、SUM 与 AVG
-- Basic counts
SELECT count(*) AS total_rows FROM fruits;
SELECT count(name) AS non_null_names FROM fruits;
SELECT count(DISTINCT name) AS unique_names FROM fruits;

-- Sum and average
SELECT sum(id) AS sum_id, avg(id) AS avg_id, round(avg(id), 2) AS avg_round
FROM fruits;

-- Min and max work on numbers, text, dates
SELECT min(name) AS min_name, max(name) AS max_name FROM fruits;

-- Build a string or array from a column
SELECT string_agg(name, ', ' ORDER BY id) AS names_csv,
       array_agg(name ORDER BY id)         AS names_arr
FROM fruits;

-- FILTER: per-aggregate WHERE; replaces messy CASE WHEN tricks
SELECT count(*) AS total,
       count(*) FILTER (WHERE id <= 3) AS low,
       count(*) FILTER (WHERE id > 3)  AS high,
       sum(id)  FILTER (WHERE id > 3)  AS high_sum
FROM fruits;

count(*) 统计行数——快速而清晰。count(col) 会跳过 col 的 NULL 值;如果你想知道“有多少行的 email 非 NULL”,这就是正确的选择。count(DISTINCT col) 会先去重;因为必须先物化去重后的集合,所以更慢。

sum 返回总和;如果没有行则返回 NULL(不是 0——这很令人意外,但符合标准)。没有行时,avg 返回 NULL;对于整数输入,avg 返回 numeric,因为整数的平均值很少还是整数。min 和 max 适用于任何可排序的内容,包括文本和时间戳。

string_agg 和 array_agg 是 PostgreSQL 的“连接值”聚合函数——当你想生成列表时,它们正是合适的工具。聚合内部可选的 ORDER BY 控制连接顺序;默认的无序行为无法保证顺序。

FILTER (WHERE ...) 是 SQL 标准规定的、为单个聚合应用条件的方式。它取代了旧式的 sum(CASE WHEN cond THEN x END) 写法——结果相同,但清楚得多。

运行示例

运行方式:

$ psql -f source/count-sum-avg.sql postgres
 total_rows
------------
          8
(1 row)
 ...
   names_csv
-----------------------------------------------------------
 apricot, blueberry, chestnut, damson, elderberry, fruit_1, fruit_2, fruit_3
(1 row)

 total | low | high | high_sum
-------+-----+------+----------
     8 |   3 |    5 |      315
(1 row)

常见陷阱

  • 对零行求 sum 时,返回 NULL 而不是 0。如果需要 0,请使用 COALESCE(sum(x), 0)。
  • 整数列上的 avg 会产生 numeric 结果;如果想显示四舍五入后的值,可以把它包在 round 中。
  • count(col) 跳过 NULL 是一个特性,但会让初学者感到意外。count(*) 统计行数;其他形式都是“非 NULL 计数”。
  • 不使用 GROUP BY 的聚合会将整个结果聚合为一行。使用 GROUP BY 则每组得到一行(下一课介绍)。
提示

在一次扫描中组合聚合函数和 FILTER,即可同时得到许多统计结果。SELECT count(*), count(*) FILTER (WHERE is_active), avg(amount) FILTER (WHERE status = 'paid'), max(created_at) FROM ... 只扫描一次,却能给出许多答案。

动手练习

给 fruits 增加一个价格列:ALTER TABLE fruits ADD COLUMN price numeric(10,2);(然后用 UPDATE 修改一些价格),再对它运行 sum、avg、min、max。然后试试 SELECT string_agg(name, ', ' ORDER BY id DESC) FROM fruits;,看看顺序反过来了。

下载

SQL 文件:count-sum-avg.sql · 全部示例文件 · 准备工作

34 GROUP BY

按组聚合行,并使用 ROLLUP 和 GROUPING SETS 构建小计。

GROUP BY 会按一个或多个表达式的值把行拆分成组,然后对每组应用聚合函数。不使用 GROUP BY 时,聚合会把整个结果压缩为一行。使用它后,每个不同的组得到一行——每位客户的 count、每月的 sum、每个类别的 avg。SELECT 列表中的每个非聚合列都必须出现在 GROUP BY 中(或者函数依赖于分组表达式)。

本章要点

  • 按一个或多个列分组
  • 对每个分组应用聚合
  • 非聚合列必须出现在 GROUP BY 中的规则
  • 按表达式而不只是列分组
  • 使用 GROUPING SETS、ROLLUP、CUBE 生成多层汇总

示例

示例 34.1 GROUP BY
-- One row per customer with order count and total
SELECT customer_id,
       count(*)             AS order_count,
       sum(total)           AS total_spent,
       avg(total)::numeric(10,2) AS avg_order
FROM orders_example
GROUP BY customer_id
ORDER BY customer_id;

-- Group by an expression (first letter of fruit name)
SELECT substring(name, 1, 1) AS letter, count(*) AS cnt
FROM fruits
GROUP BY substring(name, 1, 1)
ORDER BY letter;

-- Group by multiple columns
SELECT schemaname, count(*) AS table_count
FROM pg_tables
GROUP BY schemaname
ORDER BY table_count DESC
LIMIT 5;

-- GROUPING SETS: several groupings in one query
SELECT customer_id, count(*) AS orders, sum(total) AS spent
FROM orders_example
GROUP BY GROUPING SETS ((customer_id), ())   -- per-customer rows AND a grand total
ORDER BY customer_id NULLS LAST;

-- ROLLUP: hierarchical subtotals
SELECT customer_id, sum(total) AS spent
FROM orders_example
GROUP BY ROLLUP (customer_id);

规则很直接:在带有 GROUP BY 的查询中,SELECT 列表中的每一列必须出现在 GROUP BY 子句中,或者位于某个聚合函数内。PostgreSQL 确实支持函数依赖——如果 id 是主键,就可以 GROUP BY id,并直接引用 name,因为 name 由 id 决定。但是你不能写类似 SELECT customer_id, total FROM orders GROUP BY customer_id; 的查询——total 在组内会变化。

GROUPING SETS 及其简写 ROLLUP 和 CUBE 可以在一次查询中产生多个分组,这是 OLAP 风格报表的基础。ROLLUP (a, b, c) 会产生分组 (a, b, c)、(a, b)、(a)、()——连续的小计一直到总计。CUBE (a, b) 会产生所有组合——(a, b)、(a)、(b)、()。

运行示例

运行方式(需要连接课程中的 orders_example 表):

$ psql -f source/group-by.sql postgres
 customer_id | order_count | total_spent | avg_order
-------------+-------------+-------------+-----------
           1 |           2 |       80.00 |     40.00
          99 |           1 |       10.00 |     10.00
(2 rows)
 ...

常见陷阱

  • “column ‘x’ must appear in the GROUP BY clause or be used in an aggregate function”——最常见的 GROUP BY 错误。要么按 x 分组,要么用 min(x)、max(x) 等包住它。
  • WHERE 在分组之前过滤行;HAVING 在分组之后过滤组(下一课介绍)。把两者混淆是常见问题。
  • 对可为空的列使用 GROUP BY 时,所有 NULL 都会作为一个分组。如果想排除它们,加上 WHERE col IS NOT NULL。
  • 按表达式分组(GROUP BY date_trunc('month', created_at))没有问题,但要注意 PostgreSQL 必须为每一行计算它——如果查询很频繁,就为该表达式建立索引。
提示

GROUP BY 1, 2 按 SELECT 列表中的第一列和第二列分组。临时查询这样写很简洁;在提交到代码库的代码中,应写出列名以提高清晰度。

动手练习

按 name 的首字母对 fruits 分组:SELECT substring(name, 1, 1) AS letter, count(*) FROM fruits GROUP BY substring(name, 1, 1) ORDER BY letter;。然后试试 GROUP BY 1。再加入 HAVING count(*) > 1(下一课预览),只查看匹配次数超过一次的字母。

下载

SQL 文件:group-by.sql · 全部示例文件 · 准备工作

35 HAVING

使用 HAVING 筛选聚合分组,并区分分组谓词与行筛选。

WHERE 在分组之前筛选单独的行;HAVING 在聚合计算完成后筛选分组。如果你想表达“订单数超过三笔的客户”,那是 HAVING——只有得到计数后,才能测试 count(*) > 3。如果你想表达“在 2024 年下过订单的客户”,那是 WHERE——年份是每一行的属性。

本章要点

  • 使用 HAVING 筛选分组
  • 区分 WHERE(逐行)与 HAVING(逐组)
  • 在同一查询中组合 WHERE 和 HAVING
  • 在 HAVING 中使用聚合函数
  • 为什么把谓词下推到 WHERE 通常更快

示例

示例 35.1 HAVING
-- HAVING with one aggregate condition
SELECT customer_id, count(*) AS order_count, sum(total) AS total_spent
FROM orders_example
GROUP BY customer_id
HAVING sum(total) > 30
ORDER BY customer_id;

-- HAVING with multiple conditions
SELECT customer_id, count(*) AS order_count, sum(total) AS total_spent
FROM orders_example
GROUP BY customer_id
HAVING count(*) >= 1 AND sum(total) > 30;

-- WHERE then HAVING: filter rows first, group, then filter groups
SELECT customer_id, count(*) AS order_count
FROM orders_example
WHERE total > 20                      -- per-row filter
GROUP BY customer_id
HAVING count(*) >= 1;                 -- per-group filter

-- HAVING without aggregates is legal but pointless — same as WHERE
SELECT customer_id
FROM orders_example
GROUP BY customer_id
HAVING customer_id > 50;              -- could just be WHERE

执行顺序有助于记住区别:FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY。把行谓词写在 WHERE 中,意图更清楚,也能减少进入分组的数据;PostgreSQL 也可以把合适的 HAVING 谓词下推。尽可能把更多筛选放进 WHERE。

HAVING 允许直接写聚合谓词:HAVING avg(amount) > 100、HAVING count(DISTINCT product_id) > 5。这些条件不能出现在 WHERE 中,因为聚合在那里还不存在。

最后一个查询在技术上有效,但有隐患:在 HAVING 中筛选非聚合列,效果与 WHERE 相同,却在流水线更晚的阶段进行。为了清晰起见,优先使用 WHERE;规划器通常也能把这个分组列谓词下推。

运行示例

运行方式(需要连接课程中的 orders_example 表):

$ psql -f source/having.sql postgres
 customer_id | order_count | total_spent
-------------+-------------+-------------
           1 |           2 |       80.00
(1 row)
 ...

常见陷阱

  • 写 WHERE count(*) > 3——WHERE 不允许使用聚合函数。请使用 HAVING。
  • 写 HAVING amount > 100 只有在 amount 已被分组或函数依赖于分组时才有效。要筛选单独的输入行,请使用 WHERE amount > 100。规划器通常会重写这一点,但读者会感到困惑。
  • 忘记 HAVING 在 GROUP BY 之后运行,因此引用的任何列都必须是 GROUP BY 列,或位于聚合函数内。
  • 在 count(*) FILTER (WHERE ...) 这种逻辑中使用 HAVING 而不是 WHERE。FILTER 针对每个聚合,位于 SELECT 中;HAVING 针对每个分组,决定保留哪些组。
提示

如果你想要“按某个聚合值排名的前 N 个分组”,可以组合 GROUP BY + ORDER BY agg DESC + LIMIT N——不需要 HAVING。HAVING 用于谓词(“至少 3 个”),而不是排名(“前 3 个”)。

动手练习

修改阈值:HAVING sum(total) < 50,看看结果集如何变化。然后把筛选移到 WHERE:WHERE total > 20 GROUP BY customer_id;,观察计数下降,因为进入分组的行变少了。

下载

SQL 文件:having.sql · 全部示例文件 · 准备工作

36 标量子查询与 IN 子查询

使用标量子查询和成员资格子查询,并正确处理空结果和 NULL。

子查询是在另一个查询中编写的 SELECT。常见的有三种形式。标量子查询至多返回一行且恰好一列,可以在任何允许单个值的地方使用。IN 子查询用于测试成员资格:WHERE col IN (subquery)。EXISTS 子查询测试子查询是否返回任意行(下一课会介绍)。子查询可以是关联的(引用外层行),也可以是非关联的(不依赖外层查询)。PostgreSQL 很擅长优化它们——你可以放心使用,不必过度担心。

本章要点

  • 标量子查询:一行一列
  • IN (subquery) 如何测试成员资格
  • NOT IN 与 NULL 陷阱
  • ANY / ALL 作为更通用的比较符
  • 什么时候使用子查询而不是连接

示例

示例 36.1 标量子查询与 IN 子查询
-- Scalar subquery: the fruit with the largest id
SELECT id, name
FROM fruits
WHERE id = (SELECT max(id) FROM fruits);

-- Scalar subquery in the SELECT list
SELECT name,
       id,
       (SELECT count(*) FROM fruits) AS total_fruits
FROM fruits
ORDER BY id
LIMIT 3;

-- IN: membership in a set
SELECT *
FROM fruits
WHERE id IN (SELECT id FROM fruits WHERE id <= 2)
ORDER BY id;

-- NOT IN with no NULLs is fine
SELECT id, name
FROM fruits
WHERE id NOT IN (SELECT id FROM fruits WHERE id > 100)
ORDER BY id;

-- ANY/ALL: generalize comparators with a subquery
SELECT id, name
FROM fruits
WHERE id > ALL (SELECT id FROM fruits WHERE id < 4)
ORDER BY id;

-- A correlated subquery references the outer row (here, c.id)
SELECT c.id, c.name,
       (SELECT count(*) FROM orders_example o WHERE o.customer_id = c.id) AS order_count
FROM customers_example c
ORDER BY c.id;

标量子查询必须至多返回一行。零行会被视为 NULL;多行会引发错误。当你想获得依赖于表本身的值(最大值、计数、最新值)时,它们最有用。

IN (subquery) 等价于 = ANY (subquery)——当外层值等于子查询结果中的至少一个值时为真。规划器通常会把它转换为半连接,效率可能很高。

ANY 和 ALL 将比较推广到一个集合。x > ALL (s) 在 x 大于 s 中的每个元素时为真(包括空集合;对空集合而言,这是空真,因此结果为真)。x > ANY (s) 在 x 大于至少一个元素时为真。

关联子查询引用外层查询中的列(例如上面的 o.customer_id = c.id)。从概念上说,它们对每个外层行运行一次,不过规划器通常会把它们优化为连接。

运行示例

运行方式(需要 fruits 表和连接课程中的表):

$ psql -f source/scalar-and-in-subqueries.sql postgres
 id  |   name
-----+----------
 103 | fruit_3
(1 row)
 ...

常见陷阱

  • 返回多行的标量子查询会引发 “more than one row returned by a subquery used as an expression.” 错误。如果你确实想要“第一行”,请添加 LIMIT 1 和明确的 ORDER BY。
  • NOT IN (subquery) 是著名的陷阱:如果存在 NULL,不匹配的比较会产生未知,匹配的比较会产生 false;筛选结果不会保留任何行。为安全起见,请使用 NOT EXISTS。
  • SELECT 列表中的关联子查询会对每个外层行运行一次。如果规划器无法优化,代价就是 O(outer * inner)。当子查询代价高时,可以改写为 LEFT JOIN + count() ... GROUP BY。
  • 在很长的硬编码列表上使用 IN(如 IN ('a', 'b', ..., thousands))没问题,但不够优雅。对于动态的大型列表,优先与临时表连接,或使用 unnest(array)。
提示

许多子查询都可以改写为连接或 CTE;选择主要取决于可读性。使用子查询表示单个值,使用连接表示来自另一张表的多行,在你想为中间结果命名时使用 CTE(后面的 CTE 课程会介绍)。

动手练习

找出 fruits 中 id 等于平均 id 的行(适当进行类型转换):SELECT * FROM fruits WHERE id = (SELECT avg(id)::integer FROM fruits);。然后把关联子查询改写成 LEFT JOIN + GROUP BY,比较结果。

下载

SQL 文件:scalar-and-in-subqueries.sql · 全部示例文件 · 准备工作

37 EXISTS 与派生表

使用 EXISTS 查找相关行,并用派生表为中间结果命名。

EXISTS (subquery) 在子查询返回至少一行时为真。它是回答“是否有任何相关行满足 X?”这类问题的主力工具——通常写成引用外层行的关联子查询。派生表是在 FROM 子句中使用的子查询,给它指定别名后,就可以像表一样使用。两者结合起来,涵盖了在接触 CTE 和窗口函数之前你会编写的大多数 SQL。

本章要点

  • EXISTS 与 NOT EXISTS:测试“是否存在任何行”/“不存在任何行”
  • 为什么对于可能含空值的子查询,NOT EXISTS 胜过 NOT IN
  • 派生表:在 FROM 中使用并带有别名的子查询
  • 什么时候派生表比 CTE 更清晰
  • IN 与连接的关系

示例

示例 37.1 EXISTS 与派生表
-- EXISTS: customers who have at least one order
SELECT c.id, c.name
FROM customers_example c
WHERE EXISTS (
  SELECT 1 FROM orders_example o WHERE o.customer_id = c.id
)
ORDER BY c.id;

-- NOT EXISTS: customers with no orders (safe even with NULL customer_id values)
SELECT c.id, c.name
FROM customers_example c
WHERE NOT EXISTS (
  SELECT 1 FROM orders_example o WHERE o.customer_id = c.id
)
ORDER BY c.id;

-- Derived table: a subquery used in FROM, given an alias
SELECT sub.customer_id, sub.total_spent
FROM (
  SELECT customer_id, sum(total) AS total_spent
  FROM orders_example
  GROUP BY customer_id
) AS sub
WHERE sub.total_spent > 30
ORDER BY sub.customer_id;

-- Derived table joined back to a base table
SELECT c.name, latest.total
FROM customers_example c
JOIN (
  SELECT customer_id, max(total) AS total
  FROM orders_example
  GROUP BY customer_id
) AS latest ON latest.customer_id = c.id
ORDER BY c.id;

EXISTS 内部的约定写法 SELECT 1 并不是什么魔法——PostgreSQL 不关心子查询返回哪些列;它只检查是否会产生任何行。SELECT 1、SELECT * 和 SELECT NULL 的效果都一样。SELECT 1 表示“我不关心这些列”,因此成为约定俗成的写法。

派生表也称内联视图,其实就是 FROM 中的子查询。明确的别名(写在右括号后面)为结果指定一个可供引用的名称。派生表很容易组合:可以连接、过滤、分组,甚至嵌套它们。主要缺点是,复杂的派生表会把逻辑埋在查询中间。CTE(下一课)通常是为中间结果命名的更好方式。

运行示例

运行方式(需要 customers_example 和 orders_example):

$ psql -f source/exists-and-derived-tables.sql postgres
 id | name
----+------
  1 | Alice
(1 row)

 id | name
----+------
  2 | Bob
  3 | Carol
(2 rows)
 ...

常见陷阱

  • IN (SELECT col FROM t) 和 EXISTS (SELECT 1 FROM t WHERE t.col = outer.col) 通常等价——但当 NULL 可能出现时,NOT IN 与 NOT EXISTS 并不等价。只要子查询可能包含 NULL,就优先使用 NOT EXISTS。
  • 像 AS x 这样的别名有助于提高清晰度和可移植性。PostgreSQL 16 及更高版本允许 FROM 中的子查询省略别名;较早的 PostgreSQL 版本则要求提供别名。
  • 派生表内部可以使用 ORDER BY,但外层查询使用它时不会保留该顺序——应在外层排序。
  • 一些旧指南说 EXISTS“总是比 IN 慢”。在现代 PostgreSQL 中,规划器对两者处理得都很好;应优先根据正确性和可读性选择。
提示

EXISTS 会短路求值——找到第一条匹配行后就停止。因此,如果较早找到匹配行,WHERE EXISTS (SELECT 1 FROM big_table WHERE ...) 的执行速度可能很快。

动手练习

找出名称恰好出现一次的 fruits:SELECT name FROM fruits f WHERE NOT EXISTS (SELECT 1 FROM fruits f2 WHERE f2.name = f.name AND f2.id <> f.id);。然后在脑中把派生表改写成 CTE(下一课的预览):WITH spend AS (SELECT customer_id, sum(total) AS total_spent FROM orders_example GROUP BY customer_id) SELECT * FROM spend WHERE total_spent > 30;。

下载

SQL 文件:exists-and-derived-tables.sql · 全部示例文件 · 准备工作

38 公用表表达式(WITH)

使用 WITH 组织查询,以原子方式移动行,并递归遍历树结构。

公用表表达式(CTE)是一个由 WITH name AS (SELECT ...) 引入的命名子查询。你可以从主查询(以及后续的 CTE)中引用它,就像引用一张表一样。CTE 让复杂查询更易读:每个 CTE 都有自己的名称和任务,主查询从上到下阅读起来像一个故事。PostgreSQL 还支持递归 CTE(WITH RECURSIVE),这使你可以只用 SQL 遍历树、图和链。

本章要点

  • 使用 WITH name AS (...) 定义 CTE
  • 在一个查询中串联多个 CTE
  • 数据修改 CTE:在 WITH 中使用 INSERT、UPDATE、DELETE
  • 用递归 CTE 处理层级结构和图
  • 性能方面的权衡(MATERIALIZED / NOT MATERIALIZED)

示例

示例 38.1 公用表表达式(WITH)
-- A single CTE
WITH spend AS (
  SELECT customer_id, sum(total) AS total_spent
  FROM orders_example
  GROUP BY customer_id
)
SELECT c.name, COALESCE(s.total_spent, 0) AS spent
FROM customers_example c
LEFT JOIN spend s ON s.customer_id = c.id
ORDER BY c.id;

-- Multiple CTEs, each named and easy to read
WITH per_customer AS (
  SELECT customer_id, sum(total) AS total_spent, count(*) AS order_count
  FROM orders_example
  GROUP BY customer_id
),
big_spenders AS (
  SELECT * FROM per_customer WHERE total_spent > 30
)
SELECT c.name, b.total_spent, b.order_count
FROM big_spenders b
JOIN customers_example c ON c.id = b.customer_id
ORDER BY b.total_spent DESC;

-- Data-modifying CTE: archive then return what was archived
DROP TABLE IF EXISTS orders_archive;
CREATE TABLE orders_archive (LIKE orders_example INCLUDING ALL);

WITH archived AS (
  DELETE FROM orders_example
  WHERE total < 20
  RETURNING *
)
INSERT INTO orders_archive
SELECT * FROM archived
RETURNING id;

-- RECURSIVE CTE: walk an employee hierarchy
WITH RECURSIVE reports AS (
  -- anchor: top-level employees (no manager)
  SELECT id, name, manager_id, 1 AS depth
  FROM employees_example
  WHERE manager_id IS NULL
  UNION ALL
  -- recursive step: rows whose manager appears in the previous level
  SELECT e.id, e.name, e.manager_id, r.depth + 1
  FROM employees_example e
  JOIN reports r ON e.manager_id = r.id
)
SELECT depth, name FROM reports ORDER BY depth, id;

CTE 就是一个命名子查询。你可以定义多个 CTE,用逗号分隔,而且每个 CTE 都可以引用前面定义的 CTE。按从上到下的顺序阅读,每一步都是一个清晰的逻辑单元。与深层嵌套的派生表集合相比,CTE 通常更容易理解,也更容易修改。

数据修改 CTE 是 PostgreSQL 的特色:带有 RETURNING 的 INSERT、UPDATE 或 DELETE 可以作为 CTE 的主体,其返回的行会流入下一条语句——整个过程以原子方式在一次往返中完成。上面展示的“从一张表删除,再插入另一张表”归档模式是典型示例。

递归 CTE 由 UNION ALL 组合成两个部分:一个生成初始行的锚点查询,以及一个引用 CTE 名称来添加更多行的递归查询。PostgreSQL 会持续迭代,直到不再产生新行。可以用它们处理层级结构(员工 → 经理)、图遍历(查找所有传递依赖)和序列生成器(数字、日期)。

运行示例

运行方式(需要前面课程中的表):

$ psql -f source/cte.sql postgres
 name  | spent
-------+-------
 Alice | 80.00
 Bob   |  0.00
 Carol |  0.00
(3 rows)
 ...
 depth | name
-------+-------
     1 | Alice
     2 | Bob
     2 | Carol
     3 | Dave
     3 | Eve
(5 rows)

常见陷阱

  • 在 PostgreSQL 12 及更高版本中,一个只引用一次、无副作用的非递归 SELECT CTE 通常会折叠到其父查询中——规划器会像处理子查询一样处理它。如果你想要较早版本“始终物化”的行为,请写成 WITH x AS MATERIALIZED (...)。
  • WITH RECURSIVE 同时接受 UNION ALL 和 UNION。UNION 会消除完整行的重复项,包括此前生成的行;UNION ALL 则会保留它们。根据递归方式及其终止条件作出选择。
  • 不会收敛的递归 CTE(递归步骤持续添加行)会形成无限循环。查询可能无限运行、溢写到磁盘,或失败;work_mem 不是执行限制。拿不准时,请添加终止条件(例如 WHERE depth < 100)。
  • WITH 中的数据修改 CTE 彼此之间的求值顺序未指定——不要依赖它们之间的副作用。主语句与它们共享同一快照,且主语句与这些 CTE 之间的修改顺序没有保证。应通过 RETURNING 传递数据,而不要依赖可见的副作用。
提示

当查询超过约 20 行,或包含两个以上派生表时,把它重构为命名 CTE 几乎总是更好。你甚至可以暂时保留一个 CTE,把另一个内联,看看代价来自哪里。

动手练习

写一个 CTE 计算订单总额的平均值,然后在主查询中显示每个订单与该平均值的差值。然后试试用递归 CTE 生成 1 到 10 的数字(WITH RECURSIVE t(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM t WHERE n < 10) SELECT * FROM t;)。

下载

SQL 文件:cte.sql · 全部示例文件 · 准备工作

39 窗口函数

对行进行排名,并计算累计值、相邻值和窗口帧。

窗口函数使用一个窗口为每一行计算一个值——窗口由 OVER (...) 子句定义,是一组相关行。GROUP BY 会折叠行,而窗口函数会在原始列旁边为每一行添加一个结果。排名、累计值、移动平均、百分位区间、与“上一行”比较等场景,都适合使用窗口函数;凡是过去需要在应用代码中通过排序和迭代完成的任务,窗口函数都可以胜任。

本章要点

  • 窗口函数的形式:f() OVER (PARTITION BY ... ORDER BY ...)
  • 最有用的函数:row_number、rank、dense_rank、lag、lead、sum、avg、first_value、last_value
  • OVER 内的分区与排序
  • 使用 WINDOW name AS (...) 复用窗口
  • 窗口帧子句:ROWS BETWEEN ... AND ...

示例

示例 39.1 窗口函数
-- Numbering rows within each partition
SELECT customer_id, id AS order_id, total,
       row_number() OVER (PARTITION BY customer_id ORDER BY total DESC) AS rn,
       rank()       OVER (PARTITION BY customer_id ORDER BY total DESC) AS rnk
FROM orders_example
ORDER BY customer_id, rn;

-- "Most expensive order per customer" — keep rn = 1
WITH ranked AS (
  SELECT *,
         row_number() OVER (PARTITION BY customer_id ORDER BY total DESC) AS rn
  FROM orders_example
)
SELECT customer_id, id, total FROM ranked WHERE rn = 1;

-- lag / lead: look at previous / next row in the partition
SELECT customer_id, id, total,
       lag(total)  OVER (PARTITION BY customer_id ORDER BY id) AS prev_total,
       lead(total) OVER (PARTITION BY customer_id ORDER BY id) AS next_total
FROM orders_example
ORDER BY customer_id, id;

-- Running total (a sum is also a window function)
SELECT customer_id, id, total,
       sum(total) OVER (PARTITION BY customer_id ORDER BY id) AS running_total
FROM orders_example
ORDER BY customer_id, id;

-- Reusable window
SELECT id, total,
       sum(total) OVER w AS running,
       avg(total) OVER w AS running_avg
FROM orders_example
WINDOW w AS (PARTITION BY customer_id ORDER BY id)
ORDER BY customer_id, id;

-- Frame clause: 3-row trailing average
SELECT id, total,
       avg(total) OVER (
         PARTITION BY customer_id
         ORDER BY id
         ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
       ) AS trailing_avg
FROM orders_example
ORDER BY customer_id, id;

窗口由三个部分定义:

  1. PARTITION BY ——将行拆分成相互独立的组。与 GROUP BY 类似,但每个组都会保留其中的行。
  2. ORDER BY ——对每个分区内的行排序。它决定 row_number、lag、累计值等结果。
  3. 窗口帧子句(可选)——ROWS BETWEEN ... AND ... 或 RANGE BETWEEN ...。它定义当前行周围哪些行属于窗口。不同函数族的默认值不同:排名函数会忽略窗口帧;带有 ORDER BY 时,默认窗口帧包含从分区开头到当前行所在同等行组末尾的所有行;不带 ORDER BY 时,窗口覆盖整个分区。

row_number、rank 和 dense_rank 在存在并列时的差异很重要。row_number 总是分配 1、2、3、……,并列时任意决定先后。rank 分配 1、2、2、4(并列后有间断)。dense_rank 分配 1、2、2、3(没有间断)。

“每组取前 N 行”模式(PARTITION BY group_col ORDER BY rank_col DESC + filter rn = 1)是 row_number 的典型用法。它替代了繁琐的自连接和子查询。

示例数据

第 38 章归档了第 3 个订单。本章输出沿用该章归档后的状态,目前剩下两个订单。重新运行第 29 章会恢复原来的三个订单。

运行示例

运行方式(需要 orders_example):

$ psql -f source/window-functions.sql postgres
 customer_id | order_id | total | rn | rnk
-------------+----------+-------+----+-----
           1 |        1 | 50.00 |  1 |   1
           1 |        2 | 30.00 |  2 |   2
(2 rows)
 ...

常见陷阱

  • 窗口函数不能出现在 WHERE 或 HAVING 中(它们在这些子句之后计算)。请把查询包在 CTE 或子查询中,再在外层过滤。
  • sum() OVER (ORDER BY ...) 的默认窗口帧是 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW——ORDER BY 值相等的同等行会被计入同一个累计值。若要严格按行累计,请使用 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。
  • 如果 OVER 内没有 ORDER BY,lag / lead 会使用未指定的行顺序,PostgreSQL 也不会发出警告。请指定排序顺序和任何用于打破并列的排序键。
  • 性能:窗口函数会对分区排序。如果在 (partition_col, order_col) 上有匹配的索引,就可以跳过排序。
提示

“显示相对于上一行的变化”可写为 total - lag(total) OVER (ORDER BY ...)。“显示占总数的百分比”可写为 100.0 * total / sum(total) OVER ()。两者都用一个易读的表达式替代自连接或子查询。

动手练习

使用 ntile(4) 把每个订单分到四分位组:ntile(4) OVER (ORDER BY total)。然后在时间序列表中,用 lag(daily_total) OVER (ORDER BY day) 计算“今天的总额与昨天的总额”。最后试试 first_value(total) OVER (PARTITION BY customer_id ORDER BY id),看看每一行中显示的该客户首笔订单金额。

下载

SQL 文件:window-functions.sql · 全部示例文件 · 准备工作

40 字符串与数值函数

转换字符串,并使用常见的数值、舍入和随机数函数。

PostgreSQL 内置了数百个函数。字符串函数和数学函数涵盖了日常使用中的大多数需求:大小写转换、填充、去除空白、拆分、替换、哈希计算;舍入、绝对值、取模、乘方和随机数。熟悉这些常用函数后,你会经常用到它们。

本章要点

  • 常见字符串函数:upper、lower、length、substring、position、trim、replace、split_part
  • 使用 || 和 concat 进行字符串拼接
  • 使用 ~、~*、regexp_replace、regexp_match 处理正则表达式
  • 常见数值函数:abs、round、ceil、floor、mod、power、random
  • 用于发现更多函数的 \df 元命令

示例

示例 40.1 字符串与数值函数
-- Case and length
SELECT upper('hello') AS u, lower('HELLO') AS l, length('hello') AS len;

-- Substring: SQL syntax and function syntax
SELECT substring('postgresql' FROM 1 FOR 8) AS sql_form,
       substring('postgresql', 1, 8)        AS fn_form;

-- Position: 1-based index, 0 if not found
SELECT position('gre' IN 'postgresql') AS pos;

-- Trim and replace
SELECT trim('  hi  ')                         AS trimmed,
       trim(both '/' FROM '/path/to/file/')   AS path_trim,
       replace('a-b-c', '-', '.')             AS replaced;

-- Splitting
SELECT split_part('a.b.c.d', '.', 2)                  AS second_part,
       string_to_array('a,b,c', ',')                  AS as_array;

-- Concatenation: || vs concat. concat tolerates NULL; || does not.
SELECT 'a' || NULL AS pipe_null,
       concat('a', NULL, 'b') AS concat_null;

-- Regex: ~ matches, ~* is case-insensitive
SELECT 'PostgreSQL' ~ 'gres' AS match,
       'PostgreSQL' ~* 'POSTGRE' AS imatch,
       regexp_replace('a1b2c3', '[0-9]', '#', 'g') AS no_digits;

-- Numeric: abs, round, ceil, floor, mod, power, random
SELECT abs(-5)                AS a,
       round(3.14159, 2)      AS r,
       ceil(2.1)              AS c,
       floor(2.9)             AS f,
       mod(10, 3)             AS m,
       power(2, 10)           AS p,
       round((random() * 100)::numeric, 2) AS rand;

substring 有两种形式:SQL 标准的 substring(str FROM start FOR n) 和函数调用形式 substring(str, start, n)。两者的下标都从 1 开始。

concat 和 || 对 NULL 的处理不同:'a' || NULL 的结果是 NULL(任一操作数都会使整个表达式变为 NULL),而 concat('a', NULL, 'b') 返回 'ab'——concat 会把 NULL 当作空字符串处理。请明确选择所需的行为。

正则表达式操作符 ~ 和 ~* 是用于匹配的简写。regexp_replace(text, pattern, replacement [, flags]) 是执行替换的主要函数;'g' 标志表示“替换所有匹配项”(默认只替换第一个)。regexp_match 返回捕获组。

数值函数大多顾名思义。random() 返回 [0, 1) 范围内的 double precision;如需生成从 0 到 n − 1 的整数,请使用 floor(random() * n)::integer。直接进行类型转换会舍入,而不是截断。若需要密码学安全的随机数,请使用 pgcrypto 提供的 gen_random_bytes。

运行示例

运行方式:

$ psql -f source/string-and-numeric-functions.sql postgres
   u   |   l   | len
-------+-------+-----
 HELLO | hello |   5
(1 row)
 ...

常见陷阱

  • 不要把 substring 的下标与以 0 为起点的编程语言混淆——PostgreSQL 的下标从 1 开始。
  • replace 会按字面匹配字符串进行替换,不是正则表达式。处理正则表达式时,请使用 regexp_replace。
  • random() 不具有密码学安全性。对于涉及安全的场景,请使用 pgcrypto 的 gen_random_bytes。
  • length 统计字符数;octet_length 统计字节数。对于多字节文本(UTF-8),两者的结果不同。
  • round(numeric) 遇到舍入中点时向远离零的方向舍入:round(0.5) 的结果是 1,round(-0.5) 的结果是 -1。round(double precision) 通常将舍入中点舍入到偶数,但具体行为取决于平台。
提示

在交互式 psql 中,\df *upper* 会列出名称中包含“upper”的所有函数。\df+ upper 会显示源代码和所有者。这是发现可用函数的绝佳方式。

动手练习

生成一个标签:SELECT upper(name) || ' [' || length(name) || ']' FROM fruits;。将所有平均值舍入:SELECT customer_id, round(avg(total), 2) FROM orders_example GROUP BY customer_id;。使用正则表达式查找以元音开头的名称:SELECT name FROM fruits WHERE name ~* '^[aeiou]';。

下载

SQL 文件:string-and-numeric-functions.sql · 全部示例文件 · 准备工作

41 日期函数与 COALESCE

将日期按时间桶分组、提取字段,并用 COALESCE 提供默认值。

PostgreSQL 提供了丰富的日期和时间运算。date_trunc 将时间截断到指定精度;extract 提取单个字段;interval 允许你加减时间间隔;date_part、age、to_char、to_date 涵盖其余需求。COALESCE 虽然并非日期专用,但很适合与日期逻辑搭配,用来为可能为空的值指定默认值。

本章要点

  • current_date、current_timestamp、now()、clock_timestamp()
  • date_trunc(unit, ts):截断到年、月、日、小时等精度
  • extract(field FROM ts):提取年、月、星期几等字段
  • interval 运算与格式化
  • 使用 COALESCE 为 NULL 提供回退值

示例

示例 41.1 日期函数与 COALESCE
-- "Now" functions
SELECT current_date AS today, current_timestamp AS now_tz, clock_timestamp() AS wall_clock;

-- date_trunc: truncate to a precision
SELECT date_trunc('month', current_timestamp) AS month_start,
       date_trunc('day',   current_timestamp) AS day_start,
       date_trunc('hour',  current_timestamp) AS hour_start;

-- Interval arithmetic
SELECT current_date + interval '7 days'    AS in_a_week,
       current_date - interval '1 month'   AS a_month_ago,
       current_timestamp + interval '90 minutes' AS in_90m;

-- Difference between two timestamps as an interval
SELECT age(timestamptz '2025-01-01', timestamptz '2024-03-15') AS gap;

-- extract: pull a field
SELECT extract(year      FROM current_date) AS year,
       extract(month     FROM current_date) AS month,
       extract(dow       FROM current_date) AS day_of_week,  -- 0=Sunday
       extract(isodow    FROM current_date) AS iso_dow,      -- 1=Monday
       extract(week      FROM current_date) AS iso_week;

-- Formatting and parsing
SELECT to_char(current_timestamp, 'YYYY-MM-DD HH24:MI:SS TZ') AS formatted,
       to_date('2024-01-15', 'YYYY-MM-DD')                    AS parsed;

-- COALESCE: first non-NULL
SELECT COALESCE(NULL, NULL, 'default') AS c1,
       COALESCE(NULL, 42)              AS c2,
       COALESCE(due_at, current_timestamp + interval '7 days') AS due_or_default
FROM tasks_example
ORDER BY id;

date_trunc('month', ts) 是分析中按月“分桶”的标准方式。结果是该月第一天午夜的 timestamp(或 timestamptz)。使用 extract(isodow FROM ts) 按星期几分组,使用 date_trunc('week', ts) 按日历周分组,或者使用 date_bin(PostgreSQL 14+)划分固定宽度的时间桶。

extract 返回 numeric(较早版本中为 double precision)。dow 字段以星期日为 0;isodow 以星期一为 1(与 ISO 8601 一致)。如果拿不准,isodow 更符合大多数“星期一是一周的开始”的约定。

age(end, start) 返回按年、月、日归一化的时间间隔,适合用于显示(“3 年、2 个月”)。如果需要原始差值,可以直接减去两个 timestamptz 值,得到以日、小时和秒表示的时间间隔。

COALESCE 按从左到右的顺序检查参数,并返回第一个非 NULL 值。它是处理“默认值”的标准工具:COALESCE(user.display_name, user.email, 'Anonymous')。

运行示例

运行方式:

$ psql -f source/date-functions-and-coalesce.sql postgres
    today    |          now_tz             |       wall_clock
-------------+-----------------------------+-----------------------------
 2026-xx-xx  | 2026-xx-xx xx:xx:xx.xxxxxx+00 | 2026-xx-xx xx:xx:xx.xxxxxx+00
(1 row)
 ...

常见陷阱

  • now() 返回事务开始时间。在长事务中,每次调用都会返回相同的值。需要真实的挂钟时间时,请使用 clock_timestamp()。
  • 混用 timestamp 和 timestamptz 时,会静默使用会话的时区。请明确指定:在类型转换或显示时使用 at time zone 'UTC',并尽可能将数据存储为 timestamptz。
  • date_trunc 返回与输入相同类型的值——date_trunc('day', timestamptz) 返回的是 timestamptz,而不是 date。如果需要 date,请进行类型转换。
  • 在 PostgreSQL 17 中,extract(epoch FROM ts) 返回自 Unix 纪元起经过的 numeric 秒数;date_part('epoch', ts) 返回 double precision。与使用 Unix 时间的代码互操作时,这一点很有用。
  • COALESCE 使用短路求值,因此,当更早的参数非 NULL 时,后续参数中的有副作用表达式不会被求值。
提示

对于“下个月的第一天”,可以使用 date_trunc('month', current_date) + interval '1 month'。对于“本月最后一天”,可以使用 date_trunc('month', current_date) + interval '1 month' - interval '1 day'。这些习惯用法在报表查询中随处可见。

动手练习

计算当前年份的第一天(date_trunc('year', current_date))。将时间戳格式化为 ISO 8601:to_char(current_timestamp, 'YYYY-MM-DD"T"HH24:MI:SSOF')。在针对 tasks_example 的 SELECT 查询中使用 COALESCE,显示 due_at 或 ’no deadline’。

下载

SQL 文件:date-functions-and-coalesce.sql · 全部示例文件 · 准备工作

42 CASE

使用 CASE 表达条件值,并比较使用 FILTER 的条件聚合。

CASE 是 SQL 的条件表达式——最接近 if/else 的结构。它有两种形式。搜索式 CASE(CASE WHEN cond THEN ... END)会计算任意布尔条件。简单 CASE(CASE expr WHEN value THEN ... END)会将一个表达式与特定值进行比较。只要允许使用表达式,就可以使用 CASE——包括 SELECT、WHERE、ORDER BY、GROUP BY,以及聚合函数内部的表达式。

本章要点

  • 用于一般条件的搜索式 CASE
  • 用于值相等判断的简单 CASE
  • ELSE 以及省略它时的结果
  • 使用 CASE 将行按组分桶
  • CASE 在条件聚合中的作用(以及 FILTER 何时可以替代它)

示例

示例 42.1 CASE
-- Searched CASE (most flexible)
SELECT id, name,
  CASE
    WHEN id <= 2  THEN 'low'
    WHEN id <= 4  THEN 'mid'
    ELSE              'high'
  END AS tier
FROM fruits
ORDER BY id
LIMIT 5;

-- Simple CASE (equality)
SELECT name,
  CASE name
    WHEN 'apricot' THEN 'A'
    WHEN 'blueberry' THEN 'B'
    ELSE '?'
  END AS code
FROM fruits
ORDER BY id
LIMIT 4;

-- CASE in WHERE
SELECT id, name
FROM fruits
WHERE CASE WHEN id < 5 THEN id > 1 ELSE false END
ORDER BY id;

-- CASE for bucketing and counting per bucket
SELECT
  CASE
    WHEN id <= 2 THEN 'low'
    WHEN id <= 4 THEN 'mid'
    ELSE              'high'
  END AS tier,
  count(*) AS cnt
FROM fruits
GROUP BY tier
ORDER BY tier;

-- Conditional aggregation: CASE inside an aggregate vs. FILTER (cleaner)
SELECT
  sum(CASE WHEN total > 30 THEN total ELSE 0 END) AS big_sum_old_style,
  sum(total) FILTER (WHERE total > 30)            AS big_sum_filter
FROM orders_example;

搜索式是主力形式。PostgreSQL 按从上到下的顺序计算条件,并返回第一个为真的条件所对应的结果。如果没有条件匹配且没有 ELSE,结果就是 NULL——有时有用,有时会造成错误。

简单形式是相等比较的简写:CASE name WHEN 'a' THEN ... END 与 CASE WHEN name = 'a' THEN ... END 等价。当需要将多个离散值映射到结果时,这种写法很简洁。

条件聚合——只统计或求和匹配的行——过去需要使用 sum(CASE WHEN cond THEN val ELSE 0 END)。sum(val) FILTER (WHERE cond) 可以直接表达这个条件。当存在输入行但没有任何行匹配时,它可能返回 NULL,而 sum(CASE WHEN cond THEN val ELSE 0 END) 会返回 0。如果希望空结果为 0,请使用 COALESCE。

运行示例

运行方式:

$ psql -f source/case.sql postgres
 id |    name    | tier
----+------------+------
  1 | apricot    | low
  2 | blueberry  | low
  3 | chestnut   | mid
  4 | damson     | mid
  5 | elderberry | high
(5 rows)
 ...

常见陷阱

  • 所有 THEN/ELSE 分支都必须具有共同类型——CASE WHEN ... THEN 1 ELSE 'x' END 会报错。混用类型时,请显式进行类型转换:CASE WHEN ... THEN 1::text ELSE 'x' END。
  • 忘记 END 是最常见的语法错误。
  • 不写 ELSE 时,如果没有分支匹配,结果就是 NULL。如果这让人意外,请添加显式的 ELSE ...。
  • 在 WHERE 中使用 CASE 虽然可行,但通常没有必要——大多数条件直接写成布尔表达式会更清楚。
提示

CASE 和 COALESCE 有重叠之处,但并不相同。对于“第一个不是 NULL 的值”,COALESCE(a, b, c) 写起来更短;当条件不只是“是否为 NULL”时,CASE 更合适。

动手练习

使用 CASE 将 id 映射为类似 ‘small’ / ‘medium’ / ’large’ 的标签,并按标签计数。在 ORDER BY 中使用 CASE,按自定义顺序排列优先级:ORDER BY CASE priority WHEN 'high' THEN 1 WHEN 'mid' THEN 2 ELSE 3 END。然后将 sum(CASE WHEN ...) 重写为 sum(...) FILTER (WHERE ...)。

下载

SQL 文件:case.sql · 全部示例文件 · 准备工作

43 CREATE INDEX

创建普通索引、多列索引、部分索引和表达式索引,并使用适当的锁。

索引是一种辅助数据结构,使 PostgreSQL 可以按列值查找行,而无需扫描整张表。主键会自动创建索引。对于其他列,你可以选择:CREATE INDEX idx ON table (col)。默认且最常用的索引类型是 B-树,它可以加速等值(=)、范围(<、>、BETWEEN)和前缀(LIKE 'abc%')查找,以及 ORDER BY。PostgreSQL 还提供 GIN(用于全文检索、数组和 jsonb)、GiST(用于范围和几何数据)、BRIN(用于巨大且天然有序的表)、Hash 和 SP-GiST。

本章要点

  • 在列或一组列上创建 B-树索引
  • 命名和列出索引
  • 多列索引的列顺序及其含义
  • 部分索引和表达式索引
  • 生产环境中的 CREATE INDEX CONCURRENTLY

示例

示例 43.1 创建索引
-- Single-column B-tree index
CREATE INDEX IF NOT EXISTS fruits_name_idx ON fruits (name);

-- Multi-column index. Order matters!
-- Useful for queries that filter by customer_id, or by (customer_id, id).
CREATE INDEX IF NOT EXISTS orders_customer_idx
  ON orders_example (customer_id, id);

-- Partial index: only index rows matching a predicate. Tiny and fast.
CREATE INDEX IF NOT EXISTS tasks_open_idx
  ON tasks_example (id) WHERE done = false;

-- Expression index: lets queries that use LOWER(email) hit an index.
-- (using a temp table to avoid clutter)
DROP TABLE IF EXISTS people_example;
CREATE TABLE people_example (id integer PRIMARY KEY, email text);
INSERT INTO people_example VALUES (1, 'Alice@example.com'), (2, 'bob@example.com');
CREATE INDEX people_lower_email_idx ON people_example (lower(email));

-- Use it
SELECT * FROM people_example WHERE lower(email) = 'alice@example.com';

-- List indexes on a table
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename IN ('fruits', 'orders_example', 'tasks_example', 'people_example')
ORDER BY tablename, indexname;

B-树索引是一棵有序的平衡树,支持 =、<、>、BETWEEN、IS NULL、ORDER BY 和 LIKE 'prefix%'(锚定前缀)。它是默认类型,在典型模式中约占索引总数的 90%。

多列索引遵循最左前缀规则:对于 (a, b, c) 上的索引,可以服务于按 a、(a, b) 或 (a, b, c) 过滤的查询,也可以针对只涉及 b 或 (b, c) 的谓词进行扫描,不过没有前导列限制时,可能需要扫描索引的更大部分。请根据实际的过滤条件和排序需求选择列顺序。PostgreSQL 18 为其中一些情况增加了跳跃扫描优化。

部分索引只为谓词为真的行建立索引。它非常适合“is_active = true”这类查询——索引更小、速度更快,也不必覆盖大多数不需要关注的行。表达式索引可以为转换后的值(lower(email)、(data ->> 'name'))建立索引。它是让 WHERE lower(email) = 'x' 通过索引快速执行的唯一方式。

CREATE INDEX CONCURRENTLY 建立索引时不会获取表级写锁。它需要更长时间(对表进行两次遍历),但不会阻塞写入——生产环境中需要这样做。

运行示例

运行方式(需要前面章节创建的表):

$ psql -f source/create-index.sql postgres
CREATE INDEX
CREATE INDEX
CREATE INDEX
DROP TABLE
CREATE TABLE
INSERT 0 2
CREATE INDEX
 id |        email
----+---------------------
  1 | Alice@example.com
(1 row)
 ...

常见陷阱

  • 索引过多:插入和许多更新都会创建索引条目;HOT 更新可以避免索引工作,而死索引条目的清理通常会推迟到 vacuum。对于写入繁重的表,实际不用的索引纯属额外开销。
  • 单独为低基数列建索引(例如 boolean is_active)通常没有帮助——规划器无论如何都会执行顺序扫描。请与另一列组合,或使用部分索引。
  • 索引命名不一致会让调试变得痛苦。请采用类似 tablename_column_idx 的命名约定。
  • 不带 CONCURRENTLY 的 CREATE INDEX 会获取 SHARE 锁:普通读取可以继续,但写入会等待。写入必须继续时,请使用 CONCURRENTLY;它不能在事务块内运行,并且有额外的工作和失败处理要求。
提示

pg_stat_user_indexes 会显示每个索引的使用频率(idx_scan)。请定期检查;如果某个索引连续几周扫描次数为零,就可以考虑删除它。

动手练习

只在 customer_id 上创建一个索引,再在 (customer_id, total) 上创建另一个。查看 EXPLAIN SELECT * FROM orders_example WHERE customer_id = 1 ORDER BY total;,看看规划器选择哪一个。删除未使用的那个。

下载

SQL 文件:create-index.sql · 全部示例文件 · 准备工作

44 何时创建索引

根据真实查询选择索引,并检查 PostgreSQL 是否使用它们。

索引在被索引列上进行过滤、排序或连接时很有帮助;它们会占用磁盘空间,并拖慢 INSERT/UPDATE/DELETE;对于小表,规划器可能无论如何都会执行顺序扫描。所以问题不是“该列是否应该有索引?”——而是“这个查询需要索引吗,哪一个能帮上忙?”答案来自 EXPLAIN 和 pg_stat_user_indexes。

本章要点

  • 基本权衡:索引 = 读取更快、写入更慢、占用更多磁盘空间
  • 哪些查询会受益(等值、范围、连接、ORDER BY)
  • 为什么低基数列很少能从普通 B-树中受益
  • 使用 pg_stat_user_indexes 检测哪些索引未被使用
  • ANALYZE 在保持统计信息最新方面的作用

示例

示例 44.1 何时创建索引
-- Compare plans: without an index vs. with one
DROP INDEX IF EXISTS fruits_name_idx;
EXPLAIN (FORMAT TEXT) SELECT * FROM fruits WHERE name = 'apricot';

CREATE INDEX fruits_name_idx ON fruits (name);
ANALYZE fruits;   -- refresh planner statistics

EXPLAIN (FORMAT TEXT) SELECT * FROM fruits WHERE name = 'apricot';

-- Range queries also benefit
EXPLAIN (FORMAT TEXT) SELECT * FROM fruits WHERE id BETWEEN 2 AND 4;

-- Index usage stats — which indexes is the database actually using?
SELECT relname AS table_name,
       indexrelname AS index_name,
       idx_scan,
       idx_tup_read
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
ORDER BY idx_scan DESC
LIMIT 10;

B-树索引在以下情况下有帮助:

  • 被索引列上的等值查找(WHERE x = ?)。
  • 范围扫描(WHERE x BETWEEN ? AND ?、WHERE x < ?)。
  • 在被索引列上连接(JOIN on parent.id = child.parent_id)。始终为外键列建立索引。
  • 与索引列及其方向匹配的 ORDER BY。
  • 锚定前缀 LIKE(LIKE 'abc%')。

在以下情况下没有帮助(或帮助很小):

  • 查询无论如何都会扫描表中的大部分数据(不存在通用的百分比阈值;行宽、相关性、缓存状态和代价都很重要)。
  • 列的非重复值很少(is_active boolean——索引无法帮助常见分支)。
  • 条件对列应用了函数(WHERE lower(email) = ...)——除非你有一个表达式索引。
  • 表非常小(顺序扫描比有额外开销的索引查找更快)。

在小表上,即使存在索引,规划器也经常更倾向于使用 Seq Scan——因为直接读取一两页中的整张表,比通过索引查找更便宜。这是一个特性,而不是缺陷。

ANALYZE table 会更新规划器用来选择计划的统计信息。大量数据变更后请运行它;自动清理(autovacuum)会根据行变更阈值自动运行,但有时仍需要手动运行。

运行示例

运行方式(需要 fruits):

$ psql -f source/when-to-index.sql postgres
DROP INDEX
                      QUERY PLAN
-------------------------------------------------------
 Seq Scan on fruits  (cost=0.00..1.10 rows=1 width=36)
   Filter: (name = 'apricot'::text)
(2 rows)

CREATE INDEX
ANALYZE
                      QUERY PLAN
-------------------------------------------------------
 Seq Scan on fruits  (cost=0.00..1.10 rows=1 width=12)
   Filter: (name = 'apricot'::text)
(2 rows)
 ...

常见陷阱

  • 为将来“可能”运行的查询添加索引。每个索引都会让每次写入都变慢。请根据真实工作负载建立索引。
  • 某些索引是另一个索引的子集。如果存在 (a, b),对于 a 的等值查找,(a) 就是多余的。同一个前导列也能支持范围扫描。在认定某个索引冗余前,请检查大小、唯一性、谓词和工作负载。
  • 对被索引列使用函数会阻止使用索引:WHERE upper(name) = 'X' 会忽略 name 上的索引——请使用表达式索引或改写查询。
  • 随时间增长而膨胀的索引:定期执行 REINDEX CONCURRENTLY 可以让它们保持紧凑。
提示

教科书式规则:每个外键列都应建立索引。没有索引的连接很慢,级联删除会扫遍所有数据。Postgres 不会像对待主键那样自动为外键列建立索引。

动手练习

在创建索引前后运行 EXPLAIN ANALYZE(它会实际执行查询)进行比较——行数和耗时会说明真相。然后创建一个故意效果很差的索引(例如建在 boolean 列上),观察规划器忽略它。

下载

SQL 文件:when-to-index.sql · 全部示例文件 · 准备工作

45 EXPLAIN 与查询计划

阅读执行计划,并将估算的代价与实际行数、耗时和缓冲区进行比较。

EXPLAIN 展示 PostgreSQL 计划执行查询的方式——它会使用哪些索引、采用哪些连接策略,以及预计每一步会得到多少行。EXPLAIN ANALYZE 会实际运行查询,并在估算值旁报告实际行数和耗时。估算值与实际值之间的差距能告诉你规划器的统计信息是否准确;要找出慢查询,就需要熟练阅读这些计划。

本章要点

  • 自底向上阅读查询计划
  • 估算行数/耗时与实际行数/耗时的区别
  • 最常见的节点类型:Seq Scan、Index Scan、Bitmap Heap Scan、Nested Loop、Hash Join、Merge Join、Sort、Aggregate
  • EXPLAIN ANALYZE、BUFFERS 及其他选项
  • 估算值严重偏离时如何修复

示例

示例 45.1 EXPLAIN 与查询计划
-- Plain EXPLAIN: estimates only
EXPLAIN SELECT * FROM orders_example WHERE customer_id = 1;

-- EXPLAIN ANALYZE: actually runs the query, shows real timing
EXPLAIN ANALYZE SELECT * FROM orders_example WHERE customer_id = 1;

-- With BUFFERS: how many pages came from cache vs. disk
EXPLAIN (ANALYZE, BUFFERS)
  SELECT c.name, count(o.id) AS orders
  FROM customers_example c
  LEFT JOIN orders_example o ON o.customer_id = c.id
  GROUP BY c.name
  ORDER BY orders DESC;

-- A more complex plan to study
EXPLAIN ANALYZE
  WITH spend AS (
    SELECT customer_id, sum(total) AS s FROM orders_example GROUP BY customer_id
  )
  SELECT c.name, s.s
  FROM customers_example c
  LEFT JOIN spend s ON s.customer_id = c.id
  ORDER BY s.s DESC NULLS LAST;

应从最内层、最深的节点向上读取计划。每一行都是一个步骤;子步骤的输出会供父步骤使用。每一行中的字段含义如下:

  • cost=startup..total——规划器的估算值,使用任意单位。第一个数字表示第一行准备就绪的时间;第二个数字表示最后一行准备就绪的时间。
  • rows=N——估算的行数。
  • width=N——平均行宽,单位为字节。
  • actual time=startup..total ms(使用 ANALYZE 时)——实际耗时,按每次循环分别计算。
  • rows=N actual——实际行数。
  • loops=N——该节点执行的次数(在嵌套循环连接中,内侧一方会针对外侧的每一行运行一次)。

常见的扫描类型:

  • Seq Scan:读取整张表。对于小表没问题;对于大表,除非确实需要每一行,否则就不理想。
  • Index Scan:遍历索引,从表中取出匹配的行。匹配行很少时最佳。
  • Index Only Scan:索引包含所需的值,但可能仍需访问堆来检查可见性,除非可见性映射将堆页标记为全可见。
  • Bitmap Heap Scan / Bitmap Index Scan:先构建匹配页的位图,然后读取这些页。匹配行较多时很有用。

连接策略:

  • Nested Loop:对外侧的每一行,查找匹配的内侧行。外侧结果集较小且内侧有索引时速度很快。
  • Hash Join:为较小的一侧构建哈希表,再用较大的一侧进行探测。中等规模连接速度很快。
  • Merge Join:两侧都按连接键排序,并行遍历。两侧已经排序时最佳(例如由索引提供排序)。

如果估算的 rows=N 与实际的 rows=M actual 相差几个数量级,说明规划器使用的统计信息已经过时或具有误导性。运行 ANALYZE table 刷新统计信息。

运行示例

运行方式:

$ psql -f source/explain.sql postgres
                                  QUERY PLAN
-------------------------------------------------------------------------------
 Seq Scan on orders_example  (cost=0.00..1.04 rows=2 width=10)
   Filter: (customer_id = 1)
(2 rows)
 ...

常见陷阱

  • 从上到下而不是从下到上读取计划。叶节点才是实际数据源;上面的所有节点都在进行转换。
  • 不要把 cost= 当成绝对值。这些数字没有单位——只有在同一计划内相互比较才有意义。
  • EXPLAIN ANALYZE 会实际运行查询,包括任何 INSERT/UPDATE/DELETE。要预览写操作,请将其包在事务中并执行 ROLLBACK:BEGIN; EXPLAIN ANALYZE UPDATE ...; ROLLBACK;。
  • 小表会让优化失去意义。请使用有代表性的数据进行测试;对 10 行执行 Seq Scan 说明不了任何问题。
提示

将 EXPLAIN ANALYZE 计划粘贴到 https://explain.depesz.com/ 或 https://explain.dalibo.com/,即可获得突出显示最慢步骤的彩色可视化。对于生产环境调试不可或缺。

动手练习

删除一个索引,运行 EXPLAIN ANALYZE 并记下计划。重新创建它并再次运行,观察计划的变化。然后运行一个带连接的查询:EXPLAIN ANALYZE SELECT ... FROM customers_example c JOIN orders_example o ON o.customer_id = c.id;,并确定所选的连接策略。

下载

SQL 文件:explain.sql · 全部示例文件 · 准备工作

46 BEGIN、COMMIT、ROLLBACK

将变更分组到事务中,提交或回滚,并观察一次预期的失败。

事务将多条语句组合成一个全有或全无的单元。BEGIN(或 START TRANSACTION)启动事务;COMMIT 使变更永久生效;ROLLBACK 丢弃变更。在事务之外,每条语句都会在各自的隐式事务中运行——PostgreSQL 始终在事务中运行,你只需选择是否显式标记事务边界。事务让你能在多步变更中保持数据一致。

本章要点

  • 使用 BEGIN 开始事务,并以 COMMIT 或 ROLLBACK 结束
  • ACID 属性(尤其是原子性和隔离性)
  • 每条语句的隐式事务
  • 隔离级别:READ COMMITTED(默认)、REPEATABLE READ、SERIALIZABLE
  • ABORT / “事务已中止”状态

示例

示例 46.1 BEGIN、COMMIT 与 ROLLBACK
-- A transaction that we throw away
BEGIN;
DROP TABLE IF EXISTS tx_demo;
CREATE TABLE tx_demo (id integer PRIMARY KEY, val text);
INSERT INTO tx_demo VALUES (1, 'a');
ROLLBACK;

-- The table does not exist after rollback
SELECT count(*) FROM pg_tables WHERE tablename = 'tx_demo';

-- A transaction that we keep
BEGIN;
CREATE TABLE tx_demo (id integer PRIMARY KEY, val text);
INSERT INTO tx_demo VALUES (1, 'a'), (2, 'b');
COMMIT;

SELECT * FROM tx_demo ORDER BY id;

-- Set isolation level for the next transaction
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM tx_demo;       -- sees a consistent snapshot
COMMIT;

-- When a statement errors inside a transaction, the whole transaction is aborted.
-- Subsequent statements fail until you ROLLBACK.
BEGIN;
INSERT INTO tx_demo VALUES (1, 'duplicate');  -- PK violation
-- Any further statements here would fail with "current transaction is aborted".
ROLLBACK;

-- Cleanup
DROP TABLE tx_demo;

PostgreSQL 提供完整的 ACID 保证:

  • 原子性:事务中的所有变更要么全部生效,要么一项也不生效。
  • 一致性:数据库会检查约束,并从一个有效状态转移到另一个有效状态。
  • 隔离性:并发事务看不到彼此尚未提交的变更(严格程度因隔离级别而异)。
  • 持久性:在正常持久性设置下,成功的 COMMIT 会让已写入日志的变更在崩溃后仍然存在;synchronous_commit = off 等设置会改变这一保证。

默认隔离级别是 READ COMMITTED:每条语句看到的是它开始执行时的数据库快照。REPEATABLE READ 为整个事务提供一个快照——重复读取会看到相同的数据。SERIALIZABLE 会增加对串行化异常的检查,并可能回滚那些会产生非串行调度的事务。

PostgreSQL 的事务错误模型很严格:事务内的任何错误都会中止该事务。后续语句会失败,并显示*“current transaction is aborted, commands ignored until end of transaction block”*,直到执行 ROLLBACK。要在事务中途优雅地处理错误,请使用保存点(下一课)。

预期错误

本课特意插入一条会造成主键重复的记录,以演示事务失败。使用 psql 默认的 ON_ERROR_STOP=off 运行它,这样后续的 ROLLBACK 和清理操作才能执行。其他课程可以使用 -v ON_ERROR_STOP=1。

运行示例

运行方式:

$ psql -f source/begin-commit-rollback.sql postgres
BEGIN
DROP TABLE
CREATE TABLE
INSERT 0 1
ROLLBACK
 count
-------
     0
(1 row)
BEGIN
CREATE TABLE
INSERT 0 2
COMMIT
 id | val
----+-----
  1 | a
  2 | b
(2 rows)
 ...

常见陷阱

  • 在交互式 psql 中忘记 COMMIT,意味着断开连接时所做的一切都会被回滚。如果希望显式控制,请设置 \set AUTOCOMMIT off;默认的 AUTOCOMMIT on 会让每条语句在各自的隐式事务中运行。
  • 长时间运行的事务会持有行锁,并阻止自动清理回收死元组。请保持事务短小。
  • 在 PostgreSQL 中,把模式变更(DDL)和数据变更(DML)放在同一事务中没有问题——不同于 MySQL,DDL 也是事务性的。
  • 在 READ COMMITTED 下,同一事务中的两条语句可能看到同一行的不同值(如果并发事务在两条语句之间提交)。若要进行一致的多语句读取,请切换到 REPEATABLE READ。
提示

BEGIN; ... ROLLBACK; 可以在演练期间撤销事务性表变更,但它仍会获取锁并运行触发器。序列递增和外部副作用不会回滚。请使用临时数据库进行这些练习。

动手练习

清理过程中脚本会删除 tx_demo。先使用 CREATE TABLE tx_demo (id integer PRIMARY KEY, val text); INSERT INTO tx_demo VALUES (1, 'a'); 重新创建它。然后打开两个 psql 会话。在会话 A 中:BEGIN; UPDATE tx_demo SET val = 'x' WHERE id = 1;(先不要提交)。在会话 B 中:SELECT * FROM tx_demo;——你应该看到旧值。然后在会话 A 中执行 COMMIT;,会话 B 的下一次 SELECT 将看到新值。

下载

SQL 文件:begin-commit-rollback.sql · 全部示例文件 · 准备工作

47 保存点

使用保存点回滚事务的一部分,并从错误中恢复。

保存点是事务中的一个命名位置。你可以使用 ROLLBACK TO SAVEPOINT name 撤销该位置之后完成的工作,同时保留之前的全部工作。保存点是 PostgreSQL 实现类似嵌套事务语义的方式——当你想尝试一个有风险的步骤、从错误中恢复并继续执行,或者应用程序代码模拟嵌套事务时,它们很有用。

本章要点

  • 使用 SAVEPOINT name 设置保存点
  • 回滚到保存点
  • 释放保存点
  • 从事务中的错误恢复
  • 保存点与 BEGIN/COMMIT 的关系

示例

示例 47.1 保存点
-- Setup
BEGIN;
DROP TABLE IF EXISTS sp_demo;
CREATE TABLE sp_demo (id integer PRIMARY KEY);

INSERT INTO sp_demo VALUES (1);
SAVEPOINT after_one;

INSERT INTO sp_demo VALUES (2);
SAVEPOINT after_two;

INSERT INTO sp_demo VALUES (3);

-- Undo only the last insert
ROLLBACK TO SAVEPOINT after_two;

-- After rolling back to after_two, the table has 1 and 2 (not 3)
SELECT * FROM sp_demo ORDER BY id;

-- Release a savepoint when we are sure we won't need it
RELEASE SAVEPOINT after_one;

-- Recovering from an error mid-transaction
SAVEPOINT before_risky;
-- This insert will fail (duplicate)
DO $$ BEGIN
  INSERT INTO sp_demo VALUES (2);
EXCEPTION WHEN unique_violation THEN
  RAISE NOTICE 'duplicate caught — continuing';
END $$;
-- Or, without the PL/pgSQL: after a failing statement, you can ROLLBACK TO before_risky and keep going.

SELECT * FROM sp_demo ORDER BY id;

COMMIT;

DROP TABLE IF EXISTS sp_demo;

SAVEPOINT name 标记一个位置。ROLLBACK TO SAVEPOINT name 撤销保存点之后直到现在的所有操作,但事务会继续——你可以继续进行工作,最终执行 COMMIT。RELEASE SAVEPOINT name 会忘记该保存点(之后不能再回滚到它),但不会撤销任何工作。

应用框架通过保存点模拟“嵌套事务”:当应用代码已经处于事务中又调用 begin_transaction 时,驱动会设置保存点,而不是启动新的顶层事务。内部代码块中的“回滚”会回到该保存点。

错误恢复是一个重要场景。在事务中,任何错误都会使事务进入“中止”状态,此后每条语句都会失败,直到执行 ROLLBACK。使用保存点时,你可以执行 ROLLBACK TO SAVEPOINT before_risky 并继续——事务会恢复到可用状态。

运行示例

运行方式:

$ psql -f source/savepoints.sql postgres
BEGIN
DROP TABLE
CREATE TABLE
INSERT 0 1
SAVEPOINT
INSERT 0 1
SAVEPOINT
INSERT 0 1
ROLLBACK
 id
----
  1
  2
(2 rows)
RELEASE
 ...
COMMIT

常见陷阱

  • 保存点有开销。包含数千个保存点的事务(常见的 ORM 模式)可能开销很大。
  • 执行 ROLLBACK TO SAVEPOINT s 后,保存点 s 本身仍然存在——你可以再次回滚到它。要移除它,请使用 RELEASE。
  • RELEASE SAVEPOINT s 不会“提交”工作——只有包含它的事务执行 COMMIT 才算提交。RELEASE 只是移除标记。
  • 保存点只存在于显式事务中。在显式事务之外执行 SAVEPOINT s; 会报错。
提示

ORM 经常用保存点实现“事务性”测试:每个测试都在一个保存点中运行,测试结束时回滚到该保存点,从而不改变数据库。可以查阅 pytest-postgresql、RSpec 的事务性测试夹具等。

动手练习

添加第二个保存点,插入更多行,然后 ROLLBACK TO 第一个保存点,看看保存点之后的所有工作是否都被撤销。尝试错误恢复模式:BEGIN; INSERT ... SAVEPOINT s; INSERT (bad row); ROLLBACK TO SAVEPOINT s; INSERT (good row); COMMIT;。

下载

SQL 文件:savepoints.sql · 全部示例文件 · 准备工作

48 CREATE VIEW

通过视图提供可复用的查询,并了解视图何时支持更新。

视图是一个有名称且保存下来的 SELECT。你可以像查询表一样查询它,但每次都会即时计算结果行——不会存储任何数据。视图非常适合隐藏复杂性(将大型连接隐藏在简短名称之后)、实施列级访问控制(只授予对视图的 SELECT 权限,而不授予底层表权限),以及在底层模式发生演变时为应用提供稳定接口。PostgreSQL 还支持物化视图,它会存储行;这是下一课的内容。

本章要点

  • 使用 CREATE OR REPLACE VIEW 创建和替换视图
  • 可更新视图及其必须遵循的规则
  • 使用 WITH CHECK OPTION 确保更新仍处于视图范围内
  • 授予视图级访问权限而不暴露底层表
  • 列出和删除视图

示例

示例 48.1 创建视图
-- A simple view: customer + their order count
CREATE OR REPLACE VIEW customer_orders AS
SELECT c.id, c.name, count(o.id) AS order_count, COALESCE(sum(o.total), 0) AS total_spent
FROM customers_example c
LEFT JOIN orders_example o ON o.customer_id = c.id
GROUP BY c.id, c.name;

-- Use it like a table
SELECT * FROM customer_orders WHERE total_spent > 0 ORDER BY total_spent DESC;

-- Simple updatable view (no joins, no aggregates, no DISTINCT)
CREATE OR REPLACE VIEW alice_orders AS
SELECT * FROM orders_example WHERE customer_id = 1;

-- You can INSERT through an updatable view
INSERT INTO alice_orders (id, customer_id, total) VALUES (10, 1, 99.00);

-- WITH CHECK OPTION: refuse INSERTs/UPDATEs that wouldn't satisfy the view's WHERE
CREATE OR REPLACE VIEW alice_orders_strict AS
SELECT * FROM orders_example WHERE customer_id = 1
WITH CHECK OPTION;

-- This would fail because customer_id = 2 is not in the view's scope
-- INSERT INTO alice_orders_strict (id, customer_id, total) VALUES (11, 2, 1.00);

-- List views
SELECT viewname, definition
FROM pg_views
WHERE schemaname = 'public'
ORDER BY viewname;

-- Drop a view
DROP VIEW IF EXISTS alice_orders;
DROP VIEW IF EXISTS alice_orders_strict;
DROP VIEW IF EXISTS customer_orders;

视图是虚拟的:对视图执行的每个 SELECT 都会重新运行底层查询。视图没有缓存——本质上是规划器会内联的宏。这使视图不会产生过时数据,但也意味着视图本身不会带来性能收益;视图解决的是抽象问题,而不是速度问题。

可更新视图——那些能直接映射到单个底层表,且不包含聚合、连接、DISTINCT 或集合运算的视图——可以接受 INSERT、UPDATE 和 DELETE。这些操作会作用于底层表。WITH CHECK OPTION 是安全带,可防止 UPDATE 将行移出视图的谓词范围。

如果视图已存在,CREATE OR REPLACE VIEW 会更新其定义。列列表和类型必须保持兼容——不能通过这种方式删除列或更改其类型。使用 ALTER VIEW ... RENAME COLUMN 重命名列;其他不兼容的更改可能需要先删除再重新创建。

运行示例

运行方式(需要 customers_example 和 orders_example):

$ psql -f source/create-view.sql postgres
CREATE VIEW
 id | name  | order_count | total_spent
----+-------+-------------+-------------
  1 | Alice |           2 |       80.00
(1 row)
 ...

常见陷阱

  • 视图不会自动带来性能提升——视图定义会被内联到每个查询中。对于反复执行的繁重工作,可以考虑物化视图。
  • 可更新视图的规则很严格:任何聚合、DISTINCT、GROUP BY、集合运算或多个基表,通常都会使视图不可更新。你可以编写 INSTEAD OF 触发器来允许写入,但那属于高级用法。
  • CREATE OR REPLACE VIEW 必须保留现有的列名、顺序和类型,即使没有依赖对象也是如此。它可以追加列;重命名请使用 ALTER VIEW。
  • 视图可能隐藏代价高昂的连接,因此对视图执行的一行查询可能会运行数秒。使用 EXPLAIN 检查。
提示

视图是应用安全中常被低估的工具。只授予应用角色对某个视图的 SELECT 权限,该视图只返回它应该看到的列(例如排除 password_hash)。底层表仍受保护。

动手练习

创建一个列出没有订单的客户的视图(HAVING count(o.id) = 0)。在 psql 中使用 \d customer_orders 检查视图。然后创建一个限定到单个客户、支持 UPDATE 的视图,并通过它执行更新——观察底层表的变化。

下载

SQL 文件:create-view.sql · 全部示例文件 · 准备工作

49 物化视图

在物化视图中存储查询结果并刷新它们,同时管理读者访问。

物化视图是将结果存储在磁盘上的视图。与普通视图不同,它不会在每次读取时重新运行查询,而是返回已保存的行。代价在于结果会过时:在你执行 REFRESH 之前,看到的都是上次刷新时的结果。物化视图是 PostgreSQL 中缓存昂贵聚合、搜索索引和预计算报表的标准方式。

本章要点

  • 创建和刷新物化视图
  • REFRESH MATERIALIZED VIEW 与 REFRESH MATERIALIZED VIEW CONCURRENTLY 的区别
  • 为物化视图建立索引
  • 检测物化视图的陈旧程度
  • 何时使用物化视图、普通视图或缓存表

示例

示例 49.1 物化视图
-- A materialized view: aggregate per customer
DROP MATERIALIZED VIEW IF EXISTS customer_spend_mv;
CREATE MATERIALIZED VIEW customer_spend_mv AS
SELECT c.id              AS customer_id,
       c.name            AS customer_name,
       count(o.id)       AS order_count,
       COALESCE(sum(o.total), 0) AS total_spent,
       max(o.id)         AS last_order_id
FROM customers_example c
LEFT JOIN orders_example o ON o.customer_id = c.id
GROUP BY c.id, c.name;

-- Query it like a table
SELECT * FROM customer_spend_mv ORDER BY total_spent DESC;

-- Index it (a matview is a real table, so it supports indexes)
CREATE UNIQUE INDEX customer_spend_mv_pk ON customer_spend_mv (customer_id);
CREATE INDEX customer_spend_mv_total_idx ON customer_spend_mv (total_spent);

-- Add another order, then refresh
INSERT INTO orders_example VALUES (200, 1, 25.00);

-- Plain REFRESH locks the matview against reads for the duration
REFRESH MATERIALIZED VIEW customer_spend_mv;

-- CONCURRENTLY lets readers continue. Requires a UNIQUE index on the matview.
REFRESH MATERIALIZED VIEW CONCURRENTLY customer_spend_mv;

SELECT * FROM customer_spend_mv ORDER BY total_spent DESC;

-- Find matviews and when they were last refreshed (PostgreSQL keeps the relation modification time)
SELECT schemaname, matviewname, hasindexes, ispopulated
FROM pg_matviews
WHERE schemaname = 'public';

DROP MATERIALIZED VIEW IF EXISTS customer_spend_mv;

物化视图是一张表,只有在你要求时,PostgreSQL 才会将它与查询同步。REFRESH MATERIALIZED VIEW name 会重新运行底层的 SELECT 并替换存储的行。默认情况下,刷新会获取 ACCESS EXCLUSIVE 锁——刷新期间读者不能查询。

REFRESH MATERIALIZED VIEW CONCURRENTLY 更新物化视图时不会阻塞读者。代价在于物化视图必须已经填充,并且存在覆盖所有行的 UNIQUE 索引,且索引基于列名而不是表达式。它会重新运行完整查询,比对并协调结果;这不是增量视图维护。对于需要保持可查询的物化视图,这是正确的选择。

物化视图是磁盘上的真实表——你可以在其上创建索引、对其运行 ANALYZE,甚至运行 VACUUM。因此它非常适合缓存昂贵的查询结果,让许多其他查询读取这些结果。

运行示例

运行方式(需要 customers_example 和 orders_example):

$ psql -f source/materialized-views.sql postgres
DROP MATERIALIZED VIEW
SELECT 3
 customer_id | customer_name | order_count | total_spent | last_order_id
-------------+---------------+-------------+-------------+---------------
           1 | Alice         |           3 |      179.00 |            10
           2 | Bob           |           0 |           0 |
           3 | Carol         |           0 |           0 |
(3 rows)

CREATE INDEX
CREATE INDEX
INSERT 0 1
REFRESH MATERIALIZED VIEW
REFRESH MATERIALIZED VIEW
 customer_id | customer_name | order_count | total_spent | last_order_id
-------------+---------------+-------------+-------------+---------------
           1 | Alice         |           4 |      204.00 |           200
           2 | Bob           |           0 |           0 |
           3 | Carol         |           0 |           0 |
(3 rows)

 ...

常见陷阱

  • 物化视图在刷新前都是陈旧的。如果忘记刷新,报表会悄悄落后。请设置定时任务或调度器。
  • REFRESH ... CONCURRENTLY 至少需要一个 UNIQUE 索引。没有这样的索引时,PostgreSQL 会报错。请在你视为物化视图“主键”的列上添加唯一索引(通常是源表主键中的某一列)。
  • 外键不能引用物化视图,刷新时触发器也不会触发——这些都是它与普通表的实际差异。
  • REFRESH 会从头开始运行原始的 SELECT。如果该查询很慢,刷新也会很慢。若要增量刷新,目前需要自行实现或使用 pg_ivm 扩展。
提示

将物化视图用于仪表盘:每晚预先聚合一次“昨天的指标”,之后仪表盘即可即时读取。若要求亚分钟级新鲜度,实时写入路径更新或普通视图通常更合适。

动手练习

在脚本最后的 DROP MATERIALIZED VIEW 之前运行此实验,或者先重新创建视图及其索引。向 orders_example 添加几行。查询物化视图——它仍显示旧的总额。运行 REFRESH MATERIALIZED VIEW customer_spend_mv; 并再次查询。然后再添加几行,在另一个会话正在读取时使用 CONCURRENTLY 形式。

下载

SQL 文件:materialized-views.sql · 全部示例文件 · 准备工作

50 函数与 PL/pgSQL

使用适当的易变性和安全设置定义 SQL 与 PL/pgSQL 函数。

PostgreSQL 允许你定义自己的函数。最简单的是SQL 函数——函数体由一条或多条 SQL 语句组成;PostgreSQL 通常可以将它们内联。功能更强的是PL/pgSQL 函数——用服务器内置的过程语言编写,支持变量、循环、条件和异常。PL/pgSQL 用于触发器、复杂业务逻辑和一次性维护例程。还有 PL/Python、PL/Perl 等语言,但 PL/pgSQL 始终可用,也是你应该首先学习的语言。

本章要点

  • CREATE FUNCTION 语法
  • LANGUAGE sql 与 LANGUAGE plpgsql 的区别
  • 易变性类别:IMMUTABLE、STABLE、VOLATILE
  • PL/pgSQL 中的变量、IF、LOOP 和 RAISE NOTICE
  • SECURITY DEFINER 与 search_path 警告

示例

示例 50.1 函数与 PL/pgSQL
-- A simple SQL function
CREATE OR REPLACE FUNCTION square(x integer)
RETURNS integer
LANGUAGE sql
IMMUTABLE
AS $$
  SELECT x * x;
$$;

SELECT square(7) AS result;

-- A SQL function that returns a set (multiple rows)
CREATE OR REPLACE FUNCTION recent_orders(min_total numeric)
RETURNS TABLE (id integer, customer_id integer, total numeric)
LANGUAGE sql
STABLE
AS $$
  SELECT id, customer_id, total
  FROM orders_example
  WHERE total >= min_total
  ORDER BY id;
$$;

SELECT * FROM recent_orders(20);

-- A PL/pgSQL function with control flow
CREATE OR REPLACE FUNCTION classify_total(total numeric)
RETURNS text
LANGUAGE plpgsql
IMMUTABLE
AS $$
DECLARE
  result text;
BEGIN
  IF total IS NULL THEN
    result := 'unknown';
  ELSIF total < 20 THEN
    result := 'small';
  ELSIF total < 100 THEN
    result := 'medium';
  ELSE
    result := 'large';
  END IF;
  RETURN result;
END;
$$;

SELECT id, total, classify_total(total) AS bucket
FROM orders_example
ORDER BY id;

-- A function that loops and uses RAISE NOTICE (a print statement)
CREATE OR REPLACE FUNCTION ping(n integer)
RETURNS integer
LANGUAGE plpgsql
AS $$
DECLARE
  i integer;
BEGIN
  FOR i IN 1..n LOOP
    RAISE NOTICE 'ping %', i;
  END LOOP;
  RETURN n;
END;
$$;

SELECT ping(3);

-- Cleanup
DROP FUNCTION IF EXISTS square(integer);
DROP FUNCTION IF EXISTS recent_orders(numeric);
DROP FUNCTION IF EXISTS classify_total(numeric);
DROP FUNCTION IF EXISTS ping(integer);

函数声明包括名称、参数列表、返回类型、语言、可选的易变性属性和函数体。函数体由美元引用($$ ... $$)包围——这样无需转义函数体内的引号。如果需要嵌套美元引用,也可以使用 $tag$ ... $tag$。

易变性类别对于性能至关重要:

  • IMMUTABLE:相同输入始终产生相同输出,不涉及数据库状态。规划器可以把这些调用折叠为表达式中的常量,甚至在规划时预先求值。用于数学运算、字符串格式化和确定性转换。
  • STABLE:相同输入在单条语句内产生相同输出,但跨语句可能不同(例如读取表的函数)。这允许进行一些优化,例如将这类函数用于索引扫描条件;但这并不保证每条语句只求值一次。
  • VOLATILE(默认):即使在同一条语句内也可能返回不同结果(例如 random()、clock_timestamp(),以及任何会修改数据的函数);now() 是 STABLE。规划器每次都会重新求值。

错误的易变性声明会导致错误的查询计划——大多数初学者把所有函数都保留为 VOLATILE,因而承担性能代价。只要确实满足条件,就应将函数标记为 IMMUTABLE / STABLE。

运行示例

运行方式:

$ psql -f source/functions-and-plpgsql.sql postgres
CREATE FUNCTION
 result
--------
     49
(1 row)
 ...
NOTICE:  ping 1
NOTICE:  ping 2
NOTICE:  ping 3
 ping
------
    3
(1 row)

常见陷阱

  • 定义 SECURITY DEFINER 函数(以函数所有者身份运行)却不设置 search_path,是已知的权限提升模式。只使用受信任的模式,明确把 pg_temp 放在最后,并为敏感对象名称加上限定名;例如,在 app 受信任时设置 SET search_path = pg_catalog, app, pg_temp。
  • 函数实际读取表却标记为 IMMUTABLE——规划器会缓存错误的值。只有真正的数学/字符串函数才应使用 IMMUTABLE。
  • 允许按参数类型重载,但容易被滥用。recent_orders(numeric) 与 recent_orders(integer) 是不同的函数;传入较小的数值时,可能意外匹配到错误的重载。
  • PL/pgSQL 使用以分号结束的语句。关键字和未加引号的标识符不区分大小写;带引号的标识符和字符串内容区分大小写。函数体内使用保留字时要小心。
提示

存储过程(CREATE PROCEDURE,PostgreSQL 11+)类似函数,但可以管理事务(在过程体内执行 COMMIT、ROLLBACK)。将函数用于需要返回值的逻辑;将过程用于需要事务控制的批处理操作。

动手练习

编写一个返回折后价格的 IMMUTABLE SQL 函数 discount(price, pct)。在查询中使用它:SELECT id, total, discount(total, 10) FROM orders_example;。然后编写一个 PL/pgSQL 函数,用循环或单条 SQL 查询返回每个客户的最大订单金额(SQL 形式快得多——两种都试试)。

下载

SQL 文件:functions-and-plpgsql.sql · 全部示例文件 · 准备工作

51 触发器

使用行级和语句级触发器维护时间戳并审计变更。

触发器是一种函数,当表上发生特定事件时,PostgreSQL 会自动调用它——事件可以是 INSERT、UPDATE、DELETE 或 TRUNCATE;触发时机可以是行变更之前或之后(BEFORE 或 AFTER),作用范围可以是每一行,也可以是整条语句。触发器是强制执行类型系统无法表达的不变式、审计变更、出于性能考虑进行反规范化,以及保持 updated_at 时间戳最新的标准工具。

本章要点

  • BEFORE 与 AFTER 的区别
  • 行级(FOR EACH ROW)与语句级(FOR EACH STATEMENT)
  • NEW 和 OLD 行变量
  • 典型示例:自动更新 updated_at
  • 向另一个表写入审计日志的触发器

示例

示例 51.1 触发器
-- Setup
DROP TABLE IF EXISTS articles_example;
DROP TABLE IF EXISTS articles_audit;
CREATE TABLE articles_example (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  title       text NOT NULL,
  body        text NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT current_timestamp,
  updated_at  timestamptz NOT NULL DEFAULT current_timestamp
);

CREATE TABLE articles_audit (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  article_id  bigint NOT NULL,
  action      text   NOT NULL,
  by_user     text   NOT NULL DEFAULT current_user,
  at          timestamptz NOT NULL DEFAULT current_timestamp,
  old_row     jsonb,
  new_row     jsonb
);

-- Trigger function: bump updated_at before every UPDATE
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
  NEW.updated_at := current_timestamp;
  RETURN NEW;
END;
$$;

CREATE TRIGGER articles_updated_at
BEFORE UPDATE ON articles_example
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();

-- Trigger function: write to the audit log after every change
CREATE OR REPLACE FUNCTION audit_article_change()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
  INSERT INTO articles_audit (article_id, action, old_row, new_row)
  VALUES (
    COALESCE(NEW.id, OLD.id),
    TG_OP,
    CASE WHEN TG_OP IN ('UPDATE', 'DELETE') THEN to_jsonb(OLD) END,
    CASE WHEN TG_OP IN ('INSERT', 'UPDATE') THEN to_jsonb(NEW) END
  );
  RETURN NULL;  -- AFTER trigger return value is ignored
END;
$$;

CREATE TRIGGER articles_audit
AFTER INSERT OR UPDATE OR DELETE ON articles_example
FOR EACH ROW
EXECUTE FUNCTION audit_article_change();

-- Exercise the triggers
INSERT INTO articles_example (title, body) VALUES ('Hello', 'world');
UPDATE articles_example SET body = 'edited world' WHERE title = 'Hello';
DELETE FROM articles_example;

SELECT * FROM articles_audit ORDER BY id;

-- Cleanup
DROP TRIGGER IF EXISTS articles_updated_at ON articles_example;
DROP TRIGGER IF EXISTS articles_audit ON articles_example;
DROP FUNCTION IF EXISTS set_updated_at();
DROP FUNCTION IF EXISTS audit_article_change();
DROP TABLE IF EXISTS articles_example;
DROP TABLE IF EXISTS articles_audit;

触发器由两部分组成:一个触发器函数(返回 trigger),以及一个将该函数绑定到表和事件的触发器。在函数内部:

  • NEW 是新行(适用于 INSERT 和 UPDATE)。
  • OLD 是旧行(适用于 UPDATE 和 DELETE)。
  • TG_OP 表示操作('INSERT'、'UPDATE'、'DELETE'、'TRUNCATE')。
  • 还可以使用 TG_TABLE_NAME 等变量来编写可复用的触发器。

对于 BEFORE ROW 的 INSERT/UPDATE 触发器,应返回 NEW(它可能已被修改);对于 DELETE,返回 OLD。返回 NULL 会跳过该行的操作。对于 AFTER ROW 触发器,返回值会被忽略——因为操作已经发生。语句级触发器(FOR EACH STATEMENT)每条语句只触发一次,与行数无关,也没有 NEW/OLD。

自动更新 updated_at 的触发器是世界上最常见的触发器。定义一个触发器函数,把它绑定到每个包含 updated_at 列的表上。

运行示例

运行方式:

$ psql -f source/triggers.sql postgres
DROP TABLE
DROP TABLE
CREATE TABLE
CREATE TABLE
CREATE FUNCTION
CREATE TRIGGER
CREATE FUNCTION
CREATE TRIGGER
INSERT 0 1
UPDATE 1
DELETE 1
 id | article_id | action  | by_user  |              at               | old_row | new_row
----+------------+---------+----------+-------------------------------+---------+---------
  1 |          1 | INSERT  | your_user | 2026-xx-xx ...                |         | {"id":1,...}
  2 |          1 | UPDATE  | your_user | 2026-xx-xx ...                | {...}   | {...}
  3 |          1 | DELETE  | your_user | 2026-xx-xx ...                | {...}   |
(3 rows)
 ...

常见陷阱

  • 返回 NULL 的 BEFORE 触发器会悄无声息地吞掉该行——INSERT/UPDATE 会在不报错的情况下被跳过。除非你确实要跳过该行,否则对 INSERT/UPDATE 返回 NEW,对 DELETE 返回 OLD。
  • 触发器与触发它的语句在同一事务中运行。触发器中的耗时逻辑会拖慢对该表的每次修改。
  • 触发器在 COPY ... FROM 时默认不会触发?其实会触发——但 TRUNCATE 不会触发行级触发器(只触发语句级触发器)。请据此安排。
  • 把业务逻辑放进触发器后,应用代码中就很难发现这些逻辑。许多团队会把触发器限制在基础设施关注点(updated_at、审计)上,并把业务逻辑留在应用程序中。
提示

在触发器内使用 pg_trigger_depth() 检测递归。如果触发器更新了触发它的表,就可能陷入循环——可用 IF pg_trigger_depth() > 1 THEN RETURN NEW; END IF; 防止这种情况。

动手练习

脚本最后会删除演示表和触发器函数。继续之前,先根据示例重新创建时间戳触发器函数。给 fruits 添加 updated_at 列,并绑定上面的触发器。更新一行,确认 updated_at 发生变化。然后添加一个 BEFORE INSERT 触发器,让它自动将 name 转为大写,观察它的效果。

下载

SQL 文件:triggers.sql · 全部示例文件 · 准备工作

52 全文检索

使用 tsvector、tsquery、语言配置和 GIN 索引搜索文本并进行排名。

PostgreSQL 内置了一套功能完善的全文检索引擎——对大多数应用来说,不需要 Elasticsearch,也不需要 Solr。核心抽象是 tsvector(文档经过解析和规范化后的表示)和 tsquery(经过解析的搜索表达式)。在 tsvector 上建立 GIN 索引后,就能在纯 SQL 中获得快速文本搜索、词干提取、排名和多语言支持。

本章要点

  • to_tsvector 和 to_tsquery
  • 用于测试向量与查询是否匹配的 @@ 操作符
  • 配置语言(英语、simple 等)
  • 使用 GIN 建立索引以加快搜索
  • 使用 ts_rank 对结果排名

示例

示例 52.1 全文检索
-- Setup
DROP TABLE IF EXISTS docs_fts;
CREATE TABLE docs_fts (
  id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  title text NOT NULL,
  body  text NOT NULL
);

INSERT INTO docs_fts (title, body) VALUES
  ('Postgres tips',    'PostgreSQL is a powerful relational database with full-text search built in.'),
  ('SQL joins',        'Inner joins keep matching rows; left joins keep all left rows.'),
  ('Indexing',         'B-tree indexes speed up equality and range queries. GIN indexes are great for arrays and text search.'),
  ('Cats',             'Cats are not databases. This sentence exists to add noise.');

-- to_tsvector parses, normalizes, and removes stop words
SELECT to_tsvector('english', 'PostgreSQL has powerful full-text search') AS v;

-- to_tsquery turns a query string into a tsquery
SELECT to_tsquery('english', 'powerful & search') AS q;

-- @@ tests "does this document match this query?"
SELECT title
FROM docs_fts
WHERE to_tsvector('english', title || ' ' || body) @@ to_tsquery('english', 'powerful & search');

-- websearch_to_tsquery: parses Google-style input (quotes, OR, -minus)
SELECT title
FROM docs_fts
WHERE to_tsvector('english', title || ' ' || body) @@ websearch_to_tsquery('english', 'index OR join -cats');

-- Add a generated tsvector column + GIN index for fast search at scale
ALTER TABLE docs_fts ADD COLUMN search_tsv tsvector
  GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;

CREATE INDEX docs_fts_search_idx ON docs_fts USING gin (search_tsv);

-- Rank results
SELECT title, ts_rank(search_tsv, websearch_to_tsquery('english', 'database search')) AS rank
FROM docs_fts
WHERE search_tsv @@ websearch_to_tsquery('english', 'database search')
ORDER BY rank DESC;

DROP TABLE IF EXISTS docs_fts;

to_tsvector('english', text) 会将输入切分为词元、转换为小写、应用英语词干提取器(因此 running 和 runs 会映射到同一个词根),移除停用词(the、a、is),并返回一个 tsvector——一个带有位置信息的规范化词元排序列表。不同的配置(english、simple、german、russian、……)实现不同的词干提取和停用词规则。

to_tsquery 会解析表达式:&(与)、|(或)、!(非)、<->(后跟)。websearch_to_tsquery 接受更友好的语法——双引号短语、OR 关键字以及用于排除的 -。后者正是用户搜索框后端所需要的函数。

@@ 操作符用于测试 tsvector 是否匹配 tsquery。在 tsvector 上建立 GIN 索引后,这是 PostgreSQL 用于文本搜索的快速路径。上面的做法使用一个生成列,它始终反映 title || ' ' || body——无需触发器,也不用担心忘记更新。

运行示例

运行方式:

$ psql -f source/full-text-search.sql postgres
DROP TABLE
CREATE TABLE
INSERT 0 4
                                  v
---------------------------------------------------------------------
 'full':5 'full-text':4 'postgresql':1 'power':3 'search':7 'text':6
(1 row)
 ...

常见陷阱

  • 在 WHERE 子句中调用 to_tsvector('english', ...),却没有表达式索引,会使索引优化失效。可以为该表达式建立索引,或者使用生成列。
  • 选择错误的语言:to_tsvector('english', russian_text) 会生成毫无意义的词干。让配置与数据匹配。
  • LIKE '%foo%' 不是全文检索,而是子字符串匹配;没有三字符组(trigram)索引时速度很慢,也不支持词干提取或停用词。自然语言查询应使用全文检索。
  • 对于多语言数据,可能需要将语言与文本一起存储,并将配置参数化。
提示

对于不需要词干提取的子字符串或模糊匹配,pg_trgm 扩展提供基于三字符组的相似度(similarity(a, b))以及一个能高效支持 LIKE '%foo%' 的索引。它通常与全文检索一起使用。

动手练习

在最后的 DROP TABLE docs_fts 之前运行这段练习,或者先重新创建演示表。添加更多文档并搜索它们。使用 <-> 进行短语查询:to_tsquery('english', 'full <-> text') 会精确匹配“full text”。添加 ts_headline('english', body, query),高亮每条结果中匹配的片段。

下载

SQL 文件:full-text-search.sql · 全部示例文件 · 准备工作

53 VACUUM 与 ANALYZE

使用 VACUUM、ANALYZE 和自动清理维护行可见性与规划器统计信息。

PostgreSQL 使用多版本并发控制(MVCC):UPDATE 不会覆盖一行,而是写入一个新版本,并将旧版本标记为死行。DELETE 只会将行标记为死行——它们仍留在磁盘上。VACUUM 回收这部分空间,并防止事务 ID 回卷。ANALYZE 更新规划器的统计信息,使其能够做出合理的决策。PostgreSQL 通过自动清理自动运行这两个命令,但理解它们做什么,以及何时需要手动干预,是成为合格运维人员的一部分。

本章要点

  • MVCC 为什么会产生死行,以及 VACUUM 如何处理它们
  • VACUUM、VACUUM FULL 与 VACUUM ANALYZE 的区别
  • ANALYZE 实际计算了什么
  • 读取 pg_stat_user_tables,查看自动清理活动
  • 事务 ID 回卷的临界点,以及为什么不能忽视它

示例

示例 53.1 VACUUM 与 ANALYZE
-- Inspect the per-table autovacuum stats
SELECT relname, n_live_tup, n_dead_tup,
       last_vacuum, last_autovacuum,
       last_analyze, last_autoanalyze
FROM pg_stat_user_tables
WHERE schemaname = 'public'
ORDER BY n_dead_tup DESC
LIMIT 5;

-- Manually vacuum and analyze a table
VACUUM (VERBOSE) fruits;
ANALYZE VERBOSE fruits;

-- Combined: VACUUM ANALYZE (most common manual invocation)
VACUUM ANALYZE fruits;

-- See the planner's stored statistics for a column
SELECT attname, n_distinct, most_common_vals, most_common_freqs
FROM pg_stats
WHERE schemaname = 'public' AND tablename = 'fruits';

-- Bloat estimate (approximation): pg_stat_user_tables gives live vs dead row counts
-- pgstattuple extension gives exact numbers if you need them.

-- VACUUM FULL: rewrites the whole table, fully reclaims space, takes an exclusive lock.
-- Use sparingly.
-- VACUUM FULL fruits;

MVCC 让 PostgreSQL 能够实现高并发:由于每个事务都能看到一致的快照,写入者不会阻塞读取者,反之亦然。代价就是死行。VACUUM 遍历表,将死行占用的空间标记为可复用,并更新可见性映射(一种按页维护的位图,让仅索引扫描在标记为全可见的页面上无需检查堆可见性)。

普通 VACUUM 通常会让空间在表内变得可复用。它还可以截断表末尾的空页,并将这部分空间归还给操作系统。VACUUM FULL 会在新文件中重写整张表,释放旧文件——它能回收空间,但需要获取 ACCESS EXCLUSIVE 锁,而且速度很慢。很少使用它;日常维护由自动清理负责。

ANALYZE 对表进行抽样,以更新 pg_statistic。规划器使用这些统计信息——非重复值的数量、高频值和直方图——来估算行数并选择计划。过时的统计信息会产生糟糕的计划。

PostgreSQL 的自动清理守护进程会根据每张表的阈值(autovacuum_vacuum_scale_factor、autovacuum_analyze_scale_factor)自动运行 VACUUM 和 ANALYZE。自动清理负责日常维护,但仍需监控它的进度和阈值,尤其是在批量变更之后,或针对大型表和频繁更新的表。

运行示例

运行方式:

$ psql -f source/vacuum-and-analyze.sql postgres
 relname | n_live_tup | n_dead_tup | last_vacuum | ...
---------+------------+------------+-------------+----
 ...
INFO:  vacuuming "public.fruits"
INFO:  ...
VACUUM
 ...

常见陷阱

  • 为了“节省资源”而禁用自动清理——这几乎总是一个错误。死行会不断堆积,查询会变慢,最终追赶的代价远高于自动清理在稳定运行时的开销。
  • 在繁忙的表上运行 VACUUM FULL——在整个运行期间,它会阻塞所有读写。使用 pg_repack(扩展)进行在线压实。
  • 批量导入后忘记运行 ANALYZE——在自动清理追赶上来之前,规划器没有新行的统计信息,可能会选择非常糟糕的计划。大量导入后,手动运行 ANALYZE schema.table;。
  • 长事务会阻止自动清理清除对其可见的行。在 psql 中遗留的 BEGIN; 可能导致整个数据库的清理都无法进行——要留意 pg_stat_activity 中 state = 'idle in transaction' 的状态。
提示

pg_stat_user_tables.n_dead_tup 是判断“是否需要清理”的权威指标。在繁忙表中,n_dead_tup / n_live_tup 比例较高,说明自动清理跟不上——使用 ALTER TABLE t SET (autovacuum_vacuum_scale_factor = 0.02); 调整每张表的阈值。

动手练习

在一张表上运行一批 UPDATE,然后查看 pg_stat_user_tables 中的 n_dead_tup。运行 VACUUM 并再次检查。然后在 ANALYZE 前后分别对同一个查询运行 EXPLAIN ANALYZE,看看估算值是否发生变化。

下载

SQL 文件:vacuum-and-analyze.sql · 全部示例文件 · 准备工作

54 pg_dump 与 pg_restore

使用纯文本和自定义格式的逻辑转储备份和恢复练习数据库。

pg_dump 是 PostgreSQL 的标准备份工具。它会为单个数据库生成一致的快照,格式可以是纯文本 SQL 脚本,也可以是自定义格式的二进制归档。它的配套工具 pg_restore 可以读取二进制归档并将其恢复。对于整个集群的备份(pg_dumpall)、各表空间的数据备份以及时间点恢复,PostgreSQL 提供了单独的工具——但 pg_dump 已经覆盖了绝大多数“备份我的数据库”需求。遇到这类需求时,通常先用它。

本章要点

  • 两种输出格式(纯文本 .sql 和自定义格式 .dump)
  • 值得记住的 pg_dump 和 pg_restore 选项
  • 仅转储数据库结构、仅转储数据,或两者都转储
  • 包含或排除指定的表或模式
  • 并行恢复以提高速度

准备工作。 这些是 Shell 命令,不是 SQL。请在终端中运行它们。本课的源文件只是对这些命令的简单封装和记录。

示例

示例 54.1 pg_dump 与 pg_restore
# Plain-text SQL dump (human-readable, runnable with psql)
pg_dump --dbname=postgres --format=plain --file=backup.sql

# Custom-format dump (compressed binary, restorable with pg_restore)
pg_dump --dbname=postgres --format=custom --file=backup.dump

# Schema only (DDL, no data) — useful for reproducing the shape of a database
pg_dump --schema-only --dbname=postgres --file=schema.sql

# Data only (no DDL)
pg_dump --data-only --dbname=postgres --file=data.sql

# Dump only specific tables (or use --exclude-table)
pg_dump --table=fruits --table=orders_example --dbname=postgres --file=subset.sql

# Dump only one schema
pg_dump --schema=app --dbname=postgres --file=app_schema.dump --format=custom

# Restore from a custom-format dump
pg_restore --dbname=postgres --jobs=4 --clean --if-exists backup.dump

# Restore a plain SQL dump
psql --dbname=postgres --file=backup.sql

# Dump every database in the cluster, plus roles and globals
pg_dumpall --file=cluster.sql

自定义格式(--format=custom 或 -Fc)是大多数团队使用的格式。它默认经过压缩,支持并行恢复(--jobs=N),还可以选择性地恢复单个对象(先运行 pg_restore --list backup.dump 查看清单,再使用 --use-list 选择要恢复的对象)。纯文本格式更容易阅读和用 grep 搜索,但灵活性较低。

pg_dump 使用一致快照,并对要转储的表获取 ACCESS SHARE 锁。并发数据变更不会使该快照失效,普通写入者可以继续工作。与之冲突的 DDL 可能需要等待,也可能导致转储失败;并行转储使用同步快照。

--clean --if-exists 会在重新创建归档中包含的对象之前先删除它们;它不会删除所有无关对象,也不能保证恢复过程不报错。为了得到可预测的结果,请恢复到一个全新的练习数据库。--jobs=4 可以加快自定义格式或目录格式的恢复。

对于生产环境的备份,pg_dump 是策略的一部分,但不是全部。持续归档(archive_mode = on)加上基础备份(pg_basebackup)可以提供时间点恢复。云托管的 PostgreSQL 会透明地处理这些工作。

运行示例

运行方式(这是一个 Shell 示例——记录这些命令的 SQL 文件可供参考):

$ pg_dump --dbname=postgres --format=custom --file=backup.dump
$ pg_restore --dbname=postgres --clean --if-exists --jobs=4 backup.dump

常见陷阱

  • 使用 pg_dumpall --globals-only 备份角色和表空间。它和 pg_dump 都不会备份复制槽;如有需要,请单独记录并重新创建复制槽。
  • 恢复到已经存在该模式的数据库会产生冲突。可以使用 --clean --if-exists,删除并重新创建数据库,或者恢复到一个空数据库。
  • 使用 psql 恢复纯文本 SQL 转储时,默认不会在第一个错误处停止——传入 -v ON_ERROR_STOP=1,让它在出错时直接失败。
  • 版本兼容性:pg_dump 不能转储比自身大版本更新的服务器。转储文件的用途是恢复到相同或更高的 PostgreSQL 大版本;恢复到更低的大版本并无保证。升级时,如果目标大版本的 pg_dump 支持源服务器,应使用它。
提示

pg_dump -t my_table -Fc db | pg_restore -t my_table -d other_db 可以通过一条管道将一个数据库中的一张表复制到另一个数据库,而无需中间文件——适合移动快照。

动手练习

转储你的练习数据库,删除一张表,然后从转储中恢复它。运行 pg_restore --list backup.dump 查看对象清单。尝试运行 pg_restore --schema-only,只在全新的数据库上重新创建结构。

下载

SQL 文件:pg-dump-and-restore.sql · 全部示例文件 · 准备工作

55 角色与 GRANT

向对象授予权限,并使用普通数据库角色演示行级安全。

PostgreSQL 用一个统一的概念——角色——表示用户和组。角色可以登录(使用 LOGIN),可以被授予给其他角色(实现类似组的行为),可以拥有对象,也可以获得授予给它的权限。GRANT 添加权限;REVOKE 移除权限。每个数据库对象都有一个拥有全部权限的所有者;其他人都需要获得明确的授权。这里的原则是最小权限:每个角色只获得它所需的权限。

本章要点

  • 创建带登录和不带登录的角色
  • 在模式、表和列上授予与撤销权限
  • 模式上的 USAGE 权限(经常被忘记)
  • 为将来创建的对象设置 DEFAULT PRIVILEGES
  • 行级安全(RLS)简介

示例

示例 55.1 角色与 GRANT
-- Inspect the current session
SELECT current_user, session_user;

-- Create ordinary roles (requires CREATEROLE or superuser privileges)
-- CREATE ROLE app_read NOLOGIN;                                 -- a "group" role
-- CREATE ROLE alice LOGIN PASSWORD 'change-me' IN ROLE app_read;
-- CREATE ROLE app_write NOLOGIN;

-- Grant access to a schema and read on tables
-- GRANT USAGE  ON SCHEMA public TO app_read;
-- GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_read;

-- Future tables in this schema also get SELECT for app_read
-- ALTER DEFAULT PRIVILEGES IN SCHEMA public
--   GRANT SELECT ON TABLES TO app_read;

-- Write-level
-- GRANT app_read TO app_write;                          -- inheritance: app_write also reads
-- GRANT INSERT, UPDATE, DELETE ON fruits TO app_write;

-- Revoke
-- REVOKE INSERT ON fruits FROM app_write;

-- Inspect your privileges on a table
SELECT grantee, privilege_type
FROM information_schema.role_table_grants
WHERE table_name = 'fruits' AND grantee = current_user;

-- Row-level security: per-row visibility decided by a policy
-- (demo on a separate table so we don't change `fruits` permissions)
DROP TABLE IF EXISTS tenant_docs;
CREATE TABLE tenant_docs (
  id       integer PRIMARY KEY,
  tenant   text NOT NULL,
  content  text NOT NULL
);
INSERT INTO tenant_docs VALUES (1, 'acme', 'hello'), (2, 'globex', 'world');

ALTER TABLE tenant_docs ENABLE ROW LEVEL SECURITY;
-- Apply the policy to the table owner as well (superusers/BYPASSRLS still bypass it).
ALTER TABLE tenant_docs FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON tenant_docs
  USING (tenant = current_setting('app.tenant', true));

-- Set the tenant for the session and try a select
SET app.tenant TO 'acme';
SELECT * FROM tenant_docs;        -- only sees the 'acme' row

SET app.tenant TO 'globex';
SELECT * FROM tenant_docs;        -- only sees the 'globex' row

-- Cleanup
DROP POLICY IF EXISTS tenant_isolation ON tenant_docs;
DROP TABLE IF EXISTS tenant_docs;

表权限包括 SELECT、INSERT、UPDATE、DELETE、TRUNCATE、REFERENCES、TRIGGER,以及 PostgreSQL 17 中的 MAINTAIN。模式上最重要的权限是 USAGE(允许角色引用其中的对象)和 CREATE(允许角色创建新对象)。数据库上的 CONNECT、CREATE 和 TEMPORARY 也很重要。

表上的权限不会从模式级授权中继承。要使用模式 public 中的表,角色需要在 public 上拥有 USAGE,并且在该表上拥有某项权限(例如 SELECT)。缺少模式级 USAGE 时,就会出现常见的“permission denied for schema public”错误。

DEFAULT PRIVILEGES 解决了“新表没有授权”的问题:角色以后创建的任何对象都会自动继承配置好的授权。这些配置只适用于指定的创建角色所创建的对象,不会自动适用于每个角色创建的对象。对于会创建表的每个迁移/所有者角色,都要分别进行配置。

行级安全(RLS)是一个更高级的功能:你可以在表上启用 ENABLE ROW LEVEL SECURITY,并编写根据谓词过滤行的 POLICY。启用 RLS 后,如果当前角色受该策略约束,类似 SELECT * FROM tenant_docs 的查询只会返回当前角色或设置所允许看到的行。可以自由修改的 app.tenant 设置只是演示;真实应用必须阻止不受信任的客户端选择其他租户。

运行示例

运行示例时,请使用一个不具备超级用户权限、没有 BYPASSRLS 属性,并且能够在练习数据库中创建表的角色。FORCE ROW LEVEL SECURITY 会让这张表的所有者也遵守策略。超级用户仍然可以看到两个租户。带注释的角色管理命令需要相应的角色管理权限。

$ psql -f source/roles-and-grant.sql postgres
 current_user | session_user
--------------+--------------
 your_user    | your_user
(1 row)
 ...

常见陷阱

  • 忘记 GRANT USAGE ON SCHEMA public。角色可能已经对每张表都拥有 SELECT,但仍然无法查询,因为它缺少模式级的使用权限。
  • 只为现有表授予权限,却没有配置 DEFAULT PRIVILEGES——应用创建的每张新表,读角色都无法访问。
  • 将 SECURITY DEFINER 函数与宽松的 search_path 混用,会打开权限提升的通道。此类函数应始终设置 search_path。
  • 在 CREATE ROLE ... PASSWORD 'plain' 中存储密码,对个人数据库来说可以接受;生产环境应使用 SCRAM 身份验证,绝不要在 SQL 脚本中硬编码密码。
提示

一个常见模式是使用三个角色:app_owner(拥有对象,用于迁移)、app_read(供分析和副本使用的只读角色)以及 app_write(供应用使用的读写角色)。让 app_owner 拥有对象,再向其他角色授予权限。

动手练习

使用超级用户权限创建一个角色,只向它授予 fruits 上的 SELECT。然后以该角色重新连接(psql -U newrole),确认可以读取但不能写入。接着试验 RLS:在一张表上启用它,添加策略,并观察 SET 改变后 SELECT 的结果如何变化。

下载

SQL 文件:roles-and-grant.sql · 全部示例文件 · 准备工作

56 psql 元命令

使用 psql 命令探索对象、格式化结果、运行脚本并自定义会话。

psql 的元命令(也称反斜线命令)是客户端快捷操作,可以让交互式数据库探索变得快速而愉快。它们由客户端解释,而不是由服务器解释——在交互式环境中和通过 psql -f 读取的文件中都能工作。一旦用熟了这些命令,日常工作中的大多数场景都不再需要打开 GUI 工具。

本章要点

  • 基本的探索命令(\l、\dt、\d、\df、\dn)
  • 帮助快捷命令(\?、\h)
  • 输出和格式化命令(\x、\timing、\pset)
  • 运行脚本和保存输出(\i、\o、\copy)
  • 使用 ~/.psqlrc 自定义会话

示例

示例 56.1 psql 元命令
psql command reference (backslash commands also work in psql -f scripts):

Exploration
  \l                    list databases
  \c dbname             connect to a database
  \dn                   list schemas
  \dt                   list tables in the current search_path
  \dt schema.*          list tables in a specific schema
  \dt *.*               list every table everywhere
  \d table              describe a table (columns, types, indexes, constraints)
  \d+ table             describe a table verbosely (storage, comments)
  \di                   list indexes
  \dv                   list views
  \dm                   list materialized views
  \df pattern           list functions whose name matches
  \du                   list roles (users/groups)
  \dx                   list installed extensions

Help
  \?                    list all meta-commands
  \h                    list SQL commands
  \h SELECT             help on the SELECT statement

Session/output
  \x                    toggle expanded display (column-per-line)
  \timing               toggle showing query timing
  \pset pager off       disable the less-style pager
  \pset format aligned  default table format; alternatives include csv, html, unaligned
  \a                    toggle "unaligned" mode (use format csv for CSV escaping)
  \o file               redirect query output to a file (\o without arg disables)

Files and scripts
  \i file.sql           run a SQL script from inside psql
  \ir file.sql          run relative to the current script
  \copy table FROM 'file.csv' WITH (FORMAT csv, HEADER true)  client-side import
  \copy (SELECT ...) TO 'file.csv' WITH (FORMAT csv, HEADER true)  client-side export

Editing
  \e                    open the last query in $EDITOR (or write a new one)
  \ef function_name     open a function definition in $EDITOR
  \ev view_name         open a view definition in $EDITOR
  \q                    quit

psql -f script.sql 会同时处理 SQL 和反斜线命令。下面链接的 SQL 文件展示了查询系统目录的替代方案;当调用方是数据库驱动而不是 psql 时,这些方案很有用。不要把本命令参考中的说明性标签粘贴到脚本中。

最有用的命令是 \d table(以及它的详细版本 \d+ table)。它会显示列、类型、默认值、索引、约束、外键和触发器——你需要了解一张表结构的所有信息。

最有用的显示切换命令是 \x(扩展显示)。对于有 30 列的行,默认的表格输出无法阅读;扩展显示会将 column | value 按每行一项打印。使用 \x auto,只有当行对终端来说太宽时才切换显示方式。

\copy 是 COPY 在客户端一侧的对应命令。它在客户端机器上读写文件;COPY 则在服务器上读写文件。要将 CSV 装载到远程 PostgreSQL 中,应使用 \copy。

运行示例

运行本课的 SQL 部分:

$ psql -f source/psql-meta-commands.sql postgres
 ...
 current_database | current_user | current_schema
------------------+--------------+----------------
 postgres         | your_user    | public
(1 row)
 ...

常见陷阱

  • 通过数据库驱动直接发送 \dt 会失败,因为它是 psql 语法。它可以在交互式环境中工作,也可以在由 psql -f 执行的文件中运行。
  • 不带参数的 \d 会列出当前 search_path 中的所有内容——输出很长。可以使用 \dt、\di、\dv 等命令进行筛选。
  • \copy 和 COPY:\copy 在客户端运行(因此相对路径是客户端路径,CSV 文件来自你的机器);COPY 在服务器上运行。根据文件所在位置选择正确的命令。
  • 你的 psqlrc 会在每次会话开始时运行。其中一条语句损坏,就可能让 psql 无法使用,直到你修复该文件。
提示

~/.psqlrc 用来保存你的自定义设置。常见条目包括:

\set PROMPT1 '%n@%/%x%R%# '
\set HISTSIZE 5000
\timing on
\pset null '∅'
\set COMP_KEYWORD_CASE upper
动手练习

在交互式 psql postgres 会话中,运行 \dt、\d fruits、\df *agg*,然后运行 \h SELECT。切换 \x,再运行 SELECT * FROM pg_indexes LIMIT 1;,查看扩展显示。运行一个查询并将输出通过管道写入文件:\o out.txt、SELECT * FROM fruits;、\o(停止重定向)。

下载

SQL 文件:psql-meta-commands.sql · 全部示例文件 · 准备工作

57 COPY

使用服务端 COPY 和客户端 psql copy 命令导入和导出 CSV 数据。

COPY 是 PostgreSQL 的批量数据导入/导出命令。它以 CSV、文本或二进制格式,在表与文件(或标准输入/输出)之间移动行。与 INSERT 相比,COPY 快得多——有时能快 10–100 倍——因为它跳过了大量逐行开销。要装载大量数据时,优先使用 COPY。

本章要点

  • 使用 COPY ... TO 导出,以及使用 COPY ... FROM 导入
  • COPY(服务端)与 \copy(客户端)的区别
  • CSV 格式以及 HEADER、QUOTE、DELIMITER 选项
  • 通过 STDIN / STDOUT 进行流式传输
  • 为什么批量装载时 COPY 胜过 INSERT

示例

示例 57.1 COPY
-- Export to STDOUT in CSV (the rows go to your terminal when run via `psql -f`)
COPY (SELECT id, name FROM fruits ORDER BY id) TO STDOUT WITH (FORMAT csv, HEADER true);

-- Export a whole table
-- (commented because most workflows use the CTAS / pg_dump path instead)
-- COPY fruits TO STDOUT WITH (FORMAT csv, HEADER true);

-- Server-side import: the SERVER must read the path.
-- This is rarely what you want unless you control the server filesystem.
-- COPY fruits FROM '/srv/data/fruits.csv' WITH (FORMAT csv, HEADER true);

-- Client-side import: the psql CLIENT reads the file from your machine.
-- In interactive psql:
-- \copy fruits FROM '/Users/you/Desktop/fruits.csv' WITH (FORMAT csv, HEADER true)

-- COPY can also stream from STDIN — used by drivers for bulk loading.
-- Example shows the protocol; the actual rows would follow the command in a stream.
-- COPY fruits FROM STDIN WITH (FORMAT csv, HEADER true);

COPY ... TO STDOUT 会写入连接的输出流。使用 psql -f 运行 SQL 文件时,这个流就是你的终端——很适合临时导出。可以从 Shell 重定向输出,将结果保存到文件:psql -f source/copy.sql postgres > out.csv。

COPY ... FROM <path> 会从服务器上的文件读取数据。PostgreSQL 进程需要拥有读取权限;在托管云数据库上,你通常没有这种权限。客户端一侧的 \copy(一种 psql 元命令)会从你的机器读取文件,通过连接将其传输,并在服务器上插入——这与 COPY FROM STDIN 的行为完全相同,只是通过一个便捷封装来实现。

对于编写批量装载代码的应用,几乎每个驱动都提供“COPY FROM STREAM” API(psycopg 的 copy_from、pgx 的 CopyFrom 等)。装载几千行时,它比分批执行的 INSERT 快得多;对于数百万行,它通常是最佳起点,不过收益取决于工作负载。

COPY FROM 会在目标表上调用触发器,并强制执行检查约束和外键约束。触发器的工作因此会计入装载成本。它不会调用规则。暂存表适合在将数据插入最终表之前,对装载内容进行验证和转换。

运行示例

运行方式:

$ psql -f source/copy.sql postgres
id,name
1,apricot
2,blueberry
3,chestnut
4,damson
5,elderberry
101,fruit_1
102,fruit_2
103,fruit_3

常见陷阱

  • 混淆 COPY 和 \copy:前者在服务器上运行(路径是服务器路径,权限是服务器权限);后者在客户端运行(路径是本地路径)。一个常见错误是使用 COPY ... FROM '~/file.csv',然后收到“permission denied”,因为服务器无法读取你的主目录。
  • 编码不匹配:文件使用 ENCODING 选项指定的编码;如果没有该选项,则使用当前客户端编码。编码错误的 CSV 会产生乱码或“invalid byte sequence”错误。使用 WITH (ENCODING 'utf8'),或者事先进行转换。
  • 包含嵌入换行符的 CSV 需要 QUOTE 和 ESCAPE 选项。默认值(两者都是 ")通常可以正常工作。
  • 在大规模装载中使用带约束的 COPY 会很慢。一个技巧是:先装载到暂存表,去重/校验,然后再通过 INSERT ... ON CONFLICT 插入目标表。
提示

对于非常大的导入,在 COPY 前删除索引(主键除外),然后重新创建。这样 COPY 会更快,而且最后一次性建立索引,比在数百万行上逐行维护索引更快。将操作包在一个事务中,这样失败时就能回滚。

动手练习

将 fruits 导出到文件:psql -f source/copy.sql postgres > out.csv。然后将它重新导入暂存表:在一个 psql 会话中创建 fruits_stage,再单独占一行运行 \copy:

CREATE TEMP TABLE fruits_stage (id integer, name text);
\copy fruits_stage FROM 'out.csv' WITH (FORMAT csv, HEADER true)
SELECT count(*) FROM fruits_stage;

比较行数。

下载

SQL 文件:copy.sql · 全部示例文件 · 准备工作

返回目录

技术勘误

对固定英文源版本的修订,以及相应的 PostgreSQL 参考资料。

英文网页版修正了固定源版本中的技术错误,并在每页的归属声明中标明改编情况。原仓库和许可仍可从出版与许可页查阅。

章节顺序保持不变。三个含逗号的标题已根据各章标题还原,OINK 提供按章序翻页的导航。

页面展示的 SQL 与可下载的 SQL 使用同一份修订后示例。第 54 章和第 56 章则分别展示 Shell 示例和命令参考,并附有 SQL 配套文件。

终端输出仅作示意:日期、UUID、用户、系统目录列表、统计信息和查询计划都取决于运行环境。省略号表示省去的输出。

1. 第一次查询

阅读修订后的章节。

客户端与服务器版本的说明现在区分了已连接服务器报告的版本和本地 psql 可执行文件的版本。

PostgreSQL 文档: 参考 1。

2. psql 基础

阅读修订后的章节。

psql 一章现在说明,-f 会处理反斜线命令;这些命令属于客户端语法,可能向服务器发出系统目录查询。

PostgreSQL 文档: 参考 1。

3. SELECT 基础

阅读修订后的章节。

SELECT 的说明不再承诺 SQL 本身不保证的求值顺序。

PostgreSQL 文档: 参考 1。

4. WHERE

阅读修订后的章节。

已修复用于生成数列的可运行查询;关于 ILIKE 和 NULL 谓词的说明现在符合 PostgreSQL 规划器及三值逻辑的实际行为。

PostgreSQL 文档: 参考 1、参考 2、参考 3。

7. LIMIT 与 OFFSET

阅读修订后的章节。

OFFSET 开销示例现在准确说明了要处理的行数,修正了相差一行的计数错误。

PostgreSQL 文档: 参考 1。

8. DISTINCT

阅读修订后的章节。

DISTINCT 示例现在正确说明,在选中的表达式外加括号不会改变用于去重的键。

PostgreSQL 文档: 参考 1。

9. 空值(NULL)

阅读修订后的章节。

NULL 比较的说明现在区分了结果为未知的比较、DISTINCT 的分组方式以及 IS DISTINCT FROM。

PostgreSQL 文档: 参考 1。

10. 表达式

阅读修订后的章节。

类型转换示例使用了含义明确的输出列名。

PostgreSQL 文档: 参考 1。

11. 数值类型

阅读修订后的章节。

数值范围、溢出行为,以及未指定精度和标度的 numeric 类型的限制,现在都有更准确的说明。

PostgreSQL 文档: 参考 1。

12. 文本类型

阅读修订后的章节。

字符补空格示例现在区分了字符长度与存储所占的字节长度,也修正了排序规则示例的标签。

PostgreSQL 文档: 参考 1。

13. 布尔值与日期

阅读修订后的章节。

日期与时间一章现在正确说明了 timestamptz 的显示语义,以及 current_timestamp 与 clock_timestamp 的区别。

PostgreSQL 文档: 参考 1。

14. UUID 与 JSONB

阅读修订后的章节。

UUIDv7 的支持情况现在明确区分版本:PostgreSQL 17 没有内置生成器,PostgreSQL 18 则提供 uuidv7()。

PostgreSQL 文档: 参考 1。

15. 数组与枚举

阅读修订后的章节。

数组下标的说明现在保留了 PostgreSQL 默认下界这一限定,不再将其说成一成不变的规则。

PostgreSQL 文档: 参考 1。

17. 列类型与约束

阅读修订后的章节。

复合约束和涉及多列的 CHECK 表达式应写在何处,现在有了准确的说明。

PostgreSQL 文档: 参考 1。

18. ALTER TABLE 与 DROP

阅读修订后的章节。

ALTER TABLE 的说明现在区分了需要重写表的变更与二进制兼容的变更,并更严谨地说明了重命名与依赖关系。

PostgreSQL 文档: 参考 1。

19. 主键与唯一约束

阅读修订后的章节。

关于 NULLS NOT DISTINCT 的建议不再把部分唯一索引当作等价方案。

PostgreSQL 文档: 参考 1、参考 2。

21. NOT NULL 与 DEFAULT

阅读修订后的章节。

表默认值的讨论现在区分了不具有易变性的 current_timestamp 与 random() 这类具有易变性的默认值。

PostgreSQL 文档: 参考 1、参考 2。

22. 标识列与序列

阅读修订后的章节。

序列对象由各个会话共享;currval() 的状态则保存在各自的会话中。

PostgreSQL 文档: 参考 1。

23. 模式与 search_path

阅读修订后的章节。

SECURITY DEFINER 和 search_path 的建议现在采用受信任的搜索路径,并将 pg_temp 放在最后;同时明确指出,删除模式的操作在事务提交前可以回滚。

PostgreSQL 文档: 参考 1、参考 2。

25. UPSERT(ON CONFLICT)

阅读修订后的章节。

ON CONFLICT 示例现在使用实际的约束名,并补充了部分索引注意事项的适用条件。

PostgreSQL 文档: 参考 1。

26. UPDATE

阅读修订后的章节。

更新一章现在用 IS DISTINCT FROM 检查值是否发生变化,以正确处理 NULL。

PostgreSQL 文档: 参考 1。

27. DELETE

阅读修订后的章节。

DELETE 与 TRUNCATE 的行为说明现在区分了行级触发器、外键检查、加锁、WAL 和回滚语义。

PostgreSQL 文档: 参考 1、参考 2、参考 3。

28. RETURNING

阅读修订后的章节。

RETURNING 语法的说明以 PostgreSQL 17 为准,并另行标明 PostgreSQL 18 对 old/new 的扩展。

PostgreSQL 文档: 参考 1、参考 2。

29. 内连接与左连接

阅读修订后的章节。

连接一章的提醒不再声称 PostgreSQL 会对合法的交叉连接发出警告,也不再把缺少连接条件的 INNER JOIN 当作合法语法。

PostgreSQL 文档: 参考 1。

32. UNION、INTERSECT 与 EXCEPT

阅读修订后的章节。

现在分别说明了 UNION、INTERSECT 和 EXCEPT 的 ALL 形式各自如何处理重复项。

PostgreSQL 文档: 参考 1。

33. COUNT、SUM 与 AVG

阅读修订后的章节。

聚合函数处理 NULL 的行为不再一概而论;array_agg 和 jsonb_agg 等聚合函数会保留 NULL 输入。

PostgreSQL 文档: 参考 1。

35. HAVING

阅读修订后的章节。

WHERE/HAVING 的性能建议现在考虑到了 PostgreSQL 可能将适合的谓词下推。

PostgreSQL 文档: 参考 1。

36. 标量子查询与 IN 子查询

阅读修订后的章节。

标量子查询现在正确表述为至多返回一行,返回零行时结果为 NULL。

PostgreSQL 文档: 参考 1、参考 2。

37. EXISTS 与派生表

阅读修订后的章节。

派生表别名的建议现在说明,PostgreSQL 16 起可以省略别名,同时保留了出于可移植性考虑仍应指定别名的建议。

PostgreSQL 文档: 参考 1。

38. 公用表表达式(WITH)

阅读修订后的章节。

关于公用表表达式(CTE)折叠、递归查询中 UNION 的选择、递归不终止的情况以及数据修改 CTE 执行顺序的说明,现在不再作出没有依据的保证。

PostgreSQL 文档: 参考 1。

39. 窗口函数

阅读修订后的章节。

窗口帧的默认行为,以及 lag/lead 需要明确排序的要求,现在都有准确的说明。

PostgreSQL 文档: 参考 1、参考 2。

40. 字符串与数值函数

阅读修订后的章节。

示例现在区分了随机整数生成与直接对浮点数进行类型转换,并修正了舍入行为的说明。

PostgreSQL 文档: 参考 1。

41. 日期函数与 COALESCE

阅读修订后的章节。

日期分桶与 extract(epoch) 的说明现在与 PostgreSQL 17 的返回类型和日期函数保持一致。

PostgreSQL 文档: 参考 1。

42. CASE

阅读修订后的章节。

FILTER 与 CASE 的比较不再声称两者在空结果上的行为完全相同。

PostgreSQL 文档: 参考 1。

43. 创建索引

阅读修订后的章节。

多列索引的建议现在考虑了现代规划器可选的方案、锁模式,以及 HOT 更新的行为。

PostgreSQL 文档: 参考 1、参考 2、参考 3。

44. 何时创建索引

阅读修订后的章节。

索引选择建议去除了通用的选择率百分比阈值,以及适用范围过宽的冗余索引判断规则。

PostgreSQL 文档: 参考 1、参考 2、参考 3。

45. EXPLAIN 与查询计划

阅读修订后的章节。

EXPLAIN 示例中的聚合现在统计实际匹配的子表行数;仅索引扫描的说明也补充了可见性映射与堆访问检查之间的关系。

PostgreSQL 文档: 参考 1、参考 2。

46. BEGIN、COMMIT 与 ROLLBACK

阅读修订后的章节。

事务持久性与试运行局限的说明现在涵盖了正常的持久性设置、序列递增、锁和外部影响;双会话练习也会先重新创建示例表。

PostgreSQL 文档: 参考 1、参考 2、参考 3。

48. 创建视图

阅读修订后的章节。

CREATE OR REPLACE VIEW 的限制现在与使用 ALTER VIEW 重命名列的操作区分开来。

PostgreSQL 文档: 参考 1。

49. 物化视图

阅读修订后的章节。

REFRESH MATERIALIZED VIEW CONCURRENTLY 现在明确表述为重新执行完整查询的刷新操作,要求物化视图已填充数据,并具有合适的唯一索引。

PostgreSQL 文档: 参考 1、参考 2。

50. 函数与 PL/pgSQL

阅读修订后的章节。

已修正函数易变性、SECURITY DEFINER 的搜索路径以及 PL/pgSQL 语句语法的说明。

PostgreSQL 文档: 参考 1、参考 2。

51. 触发器

阅读修订后的章节。

触发器返回值的说明现在区分了 INSERT/UPDATE 与 DELETE,练习也考虑到了脚本中的清理操作。

PostgreSQL 文档: 参考 1。

52. 全文检索

阅读修订后的章节。

全文检索练习现在提醒读者,应在脚本删除示例表之前执行练习。

PostgreSQL 文档: 参考 1、参考 2。

53. VACUUM 与 ANALYZE

阅读修订后的章节。

VACUUM 对可见性映射的作用、尾部页截断以及自动清理的建议,现在都有了更精确的说明。

PostgreSQL 文档: 参考 1。

54. pg_dump 与 pg_restore

阅读修订后的章节。

已修正关于 pg_dump 快照、全局对象、版本兼容性和恢复行为的说明,并补充了适用条件。

PostgreSQL 文档: 参考 1、参考 2。

55. 角色与 GRANT

阅读修订后的章节。

角色与 RLS 示例现在对表所有者也强制实施行级安全,并说明了租户设置可被任意修改所带来的局限。

PostgreSQL 文档: 参考 1、参考 2、参考 3、参考 4。

56. psql 元命令

阅读修订后的章节。

psql 命令参考现在准确涵盖了通过 -f 执行的文件、SQL 替代方式以及 CSV 输出模式。

PostgreSQL 文档: 参考 1。

57. COPY

阅读修订后的章节。

已修正 COPY FROM 的触发器行为、编码选择、批量装载性能,以及可直接执行的多行 \copy 练习。

PostgreSQL 文档: 参考 1、参考 2。

出版与许可

《PostgreSQL 实例教程》的作者、源版本、许可与编校修订说明。

Postgres by Example(《PostgreSQL 实例教程》)的作者是 Dariush Abbasi(dariubs)。原版维护于 github.com/boringcollege/postgres-by-example。

本书及其可运行的示例采用 Creative Commons 署名 4.0 国际许可协议。详见许可协议全文及本网页版附带的许可文件。

本中文版所依据的英文网页版以源仓库的提交 ffde0d358722498e37982b57bdbd711871a61c43 为基础,增加了 OINK 书籍导航、稳定的章节 URL、示例编号、整书打印视图、Markdown 导出,以及可下载的 SQL 示例包。

该英文版对讲解、SQL 和示意性输出作了技术修订。页面展示的 SQL 与对应的下载文件保持同步。这些编校修订已在页面归属声明中标明,并非原作者所作的修改。

上游许可文件的开头写着“Python by Example”,看起来是复制时遗留的标题。为保留来源凭据,本版原样保存了该文件。源仓库的 README 与书籍元数据均标明,作品是 Postgres by Example,采用 CC BY 4.0 许可。

中文版由 Codex 依据已作技术勘误的英文网页稿翻译,并由同一执行者分轮自检;未经独立人工审定。原作者、英文网页编校与本轮中文翻译的职责分别如上所述。