💡 面向 Python / AI 应用开发岗 面试的 PostgreSQL 知识库,共 24 章。按「面试出现频率 + 实际开发价值」组织。

🚀 学习主线

1
SQL → JOIN → 索引 → EXPLAIN → 事务 → 隔离级别 → MVCC → 锁 → VACUUM → WAL → 连接池

📋 目录

📑 总览

📗 基础篇

📘 原理篇

📙 进阶篇

📕 实战篇


📑 总览

PostgreSQL 八股文知识库 · 总览

💡 面向 Python / AI 应用开发岗 面试的 PostgreSQL 知识库,共 24 章。按「面试出现频率 + 实际开发价值」组织,不是数据库教材的顺序。

🚀 学习主线(时间紧就打这条线)

1
SQL → JOIN → 索引 → EXPLAIN → 事务 → 隔离级别 → MVCC → 锁 → VACUUM → WAL → 连接池

🔥 十大核心章节

# 章节 为什么重要
1 SQL查询基础 面试必问基本功
2 JOIN与复杂查询 面试官最爱现场手写
3 索引 数据库八股的核心章节
4 EXPLAIN与SQL优化 结合项目讲最有说服力
5 事务ACID 超高频开场题
6 事务隔离级别 经典大题,考理解深度
7 MVCC PG 面试的灵魂章节
8 锁与并发控制 核心章节,结合业务场景考
9 VACUUM PG 相比 MySQL 最有代表性的题
10 WAL与数据库恢复 经典 PG 八股

📚 全部章节

基础篇

原理篇

实战篇

对比与项目篇

🧩 灵魂四件套:串起来学

MVCCVACUUMWAL与数据库恢复锁与并发控制 这四章不要单独背答案,画出一条 tuple 的完整生命周期

1
2
3
4
5
6
7
INSERT(写入新 tuple, xmin=当前事务)

UPDATE(产生新版本;旧版本 xmax=当前事务 → 变 Dead Tuple)

DELETE(同样只是打标记,不立刻删)

VACUUM(回收 Dead Tuple;老版本靠 WAL 重放才能恢复;全程由锁保证并发安全)

一条记录走完这四个阶段,PostgreSQL 的八股基本就全串起来了。


📗 基础篇

第1章 PostgreSQL 基础与整体架构

💡 一句话核心:PostgreSQL 是「一个连接一个进程」的多进程架构,一条 SQL 经过 Parser → Analyzer → Rewriter → Planner → Executor 五个阶段执行;开场题答出进程模型差异 + SQL 生命周期,就能立住基本功。

概念详解

1.1 PostgreSQL 是什么?相比 MySQL 有什么特点

PostgreSQL(常简写 Postgres)是开源的对象-关系型数据库(Object-Relational DBMS),以「SQL 标准兼容性好、扩展性强、数据一致性严格」著称。

维度 PostgreSQL MySQL (InnoDB)
进程模型 一个连接一个 backend 进程 单进程多线程(每连接一个线程)
MVCC 实现 多版本元组直接存表里,靠 VACUUM 清理(MVCC 旧版本放 undo log
类型系统 丰富:JSONB、数组、范围类型、自定义类型 相对简单
SQL 标准兼容 高(CTE、RETURNINGFILTER、部分索引等) 方言化程度更高
擅长场景 复杂查询、分析、GIS(PostGIS)、JSON 文档 读多写少的互联网 OLTP 生态更成熟

一句话结论:复杂查询多、数据完整性要求高、JSON/全文检索重,选 PG;极致简单的读多写少场景,MySQL 生态更顺手(详见 PostgreSQL与MySQL对比)。

1.2 进程模型:postmaster 与 backend process

  • postmaster 是主守护进程:监听端口(默认 5432),负责实例的启动、关闭和子进程管理。
  • 每来一个客户端连接,postmaster 认证通过后 fork 出一个独立的 backend process(本身也是一个 postgres 进程) 专门服务这条连接,连接断开进程退出。
  • 常驻后台进程:checkpointer、bgwriter、walwriter、autovacuum launcher(按需拉起 autovacuum worker)、WAL archiver 等。
  • 内存分两层:所有 backend 共享的 Shared Memory(shared buffers 数据页缓存、WAL buffer、锁表等),每个 backend 另有私有内存(work_memmaintenance_work_mem)。
1
2
3
4
5
6
7
Client A ──┐                          ┌─ backend A (postgres 进程)
Client B ──┼── TCP:5432 → postmaster ─┼─ backend B
Client C ──┘ └─ backend C
后台: checkpointer / bgwriter / walwriter / autovacuum launcher
共享内存: shared buffers / WAL buffer / 锁表
↓ 先写 WAL (pg_wal) ↓ 再读写
Data Files (base/ 下的表和索引文件)

影响(面试加分点)

  • 连接 = fork 进程,开销大(进程私有内存以 MB 计),所以 PG 高并发必须配连接池(PgBouncer,见 连接与连接池)。
  • 进程隔离强:单个 backend 崩溃不会污染其他连接;但 postmaster 检测到 backend 崩溃会杀掉所有进程、重启实例做崩溃恢复,保证共享状态一致。
  • 对比 MySQL:线程更轻、上下文切换开销小,但所有线程共享进程地址空间,隔离性弱于进程模型。

1.3 基本架构组成

组件 作用
Client psql / 应用驱动,通过 TCP 或 Unix socket 发 SQL
postmaster 主控进程:监听、认证、fork backend
Backend Process 每连接一个,负责解析、规划、执行 SQL
Shared Memory shared buffers(缓冲池)、WAL buffer、锁表
WAL (pg_wal) 预写日志:先写日志再刷数据页,崩溃恢复的根基(WAL与数据库恢复
Data Files (base/) 表、索引的真实数据文件
Background Workers autovacuum、checkpoint、后台统计等

1.4 一条 SQL 从发送到执行经历了什么

1
2
3
4
5
6
SQL 文本
↓ ① Parser(解析器): 词法+语法分析 → 原始语法树;SQL 写错在这步报 syntax error
↓ ② Analyzer(分析器): 语义分析,查系统表确认表/列存在、解析类型 → 查询树 (Query)
↓ ③ Rewriter(重写器): 视图展开、规则系统改写 → 改写后的查询树
↓ ④ Planner/Optimizer(规划器): 基于代价选择扫描方式(Seq/Index Scan)、JOIN 顺序与方法 → 计划树
↓ ⑤ Executor(执行器): 按计划树逐层拉取元组(火山模型),返回结果集

要点:

  • PG 是基于代价的优化器(CBO):依赖 ANALYZE 收集的统计信息 + 代价模型(顺序读/随机读相对成本),详见 PostgreSQL查询优化器
  • 执行器经典是火山模型(逐条 tuple 迭代);PG9.6+ 支持并行查询,PG11+ 对复杂表达式支持 LLVM JIT。
  • 预编译语句(prepared statement)会缓存计划,执行多次后优化器可能切换为通用计划——这是常见的追问点。

1.5 数据库、Schema、Table 的关系

1
2
3
4
实例/集簇 (PGDATA, database cluster)
└── Database(CREATE DATABASE;一条连接同一时刻只能进一个库)
└── Schema(命名空间,默认 public)
└── Table / View / Index / Sequence / Function ...
  • 同一实例可建多个 database,但一条连接不能直接跨库 JOIN——跨库要用 postgres_fdw(外部表)或 dblink。
  • schema 用于命名空间隔离和权限分组:常见做法是一个业务模块一个 schema、多租户一个租户一个 schema。
  • 引用对象写 schema.table,解析顺序由 search_path 控制。

1.6 常见数据类型

类别 类型 说明
整数 smallint / int / bigint 主键一般用 bigint
精确小数 numeric(p,s) 金额必须用它,不能用 float
浮点 real / double precision 有精度误差
文本 varchar(n) / char(n) / text PG 中三者性能无差别,推荐 text
时间 date / time / timestamp / timestamptz / interval 推荐 timestamptz(内部 UTC 存储,带时区)
JSON json / jsonb jsonb 二进制存储、可索引,见 JSONB与GIN
数组 text[] / int[] PG 特色,ARRAY['a','b']
布尔 boolean true / false / null
唯一标识 uuid PG13+ 内置 gen_random_uuid()
其他 bytea、enum、inet、range 类型 按需使用

1.7 SERIAL 和 IDENTITY 的区别

1
2
3
4
5
-- 老写法 SERIAL(非 SQL 标准):本质是 int 列 + DEFAULT nextval(序列)
CREATE TABLE t1 (id SERIAL PRIMARY KEY);

-- 新写法 IDENTITY(PG10+,SQL 标准,推荐)
CREATE TABLE t2 (id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY);
对比 SERIAL IDENTITY
来源 PG 习惯用法 SQL 标准
本质 列 + DEFAULT 绑定一个序列 列的”生成身份”属性,序列归属更清晰
权限与安全 序列可以被普通会话随意 nextval 更严格,减少误用
手动插值 随意可插 GENERATED ALWAYS 禁止(需 OVERRIDING SYSTEM VALUE);BY DEFAULT 允许
结论 旧代码兼容 新项目一律用 IDENTITY

1.8 NULL 的含义与三值逻辑

  • NULL 表示「未知(UNKNOWN)」,不是 0、不是空字符串。
  • 逻辑值有三种:TRUE / FALSE / NULL(UNKNOWN);任何值与 NULL 的比较(=<>>LIKE…)结果都是 NULL
  • WHERE 只保留条件为 TRUE 的行 → WHERE nickname <> '张三' 查不出 nickname 为 NULL 的行
  • NULL = NULL 结果是 NULL → 判空必须 IS NULL / IS NOT NULL;要表达「值相等或都为 NULL」用 IS NOT DISTINCT FROM
  • 聚合函数忽略 NULL(COUNT(*) 例外;COUNT(col) 不统计 NULL)。
  • NOT IN (含 NULL 的子查询) 结果恒为空(第 2 章重点坑)。
  • UNIQUE 约束允许多个 NULL 共存;CHECK 约束对 NULL 放行(UNKNOWN 视为通过)。

高频面试题

Q:PostgreSQL 和 MySQL 的主要区别?

答题思路:进程模型 → MVCC 实现 → 功能(类型系统/SQL 标准)→ 适用场景,挑 3~4 个讲透。
参考回答:架构上 PG 是一个连接一个进程,MySQL 是单进程多线程,所以 PG 更依赖连接池。MVCC 上 PG 把多版本元组直接存在表里、靠 VACUUM 清理,MySQL 旧版本放 undo log,因此 PG 需要关注表膨胀和 autovacuum。功能上 PG 类型系统更丰富(JSONB、数组、范围类型),SQL 标准兼容更好(CTE、RETURNING、FILTER、部分索引、表达式索引),复杂查询和 GIS、分析场景更强。选型上:复杂业务、JSON 重、分析型负载选 PG;简单读多写少且团队熟悉 MySQL 生态的也可以选 MySQL。

Q:讲讲 PostgreSQL 的整体架构和一条 SQL 的执行流程?

答题思路:先画架构图(postmaster / backend / 共享内存 / WAL / 数据文件),再讲五步流程,每步一句话。
参考回答:postmaster 监听 5432,每个连接 fork 一个 backend 进程服务;共享内存里 shared buffers 缓存数据页;写操作先记 WAL 再刷数据页,保证崩溃可恢复。一条 SQL:Parser 做词法语法分析产出语法树;Analyzer 结合系统表做语义检查、类型解析,产出查询树;Rewriter 展开视图和规则;Planner 基于统计信息做基于代价的优化,决定扫描方式、JOIN 顺序和连接方法,产出计划树;Executor 用火山模型逐条执行返回。EXPLAIN 看到的就是第 4 步的产出。

Q:为什么 PG 每个连接一个进程?有什么优缺点?

参考回答:优点是隔离性强——每个连接有自己的私有内存,单个 backend 崩溃不污染其他连接,postmaster 可以统一重启做崩溃恢复。缺点是连接昂贵:fork 进程加上几 MB 私有内存,几千个直连就可能耗尽资源,所以生产环境必须上连接池,比如 PgBouncer,把几百个应用连接复用成几十个真实 backend。MySQL 的线程模型连接更轻,但隔离性弱一些。

Q:SERIAL 和 IDENTITY 用哪个?区别是什么?

参考回答:推荐 IDENTITY(PG10+,SQL 标准)。SERIAL 是 PG 老写法,本质是 int 列的 DEFAULT 绑定一个序列;IDENTITY 语义更清晰、权限更严格,而且 GENERATED ALWAYS AS IDENTITY 能阻止应用手动乱插主键值(除非显式 OVERRIDING SYSTEM VALUE)。GENERATED BY DEFAULT AS IDENTITY 则允许手动指定。新项目一律用 IDENTITY,SERIAL 只在维护老代码时遇到。

Q:为什么 WHERE col <> '张三' 查不出 col 为 NULL 的行?

参考回答:SQL 是三值逻辑:任何值与 NULL 比较的结果是 UNKNOWN 而不是 FALSE,而 WHERE 只保留结果为 TRUE 的行,UNKNOWN 会被过滤。所以 NULL 行既不满足 = '张三' 也不满足 <> '张三'。要包含 NULL 得写 (col <> '张三' OR col IS NULL),或者用 col IS DISTINCT FROM '张三'

Q:Database、Schema、Table 是什么关系?能跨库查询吗?

参考回答:实例(PGDATA)下有多个 database,database 下有多个 schema(默认 public),schema 下才是表、视图、函数等对象。一条连接只能进一个 database,不能直接跨库 JOIN,需要 postgres_fdw 或 dblink;但同一个库内跨 schema JOIN 没问题(s1.t1 JOIN s2.t2),所以多租户、模块隔离通常用 schema 而不是建多个库。

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
-- 建表:覆盖常用类型 + IDENTITY 主键
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- 推荐 IDENTITY
email text NOT NULL,
nickname text,
tags text[] NOT NULL DEFAULT '{}', -- 数组类型
profile jsonb NOT NULL DEFAULT '{}', -- JSONB
balance numeric(12,2) NOT NULL DEFAULT 0, -- 金额用 numeric,不用 float
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now() -- 带时区时间戳
);

INSERT INTO users (email, nickname, tags, profile)
VALUES ('zhang@ex.com', '张三', ARRAY['vip','python'], '{"city":"杭州"}')
RETURNING id, created_at; -- PG 特色:写操作直接返回数据,省一次查询

-- NULL 三值逻辑演示
INSERT INTO users (email) VALUES ('null_user@ex.com'); -- 这行 nickname 为 NULL

SELECT id, nickname FROM users WHERE nickname <> '张三';
-- 结果为空:NULL 行不满足 <> '张三'(比较结果是 UNKNOWN,被 WHERE 过滤)

SELECT id, nickname FROM users WHERE nickname IS DISTINCT FROM '张三';
-- 正确写法:返回 NULL 行和 nickname 不是张三的行

SELECT id, COALESCE(nickname, '匿名') FROM users; -- NULL 兜底默认值
SELECT count(*) AS 总行数, count(nickname) AS 昵称非空数 FROM users; -- 两个计数不相等

易错点与追问

易错点 正确认识
把 NULL 当 0 或 ‘’ NULL 是未知,比较结果是 UNKNOWN;判空只用 IS NULL
认为 varchar(n) 比 text 快 PG 中 varchar/char/text 性能无差别,text 最省心
用 float/double 存金额 有精度误差,必须 numeric
timestamp 和 timestamptz 随便选 多时区系统必须 timestamptz,否则跨时区读写出错
应用直连 PG 开几百个连接 连接 = fork 进程很贵,用 PgBouncer 池化
手动 INSERT 指定 IDENTITY 的 id GENERATED ALWAYS 会报错,需 OVERRIDING SYSTEM VALUE
一个业务建一个 database 同连接不能跨库 JOIN,模块隔离用 schema

常见追问链:连接数爆了怎么办 → 连接池(连接与连接池)→ 进程崩了数据会丢吗 → WAL 与崩溃恢复(WAL与数据库恢复)→ 表为什么越删越大 → MVCC 死元组与 VACUUM(MVCCVACUUM)→ SQL 为什么跑得慢 → 优化器与统计信息(PostgreSQL查询优化器)。

相关章节

第2章 SQL 查询基础

💡 一句话核心:基本功三件套——WHERE 在分组前过滤行、HAVING 在分组后过滤组;IN 与 EXISTS 常被优化器改写成同一计划,但 NOT IN 遇到 NULL 必出空集;UNION 比 UNION ALL 慢是因为多一步去重的排序/哈希。

概念详解

2.1 SELECT 的逻辑执行顺序

1
FROM/JOIN → WHERE → GROUP BY → HAVING → SELECT(投影/别名) → DISTINCT → ORDER BY → LIMIT/OFFSET

记住这条链,很多问题自动有答案:WHERE 里不能用聚合(还没分组)、WHERE 里不能用 SELECT 别名(逻辑顺序在 SELECT 之前)、HAVING 只能对分组结果过滤。

2.2 WHERE 和 HAVING 的区别(重点题)

WHERE HAVING
过滤对象 行(分组之前) 组(分组之后)
能否用聚合函数 不能(此时还没聚合)
能否引用 SELECT 别名 不能 不能(PG 的 HAVING 不识别输出别名,要写表达式)
性能意义 条件提前生效,减少参与分组的数据 作用于已聚合结果,过滤得晚

经验法则:能用 WHERE 就不用 HAVING,HAVING 只放必须基于聚合的条件。例:「近 30 天消费超 1000 的用户」——时间条件放 WHERE,sum(amount) > 1000 放 HAVING。

2.3 GROUP BY / ORDER BY / DISTINCT / LIMIT/OFFSET

  • GROUP BY:SELECT 中的非聚合列必须出现在 GROUP BY 里(PG 严格);按某表主键分组时,同表其他列可以依赖函数依赖直接 SELECT。
  • ORDER BY:可按列名、表达式、别名、序号;没有 ORDER BY 的 LIMIT 结果不确定,翻页可能重复/丢行(详见 分页)。
  • DISTINCT:整行去重;PG 特色 DISTINCT ON (col) 可保留每组按 ORDER BY 排序后的第一行。
  • LIMIT/OFFSET:语法简单,深分页性能问题见 分页

2.4 CASE WHEN / COALESCE / NULLIF

  • CASE WHEN 条件 THEN 值 ... [ELSE 默认] END:是表达式不是流程语句,可以出现在 SELECT、WHERE、ORDER BY 等任何能放表达式的地方;不写 ELSE 时默认返回 NULL。
  • COALESCE(a, b, c):返回第一个非 NULL 参数,给 NULL 兜底。
  • NULLIF(a, b):a 等于 b 返回 NULL,否则返回 a;经典用法防除零:sum(x) / NULLIF(count(*), 0)

2.5 子查询:标量子查询与相关子查询

  • 标量子查询(scalar):只返回一行一列,可放在 SELECT 列表、WHERE 中当标量用;放在 SELECT 列表里会对外层每一行执行一次,大表上要警惕。
  • 相关子查询(correlated):子查询引用了外层的列,逻辑上外层每行都要求值一次;优化器有时能改写成半连接/JOIN,有时不能(如 SELECT 列表中的相关子查询)。

2.6 IN 与 EXISTS 的区别(重点题)

语义:

  • x IN (子查询) 等价于 x = ANY(...):拿外层值去匹配子查询的结果集合。
  • EXISTS (相关子查询):只判断「有没有行满足」,属于半连接(semi-join),找到第一行就停,不产生重复、不需要去重。
场景 建议
外表大、子查询结果集小(能一次性物化/哈希) IN
外表小、内表巨大且关联列有索引 EXISTS(逐行探测,命中即停)
NOT IN 子查询可能返回 NULL 必须换 NOT EXISTS(NULL 陷阱)

NULL 陷阱(必考):

1
2
3
4
5
6
7
-- orders.customer_id 里只要有一个 NULL,整条查询返回 0 行
SELECT * FROM customers c
WHERE c.id NOT IN (SELECT o.customer_id FROM orders o);
-- 原因:x NOT IN (..., NULL) 中 x 与 NULL 比较为 UNKNOWN,NOT UNKNOWN 还是 UNKNOWN

SELECT * FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id); -- 正确

性能补一句:现代 PG 优化器常把 IN 子查询提升为半连接,两者 EXPLAIN 计划可能一模一样,所以「谁快」要用 EXPLAIN ANALYZE 验证,真正必须区分的是 NULL 语义。

2.7 UNION 与 UNION ALL(重点题)

  • UNION:合并 + 去重——实现上要对全结果集做排序(Sort + Unique)或哈希聚合,数据量大时内存装不下还要落盘临时文件。
  • UNION ALL:直接拼接两个结果集,不检查重复。
  • 两边 SELECT 的列数、类型必须兼容;UNION 去重时的排序不保证最终输出有序,要有序必须显式 ORDER BY。
  • 实践:大多数「合并多段结果」并不需要去重,默认写 UNION ALL;确认要去重再 UNION。

2.8 CTE(WITH 子句)与物化问题

1
2
3
4
WITH recent AS (
SELECT * FROM orders WHERE created_at > now() - interval '7 days'
)
SELECT * FROM recent WHERE status = 'paid';
  • PG12 之前:CTE 总是被物化(相当于临时快照),并且是优化栅栏(optimization fence)——外层的 status='paid' 推不进 CTE 内部,容易吃亏。
  • PG12 起:非递归、只被引用一次的 CTE 默认**内联(inline)**展开,谓词可以下推,行为接近普通子查询;需要旧行为时写 WITH recent AS MATERIALIZED (...) 强制物化。
  • 递归 CTE 始终物化。物化的合理用途:CTE 被引用多次时只计算一次、保证同一快照语义、有意隔离优化器行为。

2.9 RETURNING 子句(PG 特色)

INSERT / UPDATE / DELETE 后面都可以接 RETURNING,直接返回被影响行的数据:

  • 典型场景:INSERT 后立刻拿自增主键、默认值、触发器维护的字段,省掉一次额外的 SELECT 网络往返;MySQL 没有对应语法。
  • 配合 ORM 使用很常见(见 FastAPI与PostgreSQL实战)。

2.10 INSERT … ON CONFLICT(upsert,PG9.5+)

1
2
3
4
INSERT INTO t (k, v) VALUES (1, 'a')
ON CONFLICT (k) DO UPDATE SET v = EXCLUDED.v; -- 冲突则更新
INSERT INTO t (k, v) VALUES (1, 'a')
ON CONFLICT (k) DO NOTHING; -- 冲突则忽略
  • ON CONFLICT (k) 的 k 必须能对应一个已存在的唯一约束或唯一索引(冲突仲裁器),否则直接报错。
  • EXCLUDED 代表「本想插入、但冲突被拒的那一行」,用它取新值。
  • 对比 MySQL:INSERT ... ON DUPLICATE KEY UPDATE 类似;MySQL 的 REPLACE INTO 是「删旧行再插新行」,会触发删除副作用(级联删除、触发器),一般别用。

高频面试题

Q:WHERE 和 HAVING 的区别?(重点)

答题思路:逻辑执行顺序 → 过滤对象(行 vs 组)→ 聚合函数 → 性能建议。
参考回答:逻辑执行顺序是 FROM 之后先 WHERE 再 GROUP BY 再 HAVING。WHERE 在分组前过滤行,不能用聚合函数;HAVING 在分组后过滤组,可以对聚合结果判断。性能上能用 WHERE 的条件尽量放 WHERE,让数据在聚合前就变少。例如「近 30 天下单超过 10 次的用户」:时间范围和状态条件放 WHERE,count(*) > 10 放 HAVING。

Q:IN 和 EXISTS 的区别?什么时候 IN 快、什么时候 EXISTS 快?(重点)

答题思路:语义差异 → NULL 陷阱 → 优化器改写 → 以 EXPLAIN 为准。
参考回答:语义上 IN 是拿外层值匹配子查询结果集,EXISTS 是半连接,判断「是否存在匹配行」,命中即停。经典经验是「外表大、子查询结果小用 IN;外表小、内表大且关联列有索引用 EXISTS」。但要补一句:现代 PG 会把 IN 改写成半连接,两者的执行计划常常完全一样,速度差异不是硬规则,用 EXPLAIN ANALYZE 验证。真正必须区分的是 NULL:NOT IN 的子查询只要含一个 NULL,整个结果恒为空,所以「不存在」类需求一律用 NOT EXISTS。

Q:UNION 为什么比 UNION ALL 慢?(重点)

答题思路:先给结论(多了去重)→ 讲去重实现(排序/哈希,可能落盘)→ 给实践建议。
参考回答:UNION ALL 只是把两个结果集拼接;UNION 在此之上要对整个结果集去重,实现上是排序加 Unique 或哈希聚合,数据量大时内存装不下要写临时文件,代价高一个量级。所以业务不需要去重时必须写 UNION ALL。另外两个细节:去重的排序不等于输出顺序,要有序得再写 ORDER BY;UNION 去重的范围是两个结果集合并后的全行。

Q:PG 的 CTE 一定会被物化吗?

答题思路:分版本答,PG12 是分水岭。
参考回答:不是。PG12 之前 CTE 强制物化,同时是优化栅栏,外层条件推不进去,容易做出差计划。PG12 起非递归、只引用一次的 CTE 默认内联展开,谓词能下推,行为接近普通子查询;需要旧语义时用 AS MATERIALIZED 强制物化。递归 CTE 和被引用多次的 CTE 仍然物化——后者物化反而是优点,复杂计算只做一次。

Q:PG 里怎么实现 upsert?

参考回答:用 INSERT ... ON CONFLICTON CONFLICT (唯一键) DO NOTHING 表示冲突就忽略;DO UPDATE SET col = EXCLUDED.col 表示冲突就更新。前提是冲突目标必须是已存在的唯一约束或唯一索引,由它保证并发下的安全性。EXCLUDED 引用本次想插入的行。和 MySQL 对比:ON DUPLICATE KEY UPDATE 类似;REPLACE INTO 是先删后插,可能触发级联删除,要避免。

Q:RETURNING 是干什么的?MySQL 有吗?

参考回答:RETURNING 是 PG 特有语法,DML 执行完直接返回受影响的行,INSERT、UPDATE、DELETE 都支持。最典型的是 INSERT 后立刻拿自增主键和数据库生成的默认值,省掉一次额外的 SELECT 往返;写后需要立即响应字段(如订单号、统计值)的场景非常顺手。MySQL 没有对应语法,只能靠 LAST_INSERT_ID() 或再查一次。

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
-- 业务表:orders(订单),配合第 1 章的 users
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL,
status text NOT NULL DEFAULT 'pending', -- pending/paid/refunded
amount numeric(12,2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);

-- 1) WHERE + HAVING:近 30 天累计消费超过 1000 的用户
SELECT user_id, sum(amount) AS total
FROM orders
WHERE created_at > now() - interval '30 days' -- 分组前过滤行:能用 WHERE 就别放 HAVING
AND status = 'paid'
GROUP BY user_id
HAVING sum(amount) > 1000; -- 只能对聚合结果过滤

-- 2) CASE WHEN 打标 + FILTER 聚合(PG 特色语法)
SELECT user_id,
count(*) AS total,
count(*) FILTER (WHERE status = 'paid') AS paid_cnt,
count(*) FILTER (WHERE status = 'pending') AS pending_cnt,
CASE WHEN count(*) FILTER (WHERE status = 'paid') >= 10 THEN '高频'
ELSE '普通' END AS user_level
FROM orders
GROUP BY user_id;

-- 3) COALESCE / NULLIF
SELECT user_id,
avg(amount) AS avg_amount,
sum(amount) / NULLIF(count(*), 0) AS safe_avg, -- 防除零
COALESCE(max(amount), 0) AS max_amount -- NULL 兜底
FROM orders GROUP BY user_id;

-- 4) NOT IN 的 NULL 陷阱 vs NOT EXISTS
SELECT * FROM orders o
WHERE o.user_id NOT IN (SELECT u.id FROM users u WHERE u.id IS NOT NULL); -- 要手动排除 NULL
SELECT * FROM orders o
WHERE NOT EXISTS (SELECT 1 FROM users u WHERE u.id = o.user_id); -- 推荐

-- 5) upsert + RETURNING 一步到位
CREATE TABLE article_stats (article_id bigint PRIMARY KEY, views bigint NOT NULL DEFAULT 0);

INSERT INTO article_stats (article_id, views)
VALUES (42, 1)
ON CONFLICT (article_id)
DO UPDATE SET views = article_stats.views + 1
RETURNING article_id, views; -- 冲突时也返回更新后的值

-- 6) CTE:PG12+ 默认内联,谓词可下推
WITH recent AS (
SELECT * FROM orders WHERE created_at > now() - interval '7 days'
)
SELECT * FROM recent WHERE status = 'paid';

易错点与追问

易错点 说明
在 WHERE 里写聚合 WHERE count(*) > 1 直接报错,聚合过滤只能放 HAVING
在 WHERE 里引用 SELECT 别名 逻辑顺序在 SELECT 之前,报列不存在;包一层子查询/CTE
NOT IN 子查询含 NULL 结果恒为空;改 NOT EXISTS 或子查询里排除 NULL
想合并结果就无脑 UNION 多一次排序/哈希去重,确定无重复就用 UNION ALL
以为 UNION 自然有序 去重的排序不是输出顺序保证,要有序显式 ORDER BY
LIMIT 不带 ORDER BY 结果不确定,翻页重复/丢行(见 分页
相关子查询放 SELECT 列表 逻辑上每行执行一次,大表慎用,可改 JOIN/窗口函数
ON CONFLICT 写了没有唯一索引的列 直接报错:冲突目标必须对应唯一约束/唯一索引

常见追问链:NOT IN 为什么返回空 → 三值逻辑(PostgreSQL基础与整体架构)→ NOT EXISTS 快不快 → 半连接 + 关联列索引(索引)→ 怎么验证 → EXPLAIN(EXPLAIN与SQL优化)→ upsert 并发安全吗 → 唯一索引 + 行锁保证只有一个插入成功(锁与并发控制)。

相关章节

第3章 JOIN 与复杂查询

💡 一句话核心:JOIN 分两层记——逻辑语义(ON 是匹配条件、WHERE 是结果过滤,对外连接结果完全不同);物理执行(Nested Loop 适合小表驱动 + 内表索引、Hash Join 适合大数据量等值、Merge Join 适合已排序数据),最终由优化器按代价选择。

概念详解

3.1 六种逻辑 JOIN 的语义

类型 语义 结果集
INNER JOIN 两边都匹配上才保留 交集
LEFT JOIN 左表全部保留,右表匹配不上补 NULL 左 ⊕ 匹配到的右
RIGHT JOIN 与 LEFT 镜像 右 ⊕ 匹配到的左
FULL JOIN 两边都保留,缺的一侧补 NULL 并集
CROSS JOIN 笛卡尔积,m×n 行,无 ON 全组合
SELF JOIN 同一张表自己连自己(员工-经理、树形结构) 语法同 INNER/LEFT

经典笔试题:

  • 查每个员工及其经理名 → SELF JOIN(e.manager_id = m.id,老板的 manager_id 为 NULL 用 LEFT JOIN 保留)。
  • 查「从未下单的用户」→ LEFT JOIN + IS NULL 或 NOT EXISTS(反连接 anti-join)。

3.2 JOIN 条件写在 ON 和 WHERE 的区别(重点)

  • INNER JOIN:条件放 ON 还是 WHERE 完全等价,优化器会等价改写。
  • OUTER JOIN:不等价,这是高频考点:
    • ON = 匹配条件:右表不满足条件就补 NULL,左表行照样保留(保留 OUTER 语义)。
    • WHERE = 结果过滤器:在连接完成后的结果上再筛选,会把右表补的 NULL 行滤掉,LEFT JOIN 静默退化为 INNER JOIN
  • 特例:WHERE b.col IS NULL 是故意利用补的 NULL 找「左表有、右表没有」的行——反连接(anti-join)标准写法。
1
2
LEFT JOIN t2 ON (匹配条件)        →  左表全保留,右表按 ON 匹配,匹配不上补 NULL
LEFT JOIN t2 ON (键关联) WHERE(过滤) → 先连接,后过滤;NULL 行被滤掉 = INNER JOIN

3.3 三种物理执行方式(重点)

Nested Loop Join Hash Join Merge Join
原理 外表每行 → 去内表找匹配(内表最好有索引) 较小的一侧建哈希表(build),扫另一侧探测(probe) 两侧按连接键排序后归并
支持条件 任意条件(含非等值) 仅等值(hashable) 仅 mergejoinable 操作符(实践基本都是等值)
适用场景 外表很小、内表连接列有索引;OLTP 点查 两侧数据量都大、无可用索引顺序的中大型等值 JOIN 两侧已有序(索引提供顺序)、或输出要求按连接键有序
启动成本 低,第一行很快返回 要先建完哈希表 要先完成排序/读数
内存 占用小 受 work_mem 限制,超了会分批(batch)落盘 排序可能落盘

优化器怎么选(面试口径,按顺序判断):

  1. 外表估算行数很小 + 内表连接列有索引 → Nested Loop(代价 ≈ 外表行数 × 索引点查)。
  2. 等值 JOIN、两侧都大、没有现成的排序 → Hash Join(代价 ≈ 两侧各扫一遍 + 建哈希表)。
  3. 等值 JOIN 且连接列上的索引正好能提供有序数据,或结果本身要求有序 → Merge Join(省掉排序步骤)。

决定因素:统计信息估出的行数、可用索引、work_mem、代价参数。行数估错(统计信息过期)就会选错算法——这是慢查询的常见根因(PostgreSQL查询优化器EXPLAIN与SQL优化)。

3.4 多表 JOIN:执行顺序、聚合与重复放大

  • SQL 里 JOIN 的书写顺序不等于执行顺序:PG 优化器会枚举连接顺序选代价最小的(表数量超过 geqo_threshold,默认 12,会切换为遗传算法 geqo)。所以写 SQL 时更该关心「每个表的过滤条件有没有索引」,而不是死调 JOIN 顺序。
  • 一对多 JOIN 聚合放大(必考坑):用户表 JOIN 订单表后再 SUM(users.balance),一个有 10 张订单的用户,余额会被加 10 次。解法:先聚合再 JOIN(子查询/CTE 预聚合),或对维表先 DISTINCT。
  • JOIN 后的行数不等于任何一张表的行数:想统计「有多少用户有订单」,要用 count(DISTINCT u.id) 或半连接(EXISTS),而不是 count(*)

高频面试题

Q:LEFT JOIN 的条件放 ON 里和放 WHERE 里有什么区别?(重点)

答题思路:ON = 匹配条件、WHERE = 结果过滤 → 举例说明退化 → 补充 anti-join 特例。
参考回答:对 INNER JOIN 两者等价;对 OUTER JOIN 完全不同。条件写在 ON 里,右表不满足只是补 NULL,左表行仍然保留;写在 WHERE 里是在连接结果上过滤,NULL 行会被筛掉,LEFT JOIN 实际退化成 INNER JOIN。比如查所有用户及其已支付订单:LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid' 能保留没下过单的用户;改成 WHERE o.status = 'paid' 就只剩下过单的。反过来 WHERE o.id IS NULL 恰好利用 NULL 找「从未下单的用户」,这是反连接的标准写法。

Q:PG 有哪几种 JOIN 物理实现?优化器什么时候选哪种?(重点)

答题思路:三种各一句话原理 → 按「数据量 + 索引 + 是否等值 + 是否有序」给选择依据 → 补一句统计信息估错会选错。
参考回答:三种。Nested Loop:外表每行去内表找匹配,内表连接列有索引时效率高,适合外表很小的 OLTP 查询,也是唯一支持非等值连接的方式。Hash Join:用较小的一侧建哈希表、扫另一侧探测,只支持等值,适合两侧数据量都大又没有现成排序的场景,受 work_mem 约束,超了会分批。Merge Join:两侧按连接键排序后归并,只支持等值类操作符,适合索引已经提供顺序的大表,或结果要求有序时顺便省一次排序。优化器基于统计信息和代价模型选择:小驱动大且有索引选 Nested Loop,大对大无序选 Hash,有序或要输出有序选 Merge。如果统计信息过期导致行数估算错误,就会选错算法,这是慢查询常见根因。

Q:怎么查「没有下过单的用户」?给两种写法并比较。

答题思路:LEFT JOIN IS NULL(反连接)与 NOT EXISTS;提 NOT IN 的 NULL 陷阱。
参考回答:写法一是 LEFT JOIN orders o ON o.user_id = u.id WHERE o.id IS NULL;写法二是 WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)。两者语义等价,数据量大时优化器都会走反连接计划(Hash Anti Join 或 Nested Loop Anti Join)。我更推荐 NOT EXISTS:没有 NULL 语义坑、意图清晰,后续往 WHERE 里加条件不容易改坏。绝对不要用 u.id NOT IN (SELECT o.user_id ...),user_id 一旦有 NULL 整个结果为空。

Q:为什么 JOIN 之后 SUM 算出来的数变大了?(重点,一对多放大)

答题思路:先答根因(一对多导致维表行重复)→ 给修复方案(先聚合再 JOIN)→ 给排查方法。
参考回答:一对多 JOIN 会让「一」的那侧行重复出现。比如用户表 JOIN 订单表再 SUM(用户余额),下 10 单的用户余额被累计 10 次。修复方式是把聚合下推:先用子查询或 CTE 按 user_id 聚合订单(count、sum),再和用户表 JOIN;或者对维表先 DISTINCT。排查方法:对比 JOIN 前后两个阶段的行数,行数膨胀就说明存在扇出。

Q:FULL JOIN 和 CROSS JOIN 分别在什么场景用?

参考回答:FULL JOIN 两侧都保留、缺的一侧补 NULL,典型场景是对账:两个来源各自有对方没有的记录都要暴露出来,FULL JOIN 之后用 a.id IS NULL OR b.id IS NULL 过滤出差异行。CROSS JOIN 是笛卡尔积,用于穷举组合:比如日历表 × 商品表生成「每天 × 每个商品」的骨架,再 LEFT JOIN 销量补零,这样没销量的商品日期也在报表里。

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
-- users (用户) 1 ──── n orders (订单)

-- 1) 条件放 ON:保留所有用户(含没下过单的),只匹配已支付订单
SELECT u.id, u.nickname, o.id AS order_id
FROM users u
LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid';

-- 2) 同样的条件放 WHERE:LEFT JOIN 退化为 INNER JOIN,没下过单的用户消失
SELECT u.id, o.id
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.status = 'paid';

-- 3) 反连接:从未下单的用户(两种等价写法)
SELECT u.id FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.id IS NULL;

SELECT u.id FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);

-- 4) 一对多放大 → 先聚合再 JOIN(正确姿势)
SELECT u.id, u.nickname, agg.order_cnt, agg.total_amount
FROM users u
JOIN (
SELECT user_id, count(*) AS order_cnt, sum(amount) AS total_amount
FROM orders
GROUP BY user_id
) agg ON agg.user_id = u.id; -- 维表不重复,聚合值正确

-- 错误示范:JOIN 后直接对维表列聚合
-- SELECT u.id, sum(u.balance) FROM users u JOIN orders o ON o.user_id=u.id GROUP BY u.id;
-- 下单多的用户 balance 被重复累计

-- 5) SELF JOIN:员工-经理
-- employees(id, name, manager_id)
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON m.id = e.manager_id; -- 老板的 manager_id 为 NULL 也保留

-- 6) 观察优化器选择的物理连接方式
EXPLAIN
SELECT o.id FROM orders o JOIN users u ON u.id = o.user_id
WHERE u.email = 'a@ex.com';
-- 典型计划: Nested Loop —— u 走 email 索引出 1 行, 再用 orders(user_id) 索引探测
-- 若去掉 WHERE 让两侧全量扫描, 大数据量下通常变成: Hash Join

易错点与追问

易错点 说明
LEFT JOIN + WHERE 右表非 NULL 条件 静默退化为 INNER JOIN,结果「变少」
ON 里写只涉及左表的条件 对 LEFT JOIN 无过滤效果(右表整侧补 NULL),这类条件应放 WHERE
JOIN 后直接 SUM/COUNT 维表列 一对多放大;先聚合再 JOIN 或 count(DISTINCT)
用 NOT IN 找「不存在」 NULL 陷阱;用 NOT EXISTS / LEFT JOIN IS NULL
认为写的 JOIN 顺序就是执行顺序 优化器会重排;重点是有没有可用索引和准确统计信息
两侧都无索引还期待 Nested Loop 大数据量下会走 Hash Join(建哈希 + 探测)
JOIN 忘写 ON / 写错关联键 直接笛卡尔积,行数 m×n,线上事故常客
RIGHT JOIN 满天飞 语义可读性差,多数团队约定只写 LEFT JOIN、交换表位置

常见追问链:JOIN 慢怎么排查 → EXPLAIN 看连接方式与估算行数(EXPLAIN与SQL优化)→ 估算为什么错 → 统计信息与代价模型(PostgreSQL查询优化器)→ 怎么让 Nested Loop 更快 → 内表连接列建索引(索引)→ JOIN 找到的行数据在哪 → 堆表 TID 与 MVCC(MVCC)→ 外键缺失会怎样 → JOIN 键无约束、估算失真(主键唯一约束与外键)。

相关章节

第4章 索引

💡 一句话核心:索引是用空间和写放大换读速度的有序查找结构——PG 默认 B-Tree 把全表扫描变成 3~4 次页访问;走不走索引由优化器按代价决定,「建了索引没用」先查选择性、统计信息和 SQL 写法(函数 / 类型转换 / 前导通配 LIKE)。

概念详解

4.1 什么是索引?为什么快?代价是什么?

  • 索引是独立于表的查找结构:键值 → 行指针(TID = 页号 + 页内偏移)。没有索引只能 Seq Scan 全表扫 O(N);有 B-Tree 索引则是 O(树高) 定位 + 回表。
  • PG 是堆表 + 二级索引:索引叶子存的是 TID 而非整行,命中后一般要回表;只有 Index Only Scan 能不回表。
  • 索引的三大代价:
    1. 空间:索引可能接近表本身的大小;
    2. 写放大:INSERT/DELETE 都要维护每个索引;PG 的 UPDATE 是「插入新版本元组」,被索引引用的列一变,所有相关索引都要新增条目
    3. 维护成本:索引膨胀需要 REINDEX,优化器要为更多候选索引做规划。
  • 建索引的判断标准:WHERE / JOIN / ORDER BY 高频出现 + 列选择性高 + 写入不太敏感。不是越多越好。

4.2 B+Tree 基本原理

  • 多路平衡搜索树:根节点和内部节点只存键做路标,数据指针全部在叶子层(PG 的 B-Tree 叶子存 key + TID),叶子之间用双向链表串联。
  • 等值查询:从根走到叶子,O(树高);范围查询:定位到起点叶子后沿叶子链表顺序扫描,所以天然适合 > < BETWEEN ORDER BY
  • 树高估算(面试加分数字):页大小 8KB,键不大时扇出约 100500;**树高 34 层即可支撑千万到亿级行**,一次索引查找约 3~4 次页访问,且上层节点通常常驻内存缓存。
  • 为什么不用别的结构:
    • 二叉搜索树/红黑树:树高 O(log2N),千万数据 20+ 层,每层一次 IO,不可接受;
    • 哈希:等值 O(1) 但完全不支持范围查询和排序;
    • B+Tree 相比 B-Tree:非叶子不存数据 → 扇出更大 → 树更矮,且叶子链表让范围扫描更顺。

4.3 PG 常见索引类型

类型 结构 适用场景 典型例子
B-Tree B+树变体 默认类型:等值、范围、排序、前缀 LIKE = > < >= <= BETWEEN IN LIKE 'abc%'
Hash 哈希表 仅等值 =;PG10 前 hash 索引不写 WAL、不崩溃安全,等值一般仍首选 B-Tree
GIN 倒排索引 「包含」类查询 JSONB、数组、全文检索、pg_trgm 模糊匹配(JSONB与GIN
GiST 广义搜索树 范围类型、几何、KNN 最近邻、排除约束 PostGIS、&&<-> 排序
BRIN 块范围摘要 超大表且物理顺序与列值强相关 时序表的 created_at,体积极小(KB~MB 级)
SP-GiST 空间分区树 非平衡结构(前缀树、IP 等) 少见,知道名字即可

记忆口径:默认 B-Tree;JSONB/数组/全文用 GIN;范围/几何用 GiST;时序超大表用 BRIN;纯等值极端场景 Hash

4.4 联合索引与最左匹配

联合索引 (a, b):整体按 a 排序,a 相同的条目内部再按 b 排序。

查询条件 能否用 (a,b) 索引
where a = ? 能(最左前缀)
where a = ? and b = ? 能(完整使用)
where b = ? 不能:跳过 a 后 b 在整体上无序,无法定位,需另建 (b) 或 (b,a)
where a = ? order by b 能,且省掉排序步骤

设计口诀:等值列在前、范围列在后(范围条件之后的索引列失去定位能力),把排序/分组列尽量放进索引尾部。

4.5 覆盖索引与 INCLUDE(Index Only Scan)

  • Index Only Scan:查询要的列全部包含在索引里,无需回表。但还有个前提:涉及的表页必须在 visibility map 中标记为 all-visible(由 VACUUM 维护,见 VACUUM),PG 才敢跳过堆表可见性检查;否则退化为回表(EXPLAIN 里表现为 heap fetches 很多)。
  • INCLUDE 子句(PG11+):把附加列放进索引叶子但不参与排序和唯一性判断:
    CREATE UNIQUE INDEX ON users(email) INCLUDE (nickname); —— 既保证 email 唯一,又能覆盖查询。
  • 意义:覆盖索引把「索引查找 + 回表随机 IO」变成「纯索引顺序读」,是高频列表查询的大杀器。

4.6 Partial Index(部分索引)与 Expression Index(表达式索引)

  • 部分索引:只索引满足谓词的行,更小、更快、写放大更小:
    CREATE INDEX ON orders(created_at) WHERE status = 'pending';
    典型场景:软删除 WHERE deleted = false、只关心热点状态行。注意查询条件要让优化器能推导出「落在索引谓词范围内」,写法要与谓词一致(如 where status = 'pending')。
  • 表达式索引:对函数/表达式建索引:
    CREATE UNIQUE INDEX ON users(lower(email));
    查询必须写成 where lower(email) = $1 才能用上;顺带实现大小写不敏感的唯一约束。

4.7 为什么建了索引还走 Seq Scan?(重点)

一句话结论:索引只是候选方案,走不走由优化器按代价决定。常见原因:

  1. 表太小:数据只有几页,顺序读一遍比「树高 + 回表随机 IO」更快;
  2. 选择性低:条件命中 20%~30% 以上的行,大量回表随机 IO 反而比全表顺序扫贵;
  3. 统计信息过期:没跑 ANALYZE,行数估算失真;
  4. 回表代价高SELECT * 且查询列不在索引里,命中行数多时不如全扫;
  5. 写法让索引不可用:函数、运算、类型转换、前导通配 LIKE(见 4.8);
  6. 代价参数与硬件不匹配:SSD 环境 random_page_cost 默认值 4 偏高,调低后优化器更愿意用索引。

4.8 什么情况下索引会失效?(重点)

写法 例子 解决/替代
对列做函数/表达式 where lower(email)=...amount*1.1 > 100 建表达式索引,或改写为 amount > 100/1.1(计算移到常量侧)
对列做显式类型转换 where id::text = '123' 转换放常量一侧
前导通配 LIKE '%abc%' pg_trgm + GIN(见 4.9)
OR 一侧无索引 where a=? or c=?(c 无索引) 相关列都建索引后可用 Bitmap Or,否则整体 Seq Scan
统计信息过期 大批量导入后没 ANALYZE ANALYZE 表名
否定条件 !=NOT IN 语法上能用索引,但命中行太多时优化器主动放弃

PG 细节(对比 MySQL 的亮点):PG 更严格,varchar 列 = 数字字面量直接报错(operator does not exist),不做隐式转换;所以 PG 的「隐式转换失效」更多表现为对列做 :: 转换或表达式,而字面量侧转型(如 where id = '123')没问题。

4.9 LIKE ‘%abc%’ 为什么普通 B-Tree 不好用?(重点)

  • B-Tree 按前缀有序:LIKE 'abc%' 等价于范围 [abc, abd),能走索引;'%abc' 前缀未知,所有叶子都可能匹配,只能全索引/全表扫。
  • 解决方案:
    1. pg_trgm 扩展 + GIN 索引(最常用):CREATE EXTENSION pg_trgm; 然后 CREATE INDEX ON articles USING gin (title gin_trgm_ops);%abc% 也能走索引;
    2. 纯后缀匹配:存一列 reverse(col) 并建索引,查询转成 LIKE 'cba%'
    3. 真正的全文检索需求:tsvector + GIN。

高频面试题

Q:为什么建立了索引,查询还是走 Seq Scan?(重点)

答题思路:一句话结论(优化器按代价判断全表扫更便宜)→ 列举 5~6 个原因 → 给排查手段。
参考回答:索引只是候选,走不走由优化器按代价决定。常见原因:一是表太小,几页数据顺序读完比索引查找加回表更快;二是选择性低,命中大量行时回表随机 IO 超过全表顺序扫;三是统计信息过期,ANALYZE 没跑,行数估算错误;四是 SELECT * 导致大量回表;五是写法问题,比如对列用函数、前导通配 LIKE;六是参数与硬件不匹配,SSD 上 random_page_cost 默认 4 偏高。排查直接 EXPLAIN (ANALYZE, BUFFERS),对比索引路径和 Seq Scan 的成本与实际行数。

Q:哪些写法会导致索引失效?

答题思路:核心一句话「索引键被改变了」→ 分类举例 → 给 PG 与 MySQL 的差异细节。
参考回答:核心是索引列被「加工」了:对列做函数、运算、拼接、显式转换(lower(email)amount*1.1>100col::text);前导通配 LIKE '%abc';OR 两侧有一边没索引;统计信息过期;以及低选择性让优化器主动放弃。PG 和 MySQL 的一个差异:PG 不会对 varchar 列和数字字面量做隐式转换而是直接报错,所以 PG 更多是对列做转换导致失效。解决办法:表达式索引、把计算移到常量侧、pg_trgm 处理模糊匹配、导入后 ANALYZE。

Q:LIKE ‘%abc%’ 怎么优化?(重点)

答题思路:先解释为什么 B-Tree 不行(前缀有序)→ 给三个方案 → 提醒确认能否改成前缀匹配。
参考回答:B-Tree 按前缀有序,前缀未知时无法定位,只能全扫。方案一:pg_trgm 扩展加 GIN 索引,把字符串切成三元组做倒排,%abc% 也能走索引,最常用;方案二:纯后缀匹配可以存一列反转字符串,走 LIKE 'cba%';方案三:改成 tsvector 全文检索。另外注意 LIKE 'abc%' 本来就能走 B-Tree,先和业务确认能否改成前缀匹配,成本最低。

Q:讲讲 B+Tree,为什么数据库都用它?(重点)

答题思路:结构(多路平衡、数据在叶子、叶子链表)→ 对比二叉树/哈希 → 树高估算给数字 → 落到 PG 实现。
参考回答:B+Tree 是多路平衡树:非叶子节点只存键做路标,数据指针都在叶子层,叶子间双向链表。多路意味着树很矮——8KB 页、扇出几百,树高 34 就能支撑千万到亿级行,一次查找只有 34 次页访问,而且上层节点常驻缓存。叶子链表让范围查询和 ORDER BY 顺着链表顺序读即可。对比:二叉树树高 log2(N),千万数据 20 多层 IO 不可接受;哈希等值快但完全不支持范围和排序;B+Tree 相比 B-Tree 扇出更大、更矮。落到 PG:B-Tree 叶子存的是堆表 TID(页号+偏移),命中后要回表,除非覆盖索引走 Index Only Scan。

Q:联合索引 (a, b),where b = ? 能用到吗?

参考回答:用不上。索引先按 a 排序、a 相同再按 b 排,跳过 a 直接查 b 时,b 在整体上是无序的,无法二分定位,只能全表扫。解决办法:单独建 (b) 索引,或按查询模式调成 (b, a)。而 where a=?where a=? and b=?where a=? order by b 都是它的适用范围。设计顺序上:等值列在前、范围列在后,排序列放尾部。

Q:Index Only Scan 为什么快?什么条件下会退化?

参考回答:它完全不回表,查询所需列全在索引里,直接从索引返回结果。但有个前提:涉及的堆表页必须在 visibility map 里标记为 all-visible(VACUUM 维护),PG 才敢不检查元组可见性。刚大量 UPDATE/INSERT 过、VM 还没更新的表,EXPLAIN 里 heap fetches 暴涨,等于退化成回表,跑一次 VACUUM 就能恢复。所以它对「很少更新的数据 + 覆盖索引」最友好。

Q:性别这种低选择性字段建索引有意义吗?

答题思路:先给结论(单独建基本无意义 + 原因数字),再给三个例外。
参考回答:单独建基本没意义:只匹配一半的行,回表随机 IO 一定输给全表扫描。但有例外:一是部分索引,比如只索引 where status = 'active' 的少数行,索引很小很高效;二是和其他高选择性列组成联合索引 (gender, created_at),gender 等值后再走范围;三是多个低选择性条件 OR 组合时,优化器可能用 Bitmap Or 合并多个索引的位图。另外 Bitmap Index Scan 本身就是低选择性的折中方案:先把命中的页做成位图,再按页批量回表,把随机 IO 变成半顺序 IO。

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
-- 表:users / orders / articles

-- 1) 基础 B-Tree:等值列在前、排序列在后的联合索引
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC);
EXPLAIN SELECT * FROM orders WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20;
-- 预期: Index Scan using idx_orders_user_created,省掉 Sort 节点

-- 2) 覆盖索引 + INCLUDE:高频列表查询不回表
CREATE INDEX idx_orders_cover ON orders (user_id) INCLUDE (status, amount);
SELECT status, amount FROM orders WHERE user_id = 42; -- Index Only Scan
SELECT * FROM orders WHERE user_id = 42; -- SELECT * 就得回表

-- 3) 部分索引:只索引未支付订单(通常占少数)
CREATE INDEX idx_orders_pending ON orders (created_at) WHERE status = 'pending';
EXPLAIN SELECT * FROM orders
WHERE status = 'pending' AND created_at < now() - interval '1 hour';

-- 4) 表达式索引 + 大小写不敏感唯一邮箱
CREATE UNIQUE INDEX idx_users_email_lower ON users (lower(email));
EXPLAIN SELECT * FROM users WHERE lower(email) = 'a@ex.com'; -- 能用上该索引
EXPLAIN SELECT * FROM users WHERE email = 'a@ex.com'; -- 用不上表达式索引

-- 5) 软删除场景的部分索引
CREATE INDEX idx_articles_alive ON articles (author_id, created_at) WHERE deleted = false;
EXPLAIN SELECT * FROM articles WHERE deleted = false AND author_id = 7;

-- 6) JSONB / 模糊匹配:GIN
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_articles_meta ON articles USING gin (meta jsonb_path_ops);
CREATE INDEX idx_articles_trgm ON articles USING gin (title gin_trgm_ops);
SELECT * FROM articles WHERE title LIKE '%postgresql%'; -- 走 trgm GIN,B-Tree 做不到

-- 7) 时序大表 BRIN:物理顺序与时间强相关,索引只有 KB~MB 级
CREATE INDEX idx_events_brin ON events USING brin (created_at);

-- 8) 索引失效与改写对比
EXPLAIN SELECT * FROM orders WHERE amount * 1.1 > 100; -- 列参与运算: Seq Scan
EXPLAIN SELECT * FROM orders WHERE amount > 100 / 1.1; -- 计算移到常量侧: 能用索引
EXPLAIN SELECT * FROM articles WHERE title LIKE '%sql%'; -- 无 trgm 索引时: Seq Scan

易错点与追问

易错点 说明
索引越多越好 每个索引都拖慢写入、占空间;PG 的 UPDATE 会波及所有引用列的索引
SELECT * 还指望 Index Only Scan 覆盖不了就回表;只 SELECT 索引覆盖的列
建了 lower(email) 索引,查询却写 email 查询写法必须和索引表达式一致
部分索引谓词与查询条件对不上 优化器推导不出匹配就不使用;条件写法保持一致
大批量导入后不 ANALYZE 统计信息过期 → 优化器选错甚至放弃索引
图快用 Hash 索引 仅等值可用,B-Tree 的等值性能足够,默认选 B-Tree
说 != / NOT IN「索引失效」 语法上可用,只是低选择性时优化器不选;失效要区分「不可用」与「不划算」
万行小表没走索引就吐槽 小表走 Seq Scan 是优化器的正确决策,不是 bug

常见追问链:为什么走 Seq Scan → 统计信息怎么来 → ANALYZE 做了什么 → 代价模型(PostgreSQL查询优化器)→ Index Only Scan 依赖什么 → visibility map → 谁维护 → VACUUM(VACUUM)→ 索引为什么膨胀 → MVCC 死元组(MVCC)→ JSONB 怎么建索引 → GIN 专题(JSONB与GIN)→ 表太大索引都救不了 → 分区表(分区表)。

相关章节


📘 原理篇

第5章 EXPLAIN 与 SQL 优化

💡 一句话核心:先看执行计划再动手——EXPLAIN 看估算、EXPLAIN ANALYZE 看真实执行,优化主线是「减少扫描行数」:用对索引、消灭全表扫描和深分页,用 pg_stat_statements 定位慢 SQL。

概念详解

EXPLAIN 与 EXPLAIN ANALYZE 的区别

EXPLAIN EXPLAIN ANALYZE
是否执行语句 否,只生成计划 真的执行
rows / cost 优化器估算 估算 + actual 实际值
风险 SELECT 无副作用;INSERT/UPDATE/DELETE 会真改数据

写操作想看真实计划的标准姿势——包在事务里回滚:

1
2
3
BEGIN;
EXPLAIN ANALYZE DELETE FROM orders WHERE created_at < '2020-01-01';
ROLLBACK; -- 数据恢复原样,什么都没发生

PG 没有”dry run 的 ANALYZE”,要么接受估算,要么事务 + ROLLBACK。常用附加选项:

  • EXPLAIN (ANALYZE, BUFFERS):BUFFERS 显示 shared hit(内存命中)/ read(读磁盘)/ written,判断 IO 是否是瓶颈
  • TIMING OFF:关闭逐节点计时,降低 ANALYZE 自身带来的开销

执行计划节点解读

计划是树形缩进结构,最里层(缩进最深)先执行。

节点 什么条件下出现 一句话理解
Seq Scan 表很小、没有可用索引、或 WHERE 命中大部分行 全表顺序扫一遍,小表时它反而就是最优解
Index Scan 条件命中索引且返回行数较少 B-tree 定位后回表取整行,随机 IO
Index Only Scan 查询的列全部包含在索引里(覆盖索引),且页面”全可见”(visibility map 由 VACUUM 维护) 不回表,最快;SELECT * 会让它失效
Bitmap Index Scan → Bitmap Heap Scan 索引命中行数中等(大约 1%~10%),或多个索引条件组合 先在索引里画出”哪些页有命中”的位图,再按页顺序读堆表;BitmapAnd/BitmapOr 组合多索引
Nested Loop 外表结果集小、内表连接列上有索引 外表每行去内表点查一次,OLTP 小结果集首选
Hash Join 大表等值连接、无现成排序 用较小的表建哈希表(受 work_mem 限制),大表探测
Merge Join 双方已按连接键排序(索引或显式 Sort) 归并两条有序流,适合大数据量等值/范围连接
Sort ORDER BY / GROUP BY / DISTINCT 吃不到索引 内存排序;超过 work_mem 转 external merge(落盘,性能骤降)
Aggregate GROUP BY / 聚合函数 HashAggregate(分组数多)或 GroupAggregate(输入已排序)

关键字段:cost / rows / width / actual time / loops

1
2
3
Index Scan using orders_user_created_idx on orders
(cost=0.43..8.45 rows=1 width=68) -- 估算(EXPLAIN 就有)
(actual time=0.021..0.023 rows=1 loops=10) -- EXPLAIN ANALYZE 才有
  • cost=启动成本..总成本:虚拟单位(顺序读一页 = 1.0),只在同层兄弟方案之间比较;父节点包含子节点的累计值,不要拿父子节点比大小
  • rows:估算返回行数,优化器选扫描方式和 JOIN 顺序全靠它
  • width:平均每行字节数,衡量内存/网络开销
  • actual time每次 loop 的平均值,总耗时 ≈ 结束值 × loops;actual rows 同样是每 loop 平均
  • 经典陷阱:Nested Loop 的内层 Index Scan 显示 actual=0.02 rows=1 loops=100000,单看 0.02ms 以为很便宜,真实总耗时是 2 秒

rows 估算偏差说明什么?

估算行数与 actual rows 差一个数量级以上,通常意味着:

  1. 统计信息过期 → 对表跑 ANALYZE 表名
  2. 数据倾斜(某值占比异常高)
  3. 列间强相关,单列统计失真 → PG 10+ 用 CREATE STATISTICS 建扩展统计

估算错 → 计划错:该 Hash Join 的选了 Nested Loop、该走索引的走了全表扫。这是”执行计划突然变差 / 同一条 SQL 忽快忽慢”最常见的根因。

SQL 优化手段

  1. 减少全表扫描:大表的过滤列、JOIN 列建索引
  2. 创建合适索引:选区分度高、真的会被查询用到的列(见 索引
  3. 避免无效索引:列上套函数(建表达式索引)、隐式类型转换、前导通配 LIKE '%x'(换 pg_trgm GIN)、OR 两侧都要有索引
  4. 优化 JOIN:小表驱动大表、连接列类型一致且都有索引、消灭应用层的 N+1 查询
  5. 避免 SELECT *:减小 width,给 Index Only Scan 留机会,减少网络传输
  6. 避免大 OFFSETOFFSET 1000000 LIMIT 20 依然要扫过并丢弃前 100 万行;改用 Keyset PaginationWHERE id > $last_id ORDER BY id LIMIT 20,直接用索引定位起点,每页成本恒定,且翻页时新写入的数据不会导致重复/漏读(缺点是不能跳页,要求排序键唯一稳定)

如何定位慢 SQL

工具 用途 关键点
pg_stat_statements 历史 Top SQL 统计 需要 shared_preload_libraries = 'pg_stat_statements' + CREATE EXTENSION;按 total_exec_time 排序(PG 13 之前叫 total_time)
慢日志 抓单条超阈值 SQL log_min_duration_statement = 200(毫秒);配合 auto_explain 自动把执行计划记进日志
pg_stat_activity 看”此刻”谁在跑 state=’active’、wait_event、query_start、pid;配合 pg_cancel_backend / pg_terminate_backend 处理

标准排查链路:pg_stat_statements 找出高耗时高频 SQL → EXPLAIN (ANALYZE, BUFFERS) 看计划 → 检查估算偏差与扫描方式 → 建索引 / 改写 SQL → 前后对比验证。

高频面试题

Q:EXPLAIN 和 EXPLAIN ANALYZE 的区别?用 ANALYZE 要注意什么?

答题思路:一句话区分”估算 vs 真实执行”,再补充写操作风险与防护。

参考回答:EXPLAIN 只让优化器生成计划,输出 cost、rows、width 都是估算值,不执行语句,零风险。EXPLAIN ANALYZE 会真的执行语句,额外输出 actual time、actual rows、loops。风险在写操作:对 DELETE 跑 EXPLAIN ANALYZE 数据就真没了,所以写语句要放进 BEGIN…ROLLBACK 里跑。生产上我一般加 BUFFERS 看缓存命中、必要时 TIMING OFF 降低分析开销。

Q:说说常见执行计划节点分别在什么场景出现?

答题思路:按”扫描 → JOIN → 辅助节点”分组答,每类给出现条件,不追求背全。

参考回答:扫描类:Seq Scan 全表扫,表小或没有可用索引时出现,小表它就是最优;Index Scan 走索引回表,适合命中少量行;Index Only Scan 不回表,要求查询列被索引覆盖且页面可见性位图是最新的;Bitmap Index Scan 加 Bitmap Heap Scan 介于两者之间,适合命中中等数量行,多条件时用 BitmapAnd/BitmapOr 合并多个索引。JOIN 类:Nested Loop 适合外表小、内表有索引的点查;Hash Join 适合大表等值连接;Merge Join 适合两边已排序的大数据量连接。辅助类:Sort 说明排序没吃到索引,Aggregate 对应 GROUP BY。见到这些节点不一定是坏事,要结合数据量判断。

Q:rows 估算和实际差很远说明什么?怎么处理?

答题思路:估算错 → 计划错;说原因(统计信息、倾斜、列相关)和处理手段。

参考回答:说明优化器对数据分布的认知错了,会直接导致选错扫描方式和 JOIN 顺序,这是 SQL 突然变慢最常见的根因。先对表跑 ANALYZE 更新统计信息;还不准通常是数据倾斜或两列强相关,PG 10+ 可以建扩展统计 CREATE STATISTICS。另外列被函数包裹也会让估算失真,改成表达式索引后计划就正常了。日常要把”估算 vs 实际偏差超过一个数量级”当作计划 unhealthy 的信号。

Q:深分页为什么慢?怎么优化?

答题思路:OFFSET 的真实代价 → Keyset Pagination → 适用条件与取舍。

参考回答:LIMIT 20 OFFSET 1000000 不是跳读,优化器必须把前 100 万行按 ORDER BY 产出后再丢弃,页越深越慢。改 Keyset Pagination:WHERE id > 上一页最后一条的 id ORDER BY id LIMIT 20,利用索引直接定位起点,每页成本恒定,翻页间隙新数据也不会造成重复或漏读。缺点是只能连续翻页、要求排序键唯一稳定,常用主键或 (created_at, id) 组合。后台需要跳页的场景,用限制最大页深或缓存总数据量兜底。

Q:线上一条 SQL 突然变慢,你的排查步骤是什么?

答题思路:给流程:现况 → 定位 → 看计划 → 归因 → 验证。

参考回答:第一步 pg_stat_activity 看它此刻的状态和 wait_event,分清是被锁挡住还是在读磁盘;第二步 pg_stat_statements 看是偶发慢还是每次都慢;第三步 EXPLAIN (ANALYZE, BUFFERS) 对比计划,重点看 rows 估算偏差、扫描方式、Sort 是否落盘外排;第四步归因:统计过期就 ANALYZE,缺索引就补,计划切换可以用 pg_hint_plan 扩展或改写 SQL 稳住。突然变慢还要排查刚发生的大批量写入、DDL、长事务阻碍 autovacuum 等。最后优化前后用 EXPLAIN ANALYZE 对比验证效果。

Q:为什么建了索引却不走索引?

答题思路:列举典型原因并给对应解法。

参考回答:常见五类:一是列上套了函数或表达式,比如 WHERE upper(name)=…,要建表达式索引;二是隐式类型转换,varchar 列拿数字去比;三是前导通配 LIKE ‘%abc’,要换 pg_trgm GIN 索引;四是区分度太低或表太小,优化器算出全表扫更便宜;五是统计信息过期导致估算错误。方法就是 EXPLAIN 看它实际选了什么,再对照这几类原因逐个排除。

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
-- 贴近业务的表结构
CREATE TABLE users (
id bigserial PRIMARY KEY,
name text NOT NULL,
email text UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE orders (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id),
amount numeric(10,2) NOT NULL,
status text NOT NULL DEFAULT 'paid', -- paid / refunded / pending
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX orders_user_created_idx ON orders (user_id, created_at DESC);

-- 造 1 万用户 + 100 万订单
INSERT INTO users (name, email)
SELECT 'u' || g, 'u' || g || '@x.com' FROM generate_series(1, 10000) g;

INSERT INTO orders (user_id, amount, created_at)
SELECT (random()*9999+1)::bigint,
(random()*500)::numeric(10,2),
now() - (random()*365) * interval '1 day'
FROM generate_series(1, 1000000);

ANALYZE orders; -- 造数后必须更新统计信息,否则估算全是默认值

-- 1) 对比:深分页 vs Keyset Pagination
EXPLAIN ANALYZE
SELECT id FROM orders ORDER BY id LIMIT 20 OFFSET 1000000; -- 扫过并丢弃 100 万行,慢

EXPLAIN ANALYZE
SELECT id FROM orders WHERE id > 1000000 ORDER BY id LIMIT 20; -- 索引定位起点,微秒级

-- 2) 看一个 Index Scan + Hash Join + Sort 的组合计划
EXPLAIN (ANALYZE, BUFFERS)
SELECT u.name, count(*) AS order_cnt, sum(o.amount) AS total
FROM users u JOIN orders o ON o.user_id = u.id
WHERE o.created_at >= now() - interval '30 days'
GROUP BY u.id, u.name
ORDER BY total DESC
LIMIT 10;

-- 3) 写操作看计划的安全姿势:事务 + 回滚
BEGIN;
EXPLAIN ANALYZE UPDATE orders SET status = 'refunded' WHERE id = 500000;
ROLLBACK; -- 不落任何修改

-- 4) 定位慢 SQL:Top 10 按总耗时
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- 需先在 postgresql.conf 设置 shared_preload_libraries = 'pg_stat_statements' 并重启
SELECT calls, round(total_exec_time::numeric, 1) AS total_ms,
round(mean_exec_time::numeric, 1) AS avg_ms, rows,
left(query, 60) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

-- 5) 看"此刻"正在跑的长查询
SELECT pid, state, now() - query_start AS running_for,
wait_event_type, wait_event, left(query, 80) AS query
FROM pg_stat_activity
WHERE state = 'active' AND now() - query_start > interval '2 s'
ORDER BY running_for DESC;

易错点与追问

易错点 正确理解
把 cost 当耗时读 cost 是虚拟成本单位,只用于同层方案比较,与毫秒没有换算关系
actual time 当总耗时 它是每次 loop 的平均值,Nested Loop 内层要 ×loops 才是真实总耗时
见到 Seq Scan 就说有问题 小表或命中大部分行时,Seq Scan 就是最优计划
EXPLAIN ANALYZE 跑写语句 会真的执行;必须 BEGIN → EXPLAIN ANALYZE → ROLLBACK
把 EXPLAIN ANALYZE 的 ANALYZE 当 ANALYZE 命令 前者是”真实执行”选项;后者是收集统计信息的命令,两者无关
Index Only Scan 神秘失效 页面可见性位图靠 VACUUM 维护,大批量写入后未被清理就退化成回表
认为优化器保证最优 优化器基于估算贪心选择,统计一歪计划就歪,盯住估算与实际的偏差

面试官常见追问链:
慢 SQL 怎么找(pg_stat_statements / 慢日志)→ 怎么看懂计划(节点 + cost/rows/loops)→ 估算为什么不准(统计信息、倾斜、列相关)→ 建了索引为什么不用(函数包裹、类型转换、选择性)→ 深分页怎么办(Keyset)→ 优化效果怎么验证(EXPLAIN ANALYZE 前后对比、BUFFERS 看 IO 变化)。

相关章节

第6章 事务 ACID

💡 一句话核心:ACID 四个字母,PG 各有机制兜底——原子性靠事务提交状态 + WAL、持久性靠 WAL 先落盘、隔离性靠 MVCC + 锁,而一致性是前三者共同作用的结果,不是独立机制。

概念详解

四大特性逐个拆

A — Atomicity 原子性
一个事务里的所有操作要么全部成功、要么全部失败回滚,不存在”做了一半”。典型场景:转账 = 扣款 + 入账两条 UPDATE,绝不允许只执行一条。
PG 的实现:事务从 BEGIN 到 COMMIT/ROLLBACK,真正决定生死的是 CLOG(commit log)里的事务提交状态——In-Progress / Committed / Aborted。崩溃恢复时,未提交(Aborted)事务写入的数据行标记为无效,效果等同于从未发生过。注意 PG 的”回滚”不是undo 掉已写页面,而是把事务标记为 aborted,让所有人读不到它写的版本(这是 MVCC 的功劳)。

C — Consistency 一致性
事务前后数据库始终处于合法状态:满足约束(主键、唯一、外键、CHECK、NOT NULL)、业务不变量(转账后总额不变)。一致性是目标,A/I/D 是手段。有人为因素:如果 SQL 本身写错(比如忘了扣另一边),再强的数据库也保不住一致性。

I — Isolation 隔离性
并发事务互不干扰,一个事务的中间状态对其他事务不可见。PG 的实现是 MVCC + 锁:读走快照不加锁,写写之间靠行锁冲突。详见 事务隔离级别MVCC锁与并发控制

D — Durability 持久性
一旦 COMMIT 返回成功,数据就永久保存,宕机也不能丢。PG 的实现是 WAL(Write-Ahead Log)先落盘:数据页可以先留在内存(buffer),但描述这次修改的 WAL 记录必须先 fsync 到磁盘,COMMIT 才算成功;崩溃后用 WAL 重放恢复。详见 WAL与数据库恢复

核心题:PostgreSQL 是怎么保证 ACID 的?

面试标准答案骨架(四句话 + 展开点):

特性 机制 关键点
原子性 事务提交状态(CLOG) 回滚 = 标记 aborted,靠 MVCC 让旧版本可见,没有 undo log
持久性 WAL 先写日志 COMMIT 时 WAL fsync 落盘才返回成功;checkpoint 保证恢复起点
隔离性 MVCC + 锁 读不加锁(快照),写写冲突靠行锁等待
一致性 以上三者 + 约束 约束在语句级/事务级强制,是 AID 的结果

加分项:能对比 MySQL InnoDB——InnoDB 用 undo log 做回滚和 MVCC 旧版本链,PG 用多版本物理共存 + CLOG 状态,代价是 PG 会产生 Dead Tuple 需要 VACUUM。

事务控制语句

1
2
3
4
BEGIN;                       -- 或 START TRANSACTION
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; -- 或 END;出错则 ROLLBACK

要点:

  • PG 与很多数据库不同,没有开启自动提交的开关:每条语句外面包着一个隐式事务,显式 BEGIN 才把多条语句绑成一个事务
  • 事务内出错后 PG 会进入 aborted 状态:后续语句都会报 current transaction is aborted, commands ignored until end of transaction block,必须 ROLLBACK 才能继续(MySQL 可以继续执行,这是常见踩坑点)
  • 连接默认是 autocommit 模式;ORM 里通常由框架管理(如 SQLAlchemy 的 session.commit/rollback)

SAVEPOINT:事务内的存档点

SAVEPOINT 在事务内部设”存档”,ROLLBACK TO 只回退到存档点,不影响之前已执行的部分,事务仍然继续。用途:

  1. 部分回滚:一批操作中某一步失败,只撤销这一步
  2. 框架里的”嵌套事务”:PG 不支持真正的嵌套 BEGIN,SQLAlchemy 的 begin_nested()、Django 的 transaction.atomic() 嵌套调用底层就是 SAVEPOINT——外层事务照常,内层”子事务”失败只回滚到 SAVEPOINT
1
2
3
4
5
6
BEGIN;
INSERT INTO orders ... ; -- 想保留的部分
SAVEPOINT before_retry;
UPDATE inventory SET stock = stock - 1 ...; -- 可能失败的步骤
ROLLBACK TO SAVEPOINT before_retry; -- 只撤销 UPDATE
COMMIT; -- INSERT 依然提交

注意:大量 SAVEPOINT 会产生子事务开销(影响快照与 clog 访问),别在循环里无脑打存档点。

高频面试题

Q:PostgreSQL 是怎么保证 ACID 的?(超高频,必背)

答题思路:一个特性一个机制,先一句结论再展开;能点出”PG 没有 undo log”和”WAL 先落盘”两个 PG 特色是加分项。

参考回答:原子性靠事务提交状态加 WAL:PG 写数据直接改 buffer 页,事务是否生效由 CLOG 里的提交状态决定,回滚只是把事务标记为 aborted,它写的旧版本因为 MVCC 天然不会被读到,不需要 undo log;崩溃恢复时未提交事务同样按 aborted 处理。持久性靠 WAL 先落盘:修改先记 WAL 并在 COMMIT 时 fsync 到磁盘,数据页可以延后刷盘,宕机后重放 WAL 恢复。隔离性靠 MVCC 加锁:普通读走快照不加锁,读写互不阻塞,写写冲突用行锁。一致性不是独立机制,是约束加上 AID 共同达成的结果。另外提一句对比:MySQL InnoDB 靠 undo log 实现回滚和多版本,PG 是多版本物理共存,所以需要 VACUUM 清理。

Q:SAVEPOINT 是干什么的?什么场景用?

答题思路:定义 → 部分回滚 → 框架嵌套事务原理。

参考回答:SAVEPOINT 是事务内部的存档点,ROLLBACK TO SAVEPOINT 只撤销存档点之后的操作,事务本身继续执行、之前的操作保留。两个典型场景:一是批量操作中某步失败只回滚该步,比如导入一万条数据,失败的那条跳过、其余照常;二是实现”嵌套事务”,PG 不支持嵌套 BEGIN,SQLAlchemy 的 begin_nested、Django 的 atomic 嵌套都是翻译成 SAVEPOINT 实现的。代价是子事务会让快照和提交状态访问变贵,高频循环里要节制。

Q:事务里一条语句失败后,为什么后面的语句都报错了?

答题思路:PG 的 aborted 事务行为 + 正确处理方式,对比 MySQL。

参考回答:PG 的事务内语句一旦出错,整个事务进入 aborted 状态,后续所有语句都会被拒绝并提示 current transaction is aborted,直到 ROLLBACK。这是设计使然:原子性要求事务结果一致,PG 不允许”出错后跳过继续跑”造成部分提交的歧义。MySQL 在这类场景下默认可以继续执行,从 MySQL 迁移过来的人经常踩坑。正确姿势是捕获异常立即 ROLLBACK,或者应用层提前用 SAVEPOINT 划分可回滚区间。

Q:COMMIT 返回成功后瞬间断电,数据会丢吗?

答题思路:答”不会”,落到 WAL fsync 机制上。

参考回答:不会。COMMIT 返回成功的前提是这条事务的 WAL 记录已经 fsync 到磁盘,数据页本身可能还在内存没写出去,但没关系——重启后崩溃恢复会重放 WAL 把修改补齐。控制开关是 synchronous_commit 和 wal_sync_method,默认 synchronous_commit=on 保证这一点;如果业务能容忍极小概率丢尾事务,可以设 off 换性能,那属于主动权衡而非事故。

Q:一致性到底由谁保证?

答题思路:区分”数据库职责”和”业务职责”,避免背”一致性就是其他三个”这种空话。

参考回答:分两层。数据库层面:主键、唯一、外键、CHECK、NOT NULL 等约束在事务中被强制,崩溃恢复也保证约束不被破坏,这些是数据库的职责。业务层面:转账两边同时扣加、库存不为负这类不变量,要靠正确的 SQL 加上合适的隔离级别和约束(比如金额 CHECK)共同实现,SQL 写错数据库救不了。所以一句话:一致性是原子性、隔离性、持久性与约束共同作用的结果,业务不变量还需要开发者自己写对。

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
-- 经典转账事务:全部成功或全部失败
BEGIN;
UPDATE accounts SET balance = balance - 100
WHERE id = 1 AND balance >= 100; -- 条件更新防透支
-- 检查影响行数 = 0 则应 ROLLBACK(应用层判断)
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
INSERT INTO transfers (from_id, to_id, amount, created_at)
VALUES (1, 2, 100, now());
COMMIT;

-- SAVEPOINT:批量导入,坏数据跳过、好数据保留
BEGIN;
SAVEPOINT sp;
INSERT INTO messages (user_id, content) VALUES (1, 'hello');
INSERT INTO messages (user_id, content) VALUES (1, 'bad' || 1/0); -- 这条会除零报错
ROLLBACK TO SAVEPOINT sp; -- 只撤销出错那条
INSERT INTO messages (user_id, content) VALUES (1, 'world');
COMMIT; -- 'hello' 和 'world' 都在

-- 观察 aborted 事务行为
BEGIN;
SELECT 1/0; -- 报错
SELECT now(); -- 报错:current transaction is aborted
ROLLBACK; -- 必须回滚后连接才恢复可用

-- 验证原子性:回滚后数据纹丝不动
BEGIN;
DELETE FROM orders;
ROLLBACK;
SELECT count(*) FROM orders; -- 原数量不变

-- 查看当前库里的长事务(长事务会阻碍 VACUUM,生产要盯)
SELECT pid, state, now() - xact_start AS xact_age, query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;

易错点与追问

易错点 正确理解
以为 PG 回滚会”撤销”已写的数据页 回滚 = CLOG 标记 aborted;旧版本因 MVCC 不可见,没有 undo log
一致性=数据库全责 约束由库保证,业务不变量要靠正确 SQL + 隔离级别 + 约束共同实现
认为单条语句不用事务 每条语句本身就是隐式事务;多条语句要原子才需要显式 BEGIN
事务内出错后继续发语句 PG 进入 aborted 状态拒绝执行,必须先 ROLLBACK(与 MySQL 行为不同)
把 SAVEPOINT 当真嵌套事务 只是子事务存档点,外层 ROLLBACK 会连带撤销所有 SAVEPOINT
SAVEPOINT 无限打 子事务增加快照/提交状态开销,高频循环中要节制
认为 COMMIT 后数据还在内存可能丢 COMMIT 前提是 WAL 已 fsync;数据页可以延后,靠恢复重放补齐

面试官常见追问链:
ACID 分别怎么实现 → 为什么 PG 不需要 undo log(MVCC 多版本)→ WAL 是什么、为什么先写日志(见 WAL与数据库恢复)→ 隔离性具体靠什么(引到 MVCC)→ 长事务有什么危害(阻碍 VACUUM、膨胀,见 VACUUM)→ 框架里的嵌套事务怎么实现(SAVEPOINT)。

相关章节

第7章 事务隔离级别

💡 一句话核心:三种异常(脏读、不可重复读、幻读)层层递进;PG 只有 Read Committed(默认)和 Snapshot Isolation 系的 RR / Serializable——PG 的 RR 基于 MVCC 快照,天然消除了幻读,这是和标准 SQL 最大的不同。

概念详解

三种并发异常:用时间线区分

脏读(Dirty Read):读到了别的事务未提交的数据,对方若回滚,你读到的数据从未存在过。

1
2
3
4
5
6
7
时间  事务A                          事务B
t1 BEGIN; BEGIN;
t2 UPDATE orders SET amount=200
WHERE id=1; -- 未提交
t3 SELECT amount FROM orders
WHERE id=1; -- 读到 200 = 脏读!
t4 ROLLBACK; -- 200 从未存在

不可重复读(Non-repeatable Read):同一事务内两次读同一行,值变了——因为中间别的事务 UPDATE 并提交了。关注点:一行数据的值

1
2
3
4
5
6
7
8
时间  事务A                          事务B
t1 BEGIN;
t2 SELECT amount FROM orders WHERE id=1; -- 读到 100
t3 BEGIN;
UPDATE orders SET amount=200
WHERE id=1; COMMIT; -- 已提交
t4 SELECT amount FROM orders WHERE id=1; -- 读到 200
-- 同一事务两次读同一行结果不同 = 不可重复读

幻读(Phantom Read):同一事务内同一范围条件查询两次,行数变了——中间别的事务 INSERT/DELETE 并提交。关注点:结果集的行数(”幽灵行”)

1
2
3
4
5
6
7
8
时间  事务A                              事务B
t1 BEGIN;
t2 SELECT count(*) FROM orders
WHERE user_id=1; -- 3 行
t3 INSERT INTO orders(user_id,...)
VALUES(1,...); COMMIT;
t4 SELECT count(*) FROM orders
WHERE user_id=1; -- 4 行 = 幻读

一句话区分:脏读看”未提交”、不可重复读看”同一行的值”、幻读看”满足条件的行数”。

PostgreSQL 的隔离级别

隔离级别 说明
Read Uncommitted PG 中不存在实际效果:设置了也等同 Read Committed(PG 内部根本没有”读未提交”的实现路径,脏读在 PG 不可能发生)
Read Committed(默认) 每条语句开始时取一个新快照。同一事务内能看到别的事务刚提交的数据 → 不可重复读、幻读可能出现
Repeatable Read 事务开始后取一个快照用到底(Snapshot Isolation)。重复读一致、幻读消失;但可能出现序列化异常(写偏斜,见下)
Serializable 在 SI 基础上加 SSI(可串行化快照隔离),对所有真串行化异常做检测,冲突时报 40001 could not serialize access 需重试
1
2
3
SET default_transaction_isolation = 'repeatable read';  -- 会话级改默认
BEGIN ISOLATION LEVEL SERIALIZABLE; -- 单事务指定
SHOW transaction_isolation; -- 查看当前级别

重点题:PG 的 RR 和标准 SQL 的 RR 有什么不同?

  • 标准 SQL (SQL-92) 的 RR:只保证可重复读”已提交过的行”,允许幻读。MySQL InnoDB 在 RR 级用 MVCC + 间隙锁把幻读也挡了,属于厂商增强。
  • PG 的 RR:实现是 Snapshot Isolation(SI)——事务第一次查询时拍快照,整个事务只看这个快照。别的事务后来 INSERT 的行在你的快照里不存在,所以天然没有幻读(PG 文档明确写着 RR 级”phantom reads are not possible”)。
  • 代价:SI 引入了标准 RR 没有的异常——写偏斜(Write Skew)。两个事务读了彼此的重叠数据、各写各的不冲突行,组合结果违反不变量。经典例子:值班表要求”至少一名医生 on-call”,两个事务同时读到”还有两个人 on-call”,各自把不同的人调走,各自身上都不违反约束,提交后却没人值班了。SI 能挡住丢失更新和幻读,挡不住写偏斜——要根治得上 Serializable。

Serializable 在 PG 的实现:SSI

PG 9.1 起 Serializable = SSI(Serializable Snapshot Isolation):仍用快照读写不阻塞,但通过 谓词锁(predicate lock)+ SERIALIZABLEXACT 共享内存结构追踪”危险结构”(rw-antidependency 环),一旦检测出可能导致非串行化结果的提交模式,就让后提交者报错:

1
2
ERROR:  could not serialize access due to read/write dependencies
among transactions (SQLSTATE 40001)

关键性质:

  • 性能远好于两阶段锁:大部分并发场景仍然读写不阻塞,只有”读过的数据随后被改”这种危险模式才触发
  • 必须配重试:应用层要捕获 40001 重试整个事务(这是面试加分点:任何 Serializable 方案都要求业务可重试)
  • 冲突率高时重试风暴会明显放大开销,一般业务 Read Committed + 显式锁(SELECT ... FOR UPDATE)就够,只有强不变量场景(如余额、值班约束)才值得 Serializable

两张异常对照表

标准 SQL 定义的异常矩阵:

异常 \ 级别 Read Uncommitted Read Committed Repeatable Read Serializable
脏读 可能 不会 不会 不会
不可重复读 可能 可能 不会 不会
幻读 可能 可能 可能 不会

PG 的实际情况:

异常 \ 级别 RU(=RC) Read Committed Repeatable Read Serializable
脏读 不会 不会 不会 不会
不可重复读 可能 可能 不会 不会
幻读 可能 可能 不会 不会
写偏斜(PG 特有标注) 可能 可能 可能 不会

记忆点:PG 没有 Read Uncommitted(设了等于 RC);PG 的 RR 比”标准”更强(无幻读);PG 的 RC 仍是”语句级快照”所以不可重复读、幻读都可能。

高频面试题

Q:脏读、不可重复读、幻读分别是什么?怎么区分?

答题思路:先各给一句定义,再用”读什么、变什么”收拢对比,能画时间线最好。

参考回答:脏读是读到了别的事务未提交的数据,对方一旦回滚,你读到的就像没发生过,最危险;不可重复读是同一事务内两次读同一行,值不一样,根源是中间有别的事务 UPDATE 并提交;幻读是同一事务内用同一范围条件查两次,行数不一样,根源是别的事务 INSERT 或 DELETE 并提交。区分口诀:脏读看提交与否,不可重复读盯同一行的值,幻读盯结果集的行数。PG 里脏读在所有级别都不可能发生,因为 PG 没有读未提交的实现。

Q:PostgreSQL 有哪些隔离级别?默认是哪个?

答题思路:四个名字 + “RU 无效” + “RC 默认每语句快照、RR/Serializable 事务级快照”的实现分野。

参考回答:标准四级都有入口,但实际只有三档:Read Uncommitted 设置了也等同 Read Committed,PG 根本没有脏读的实现。默认 Read Committed,每条语句开始时拿一个新快照,所以同一事务内能看到别的事务陆续提交的数据,不可重复读和幻读都可能出现,但它的单条语句内部是一致的。Repeatable Read 和 Serializable 是事务级快照,整个事务用一个快照,可重复读且无幻读;Serializable 额外用 SSI 检测写偏斜等序列化异常,冲突时报 40001 需要应用重试。

Q:PG 的 Repeatable Read 和标准 SQL 的 RR 有什么不同?(重点)

答题思路:先答”标准允许幻读、PG 不允许”,再落到 Snapshot Isolation 机制,最后主动交代代价(写偏斜)。

参考回答:标准 SQL 的 RR 只保证已提交的行可重复读,允许幻读。PG 的 RR 基于 Snapshot Isolation:事务第一条查询时拍一个快照,之后所有读都走这个快照,后续别的事务插入的行不在快照里,自然看不到,所以 PG 文档明确说 RR 级不会出现幻读——这比标准更强。但 SI 引入了它特有的异常叫写偏斜:两个事务读重叠数据、各自写互不冲突的行,单独看都合法,合起来违反业务不变量,典型例子是医生值班”至少一人 on-call”被两个事务同时挪走两个人。要根治写偏斜就得升到 Serializable 的 SSI。所以一句话:PG 的 RR 消除了幻读但引入写偏斜,Serializable 才是完整串行化语义。

Q:PG 的 Serializable 是怎么实现的?会带来什么问题?

答题思路:SSI = Snapshot Isolation + 冲突检测;说出”读写依赖检测 + 40001 + 应用必须重试”三个关键词。

参考回答:PG 9.1 之前 Serializable 就是可重复读,9.1 起实现为 SSI,可串行化快照隔离。思路是仍然用快照让读写不阻塞,但在运行时用谓词锁追踪每个事务读过的范围,记录读写依赖关系,一旦发现这些依赖构成环——即提交顺序无论如何安排都等价不了串行执行——就中止其中一个事务,报 could not serialize access,SQLSTATE 40001。对应用的要求是必须捕获 40001 重试整个事务,这是用 Serializable 的前提。好处是比传统两阶段锁并发高得多,读写基本不阻塞;代价是冲突率高时重试风暴明显,所以一般场景我用 RC 加必要的 SELECT FOR UPDATE,只有强不变量的资金类、配额类场景才上 Serializable。

Q:既然 RR 已经没有幻读,为什么还需要 Serializable?

答题思路:给出写偏斜反例,说明”无幻读 ≠ 可串行化”。

参考回答:因为 RR(Snapshot Isolation)还会出现写偏斜。设 doctors 表约束”至少一人 on-call”,两个并发事务各自读到还有两人,然后 A 把医生甲改为休班、B 把医生乙改为休班——两人写的行不同,在行锁层面互不冲突,快照里看到的也都是两人,双双提交成功,结果零人在值,约束被绕过了。这不是幻读也不是丢失更新,而是读写交错造成的序列化异常。SSI 会检测到这类读写依赖环并中止其中一个事务,所以只有 Serializable 才提供真正的”可串行化”保证。

Q:MySQL 的 RR 和 PG 的 RR 一样吗?

答题思路:一句话对比,点到 InnoDB 的间隙锁即可,深水区留给 PostgreSQL与MySQL对比

参考回答:结果上两者在 RR 级都能避免幻读,但路径不同:InnoDB 靠 MVCC 读快照加 Next-Key Lock(记录锁加间隙锁)锁住范围、阻止别的事务往范围里插数据,是”锁”出来的;PG 靠事务级快照天然看不到新行,是”读”出来的,代价是引入写偏斜、必须 Serializable 才彻底。另外 InnoDB 的 RR 对当前读(比如 FOR UPDATE)仍能看到最新已提交数据,PG 的 RR 是彻底的快照一致。

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
-- 建表与数据
CREATE TABLE orders (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL,
amount numeric(10,2) NOT NULL
);
INSERT INTO orders (user_id, amount) VALUES (1, 100), (1, 100), (1, 100);

-- 复现不可重复读(默认 Read Committed 下)
-- 会话A 会话B
BEGIN; --
SELECT sum(amount) FROM orders WHERE user_id=1; -- 300.00
UPDATE orders SET amount=200 WHERE user_id=1;
COMMIT;
SELECT sum(amount) FROM orders WHERE user_id=1; -- 600.00 ← 同一事务里变了
COMMIT;

-- 复现幻读(Read Committed 下)
-- 会话A 会话B
BEGIN; --
SELECT count(*) FROM orders WHERE user_id=1; -- 3
INSERT INTO orders (user_id, amount) VALUES (1, 100);
COMMIT;
SELECT count(*) FROM orders WHERE user_id=1; -- 4 ← 幻读
COMMIT;

-- Repeatable Read:幻读消失
-- 会话A 会话B
BEGIN ISOLATION LEVEL REPEATABLE READ; --
SELECT count(*) FROM orders WHERE user_id=1; -- 3(快照已定)
INSERT INTO orders (user_id, amount) VALUES (1, 100);
COMMIT;
SELECT count(*) FROM orders WHERE user_id=1; -- 仍是 3 ← 快照看不到新行
COMMIT; -- 提交后才能看到 4

-- RR 下的写偏斜 + Serializable 检测
-- 表:doctors(id, on_call),约束逻辑"至少一人 on-call",当前甲、乙两人均在值
-- 会话A(RR) 会话B(RR)
BEGIN ISOLATION LEVEL REPEATABLE READ; BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call; -- 2
SELECT count(*) FROM doctors WHERE on_call; -- 2
UPDATE doctors SET on_call=false WHERE id=1; UPDATE doctors SET on_call=false WHERE id=2;
COMMIT; -- 成功 COMMIT; -- 成功 → 0 人在值,约束被绕过(写偏斜)

-- 同样操作在 SERIALIZABLE 下:后提交者被中止
-- ERROR: could not serialize access due to read/write dependencies among transactions
-- 应用层应对(伪代码):
-- for attempt in range(3):
-- try: run_txn(); break
-- except SerializationFailure: sleep(random); continue

-- 查看/设置级别
SHOW transaction_isolation;
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ;

易错点与追问

易错点 正确理解
认为 PG 有 Read Uncommitted 设置无效,等同 Read Committed;PG 结构上不可能脏读
认为 RR 必有幻读 标准如此,PG 的 RR 是 Snapshot Isolation,幻读不会发生
不可重复读和幻读混为一谈 前者是同一行”值”变(UPDATE),后者是结果集”行数”变(INSERT/DELETE)
认为 RR 万无一失 还有写偏斜(Write Skew),强不变量要上 Serializable
上 Serializable 不做重试 40001 序列化失败是设计内行为,应用必须捕获重试
把 RC 的并发异常当 bug RC 语义就是”每条语句一个快照”,追求更强保证要显式改级别
混淆 PG 与 MySQL 的 RR InnoDB 用 Next-Key Lock 锁范围防幻读,PG 用快照”看不到”,机制不同

面试官常见追问链:
三种异常定义 → 各级别能不能防 → PG 为什么没有 RU → PG 的 RR 凭什么没幻读(快照)→ 那写偏斜呢(例子)→ SSI 怎么检测(谓词锁 + 依赖环 → 40001 重试)→ 快照怎么来的(引到 MVCC)→ MySQL 对比(PostgreSQL与MySQL对比)。

相关章节

  • MVCC:快照与 xmin/xmax 是隔离级别的实现地基,本章异常现象在那里逐帧推演
  • 事务ACID:隔离性是 ACID 的一环,事务控制语句的基础在这里
  • 锁与并发控制:SELECT FOR UPDATE、行锁等”写写”侧手段,与 MVCC 互补
  • PostgreSQL与MySQL对比:InnoDB 的 Next-Key Lock 与 PG 快照方案的机制对比
  • FastAPI与PostgreSQL实战:应用层捕获 40001 重试、ORM 中设置隔离级别

第8章 MVCC

💡 一句话核心:PG 的每行数据带 xmin/xmax 两个事务号,UPDATE/DELETE 不改旧数据只堆新版本,靠 Snapshot + 提交状态判断每个事务该看哪个版本——读写互不阻塞的代价是 Dead Tuple,所以必须 VACUUM。

概念详解

MVCC 是什么、为什么需要它

MVCC(Multi-Version Concurrency Control,多版本并发控制):同一行数据在物理上保留多个版本,不同事务根据自己的”快照”各取所需,从而做到 读不阻塞写、写不阻塞读

没有 MVCC 的世界(纯锁方案):写事务给行上锁 → 所有想读这行的读事务排队 → 报表查询拖死前台交易。MVCC 的世界里:写事务生成新版本,读事务继续看旧版本,两边互不感知。这是 PG 作为 OLTP 数据库高并发的根基。

传统锁方案用”时间换空间”(等锁),MVCC 用”空间换时间”(存多版本)——代价就是旧版本堆积,引出 VACUUM。

实现载体:tuple 的系统列

PG 的每一行(tuple,元组)都附带三个隐藏系统列,任何表都能直接 SELECT:

系统列 含义
xmin 插入(创建)这个版本的事务 ID(xid)
xmax 删除(锁定)这个版本的事务 ID,0 表示还没被任何事务删除/更新
ctid 这个版本在表中的物理位置(页号,行号),同一行的不同版本 ctid 不同

关键认知:UPDATE = 新 INSERT + 旧版本打标记。旧版本和新版本都在表里,通过旧版本的 ctid 链接到新版本(版本链)。事务号由全局计数器分配,单调递增(32 位会回卷,引出 VACUUM 的 freeze)。

完整推演:一行数据的生命周期(必背)

初始状态:事务 100 插入一条记录并提交。

1
2
3
4
5
6
7
8
9
10
11
时间线 ─────────────────────────────────────────────────────►

[事务 100]
BEGIN;
INSERT INTO users (id, age) VALUES (1, 20);
COMMIT; -- CLOG: 100 = committed

表中物理状态(只有 1 个版本):
┌─────────────────────────────┐
│ id=1 age=20 xmin=100 xmax=0 │ ← 唯一版本,所有人可见
└─────────────────────────────┘

事务 200 执行 UPDATE users SET age = 21 WHERE id = 1;

1
2
3
4
5
6
7
8
9
10
11
12
[事务 200]
BEGIN; -- 获得 xid=200
UPDATE users SET age = 21 WHERE id = 1;
步骤1: 找到旧版本 (xmin=100, xmax=0)
步骤2: 把旧版本的 xmax 改为 200,ctid 指向新版本位置
步骤3: 在表的其他位置写入新版本 (xmin=200, xmax=0)
COMMIT; -- CLOG: 200 = committed

此后表中物理状态(2 个版本共存,谁都不删):
版本1(旧): id=1 age=20 xmin=100 xmax=200 ← xmax 非 0:已被 200 更新掉
ctid → 指向版本2
版本2(新): id=1 age=21 xmin=200 xmax=0 ← 活跃版本

注意:物理上 age=20 的旧行还躺在表里,只是被打了 xmax 标记。谁来决定读事务看到哪个版本?——Snapshot。

Snapshot 是什么

Snapshot(快照)是读事务获取的一份”事务可见性边界”记录,核心内容:

1
2
3
4
snapshot = ( xmin snapshot边界, xmax snapshot边界, xip_list )
├─ xmin边界: 当时已分配的最小未完成事务号(小于它的事务都已完结)
├─ xmax边界: 下一个将被分配的事务号(大于等于它的事务"尚未开始")
└─ xip_list: 快照时刻仍在进行中的事务号列表

配合 CLOG(commit log,记录每个 xid 是 in-progress / committed / aborted),快照能回答任意事务号的三种状态:已提交、进行中、已回滚。

快照何时取? 由隔离级别决定(衔接 事务隔离级别):

  • Read Committed:每条语句开始时取新快照 → 同一事务前后语句看到的世界可以不同
  • Repeatable Read / Serializable:事务第一条查询时取快照,全程复用 → 前后一致

可见性判断规则(简化版)

对一个 tuple 版本,判断”我的快照能否看到它”分两步:

第 1 步:xmin 必须对我”已完成可见”

1
2
3
4
xmin < 快照xmax边界           —— 在我拍快照前就已开始
且 xmin ∉ xip_list —— 拍快照时不在进行中
且 CLOG[xmin] = committed —— 最终状态是已提交
(特例:xmin = 我自己的事务号 → 自己插的当然可见)

xmin 不满足 → 这个版本在我眼里”还不存在”。

第 2 步:xmax 必须对我”无效”(删除未生效)

1
2
3
4
5
xmax = 0                          —— 从未被删/改 → 可见
或 xmax ≥ 快照xmax边界 —— 删它的事务在我快照后才开始 → 可见
或 xmax ∈ xip_list —— 删它的事务还在进行中 → 可见
或 CLOG[xmax] = aborted —— 删它的事务回滚了 → 可见
否则(xmax 已提交且在我快照前) → 不可见,沿 ctid 找下一个版本再判

用 Snapshot 收尾推演:三个事务,三种答案

数据状态仍是:版本1(xmin=100,xmax=200) + 版本2(xmin=200,xmax=0)。

事务 300 在 200 提交前开启查询:快照 xip_list=[200]

1
2
3
版本1: xmin=100 已提交 ✓ → xmax=200 在 xip_list(进行中)→ 删除未生效 → 可见 ✓
版本2: xmin=200 在 xip_list → "不存在"
结论:看到 age=20(读不到未提交的修改 = 没有脏读)

事务 300 在 200 提交后开启查询:快照 xip_list=[],xmax边界=201

1
2
3
版本1: xmin=100 已提交 ✓ → xmax=200 已提交且 < 201 → 旧版本不可见
版本2: xmin=200 已提交且 < 201 ✓,xmax=0 → 可见 ✓
结论:看到 age=21

事务 200 最终回滚了(CLOG: 200 = aborted)

1
2
3
版本1: xmax=200 aborted → 删除未生效 → 可见 ✓
版本2: xmin=200 aborted → "从未存在"
结论:看到 age=20(回滚的天然实现,无需 undo)

这就是”原子性靠提交状态 + MVCC”的落地:回滚不擦数据,只改 CLOG 状态,旧版本自动”复活”。

UPDATE 为什么产生新版本而不是原地改?DELETE 为什么不立刻物理删除?

同一个原因的两面:旧版本可能正被其他并发事务读取

  • 如果 UPDATE 原地改:并发读事务的快照里只有”改之前的值”,原地改要么让它们读到新值(违反快照语义、产生脏数据),要么给读加锁(退回阻塞式并发)。生成新版本,读旧版本的事务完全无感知。
  • 如果 DELETE 立刻物理删除:正在读旧行的事务会读到”半行”或行消失。打 xmax 标记是”逻辑删除”,等所有可能读它的事务都结束后,再由 VACUUM 回收。

一句话:多版本让”读过去”成为可能,物理清理必须等没人再读过去——谁判断”没人读了”?VACUUM(详见 VACUUM)。

MVCC 与锁的关系

MVCC 只解决 读写冲突,不取消锁:

冲突 解决手段
读 vs 写 MVCC 快照——互不阻塞
写 vs 写(两个事务改同一行) 行级锁:后到者阻塞等待(或 NOWAIT 报错)
写 vs 表结构(DDL) 表级锁(如 ALTER 需要ACCESS EXCLUSIVE)

注意两个事务同时 UPDATE 同一行时,即使 MVCC 也会串行化:后者发现 xmin 已被并发事务标记,等待前者结局;若前者回滚则重试。另外 SELECT ... FOR UPDATE 是主动给读加锁(当前读),用来在”读-改-写”流程中防丢失更新——这是 MVCC 快照读之外的应用层武器(见 锁与并发控制)。

副作用:Dead Tuple

被 xmax 标记、且删除它的事务已提交的旧版本 = Dead Tuple(死元组)。它们:

  1. 占空间:频繁 UPDATE 的表(如计数器、状态字段)膨胀数倍很常见
  2. 拖慢扫描:Seq Scan、索引扫描都要跳过死元组;索引里新旧版本各有条目,索引也膨胀
  3. 需要回收:VACUUM/autovacuum 回收死元组空间;回收前要确认”没有任何还活着的事务能看到它”——所以 长事务会阻碍 VACUUM,导致表持续膨胀(生产最经典的坑)

由此 PG 的正确使用姿势:尽量用 UPSERT/批量替代高频小 UPDATE、监控 n_dead_tup、别留长事务。

高频面试题

Q:什么是 MVCC?PG 为什么需要它?

答题思路:一句话定义 → 解决什么问题(读写互阻塞)→ 代价一句话带出 VACUUM。

参考回答:MVCC 多版本并发控制,让每行数据保留多个版本,读事务按快照选版本,从而读写互不阻塞。没有它,写事务上锁时所有读者都得排队,报表一跑前台就卡死。PG 的实现是每行带 xmin、xmax 两个事务号,UPDATE 不覆盖旧数据而是插入新版本,读事务靠快照加 CLOG 提交状态判断可见性。代价是旧版本变成 Dead Tuple 占空间拖慢查询,所以需要 VACUUM 周期回收——MVCC 和 VACUUM 是一体两面。

Q:讲讲 xmin、xmax,一条 UPDATE 之后表里到底发生了什么?(必考)

答题思路:直接推演时间线:xmin=100/xmax=0 → 200 更新 → 双版本共存 → 快照决定可见性。这是本章得分点,务必完整。

参考回答:每行都有隐藏系统列:xmin 是创建该版本的事务号,xmax 是删除该版本的事务号,0 表示未被删改,ctid 是物理位置。推演一遍:事务 100 插入 id=1, age=20,得到版本 xmin=100、xmax=0。事务 200 执行 UPDATE age=21,PG 不是原地改,而是把旧版本打上 xmax=200,再插入新版本 xmin=200、xmax=0,此刻表里物理上同时存在 age=20 和 age=21 两个版本。之后谁看到哪个由快照决定:在 200 提交前拍快照的事务,xip_list 里有 200,判断旧版本的删除未生效,看到 age=20;200 提交后拍快照的事务,xmax=200 已提交生效,旧版本不可见,读到新版本 age=21;如果 200 回滚了,CLOG 记为 aborted,旧版本 xmax 无效自动复活,新版本从未存在。整个过程读写不加锁,这就是 MVCC 的核心机制。

Q:Snapshot 是什么?可见性到底怎么判断?

答题思路:快照三要素 + 两步判断法,用规则表达,不用背源码。

参考回答:快照是拍快照那一刻的事务可见性边界,包含三个信息:当时已完结事务的下界、下一个将分配的事务号 xmax边界、以及仍在进行中的 xip_list。配合 CLOG 能判定任意事务号是已提交、进行中还是已回滚。判断一行可不可见分两步:第一步看 xmin——它必须小于快照边界、不在 xip_list 里、且 CLOG 状态是已提交(或是我自己),否则这行对我”不存在”;第二步看 xmax——它为 0、或删除者还在进行中/在我快照之后才开始/已回滚,这行可见;如果 xmax 已提交且在我快照之前,说明这行在我能看到它之前就被删了,沿 ctid 到新版本再走一遍判断。取快照的时机由隔离级别决定:RC 每条语句一个快照,RR 和 Serializable 整个事务一个快照。

Q:为什么 UPDATE 要生成新版本,而不是原地修改?

答题思路:核心是”旧版本正被并发读”,从快照语义反推。

参考回答:因为同一时刻可能有别的事务正拿着更早的快照读这一行。原地改有两种坏结果:要么读者看到改后的新值,等于把未提交数据泄漏给旧快照,快照语义被破坏;要么改之前先锁行,读事务全部阻塞,MVCC 就白做了。生成新版本则两全:读者继续读旧版本,写者写新版本,互不感知,提交时只改 CLOG 状态。DELETE 不立刻物理删除是同一个道理——打 xmax 做逻辑删除,等可能读它的所有事务结束再由 VACUUM 回收。

Q:MVCC 之后还需要锁吗?读操作加锁吗?

答题思路:MVCC 管读写冲突、锁管写写冲突,普通读不加锁。

参考回答:普通 SELECT 是快照读,完全不加锁,这是 MVCC 的意义。但 MVCC 只化解读写冲突,写写冲突仍靠锁:两个事务 UPDATE 同一行,后到者会阻塞等前者的行锁,前者回滚后还要重试。另外两种场景必须补锁:一是”读-改-写”防丢失更新,普通快照读后直接 UPDATE 会覆盖别人的修改,要用 SELECT FOR UPDATE 当前读加锁,或用带条件的乐观更新检查影响行数;二是 DDL 与 DML 冲突靠表级锁。所以准确说法是:MVCC 负责读,锁负责写,二者是互补不是替代。

Q:MVCC 的副作用是什么?会引发什么生产问题?

答题思路:Dead Tuple 三宗罪 → autovacuum → 长事务阻碍回收这个经典坑。

参考回答:副作用是 Dead Tuple 堆积。UPDATE/DELETE 后旧版本还留在表和索引里,频繁更新的表会膨胀好几倍,扫描变慢、索引变厚。正常由 autovacuum 周期回收,但回收有前提:没有任何活着的事务还可能看到这些版本。所以生产上最经典的坑是长事务或被遗忘的连接——一个跑了几小时的事务会让全库的死元组都回收不掉,表持续膨胀、查询变慢,pg_stat_activity 里揪出长事务杀掉后立刻缓解。日常要监控 pg_stat_user_tables 的 n_dead_tup 和 n_mod_since_analyze,高频 UPDATE 的表考虑用 UPSERT 或把可变字段拆到旁表。

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
-- 1) 亲眼看见 xmin / xmax / ctid
CREATE TABLE users (id int PRIMARY KEY, age int, updated_at timestamptz DEFAULT now());

BEGIN;
INSERT INTO users VALUES (1, 20);
SELECT xmin, xmax, ctid, * FROM users; -- xmin = 当前事务号, xmax = 0
COMMIT;

-- 2) UPDATE 后的双版本共存(单会话即可观察当前版本)
UPDATE users SET age = 21 WHERE id = 1;
SELECT xmin, xmax, ctid, age FROM users WHERE id = 1;
-- 新版本: xmin = 事务200 的号, xmax = 0, ctid 变了(新物理位置)

-- 3) 双会话复现"快照决定可见性"
-- 会话A 会话B
BEGIN ISOLATION LEVEL REPEATABLE READ; --
SELECT age FROM users WHERE id=1; -- 21
BEGIN;
UPDATE users SET age=30 WHERE id=1;
COMMIT; -- 已提交
SELECT xmin, xmax, ctid, age FROM users WHERE id=1;
-- RR 下仍看到 age=21,xmin 是 200 那个版本的号
-- 因为快照固定:xmax=300(更新者) 已提交但发生在快照之后 → 旧版本仍可见
COMMIT;
SELECT age FROM users WHERE id=1; -- 现在看到 30

-- 4) 观察 Dead Tuple 与膨胀
UPDATE users SET age = age + 1 FROM generate_series(1,1000) g
WHERE users.id = 1; -- 反复更新同一行(业务反例,仅演示)

SELECT relname, n_live_tup, n_dead_tup, n_mod_since_analyze,
last_autovacuum, last_vacuum
FROM pg_stat_user_tables
WHERE relname = 'users';
-- n_dead_tup 很高 = 大量旧版本等待回收

VACUUM (VERBOSE, ANALYZE) users; -- 手动回收并更新统计
SELECT relname, n_dead_tup FROM pg_stat_user_tables WHERE relname='users';
-- 回收后 n_dead_tup 归零(空间复用给后续插入,文件大小通常不变)

-- 5) 找出阻碍 VACUUM 回收的长事务
SELECT pid, now() - xact_start AS xact_age, state, left(query, 60) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start ASC;
-- 查看为最老事务保留的快照
SELECT datname, backend_xmin, age(backend_xmin) AS xmin_age
FROM pg_stat_activity
ORDER BY age(backend_xmin) DESC NULLS LAST;

易错点与追问

易错点 正确理解
认为 UPDATE 是原地修改 PG 生成新版本,旧版本打 xmax 标记留在原地(对比 MySQL InnoDB:undo log 存旧版本)
认为 DELETE 立刻释放空间 只打 xmax 逻辑删除,物理回收交给 VACUUM
认为 MVCC 后不需要锁 普通读不加锁,但写写仍靠行锁,读改写仍要 FOR UPDATE 防丢失更新
以为回滚会擦除数据 回滚 = CLOG 标记 aborted,旧版本因 xmax 无效而”自动可见”,没有 undo
把 xmax=0 当异常 xmax=0 表示”从未被删改”,是正常活跃版本
认为快照是”某时刻数据副本” 快照只是事务号集合(边界 + 进行中列表),不复制任何数据
VACUUM 后文件变小 VACUUM 复用页内空间,一般不缩文件;缩文件要 VACUUM FULL(锁表)
长事务”只是跑得慢” 它的快照会拖住全库 Dead Tuple 回收,是表膨胀的头号元凶

面试官常见追问链:
MVCC 是什么 → xmin/xmax 怎么工作(推演 100/200 例子)→ 快照长什么样、何时取(隔离级别)→ 可见性判断规则 → 为什么不原地改(并发读旧版本)→ 死元组谁清理(VACUUM)→ 什么会阻碍清理(长事务、待冻结的 xid 回卷)→ 和 MySQL InnoDB 的 MVCC 差异(undo 链 vs 物理多版本)。

相关章节

  • VACUUM:MVCC 的直接后果 Dead Tuple 由它回收,xid 回卷也在那里讲
  • 事务隔离级别:快照的取用时机(每语句 vs 每事务)决定各级别的异常表现
  • 事务ACID:原子性(提交状态 + 无 undo)与持久性(WAL)的底层正是本章机制
  • 锁与并发控制:MVCC 管读写、锁管写写,FOR UPDATE 防丢失更新
  • PostgreSQL与MySQL对比:InnoDB undo log 版本链与 PG 物理多版本的机制对比
  • 索引:索引条目同样指向新旧版本,索引膨胀与 HOT 更新相关

第9章 VACUUM

💡 一句话核心:PostgreSQL 的 MVCC 把旧版本行直接留在表里(不像 MySQL InnoDB 放 undo log),UPDATE/DELETE 只标记、不物理删除,久而久之留下大量 Dead Tuple;VACUUM 就是 PG 的”后台垃圾回收”——回收 Dead Tuple 空间、更新 Visibility Map、冻结过旧的事务 ID。理解 VACUUM = 理解 PG 为什么表会”越用越大”。

概念详解

为什么 PostgreSQL 必须 VACUUM:MVCC 的实现方式决定的

PG 的 MVCC(见 MVCC)不使用回滚段/undo log,而是把所有版本的行(tuple)直接堆在表的数据文件里

操作 物理上发生什么
INSERT 追加写入一条新 tuple
DELETE 不删除,只在该 tuple 的 xmax 写入”删除它的事务 ID”
UPDATE 删除旧版本(标记 xmax)+ 插入新版本,一行更新 = 2 条 tuple

对比 InnoDB:UPDATE 的旧版本写到 undo log,回滚和多版本读都靠 undo 重建;PG 的旧版本就在原表里,读旧版本不用回溯 undo 链,代价是垃圾必须由 VACUUM 事后清理。这是 PG 与 MySQL 最有代表性的架构差异之一(见 PostgreSQL与MySQL对比)。

Dead Tuple 是什么

  • 每条 tuple 头部有 xmin(创建它的事务 ID)和 xmax(删除/更新它的事务 ID)。
  • 一条 tuple 是 Dead Tuple(死元组):对所有活动事务的快照都不可见,且删除/替换它的事务已经提交。它没有任何人能再读到,但还占着磁盘空间和 Buffer
  • 如果 UPDATE/DELETE 频繁而没有 VACUUM 跟上,Dead Tuple 持续堆积 → 表文件膨胀(Table Bloat)→ 顺序扫描、索引都要扫过更多无效数据,性能下降。

VACUUM 具体做三件事

  1. 回收 Dead Tuple 的空间,供本表原地复用:把 Dead Tuple 占用的空间登记为可复用(FSM 空间映射),之后的 INSERT/UPDATE 可以就地使用。注意:空间留给表自己用,一般不还给操作系统(只有文件末尾整段空闲时才可能截断归还,中间的空洞不会移动)。
  2. 更新 Visibility Map(可见性映射):给每个 8KB 页打上”全可见 / 全冻结”标记。Index-Only Scan(见 索引)依赖它跳过回表;VACUUM 也靠它快速跳过干净页。
  3. 冻结(Freeze)过旧的事务 ID:防止事务 ID 回卷(见下文进阶部分)。

普通 VACUUM 不需要独占表:它只拿 SHARE UPDATE EXCLUSIVE 表锁,不阻塞读写(但阻塞其他 VACUUM 和大部分 DDL,见 锁与并发控制)。

VACUUM vs VACUUM FULL(高频对比)

维度 VACUUM(普通) VACUUM FULL
原理 原地清理 Dead Tuple,空间表内复用 复制整表到新文件,替换旧文件(连同索引全部重写)
表锁 SHARE UPDATE EXCLUSIVE,不阻塞读写 ACCESS EXCLUSIVE,阻塞一切读写
磁盘空间 基本不缩文件(只有末尾截断) 真正归还磁盘空间,表和索引都压缩
额外磁盘 需要约等于表大小的临时空间
使用场景 日常维护、autovacuum 默认执行的就是它 膨胀严重、且能接受停写窗口时;生产慎用

生产上更常用的替代方案是第三方扩展 pg_repack:在线重组表、几乎不长时间阻塞业务,效果类似 VACUUM FULL。

VACUUM 不是”删除数据”

面试易混淆点:DELETE 是 DML,删除业务数据(行的逻辑删除,产生 Dead Tuple);VACUUM 是物理维护命令,删除的是对任何人都不可见的旧版本垃圾,不碰任何活数据。所以”数据删不掉找 VACUUM””VACUUM 清空表”这类说法都是错的;反过来,DELETE FROM t 之后表文件也不会变小——文件大小只由高水位决定,这是和 VACUUM FULL 的分界线。

AutoVacuum:自动清理进程

  • autovacuum launcher 周期性检查 pg_stat_user_tables,启动 autovacuum worker 对”够脏”的表执行 VACUUM / ANALYZE。
  • 触发公式:表内死元组估计数 > autovacuum_vacuum_threshold(默认 50)+ autovacuum_vacuum_scale_factor(默认 0.2,即表的 20%)× 表行数估计。也就是说默认一张 1000 万行的表要积累 200 万死元组才触发,大表往往需要按表调低 scale_factor。
  • PG 13+ 对 insert-only 表(只插入不更新)额外有 autovacuum_vacuum_insert_scale_factor,避免只追加的表永远不触发 VACUUM(不冻结会有回卷风险)。
  • 为什么不能关:VACUUM 不只是清垃圾,还负责 Freeze 防 XID 回卷。关掉 autovacuum 的库迟早遇到表膨胀和事务 ID 耗尽(PG 会拒绝写入)。正确做法是调优而不是关闭:降低 scale_factor、错峰、增加 worker 数量(autovacuum_max_workers)、对热点表单独 ALTER TABLE ... SET (autovacuum_vacuum_scale_factor = 0.01)

Table Bloat / Index Bloat:膨胀的原因与观察

膨胀的常见原因:

  • 长事务(或 idle in transaction 的连接)长期持有旧快照,VACUUM 不能回收它之后产生的所有 Dead Tuple(它还可能需要读旧版本);
  • 高频小更新(如计数器、状态字段)不断产生死元组;
  • autovacuum 跟不上写入速度(阈值过高、worker 不够);
  • 复制槽(replication slot)闲置:备库/逻辑订阅落后会像长事务一样钉住旧版本。

观察手段——pg_stat_user_tables 是最常用的口子:

1
2
3
4
5
SELECT relname, n_live_tup, n_dead_tup,
round(n_dead_tup * 100.0 / GREATEST(n_live_tup,1), 2) AS dead_pct,
last_autovacuum, autovacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;

n_dead_tup 持续高、last_autovacuum 很久以前 → 清理跟不上。Index Bloat 指索引里积累大量指向 Dead Tuple 的条目(VACUUM 只做最简单的索引清理),严重的索引用 REINDEX CONCURRENTLY 在线重建。

补充:UPDATE 如果没改任何索引列且新版本能放进同一页,PG 走 HOT(Heap-Only Tuple)更新——不写索引条目,大幅缓解索引膨胀,这也是建表时对高频更新列的索引要克制、可调 fillfactor 的原因。

ANALYZE 与 statistics:和 VACUUM 的分工

VACUUM ANALYZE
作用 回收 Dead Tuple 空间、更新 Visibility Map、Freeze 采样收集统计信息(列的distinct、直方图、相关性),存入 pg_statistic
谁用 物理存储层 查询优化器(见 PostgreSQL查询优化器),决定 JOIN 顺序、走不走索引
类比 打扫房间 给导航更新路况

两者互相独立:只 VACUUM 不 ANALYZE,表干净了但优化器还拿着过期统计信息,可能选错执行计划。autovacuum 会按类似公式(autovacuum_analyze_scale_factor 默认 0.1)自动 ANALYZE;大批量导入/改数据后应手动 ANALYZE 表名,尤其在大表上(默认采样可能不够准)。

进阶:Transaction ID Wraparound(事务 ID 回卷)

  • PG 的事务 ID(XID)是 32 位,只有约 42 亿个,且 MVCC 比较版本新旧是模运算意义上的”A 在 B 之前”。一旦 XID 用完回卷,”过去的事务”会突然被当成”未来”,所有历史数据仿佛都是未来的新数据——数据完整性灾难。
  • 解决办法就是 VACUUM 的 Freeze:把足够老的 tuple 的 xmin 改写成特殊的 Frozen 值(”永久过去”),这些 tuple 不再依赖具体 XID,对应的 XID 即可安全复用。
  • 保护机制:autovacuum_freeze_max_age(默认 2 亿)——表中最老的未冻结 XID 超过它,即使没到死元组阈值也强制触发 anti-wraparound VACUUM;越接近耗尽 PG 警告越急,最后一步会直接拒绝分配新 XID(报错 database is not accepting commands to avoid wraparound data loss),此时只能停写、清理才能恢复。
  • 为什么长事务危险:Freeze 的进度受”全库最老的活跃快照”约束——一个跑几小时的事务(或忘掉 commit 的 idle in transaction 连接、无人消费的复制槽)会拖住最老 XID,让死元组不能回收、冻结不能推进。所以”避免长事务”既是性能问题,也是防止 XID 回卷的硬要求。

高频面试题

Q:为什么 PostgreSQL 需要 VACUUM,而 MySQL 不需要?

答题思路:根因是 MVCC 实现位置不同 → 引出 Dead Tuple → 概括 VACUUM 职责。

参考回答:PG 的多版本直接存在表的数据文件里,UPDATE/DELETE 只做标记不物理删除,旧版本要等 VACUUM 回收;InnoDB 的旧版本放在 undo log 里,由 purge 线程清理,表文件本身不膨胀。所以 VACUUM 是 PG 的架构内生需求,负责三件事:回收 Dead Tuple 空间供表内复用、更新 Visibility Map、冻结老事务 ID 防回卷。

Q:VACUUM 做了什么?它和 VACUUM FULL 的区别?

答题思路:先讲三个职责,再按”锁、空间、代价”三维度对比。

参考回答:普通 VACUUM 原地回收死元组、把空间标记为表内可复用(不还给 OS)、更新 VM、冻结老 XID,只拿 SHARE UPDATE EXCLUSIVE 锁,不阻塞读写。VACUUM FULL 则把整张表(含索引)复制重写成新紧凑文件,能真正把磁盘空间还给 OS,但要 ACCESS EXCLUSIVE 锁、阻塞所有读写、需要额外一倍磁盘空间,生产基本只在维护窗口用,线上更常用 pg_repack 在线重建。

Q:DELETE 之后表会变小吗?VACUUM 和 DELETE 的区别?

参考回答:不会。DELETE 只是标记 xmax,空间留给 VACUUM 回收;且普通 VACUUM 回收的空间优先供表内复用,文件也不缩。DELETE 删的是业务数据,VACUUM 清的是对任何事务都不可见的旧版本垃圾,两者一个属于 DML、一个属于物理维护。要真正缩文件得 VACUUM FULL 或 pg_repack。

Q:AutoVacuum 能不能关掉?为什么?

参考回答:不能关。它不仅清理死元组,还承担 Freeze 防 XID 回卷的兜底职责,关掉迟早出现表膨胀甚至数据库拒绝写入。觉得它碍事时应调优:对大表单独调低 autovacuum_vacuum_scale_factor、增加 autovacuum_max_workers、治理长事务和闲置复制槽。默认公式是 50 + 0.2 × 行数,千万级大表要积累两百万死元组才触发,必须按表调。

Q:什么是表膨胀?怎么发现和处理?

参考回答:死元组和索引垃圾持续堆积、表文件远大于有效数据量就是膨胀。观察 pg_stat_user_tablesn_dead_tup、死元组占比和 last_autovacuum;根因常见四类:长事务钉住旧快照、autovacuum 阈值过高跟不上、高频小更新、闲置复制槽。处理:手动 VACUUM 缓解读放大,严重时维护窗口 VACUUM FULL 或在线 pg_repack / REINDEX CONCURRENTLY。

Q:ANALYZE 是干什么的?和 VACUUM 什么关系?

参考回答:ANALYZE 采样收集列级统计信息存入 pg_statistic,给优化器估算行数、选择计划用;VACUUM 管物理垃圾。两者独立但都由 autovacuum 自动执行,大表上只清垃圾不更新统计,优化器照样可能选错计划。批量导入后要手动 ANALYZE。

Q:什么是事务 ID 回卷?为什么面试官总说”别写长事务”?

答题思路:XID 32 位 → 模比较会翻转 → Freeze 推进被最老快照卡住 → 长事务是元凶。

参考回答:XID 只有 32 位约 42 亿,MVCC 判断新旧依赖”谁在先”的模运算;XID 耗尽回卷会让历史数据被当成未来数据。VACUUM 通过 Freeze 把老 tuple 的 xmin 改成 Frozen 值来释放 XID,但冻结进度被全库最老的活跃快照卡住——一个几小时的长事务或 idle in transaction 连接会同时导致死元组无法回收和冻结无法推进,最坏情况 PG 拒绝写入防止数据丢失。所以长事务不是慢一点的问题,是全库稳定性问题。

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
-- 场景:电商 orders 表,订单状态被高频更新

CREATE TABLE orders (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL,
status text NOT NULL DEFAULT 'pending', -- 高频更新列
amount numeric(10,2),
updated_at timestamptz DEFAULT now()
);

-- 1. 制造死元组:同一行更新 1000 次 = 1 行活跃数据 + 999 条死元组
UPDATE orders SET status = 'paid', updated_at = now() WHERE id = 1;
UPDATE orders SET status = 'shipped', updated_at = now() WHERE id = 1;
UPDATE orders SET status = 'done', updated_at = now() WHERE id = 1;

-- 2. 观察 Dead Tuple(pg_stat_user_tables 是统计累积值)
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables WHERE relname = 'orders';

-- 3. 手动清理,VACUUM VERBOSE 能看到每个阶段(scan heap / vacuum index 等)
VACUUM (VERBOSE, ANALYZE) orders; -- 顺便 ANALYZE 更新统计信息

-- 4. 验证:清理后 n_dead_tup 归零,但表文件大小不变(空间表内复用)
SELECT pg_size_pretty(pg_total_relation_size('orders'));

-- 5. 热点大表单独调低 autovacuum 阈值(按表级参数,不改全局)
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.01, -- 1% 死元组就触发
autovacuum_analyze_scale_factor = 0.005
);

-- 6. 查看当前长事务(膨胀排查第一步:谁钉住了旧快照)
SELECT pid, state, now() - xact_start AS xact_age, query
FROM pg_stat_activity
WHERE state <> 'idle' AND xact_start < now() - interval '5 minutes'
ORDER BY xact_age DESC;
1
2
3
4
5
6
7
# Python/FastAPI 侧的配合:短事务纪律 + 事务ID回卷监控
# 1) 不要在事务里做外部调用(HTTP/LLM 调用),否则极易 idle in transaction
# 2) 定期巡检最老 XID 年龄,接近 20 亿要有告警
async def oldest_xid_age(conn):
row = await conn.fetchrow(
"SELECT max(age(datfrozenxid)) AS max_age FROM pg_database")
return row["max_age"] # 超过 autovacuum_freeze_max_age(2亿) 须介入

易错点与追问

易错说法 正确理解
“VACUUM 会把表清空/删数据” 只清理对任何事务不可见的旧版本,不碰业务数据
“DELETE 后 VACUUM 一定缩小文件” 普通 VACUUM 空间表内复用,缩文件要 VACUUM FULL/pg_repack
“VACUUM 会阻塞业务” 普通版不阻塞读写;VACUUM FULL 的 ACCESS EXCLUSIVE 才阻塞一切
“把 autovacuum 关掉提高性能” 关掉 = 放任膨胀 + XID 回卷风险,只能调参不能关闭
“事务开多久都没事,反正会提交” 长事务钉住快照:死元组回收不了 + Freeze 推不动
“死元组=0 就没有膨胀” 索引膨胀、历史高水位同样让文件偏大

常见追问链:为什么 PG 要 VACUUM?→ MVCC 怎么实现的?→ Dead Tuple 谁能清理?→ 为什么长事务挡住 VACUUM?→ XID 32 位耗尽怎么办?→ Freeze 是什么?→ 这条链就是本章的知识主干。

相关章节

  • MVCC:VACUUM 存在的前提,xmin/xmax、快照可见性都在那里讲
  • 事务隔离级别:隔离级别决定快照范围,进而影响 Dead Tuple 何时”变死”
  • 锁与并发控制:VACUUM 持有的 SHARE UPDATE EXCLUSIVE 表锁、与 DDL 的冲突
  • PostgreSQL查询优化器:ANALYZE 产出的统计信息是优化器估算的输入
  • PostgreSQL与MySQL对比:PG “表内多版本+VACUUM” vs InnoDB “undo+purge” 的经典对比

第10章 锁与并发控制

💡 一句话核心:PG 用”表级锁(8 种模式)+ 行级锁(4 种模式)”两级锁配合 MVCC 做并发控制:普通 SELECT 只加最弱的 ACCESS SHARE 表锁、不加行锁;DML 加 ROW EXCLUSIVE 表锁靠行锁互斥;而 ALTER TABLE/DROP 的 ACCESS EXCLUSIVE 与一切冲突,还会被长事务堵在锁队列里反过来阻塞后面所有查询——这是线上 DDL 卡库的经典事故,用 lock_timeout 防护。

概念详解

锁的分类:表级锁 vs 行级锁

表级锁(Table-Level Lock) 行级锁(Row-Level Lock)
对象 整张表 单行(tuple)
决定什么 谁能同时对这张表”做什么类型的事”(读/写/DDL) 谁能修改/锁定这一行
典型来源 SELECT/DML/DDL/VACUUM 自动加 UPDATE/DELETE、SELECT FOR UPDATE/SHARE
冲突判定 由 8 种锁模式的冲突矩阵决定 由 4 种行锁模式的兼容性决定
存储 共享锁表(pg_locks 可查) 直接记录在 tuple 头部(xmin/xmax),不占锁表内存

另有 Advisory Lock(咨询锁,pg_advisory_lock):由应用自定义语义的命名锁,常用于任务调度去重,一句话了解即可。

表级锁模式:不必死背 8 种,记住主要冲突对

PG 有 8 种表锁模式,从弱到强:ACCESS SHARE → ROW SHARE → ROW EXCLUSIVE → SHARE UPDATE EXCLUSIVE → SHARE → SHARE ROW EXCLUSIVE → EXCLUSIVE → ACCESS EXCLUSIVE。面试只要能对上”命令 → 锁模式 → 冲突谁”即可:

SQL 命令 表锁模式 关键冲突点
SELECT ACCESS SHARE(最弱) 只与 ACCESS EXCLUSIVE 冲突 → 读几乎不被挡
SELECT ... FOR UPDATE/SHARE ROW SHARE
INSERT / UPDATE / DELETE ROW EXCLUSIVE 与 SHARE 及以上冲突;多个 DML 之间不冲突(靠行锁协调)
VACUUM(非 FULL)、ANALYZECREATE INDEX CONCURRENTLY SHARE UPDATE EXCLUSIVE 自相冲突(同时只有一个 VACUUM);不阻塞读写
CREATE INDEX(非 CONCURRENTLY) SHARE 阻塞写、不阻塞读
ALTER TABLE / DROP TABLE / TRUNCATE / VACUUM FULL ACCESS EXCLUSIVE(最强) 与一切模式冲突,包括 SELECT

三个最常考的冲突对:

  1. SELECT vs ALTER TABLE:ACCESS SHARE ↔ ACCESS EXCLUSIVE 冲突 → 建表后一直有查询,ALTER TABLE 就要等所有查询结束。
  2. DML vs DML:表级都是 ROW EXCLUSIVE,互相兼容;真正的互斥发生在行级锁上 → MVCC 下读写也不互斥(见 MVCC)。
  3. VACUUM vs DDL:VACUUM 拿 SHARE UPDATE EXCLUSIVE,和 ACCESS EXCLUSIVE 互斥 → 一次 ALTER TABLE 会让正在跑的 autovacuum 失败重试,反之亦然。

面试重点一:SELECT 会加锁吗?

会加表锁、不加行锁。 普通 SELECT 对表加 ACCESS SHARE 表锁(防止它读到一半表被 DROP/重写),与任何 DML 的 ROW EXCLUSIVE 都兼容,所以读写互不阻塞;同时普通 SELECT 走 MVCC 快照读,完全不加行锁。只有显式锁定语法(FOR UPDATE/SHARE)才加行锁。所以答案不是”不加锁”,而是”加的锁弱到不产生任何业务可见的阻塞”。

面试重点二:锁队列与 lock_timeout(DDL 卡库事故)

PG 的锁请求是排队且先到先得的:一旦 ACCESS EXCLUSIVE 在队列里等待,后面新来的 ACCESS SHARE(普通 SELECT)也要排在它后面(防止写饿死)。于是出现经典事故链:

1
2
3
T1: 长事务/慢查询持有 users 的 ACCESS SHARE(一直不结束)
T2: ALTER TABLE users ADD COLUMN ... 排队等 ACCESS EXCLUSIVE(队头)
T3,T4,T5...: 新来的所有 SELECT 全部排在 ALTER 后面 → 全表瞬间"卡死"

解法(必须会):DDL 前先设置 lock_timeout,拿不到锁就放弃、稍后重试,避免堵队列:

1
2
3
SET lock_timeout = '3s';          -- 只影响当前会话
ALTER TABLE users ADD COLUMN remark text;
-- 3 秒内拿不到 ACCESS EXCLUSIVE 就报错,不影响后面的查询

配合运维手段:先查 pg_stat_activity 有没有长事务,错峰执行 DDL;或用 SET lock_timeout + 重试脚本反复尝试。这类题答出”ACCESS EXCLUSIVE + 队列队头阻塞 + lock_timeout”三要素就完整了。

行级锁:四种模式与外键场景

模式 谁会加 与谁冲突(表内另一行上的操作)
FOR UPDATE 显式悲观锁 与所有其他行锁模式、UPDATE/DELETE 冲突(最强)
FOR NO KEY UPDATE 不改主键/唯一键的 UPDATE 自动加 与 FOR UPDATE、FOR SHARE 冲突;兼容 FOR KEY SHARE
FOR SHARE 显式共享锁 允许别人也 FOR SHARE,阻塞一切更新
FOR KEY SHARE 外键检查自动加(子表插入时锁父表行) 只阻塞 FOR UPDATE 和”改键的 UPDATE / DELETE”

外键为什么用 FOR KEY SHARE:向 orders 插入一条 user_id = 1 的订单时,PG 必须确保 users 里 id=1 这行不被删除、主键不被改,于是对父行加 FOR KEY SHARE。但校验并不关心 nickname 会不会变——FOR KEY SHARE 与 FOR NO KEY UPDATE(普通 UPDATE 自动加的锁)互相兼容,所以”插订单”和”改用户昵称”可以并发,外键检查不会把热点用户行变成串行瓶颈。若校验用的是 FOR UPDATE,每一次插订单都会阻塞该用户的所有更新——这就是四种种类的存在意义:把”保护键不变”和”保护整行不变”分成两档

面试重点三:SELECT FOR UPDATE 有什么用?悲观锁 vs 乐观锁

SELECT ... FOR UPDATE悲观锁:假定并发冲突经常发生,读的时候就把行锁住(事务结束才释放),其他人读写这行都会等它。典型场景——库存扣减,”读库存 → 判断 → 扣减”必须是原子的,否则两个请求同时读到 stock=1,都判断可买,超卖:

1
2
3
悲观锁流程:BEGIN → SELECT stock FROM products WHERE id=42 FOR UPDATE
→ 应用判断 stock >= 1 → UPDATE 扣减 → INSERT 订单 → COMMIT
全程独占该行,第二个请求在 SELECT FOR UPDATE 处排队,绝不超卖
悲观锁(FOR UPDATE) 乐观锁(version 字段)
思路 先锁住再改,冲突=等待 不加锁,提交时校验版本,冲突=重试
冲突多时 稳定,排队但不重试 大量重试失败,浪费 CPU
冲突少时 锁等待开销白付 几乎零开销,一次成功
死锁风险 有(需统一加锁顺序,见 死锁 无锁,无死锁
依赖 数据库行锁 应用层实现(WHERE version=n)
适用 热点行、强一致扣减、串行化要求高 读多写少、冲突概率低(如用户改自己的资料)

Python/AI 岗还常被追问 FOR UPDATE NOWAIT(拿不到锁立刻报错而不是等待)和 FOR UPDATE SKIP LOCKED(跳过已锁行,用于多 worker 抢任务队列),见实战示例。

高频面试题

Q:SELECT 会加什么锁?会不会阻塞别人?

参考回答:普通 SELECT 对表加 ACCESS SHARE 表锁——8 种模式里最弱的,只和 ACCESS EXCLUSIVE(ALTER/DROP/VACUUM FULL)冲突,所以不会被 DML 阻塞、也不阻塞 DML;同时普通 SELECT 是 MVCC 快照读,不加任何行锁。只有 FOR UPDATE/FOR SHARE 这类显式语法才加行锁。唯一要注意的是它可以挡在 ALTER TABLE 前面,间接参与锁队列阻塞。

Q:INSERT/UPDATE/DELETE 加什么锁?

参考回答:表级加 ROW EXCLUSIVE,行级加行锁:UPDATE/DELETE 对目标行加 FOR NO KEY UPDATE(不改键)或 FOR UPDATE(改键);INSERT 插入新行天然独占自己。表级 ROW EXCLUSIVE 之间互相兼容,所以 DML 之间的互斥全在行级,MVCC 又让读完全不用等写。

Q:为什么一条 ALTER TABLE 可能把整张表卡住?

答题思路:ACCESS EXCLUSIVE 与一切冲突 → 锁队列排队规则 → 长事务堵队头 → lock_timeout。

参考回答:ALTER TABLE 要 ACCESS EXCLUSIVE,与包括 ACCESS SHARE 在内的所有模式冲突,必须等表上所有现有锁释放;而 PG 锁请求排队,排在其后的所有新 SELECT 也要等它,所以一个跑很久的事务就能让一条 DDL 堵死后面全部流量。防护是执行 DDL 前设 lock_timeout(比如 3 秒)拿不到就失败重试,并先排查长事务、错峰变更。

Q:SELECT FOR UPDATE 有什么用?讲一个具体场景。

答题思路:先定性(悲观锁)→ 库存扣减流程 → 对比乐观锁 → 提 NOWAIT/SKIP LOCKED。

参考回答:它是事务内的行级悲观锁,锁到 COMMIT 才释放,用来保证”读-判断-写”的原子性。经典场景是扣库存:BEGIN 后先 SELECT stock FROM products WHERE id=42 FOR UPDATE 锁住商品行,应用判断库存足够再 UPDATE 扣减、INSERT 订单、COMMIT;并发请求在 FOR UPDATE 处排队,天然不超卖。冲突频繁时它比乐观锁(version 字段+失败重试)稳定;冲突少时乐观锁更轻。另外两个变体:NOWAIT 拿不到锁立刻报错,SKIP LOCKED 跳过已锁行,适合做任务队列。

Q:行级锁有哪几种?外键检查为什么用 FOR KEY SHARE?

参考回答:四种:FOR UPDATE 最强,独占到提交;FOR NO KEY UPDATE 是不改键的 UPDATE 自动加的,略弱一档;FOR SHARE 共享锁,允许多个读者但阻塞更新;FOR KEY SHARE 只保证键列不被改、行不被删。外键校验(子表插入时锁父表行)用 FOR KEY SHARE,因为它只关心父行主键不变,与”改父行其他列”的 FOR NO KEY UPDATE 兼容,从而外键存在不会拖垮父行的普通更新。

Q:悲观锁和乐观锁怎么选?

参考回答:看冲突率和对重试的容忍度。热点行扣库存、余额这类高冲突强一致场景用 FOR UPDATE 悲观锁,让冲突表现为排队而不是反复失败;读多写少、冲突概率低的场景用 version 乐观锁,省掉锁开销,代价是要写”UPDATE … WHERE version=n,影响行数为 0 就重试”的循环。二者也常混用:热商品悲观锁、冷商品乐观锁。

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
-- 业务表
CREATE TABLE products (
id bigserial PRIMARY KEY,
name text NOT NULL,
stock int NOT NULL CHECK (stock >= 0), -- 数据库层兜底防负库存
version int NOT NULL DEFAULT 0
);
CREATE TABLE orders (
id bigserial PRIMARY KEY,
product_id bigint REFERENCES products(id), -- 外键 → 父行会被 FOR KEY SHARE 锁
qty int NOT NULL
);

-- ========== 方案 A:悲观锁扣库存(SELECT FOR UPDATE)==========
BEGIN;
SELECT stock FROM products WHERE id = 42 FOR UPDATE; -- 锁住商品行,别人排队
-- 应用层判断 stock >= 2,不足则 ROLLBACK
UPDATE products SET stock = stock - 2 WHERE id = 42;
INSERT INTO orders (product_id, qty) VALUES (42, 2);
COMMIT; -- 锁在此刻才释放

-- ========== 方案 B:乐观锁(version 字段,不加锁)==========
-- 读:SELECT stock, version FROM products WHERE id = 42; (假设 stock=5, version=3)
UPDATE products
SET stock = stock - 2, version = version + 1
WHERE id = 42 AND version = 3; -- 只在"没人改过"时成功
-- 影响行数 = 0 → 期间被别人改过 → 重读最新 version 再试(代码见下方 Python)

-- ========== NOWAIT / SKIP LOCKED 变体 ==========
SELECT stock FROM products WHERE id = 42 FOR UPDATE NOWAIT;
-- 拿不到锁立刻报错(ERROR: could not obtain lock),适合前端快速失败

-- 任务队列:多个 worker 并发抢单,互不重复消费
CREATE TABLE messages (
id bigserial PRIMARY KEY,
payload text,
status text DEFAULT 'pending'
);
BEGIN;
SELECT id, payload FROM messages
WHERE status = 'pending'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED; -- 跳过别的 worker 已取走的行
UPDATE messages SET status = 'done' WHERE id = <上一步的id>;
COMMIT;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# FastAPI + SQLAlchemy:两种方案的落地对比
from sqlalchemy import text
from contextlib import contextmanager

def buy_pessimistic(session, product_id: int, qty: int):
with session.begin(): # 事务结束才放锁
stock = session.execute(text(
"SELECT stock FROM products WHERE id = :i FOR UPDATE"),
{"i": product_id}).scalar_one()
if stock < qty:
raise ValueError("库存不足") # ROLLBACK,自动放锁
session.execute(text(
"UPDATE products SET stock = stock - :q WHERE id = :i"),
{"q": qty, "i": product_id})
session.execute(text(
"INSERT INTO orders (product_id, qty) VALUES (:i, :q)"),
{"i": product_id, "q": qty})

def buy_optimistic(session, product_id: int, qty: int, max_retry=3):
for _ in range(max_retry): # 乐观锁 = 校验失败重试
row = session.execute(text(
"SELECT stock, version FROM products WHERE id = :i"),
{"i": product_id}).one()
if row.stock < qty:
raise ValueError("库存不足")
res = session.execute(text(
"UPDATE products SET stock = stock - :q, version = version + 1 "
"WHERE id = :i AND version = :v"),
{"q": qty, "i": product_id, "v": row.version})
if res.rowcount == 1:
session.commit()
return
session.rollback() # 版本变了,重读重试
raise RuntimeError("并发冲突,重试耗尽")

易错点与追问

易错点 正确理解
“SELECT 不加锁” 加最弱的 ACCESS SHARE 表锁,不加行锁;业务上”感觉不到”但不是零
“UPDATE 之间靠表锁互斥” 表级都是 ROW EXCLUSIVE、互相兼容,互斥在行锁
“读写互不阻塞,所以 DDL 也安全” ACCESS EXCLUSIVE 与 SELECT 也冲突,且会堵住整个锁队列
“锁等待就加 lock_timeout” lock_timeout 防 DDL 堵队列;行锁等待由 deadlock_timeout/业务重试处理
“FOR SHARE 和 FOR UPDATE 一样” FOR SHARE 允许并发共享持有,只挡更新;FOR UPDATE 完全独占
“外键不影响并发” 子表插入会对父行加 FOR KEY SHARE;批量插单与改父行其他列可并发,但删父行会被挡
“乐观锁一定更优” 高冲突场景重试风暴反而更差;按冲突率选型

常见追问链:SELECT 加锁吗 → DML 加什么锁 → ALTER TABLE 为什么卡库 → 什么是锁队列 → lock_timeout 怎么配 → 行锁有几种 → FOR UPDATE 做什么 → 悲观锁 vs 乐观锁 → SKIP LOCKED 任务队列 → 并发改同一行会不会死锁 → 死锁

相关章节

  • 死锁:锁的等待成环就是死锁,本章”锁规则”是它的前置知识
  • MVCC:普通 SELECT 不加行锁、读写不阻塞的底层原理
  • 事务隔离级别:不同隔离级别下锁与快照如何配合
  • VACUUM:VACUUM 持有的 SHARE UPDATE EXCLUSIVE 表锁,与 DDL 互相冲突
  • 事务ACID:锁的持有边界是事务(COMMIT/ROLLBACK 才释放)

第11章 死锁

💡 一句话核心:死锁 = 两个(或多个)事务互相持有对方需要的锁、形成循环等待;PostgreSQL 有死锁自动检测(deadlock_timeout 默认 1 秒),检测到就主动回滚其中一个事务报 deadlock detected。应用层最好的防御是所有事务按统一顺序加锁,直接破坏”循环等待”条件。

概念详解

什么是死锁:循环等待

死锁(Deadlock)发生在多个事务以不同顺序获取同一组资源时。经典场景——事务 A 和事务 B 都要更新 user 1 和 user 2 两行,但顺序相反:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
事务A                                事务B
────────────────────────────── ──────────────────────────────
BEGIN;
UPDATE users SET ... BEGIN;
WHERE id = 1; ✔ 持有 user1 UPDATE users SET ...
WHERE id = 2; ✔ 持有 user2
UPDATE users SET ...
WHERE id = 2; ⏳ 等待 user2 UPDATE users SET ...
│ WHERE id = 1; ⏳ 等待 user1
│ │
└──────── A 等 B ───────────┘ │
B 等 A ─────────────────┘

循环等待环:A ──持有user1,等待user2──▶ B ──持有user2,等待user1──▶ A
两方永远等不到对方释放 → 死锁

注意前提:单独看每个事务都完全合法,单跑任何一个都不会出问题;死锁是并发时序才暴露的 bug,所以测试环境常复现不出来,线上偶发。

死锁的四个必要条件(数据库语境)

条件 含义 PG 中的体现
互斥 锁被一个事务持有后,其他事务不能同时持有(写锁) 行排他锁
持有并等待 已拿到部分锁,还在等剩余的锁 事务中途逐条 UPDATE
不可剥夺 锁只能由持有者主动释放(COMMIT/ROLLBACK),不能被抢 PG 行锁不会中途转移
循环等待 等待关系构成环 上图 A→B→A

四个条件同时成立才会死锁。工程上破环最容易下手的就是”循环等待”——统一加锁顺序(见下文)。

PostgreSQL 如何检测死锁

  • 每个后端进程在自己被阻塞时启动一个定时器,超过 deadlock_timeout(默认 1000ms)后,唤醒死锁检测:在等待图(谁在等谁)上找环。
  • 找到环 → 主动选一个事务作为牺牲者回滚,抛错:
1
2
3
4
5
6
ERROR:  deadlock detected
DETAIL: Process 1234 waits for ShareLock on transaction 5678;
blocked by process 5679.
Process 5679 waits for ShareLock on transaction 5677;
blocked by process 1234.
HINT: See server log for query details.
  • 被回滚的是”检测到死锁的那个事务”(自动选择,不是随机挑最大的),应用收到 SQLSTATE 40P01,可以捕获后重试——回滚后环被打破,另一个事务能继续。
  • 两个可调参数:
    • deadlock_timeout:等多久才启动检测。默认 1s 对正常锁等待影响小;调小能更快发现死锁,但在锁竞争激烈时检测本身有 CPU 开销,一般不动。
    • log_lock_waits = on:超过 deadlock_timeout 的等待都记日志,排查锁问题的常用开关。
  • 另外 pg_locks 视图 + pg_stat_activity 可以实时看谁在等谁(排查”没到死锁程度但互相拖”的锁等待)。

如何避免死锁(面试重点:为什么”统一顺序”有效)

  1. 统一加锁顺序(最有效):规定所有事务都按同一顺序访问资源,例如”永远先更新 id 小的行”(WHERE id IN (...) ORDER BY id,或在代码里先排序再操作)。只要顺序全序一致,等待关系只能是”A 等 B”,不可能成环——循环等待条件被结构性破坏,死锁从原理上不可能发生。
  2. 小事务、短持有时间:事务尽早提交,锁持有时间窗口小,和其他事务交叠的概率小;不要在事务里做 RPC、HTTP、LLM 调用(这对 PG 还有个副作用是长事务阻碍 VACUUM,见 VACUUM)。
  3. 一次性锁定所需资源:明确知道要改哪些行时,事务开头就用 SELECT ... FOR UPDATE 按顺序全部锁住,之后慢慢改,避免”改了一半再去拿新锁”。
  4. 走索引、减少意外锁行:UPDATE/DELETE 的 WHERE 没走索引时会顺序扫描,可能先锁到不满足条件的行再释放,无谓扩大锁范围和死锁窗口;给过滤条件建索引(见 索引)。
  5. 应用层兜底:捕获 SQLSTATE 40P01 后重试整个事务(幂等设计),因为死锁永远无法 100% 消灭,只能概率上压到接近零。

高频面试题

Q:什么是死锁?PostgreSQL 怎么处理死锁?

答题思路:先给定义(循环等待)→ 检测机制 → 谁被回滚 → 应用层怎么办。

参考回答:死锁是多个事务互相持有对方需要的锁形成循环等待。PG 不是靠超时失败,而是主动检测:事务等待超过 deadlock_timeout(默认 1 秒)后,死锁检测器在等待图里找环,找到就回滚其中检测到环的那个事务,报 deadlock detected(SQLSTATE 40P01),另一个事务得以继续。应用层应捕获 40P01 并重试整个事务,同时从根上按统一顺序加锁避免成环。

Q:怎么从根上避免死锁?

参考回答:核心是破坏循环等待条件——让所有事务按统一的全序加锁,比如按主键升序更新,等待关系就永远单向不成环。再配合:事务尽量小、不在事务里做外部调用;需要的行在事务开头一次性 FOR UPDATE 锁好;WHERE 走索引避免意外锁到无关行;最后应用层对 40P01 做有限次数重试兜底。

Q:生产报 deadlock detected,你怎么排查和修复?

答题思路:日志 → 还原两个事务 → 改顺序/拆事务。

参考回答:先开 log_lock_waits,从日志 DETAIL 里拿到互相等待的进程号和事务,结合应用日志还原两条 SQL 路径;常见根因是代码里以不同顺序更新同一批行,或一个长事务中途又去锁新资源。修复就三招:统一加锁顺序、把事务拆小、锁前移(一次 FOR UPDATE 锁齐)。如果死锁来自批量任务并发,还可以给任务分片错峰。

Q:死锁和锁等待是一回事吗?

参考回答:不是。锁等待是单向的”A 等 B”,B 提交后 A 自然继续,这是并发控制的正常现象;死锁是互相等待成环,不干预永远解不开。锁等待只需要优化(缩小事务、加索引),死锁必须靠检测机制打破 + 代码层消除成环条件。介于两者之间的长锁等待可用 pg_locks/pg_stat_activity 排查。

Q:SELECT 会造成死锁吗?

参考回答:普通 SELECT 在 MVCC 下不加行锁、读快照,不参与死锁;参与死锁的是写锁(UPDATE/DELETE)和显式锁定(SELECT FOR UPDATE/SHARE)。但注意 FOR SHARE/FOR KEY SHARE 也拿锁,外键校验场景子表插入会锁父表行——高并发下外键 + 批量更新也可能成环,排查死锁时别漏了隐式锁。

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
-- 死锁复现:两个会话按相反顺序更新 users 的两行

-- ========== 会话 A ==========
BEGIN;
UPDATE users SET nickname = 'A1' WHERE id = 1; -- A 持有 id=1
UPDATE users SET nickname = 'A2' WHERE id = 2; -- A 阻塞,等 B 释放 id=2

-- ========== 会话 B(另开一个连接)==========
BEGIN;
UPDATE users SET nickname = 'B1' WHERE id = 2; -- B 持有 id=2
UPDATE users SET nickname = 'B2' WHERE id = 1; -- B 阻塞,等 A 释放 id=1
-- 约 1 秒后:其中一个会话报 ERROR: deadlock detected (SQLSTATE 40P01)
-- 另一个会话的 UPDATE 成功执行

-- ========== 正确写法 1:统一按 id 升序 ==========
-- 会话 A 和 B 都执行:
BEGIN;
UPDATE users SET nickname = 'X' WHERE id = 1; -- 谁先到谁先锁 id=1
UPDATE users SET nickname = 'Y' WHERE id = 2;
COMMIT;

-- ========== 正确写法 2(批量更新更稳妥):"一次性锁齐" ==========
-- 注意:PG 的 UPDATE 语句本身没有 ORDER BY 子句,
-- WHERE id IN (...) 的加锁顺序由执行计划决定、不可控,
-- 所以批量操作的标准范式是:先按 id 升序显式锁齐,再逐行更新。
BEGIN;
SELECT id FROM users WHERE id IN (9, 3, 7) ORDER BY id FOR UPDATE; -- 锁 3,7,9
UPDATE users SET points = points + 10 WHERE id = 3;
UPDATE users SET points = points + 10 WHERE id = 7;
UPDATE users SET points = points + 10 WHERE id = 9;
COMMIT;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# FastAPI/SQLAlchemy 侧:捕获死锁并重试的标准模式
import time
from sqlalchemy.exc import DBAPIError

DEADLOCK_SQLSTATE = "40P01"

def transfer_points(session_factory, from_id: int, to_id: int, amount: int):
# 关键点 1:先统一排序,保证任何调用方加锁顺序一致
first, second = sorted((from_id, to_id))
for attempt in range(3): # 关键点 2:重试兜底
try:
with session_factory() as s, s.begin():
rows = s.execute( # 一次性锁齐(顺序=加锁顺序)
text("SELECT id FROM users WHERE id IN (:a, :b) "
"ORDER BY id FOR UPDATE"),
{"a": first, "b": second}).fetchall()
s.execute(text("UPDATE users SET points = points - :n "
"WHERE id = :i"), {"n": amount, "i": from_id})
s.execute(text("UPDATE users SET points = points + :n "
"WHERE id = :i"), {"n": amount, "i": to_id})
return
except DBAPIError as e:
if e.orig and e.orig.pgcode == DEADLOCK_SQLSTATE:
time.sleep(0.1 * (attempt + 1)) # 指数退避后重试整个事务
continue
raise
raise RuntimeError("transfer failed after retries")

易错点与追问

易错点 正确理解
“死锁就是锁等待太久” 锁等待单向、会自己解开;死锁是环,永不自动解开
“PG 死锁靠超时回滚所有事务” 检测到环后只回滚一个牺牲者,另一个正常继续
“deadlock_timeout 越小越好” 调小检测更及时,但锁竞争激烈时检测开销大,默认 1s 通常合理
“SELECT 会参与死锁” 普通 SELECT 不加行锁;会成环的是写锁和 FOR UPDATE/SHARE 类显式锁
“加了索引只是为了速度” UPDATE/DELETE 不走索引会顺序扫描、意外锁到不相关行,放大死锁窗口
“死锁 100% 可以杜绝” 只能结构性压低概率,应用层 40P01 重试是必备兜底

常见追问链:什么是死锁 → PG 怎么检测 → deadlock_timeout 是什么 → 怎么避免 → 为什么统一顺序能避免 → 重试要不要做幂等 → (延伸)长事务除了死锁还有什么危害 → VACUUM 的 XID 回卷。

相关章节

  • 锁与并发控制:死锁的物质基础——表锁/行锁的加锁规则,先学锁再看死锁
  • VACUUM:长事务的另一面危害(钉住快照、阻碍 VACUUM 与 Freeze)
  • 事务隔离级别:快照与可见性决定哪些读写真正需要排队
  • 索引:WHERE 走索引能缩小锁范围,是死锁预防的隐藏手段
  • 事务ACID:事务边界与回滚语义,牺牲者回滚的原理落点

第12章 WAL与数据库恢复

💡 一句话核心:WAL(Write Ahead Logging,预写日志)的核心原则是——数据页刷盘之前,描述这次修改的 WAL 记录必须先持久化到磁盘。于是 COMMIT 只需顺序刷一小段 WAL 就能返回成功,脏页可以延后慢慢刷;崩溃后从最近一次 Checkpoint 起重放 WAL(redo)就能恢复到崩溃前状态。WAL 同时是崩溃恢复、PITR 时间点恢复和主从复制的共同基础。

概念详解

WAL 是什么:先写日志、后写数据

WAL(Write Ahead Logging,也叫 XLOG)是 PG 把”每一次数据变更”以追加方式记录到磁盘日志文件(默认在 pg_wal/ 目录,16MB 一个段文件)的机制。铁律:修改的数据页写到磁盘之前,对应的 WAL 记录必须先落盘。只要满足这条,磁盘上”旧数据页 + 完整 WAL”就能在崩溃后重建出最新状态。

写入流程(面试要能画出来)

1
2
3
4
5
6
7
8
事务执行 UPDATE 的时间线:
1. 生成 WAL 记录 → 写入内存里的 WAL Buffer
2. 修改 Shared Buffers 中的数据页(置脏位)
——此时磁盘上的数据文件仍是旧值,客户端即可读到新值(MVCC)
3. COMMIT:把 WAL Buffer 中该事务的全部记录 fsync 刷盘
4. 返回客户端"提交成功" ← 此刻只有 WAL 落盘,数据页还没落盘
5. (以后)Checkpointer / Background Writer 把脏页慢慢刷盘
刷页前保证该页对应的 WAL 已先行落盘(先写日志原则)

关键结论:COMMIT 成功 = WAL 已持久化,不代表数据页已持久化。脏页丢失没关系,崩溃恢复会用 WAL 重做;反之如果 WAL 没落盘就写数据页,崩溃后就会出现”磁盘是新页、日志无记录”的无法解释状态。

为什么需要 WAL:三个理由

  1. 性能:把随机写变成顺序写。不写 WAL 的话,每次 COMMIT 为了持久性必须把涉及的 8KB 数据页立刻随机刷盘;有了 WAL,COMMIT 只需顺序追加、fsync 一小段日志,数据页的随机写被合并、延后给 Checkpoint 批量做。顺序 IO 的吞吐远高于随机 IO,这是”先写日志反而更快”的本质。
  2. 崩溃恢复的依据:WAL 是所有变更的完备历史,断电重启后重放即可重建内存中丢失的更新。
  3. 天然是”变更流”:物理复制直接把 WAL 发给备库重放(见 主从复制与高可用);归档 WAL + 基础备份就能做到时间点恢复(PITR)。一套日志服务三个需求。

WAL 如何保证持久性:COMMIT 等待 WAL flush

  • 提交时,Backend 把本事务的 WAL 从 WAL Buffer 刷盘(fsync),确认写完才返回成功;频繁提交的并发事务会合并成组一起刷(group commit),摊薄 fsync 成本。
  • 相关参数:wal_buffers(WAL 缓冲)、commit_delay(微调组提交等待)、fsync生产绝不能关,关了等于放弃持久性)。
  • synchronous_commit 提供持久性档位(下表),面试答”能不能关”要看业务:
COMMIT 等到什么 风险/收益
on(默认) 本地 WAL 已 fsync;流复制下还等备库刷盘 不丢已提交事务
local 只等本地 WAL fsync 主库不丢,备库可能少事务
off 不等,交给 wal_writer 异步刷 OS/机器崩溃可能丢最近几百毫秒已提交事务;不会损坏数据;适合埋点、日志类低价值写入
remote_write 备库已收到并写入 OS 折中档
remote_apply 备库已重放完成 主从读一致(读己之写),延迟最大

Torn Page 与 full_page_writes

磁盘一次 IO 不保证 8KB 页原子落盘,崩溃可能留下”半新半旧”的损坏页(torn page)。PG 的对策是 full_page_writes = on(默认开):每次 Checkpoint 之后,某数据页第一次被修改时,把整页镜像写进 WAL。这样恢复时遇到损坏页,直接用 WAL 里的整页镜像覆盖重建,再继续重放后续增量。代价是 Checkpoint 后第一轮写放大,这也是 Checkpoint 频率需要平衡的原因之一(见 Buffer与Checkpoint)。

Crash Recovery:重启后发生什么

1
2
3
4
5
6
7
8
9
崩溃 → 重启
1. 读取 pg_control,找到最后一次成功 Checkpoint 的位置(redo 起点)
2. 从 redo 起点开始顺序重放(REDO)所有 WAL 记录,直到 WAL 末尾
- full_page_writes 的整页镜像直接恢复页
- 后续增量记录重复应用(幂等)
3. 未提交事务怎么办?—— PG 只做 redo、不做 undo:
事务提交状态记录在 pg_xact(clog)中,未提交事务的修改
依靠 MVCC 可见性规则天然"看不见",之后由 VACUUM 物理清理
4. 恢复完成,对外提供服务

这段有两处高频考点:一是恢复起点是最近一次 Checkpoint(更早的 WAL 已不需要,因为脏页都落盘了),所以 Checkpoint 频率 = 恢复时间 vs 运行时 IO 压力的权衡;二是 PG 不需要 undo,未提交事务的垃圾交给 MVCC + VACUUM(衔接 MVCCVACUUM),与 InnoDB “redo + undo 回滚”形成对比。

Checkpoint 与 WAL 的关系

  • Checkpoint 做:全部脏页刷盘 → 在 WAL 里写一条 CHECKPOINT 记录 → 更新 pg_control。效果是推进恢复起点、缩短未来崩溃后的重放时间
  • 触发条件:checkpoint_timeout(默认 5 分钟)定时触发,或 WAL 量达到 max_wal_size(软限制,PG 会尽量避免超过);CHECKPOINT 命令手动触发。
  • 与 WAL 的两条相互作用:Checkpoint 之后第一次改页要写整页镜像(full_page_writes);max_wal_size 本身就是”两次 Checkpoint 之间允许产生的 WAL 量”的预算。细节在 Buffer与Checkpoint 展开。

WAL 与备份:Base Backup + 归档 = PITR

  • Base Backup(基础备份):用 pg_basebackup(或备份工具 pgBackRest、WAL-G)在库运行中拷贝整个数据目录的一致性快照。
  • WAL 归档:配置 archive_mode = onarchive_command,把写满的 WAL 段文件持续归档到外部存储。
  • PITR(Point-In-Time Recovery,时间点恢复):恢复 = “基础备份 + 重放归档 WAL 到指定时刻”,可精确到某时间戳/LSN/还原点。典型用途:把误删表恢复到删除前一秒——这是”只靠每天全量备份”做不到的。
  • 注意点:归档失败堆积会占满 pg_wal 目录(磁盘爆掉),要监控归档延迟;基础备份期间 PG 自动进入 backup 模式记录起始 WAL 位置。

WAL 与主从复制

  • 物理流复制:主库把 WAL 记录流式发给备库(walreceiver 接收 → 写入备库 pg_walstartup 进程重放),备库是主库的”WAL 重放器”。WAL 就是复制的载体,同步级别由 synchronous_commit / synchronous_standby_names 控制。
  • 逻辑复制:把 WAL 解码成逻辑变更(INSERT/UPDATE 事件,pgoutput 协议)发给订阅端,可跨版本、选择性复制。
  • 因此 WAL 相关参数(wal_levelmax_wal_senders、复制槽)同时决定”能不能建备库、能不能逻辑订阅”。复制细节见 主从复制与高可用

高频面试题

Q:什么是 WAL?为什么需要它?

答题思路:定义 → 铁律 → 三个理由(性能/恢复/复制)。

参考回答:WAL 是预写日志,原则是数据页落盘前对应日志必须先落盘。它一举三得:一是性能,COMMIT 只需顺序 fsync 一小段 WAL,把随机页写延后合并给 Checkpoint;二是可靠性,崩溃后从最近 Checkpoint 重放 WAL 就能恢复;三是 WAL 本身是变更流,直接作为物理复制和 PITR 归档的载体。

Q:一次 COMMIT 过程中 PostgreSQL 做了什么?

参考回答:执行期变更先进 WAL Buffer 并修改 Shared Buffers 里的脏页;COMMIT 时把该事务的 WAL 记录 fsync 刷盘(并发的多个提交会合并成组刷),然后立即返回成功。此时数据页还在内存里没落盘——这正体现”先写日志后写数据”:保证的是可恢复性而不是页已持久化,脏页由 Checkpointer 稍后批量刷出。

Q:数据库崩溃重启后是怎么恢复的?

答题思路:pg_control 找最近 Checkpoint → 从 redo 点重放到 WAL 末尾 → 未提交事务靠可见性屏蔽 → 强调只 redo 不 undo。

参考回答:PG 从 pg_control 读到最后一次 Checkpoint 的 redo 起点开始重放 WAL 直到末尾:Checkpoint 后首改页的整页镜像先修复可能损坏的页,再应用增量记录。PG 没有 undo 阶段——未提交事务的修改留在页里也没关系,事务状态在 pg_xact 里,MVCC 可见性让它们天然不可见,最终由 VACUUM 清理。恢复时长取决于 Checkpoint 到崩溃点之间累积的 WAL 量,所以 Checkpoint 越频繁恢复越快,但运行时 IO 压力越大。

Q:synchronous_commit = off 可以吗?丢了什么?

参考回答:可以,它是性能档位而不是开关。off 时 COMMIT 不等本地 WAL fsync,由 wal_writer 异步刷,机器崩溃可能丢最近几百毫秒已提交事务,但不会出现数据损坏(WAL 机制本身没破坏)。适合埋点、行为日志、可重算的缓存类数据;交易、订单类保持默认 on。有流复制时还有 remote_write/remote_apply 等主从档位,remote_apply 能做到读己之写。

Q:full_page_writes 是干什么的?

参考回答:防止 torn page。一次 IO 不保证 8KB 页原子落盘,崩溃可能留下半新半旧的页;full_page_writes 让每次 Checkpoint 后某页第一次修改时把整页镜像写入 WAL,恢复时用镜像整页覆盖再重放增量,保证任何情况都能重建出一致页。代价是 Checkpoint 后有写放大,这也是不能把 Checkpoint 调得过频的原因之一。

Q:什么是 PITR?怎么实现”恢复到昨天 14:00 误删表之前”?

参考回答:PITR 是时间点恢复,靠”基础备份 + WAL 归档”:平时用 pg_basebackup 定期做基础备份,同时 archive_mode 持续归档写满的 WAL 段;出事后恢复最近一次基础备份,再配置 restore_command 和 recovery_target_time 重放归档 WAL,精确停在删除发生前的那一刻。所以 RPO 能做到接近零——前提是 WAL 归档一直在正常工作,这也是要监控归档延迟和 pg_wal 目录大小的原因。

Q:WAL 和主从复制是什么关系?

参考回答:物理流复制就是把 WAL 流实时发给备库重放,备库本质是 WAL 重放器;同步等级由 synchronous_commit 控制(remote_apply 下备库重放完才回主库,主从读一致)。逻辑复制则是把 WAL 解码成逻辑变更事件发送。所以 wal_level、复制槽这些 WAL 侧配置决定了整个高可用架构的形态。

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
-- ========== 观察 WAL ==========
SELECT pg_current_wal_lsn(); -- 当前写入位置(Log Sequence Number)
SELECT pg_current_wal_insert_lsn(); -- buffer 中尚未写出的位置

-- 计算两个位置之间产生了多少 WAL(容量规划常用)
SELECT pg_size_pretty(
pg_wal_lsn_diff('0/8000001', '0/7000000')); -- 例如 16MB ≈ 一个段文件

-- 看本库 WAL 使用统计(PG 14+)
SELECT wal_records, wal_bytes, pg_size_pretty(wal_bytes) FROM pg_stat_wal;

-- pg_wal 目录占用(归档堆积/复制槽泄漏会暴涨)
SELECT pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), redo_lsn)) AS replay_distance
FROM pg_control_checkpoint(); -- 距上次 checkpoint 已产生多少 WAL

-- ========== 持久性档位实验 ==========
SHOW synchronous_commit; -- 默认 on
SET synchronous_commit = off; -- 仅当前会话:埋点类写入提速
INSERT INTO events(payload) VALUES ('trace-1');
RESET synchronous_commit;

-- ========== 手动触发 Checkpoint 并观察 ==========
CHECKPOINT;
SELECT checkpoint_lsn, redo_lsn,
pg_size_pretty(pg_wal_lsn_diff(checkpoint_lsn, redo_lsn))
FROM pg_control_checkpoint(); -- checkpoint 与 redo 点的关系
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# ========== WAL 归档(PITR 的基础设施)==========
# postgresql.conf
archive_mode = on
archive_command = 'test ! -f /archive/wal/%f && cp %p /archive/wal/%f'
# %p = pg_wal 里的源路径, %f = 目标文件名;命令返回非 0 会不断重试
# 生产推荐用 pgBackRest / WAL-G 代替 cp + 校验

# ========== PITR 恢复到指定时间(PG 12+ 流程)==========
# 1) 恢复基础备份到数据目录
# 2) 清空 pg_wal,创建 recovery.signal 空文件
touch $PGDATA/recovery.signal
# 3) 在 postgresql.auto.conf 配置:
# restore_command = 'cp /archive/wal/%f %p'
# recovery_target_time = '2026-08-29 14:00:00+08'
# recovery_target_action = 'promote'
# 4) 启动库,重放归档 WAL 到 14:00 自动提升为主 —— 误删的数据回来了
1
2
3
4
5
6
7
8
9
10
11
12
# Python 运维脚本:监控 WAL 堆积与复制槽延迟(长事务/闲置槽会阻碍 VACUUM)
import psycopg

with psycopg.connect("dbname=app") as conn, conn.cursor() as cur:
cur.execute("""
SELECT slot_name, active,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn))
AS retained_wal
FROM pg_replication_slots""")
for slot, active, retained in cur.fetchall():
if not active or retained.endswith(("GB", "TB")):
print(f"[ALERT] 槽 {slot} 保留 {retained} WAL,需清理/排查")

易错点与追问

易错点 正确理解
“COMMIT 成功 = 数据已写入数据文件” 只保证 WAL 已持久化,脏页延后刷;恢复靠重放 WAL
“WAL 是为了安全所以更慢” 恰相反:把随机页写换成顺序日志写,通常更快
“崩溃恢复要回滚未提交事务(undo)” PG 只 redo;未提交修改靠 MVCC 不可见 + VACUUM 清理
“synchronous_commit=off 会损坏数据” 最多丢最近一小段已提交事务,不会破坏一致性
“Checkpoint 越频繁越好” 恢复快 but IO 尖峰 + full_page_writes 写放大;看 max_wal_size/checkpoint_timeout 权衡
“full_page_writes 可以关了提速” 生产绝不关,torn page 无法用增量 WAL 修复
“归档/复制槽不用管” 失败堆积或闲置槽会让 pg_wal 无限增长,直至磁盘爆满

常见追问链:COMMIT 时发生了什么 → 为什么只刷 WAL 就能返回 → 崩溃后怎么恢复 → 恢复从哪开始 → Checkpoint 和 WAL 什么关系 → full_page_writes 防什么 → WAL 还能用在哪 → PITR 和流复制 → 顺着就是 Buffer与Checkpoint主从复制与高可用

相关章节

  • Buffer与Checkpoint:脏页从哪来(Shared Buffers)、Checkpoint 触发与调优的完整细节
  • 事务ACID:WAL 是 Durability 持久性的实现载体
  • 主从复制与高可用:WAL 流 = 物理复制的载体,同步级别与高可用架构
  • MVCC:恢复阶段”未提交修改靠可见性屏蔽”的前提
  • VACUUM:闲置复制槽/长事务阻碍 WAL 回收与 Dead Tuple 清理的联动问题
  • PostgreSQL基础与整体架构:walwriter、checkpointer 等后台进程的角色分工

第13章 Buffer与Checkpoint

💡 一句话核心:PostgreSQL 的所有数据页读写都先经过共享内存里的 Shared Buffers,脏页由 Checkpointer/Background Writer 异步批量刷盘;因此 COMMIT 后 WAL 一定已持久化,但数据页不一定已写磁盘——崩溃恢复靠重放 WAL,这就是”先写日志、后写数据”的精髓。

概念详解

整体层次:磁盘 ← OS Page Cache ← Shared Buffers ← Backend

1
2
3
4
5
6
7
8
9
10
Backend 进程(每个连接一个)
│ pin/锁 保护下读写 8KB 页

PostgreSQL Shared Buffers(shared_buffers,进程间共享内存)
│ read()/write() 系统调用

OS Page Cache(操作系统页缓存)
│ fsync() 由 PG 主动调用

磁盘(数据文件 base/... 与 WAL 文件 pg_wal/...)
  • PG 不绕过 OS 缓存:数据文件用常规的 buffered read/write,fsync 保证持久化(截至 PG 16 才有实验性的 debug_io_direct,生产默认不用 Direct IO)。
  • 双重缓存(double buffering)问题:同一个 8KB 页可能在 Shared Buffers 和 OS Page Cache 里各存一份,浪费内存。这是”为什么 shared_buffers 不能设成机器内存的 80%”的核心原因——后面的空间要留给 OS Page Cache,两者合起来才是有效缓存。

Shared Buffers:读页 / 改页的流程

shared_buffers 默认仅 128MB,生产环境通常调到机器内存的 25% 左右(经验值,非硬性规则)。

读页流程

1
2
3
4
5
1. 用 (tablespace, relation, block) 做 hash 查找 Buffer 映射表
2. 命中:pin 住该 buffer(加共享锁)→ 直接返回,不碰磁盘
3. 未命中:找一个可牺牲的 buffer(victim)
├─ victim 是脏页 → 先把它刷盘(前台 IO,可能造成卡顿)
└─ 从磁盘读入目标页 → 更新映射表 → pin + 返回
  • 淘汰策略:Clock Sweep(时钟扫描),近似 LRU 但带 usage_count 访问计数——每次访问计数 +1(有上限),扫描时把计数为 0 的页拿走、非 0 的减 1 再放过,避免一次全表扫描把热点页全部冲掉。
  • 监控命中率:pg_statio_user_tables,或 CREATE EXTENSION pg_buffercache; 直接看每个 buffer 里是什么。

改页流程

1
2
3
4
1. pin 该 buffer + 排他锁
2. 修改前先写 WAL 记录到 wal_buffers(先写日志原则,见 [WAL与数据库恢复](/2026/08/29/12-WAL与数据库恢复/))
3. 在内存中修改页内容 → 置脏标记(dirty bit)→ 解锁
(此时磁盘上的数据文件还是旧值)

Dirty Page(脏页)

定义:在 Shared Buffers 中被修改过、还没有刷到磁盘的页。 内存是新版、磁盘是旧版。

刷脏页的三个角色:

角色 谁触发 特点
Checkpointer checkpoint 触发 一次性把全部脏页刷盘,量大
Background Writer 周期性 提前刷”快被淘汰的脏页”,削峰填谷
Backend 自己 淘汰 victim 时发现是脏页 前台刷,直接拖慢当前查询

Checkpoint:做什么、什么时候触发、性能影响

做什么(三件事)

  1. 把 Shared Buffers 中所有脏页写到磁盘(数据文件恢复到最新状态);
  2. 在 WAL 中写入一条 CHECKPOINT 记录,记录当时的 redo 起点LSN;
  3. 更新 pg_control 中的检查点位置。

效果:这个点之前的数据页已全部落盘,崩溃恢复只需要从这个点开始重放 WAL;之前的 WAL 不再需要(可被回收复用)。同时 checkpoint 之后每页第一次修改要把整页写进 WAL(full_page_writes,防止”页只写了一半”的 torn page,衔接 WAL与数据库恢复)。

什么时候触发

条件 参数 说明
时间到了 checkpoint_timeout,默认 5min 哪怕没有写入也会触发
WAL 量够大 max_wal_size,默认 1GB 上次 checkpoint 之后累计写的 WAL 超限
手动执行 CHECKPOINT; 命令 一般只用于测试
特殊事件 pg_backup_start、smart/fast 关库、pg_ctl 保证备份/停库一致性

对性能的影响

  • checkpoint 一次性刷大量脏页 → IO 风暴,期间查询延迟抖动。
  • PG 的对策是 spread checkpoint(平滑检查点)checkpoint_completion_target(PG 14 起默认 0.9)表示把刷盘动作摊在 checkpoint_timeout 的 90% 时间内匀速完成,而不是最后一把梭。
  • checkpoint 太频繁:WAL 和 fsync 开销上升、full page image 增多导致 WAL 放大;太稀疏:崩溃恢复要重放更多 WAL,恢复时间变长。

Background Writer(bgwriter)

  • 常驻后台进程,每隔 bgwriter_delay(默认 200ms)醒一次,把最近较少使用、大概率即将被淘汰的脏页提前刷出。
  • 目的:脏页”生前”就被后台慢慢写掉,让 Backend 尽量不用前台刷盘、让 checkpoint 到来时积累的脏页更少——削峰填谷,让写 IO 更平滑
  • 关键参数:bgwriter_lru_maxpages(每轮最多刷多少页,默认 100)、bgwriter_lru_multiplier(按近期需求预估的系数,默认 2.0)。
  • 注意:bgwriter 不是用来代替 checkpoint 的,两者分工不同。

WAL Writer

  • 常驻后台进程,每 wal_writer_delay(默认 200ms)把 wal_buffers(默认自动,约为 shared_buffers 的 1/32)中尚未写出的 WAL 尽快刷到磁盘。
  • 意义:让 WAL 尽早落盘,减轻 COMMIT 时同步刷 WAL 的负担(COMMIT 时只等”自己那条 WAL”落盘即可)。
  • 持久性的最终保证不是 wal_writer,而是 COMMIT 时的刷盘synchronous_commit=on 时事务返回成功前,其 WAL 必须已 fsync 到磁盘。

OS Page Cache:为什么要给 OS 留内存

  • 热点数据即使不在 Shared Buffers 里,在 OS Page Cache 中命中也远快于真实磁盘。
  • 所以经验配置是:shared_buffers ≈ 25% 内存,其余大部分留给 OSeffective_cache_size 设为”Shared Buffers + OS 可用缓存”的估计值(它不分配内存,只告诉优化器”索引页大概率在缓存里,随机读没那么贵”)。
  • 若 shared_buffers 给得过大:双重缓存 + PG 自持内存换页,反而整体效率下降。

经典题:COMMIT 之后数据页写到磁盘了吗?

1
2
3
4
5
6
7
8
9
10
11
时间线
─────►
T1 UPDATE:在 Shared Buffers 中修改页 → 产生脏页
T2 生成 WAL 记录 → 写入 wal_buffers
T3 COMMIT:等待该事务的 WAL 记录 fsync 落盘 → 返回客户端成功
↑ 此刻:WAL ✅ 已持久化;数据页 ❌ 仍在内存中(脏页)
T4 之后某次 checkpoint → 脏页才真正写入数据文件

若在 T3 之后、T4 之前断电:
重启 → 从 pg_control 记录的 checkpoint 位置开始重放 WAL
→ 把 T3 的修改"重做"到数据页上 → 数据不丢(RDBMS 的 durability)

结论:PG 用”顺序写小体积的 WAL”代替”立即随机写大的数据页”,既保证了持久性,又把数据页的写合并、延迟到 checkpoint 批量做——这是所有 WAL 型数据库的通用设计。

高频面试题

Q:COMMIT 返回成功后,数据是不是已经写到磁盘了?

答题思路:先给结论(不是),再拆”WAL 持久化 vs 数据页持久化”两层,最后落到崩溃恢复闭环。

参考回答:不是。COMMIT 时 PG 只保证这条事务的 WAL 记录已经 fsync 到磁盘(synchronous_commit=on),被修改的数据页还以脏页形式留在 Shared Buffers 里,要等到之后的 checkpoint(或 bgwriter/淘汰时)才写入数据文件。如果 COMMIT 后立刻断电,重启时 PG 会从最近的 checkpoint 起点重放 WAL,把没来得及落盘的修改重做出来,所以不丢数据。这个设计本质是拿”顺序追加 WAL + 恢复时重放”换”数据页的批量顺序化写入”,性能和持久性兼得。

Q:Checkpoint 是干什么的?什么时候触发?对性能有什么影响?

答题思路:作用(刷脏页 + 推进恢复起点)→ 触发条件(时间/大小/手动)→ 好处与代价(恢复快 vs IO 风暴)→ spread checkpoint。

参考回答:checkpoint 做两件事:把所有共享内存里的脏页刷到数据文件;在 WAL 里写一条检查点记录并更新 pg_control,作为之后崩溃恢复的起点(之前的 WAL 可以回收)。触发条件主要有三个:checkpoint_timeout(默认 5 分钟)到时;上次 checkpoint 后累计 WAL 超过 max_wal_size(默认 1GB);手动执行 CHECKPOINT 命令。好处是 WAL 不会无限增长、崩溃恢复时间可控;代价是集中刷脏页会造成 IO 风暴。PG 用平滑检查点缓解:checkpoint_completion_target=0.9 表示把刷盘摊在超时时间的 90% 内匀速完成。

Q:shared_buffers 设多大合适?为什么不是越大越好?

答题思路:默认 128MB 太小 → 生产 25% 左右 → 剩余留给 OS Page Cache → 双重缓存原理。

参考回答:默认值 128MB 只够开发用,生产经验值是机器内存的 25% 左右。不建议再大有两个原因:一是 PG 使用 OS 的 buffered IO,同一页可能在 Shared Buffers 和 OS Page Cache 里各存一份(双重缓存),shared_buffers 占满内存会挤压 OS 缓存;二是数据库的负载不是纯 buffer 池问题,effective_cache_size 应设成 Shared Buffers 加上 OS 可用缓存的总量,让优化器知道索引页大概率在内存中。判断依据是缓存命中率(pg_statio_* 视图 / pg_buffercache),命中率已经很高就不用盲目加大。

Q:Background Writer、Checkpointer、WAL Writer 有什么区别?

答题思路:三者都在”写”,但写的对象、时机、目的不同,适合用表格答。

参考回答

进程 写什么 什么时候 目的
Background Writer 脏数据页 周期性(200ms) 提前刷快淘汰的页,让前台少做 IO
Checkpointer 全部脏数据页 定时/WAL 超量/手动 建立恢复起点,回收 WAL
WAL Writer WAL buffer 周期性(200ms) 让 WAL 尽早落盘,减小 COMMIT 延迟

一句话:bgwriter 求平滑,checkpointer 求恢复点,WAL writer 求提交低延迟;真正的持久性闸门是 COMMIT 时的 WAL fsync。

Q:数据库突然断电,会丢数据吗?为什么?

答题思路:分”已 COMMIT / 未 COMMIT”两种事务说,落到 WAL 重放。

参考回答:已提交的事务不丢。COMMIT 返回前其 WAL 已 fsync 落盘,断电丢失的只是内存中的脏数据页和 wal buffer;重启后崩溃恢复流程从最近 checkpoint 的 redo 点开始重放 WAL,把已提交事务的修改重做出来,同时回滚未提交事务。所以断电不破坏 ACID 中的持久性;会”丢”的只有 synchronous_commit=off 时、COMMIT 返回后尚未刷盘的那一小段 WAL(性能换风险的显式取舍)。

Q:为什么 PG 一定要”先写 WAL,再改数据页”?

答题思路:脏页随时可能因淘汰被写盘且顺序随机 → 必须有一个顺序的、更小的持久化日志先行 → 衔接 WAL与数据库恢复

参考回答:因为数据页的刷盘时机是”被动”的——淘汰、bgwriter、checkpoint 都可能把任意时刻的页写出去,无法保证”提交时数据已落盘”。于是 PG 把顺序反过来:任何修改先以日志形式顺序追加到 WAL 并在 COMMIT 时落盘;数据页什么时候刷无所谓,崩溃后总能靠重放 WAL 恢复。WAL 是顺序写、量小(一个事务几百字节),数据页是随机写、量大(8KB 一页),”先写日志”用最小的顺序写成本买到了完整持久性。

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
-- 1. 查看核心参数(会话级演示,生产请改 postgresql.conf)
SHOW shared_buffers; -- 默认 128MB,生产常调到内存 25%
SHOW checkpoint_timeout; -- 默认 5min
SHOW max_wal_size; -- 默认 1GB
SHOW checkpoint_completion_target; -- PG14+ 默认 0.9(平滑刷盘占比)
SHOW bgwriter_delay; -- 默认 200ms

-- 2. 手动触发一次 checkpoint,观察脏页变化
SELECT buffers_dirty FROM pg_stat_bgwriter; -- 当前脏页数
CHECKPOINT;
SELECT buffers_dirty FROM pg_stat_bgwriter; -- 归零:脏页已全部刷盘

-- 3. 观察 checkpoint 统计(注意:PG 15 起 checkpoint 统计拆到 pg_stat_checkpointer,
-- PG 14 及之前在 pg_stat_bgwriter)
SELECT checkpoints_timed, -- 按时间触发的次数(checkpoint_timeout)
checkpoints_req, -- 按大小等条件被动触发的次数
buffers_checkpoint -- checkpoint 刷掉的 buffer 数
FROM pg_stat_bgwriter;

-- 4. 装扩展直视 Buffer 池里有什么(热点分析)
CREATE EXTENSION IF NOT EXISTS pg_buffercache;
SELECT c.relname,
count(*) AS pages_in_buffer,
pg_size_pretty(count(*) * 8192) AS size
FROM pg_buffercache b
JOIN pg_class c ON b.relfilenode = pg_relation_filenode(c.oid)
WHERE c.relname IS NOT NULL
GROUP BY c.relname
ORDER BY pages_in_buffer DESC
LIMIT 10;

-- 5. 观察某个表的缓存命中情况
SELECT relname,
heap_blks_read AS 磁盘读页数,
heap_blks_hit AS 缓存命中页数,
round(heap_blks_hit::numeric /
NULLIF(heap_blks_hit + heap_blks_read, 0) * 100, 2) AS 命中率
FROM pg_statio_user_tables
ORDER BY heap_blks_read DESC
LIMIT 5;

易错点与追问

常见误区对比

误区 真相
COMMIT 后数据一定在磁盘上 只保证 WAL 落盘;数据页等 checkpoint 批量刷
shared_buffers 越大越好 双重缓存 + 挤压 OS 缓存,25% 左右是经验上限,看命中率
bgwriter 负责 checkpoint bgwriter 只提前刷快淘汰的页;checkpoint 是独立进程
WAL writer 保证持久性 持久性闸门是 COMMIT 时的 fsync,wal writer 只是减负
checkpoint 越频繁越安全越好 WAL 放大 + IO 风暴;太稀疏则恢复时间变长,需平衡
OS Page Cache 是浪费 PG 不用 Direct IO,OS 缓存是有效缓存的一部分

面试官常见追问链

1
2
3
4
5
6
COMMIT 后数据落盘了吗?
→ 那 WAL 是什么时候落的?(COMMIT 时 fsync,受 synchronous_commit 控制)
→ 断电后怎么恢复?(从 checkpoint redo 点重放 WAL → 12章)
→ checkpoint 本身很慢怎么办?(spread checkpoint / completion_target)
→ 脏页一直不刷会怎样?(buffer 池被占满,前台被迫刷盘,延迟抖动 → 所以有 bgwriter)
→ 怎么知道缓存配置合不合理?(命中率、pg_buffercache、checkpoints_req 占比)

相关章节

  • WAL与数据库恢复 —— “先写日志后写数据”的另一面:本章讲怎么写,12 章讲崩溃后怎么靠 WAL 恢复,两章互为因果。
  • PostgreSQL基础与整体架构 —— checkpointer/bgwriter/wal writer 都是后台进程体系的一员,先有全景再看细节。
  • VACUUM —— VACUUM 清理死元组同样产生脏页,其 IO 同样受 checkpoint/bgwriter 平滑机制影响。
  • PostgreSQL查询优化器 —— effective_cache_size 是 Buffer 体系与成本模型之间的接口参数。
  • 主从复制与高可用 —— 流复制传输的正是 WAL,本章的 WAL 落盘链路是复制的基础。

第14章 PostgreSQL查询优化器

💡 一句话核心:PG 是基于成本的优化器(Cost-Based Optimizer, CBO):用统计信息估算每个候选计划的行数(基数)与 IO+CPU 成本,在”扫描方式 × 连接算法 × 连接顺序”的组合里挑总成本最低的一个——所以有索引不代表一定用索引,统计不准就是灾难

概念详解

Planner 在 SQL 执行链路中的位置

1
2
3
4
5
6
SQL 文本
→ Parse(词法/语法解析,产出语法树)
→ Analyze(语义分析:列是否存在、类型推断)
→ Rewrite(视图展开、规则系统改写)
→ Plan(查询优化器/规划器:选执行计划)★ 本章
→ Execute(执行器按计划树执行,见 [EXPLAIN与SQL优化](/2026/08/29/05-EXPLAIN与SQL优化/))

优化器面对的搜索空间是三个维度的笛卡尔积:

  • 每张表的访问路径:Seq Scan / Index Scan / Index Only Scan / Bitmap Scan
  • 连接算法:Nested Loop / Hash Join / Merge Join(衔接 JOIN与复杂查询
  • 连接顺序:N 张表有 N! 量级的顺序组合

成本模型:一切以 cost 说话

成本是一个抽象值,以 seq_page_cost = 1.0(顺序读一页)为基准:

参数 默认值 直觉含义
seq_page_cost 1.0 顺序读一页(基准单位)
random_page_cost 4.0 随机读一页;SSD 上常调到 1.1~1.5,调小后优化器更倾向走索引
cpu_tuple_cost 0.01 处理一行元组的 CPU 开销
cpu_index_tuple_cost 0.005 在索引里处理一条条目
cpu_operator_cost 0.0025 执行一次操作符/函数(如 WHERE 比较)

总成本 = IO 成本(页访问数 × 单页成本)+ CPU 成本(行处理 + 操作符)EXPLAIN 里的 cost=0.00..105.30 分别是启动成本(出第一行前要花的)和总成本。优化器对 LIMIT 类查询会同时比较”总成本”和”到第 N 行为止的成本”。

统计信息:优化器的眼睛

PG 不逐行看数据再决策(那比执行还贵),而是看预先采样好的统计信息

  • 存储位置:系统表 pg_statistic(内部、超用户可见),人类可读视图是 pg_stats
  • ANALYZE 按列随机采样(样本行数 = 300 × statistics target,default_statistics_target 默认 100,即约 3 万行)更新统计信息;autovacuum 也会顺带触发 autoanalyze。
  • 每列记录的关键内容:
字段(pg_stats 视图) 含义 用途
null_frac NULL 占比 估算 col IS NULL 的选择性
n_distinct 去重行数(负数表示相对占比,如 -0.3 = 30% 唯一) 等值条件、GROUP BY 规模估算
most_common_vals / most_common_freqs(MCV) 最常见的值及其出现频率 WHERE city='北京' 精确估算
histogram_bounds 除 MCV 外值的等频直方图分界 范围条件 WHERE age > 25 插值估算
correlation 列的逻辑顺序与物理存储顺序的相关度(-1~1) 相关度高 → Index Scan 便宜(页接近顺序读)

统计过期是坏计划的第一大来源:大批量导入/删除后没等 autoanalyze 就查询,估算行数可能与真实值差几个数量级,导致本该 Hash Join 的选了 Nested Loop、本该索引的走了全表扫。

Cardinality 与 Selectivity:估算 rows 的依据

  • Selectivity(选择性):条件过滤后剩下的行占比。例如 city 列有 MCV {北京:0.4, 上海:0.3},则 WHERE city='北京' 的选择性直接用 0.4,非常准。
  • Cardinality(基数/行数估算)估算行数 = 表行数(reltuples) × 选择性。这就是 EXPLAINrows=... 的来源。
  • 组合条件遵循独立性假设A AND B 的选择性 = sel(A) × sel(B)。两个条件若实际强相关(如 city='北京' AND district='朝阳'),乘积估算会严重偏小——这正是扩展统计 CREATE STATISTICS(多列相关性统计)要解决的问题。

Seq Scan vs Index Scan:成本权衡

直觉模型:从 1000 万行的表取 5 万行(0.5%)——

1
2
3
4
5
Seq Scan:   顺序读全部页 → 10万页 × 1.0 ≈ 100000 成本(顺序读非常便宜)
Index Scan: 5万次随机页访问 → ≈ 50000 × 4.0 = 200000 成本(随机读贵)
→ 优化器选 Seq Scan,尽管索引存在
取 50 行(0.0005%):
Index Scan: ≈ 50 × 4.0 = 200 成本 → 完胜 Seq Scan
  • 经验阈值:取回占比超过百分之几(取决于表大小、相关度、硬件),全表扫往往更划算——索引的价值在于”少访问页”,一旦要访问的页太多,随机 IO 代价反而超过顺序扫全表。
  • Bitmap Heap Scan 是折中:先用索引把所有匹配的指针收集起来,按物理页号排序,再批量读堆表——把大量随机读变成”近顺序”读,适合中等占比(大致 1%~5% 区间,非精确界线)。
  • correlation 高(如自增主键、时间列)时 Index Scan 的实际页访问少,成本公式会打折扣,所以”时间范围 + 索引”通常很高效。

Join Order:多表连接顺序怎么搜

  • 连接顺序对成本影响巨大(先用小表过滤再 join 大表,中间结果可以小几个数量级)。
  • PG 对 geqo_threshold(默认 12)张表以内的连接用动态规划(Dynamic Programming):完整枚举所有 left-deep/bushy 顺序组合,保证找到最优。
  • 超过 12 张表切到 GEQO(Genetic Query Optimization,遗传查询优化):用遗传算法做近似搜索,避免组合爆炸,代价是可能给出次优计划(视图/子查询嵌套很深的报表 SQL 偶尔命中)。
  • join_collapse_limit / from_collapse_limit(默认 8):控制优化器把多少个 JOIN/FROM 项重排,显式写成子查询/CTE 可以”锁住”连接顺序(PG 12 前 CTE 是优化屏障,12+ 会内联,需 MATERIALIZED 才强制屏障)。

Join 算法的选择(概要,细节见 JOIN与复杂查询

算法 适用场景 关键前提
Nested Loop 外表小、内表连接列有索引 行数少时随机点查总代价低
Hash Join 中大数据量、等值连接 需内存建哈希表(受 work_mem 限制,超了落盘)
Merge Join 两输入已排序、大量数据、要求排序输出 等值连接,排序可被索引省掉

优化器的选择依据依然是:用统计行数分别算三种算法的成本,取最小

核心思想:不是”有索引就用索引”

优化器只回答一个问题:”哪个计划的总成本最低?” 索引只是候选之一。工程上要做的不是”命令”优化器(PG 核心不支持 hint,需要 pg_hint_plan 扩展),而是给它喂对信息

  1. 表统计是最新的(及时/手动 ANALYZE,大批量导入后尤其重要);
  2. 别对索引列做运算或函数包裹(WHERE upper(name)=... 用表达式索引,或改写 SQL);
  3. 连接列、排序列类型一致(隐式类型转换会让索引失效);
  4. 合理索引本身(覆盖 Index Only Scan 场景、符合最左前缀);
  5. SSD 上把 random_page_cost 调小、effective_cache_size 设真实,成本模型才符合硬件;
  6. 选择性差的列适当调大 ALTER TABLE ... ALTER COLUMN ... SET STATISTICS n,或用多列统计。

高频面试题

Q:PostgreSQL 是怎么为一个查询选择执行计划的?

答题思路:先定性(CBO,不是规则型),再给三步流程:统计 → 枚举 → 比成本。

参考回答:PG 是基于成本的优化器。流程是:第一步,从 pg_statistic 里的统计信息(MCV、直方图、n_distinct 等)估算每个条件下能过滤出多少行;第二步,枚举候选计划——每张表的扫描方式(Seq/Index/Bitmap Scan)、连接算法(Nested Loop/Hash/Merge)、连接顺序(12 张表内动态规划精确求解,更多表用遗传算法 GEQO 近似);第三步,用成本模型把每个计划折算成 IO+CPU 成本,取最低者,EXPLAIN 里能直接看到。所以它不是”见到索引就用”,而是估算哪条路总代价最小。

Q:为什么表上明明有索引,查询却不走索引?

答题思路:这是必考题,答”这是特性不是 bug”,然后分类:成本原因 / 写法原因 / 统计原因。

参考回答:分三类原因。第一类是成本上确实不该走:要返回大比例的行(比如百分之几十)时,每行一次随机 IO 的 Index Scan 比顺序扫全表贵,EXPLAIN 里 rows 估算就能看出来——这是优化器在对,不是错。第二类是写法让索引用不上:对列包函数或运算(WHERE age + 1 = 20)、隐式类型转换(varchar 列和数字比较)、前导通配符 LIKE ‘%x’。第三类是统计信息问题:统计过期导致 rows 估算离谱,成本算错,可以 ANALYZE 修复。排查路径就是 EXPLAIN (ANALYZE, BUFFERS) 对比估算行数和实际行数,先分清是”估算错了”还是”估算对了但就是全表扫更优”。

Q:统计信息是什么?过期的统计会怎样?怎么更新?

答题思路:内容(四件套)→ 采样方式 → 过期后果举例 → 更新手段与参数。

参考回答:统计信息存在 pg_statistic 里,通过 pg_stats 看,每列包括 NULL 占比、n_distinct、最常见值 MCV 及频率、等频直方图、物理相关性。ANALYZE 随机采样约 300 × statistics_target(默认 100)行来更新它,autovacuum 也会顺带 autoanalyze。统计过期最典型的灾难是:导入大批数据后估算 rows 还停留在几千,实际几百万,优化器据此选错连接算法——比如本该 Hash Join 选成 Nested Loop,查询从 1 秒劣化到几分钟。手动 ANALYZE 表名; 立即修复,长期方案是检查 autoanalyze 阈值(默认 10% 变更)对大表太钝,可按表调低 autovacuum_analyze_scale_factor。

Q:random_page_cost 和 effective_cache_size 是干什么的?什么时候调?

答题思路:都是”告诉优化器硬件长什么样”的参数——一个管随机读单价,一个管缓存总量;SSD 时代要调。

参考回答:这两个参数不改变任何内存分配,只影响成本估算。random_page_cost 是随机读一页相对顺序读(1.0)的价格,默认 4.0 是上世纪机械盘的比值;在 SSD/NVMe 上随机读很便宜,常调到 1.11.5,调小后优化器会更愿意选 Index Scan。effective_cache_size 告诉优化器”Shared Buffers 加 OS Page Cache 一共大概有多少内存可用”,默认 4GB,应设成真实可用量(如内存的 50%75%);设得越大,优化器越认为索引页已经在缓存里、随机读便宜。机器换 SSD 后不调这两个参数,优化器会持续低估索引的价值。

Q:多表连接时,连接顺序是怎么确定的?什么是 GEQO?

答题思路:顺序是搜索空间的核心维度;小查询 DP 穷举,大查询遗传算法;给出阈值参数。

参考回答:连接顺序对成本影响巨大,PG 把它当搜索问题处理:参与连接的表不超过 geqo_threshold(默认 12)张时,用动态规划完整枚举所有合法顺序与算法组合,找到全局最优;超过后组合爆炸,切换到 GEQO 遗传算法——把候选计划当”个体”做选择、交叉、变异,迭代近似求优,速度快但可能次优。十几张表以上的大查询如果计划不稳定,可以检查是不是踩到了 GEQO,或者用子查询/ join_collapse_limit 控制重排范围。另外表间估算偏差大时,多列统计 CREATE STATISTICS 能显著改善连接两侧的行数估算。

Q:作为开发者,怎么”喂好”优化器?说几条工程实践。

答题思路:给一份可背的清单,按”统计 → 写法 → 索引 → 参数”分层。

参考回答:我的清单是四层。统计层:大批量写入/删除后主动 ANALYZE,监控 autoanalyze 是否跟上大表节奏。写法层:不在索引列上包函数和运算、避免隐式类型转换、LIKE 不用前导通配符、OR 大条件考虑改 UNION。索引层:按查询形态建索引(等值列在前、范围列在后)、能用覆盖索引就 INCLUDE 进去、外键列建索引(不然父表删除会触发子表顺序扫)。参数层:SSD 上调低 random_page_cost、把 effective_cache_size 设成真实缓存量、复杂查询适当调大 default_statistics_target。最后所有调优都以 EXPLAIN (ANALYZE, BUFFERS) 验证,不凭感觉。

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
-- 建一张业务表并制造数据(用户订单)
CREATE TABLE orders (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL,
status text NOT NULL,
amount numeric(10,2),
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO orders (user_id, status, amount, created_at)
SELECT (random() * 1000000)::bigint, -- 100 万用户
(ARRAY['paid','pending','refunded'])[1 + (random()*2)::int],
round((random() * 2000)::numeric, 2),
now() - (random() * 365 || ' days')::interval
FROM generate_series(1, 5000000);

ANALYZE orders; -- 显式更新统计信息

-- 1. 看优化器的"眼睛":pg_stats
SELECT attname, n_distinct, most_common_vals, most_common_freqs
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';
-- 结果示例:most_common_vals={paid,pending,refunded},
-- most_common_freqs={0.4,0.35,0.25}
-- WHERE status='paid' 的选择性直接取 0.4,估算非常准

-- 2. 成本权衡:status='paid' 占 40% → 不走索引(即使建了索引)
CREATE INDEX idx_orders_status ON orders (status);
EXPLAIN SELECT * FROM orders WHERE status = 'paid';
-- → Seq Scan on orders(rows≈2000000,走索引反而贵)

-- 3. 同一列,选择性好的条件 → 走索引
-- 先手工造一个低频值
UPDATE orders SET status = 'vip_refund' WHERE id BETWEEN 1 AND 500;
ANALYZE orders;
EXPLAIN SELECT * FROM orders WHERE status = 'vip_refund';
-- → Bitmap Heap Scan / Index Scan(rows≈500,索引更便宜)

-- 4. 统计过期 → 坏计划复现
CREATE TABLE t2 AS SELECT * FROM orders WHERE status = 'pending'; -- 全部行,但统计还停留在 0 行
EXPLAIN SELECT * FROM t2 WHERE status = 'paid'; -- rows 估算极小 → 计划失真
ANALYZE t2;
EXPLAIN SELECT * FROM t2 WHERE status = 'paid'; -- ANALYZE 后估算恢复

-- 5. 对列做运算使索引失效,以及表达式索引修复
EXPLAIN SELECT * FROM orders WHERE extract(year from created_at) = 2026;
-- → Seq Scan:created_at 被函数包裹
CREATE INDEX idx_orders_created_year ON orders ((extract(year FROM created_at)));
EXPLAIN SELECT * FROM orders WHERE extract(year from created_at) = 2026;
-- → Index Scan:表达式索引生效

-- 6. SSD 机器上让成本模型匹配硬件(改 postgresql.conf,此处仅示意)
-- random_page_cost = 1.1
-- effective_cache_size = 24GB -- 假设 32GB 内存机器

易错点与追问

常见误区对比

误区 真相
“建了索引就必须走索引” CBO 按成本选;取回占比大时全表扫更便宜,不走索引是对的
“EXPLAIN rows 数字是实际行数” 是统计估算值,和实际差很多说明统计过期或独立性假设失效
“没走索引 = 优化器 bug” 先查三类原因:估算偏差、SQL 写法、成本本来就高
“ANALYZE 会在写入时自动精确更新” 靠 autovacuum 触发,阈值 10% 变更,大表上非常迟钝
“PG 支持 Oracle 式 hint” 核心不支持 hint;需要 pg_hint_plan 扩展,首选仍是喂好统计和索引
“多条件 AND 选择性是相乘” 优化器确实这么算(独立性假设),但条件相关时会严重低估 → 多列统计

面试官常见追问链

1
2
3
4
5
6
为什么有索引不走?
→ 你怎么区分"估算错了"还是"真不该走"?(EXPLAIN ANALYZE 对比 rows vs actual)
→ 估算为什么错?(统计过期 / 独立性假设 → CREATE STATISTICS 多列统计)
→ 统计怎么来的?(ANALYZE 采样 300×target 行 → pg_statistic)
→ 那直方图长什么样?(等频分界 → pg_stats.histogram_bounds)
→ 12 张表以上怎么办?(GEQO 遗传算法,可能次优 → 拆查询/控制重排)

相关章节

  • EXPLAIN与SQL优化 —— 本章讲优化器”怎么想”,05 章讲怎么用 EXPLAIN 看到它的想法并优化,配套使用。
  • 索引 —— 索引是候选计划之一,Index/Bitmap/Only Scan 的成本构成在两章间衔接。
  • JOIN与复杂查询 —— Nested Loop/Hash/Merge 三种算法的执行细节,是本章”连接算法选择”的展开。
  • Buffer与Checkpoint —— effective_cache_size 把 Buffer/OS 缓存情况翻译给成本模型。
  • 分区表 —— 分区裁剪是优化器在计划阶段剔除无关分区,属于统计与估算能力的延伸。

📙 进阶篇

第15章 分页

💡 一句话核心LIMIT 20 OFFSET 100000 要先按排序取过 100020 行再丢掉前 10 万行,越翻越慢;深分页改用 Keyset(游标)分页 WHERE id > last_id ORDER BY id LIMIT 20,靠索引直接定位,代价恒定不随页深增长。

概念详解

LIMIT / OFFSET 的工作原理

1
SELECT * FROM messages ORDER BY created_at DESC LIMIT 20 OFFSET 100000;
  • PG 没有”直接跳到第 100000 行”的机制:执行器按 ORDER BY 顺序一行行产出,Limit 节点先消费并丢弃 OFFSET 指定的行,才开始返回 LIMIT 的行。
  • 代价 = 读出并排序/扫过 OFFSET + LIMIT 行。OFFSET 越大,浪费的工作越多,耗时随页深线性增长
  • 如果排序列上没有索引,还得先做排序(大 OFFSET 时是全量 sort,小 LIMIT 时是 top-N heapsort),雪上加霜。
  • 附带问题:没有 ORDER BY 的分页结果顺序不确定,翻页可能重复/丢行。
1
2
3
OFFSET 100000 LIMIT 20 的实际执行:
扫过第 1 行…第 100000 行(全部丢弃)→ 第 100001~100020 行才返回
~~~~~~~~~~~~ 纯浪费,且随 OFFSET 增长线性变慢

Keyset Pagination(键集/游标分页,也叫 seek method)

思路:不数行数,记住上一页最后一行的排序键,下一页直接”从它后面”开始。B-tree 索引可以直接 seek 到该位置,无需跳过任何行。

1
2
3
4
5
6
7
8
9
10
11
12
-- 第一页(id 是主键,降序 = 最新的在前)
SELECT id, content, created_at
FROM messages
ORDER BY id DESC
LIMIT 20;

-- 下一页:cursor = 上一页最后一行的 id
SELECT id, content, created_at
FROM messages
WHERE id < 12345 -- :last_id,注意与排序方向一致(DESC 用 <)
ORDER BY id DESC
LIMIT 20;
  • 复合索引 (id) 或按其他列排序时对应索引 → 定位成本 O(log n),取 20 行成本恒定 → 第 1 页和第 10 万页一样快
  • 上一页同理(方向取反再翻回来),适合”无限下拉”流。

多列排序的 Keyset:行构造器(Row Constructor)

常见需求是 ORDER BY created_at DESC,但 created_at 有重复值,必须再加唯一列兜底。PG 支持行构造器比较,逐列比较、第一处不等即定结果:

1
2
3
4
5
6
7
8
9
10
11
12
-- 第一页
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- 下一页:把最后一行的 (created_at, id) 作为游标传入
SELECT id, title, created_at
FROM articles
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;
  • (a, b) < (x, y) 等价于 a < x OR (a = x AND b < y),但写法简洁且优化器执行更高效。
  • 配套索引:(created_at DESC, id DESC)(created_at, id)(B-tree 可双向扫描,方向可反向利用)。
  • 规则:游标列与排序列完全一致、方向一致、最后一列必须唯一(id 兜底)

Keyset 的局限

  1. 不能随机跳页:只能顺序”下一页/上一页”,不知道总页数,也不能直接去第 50 页。后台管理类”点页码”的需求仍常用 OFFSET(或干脆缓存总数据做估算)。
  2. 排序列必须可比较、单调稳定:按”会变的值”分页(如按点赞数)语义会漂移——翻页途中数据变了,游标条件可能漏行。
  3. 游标需要对前端不透明/不易篡改时,要编码(见下节),实现比 OFFSET 复杂一档。

排序不稳定问题:分页必须配确定性排序

  • 只按 ORDER BY created_at(值有重复)排序时,两组相同的行在两次查询中的相对顺序不保证一致 → 翻页会出现”某行第 1 页见过、第 2 页又见”或整行丢失。
  • 并发写入会加剧:其他事务 INSERT/DELETE 改变集合。
  • 解决:加唯一 tie-breaker——ORDER BY created_at DESC, id DESC;主键/自增 id 是最常用的兜底列。

API 里的游标传递(结合 FastAPI 场景)

典型设计:服务端返回 items + next_cursor + has_more,前端把 next_cursor 原样传回取下一页:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# FastAPI 路由(示意)
@router.get("/messages", response_model=MessagePage)
async def list_messages(cursor: str | None = None,
limit: int = Query(20, le=100)):
# cursor 是上一页最后一行 id 的 base64 编码,防篡改也可签名
last_id = decode_cursor(cursor) if cursor else None
rows = await db.fetch(
"""SELECT id, content FROM messages
WHERE (:last_id IS NULL OR id < :last_id)
ORDER BY id DESC LIMIT :limit + 1""",
{"last_id": last_id, "limit": limit})
has_more = len(rows) > limit # 多取一行探测还有没有下一页
page = rows[:limit]
next_cursor = encode_cursor(page[-1]["id"]) if has_more and page else None
return MessagePage(items=page, next_cursor=next_cursor, has_more=has_more)

要点:多取 1 行探测 has_more(避免再发一次 COUNT);cursor 只编码最后一条的排序键;不提供”总条数”(深分页下 COUNT 也贵)。

两种方案对比

维度 LIMIT / OFFSET Keyset(游标)分页
能否跳页/知道总页数 ✅ 可以,配合 COUNT ❌ 只能顺序翻页
深分页性能 随 OFFSET 线性变慢 恒定(索引 seek,O(log n + limit))
排序要求 建议确定性排序 强制:排序列 + 唯一列兜底,方向一致
数据变动下的稳定性 页边界漂移,易重复/丢行 较稳定(从已见过的位置继续)
实现复杂度 极简 需编码游标、多列行构造器
典型场景 后台管理分页表、低页码 信息流/聊天记录/无限下拉、导出全表

高频面试题

Q:LIMIT 20 OFFSET 100000 为什么慢?怎么优化?

答题思路:先讲执行机制(没有跳转、逐行丢弃),再给数字感受,最后引出 keyset。

参考回答:PG 执行 OFFSET 100000 LIMIT 20 时并没有”跳到第 10 万行”的能力——它会按排序顺序实际产出前 100020 行,把前 10 万行丢掉,只返回最后 20 行,所以耗时随 OFFSET 线性增长;如果排序列还没索引,前面还要加一次全量排序。优化首选 Keyset 分页:记住上一页最后一行的排序键,下一页用 WHERE id > :last_id ORDER BY id LIMIT 20,复合索引直接定位到起点,任何一页都只处理 20 行,代价恒定。唯一代价是不能跳页,需要跳页的管理后台可以继续用 OFFSET,或者限制最大页深。

Q:Keyset 分页具体怎么写?多列排序怎么办?

答题思路:单列模板 + 行构造器模板 + 三个纪律(列一致、方向一致、唯一兜底)。

参考回答:单列就是 WHERE id > :last_id ORDER BY id LIMIT :n(升序;降序用 <)。多列排序用 PG 的行构造器:ORDER BY created_at DESC, id DESC 配套 WHERE (created_at, id) < (:last_ts, :last_id),它等价于 created_at < :last_ts OR (created_at = :last_ts AND id < :last_id),能正确处理同秒多条记录的边界。三个纪律必须守住:游标列和排序列完全一致;比较方向和排序方向一致;排序列组必须以唯一列结尾(一般用主键 id),否则排序不稳定会重复或丢行。索引按排序列组建,如 (created_at, id)

Q:Keyset 分页有什么局限?什么场景反而该用 OFFSET?

答题思路:三个局限(不能跳页、排序列约束、语义漂移),再用场景对比收尾。

参考回答:三个局限:一是不能随机跳页、拿不到总页数(除非再付一次大 COUNT 的代价);二是要求排序列可比较且稳定,按点赞数这类会变的值分页会语义漂移;三是多列游标写法更复杂,还要考虑游标编码。所以信息流、聊天记录、无限下拉这类只往前翻的场景用 keyset;后台管理系统需要页码、需要跳页、数据量又不大,用 LIMIT/OFFSET 完全合理——分页方式是按交互形态选的,不是 OFFSET 一无是处。

Q:分页时出现重复数据或丢行,可能是什么原因?

答题思路:两大根因——排序不稳定、页间数据变动;都给修法。

参考回答:第一根因是排序不稳定:排序列有重复值且没有唯一列兜底,相同值的行两次查询顺序可能不同,导致跨页重复或丢失,修法是 ORDER BY created_at DESC, id DESC 这样加 tie-breaker,keyset 的游标也要带上这个唯一列。第二根因是页间数据变动:用户看第 1 页的几秒里别人插入或删除了数据,OFFSET 方案的页边界整体漂移;keyset 受影响小得多,因为它锚定在”已见过的最后一行的键”上,新插入的比锚点新的数据只会出现在第 1 页方向,不会把后面的页推乱。对一致性要求极高的场景(如导出)可以在一个可重复读事务里分页。

Q:为什么深分页时 COUNT(*) 也很慢?接口里怎么处理总条数?

答题思路:COUNT 要数完所有满足条件的行;给工程替代方案。

参考回答SELECT COUNT(*) 没有”估算后偷懒”的语义,必须实际访问所有满足 WHERE 的行(索引覆盖时是索引全扫),几百万行就是几百万条目的扫描,和深分页是同一种病。工程上通常不做精确总数:接口返回 has_more(多取一行探测)代替 total;要展示”约 100 万条”就定期缓存一个估算值,或读 pg_class.reltuples 的统计估算;用户真的要精确总数时再单独异步算。这也是为什么信息流类产品都不显示页码只做下拉加载。

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
-- 真实业务表:聊天消息
CREATE TABLE messages (
id bigserial PRIMARY KEY,
chat_id bigint NOT NULL,
sender_id bigint NOT NULL,
content text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_messages_chat_time_id ON messages (chat_id, created_at DESC, id DESC);

INSERT INTO messages (chat_id, sender_id, content, created_at)
SELECT 1, (random()*100)::int, 'msg-' || g,
now() - (g || ' seconds')::interval
FROM generate_series(1, 2000000) g;
ANALYZE messages;

-- ============================================================
-- 1. 反面教材:OFFSET 深分页(越翻越慢)
-- ============================================================
EXPLAIN ANALYZE
SELECT id, content FROM messages
WHERE chat_id = 1
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;
-- 执行计划:Index Scan Backward → 节点里 rows removed by filter/丢弃 10 万行
-- 时间随 OFFSET 线性增长

-- ============================================================
-- 2. 正确姿势:Keyset 分页(代价恒定)
-- ============================================================
-- 第一页
SELECT id, content, created_at
FROM messages
WHERE chat_id = 1
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 记下最后一行的 (created_at, id),假设 ('2026-08-29 12:00:00+00', 1900001)

-- 下一页:行构造器游标
SELECT id, content, created_at
FROM messages
WHERE chat_id = 1
AND (created_at, id) < ('2026-08-29 12:00:00+00', 1900001)
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- EXPLAIN ANALYZE:直接索引定位,无论翻多深都是亚毫秒级

-- ============================================================
-- 3. 确定性排序的重要性:去掉 id 兜底会怎样
-- ============================================================
-- created_at 每秒可能多条 → 只按 created_at 排序时,
-- 同一秒内的行顺序不保证,两页之间会重复/丢行
-- 修法:永远带上唯一 tie-breaker(id)
SELECT id, created_at FROM messages WHERE chat_id = 1
ORDER BY created_at DESC, id DESC LIMIT 20; -- 稳定
-- ORDER BY created_at DESC LIMIT 20; -- 不稳定,禁用

-- ============================================================
-- 4. 判断"还有没有下一页":多取一行,不付 COUNT 的代价
-- ============================================================
SELECT id, content FROM messages
WHERE chat_id = 1 AND (created_at, id) < (:last_ts, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 21; -- 返回 21 行说明还有下一页,前端只展示前 20 行

-- ============================================================
-- 5. 全表导出这类"顺序遍历"任务也用 keyset 思想
-- ============================================================
-- 游标分页批处理,避免 OFFSET 递增导致的 O(n²) 总开销
SELECT * FROM messages WHERE id > :last_id ORDER BY id LIMIT 5000;

易错点与追问

常见写法问题对比

写法 问题 修正
LIMIT 20 OFFSET 1000000 扫过并丢弃 100 万行,线性变慢 keyset:WHERE id < :last ORDER BY id DESC LIMIT 20
ORDER BY created_at LIMIT 20 OFFSET n 排序列有重复 → 翻页重复/丢行 加唯一兜底 ORDER BY created_at DESC, id DESC
游标比较方向与排序不一致 丢行或重复(DESC 却用 > 降序配 <,升序配 >,方向严格一致
多列游标写成 WHERE a < :x AND b < :y 语义错误:应是 (a,b) < (x,y) 的字典序 用行构造器 (a, b) < (:x, :y)
接口返回前先 COUNT(*) 深过滤条件下 COUNT 与深分页同病 返回 has_more(LIMIT n+1 探测)
LIMITORDER BY 结果顺序不保证,每页内容可能交叉 任何分页必须显式 ORDER BY

面试官常见追问链

1
2
3
4
5
6
7
OFFSET 深分页为什么慢?
→ EXPLAIN 里能看到它跳过行吗?(能看到 Limit 节点下扫过的行数)
→ 那 keyset 怎么就快了?(索引 seek,不处理已翻过的行)
→ 排序列不唯一怎么办?(行构造器 + id 兜底)
→ 为什么翻页会重复/丢行?(排序不稳定 + 页间数据变动)
→ 跳页需求怎么办?(管理后台用 OFFSET / 限制页深 / 预计算页表)
→ API 里怎么把游标传给前端?(base64 编码最后一条的排序键,返回 next_cursor)

相关章节

  • 索引 —— Keyset 分页快的前提是排序列组上有 B-tree 索引,复合索引列序是配套知识。
  • EXPLAIN与SQL优化 —— 用 EXPLAIN ANALYZE 观察 OFFSET 丢弃行数与 keyset 的索引定位,验证差距。
  • SQL查询基础 —— LIMIT/OFFSET 与 ORDER BY 的基础语法在本章只是起点。
  • JOIN与复杂查询 —— 分页常与多表查询叠加,分页前先过滤再 join 才能不白翻页。
  • FastAPI与PostgreSQL实战 —— next_cursor 接口设计、多取一行探测 has_more 的完整工程实现。
  • PostgreSQL高级SQL —— LATERAL/窗口函数做”Top-N 每组”与分页是近亲问题。

第16章 PostgreSQL高级SQL

💡 一句话核心:PG 高级 SQL 三板斧——窗口函数(组内排名/环比但不折叠行)、递归 CTE(一把查询组织架构树)、PG 特色语法 DISTINCT ON / LATERAL / 数组类型——是 Python/AI 应用开发岗笔试面试出现率最高的 SQL 考点。

概念详解

窗口函数(Window Functions)

语法:函数() OVER (PARTITION BY ... ORDER BY ...)PARTITION BY 把行分组(窗口),ORDER BY 决定组内行序;每一行都保留在结果里,只是附加一列计算值——这是与 GROUP BY 的本质区别(GROUP BY 把每组折叠成一行)。

排名三兄弟(笔试必考),假设分数序列是 95, 90, 90, 85

函数 结果 规则 典型用途
ROW_NUMBER() 1, 2, 3, 4 强制连续编号,并列也分先后 取”恰好前 N 名”、去重
RANK() 1, 2, 2, 4 并列同名次,跳过其后名次 通用排名(比赛计分)
DENSE_RANK() 1, 2, 2, 3 并列同名次,不跳号 分档/等级划分

环比/同比LAG(col, n) 取窗口内前 n 行的值,LEAD(col, n) 取后 n 行(越界默认 NULL,可给第三参数兜底)——算”今日 vs 昨日”环比的标准工具。

累计与移动统计SUM(amount) OVER (ORDER BY dt) 是累计和;注意有 ORDER BY 时默认帧是 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,要按”行”算移动平均需显式 ROWS BETWEEN 6 PRECEDING AND CURRENT ROW(7 日均值)。多个窗口可命名复用:SUM(x) OVER wWINDOW w AS (ORDER BY dt)

经典题:每个部门工资最高的前 N 名

为什么 GROUP BY 做不了GROUP BY dept_id 后每部门只剩一行,聚合函数(MAX)只能给”最高工资是多少”,取不出”拿这些工资的明细行(人名、工号)”,更别说前 3 名。标准解法是窗口函数:

1
2
3
4
5
6
7
SELECT dept_id, emp_name, salary
FROM (
SELECT dept_id, emp_name, salary,
ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn
FROM employee
) t
WHERE rn <= 3; -- 每个部门的前 3 名

要点:子查询里先算 rn 再外层过滤——窗口函数不能直接出现在 WHERE 里(WHERE 在窗口计算之前执行)。若”工资并列都算第 N 名”是业务要求,改用 DENSE_RANK()(可能返回超过 3 行);用 RANK() 则并列同分者同名次但会截断。大表上的高性能替代写法是 LATERAL(见下文)。

JSON / JSONB(简述)

  • json:存原始文本,保留键序与重复键,每次操作重新解析;jsonb:解析后的二进制,支持 GIN 索引、存在去重、操作符丰富@>?#>>),绝大多数场景选 jsonb。
  • AI 应用里常拿 jsonb 存 LLM 的结构化返回、agent 的中间状态。完整对比、GIN 索引与实战见 JSONB与GIN

Array 数组类型

PG 原生支持一维/多维数组,是很多”标签、多值属性”场景的轻量方案:

1
2
3
4
5
6
7
8
9
-- 定义与查询
CREATE TABLE articles (id serial PRIMARY KEY, title text, tags text[]);
SELECT * FROM articles WHERE 'ai' = ANY(tags); -- 数组中包含 'ai'(逐元素比较)
SELECT * FROM articles WHERE tags @> ARRAY['ai','llm']; -- 包含这些元素(GIN 可加速,见 17 章)

-- 聚合与展开
SELECT array_agg(DISTINCT tag) FROM articles; -- 多行聚合成一个数组
SELECT unnest(ARRAY[1,2,3]); -- 数组炸开成 3 行(FROM 里最常用)
SELECT id, tag FROM articles, unnest(tags) AS tag; -- 每篇文章的每个标签一行(LATERAL 隐式用法)

常用配套:array_length(a, 1) 取长度、a[1:3] 切片、|| 拼接、cardinality()。Java/Python 驱动会把数组映射为列表,与 ORM 兼容性好。

CTE 与递归 CTE(WITH / WITH RECURSIVE)

CTE(Common Table Expression,公共表表达式)= WITH 名字 AS (子查询),把复杂查询拆成有名字的步骤,可被引用多次、大幅提升可读性。PG 12 起只被引用一次的 CTE 会内联进外层查询参与优化(旧版本一律物化成优化屏障),MATERIALIZED/NOT MATERIALIZED 可显式控制。

递归 CTE 是查”组织架构树、评论树、BOM 物料清单”的标准答案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- 从某经理出发,找出所有下属(任意层级)
WITH RECURSIVE subordinates AS (
-- ① 锚定成员(非递归部分):起点,只执行一次
SELECT emp_id, emp_name, manager_id, 1 AS depth,
ARRAY[emp_id] AS path
FROM employee
WHERE manager_id = 100 -- 起点条件

UNION ALL -- ② UNION ALL 不去重、更快;UNION 去重可防环但更慢
-- ③ 递归成员:拿上一轮结果(工作表)与 employee 连接,反复迭代直到无新行
SELECT e.emp_id, e.emp_name, e.manager_id, s.depth + 1,
s.path || e.emp_id
FROM employee e
JOIN subordinates s ON e.manager_id = s.emp_id
WHERE NOT e.emp_id = ANY(s.path) -- ④ 路径数组检测:访问过的不再走,防循环引用死循环
)
SELECT * FROM subordinates ORDER BY depth;

执行过程时间线:锚定查询跑一次 → 结果放入工作表 → 递归部分对工作表迭代 → 新结果继续入表 → 某轮产出空集则停止。防循环两手:UNION(自动去重)或显式路径数组(WHERE NOT x = ANY(path));PG 14+ 还可以用内置 CYCLE emp_id SET is_cycle USING path 子句实现同样语义。

LATERAL:对左表每行执行一次子查询

LATERAL 允许 FROM 子句里的子查询引用左边表的列,即”对左表每一行跑一次右子查询”(类似 for-each 循环)。最经典用途是 Top-N per group 的高性能写法

1
2
3
4
5
6
7
8
9
10
-- 每个部门工资最高的 3 名(与窗口函数版等价,大表上常更快)
SELECT d.dept_name, t.emp_name, t.salary
FROM departments d
CROSS JOIN LATERAL (
SELECT e.emp_name, e.salary
FROM employee e
WHERE e.dept_id = d.dept_id -- ← 引用了左表 d 的列,LATERAL 的意义所在
ORDER BY e.salary DESC
LIMIT 3 -- 若 (dept_id, salary DESC) 有索引,
) t; -- 每个部门都是 3 次索引定位,而非全表窗口排序

没有 CROSS JOIN LATERAL 时子查询只能独立求值一次;加 LATERAL 后它变成”参数化子查询”。 unnest、generate_series 也常放在 LATERAL 位(对每行展开数组)。

DISTINCT ON:PG 特色,每组取第一行

PG 扩展语法:DISTINCT ON (expr, ...) 保留按 ORDER BY 排序后每组(expr 相同)的第一行。最典型应用——每个用户最新一条消息

1
2
3
4
SELECT DISTINCT ON (user_id)
user_id, id, content, created_at
FROM messages
ORDER BY user_id, created_at DESC, id DESC; -- ORDER BY 必须以 DISTINCT ON 的列开头
  • 约束:ORDER BY 最左前缀必须匹配 DISTINCT ON 表达式,所以”组内怎么排”只能通过其余列表达;id DESC 兜底保证确定性。
  • 语义上等价于 ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) = 1 的简写;配 (user_id, created_at DESC) 索引时效率极高。
  • 是 PG 方言,MySQL/标准 SQL 没有(迁移时需改写成窗口函数)。

高频面试题

Q:ROW_NUMBER、RANK、DENSE_RANK 有什么区别?

答题思路:背一组并列分数的例子直接给三种结果,再各说一个适用场景。

参考回答:假设组内分数是 95、90、90、85。ROW_NUMBER 给 1、2、3、4——无视并列强制连续编号;RANK 给 1、2、2、4——并列同名次,但跳过被占的名次;DENSE_RANK 给 1、2、2、3——并列同名次但不跳号。选型:要”恰好取前 N 行”(每个部门前 3 名)用 ROW_NUMBER;比赛排名要体现并列且名次连续递进用 RANK;分等级/档位(金牌、银牌、铜牌层级)用 DENSE_RANK。三者都是窗口函数,写在 SELECT 里配 OVER 子句,不能直接放 WHERE。

Q:查每个部门工资最高的前 3 名,怎么写?

答题思路:先说 GROUP BY 为什么不行(只出聚合值不出明细行),再给窗口函数模板,最后补 LATERAL 备选。

参考回答:GROUP BY 做不到——它把每组折叠成一行,MAX 只能回答”最高是多少”,取不出”谁拿的”以及前 3 名的完整明细。标准解法是窗口函数:ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) 编号后,包一层子查询 WHERE rn <= 3;窗口函数不能直接写进 WHERE,因为 WHERE 先于窗口计算执行。如果业务上并列工资都算进前 3,换成 DENSE_RANK。数据量大、每个组都按 (dept_id, salary DESC) 建了索引时,LATERAL 写法(每个部门 LIMIT 3 的索引定位)比全表窗口排序更快。

Q:窗口函数和 GROUP BY 有什么区别?

答题思路:一句话本质(折叠 vs 不折叠)→ 语义对比 → 一个能体现差异的例子。

参考回答:本质区别是行数:GROUP BY 把每组折叠成一行,结果只含分组列和聚合值;窗口函数对每一行都输出,把计算结果作为新列附加,行不减少。所以”每个部门的平均工资”用 GROUP BY,而”每个员工的工资 + 他所在部门的平均工资 + 部门内排名”必须用窗口函数——一条 SQL 同时给出明细和组级统计。执行顺序上窗口函数发生在 GROUP BY、HAVING 之后、ORDER BY 之前,因此不能出现在 WHERE 里。

Q:递归 CTE 怎么查组织架构树?怎么防止死循环?

答题思路:三段式结构(锚定 + UNION ALL + 递归)→ 执行机制(工作表迭代)→ 两种防环手段。

参考回答WITH RECURSIVE 由两部分组成:锚定成员负责起点(如 WHERE manager_id = 100 的直接起点行),递归成员引用 CTE 自身、拿上一轮结果继续向下找(JOIN subordinates s ON e.manager_id = s.emp_id),执行器用工作表不断迭代,直到某轮不再产出新行。防死循环:数据里若有循环引用(A 是 B 的经理、B 又是 A 的经理),UNION ALL 会无限迭代——一种办法是 UNION 代替 UNION ALL 靠去重终止,更通用的是给每行维护路径数组 path,递归时加 WHERE NOT e.emp_id = ANY(s.path),访问过就剪枝;PG 14+ 也可以直接用 CYCLE 子句。层深用 depth 计数,还可用它限制最大递归深度。

Q:DISTINCT ON 是什么?每个用户最新一条消息怎么取?

答题思路:先给定义和 PG 特色身份,再给标准写法,强调 ORDER BY 约束。

参考回答:DISTINCT ON 是 PG 扩展语法,DISTINCT ON (user_id) 表示按 user_id 分组、每组只保留 ORDER BY 排序后的第一行,约束是 ORDER BY 的最左前缀必须是 DISTINCT ON 的那些列。所以”每个用户最新一条消息”写法是:SELECT DISTINCT ON (user_id) user_id, content, created_at FROM messages ORDER BY user_id, created_at DESC, id DESC——先按用户分组,组内按时间倒序,取第一行即最新一条;id 兜底保证同秒消息的确定性。配上 (user_id, created_at DESC) 索引每组只需一次索引定位。它等价于 ROW_NUMBER()=1 的窗口函数写法,但更简洁;这是 PG 方言,换数据库要改写。

Q:LATERAL 是干什么的?什么场景用?

答题思路:定义(子查询引用左表列、逐行执行)→ Top-N per group 场景 → 与窗口函数的取舍。

参考回答:LATERAL 让 FROM 里的子查询可以引用左边表的列,相当于对左表每一行执行一次参数化子查询。最常见用途是 Top-N per group:departments CROSS JOIN LATERAL (SELECT ... WHERE dept_id = d.dept_id ORDER BY salary DESC LIMIT 3) t,每个部门只要 3 次索引定位,比窗口函数对全表排序便宜得多,前提是 (dept_id, salary DESC) 上有索引。另外 PG 里 unnest、generate_series 放在 FROM 里展开多行,也经常以 LATERAL 形式按行执行。

Q:PG 的数组类型有什么用?举例说 ANY、array_agg、unnest。

答题思路:先说适用场景(标签/多值属性/批处理参数),再三个函数各配一句例子。

参考回答:PG 原生数组适合存”一组同类型的小值”,典型是文章标签、用户权限列表、批量 ID 参数,省掉一张关联表。查询用 WHERE 'ai' = ANY(tags) 做等值包含,或 tags @> ARRAY['ai','llm'] 做集合包含(配 GIN 索引很高效);array_agg(col) 反方向地把多行聚合成一个数组,适合”一行里带出所有子项”;unnest(array) 把数组炸开成行,常用在 FROM 里和原表做隐式 LATERAL 连接,实现”每个标签一行”的规范化输出。应用层驱动(如 Python 的 psycopg)会把数组直接映射成 list,用起来接近原生。

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
-- 0. 业务表与数据:员工-部门(贯穿全章)
CREATE TABLE departments (dept_id int PRIMARY KEY, dept_name text);
CREATE TABLE employee (
emp_id int PRIMARY KEY,
emp_name text,
dept_id int REFERENCES departments,
manager_id int, -- 组织架构树用
salary numeric(10,2),
hire_date date,
skills text[] -- 数组类型演示
);
INSERT INTO departments VALUES (1,'研发部'),(2,'产品部');
INSERT INTO employee VALUES
(100,'张总', NULL, NULL, 50000, '2015-01-01', '{管理}'),
(101,'Alice', 1, 100, 25000, '2019-03-01', '{python,sql}'),
(102,'Bob', 1, 100, 22000, '2020-06-01', '{java,sql}'),
(103,'Carol', 1, 101, 22000, '2021-01-15', '{python,ai}'),
(104,'Dave', 2, 100, 18000, '2022-05-01', '{产品}');
ANALYZE employee;

-- 1. 排名三兄弟对比(并列工资 22000 时的差异)
SELECT emp_name, salary,
ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_num, -- 1,2,3,4,5
RANK() OVER (ORDER BY salary DESC) AS rank_, -- 1,2,3,3,5(跳号)
DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rank -- 1,2,3,3,4(不跳号)
FROM employee;

-- 2. 经典题:每个部门工资前 2 名
SELECT dept_id, emp_name, salary FROM (
SELECT dept_id, emp_name, salary,
ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) rn
FROM employee
) t WHERE rn <= 2;

-- 3. LAG/LEAD 环比 + 累计求和
WITH daily AS (
SELECT hire_date::date AS dt, count(*) AS hires
FROM employee GROUP BY 1
)
SELECT dt, hires,
LAG(hires) OVER (ORDER BY dt) AS 上一天,
hires - LAG(hires) OVER (ORDER BY dt) AS 环比,
SUM(hires) OVER (ORDER BY dt) AS 累计入职人数
FROM daily ORDER BY dt;

-- 4. 递归 CTE:从 Alice(101) 沿管理线向上找到 CEO
WITH RECURSIVE chain AS (
SELECT emp_id, emp_name, manager_id, 0 AS depth, ARRAY[emp_id] AS path
FROM employee WHERE emp_id = 101
UNION ALL
SELECT e.emp_id, e.emp_name, e.manager_id, c.depth + 1, c.path || e.emp_id
FROM employee e JOIN chain c ON e.emp_id = c.manager_id
WHERE NOT e.emp_id = ANY(c.path) -- 防循环
)
SELECT * FROM chain ORDER BY depth; -- Alice → 张总

-- 5. 数组:ANY 过滤、array_agg 聚合、unnest 展开
SELECT emp_name FROM employee WHERE 'python' = ANY(skills); -- 会 python 的员工
SELECT array_agg(emp_name) FROM employee WHERE dept_id = 1; -- 部门员工聚成数组
SELECT e.emp_name, skill
FROM employee e, unnest(e.skills) AS skill; -- 每人每个技能一行

-- 6. DISTINCT ON:每个部门最新入职的员工
SELECT DISTINCT ON (dept_id) dept_id, emp_name, hire_date
FROM employee
WHERE dept_id IS NOT NULL
ORDER BY dept_id, hire_date DESC, emp_id DESC;

-- 7. LATERAL:每个部门工资前 1 名(Top-N per group,与第 2 题窗口函数写法等价)
SELECT d.dept_name, t.emp_name, t.salary
FROM departments d
CROSS JOIN LATERAL (
SELECT emp_name, salary FROM employee
WHERE dept_id = d.dept_id
ORDER BY salary DESC LIMIT 1
) t;

易错点与追问

易混淆对比

对比项 关键差异
ROW_NUMBER vs RANK vs DENSE_RANK 并列时编号 1,2,3,4 / 1,2,2,4 / 1,2,2,3;取前 N 用 ROW_NUMBER,分档用 DENSE_RANK
窗口函数 vs GROUP BY 不折叠行 vs 折叠成一行;”明细+组级统计同出”必须用窗口
WHERE vs 窗口函数 窗口计算晚于 WHERE,过滤窗口结果必须包一层子查询/CTE
UNION vs UNION ALL(递归 CTE) UNION 去重可防环但每轮开销大;首选 UNION ALL + path 数组防环
DISTINCT ON vs 窗口函数 简洁、每组取 1 行快;窗口函数更通用(前 N、多列派生)
LATERAL vs 普通子查询 LATERAL 引用左表列、逐行执行;普通子查询独立求值一次

易错点清单

  • 窗口函数写进 WHERE 直接报错——必须子查询/CTE 包裹后再过滤。
  • LAST_VALUE() 陷阱:默认帧到当前行为止,LAST_VALUE 永远等于当前行;要真正的组内最后值需 ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
  • DISTINCT ON 忘了让 ORDER BY 以分组列开头 → 语法错误。
  • 递归 CTE 忘记防环 → 存在循环引用时无限循环;path 检测或 UNION 防环,WHERE s.depth < N 限层兜底。

面试官常见追问链

1
2
3
4
5
6
排名并列怎么处理?
→ 那"每部门前 N"为什么不能 GROUP BY?(折叠后取不出明细行)
→ 窗口函数能在 WHERE 里用吗?(不能,执行顺序 → 子查询包裹)
→ 大表上窗口函数慢怎么办?(改 LATERAL + 索引,或物化中间结果)
→ 组织架构树怎么查?(递归 CTE → 防环 → UNION vs path 数组)
→ "每个用户最新一条"除了窗口函数还有别的写法吗?(DISTINCT ON / LATERAL,配索引)

相关章节

  • JSONB与GIN —— 本章只一句话带过 jsonb,类型细节、GIN 索引与操作符在那章完整展开。
  • JOIN与复杂查询 —— LATERAL 是 JOIN 家族的特殊形态,连接语义基础在那章。
  • 索引 —— DISTINCT ON / LATERAL / 窗口排序的快慢都取决于排序列上的复合索引。
  • EXPLAIN与SQL优化 —— 验证窗口排序 vs LATERAL 索引定位的成本差异靠 EXPLAIN ANALYZE。
  • SQL查询基础 —— 聚合与 GROUP BY 基础,是理解窗口函数”不折叠”特性的对照面。
  • 数据库设计 —— 标签用数组/jsonb 还是子表,属于建模范畴的决策。

第17章 JSONB 与 GIN

💡 一句话核心:JSONB 把 JSON 解析成二进制再存储,用一点写入代价换来高效的查询和 GIN 索引能力——LLM/Agent 应用里 messages.metadata 这类半结构化数据,业务上几乎都选 JSONB 而不是 JSON 类型。

概念详解

场景引入:LLM 消息元数据为什么放 JSONB

Agent/LLM 应用中,每条消息通常要记录模型调用信息:

1
{"model": "gpt-5", "tokens": 1024, "finish_reason": "stop"}

问题在于:不同厂商字段不一样(OpenAI 的 usage.prompt_tokens、Claude 的 input_tokens、自研模型的评分字段……),如果每个字段都建列,表结构会跟着上游 API 无限膨胀。放进 messages.metadata JSONB:schema 由应用定义、按字段可查、还能建索引——这正是 JSONB 的主场。

JSON vs JSONB

jsonjsonb 是两种不同的类型,不是”格式开关”:

维度 json jsonb
存储方式 原样保存输入文本 解析后按二进制(分解树)存储
写入速度 快(几乎只做文本存储) 稍慢(要解析、转换、去重)
查询速度 每次函数调用都重新解析文本 直接操作二进制,快
重复键 保留 去重,只保留最后一个
键顺序 / 多余空格 保留原文 不保留(按内部规则重排)
GIN 索引 不支持 支持(jsonb_ops / jsonb_path_ops)

结论:写多读少、只做透明转存的用 json;只要需要按字段查询、建索引,就选 jsonb——这也是绝大多数业务的选择。

查询操作符(必背)

metadata = {"model": "gpt-5", "tokens": 1024, "usage": {"prompt_tokens": 800, "completion_tokens": 224}} 为例:

操作符 作用 示例 返回类型
-> 取键 / 数组元素 metadata->'model' jsonb("gpt-5" 带引号)
->> 取键 / 数组元素 metadata->>'model' text(gpt-5 不带引号)
#> / #>> 按路径取值 metadata#>>'{usage,prompt_tokens}' jsonb / text
@> 左边包含右边 metadata @> '{"model":"gpt-5"}'::jsonb boolean
? 顶层键存在 metadata ? 'tokens' boolean
`? /?&` 任一键 / 全部键存在 `metadata ?
jsonb_set() 修改某路径的值 jsonb_set(metadata,'{tokens}','2048') jsonb
jsonb_array_elements() 把数组展开成多行 配合 LATERAL setof jsonb

最重要的一条规则:与普通值比较时必须用 ->>(返回 text)或显式类型转换。

  • metadata->>'model' = 'gpt-5'
  • metadata->'model' = 'gpt-5' ❌(jsonb 与文本比较,报 invalid input syntax for type json)
  • (metadata->>'tokens')::int > 500 ✅(取出的是 text,要 cast 后再做数值比较)

嵌套路径与数组查询

1
2
3
4
5
6
7
8
9
-- 嵌套对象:两种等价写法
SELECT metadata->'usage'->>'prompt_tokens' FROM messages;
SELECT metadata#>>'{usage,prompt_tokens}' FROM messages;

-- 数组:把 tool_calls 展开成行,再取每个元素的键
SELECT m.id, t->>'name' AS tool_name, t->'arguments' AS args
FROM messages m,
jsonb_array_elements(m.metadata->'tool_calls') AS t
WHERE m.metadata ? 'tool_calls';

GIN 索引:为什么 B-Tree 不行

  • B-Tree 要求被索引值有序、定长、可比较,而 jsonb 是一个无序、变长的复合”黑盒”,B-Tree 只能对整块值排序,无法回答”谁的 model 字段等于 gpt-5”这类问题。
  • **GIN(Generalized Inverted Index,倒排索引)**一句话原理:把 jsonb 拆成内部元素(键、值、键值对),为每个元素维护一张”元素 → 含该元素的行指针列表”的倒排表;查询 @>? 时先查倒排表定位候选行,再回表校验。
1
2
3
4
-- 默认 jsonb_ops:支持 @>、?、?|、?&
CREATE INDEX idx_messages_metadata ON messages USING GIN (metadata);
-- jsonb_path_ops:更小更快,但只支持 @> 这类包含查询,不支持 ? 键存在操作符
CREATE INDEX idx_messages_metadata_path ON messages USING GIN (metadata jsonb_path_ops);
  • jsonb_ops(默认):支持的运算符全,索引更大;
  • jsonb_path_ops:只存键值对的 hash,索引显著更小、查询更快,代价是不支持 ? 系列。
  • 附带特性:GIN 默认开启 fastupdate,更新先进 pending list 批量合并写入(gin_pending_list_limit 默认 4MB),写友好但查询偶发抖动。

表达式索引:只查固定字段的更优解

如果永远只按 model 这几个固定字段查,没必要为整个 jsonb 付 GIN 的存储与更新代价,B-Tree 表达式索引更省:

1
2
3
CREATE INDEX idx_messages_model ON messages ((metadata->>'model'));
-- 数值字段:cast 后建 B-Tree,还能支持范围查询
CREATE INDEX idx_messages_tokens ON messages ((metadata->>'tokens')::int);

查询时写出一模一样的表达式才能命中索引:WHERE metadata->>'model' = 'gpt-5'

JSONB 的写入代价与 MVCC 联动

PG 的 MVCC 是追加式更新:UPDATE 任何字段都会写出一行新版本,旧版本变成死元组等 autovacuum 回收(详见 VACUUM)。JSONB 把这个问题放大:

  • 改一个键 = 生成整个 jsonb 值的新版本(大 JSONB 走 TOAST 时整个值重写);
  • 死元组体积 ≈ 旧 JSONB 大小,宽 JSONB 高频更新 → 表膨胀 + WAL 放大 + vacuum 压力。

实践准则:高频局部更新的字段(计数器、状态)拆成普通列,JSONB 只放低频变化的扩展元数据

高频面试题

Q:json 和 jsonb 的区别是什么?为什么业务里基本都用 jsonb?

答题思路:存储方式 → 写入/查询性能取舍 → 格式语义差异(键序/重复键)→ 索引能力 → 场景结论。

参考回答:json 存原样文本,保留键顺序、空格和重复键,每次查询都要重新解析;jsonb 先解析成二进制存储,写入稍慢,但查询快、会去重,且支持 GIN 索引。业务数据几乎总要”按字段查”,jsonb 的读优势远大于写劣势,所以默认选 jsonb;只有把 PG 当透明存储转发 JSON 时才用 json。

Q:->->> 的区别?

答题思路:返回类型 → 比较行为 → 现场最容易踩的报错。

参考回答-> 返回 jsonb(字符串结果带引号),->> 返回 text。与普通值比较时必须用 ->>,或把 -> 的结果与 '...'::jsonb 比较;数值比较要把 ->> 的结果 cast 成 int/numeric。最容易踩的坑是写 metadata->'model' = 'gpt-5',jsonb 和文本比较直接报类型错误。

Q:JSONB 为什么用 GIN 而不是 B-Tree?jsonb_ops 和 jsonb_path_ops 有什么区别?

答题思路:B-Tree 前提(有序定长)→ GIN 倒排原理 → 两种 opclass 的支持面与体积 → 固定字段用表达式索引兜底。

参考回答:jsonb 是无序变长的复合值,B-Tree 无法对内部字段建序;GIN 是倒排索引,对 jsonb 内每个键/值元素建”元素 → 行列表”的映射,天然支持 @> 包含与 ? 键存在查询。默认 jsonb_ops 操作符全但索引大;jsonb_path_ops 只支持 @> 一类包含查询,更小更快。如果只按固定的某几个字段查询,用 ((metadata->>'model')) 这种 B-Tree 表达式索引更省,更新代价也更低。

Q:什么时候该用 JSONB,什么时候该拆关系表?(重点)

答题思路:给判断框架(三个问题),再落到 Agent 场景的例子。

参考回答:三个判断问题——① 字段集合是否可控?需要强约束、外键、精确统计的用关系表;② 是否要与其它表 JOIN?要 JOIN 的拆表;③ 是否高频局部更新?JSONB 更新会重写整个值,热字段多就拆列。我的实践:messages 的 metadata(模型、token 用量、finish_reason 这类随上游 API 变化的扩展字段)用 JSONB;user、session、订单这类核心实体用关系表;两者可组合——关系表 + 一个 JSONB 扩展列。

Q:JSONB 更新一个键的代价是什么?和 MVCC 有什么关系?(重点)

答题思路:MVCC 追加写 → 整个值重写 → 死元组与膨胀 → 缓解手段。

参考回答:PG 更新走 MVCC,会产生整行新版本;JSONB 是一个整体值,改一个键意味着整个 jsonb 重写一遍(大值还会整体重写 TOAST),旧值全部变成死元组,依赖 autovacuum 清理。所以对宽 JSONB 做高频小更新,会带来表膨胀和 WAL 放大。缓解:把热点可变字段拆成普通列,JSONB 只存低频变化的元数据,并控制单个 jsonb 的体积。

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
-- 1) Agent 会话消息表
CREATE TABLE messages (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
session_id bigint NOT NULL,
role text NOT NULL, -- user / assistant / system / tool
content text,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);

-- 2) 写入:不同厂商的元数据都塞得下
INSERT INTO messages (session_id, role, content, metadata) VALUES
(1, 'assistant', '你好,我是 AI 助手',
'{"model": "gpt-5", "tokens": 1024, "finish_reason": "stop",
"usage": {"prompt_tokens": 800, "completion_tokens": 224}}');

-- 3) 查询:->> / @> / ? / 嵌套路径
SELECT * FROM messages WHERE metadata->>'model' = 'gpt-5';
SELECT * FROM messages WHERE (metadata->>'tokens')::int > 500;
SELECT * FROM messages WHERE metadata @> '{"finish_reason": "stop"}'::jsonb;
SELECT * FROM messages WHERE metadata ? 'usage';
SELECT metadata#>>'{usage,prompt_tokens}' AS prompt_tokens FROM messages;

-- 4) 更新:jsonb_set 改路径值,|| 做浅层合并(都会重写整个 jsonb)
UPDATE messages SET metadata = jsonb_set(metadata, '{tokens}', '2048') WHERE id = 1;
UPDATE messages SET metadata = metadata || '{"rating": 5}'::jsonb WHERE id = 1;

-- 5) 索引:按查询形态三选一
CREATE INDEX idx_msg_meta ON messages USING GIN (metadata);
CREATE INDEX idx_msg_meta_path ON messages USING GIN (metadata jsonb_path_ops);
CREATE INDEX idx_msg_model ON messages ((metadata->>'model'));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# SQLAlchemy 2.0 中使用 JSONB
from sqlalchemy import Integer, cast, select
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

class Base(DeclarativeBase):
pass

class Message(Base):
__tablename__ = "messages"
id: Mapped[int] = mapped_column(primary_key=True)
role: Mapped[str]
# metadata 是 Python 保留属性名,映射为 metadata_
metadata_: Mapped[dict] = mapped_column("metadata", JSONB, default=dict)

# ->> 等价写法:JSONB 下标 + as_string()
stmt = select(Message).where(Message.metadata_["model"].as_string() == "gpt-5")
# 数值比较:(metadata->>'tokens')::int > 500
stmt = select(Message).where(
cast(Message.metadata_["tokens"].as_string(), Integer) > 500)
# @> 等价写法:contains()
stmt = select(Message).where(
Message.metadata_.contains({"finish_reason": "stop"}))

易错点与追问

易错点 / 追问 正确认识
metadata->'model' = 'gpt-5' jsonb 不能和文本直接比,用 ->>'...'::jsonb
@> 方向写反 metadata @> '{"a":1}''{"a":1}' <@ metadata 含义相反
以为 ? 能查嵌套键 ? 只查顶层键,嵌套用 #> 路径或 @> 包含
用 GIN 支持排序 GIN 不支持 ORDER BY,排序要另建 B-Tree(表达式)索引
无脑建全量 GIN 只查固定字段时,B-Tree 表达式索引更小更快
JSONB 放高频更新字段 触发整值重写与死元组,热字段应拆列
默认值写 DEFAULT '{}' 建议显式 DEFAULT '{}'::jsonb,避免歧义
追问:GIN 为什么写入变慢 每次更新要维护所有受影响元素的倒排表(pending list 批量合并)

追问链:json vs jsonb → ->->> → 怎么建索引(GIN / jsonb_path_ops / 表达式索引)→ GIN 写入代价 → 更新一个键会发生什么 → MVCC 与 VACUUM。

相关章节

  • 索引:GIN 与 B-Tree 的适用场景对比,表达式索引详解。
  • EXPLAIN与SQL优化:验证 JSONB 查询是否命中 GIN / 表达式索引。
  • MVCC:理解更新为什么产生整行新版本与死元组。
  • VACUUM:JSONB 高频更新导致的膨胀与回收。
  • PostgreSQL高级SQL:jsonb_array_elements 等集合返回函数。
  • 数据库设计:JSONB 作为”受控反范式”的建模边界。
  • FastAPI与PostgreSQL实战:SQLAlchemy 中 JSONB 字段的读写实践。

第18章 主键、唯一约束与外键

💡 一句话核心:主键是行的逻辑身份(唯一 + 非空 + 隐式索引,每表最多一个);唯一约束只保证不重复(PG 默认允许多个 NULL);外键用写入开销和锁代价换引用完整性——高并发互联网项目常把它移到应用层,但要能主动说清”不用外键的代价”。

概念详解

约束全家

约束 作用 示例
NOT NULL 非空 email text NOT NULL
DEFAULT 默认值 status text DEFAULT 'active'
CHECK 行内条件校验 CHECK (salary >= 0)
UNIQUE 列 / 列组合不重复 UNIQUE (email)
PRIMARY KEY 行身份:NOT NULL + UNIQUE id bigint PRIMARY KEY
FOREIGN KEY 引用完整性(跨表) REFERENCES departments(id)
1
2
3
4
5
6
7
CREATE TABLE employees (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL,
salary numeric(10,2) NOT NULL CHECK (salary >= 0),
dept_id bigint REFERENCES departments(id),
CONSTRAINT uniq_employees_email UNIQUE (email) -- 命名约束,便于运维报错定位
);

Primary Key vs Unique(必考)

维度 PRIMARY KEY UNIQUE
数量 每表最多 1 个 可以有多个(列级或复合)
NULL 不允许 默认允许多行 NULL(NULL ≠ NULL)
索引 隐式创建唯一 B-Tree 索引 同样隐式创建唯一索引
逻辑含义 这一行”是谁”(身份标识) 某业务属性”不重复”(邮箱、手机号)
被引用 外键默认引用主键 也可以被外键引用

要点:

  • 两者都隐式建唯一索引,查询性能没有本质区别;PG 是堆表,主键不是 MySQL InnoDB 那种决定数据物理顺序的聚簇索引(见 PostgreSQL与MySQL对比)。
  • PG 15+ 提供 UNIQUE NULLS NOT DISTINCT,可把 NULL 也视为重复(默认语义是 NULLS DISTINCT)。

Foreign Key:作用与代价

作用:由数据库保证引用完整性——子表不会出现父表中不存在的 dept_id;删除被引用的父行会被阻止或级联。

代价(互联网项目权衡的核心):

  1. 写入开销:每次 INSERT/UPDATE 子表都要到父表索引上做一次引用检查,并在父行上拿 KEY SHARE 行锁;批量导入、数据迁移时开销放大。
  2. 锁与死锁风险:外键检查引入跨表行锁,锁竞争面变大,容易参与死锁(见 死锁);ALTER TABLE ... ADD FOREIGN KEY 建约束校验存量数据时要加锁阻塞写入(可用 NOT VALID + VALIDATE CONSTRAINT 缓解)。
  3. 分库分表不兼容:水平拆分后父子表可能不在同一实例,数据库无法跨库校验。

级联行为(ON DELETE / ON UPDATE):

行为 效果
NO ACTION(默认) 存在引用则报错(约束可延迟到事务结束检查)
RESTRICT 立即检查、存在引用立即报错,不可延迟
CASCADE 级联删除 / 更新子表中的引用行
SET NULL / SET DEFAULT 把子表引用列置 NULL / 默认值(列须可空或有默认值)

重点:为什么很多互联网项目不用数据库外键?

标准答法是”先说结论,再说理由,最后主动补代价”:

  1. 性能:每次写子表多一次父表索引查找,高并发写入、批量导入场景开销明显。
  2. 锁与死锁:外键检查引入跨表行锁,增大锁冲突和死锁概率。
  3. 分库分表:水平拆分后外键跨库失效,约束名存实亡。
  4. 应用层统一保证:服务层校验、事务内双写、异步对账兜底,逻辑集中、可测试、可灰度。
  5. 运维灵活性:批量修数、归档、迁移不受约束牵制,DDL 变更更轻。

不用外键是有代价的:可能出现孤儿行,一致性完全依赖代码质量。成熟团队的补偿手段:应用层强校验 + 定期对账脚本 + 关键报表做孤儿数据监控。加分点:小团队、内部系统、数据正确性优先的场景,直接用外键兜底反而更稳——能说出”什么时候该用”比只会说”不用”更值钱。

ON DELETE CASCADE 的使用与风险

CASCADE 让”删父行 → 自动删子行”一条 SQL 搞定,适合明确的生命周期从属关系(如删除 users 时清理其 sessions)。风险:

  • 连锁误删:删一行用户 → 删会话 → 删消息 → …,层级深时影响面远超预期,且是物理删除、难以恢复;
  • 大范围级联删除持锁时间长,阻塞并发业务。

生产建议:默认 ON DELETE RESTRICT(阻止误删),需要级联的场景在应用层显式执行(常配合逻辑删除),并对每一层 CASCADE 做代码评审。

唯一约束 + 逻辑删除的经典冲突

逻辑删除(打 deleted 标记)后,用户注销再重新注册同邮箱,UNIQUE (email) 会拒绝——因为”已删除”的旧记录还占着唯一值。

标准解法:partial unique index(部分唯一索引)——只对未删除行保证唯一:

1
2
CREATE UNIQUE INDEX uniq_employees_email_alive
ON employees (email) WHERE deleted = false;

它本质是带 WHERE 的唯一 B-Tree 索引:写入时只对”活行”做唯一性检查,查询未删除数据时也能正常命中(详见 索引)。等价写法还有 WHERE deleted_at IS NULL;不建 partial index 的土办法(删除时把 email 改成 email#id 挪开)会污染数据,不推荐。

高频面试题

Q:主键和唯一约束的区别?(必考)

答题思路:数量 → NULL 语义 → 索引 → 逻辑含义 → 引申 PG 堆表特性。

参考回答:一张表只能有一个主键,但可以有多个唯一约束;主键隐含 NOT NULL,唯一约束在 PG 默认允许多个 NULL(NULL 之间不相等,PG15 起可用 NULLS NOT DISTINCT 改变语义);两者都隐式创建唯一索引,查询性能无差;语义上主键是行身份、是外键的默认引用目标,唯一约束只是业务属性去重。另外 PG 是堆表,主键不像 MySQL InnoDB 的主键那样决定数据物理存储顺序。

Q:为什么有些互联网项目不用数据库外键?(必考)

答题思路:性能 → 锁/死锁 → 分库分表 → 应用层保证 → 运维灵活 → 主动补”代价与补救”。

参考回答:核心是权衡。外键带来引用完整性,但每次写子表都要查父表并拿锁,高并发下有额外开销,还增加死锁概率;水平分库后外键跨库失效。因此大厂普遍在应用层保证一致性,配套对账兜底。同时我会说清代价:可能产生孤儿行,需要应用层校验 + 定期对账 + 数据质量监控来补。如果是小团队或强一致优先的场景,我反而建议用数据库外键兜底。

Q:ON DELETE 的 CASCADE、SET NULL、RESTRICT、NO ACTION 有什么区别?

答题思路:逐个说明效果 + 默认值 + RESTRICT 与 NO ACTION 的细节差异。

参考回答:默认是 NO ACTION——存在引用时报错,且约束可以声明为可延迟(事务结束才检查);RESTRICT 是立即检查、不可延迟;CASCADE 会级联删除/更新子表引用行;SET NULL / SET DEFAULT 把子表引用列改成 NULL 或默认值。生产上我倾向 RESTRICT 防误删,级联逻辑放应用层,因为深层级联删除是物理删除且持锁时间长。

Q:唯一约束和逻辑删除冲突怎么解决?

答题思路:现象 → partial unique index → 原理 → 查询侧的配合。

参考回答:软删除后旧记录仍占用唯一值,重新注册同邮箱会被拒。解法是部分唯一索引:CREATE UNIQUE INDEX ... ON users(email) WHERE deleted = false,只对未删除行保证唯一。它就是带 WHERE 的唯一 B-Tree 索引,插入的唯一性检查与查询都能用到。注意所有查询都要带同样的 deleted = false 条件才能命中这个索引。

Q:PG 的 unique 列可以存多个 NULL 吗?

答题思路:NULL 语义 → 版本增强 → 实际影响。

参考回答:可以。SQL 标准里 NULL ≠ NULL,默认 NULLS DISTINCT 语义下多行 NULL 互不冲突,工程上常用它做”可空的唯一标识”。PG15 起提供 UNIQUE NULLS NOT DISTINCT,需要把 NULL 也当重复处理时显式声明。

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
-- 约束与级联综合示例
CREATE TABLE departments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE
);

CREATE TABLE employees (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL,
nickname text NOT NULL DEFAULT '新用户',
salary numeric(10,2) NOT NULL CHECK (salary >= 0),
dept_id bigint NOT NULL REFERENCES departments(id)
ON DELETE RESTRICT ON UPDATE CASCADE,
deleted boolean NOT NULL DEFAULT false, -- 逻辑删除
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uniq_employees_email UNIQUE (email)
);

-- 软删除场景:只对未删除行保证 email 唯一
CREATE UNIQUE INDEX uniq_employees_email_alive
ON employees (email) WHERE deleted = false;

-- 大表加外键:先 NOT VALID 只管新数据,再补校验,减少锁表时间
ALTER TABLE employees ADD CONSTRAINT fk_emp_dept
FOREIGN KEY (dept_id) REFERENCES departments(id) NOT VALID;
ALTER TABLE employees VALIDATE CONSTRAINT fk_emp_dept;

-- 演示 RESTRICT:还有员工的部门删不掉
DELETE FROM departments WHERE id = 1;
-- ERROR: update or delete on table "departments" violates foreign key constraint
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# SQLAlchemy 2.0 模型写法
from decimal import Decimal

from sqlalchemy import CheckConstraint, ForeignKey, Index, text
from sqlalchemy.orm import Mapped, mapped_column

class Employee(Base):
__tablename__ = "employees"
__table_args__ = (
CheckConstraint("salary >= 0", name="ck_employees_salary"),
# 软删除友好的唯一约束:partial unique index
Index("uniq_employees_email_alive", "email", unique=True,
postgresql_where=text("deleted = false")),
)
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(nullable=False)
salary: Mapped[Decimal]
dept_id: Mapped[int] = mapped_column(
ForeignKey("departments.id", ondelete="RESTRICT"))
deleted: Mapped[bool] = mapped_column(default=False)

易错点与追问

易错点 / 追问 正确认识
以为 PG 主键是聚簇索引 PG 是堆表,主键只是唯一 B-Tree;聚簇是 MySQL InnoDB 的特性
以为唯一约束能防两行 NULL 默认 NULLS DISTINCT,NULL 不参与唯一性;PG15+ 用 NULLS NOT DISTINCT
CASCADE 层层链接 级联范围随层级放大,误删不可逆,默认用 RESTRICT
不用外键 = 不用管一致性 应用层校验 + 对账是必须的补偿,不是”省事”
NOT VALID 当一步到位 NOT VALID 只跳过存量校验,旧行要 VALIDATE CONSTRAINT 补查
partial index 建了却不命中 查询必须带同样的 WHERE 条件
子表外键列不建索引 父行删除/更新时要靠它反查子表,缺了就全表扫

追问链:PK vs Unique → NULL 语义 → 外键的写入与锁代价 → 为什么大厂不用外键 → 不用外键谁保证一致性 → 级联删除风险 → 软删除与唯一索引冲突 → partial unique index 原理。

相关章节

第19章 数据库设计

💡 一句话核心:三大范式消除冗余与更新异常,反范式化用”可控冗余 + 一致性补偿”换读性能;好的设计是先按范式建模,再针对读密集路径做有意识的反范式,同时把主键选型、软删除、时间字段这些基础设施一次做对。

概念详解

三大范式(每条一个违反 → 修复的例子)

1NF(原子性):每个字段的值不可再分。

  • 违反:users.contact = "13800000001,13900000002"(一列塞多个手机号),没法按单个手机号查询。
  • 修复:一列一个值;多值关系拆子表 user_phones(user_id, phone)。(PG 的数组/JSONB 是工程上的实用例外,但要清楚自己换来的是什么。)

2NF(消除部分依赖):在联合主键下,非主属性不能只依赖主键的一部分。

  • 违反:订单明细 (order_id, product_id) 联合主键,却把 product_name, product_price 放在明细表——它们只依赖 product_id。改商品名要更新所有历史明细,还可能改得不一致。
  • 修复:商品信息放 products 表,明细只存 product_id(下单时的价格另存快照,见反范式化)。

3NF(消除传递依赖):非主属性不能依赖另一个非主属性。

  • 违反:员工表 (emp_id, dept_id, dept_name)——dept_name 依赖 dept_id,传递依赖主键。一个部门几百人,改名要改几百行。
  • 修复:拆出 departments(dept_id, dept_name),员工表只存 dept_id

一句话记忆:1NF 管列原子性,2NF 管联合主键的部分依赖,3NF 管非主属性之间的传递依赖。范式越高冗余越小、写入越安全,代价是查询要更多 JOIN。

反范式化:故意冗余的场景与代价

适合冗余的场景:读多写少、列表页/报表要避免多表 JOIN、统计数字(评论数、点赞数)避免实时聚合。

常见手法:

  • 冗余展示快照:订单表冗余 product_name(下单时的快照,历史正确性反而需要冗余);
  • 冗余计数字段:posts.comment_count,写入时同事务 +1;
  • 宽表/汇总表:报表预聚合,定时刷新。

一致性代价:冗余字段必须在同一事务里更新,或用触发器/异步任务补偿;跨服务冗余只能最终一致 + 对账。反范式是一笔”借了要还的债”:冗余越多,写路径与一致性维护成本越高,每个冗余字段都要有明确的更新 owner。

关系建模:一对一 / 一对多 / 多对多

  • 一对一:主表 + 详情表(users + user_profiles)。拆表动机:主表窄、查询快;大字段/低频字段拆开;敏感信息(身份证、密码哈希)单独授权访问。
  • 一对多:子表存父表外键,如 departments.id ← employees.dept_id;子表的外键列要建索引,否则父行删除/更新时全表扫子表(见 主键唯一约束与外键)。
  • 多对多:中间表。users + roles + user_roles(user_id, role_id);中间表用复合主键 (user_id, role_id),天然防重复授权,同时就是一条 B-Tree 索引;再给 role_id 建独立索引支持”按角色反查用户”。

逻辑删除 vs 物理删除

维度 逻辑删除(deleted / deleted_at) 物理删除(DELETE)
可恢复 / 审计 可恢复、有痕 不可恢复
查询复杂度 所有查询都要带 WHERE deleted_at IS NULL 简单
唯一约束 冲突,需 partial unique index 修正 无冲突
空间 持续膨胀,需归档策略 行立即标记可复用(配合 vacuum)

取舍:业务主数据(账号、订单)常用逻辑删除 + 定期归档到历史表;日志、中间数据用物理删除;合规场景(如 GDPR 被遗忘权)敏感字段必须物理删除。

时间字段设计规范

1
2
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
  • 一律用 timestamptz(timestamp with time zone):内部按 UTC 存绝对时间,展示随会话时区转换;裸 timestamp 存的是”墙上时间”,跨时区部署必出事故。
  • created_at 由 DEFAULT 兜底;updated_at 用触发器或 ORM 事件自动维护,不要依赖人手填。

UUID vs 自增 ID(必考)

维度 bigint 自增(IDENTITY) UUIDv4(随机)
大小 8 字节 16 字节(所有二级索引都跟着变胖)
插入行为 递增,总是追加到 B-Tree 最右页,缓存友好、几乎不分裂 随机落点 → 页分裂、缓存命中率低、WAL 放大
排序/分页 天然有序,适合游标分页(见 分页 无序,分页排序要另建时间列
业务暴露 自增 ID 可枚举,暴露业务量 不可枚举,对外更安全
分布式 单库自增,分库分表会冲突 天然全局唯一

折中方案:

  1. 时间有序 UUID(UUIDv7):前缀是毫秒时间戳,兼顾全局唯一与趋势有序;PG 18 内置 uuidv7(),低版本可在应用层生成(Python 的 uuid6/uuid7 库)。
  2. bigint + 应用层发号器(雪花 ID Snowflake 等):存储小、趋势递增,由发号器保证全局唯一,适配分库分表。

常见组合:对内主键用 bigint identity,对外暴露单独生成的、不可枚举的 public_id uuid,两头兼顾。

高频面试题

Q:讲讲三大范式,各举一个违反与修复的例子。

答题思路:每条范式一句话定义 + 一个具体坏表 + 怎么拆,最后补一句范式与 JOIN 的权衡。

参考回答:1NF 要求字段原子,比如把多个手机号塞一列没法查询,拆成子表;2NF 针对联合主键,订单明细里放商品名是部分依赖,商品信息应拆到商品表;3NF 消除传递依赖,员工表里放部门名,部门名其实依赖部门 ID,要拆出部门表。范式化的收益是消除更新异常和数据冗余,代价是查询要更多 JOIN,所以工程上要按读路径配合反范式化。

Q:什么场景会故意反范式化?冗余的一致性怎么保证?

答题思路:读多写少/报表/避免 JOIN → 冗余的三种常见形态 → 三档一致性补偿。

参考回答:读密集的列表页和报表。典型做法:订单冗余商品名快照(这同时是业务正确性需要的)、帖子冗余评论数、报表用预聚合宽表。一致性保证分三档:同事务强一致更新、触发器自动维护、异步任务 + 对账做到最终一致。关键原则是每个冗余字段必须有明确的更新链路和 owner,否则就是数据事故的来源。

Q:主键用自增 ID 还是 UUID?(必考)

答题思路:自增优点 → 自增两个缺点 → UUIDv4 的问题 → 有序 UUID / 雪花 ID 折中 → 工程组合拳。

参考回答:自增 bigint 只有 8 字节,递增插入对 B-Tree 最友好——总是写最右页,几乎不分裂;但它会对外暴露业务量(订单号可枚举),分库分表时各库自增会冲突。UUIDv4 全局唯一但完全随机,插入位置随机导致索引页分裂、写放大和 WAL 增加,且 16 字节让所有二级索引变胖。折中是时间有序的 UUIDv7(PG18 内置 uuidv7(),低版本应用层生成)或 bigint + 雪花 ID 发号器。我的常用方案:内部主键 bigint identity,对外另发一个不可枚举的 public_id。

Q:逻辑删除和物理删除怎么选?

答题思路:审计/可恢复 vs 简单/空间 → 唯一约束冲突 → 归档与合规。

参考回答:看数据是否需要可追溯。业务主数据用逻辑删除(deleted_at),代价是所有查询带过滤条件、唯一约束要用 partial unique index 修正、表会膨胀需要归档;日志类、中间态数据用物理删除;合规要求”被遗忘权”时必须物理删除敏感字段。实践常见组合是”逻辑删除 + 定期归档历史表”。

Q:多对多关系在 PG 里怎么设计?中间表有什么讲究?

答题思路:中间表 + 复合主键 + 两个外键 + 反向索引 + 级联行为。

参考回答:users、roles 两张实体表,加 user_roles 中间表存两个外键。中间表用复合主键 (user_id, role_id):既天然防止重复授权,本身又是可从 user_id 方向查询的索引;再给 role_id 建独立索引支持按角色反查用户。级联建议 ON DELETE CASCADE——中间表是纯关系数据,删人或删角色时关系行应随之清理。

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
-- 用户-角色 多对多 + 规范时间字段 + 软删除 + 双 ID
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- 内部主键
public_id uuid NOT NULL DEFAULT gen_random_uuid(), -- 对外暴露
email text NOT NULL,
password text NOT NULL,
deleted_at timestamptz, -- NULL = 未删除
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE roles (
id smallint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code text NOT NULL UNIQUE -- 'admin' / 'editor'
);

CREATE TABLE user_roles (
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_id smallint NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
granted_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, role_id) -- 复合主键防重复授权
);
CREATE INDEX idx_user_roles_role ON user_roles(role_id); -- 按角色反查

-- 软删除下的邮箱唯一:partial unique index
CREATE UNIQUE INDEX uniq_users_email_alive
ON users (email) WHERE deleted_at IS NULL;

-- updated_at 自动维护
CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END $$ LANGUAGE plpgsql;

CREATE TRIGGER trg_users_touch BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# SQLAlchemy 2.0:多对多声明 + UUID 列
import uuid

from sqlalchemy import Column, ForeignKey, Table
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship

user_roles = Table(
"user_roles", Base.metadata,
Column("user_id", ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
Column("role_id", ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
)

class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
public_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), default=uuid.uuid4)
roles: Mapped[list["Role"]] = relationship(
secondary=user_roles, lazy="selectin") # selectin 避免 N+1

易错点与追问

易错点 / 追问 正确认识
把范式当银弹 范式是起点不是终点,读路径该冗余就冗余,但要能补偿
忽略 2NF 的前提 2NF 只在联合主键下谈;单列主键表不存在部分依赖
用裸 timestamp 一律 timestamptz,内部 UTC,展示随会话时区
updated_at 靠应用手填 触发器或 ORM 事件自动维护
拿 UUIDv4 当主键 随机插入导致页分裂与写放大;要 UUID 就用 UUIDv7
逻辑删除后唯一约束失效 partial unique index(WHERE deleted_at IS NULL)
一对多子表外键不建索引 父行删除/更新、反向查询会全表扫子表
冗余字段没人负责更新 每个冗余字段必须有事务/触发器/对账的明确链路

追问链:三大范式 → 什么时候反范式 → 主键选型 → UUIDv7 为什么有序 → 软删除与唯一索引 → 分库分表下主键怎么办(雪花 ID)→ 冗余计数字段怎么防止漂移(定时校准任务)。

相关章节

  • 主键唯一约束与外键:主键/唯一约束语义、软删除与 partial unique index。
  • 索引:自增与随机主键对 B-Tree 的影响、外键列索引。
  • 分页:自增 ID 与游标分页(keyset pagination)的配合。
  • JSONB与GIN:半结构化扩展字段作为”受控反范式”的补充手段。
  • 分区表:大数据量下的物理拆分,主键需包含分区键。
  • PostgreSQL与MySQL对比:堆表与聚簇索引对主键选型的影响。

第20章 连接与连接池

💡 一句话核心:PostgreSQL 一个连接就是一个 backend 进程,又贵又有限(max_connections 默认 100),所以必须用连接池复用连接、限制并发;FastAPI + SQLAlchemy 的标准形态是”全局一个 engine + 每请求一个 Session(依赖注入)”,生产大架构再加一层 PgBouncer。

概念详解

PostgreSQL 的进程模型:连接为什么贵

PG 是进程模型:每接受一个客户端连接,postmaster 就 fork 一个 backend 进程为它服务(见 PostgreSQL基础与整体架构)。后果:

  • 建连开销 = fork 进程 + 认证 + 初始化 catalog 缓存,远比一次 TCP 握手重;
  • 每个 backend 独占内存:空闲时几 MB 起步,work_mem 等按算子分配后,复杂查询可到几十上百 MB;
  • 进程间上下文切换成本高于线程。

对比:MySQL 用线程模型,一个连接一个线程,轻得多。所以”连接数打满 PG”比”打满 MySQL”更快演变成事故——这也是 PG 面试必问连接池的根本原因。

连接过多的直接后果:内存吃紧、切换开销增大、锁管理变慢,吞吐不升反降。max_connections 默认 100,超限直接报 FATAL: sorry, too many clients already

为什么需要连接池

  1. 消除建连开销:TCP 握手 + 认证 + 进程 fork 的成本,被成百上千次请求摊薄;
  2. 限制并发:池的上限就是打到数据库的最大并发,保护 PG 不被打垮;
  3. 可控排队:拿不到连接时应用侧排队(pool_timeout),好过让数据库雪崩。

SQLAlchemy 连接池:QueuePool

QueuePool 是默认池实现(异步引擎对应 AsyncAdaptedQueuePool):内部维护一个空闲连接队列,请求来时先取空闲连接,没有就新建——超过 pool_size 的部分叫 overflow,用完立即关闭、不回池。

参数 含义 默认值
pool_size 常驻连接数 5
max_overflow 允许临时超开的数量;总上限 = pool_size + max_overflow 10
pool_timeout 池满时最长等待时间,超时抛 TimeoutError 30 秒
pool_recycle 连接存活超过 N 秒强制回收,对抗 NAT/LB 掐空闲 TCP -1(关闭)
pool_pre_ping 取连接前先发轻量探测,断线自动重建(代价是每次多一个 RTT) False

记忆点:最大连接数是 pool_size + max_overflow,面试常挖这个坑。pre_pingrecycle 都是对抗”数据库还好着、中间网络把空闲连接掐了”的手段:前者即时检测,后者定期换血,常组合使用。

PgBouncer:独立连接中间件

当应用实例多、各实例 SQLAlchemy 池之和逼近 max_connections 时,加一层 PgBouncer:单进程事件循环,用少量真实 PG 连接服务海量客户端连接(max_client_conn 可配到数千)。

模式 连接何时归还 特点
session(默认) 客户端断开才归还 最接近直连,池化收益最低
transaction 每个事务结束就归还 最常用,池化收益最高
statement 每条语句执行完就归还 不支持多语句事务,很少用

transaction 模式注意事项(面试措辞要谨慎、给版本号):

  • 连接在事务之间会被复用给不同客户端,会话级状态不可依赖:session 级 SET、临时表、LISTEN/NOTIFY、session 级 advisory lock 都不能跨事务使用;
  • prepared statements 兼容性:早期 PgBouncer 在 transaction 模式下不支持服务端 prepared statement;1.21 起支持协议级命名 prepared statement(需配置 max_prepared_statements)。使用 asyncpg、psycopg3 这类默认走服务端 prepared statement 的驱动要确认版本;psycopg2 默认在客户端组包 SQL,基本不受影响。

典型架构

1
2
3
4
5
6
7
8
9
10
11
12
13
FastAPI (uvicorn, N 个副本)
│ 每请求一个 Session

SQLAlchemy QueuePool(每副本最多 pool_size + max_overflow 个客户端连接)


PgBouncer (pool_mode=transaction) ←—— 可选,生产常见
│ 少量真实 PG 连接(default_pool_size)

PostgreSQL (max_connections=100)

数字示例:4 副本 × 30 = 120 个客户端连接 → PgBouncer default_pool_size=30
→ PG 最多 30~40 个 backend 进程

常见坑(排查向)

  1. max_connections 打满:现象是 too many clients already。用 pg_stat_activity 按状态、application_nameclient_addr 聚合看连接分布;解法是降应用池参数 + 上 PgBouncer,而不是无脑调大 max_connections。
  2. Idle In Transaction:事务开着不提交也不回滚——占坑连接,还阻塞 vacuum 回收死元组(衔接 VACUUM)。用 pg_stat_activitystate = 'idle in transaction' 找元凶,配 idle_in_transaction_session_timeout 兜底。
  3. 连接泄漏:Session 忘了 close,池被慢慢耗尽,最终大面积 TimeoutError。FastAPI 里用 yield 依赖保证请求结束必然归还。
  4. 网络断连:云上 LB/NAT 常掐长时间空闲的 TCP(比如 1 小时),表现为”第二天第一个请求报错”。pool_recycle(如 1800 秒)+ pool_pre_ping 组合解决。

FastAPI 实战形态

标准形态:应用启动建一个 engine/池,每个请求创建一个 Session,请求结束归还。Session 即”一次逻辑工作单元”:一个请求一个事务边界,成功 commit,异常 rollback 并释放连接。engine 和池必须全局唯一。

高频面试题

Q:为什么说 PostgreSQL 的连接很贵?

答题思路:进程模型 → 建连成本 → 内存开销 → max_connections 限制 → 引出连接池。

参考回答:PG 每个连接对应一个 fork 出来的 backend 进程,建立要 fork + 认证 + 初始化;每个进程独立内存,几 MB 起步,复杂查询时 work_mem 按算子分配可到几十上百 MB;max_connections 默认只有 100,调大了内存和上下文切换会把吞吐拖垮。相比之下 MySQL 是线程模型更轻,所以 PG 场景连接池是标配,大架构还要上 PgBouncer。

Q:SQLAlchemy QueuePool 的参数怎么配?总连接上限是多少?

答题思路:逐参数含义 → 总上限公式 → pre_ping/recycle 的关系 → 数值怎么定。

参考回答:pool_size 是常驻连接,max_overflow 是峰值临时连接(归还即关闭),总上限是两者之和;池满后等待 pool_timeout(默认 30 秒)再拿不到就抛 TimeoutError。pool_recycle 定期回收老连接对抗 NAT/LB 掐线,pool_pre_ping 在取出时探活、断线自动重建。中小服务一般 pool_size=10、max_overflow=20 起步,原则是”池上限 × 实例数 ≤ 数据库能承受的连接数”,倒推着配。

Q:PgBouncer 有哪三种模式?transaction 模式要注意什么?

答题思路:三模式一句话 → transaction 收益 → 会话状态失效清单 → prepared statement 兼容性(带版本)。

参考回答:session 模式客户端连着就一直占用,池化收益最低;transaction 模式事务结束就归还,最常用;statement 模式每条语句归还,不支持多语句事务。transaction 模式下连接会串复,所以 session 级 SET、临时表、LISTEN/NOTIFY、session advisory lock 都不能依赖;另外它历史上不支持服务端 prepared statement,1.21 之后支持协议级 prepared statement 但要配 max_prepared_statements——用 asyncpg、psycopg3 这类默认服务端预编译的驱动要确认版本,psycopg2 默认客户端组包,基本没这个问题。

Q:线上出现 “too many connections” 或池 TimeoutError,怎么排查?

答题思路:先分清是数据库侧满还是应用侧池满 → pg_stat_activity 定位 → 常见元凶 → 治理顺序。

参考回答:先查 pg_stat_activity,按 state 聚合:active 堆积说明并发或慢查询问题;idle in transaction 多半是代码里开事务后忘了提交/回滚,用 xact_start 定位最老的,再配 idle_in_transaction_session_timeout 兜底。如果是应用侧 TimeoutError,通常是池太小或连接泄漏,检查 Session 是否在依赖里正确关闭。治理顺序:先修代码,再调池参数,最后上 PgBouncer——而不是直接调大 max_connections。

Q:FastAPI 里 Session 应该怎么管理生命周期?

答题思路:一个引擎一个池 + 每请求一个 Session + yield 依赖 + 事务边界。

参考回答:engine 和连接池在进程启动时创建一次;每个请求通过依赖注入拿一个 Session,请求结束后由 yield 依赖的清理逻辑关闭并归还连接。一个请求一个事务边界:成功 commit、异常自动 rollback。绝不要用全局单例 Session——它并发不安全,而且会长期占住连接,是连接泄漏的典型来源。

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 异步版:FastAPI + SQLAlchemy 2.0(推荐形态)
from fastapi import Depends, FastAPI
from sqlalchemy import select
from sqlalchemy.ext.asyncio import (
AsyncSession, async_sessionmaker, create_async_engine,
)

engine = create_async_engine(
"postgresql+asyncpg://app:secret@pgbouncer-host:6432/appdb",
pool_size=10, # 常驻连接
max_overflow=20, # 峰值临时连接,总上限 30
pool_timeout=30, # 池满等待 30s,超时抛 TimeoutError
pool_recycle=1800, # 30 分钟回收,对抗 LB 掐空闲连接
pool_pre_ping=True, # 取用前探活,断线自动重建
)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)

app = FastAPI()

# 每请求一个 Session:请求结束自动 close,连接归还池
async def get_db():
async with AsyncSessionLocal() as session:
yield session

@app.get("/messages/{session_id}")
async def get_messages(session_id: int, db: AsyncSession = Depends(get_db)):
rows = await db.scalars(
select(Message).where(Message.session_id == session_id).limit(50))
return rows.all()
1
2
3
4
5
6
7
8
9
10
# 同步版(脚本 / 传统部署),参数语义完全一致
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

engine = create_engine(
"postgresql+psycopg2://app:secret@pgbouncer-host:6432/appdb",
pool_size=10, max_overflow=20, pool_timeout=30,
pool_recycle=1800, pool_pre_ping=True,
)
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
1
2
3
4
5
6
7
8
9
10
11
12
; PgBouncer 最小配置 /etc/pgbouncer/pgbouncer.ini
[databases]
appdb = host=127.0.0.1 port=5432 dbname=appdb

[pgbouncer]
listen_port = 6432
pool_mode = transaction ; 生产最常用
default_pool_size = 30 ; 到 PG 的真实连接数
max_client_conn = 5000 ; 接受的客户端连接上限
; transaction 模式 + 服务端 prepared statement 驱动(asyncpg/psycopg3)
; 需要 PgBouncer >= 1.21
max_prepared_statements = 100
1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 连接排查三件套 1:连接状态分布
SELECT count(*), state FROM pg_stat_activity
WHERE datname = current_database() GROUP BY state ORDER BY count DESC;

-- 2:找 idle in transaction 的元凶
SELECT pid, usename, client_addr, now() - xact_start AS xact_age,
left(query, 60) AS last_query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY xact_age DESC;

-- 3:兜底参数——空闲事务超时自动断开
ALTER SYSTEM SET idle_in_transaction_session_timeout = '60s';
SELECT pg_reload_conf();

易错点与追问

易错点 / 追问 正确认识
以为 max_overflow 是总上限 总上限 = pool_size + max_overflow
无脑调大 max_connections 每个 backend 是进程;先池化、再谈上限
混淆 idle 与 idle in transaction idle 是空闲无事务;idle in transaction 占连接还阻塞 vacuum
全局共享一个 Session 并发不安全且长期占连接,必须每请求一个
每次请求都 create_engine 引擎/池必须全局唯一,重复建会泄漏真实连接
PgBouncer 后用 session 级 SET transaction 模式下会话状态不可靠
asyncpg 走 PgBouncer 报 prepared statement 错 检查 PgBouncer 版本与 max_prepared_statements
只配 pre_ping 不配 recycle(或反之) pre_ping 即时但多一个 RTT;recycle 定期换血,常组合使用

追问链:连接为什么贵 → 池参数怎么配 → 池满了谁先扛不住 → PgBouncer 三模式 → transaction 模式的限制 → idle in transaction 怎么来的 → 连接泄漏如何定位 → 故障切换后连接池怎么办。

相关章节

第21章 主从复制与高可用

💡 一句话核心:PG 复制的载体就是 WAL——主库把 WAL 实时流给从库重放;默认异步复制快但主库宕机可能丢最后几毫秒数据,同步复制不丢数据但写延迟高;读多写少就做读写分离(注意写后读问题),主库挂了做 Failover 提升从库并防脑裂

概念详解

复制原理:主从之间传的就是 WAL

1
2
3
4
5
┌──────────────────┐      WAL 流(持续发送)        ┌──────────────────┐
│ Primary 主库 │ ─────────────────────────────▶ │ Standby 从库 │
│ 可读可写 │ walsender ──▶ walreceiver │ 只读(Hot Standby)│
└──────────────────┘ ◀──── 确认 / 心跳 / 位点 ───── └──────────────────┘
从库 startup 进程在本地持续重放收到的 WAL
  • 主库为每个从库启动一个 walsender 进程发 WAL;从库 walreceiver 接收并落盘,再由 startup 进程按序重放——重放逻辑和崩溃恢复完全一致。
  • 所以复制不用单独学一套”日志”:WAL 既是崩溃恢复的依据(WAL与数据库恢复),也是复制的载体
  • 默认是物理复制:按磁盘块级变更复制,粒度是整个实例(所有数据库一起复制),从库与主库逐块一致;如果只想复制部分表、或跨大版本升级,用逻辑复制(Publication/Subscription,PG 10+),它不复制 DDL 和序列。

Streaming Replication(流复制)vs WAL 归档 Shipping(日志归档)

维度 流复制 Streaming Replication WAL 归档 Shipping(日志归档)
传输时机 WAL 一产生就实时推给从库 WAL 段(默认 16MB)写满后才归档拷贝
延迟 亚秒级 秒级到分钟级(可用 archive_timeout 缩短)
定位 日常高可用的主力(热备) 兜底手段 + 异地容灾 + PITR 时间点恢复
断线行为 自动从上次位点续传 恢复时按归档文件顺序重放

生产实践是两者叠加:从库优先走流复制,追不上的部分用 restore_command 从归档补齐,流断了也不至于落后太多。

同步复制 vs 异步复制

  • 异步复制(默认):主库 commit 只等本地 WAL 落盘(见 Buffer与Checkpoint),不等从库。性能好;代价是主库突然宕机时,还没送到或没重放完的最后一点 WAL 会丢——“可能丢最后几毫秒数据”。
  • 同步复制:配置 synchronous_standby_names = 'ANY 1 (standby1, standby2)',commit 必须等从库把 WAL 落盘并回确认才对客户端返回成功。已提交事务零丢失,但每次写多一次网络往返;更大的风险是唯一同步从库挂掉会卡住主库所有写入,所以同步复制至少配两台候选从库。
  • 选择:金融/支付等”一条都不能丢”的写路径用同步复制;绝大多数互联网业务默认异步,用产品手段兜底一致性问题(见下文读写分离)。

Replication Lag 复制延迟

原因(按面试好记的顺序):

  1. 重放是单进程串行的:主库多连接并发写,从库只有一个 startup 进程按序重放,写高峰天然追不上;
  2. 大事务:主库跑 10 分钟的批量 UPDATE,从库也要重放 10 分钟;
  3. 从库硬件规格差,或在从库上跑了很重的分析查询抢占 IO;
  4. 网络带宽不足。

危害:读写分离下读到旧数据(刚下的单在列表里看不到);lag 越大,Failover 时丢的数据越多

缓解:监控 pg_stat_replicationreplay_lag 并告警;大事务拆小、分批提交;从库机器规格对齐主库、从库查询限流;一致性敏感的读强制走主库。

Read Replica 读写分离

  • 写走主库,读分散到多台从库——读多写少业务(列表页、详情页、报表)收益最大。
  • 路由实现:应用层配主/从两套 DSN 自己切(Python 项目最常见),或用代理件——注意 Pgpool-II 才做读写分离;PgBouncer 只做连接池,不做读写分离(见 连接与连接池)。
  • 核心难题:写后立刻读(Read Your Own Writes)。用户发帖后马上刷新列表,读走从库可能还没同步到这条数据。
    • 方案:写操作后的一小段时间内把该用户会话的读”粘”在主库;关键路径读写主库;或按 replay_lag 阈值决定本次读能否走从库。

Failover 故障切换与脑裂

  • 手动切换流程:确认主库确实挂了 → 从库执行 pg_ctl promote(或 SQL SELECT pg_promote();)提升为新主库 → 应用/VIP/DNS 切到新主库。
  • 自动切换:PG 内核不带自动 failover,生产靠 Patroni(基于 etcd/Consul 选主与 fencing)、repmgr 等工具,或直接用云 RDS 的高可用能力。
  • 脑裂(Split-Brain):旧主库只是网络分区、其实还活着,此时提升从库就出现两个可写主库,各自接受写入导致数据分叉,修复代价极大。一句话防御:提升从库前必须先隔离旧主库(fence:杀进程/断电),宁可短暂不可用,也不能双主。

Hot Standby 热备

  • 从库 hot_standby = on(新版本默认开启):一边重放 WAL 一边对外提供只读查询——这是”读从库”的前提。
  • 代价:从库上的长查询可能与 WAL 重放冲突(查询正读的行马上要被重放的变更覆盖),max_standby_streaming_delay(默认 30s)控制最多为保查询而延迟重放多久,超时会取消查询——这就是从库偶发 canceling statement due to conflict with recovery 报错的来源。

高频面试题

Q:PostgreSQL 主从复制的原理是什么?

答题思路:先一句”传的就是 WAL”,再讲三个角色(walsender / walreceiver / startup 重放),最后补物理复制的粒度。

参考回答:PG 复制的载体就是 WAL。主库为每个从库启动一个 walsender 进程,把 WAL 实时发给从库的 walreceiver,从库落盘后由 startup 进程按序重放,重放逻辑和崩溃恢复完全一致。默认是异步的物理流复制,复制的是整个实例;只需要复制部分表或跨版本时用逻辑复制,它不复制 DDL 和序列。

Q:流复制和 WAL 归档(日志归档)有什么区别?

答题思路:传输时机与粒度不同;定位一个主力一个兜底。

参考回答:流复制是 WAL 产生后实时推送,亚秒级延迟,用于常驻热备;WAL 归档是 WAL 段写满 16MB 后才拷到归档目录,延迟大,主要用于异地容灾和 PITR 时间点恢复。生产上两者叠加:从库以流复制为主,落后部分从归档补齐。

Q:同步复制和异步复制怎么选?

答题思路:一句话结论(默认异步)→ 两者的丢失/延迟特征 → 同步复制的坑(从库挂了卡写)。

参考回答:默认异步:commit 不等从库,性能好,但主库宕机可能丢最后几毫秒已返回”成功”的事务。同步复制 commit 等从库确认,已提交事务不丢,但每次写延迟增加,而且唯一的同步从库挂掉会阻塞主库写入,所以要配 ANY 1 (a, b) 多候选。我的项目读多写少、能容忍极端情况丢少量数据,用异步加监控;强一致写路径才上同步复制。

Q:什么是复制延迟?怎么发现、怎么缓解?

答题思路:原因抓三点(单进程重放、大事务、资源与网络)→ 危害(读旧数据、Failover 丢更多)→ 缓解一一对应。

参考回答:从库重放是单进程串行的,主库写高峰、大事务、从库资源差或网络慢都会造成 lag。发现靠监控 pg_stat_replication 的 replay_lag,或比对主从 LSN 差值。缓解:大事务分批提交、从库规格对齐主库、从库上查询限流,业务上让一致性敏感的读走主库。

Q:读写分离后,”写完立刻读不到”怎么办?

答题思路:先承认这是异步复制的必然 → 给两三个工程方案 → 总结原则。

参考回答:这是”读自己写”问题。常用方案:一是写操作后短时间内把该用户会话的读固定到主库;二是关键路径(下单后跳详情页)直接读主库;三是读从库前检查复制延迟,超阈值降级读主库。原则是让用户能感知的写后读走主库,其余读尽量分摊到从库。

Q:主库挂了怎么办?什么是脑裂,怎么防?

答题思路:切换三步(确认→提升→切流量)→ 自动化工具 → 脑裂一句话 + fence 防御。

参考回答:确认主库真挂后,在从库执行 promote 提升为新主库,再把应用/VIP 切过去;生产上用 Patroni 加 etcd 自动选主。脑裂是旧主库没被隔离(比如只是网络分区)就提升了从库,出现双主各自写、数据分叉;防御是提升前先 fence 旧主库——杀进程或断电,宁可短暂不可用也不能双主。

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- ============ 主库配置(postgresql.conf)============
wal_level = replica -- 流复制最低要求(logical 用于逻辑复制)
max_wal_senders = 5 -- 允许的 walsender 进程数
max_replication_slots = 5
-- synchronous_standby_names = 'ANY 1 (standby1, standby2)' -- 需要同步复制时启用

-- 复制专用账号
CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'secret';

-- pg_hba.conf 放行从库网段
-- host replication replicator 10.0.0.0/24 scram-sha-256

-- 物理复制槽:主库为从库保留 WAL,防止从库落后时 WAL 被回收
SELECT pg_create_physical_replication_slot('standby1_slot');
-- 注意:从库长期宕机会把主库磁盘撑爆,不再使用的槽要及时删
1
2
3
4
# ============ 从库搭建:基于主库全量物理备份 ============
pg_basebackup -h 10.0.0.1 -U replicator -D /pgdata/standby -R -P
# -R 自动写入 standby.signal 与 primary_conninfo/slot 配置
pg_ctl -D /pgdata/standby start
1
2
3
4
5
6
7
8
9
10
-- ============ 监控复制状态 ============
-- 主库上:每个从库的状态与各阶段延迟
SELECT application_name, state,
sent_lsn, replay_lsn, replay_lag
FROM pg_stat_replication;

-- 从库上:确认处于恢复模式,以及收到与已重放的差距(字节)
SELECT pg_is_in_recovery() AS is_standby,
pg_wal_lsn_diff(pg_last_wal_receive_lsn(),
pg_last_wal_replay_lsn()) AS local_lag_bytes;
1
2
3
4
5
6
7
-- ============ 手动 Failover 演练 ============
-- 1) 在从库上提升为主库
SELECT pg_promote(); -- 或操作系统层 pg_ctl promote
-- 2) 验证已退出恢复模式
SELECT pg_is_in_recovery(); -- 期望 false
-- 3) 应用侧把 DSN/VIP 切到新主库
-- 旧主库修复后重新加入:需 pg_rewind 对齐时间线,或重做一次 basebackup

易错点与追问

易错点 / 混淆 正确认识
“从库也能写” 从库只读(Hot Standby),写报错;要可写必须先 promote
“PgBouncer 能做读写分离” 不能,PgBouncer 只管连接池;读写分离靠应用路由或 Pgpool-II
“同步复制能消除复制延迟” 同步只保证”不丢已提交事务”,不消除 lag,且同步从库故障会卡写
“复制槽是免费的” 槽会阻止 WAL 回收,从库长期宕机把主库磁盘撑爆
“failover 想切就切” 必须先 fence 旧主再提升,否则双主脑裂;PG 内核无自动 failover
流复制 vs 逻辑复制 流=物理、整实例、不能跨大版本;逻辑=表级发布订阅、可跨版本、不复制 DDL/序列

常见追问链:复制传什么?(WAL)→ 为什么异步会丢数据?(commit 不等从库)→ 丢了怎么办?(同步复制/业务兜底)→ 复制是实例级还是表级?(物理 vs 逻辑)→ 你们生产怎么做 failover?(Patroni / 云 RDS 高可用 + 演练)。

相关章节

第22章 分区表

💡 一句话核心:分区表 = 逻辑上一张表、物理上多张子表;两大价值——分区裁剪让带分区键的查询只扫命中的”小表”、DROP 分区秒级归档历史数据;前提是查询必须带分区键、主键必须包含分区键,条件不满足时宁可不分。

概念详解

什么是分区表

逻辑上是一张表(对应用透明,SQL 不用关心子表名),物理上是多张独立的子表(partition):

1
2
3
4
5
                 logs 表(父表,只是一个逻辑入口)
┌───────────────────┼───────────────────┐
logs_2026_01 logs_2026_02 logs_2026_03
created_at ∈ 1月 created_at ∈ 2月 created_at ∈ 3月
(每张子表独立存储、各有自己的一套索引)
  • INSERT 时 PG 按分区键自动路由到对应子表;SELECT 时优化器按 WHERE 条件裁剪掉无关分区;UPDATE 把分区键改到区间外会自动做行迁移(delete+insert 语义)。
  • 演进一句话:PG 10 之前靠继承表 + 触发器手工模拟;PG 10 引入声明式分区(declarative partitioning);PG 11 补齐 Hash 分区、分区表主键/唯一约束、父表索引自动下发到子表;PG 12 大幅增强裁剪能力(含运行时裁剪)。面试讲到这三个版本节点即可,不必追更新的版本细节。

三种分区方式

方式 按什么分 典型场景 备注
Range 范围 时间、ID 的连续区间 日志、订单、消息、埋点(最常用 天然匹配”按月归档”需求
List 列表 枚举值 地区、租户、订单状态 值集合可枚举且稳定
Hash 哈希 hash(key) 取模 没有自然范围列、只想把热点打散 缺点:无法按期归档

选型口诀:能按时间就 Range,是枚举就 List,只想打散就 Hash

为什么分区:三个核心收益

  1. 大表变小表:单表 5 亿行时 B-Tree 索引 4~5 层深、缓存命中率差;按月分区后热分区(当月)只有几百万行、索引浅、常驻内存,冷分区几乎不被访问。每个分区有独立的索引,写入也分散。
  2. Partition Pruning 分区裁剪WHERE created_at >= '2026-08-01' 时优化器直接排除不相关分区,执行计划里只出现命中的分区——这是分区表性能收益的直接来源,必须用 EXPLAIN 验证(EXPLAIN与SQL优化)。
  3. 归档/删除按分区做:下线一年前的数据用 DETACH PARTITION + DROP TABLE,是秒级元数据操作、不产生死元组;对比 DELETE 亿级行——全表扫描、产生海量 Dead Tuple、写入巨量 WAL、还要等 VACUUM 收尾(VACUUM),只能分批跑好几天。

分区键选择:决定成败

  • 查询必须带分区键:不带分区键的查询要逐个分区扫一遍(Append 所有分区 + 各分区索引),比不分区还慢。所以先看业务 SQL 再定分区键——按时间分区的前提是绝大多数查询天然带时间范围。
  • 主键/唯一约束必须包含分区键:PG 要求分区表上的 PK、unique 约束包含全部分区键列。所以按 created_at 分区的表建不了 PRIMARY KEY (id),通常改成 PRIMARY KEY (id, created_at),全局唯一交给应用层生成(雪花 ID 等分布式 ID,见 主键唯一约束与外键数据库设计)。
  • 分区数不是越多越好:上万个分区会让优化器规划耗时明显增长、锁与元数据开销变大;一般按月/按周,几百个分区量级为宜。

分区 vs 分表 vs 分库分表(Sharding)

  • 分区表:单库内部的物理切分,PG 自动路由,对应用透明,没有跨库事务问题。
  • 手工分表 / 分库分表:应用层或中间件自己算路由;跨库 JOIN、跨库事务、全局 ID、扩容迁移都要自己解决。
  • 一句话定位:单机撑得住、只是单表过大 → 先分区;单机彻底撑不住 → 才考虑分布式。PG 的分布式扩展方案存在 Citus(协调器 + 工作节点把分片铺到多机),面试一句话带过即可,项目里一般到不了这一步。

高频面试题

Q:什么是分区表?它和手工分表有什么区别?

答题思路:先定义(逻辑一张表、物理多张子表)→ 再从路由方、透明性、跨分区查询三个维度对比。

参考回答:分区表逻辑上是一张表,物理上按分区键拆成多张子表,插入自动路由、查询自动裁剪,对应用完全透明。手工分表是应用层自己维护 N 张表自己路由,SQL 要拼表号,跨表聚合要 union。分区表解决”单表过大”,分库分表解决”单机撑不住”,前者是后者的前置选项,代价小得多。

Q:三种分区方式怎么选?

答题思路:给口诀 + 各自典型场景和缺点,最好落到自己项目。

参考回答:Range 按时间或 ID 区间分,最常用,日志订单类带时间范围的查询收益最大,还能按期 DROP 归档;List 按枚举值分,比如地区、租户;Hash 把没有自然范围的键打散消热点,但没法按期归档。我的日志表场景直接选 Range 按月分区。

Q:什么是分区裁剪?什么情况下会失效?

答题思路:先定义 → 正例(条件直接作用在分区键)→ 反例(不带分区键、分区键被函数包裹)→ 验证手段。

参考回答:分区裁剪是优化器根据 WHERE 条件判断哪些分区不可能有结果,直接从执行计划里排除,只扫命中的分区。生效前提是条件直接作用在分区键上,例如 WHERE created_at >= '2026-08-01'。失效的典型情况:查询完全不带分区键;对分区键套了函数或隐式类型转换导致无法比对区间。失效时执行计划退化成 Append 全分区,比不分区更慢,所以上线前要用 EXPLAIN 确认裁剪生效。

Q:为什么分区表的主键必须包含分区键?

答题思路:从唯一约束的实现机制讲:每个分区只有本地索引,跨分区无法校验唯一。

参考回答:每个分区有自己独立的本地索引,唯一性校验只发生在单分区内;如果不包含分区键,同一个 id 可能落到不同分区,数据库无法用本地索引保证全局唯一,所以 PG 要求分区表的 PK/unique 必须包含全部分区键列。工程妥协是联合主键 (id, created_at),全局唯一靠应用层生成雪花 ID 保证。

Q:删一年前的历史数据,怎么删最快?

答题思路:DELETE 与 DROP 分区两条路径对比,落到 Dead Tuple 和 VACUUM 代价。

参考回答:按时间分区的表直接 DETACH PARTITION 再 DROP 或归档,秒级元数据操作,不产生死元组。DELETE 亿级行则要扫全表、写海量 Dead Tuple 和 WAL,长期持锁影响线上,还要等 AutoVacuum 收尾,只能分批跑好几天。这也是日志、订单类表要按时间分区的最硬理由。

Q:什么时候该考虑分区表?

答题思路:不给死数字,给判断信号:单表体积、归档需求、查询模式三要素。

参考回答:看信号不看行数:单表几亿行或几百 GB、B-Tree 层深导致缓存命中率下降;有明确的按期归档或删除需求;且绝大多数查询天然带时间或某个稳定过滤列。三个条件满足就值得按 Range 分区;反过来,查询从不带分区键的表上了分区反而更慢。

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
-- ============ 1. 按月 Range 分区建表 ============
CREATE TABLE logs (
id bigserial,
level text NOT NULL,
message text NOT NULL,
meta jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (id, created_at) -- 主键必须包含分区键 created_at
) PARTITION BY RANGE (created_at);

-- 建三个按月分区(上界开区间:FROM 含、TO 不含)
CREATE TABLE logs_2026_07 PARTITION OF logs
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
CREATE TABLE logs_2026_08 PARTITION OF logs
FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
CREATE TABLE logs_2026_09 PARTITION OF logs
FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');

-- 兜底分区:没匹配到任何分区的数据也能插入,避免线上报错
CREATE TABLE logs_default PARTITION OF logs DEFAULT;

-- 在父表建索引会自动下发到所有分区(PG 11+)
CREATE INDEX ON logs (created_at);
CREATE INDEX ON logs USING gin (meta); -- JSONB 检索见 [JSONB与GIN](/2026/08/29/17-JSONB与GIN/)

-- ============ 2. 插入自动路由 ============
INSERT INTO logs (level, message) VALUES ('ERROR', 'timeout');
-- created_at 默认 now()(当前 2026-08)→ 自动落进 logs_2026_08

-- ============ 3. 验证分区裁剪 ============
EXPLAIN SELECT * FROM logs
WHERE created_at >= '2026-08-01' AND created_at < '2026-09-01';
-- 执行计划里只出现 logs_2026_08,其他分区被裁剪掉

EXPLAIN SELECT * FROM logs WHERE message LIKE '%timeout%';
-- 不带分区键 → Append 扫所有分区:这就是"分区反而更糟"的场景

-- ============ 4. 归档:秒级下线旧分区 ============
ALTER TABLE logs DETACH PARTITION logs_2026_07; -- 先脱离父表
DROP TABLE logs_2026_07; -- 再删除(或 dump 后归档)

-- ============ 5. List / Hash 分区一眼了解 ============
CREATE TABLE orders_p (region text, id bigint) PARTITION BY LIST (region);
CREATE TABLE orders_east PARTITION OF orders_p
FOR VALUES IN ('east', 'sh', 'hz');

CREATE TABLE events (uid bigint, ts timestamptz) PARTITION BY HASH (uid);
CREATE TABLE events_p0 PARTITION OF events
FOR VALUES WITH (MODULUS 4, REMAINDER 0);

易错点与追问

易错点 / 混淆 正确认识
“分区表查询一定更快” 只有带分区键的查询才裁剪;不带分区键等于全分区串一遍,更慢
“主键只用 id 就行” 分区表 PK/unique 必须包含分区键;全局唯一靠应用层 ID 生成器
“分区越多越好” 分区数过多会拖慢规划器、增加锁与元数据开销;按月/按周即可
“分区 = 分库分表” 分区在单库内、PG 自动路由;分库分表要应用层路由与分布式事务
“DELETE 几亿行慢慢删也行” 海量 Dead Tuple + WAL、长事务锁表;能 DROP 分区就 DROP
“子表索引要一张张建” 在父表建索引自动下发所有分区(PG 11+)
“忘了建 default 分区” 插入落不进任何分区会直接报错;生产建议建 DEFAULT 分区兜底

常见追问链:为什么用分区?→ 查询不带分区键怎么办?(先看 SQL 再定分区键)→ 主键唯一怎么保证?→ 老的大表怎么迁成分区表?(新建分区表 + 批量灌数据 + 改名切换,留双写/追增量窗口)→ 单机最后扛不住怎么办?(Citus 类分布式方案,一句话带过)。

相关章节

  • EXPLAIN与SQL优化 — 用执行计划验证分区裁剪是否生效
  • 索引 — 每个分区各有一套本地索引,热分区索引小、命中率高
  • VACUUM — DELETE 大表的 Dead Tuple 代价 vs DROP 分区秒删
  • 主键唯一约束与外键 — 主键必须包含分区键的机制与应用层妥协
  • 分页 — 按时间游标分页天然受益于 Range 分区
  • 数据库设计 — 何时分区、分布式 ID 怎么生成

📕 实战篇

第23章 PostgreSQL 与 MySQL 对比

💡 一句话核心:MySQL 是”简单够用”的互联网默认项,PG 是”功能全、标准严、可扩展”的瑞士军刀;选 PG 的硬理由是 AI 场景(pgvector + JSONB)与复杂查询能力,代价是 VACUUM 运维和昂贵的连接数——面试要讲出这条完整的因果链,而不是一句”PG 功能比较强”。

概念详解

全面对比表

维度 PostgreSQL MySQL (InnoDB)
MVCC 实现 多版本存表内:旧元组留在堆里,靠 VACUUM 回收 Dead Tuple 旧版本写 undo log(回滚段),purge 线程清理
更新代价 UPDATE 写新元组,表会膨胀、依赖 VACUUM(有 HOT 更新优化缓解) 近似原地更新 + undo 记录,表不膨胀
JSON JSONB:二进制存储 + GIN 索引,查询与索引能力完整 有 JSON 类型;8.0 起支持多值/函数索引,能力弱一档
索引种类 B-Tree / GIN / GiST / SP-GiST / BRIN / Hash,另支持部分索引、表达式索引 以 B+Tree 为主(另有 FULLTEXT、空间索引)
窗口函数 / CTE 很早就完整支持(含递归 CTE、DISTINCT ON) 8.0 才支持窗口函数与 CTE
DDL 事务性 大多数 DDL 在事务内可回滚(建表/加列可随事务一起撤销) DDL 隐式提交,不可回滚
扩展系统 CREATE EXTENSION:PostGIS、pgvector、pg_stat_statements… 插件生态弱,功能主要靠内核内置
SQL 标准兼容 好:完整约束体系(CHECK/EXCLUDE)、递归 CTE、FULL OUTER JOIN 兼容性一般,隐式类型转换等历史包袱较多
并发写 行级锁 + MVCC,读写互不阻塞;RR 靠快照、无间隙锁 行级锁 + MVCC;RR 下用间隙锁防幻读
默认隔离级别 Read Committed Repeatable Read
全文搜索 内置 tsvector/tsquery + GIN(中文分词需 zhparser 等扩展) FULLTEXT 索引,能力有限
连接模型 每连接一个进程:连接创建成本高、内存占用大 每连接一个线程:连接相对便宜
复制 WAL 物理流复制 + 逻辑复制(发布/订阅) binlog 复制(statement/row/mixed)+ 半同步
表组织方式 堆表:二级索引指向堆位置 聚簇索引(按主键组织):二级索引叶子存主键

两个最常被追问的细节:

  • MVCC 差异是”母题”:PG 把旧版本放在”正文”(表内),所以要 VACUUM、会表膨胀、有 TXID 回卷这类专属问题(MVCCVACUUM);InnoDB 把旧版本放”注释”(undo log),回滚快、不膨胀,但长事务会撑大 undo,且所有二级索引都带主键、主键过长会拖累全部索引。先答实现差异,再答各自代价,才是高分答案。
  • 进程 vs 线程模型:PG 每连接一个进程,几百个连接内存就吃紧,所以必须配连接池 / PgBouncer(连接与连接池);MySQL 线程轻,裸扛几千连接相对从容。这也是”为什么 PG 项目要上 PgBouncer”的根源。

扩展系统:PG 的护城河

一句话框架:MySQL 的功能路径是”内核内置什么就用什么”,PG 的路径是”内核 + 扩展生态“——CREATE EXTENSION 一条命令装上 PostGIS(地理信息)、pgvector(向量检索)、pg_stat_statements(慢 SQL 统计)。对 AI 应用项目,pgvector 直接决定选型:不用为向量检索单独维护一套检索服务。

什么时候 MySQL 更合适(要诚实,别踩一捧一)

  • 团队更熟悉、运维体系现成:各大云 RDS、DBA 工具链、备份与监控方案都非常成熟;
  • 典型 OLTP:简单读写为主,用不到 GIN、窗口函数、复杂 SQL 和扩展;
  • 超高并发短查询:线程模型连接便宜,裸连接数上限更高;
  • 结论话术:这个量级两个都够用,团队熟悉度 > 数据库信仰。但如果需求里出现向量检索、JSONB 深度查询、复杂分析 SQL,PG 是更短的路径。

pgvector 与 Milvus 的定位差异(AI 岗加分项)

  • pgvector:向量与业务数据同库——事务一致(元数据和向量一起提交/回滚)、可 JOIN、可先按 JSONB/SQL 条件过滤再做向量检索;适合百万级以内、单机放得下的规模。
  • Milvus:专用向量数据库,面向亿级向量、分布式部署、为高维 ANN 检索优化,但不承担业务关系数据。
  • 项目话术:当前规模”一库多用”用 pgvector;向量规模上去后把检索迁到 Milvus,PG 继续当元数据主库——两者是演进关系,不是替代关系。

高频面试题

Q:PG 和 MySQL 最核心的三个区别?

答题思路:挑最有深度的三个:MVCC 实现(连带 VACUUM)、扩展生态(连带 pgvector)、连接模型(连带 PgBouncer);每个都答出”连带影响”。

参考回答:一是 MVCC 实现不同:PG 的旧版本存在表内、靠 VACUUM 清理,会有表膨胀问题;InnoDB 走 undo log,不膨胀但长事务会撑大回滚段。二是扩展生态:PG 可以 CREATE EXTENSION 装 pgvector、PostGIS,我项目的向量检索就直接用 pgvector,MySQL 做不了这个。三是连接模型:PG 每连接一个进程、连接很贵,必须上连接池和 PgBouncer;MySQL 线程模型连接便宜。此外还有 DDL 可回滚、窗口函数和 CTE 支持更早、索引类型更丰富这些差异。

Q:为什么你的项目选择 PostgreSQL?(必考,背熟)

答题思路:按”硬需求 → 查询能力 → 一致性 → 生态 → 诚实代价”五段展开,全程绑定项目细节,最后不贬低 MySQL。

参考回答:我们项目是 FastAPI + SQLAlchemy + PostgreSQL + Redis + Milvus 的 AI 应用栈,选 PG 有四个具体理由:

  1. AI 场景硬需求:RAG 的向量检索直接用 pgvector(HNSW 索引),文档元数据(来源、页码、租户、自定义字段)用 JSONB + GIN 存查一体,”先按元数据过滤、再做向量检索”在一条 SQL 里完成,不用在两套存储之间同步数据。
  2. 复杂查询能力:会话统计、去重、排行这类需求,窗口函数、CTE、LATERAL 写起来简洁清晰;MySQL 8.0 之前连窗口函数都没有。
  3. 一致性与工程质量:DDL 可以放进事务回滚,迁移失败能整体撤销;约束体系强,比如”同一用户同一资源只允许一条激活记录”用部分唯一索引一条 DDL 就解决。
  4. 生态契合:SQLAlchemy 对 PG 类型(JSONB、ARRAY、UUID)和 asyncpg 驱动支持完善,Alembic 迁移配合顺畅,团队是 Python 技术栈,上手成本低。

同时诚实说代价:一是 PG 靠 VACUUM 清理旧版本,长事务会阻碍清理,我们要监控膨胀、控制事务长度;二是连接是进程级的、很贵,所以上了 PgBouncer;三是有团队学习成本。结论:MySQL 不是做不了,而是 pgvector + JSONB 的组合让 PG 成为这个 AI 场景”一库多用”的最短路径,少维护一套检索服务。

Q:PG 的 MVCC 和 InnoDB 的 MVCC 有什么不同?各自的问题是什么?

答题思路:先讲存储位置差异(表内 vs undo),再各答一个代价,最后各答清理机制。

参考回答:PG 更新时新元组写进表里,旧版本也留在表内等 VACUUM 回收,代价是表和索引可能膨胀,还有 TXID 回卷这类特有问题;InnoDB 更新近似原地改,旧版本写入 undo log,由 purge 线程清理,表不膨胀,但长事务会阻止 purge 让 undo 膨胀,而且二级索引叶子存主键。一句话总结:PG 把旧版本放正文,MySQL 放注释;PG 的代价靠 VACUUM 运维兜住。

Q:什么时候你会反过来推荐 MySQL?

答题思路:诚实且有判断力比站队更加分:团队熟悉度、简单 OLTP、连接成本、运维生态四个点。

参考回答:如果团队只有 MySQL 的 DBA 和运维经验、业务是典型的简单读写 OLTP、用不到 JSONB 深度查询和扩展,那 MySQL 是更稳的选择——它的云上生态和工具链太成熟了。另外超高并发短连接场景下,MySQL 线程模型连接更便宜。技术选型的第一原则是团队能驾驭,而不是参数表比较。

Q:你们为什么 PG 之外还要 Milvus?pgvector 不够吗?

答题思路:先说 pgvector 的舒适区(同库、事务、混合查询),再说规模上限,最后给演进结论。

参考回答:pgvector 的优势是向量和元数据同库:事务一致、可以先 SQL 过滤再做 ANN 检索,百万级规模完全够用。但它是 PG 插件、单机架构,亿级向量下索引内存和检索延迟都不是它擅长的;Milvus 是分布式专用向量库,为这个量级设计。所以我们按演进规划:当前规模 pgvector 就够;向量规模上来后把检索层迁到 Milvus,PG 继续做元数据主库,业务代码只换检索接口。

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
-- ============ JSONB + GIN:Agent 元数据存查一体(PG 强项)============
CREATE TABLE agent_runs (
id bigserial PRIMARY KEY,
agent_name text NOT NULL,
status text NOT NULL DEFAULT 'running',
meta jsonb NOT NULL DEFAULT '{}', -- 模型、token 用量、工具调用链……
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON agent_runs USING gin (meta jsonb_path_ops);
CREATE INDEX ON agent_runs (agent_name, created_at);
CREATE INDEX idx_runs_failed ON agent_runs (created_at) WHERE status = 'failed';
-- ↑ 部分索引:只索引失败记录,索引更小、写入开销更低(MySQL 没有这种能力)

-- 嵌套字段直接过滤 + 聚合
SELECT agent_name, count(*)
FROM agent_runs
WHERE meta @> '{"model": "glm-4"}' AND status = 'failed'
AND created_at >= now() - interval '7 days'
GROUP BY agent_name;

-- ============ pgvector:RAG 向量检索 ============
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
id bigserial PRIMARY KEY,
source text NOT NULL,
meta jsonb NOT NULL DEFAULT '{}',
embedding vector(1024) NOT NULL -- 维度与 Embedding 模型输出一致
);
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops); -- 大表用 HNSW
CREATE INDEX ON documents USING gin (meta jsonb_path_ops);

-- 混合检索:先 GIN 按租户过滤,再按余弦距离取 Top5(一条 SQL、同库事务)
SELECT id, source, embedding <=> $1 AS distance
FROM documents
WHERE meta @> '{"tenant": "acme"}'
ORDER BY embedding <=> $1
LIMIT 5;
-- 操作符:<=> 余弦距离;<-> L2 距离;<#> 内积(负值)

易错点与追问

易错说法 正确说法
“MySQL 没有窗口函数 / CTE” 8.0 已支持;要说”8.0 之前没有”
“MySQL 没有 MVCC / 事务” InnoDB 有完整 MVCC 和事务,差异在实现方式
“JSONB 什么都比 JSON 快” 写入时 jsonb 要解析转成二进制更慢;读取与索引查询更快
“PG 每连接一个线程” 进程;MySQL 才是线程
“PG 默认 RC,隔离更弱” 默认 RC 是工程选择不是缺陷;SERIALIZABLE 级别 PG 用 SSI 实现
“选 PG 因为它永远更快” 简单 OLTP 两者相当;选 PG 的理由是功能与生态,不是玄学性能
“pgvector 能替代 Milvus” 百万级内可以;亿级、高 QPS 的 ANN 要专用向量库

常见追问链:为什么选 PG?→ pgvector 和 Milvus 怎么分工?→ PG 的代价你实际踩过什么?(长事务阻碍 VACUUM / 表膨胀监控)→ 连接数怎么处理?(PgBouncer,衔接 连接与连接池)→ 明天要支持千万级文档怎么演进?(分区 分区表 + 检索层迁 Milvus)。

相关章节

第24章 FastAPI 与 PostgreSQL 实战

💡 一句话核心:SQLAlchemy 的 Session 是”工作单元 + 对象状态管理器”,不是连接(连接从池里借还);FastAPI 里一个请求一个 Session、一个事务(yield 依赖注入);异步下懒加载不可用,必须用 selectinload/joinedload 预加载防 N+1;表结构变更交给 Alembic,生产迁移要防锁表。

概念详解

SQLAlchemy Session 是什么?不是数据库连接

  • Session 的本质是工作单元(Unit of Work)+ 对象状态管理器(Identity Map)
    • 跟踪加载过的对象及改动(哪些是新增、哪些被改 dirty、哪些被标记删除);
    • flush() 时把这些变更统一翻译成 SQL、按依赖顺序发出;
    • 同一 Session 内主键相同的对象只有一份(Identity Map),避免同一行出现两个对象。
  • 连接是 Session 执行 SQL 时从 Engine 管理的连接池临时借的,用完归还:一个 Session 生命周期内可能换用连接,一个连接也会先后服务多个 Session。
  • 一句话:Session 管”对象与事务”,Pool 管”连接”(细节见 连接与连接池)。

commit() / flush() / refresh() 的区别

操作 做什么 事务状态 典型场景
flush() 把内存变更翻译成 SQL 发给数据库但不提交 事务仍在进行 INSERT 后立刻拿自增主键;让后续 SQL 能在库内看到这行
commit() 提交事务(默认先自动 flush),连接归还池 事务结束 请求处理成功后收尾
refresh() 重新 SELECT,把数据库最新值刷回对象 事务内执行 拿服务端生成的默认值/触发器结果;expire_on_commit=True 时 commit 后访问属性会触发隐式刷新
  • flush 不提交:其他事务看不到、rollback 可撤;commit 隐含 flush。
  • 异步场景推荐 expire_on_commit=False:否则 commit 后对象过期,再访问属性会触发隐式 IO,AsyncSession 下直接抛错,必须手动 await refresh

为什么需要 rollback()

  • 异常时回滚未提交事务:避免”半截写入”(前几条 INSERT 成了、后面失败)留在库里;
  • 归还连接前清理状态:Session 带着 open transaction 回池,下一个请求借到的就是脏连接/脏事务。session.close()(依赖收尾时自动调用)会回滚未提交事务并还连接,但代码里显式 except: await session.rollback(); raise 语义更清晰。

一个 HTTP 请求一个 Session(FastAPI 依赖注入)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# db.py —— 引擎与 Session 工厂(模块级,全局只建一次)
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession

engine = create_async_engine(
"postgresql+asyncpg://app:secret@db:5432/mydb",
pool_size=10, # 常驻连接数
max_overflow=20, # 高峰最多再临时借 20 个
pool_timeout=30, # 借不到连接最多等 30s
pool_recycle=1800, # 连接最多复用 30 分钟,防被防火墙/LB 静默掐断
pool_pre_ping=True, # 借用前探活,自动剔除失效连接
)
async_session = async_sessionmaker(engine, expire_on_commit=False) # 异步下关闭过期

async def get_db() -> AsyncSession: # FastAPI 依赖:一个请求一个 Session
async with async_session() as session:
yield session # 请求期间挂起;响应返回后自动 close
# close 会回滚未提交事务,并把连接还回池
  • 为什么用 yield 形式的依赖yield 之前的代码在”请求前”执行(建 Session),之后的代码在响应返回后执行(清理),保证异常路径也能释放资源;依赖可嵌套、可用 app.dependency_overrides 在测试里替换。
  • 为什么不能全局共享一个 Session:Session 承载事务与对象状态,不是并发安全的;全局共享会状态串台。请求级 Session 的成本只是从池借还连接,并不是每次新建连接。

AsyncSession 与异步驱动;懒加载的坑

  • 技术栈:create_async_engine + asyncpg(纯异步、性能好)或 psycopg 3(支持异步模式),URL 前缀 postgresql+asyncpg://
  • 最大的坑:懒加载在异步里默认不可用。同步 SQLAlchemy 访问 user.posts 会自动补发一条 SQL(懒加载);异步下这等于在事件循环里偷偷做同步 IO,SQLAlchemy 直接抛 MissingGreenlet 错误。
  • 出路只有三条:
    1. 查询时显式预加载selectinload / joinedload);
    2. 需要时 await session.refresh(obj, ["posts"])
    3. 关系上声明 lazy="selectin"(查主表自动带出)或 lazy="raise"(忘了预加载就报错,把问题提前暴露到开发期)。

N+1 问题:异步下更致命

  • 定义:查列表用 1 条 SQL,再对每条记录逐个查关联,共 1+N 条 SQL。
  • 为什么异步更致命
    1. 每条查询都是一次独立网络往返,而且常被写成 for u in users: await u.posts串行 await,延迟线性累加((1+N) × RTT);
    2. 事件循环是单线程的,串行等待会把整个服务的并发吞吐拖垮;
    3. 同步时代 N+1 至少”能跑”,异步里隐式懒加载直接报错——这反而是保护。
  • 解决:预加载(Eager Loading)
预加载方式 生成的 SQL 适用
selectinload(User.posts) 第二条 SELECT * FROM posts WHERE author_id IN (...) 一对多/多对多集合(首选)
joinedload(User.bio) 同一条 SQL LEFT JOIN 带出 多对一/一对一;集合关系会产生行重复需 .unique(),一般不用
关系声明 lazy="selectin" 每次查主表自动走 selectin 默认就要关联的强关系
  • Lazy vs Eager:Lazy 用到才查(省查询但易 N+1,异步下不可用);Eager 一次带回(多查一点数据但次数可控)。异步应用的默认姿态应该是 Eager。

事务边界

  • 默认模式:一个请求一个事务——依赖里用 async with session.begin(): 包住 yield,正常返回自动 commit、异常自动 rollback;简单 CRUD 最省心。
  • 手动控制:一个请求里有独立单元(如”审计日志写失败不影响主流程”)就拆多个小事务;调用外部 API / 发 MQ 这类慢 IO 不要夹在数据库事务里——事务拉长意味着行锁久持(锁与并发控制)且阻碍 VACUUM 清理旧版本(VACUUM)。
  • SQLAlchemy 2.0 有 autobegin:第一次 execute 自动开启事务,但 commit 必须显式调用;写操作一定要在事务语义内完成提交。

Repository Pattern:为什么把数据访问抽出来

  • 路由函数里直接写查询的问题:查询逻辑散落重复(”活跃用户”的定义出现五次)、路由层背着 SQL 细节、单测必须连真实库、换实现要改路由。
  • 分层:路由(参数校验)→ service(业务编排)→ repository(数据访问)
    • 可测试:测试时 dependency_overrides 换 mock repo 或连测试库,不起真实 PG;
    • 可替换/可演进:加 Redis 缓存、换检索实现只改 repo 一层;
    • 避免重复:同一业务查询只写一处,口径一致。
  • 不教条:小项目直接在路由里写也没问题,面试讲清”什么规模抽哪一层”即可。

Alembic 迁移

  • 基本用法:alembic init -t async migrationsenv.py 里配 target_metadata = Base.metadataalembic revision --autogenerate -m "..." 生成脚本 → alembic upgrade head / downgrade -1
  • autogenerate 不是万能的:它只对比模型与库结构——改列名会生成 drop+add(丢数据,必须手工改成 rename);不感知纯数据迁移;类型变更检测依赖配置。生成的脚本必须人工 review
  • 生产注意点:
    1. 先备份
    2. ALTER TABLEACCESS EXCLUSIVE 锁:长事务会阻塞它、它也会阻塞这张表的一切查询——低峰执行,并先 SET lock_timeout = '3s' 拿不到锁就快速失败;
    3. 加列带常量默认值(PG 11+)是元数据级操作、秒完成不重写表;但加 NOT NULL(需全表扫描验证)和函数默认值仍可能长锁——NOT NULL 用 ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID + VALIDATE CONSTRAINT 两步走(VALIDATE 只拿共享锁、不阻塞写);
    4. 迁移与代码版本对齐(先迁库再发代码,或 expand-contract 两段式兼容旧结构),迁移脚本进代码库并在 CI 里验证。

高频面试题

Q:SQLAlchemy 的 Session 是数据库连接吗?

答题思路:明确答”不是”,分别定义 Session 与连接的职责,最后点出借还关系。

参考回答:不是。Session 是工作单元加对象状态管理器:跟踪对象的增删改、flush 时统一翻译成 SQL、维护同一主键对象的唯一性。连接是执行 SQL 时从 Engine 的连接池借的,用完归还;一个 Session 生命周期可能用多个连接,一个连接也会先后服务多个 Session。职责分离:Session 管对象和事务,Pool 管连接。

Q:flush、commit、refresh 的区别?

答题思路:各给一句话定位:flush 发 SQL 不提交、commit 先 flush 再提交、refresh 重新 SELECT。

参考回答:flush 把 ORM 内存变更翻译成 SQL 发给数据库但不提交,事务还在,典型用途是插入后立刻拿自增主键;commit 提交事务并默认先 flush,提交后连接归还池;refresh 是重新 SELECT 把库里的最新值刷回对象,用于拿数据库生成的默认值或触发器结果。另外异步下建议 expire_on_commit=False,否则 commit 后访问属性会触发隐式刷新而报错。

Q:FastAPI 里怎么管理 Session 生命周期?为什么一个请求一个 Session?

答题思路:yield 依赖注入 + 生命周期(请求前建、响应后关)+ 为什么不能全局共享。

参考回答:用 yield 形式的依赖:请求进来创建 Session,yield 交给路由用,响应返回后关闭;close 会回滚未提交事务并还连接,异常路径也能清理。一个请求一个 Session 是因为 Session 承载事务和对象状态、不是并发安全的,全局共享会串台;这也天然给出”一个请求一个事务”的边界。连接本身由池管理,请求级会话的成本只是借还,不是建连接。

Q:什么是 N+1 问题?为什么异步下更致命?怎么解决?

答题思路:先给定义(1+N 条 SQL)→ 异步下串行 RTT 拖垮事件循环 → selectinload/joinedload/lazy=”selectin”。

参考回答:查 50 个用户是 1 条 SQL,再逐个访问 user.posts,每个又发 1 条,共 51 条。同步下只是慢;异步下这些查询往往是串行 await,每次一个网络往返,事件循环单线程被占满,整个服务吞吐被拖垮,部分隐式懒加载还会直接抛 MissingGreenlet。解决靠预加载:selectinload 用第二条 IN 查询一次带回集合关联,joinedload 用 LEFT JOIN 带出多对一;或关系声明 lazy=”selectin”。我还会把关系配成 lazy=”raise”,让漏写预加载的代码在开发期就报错。

Q:异步下懒加载为什么报错?怎么办?

答题思路:根因(属性访问触发隐式同步 IO,与事件循环冲突)→ 三条出路。

参考回答:懒加载本质是属性访问时补发 SQL,这在事件循环里是阻塞行为,AsyncSession 检测到后抛 MissingGreenlet。出路:查询时显式 selectinload/joinedload 预加载;需要时 await session.refresh(obj, ["attr"]);或关系声明 lazy=”selectin”/“raise”。生产推荐 lazy=”raise”,把隐式懒加载变成显式错误,问题在测试期暴露。

Q:事务边界怎么定?写操作为什么必须在事务里?

答题思路:默认一请求一事务 → 何时拆分 → 事务要短的原则与危害。

参考回答:默认一个请求一个事务,用 begin 上下文包住依赖,成功 commit、异常 rollback。请求内有独立单元(日志失败不影响主流程)再拆成多个小事务。核心原则是事务要短:外部 API、发 MQ 这类慢 IO 别夹在事务里,否则行锁久持阻塞其他写,还会阻碍 VACUUM 清理旧版本。写操作在事务里是为了原子性——要么全提交要么全回滚,不能留半截。

Q:为什么用 Repository 模式?不觉得多此一举吗?

答题思路:先承认小项目可以不抽,再讲三个收益:可测试、可替换、查询口径统一。

参考回答:小项目直接在路由写查询确实可以。抽 Repository 是为了:查询逻辑只写一处,”活跃用户”这类口径不散落各处;路由不依赖 ORM 细节,测试时用 dependency_overrides 换 mock 或测试库,不用起真实 PG;将来加缓存或换实现只改这一层。我的项目里路由薄、service 编排、repo 管数据访问,收益主要体现在集成测试速度和口径一致性上。

Q:Alembic 迁移在生产上要注意什么?

答题思路:autogenerate 要人工 review → 锁与长事务 → 加列/NOT NULL 的具体坑 → 备份与版本对齐。

参考回答:四点。一,autogenerate 不可全信:改列名会生成 drop+add 丢数据,要手工改成 rename,脚本必须 review。二,ALTER 拿 ACCESS EXCLUSIVE 锁,长事务会让它排队并阻塞全表查询,所以低峰执行并设 lock_timeout 快速失败。三,PG 11+ 加列带常量默认值是秒级元数据操作,但加 NOT NULL 要全表扫描,用 NOT VALID CHECK 加 VALIDATE 两步走。四,先备份,迁移与发布版本对齐,downgrade 脚本也要测过。

实战示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# ============ models.py:模型与关系(防 N+1 意识)============
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from sqlalchemy import ForeignKey, String

class Base(DeclarativeBase): ...

class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(64))
posts: Mapped[list["Post"]] = relationship(
back_populates="author",
lazy="raise", # 忘了预加载就报错,杜绝隐式 N+1 / MissingGreenlet
)

class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
author: Mapped["User"] = relationship(back_populates="posts")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# ============ repository.py:数据访问层 ============
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload

class UserRepository:
def __init__(self, db: AsyncSession):
self.db = db

async def list_with_posts(self, limit: int = 50) -> list:
# selectinload:两条 SQL(users + posts WHERE author_id IN (...)),无 N+1
stmt = (select(User)
.options(selectinload(User.posts))
.order_by(User.id)
.limit(limit))
return list((await self.db.execute(stmt)).scalars().all())

async def add(self, user: User) -> None:
self.db.add(user)
await self.db.flush() # 发 SQL 拿主键;提交由外层事务上下文负责
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# ============ main.py:路由 + 依赖注入 ============
from fastapi import FastAPI, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from db import async_session
from repository import UserRepository

app = FastAPI()

async def get_db() -> AsyncSession:
async with async_session() as session: # 一请求一 Session
async with session.begin(): # 一请求一事务:成功 commit / 异常 rollback
yield session

@app.get("/users")
async def list_users(db: AsyncSession = Depends(get_db)):
users = await UserRepository(db).list_with_posts()
return [{"id": u.id, "name": u.name,
"posts": [p.title for p in u.posts]} for u in users]
# 请求结束:begin() 上下文自动 commit,Session 关闭,连接还池
1
2
3
4
5
6
7
8
# ============ Alembic 迁移流程 ============
pip install alembic
alembic init -t async migrations # 异步项目用 async 模板
# env.py: target_metadata = Base.metadata(否则 autogenerate 比对不到模型)
alembic revision --autogenerate -m "create users and posts"
# ↑ 生成后必须人工 review(改列名会被误判为 drop+add)
alembic upgrade head # 升到最新
alembic downgrade -1 # 回滚一步(生产也要能 downgrade)
1
2
3
4
5
6
7
8
9
10
11
-- ============ 生产迁移的锁安全写法 ============
SET lock_timeout = '3s'; -- 拿不到锁就失败退出,避免排队阻塞全站查询

-- 加列带常量默认值:PG 11+ 是元数据级操作,秒完成、不重写表
ALTER TABLE orders ADD COLUMN status text NOT NULL DEFAULT 'pending';

-- 大表加 NOT NULL:两步走,避免长时间持排他锁
ALTER TABLE orders ADD CONSTRAINT orders_amount_check
CHECK (amount IS NOT NULL) NOT VALID; -- 只约束后续写入,瞬间完成
ALTER TABLE orders VALIDATE CONSTRAINT orders_amount_check; -- 只拿共享锁,不阻塞写
ALTER TABLE orders ALTER COLUMN amount SET NOT NULL; -- 有已验证 CHECK 兜底,秒过

易错点与追问

症状 / 易错点 原因 修复
commit 后访问属性报错 expire_on_commit=True,过期对象触发隐式 IO 异步下设 expire_on_commit=False,或手动 await refresh
MissingGreenlet 异常 异步下访问了未加载的关系(隐式懒加载) selectinload/joinedload 预加载,或 lazy="selectin"
列表接口越来越慢 N+1:(1+N) 条串行 SQL 预加载;用 echo=True/SQL 日志确认条数
pool_timeout 超时 连接泄漏:Session 未关闭、事务不提交 检查依赖是否 yield+close;调大池参数只是止痛
偶发 deadlock detected 多请求以不同顺序更新同样的行 固定加锁顺序、缩小事务(死锁
迁移执行卡住全站 ALTER 排队等长事务释放锁 lock_timeout + 低峰执行 + 先清理长事务
Alembic 迁移与代码不匹配 迁移未与发布版本对齐 expand-contract 两段式,迁移进 CI
Engine 在请求里创建 每请求建引擎/连接池,连接爆炸 模块级或 lifespan 里只建一次

常见追问链:Session 是连接吗?→ 连接池参数怎么配?(连接与连接池)→ 一个请求 commit 几次?→ N+1 怎么发现?(SQL 日志数条数)→ 异步懒加载报错怎么办?→ 事务边界怎么切?→ 上线怎么做 DDL?(Alembic + lock_timeout)。

相关章节