PostgreSQL 八股文知识库
💡 面向 Python / AI 应用开发岗 面试的 PostgreSQL 知识库,共 24 章。按「面试出现频率 + 实际开发价值」组织。
🚀 学习主线
1 | SQL → JOIN → 索引 → EXPLAIN → 事务 → 隔离级别 → MVCC → 锁 → VACUUM → WAL → 连接池 |
📋 目录
📑 总览
📗 基础篇
📘 原理篇
- 第5章 EXPLAIN 与 SQL 优化
- 第6章 事务 ACID
- 第7章 事务隔离级别
- 第8章 MVCC
- 第9章 VACUUM
- 第10章 锁与并发控制
- 第11章 死锁
- 第12章 WAL与数据库恢复
- 第13章 Buffer与Checkpoint
- 第14章 PostgreSQL查询优化器
📙 进阶篇
- 第15章 分页
- 第16章 PostgreSQL高级SQL
- 第17章 JSONB 与 GIN
- 第18章 主键、唯一约束与外键
- 第19章 数据库设计
- 第20章 连接与连接池
- 第21章 主从复制与高可用
- 第22章 分区表
📕 实战篇
📑 总览
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 八股 |
📚 全部章节
基础篇
- PostgreSQL基础与整体架构 — 进程模型、SQL 执行流程、三值逻辑
- SQL查询基础 — WHERE/HAVING、子查询、CTE、ON CONFLICT
- JOIN与复杂查询 — JOIN 类型与三种物理执行算法
- 索引 — B-Tree/GIN/GiST/BRIN、索引失效场景
原理篇
- EXPLAIN与SQL优化 — 读懂执行计划、慢 SQL 治理
- 事务ACID — PG 如何用 WAL + MVCC + 锁保证 ACID
- 事务隔离级别 — 脏读/不可重复读/幻读、PG 的 RR 与标准 SQL 的差异
- MVCC — xmin/xmax/Snapshot/可见性判断
- VACUUM — Dead Tuple、Bloat、AutoVacuum、TXID 回卷
- 锁与并发控制 — 表级锁/行级锁、SELECT FOR UPDATE
- 死锁 — 检测机制与预防手段
- WAL与数据库恢复 — Write Ahead Logging、Crash Recovery、Checkpoint
- Buffer与Checkpoint — Shared Buffer、COMMIT 后数据到底落盘了吗
- PostgreSQL查询优化器 — 成本模型、统计信息、Join Order
实战篇
- 分页 — 大 OFFSET 的坑与 Keyset Pagination
- PostgreSQL高级SQL — 窗口函数、CTE、LATERAL、DISTINCT ON
- JSONB与GIN — Agent/LLM 应用元数据存储的标配
- 主键唯一约束与外键 — PK vs Unique、互联网项目为什么不用外键
- 数据库设计 — 范式、反范式、UUID vs 自增 ID
- 连接与连接池 — SQLAlchemy Pool、PgBouncer、FastAPI 架构
- 主从复制与高可用 — 流复制、读写分离、Failover
- 分区表 — Range/List/Hash、Partition Pruning
对比与项目篇
- PostgreSQL与MySQL对比 — 「为什么你的项目选 PG」的标准答案
- FastAPI与PostgreSQL实战 — Session、N+1、Alembic,项目面试的落点
🧩 灵魂四件套:串起来学
MVCC → VACUUM → WAL与数据库恢复 → 锁与并发控制 这四章不要单独背答案,画出一条 tuple 的完整生命周期:
1 | INSERT(写入新 tuple, xmin=当前事务) |
一条记录走完这四个阶段,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、RETURNING、FILTER、部分索引等) |
方言化程度更高 |
| 擅长场景 | 复杂查询、分析、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_mem、maintenance_work_mem)。
1 | Client A ──┐ ┌─ backend A (postgres 进程) |
影响(面试加分点):
- 连接 = 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 | SQL 文本 |
要点:
- PG 是基于代价的优化器(CBO):依赖
ANALYZE收集的统计信息 + 代价模型(顺序读/随机读相对成本),详见 PostgreSQL查询优化器。 - 执行器经典是火山模型(逐条 tuple 迭代);PG9.6+ 支持并行查询,PG11+ 对复杂表达式支持 LLVM JIT。
- 预编译语句(prepared statement)会缓存计划,执行多次后优化器可能切换为通用计划——这是常见的追问点。
1.5 数据库、Schema、Table 的关系
1 | 实例/集簇 (PGDATA, database cluster) |
- 同一实例可建多个 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 | -- 老写法 SERIAL(非 SQL 标准):本质是 int 列 + DEFAULT nextval(序列) |
| 对比 | 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 | -- 建表:覆盖常用类型 + IDENTITY 主键 |
易错点与追问
| 易错点 | 正确认识 |
|---|---|
| 把 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(MVCC、VACUUM)→ SQL 为什么跑得慢 → 优化器与统计信息(PostgreSQL查询优化器)。
相关章节
- SQL查询基础:本章数据类型与 NULL 三值逻辑在 SQL 层的直接体现
- MVCC:进程模型之后最重要的底层机制
- WAL与数据库恢复:架构图中 WAL 组件的展开
- PostgreSQL查询优化器:SQL 执行流程第 4 步 Planner 的深入
- 连接与连接池:一连接一进程带来的必然话题
- PostgreSQL与MySQL对比:本章对比表的完整展开
- MOC-学习路线总览:全库学习路线导航
第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 | -- orders.customer_id 里只要有一个 NULL,整条查询返回 0 行 |
性能补一句:现代 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 | WITH recent AS ( |
- 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 | INSERT INTO t (k, v) VALUES (1, 'a') |
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 CONFLICT:ON 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 | -- 业务表:orders(订单),配合第 1 章的 users |
易错点与追问
| 易错点 | 说明 |
|---|---|
在 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 并发安全吗 → 唯一索引 + 行锁保证只有一个插入成功(锁与并发控制)。
相关章节
- PostgreSQL基础与整体架构:三值逻辑、数据类型是本章语法的基础
- JOIN与复杂查询:子查询与 JOIN 的等价改写、半连接与反连接
- 索引:EXISTS/IN 想快,关联列必须有索引
- EXPLAIN与SQL优化:验证 IN/EXISTS、CTE 内联的唯一手段
- 分页:LIMIT/OFFSET 深分页专题
- PostgreSQL高级SQL:窗口函数等进阶写法
- FastAPI与PostgreSQL实战:RETURNING / ON CONFLICT 在应用层的落地
第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 | LEFT JOIN t2 ON (匹配条件) → 左表全保留,右表按 ON 匹配,匹配不上补 NULL |
3.3 三种物理执行方式(重点)
| Nested Loop Join | Hash Join | Merge Join | |
|---|---|---|---|
| 原理 | 外表每行 → 去内表找匹配(内表最好有索引) | 较小的一侧建哈希表(build),扫另一侧探测(probe) | 两侧按连接键排序后归并 |
| 支持条件 | 任意条件(含非等值) | 仅等值(hashable) | 仅 mergejoinable 操作符(实践基本都是等值) |
| 适用场景 | 外表很小、内表连接列有索引;OLTP 点查 | 两侧数据量都大、无可用索引顺序的中大型等值 JOIN | 两侧已有序(索引提供顺序)、或输出要求按连接键有序 |
| 启动成本 | 低,第一行很快返回 | 要先建完哈希表 | 要先完成排序/读数 |
| 内存 | 占用小 | 受 work_mem 限制,超了会分批(batch)落盘 | 排序可能落盘 |
优化器怎么选(面试口径,按顺序判断):
- 外表估算行数很小 + 内表连接列有索引 → Nested Loop(代价 ≈ 外表行数 × 索引点查)。
- 等值 JOIN、两侧都大、没有现成的排序 → Hash Join(代价 ≈ 两侧各扫一遍 + 建哈希表)。
- 等值 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 | -- users (用户) 1 ──── n orders (订单) |
易错点与追问
| 易错点 | 说明 |
|---|---|
| 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 键无约束、估算失真(主键唯一约束与外键)。
相关章节
- SQL查询基础:子查询与 JOIN 的等价改写(IN/EXISTS ↔ 半连接)
- 索引:Nested Loop 快的前提是内表连接列有索引
- EXPLAIN与SQL优化:观察三种物理 JOIN 的窗口
- PostgreSQL查询优化器:连接顺序与算法选择的完整逻辑
- 主键唯一约束与外键:JOIN 键上的约束影响行数估算
- 数据库设计:范式与一对多关系是 JOIN 放大问题的根源
- PostgreSQL基础与整体架构:NULL 三值逻辑解释 LEFT JOIN 补 NULL 的行为
第4章 索引
💡 一句话核心:索引是用空间和写放大换读速度的有序查找结构——PG 默认 B-Tree 把全表扫描变成 3~4 次页访问;走不走索引由优化器按代价决定,「建了索引没用」先查选择性、统计信息和 SQL 写法(函数 / 类型转换 / 前导通配 LIKE)。
概念详解
4.1 什么是索引?为什么快?代价是什么?
- 索引是独立于表的查找结构:
键值 → 行指针(TID = 页号 + 页内偏移)。没有索引只能 Seq Scan 全表扫 O(N);有 B-Tree 索引则是 O(树高) 定位 + 回表。 - PG 是堆表 + 二级索引:索引叶子存的是 TID 而非整行,命中后一般要回表;只有 Index Only Scan 能不回表。
- 索引的三大代价:
- 空间:索引可能接近表本身的大小;
- 写放大:INSERT/DELETE 都要维护每个索引;PG 的 UPDATE 是「插入新版本元组」,被索引引用的列一变,所有相关索引都要新增条目;
- 维护成本:索引膨胀需要 REINDEX,优化器要为更多候选索引做规划。
- 建索引的判断标准:WHERE / JOIN / ORDER BY 高频出现 + 列选择性高 + 写入不太敏感。不是越多越好。
4.2 B+Tree 基本原理
- 多路平衡搜索树:根节点和内部节点只存键做路标,数据指针全部在叶子层(PG 的 B-Tree 叶子存 key + TID),叶子之间用双向链表串联。
- 等值查询:从根走到叶子,O(树高);范围查询:定位到起点叶子后沿叶子链表顺序扫描,所以天然适合
> < BETWEEN ORDER BY。 - 树高估算(面试加分数字):页大小 8KB,键不大时扇出约 100
500;**树高 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?(重点)
一句话结论:索引只是候选方案,走不走由优化器按代价决定。常见原因:
- 表太小:数据只有几页,顺序读一遍比「树高 + 回表随机 IO」更快;
- 选择性低:条件命中 20%~30% 以上的行,大量回表随机 IO 反而比全表顺序扫贵;
- 统计信息过期:没跑 ANALYZE,行数估算失真;
- 回表代价高:
SELECT *且查询列不在索引里,命中行数多时不如全扫; - 写法让索引不可用:函数、运算、类型转换、前导通配 LIKE(见 4.8);
- 代价参数与硬件不匹配: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'前缀未知,所有叶子都可能匹配,只能全索引/全表扫。 - 解决方案:
- pg_trgm 扩展 + GIN 索引(最常用):
CREATE EXTENSION pg_trgm;然后CREATE INDEX ON articles USING gin (title gin_trgm_ops);,%abc%也能走索引; - 纯后缀匹配:存一列
reverse(col)并建索引,查询转成LIKE 'cba%'; - 真正的全文检索需求:tsvector + GIN。
- pg_trgm 扩展 + 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>100、col::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 | -- 表:users / orders / articles |
易错点与追问
| 易错点 | 说明 |
|---|---|
| 索引越多越好 | 每个索引都拖慢写入、占空间;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)→ 表太大索引都救不了 → 分区表(分区表)。
相关章节
- EXPLAIN与SQL优化:验证索引是否生效的第一工具
- PostgreSQL查询优化器:走不走索引的最终裁判(代价模型)
- VACUUM:Index Only Scan 依赖 visibility map;索引膨胀的治理
- MVCC:UPDATE 写放大与索引膨胀的根源
- JSONB与GIN:GIN 索引的专题展开
- 分区表:大表 + 索引的工程化方案
- JOIN与复杂查询:Nested Loop 依赖内表连接列的索引
- 主键唯一约束与外键:唯一约束靠唯一索引实现
📘 原理篇
第5章 EXPLAIN 与 SQL 优化
💡 一句话核心:先看执行计划再动手——EXPLAIN 看估算、EXPLAIN ANALYZE 看真实执行,优化主线是「减少扫描行数」:用对索引、消灭全表扫描和深分页,用 pg_stat_statements 定位慢 SQL。
概念详解
EXPLAIN 与 EXPLAIN ANALYZE 的区别
| EXPLAIN | EXPLAIN ANALYZE | |
|---|---|---|
| 是否执行语句 | 否,只生成计划 | 真的执行 |
| rows / cost | 优化器估算 | 估算 + actual 实际值 |
| 风险 | 无 | SELECT 无副作用;INSERT/UPDATE/DELETE 会真改数据 |
写操作想看真实计划的标准姿势——包在事务里回滚:
1 | BEGIN; |
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 | Index Scan using orders_user_created_idx on orders |
- 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 差一个数量级以上,通常意味着:
- 统计信息过期 → 对表跑
ANALYZE 表名 - 数据倾斜(某值占比异常高)
- 列间强相关,单列统计失真 → PG 10+ 用
CREATE STATISTICS建扩展统计
估算错 → 计划错:该 Hash Join 的选了 Nested Loop、该走索引的走了全表扫。这是”执行计划突然变差 / 同一条 SQL 忽快忽慢”最常见的根因。
SQL 优化手段
- 减少全表扫描:大表的过滤列、JOIN 列建索引
- 创建合适索引:选区分度高、真的会被查询用到的列(见 索引)
- 避免无效索引:列上套函数(建表达式索引)、隐式类型转换、前导通配
LIKE '%x'(换 pg_trgm GIN)、OR两侧都要有索引 - 优化 JOIN:小表驱动大表、连接列类型一致且都有索引、消灭应用层的 N+1 查询
- 避免 SELECT *:减小 width,给 Index Only Scan 留机会,减少网络传输
- 避免大 OFFSET:
OFFSET 1000000 LIMIT 20依然要扫过并丢弃前 100 万行;改用 Keyset Pagination:WHERE 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 | -- 贴近业务的表结构 |
易错点与追问
| 易错点 | 正确理解 |
|---|---|
| 把 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 变化)。
相关章节
- 索引:扫描节点的物理基础,决定该建什么索引、为什么失效
- PostgreSQL查询优化器:cost 模型与计划选择规则的深水区
- 分页:LIMIT/OFFSET 与 Keyset Pagination 的专题展开
- JOIN与复杂查询:JOIN 写法直接决定 Nested Loop / Hash / Merge 的选择
- FastAPI与PostgreSQL实战:应用层接入 pg_stat_statements 与慢查询治理
第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 | BEGIN; -- 或 START TRANSACTION |
要点:
- 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 只回退到存档点,不影响之前已执行的部分,事务仍然继续。用途:
- 部分回滚:一批操作中某一步失败,只撤销这一步
- 框架里的”嵌套事务”:PG 不支持真正的嵌套 BEGIN,SQLAlchemy 的
begin_nested()、Django 的transaction.atomic()嵌套调用底层就是 SAVEPOINT——外层事务照常,内层”子事务”失败只回滚到 SAVEPOINT
1 | BEGIN; |
注意:大量 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 | -- 经典转账事务:全部成功或全部失败 |
易错点与追问
| 易错点 | 正确理解 |
|---|---|
| 以为 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)。
相关章节
- 事务隔离级别:隔离性的展开,三种并发异常与四种级别
- MVCC:原子性与隔离性的底层原理,xmin/xmax 多版本机制
- VACUUM:MVCC 多版本的代价——Dead Tuple 的清理者
- WAL与数据库恢复:持久性的完整闭环,先写日志再刷数据页
- 锁与并发控制:写写冲突的处理,MVCC 之外的另一半并发手段
- FastAPI与PostgreSQL实战:ORM 中事务边界、rollback 与 SAVEPOINT 的工程用法
第7章 事务隔离级别
💡 一句话核心:三种异常(脏读、不可重复读、幻读)层层递进;PG 只有 Read Committed(默认)和 Snapshot Isolation 系的 RR / Serializable——PG 的 RR 基于 MVCC 快照,天然消除了幻读,这是和标准 SQL 最大的不同。
概念详解
三种并发异常:用时间线区分
脏读(Dirty Read):读到了别的事务未提交的数据,对方若回滚,你读到的数据从未存在过。
1 | 时间 事务A 事务B |
不可重复读(Non-repeatable Read):同一事务内两次读同一行,值变了——因为中间别的事务 UPDATE 并提交了。关注点:一行数据的值。
1 | 时间 事务A 事务B |
幻读(Phantom Read):同一事务内同一范围条件查询两次,行数变了——中间别的事务 INSERT/DELETE 并提交。关注点:结果集的行数(”幽灵行”)。
1 | 时间 事务A 事务B |
一句话区分:脏读看”未提交”、不可重复读看”同一行的值”、幻读看”满足条件的行数”。
PostgreSQL 的隔离级别
| 隔离级别 | 说明 |
|---|---|
| Read Uncommitted | PG 中不存在实际效果:设置了也等同 Read Committed(PG 内部根本没有”读未提交”的实现路径,脏读在 PG 不可能发生) |
| Read Committed(默认) | 每条语句开始时取一个新快照。同一事务内能看到别的事务刚提交的数据 → 不可重复读、幻读可能出现 |
| Repeatable Read | 事务开始后取一个快照用到底(Snapshot Isolation)。重复读一致、幻读消失;但可能出现序列化异常(写偏斜,见下) |
| Serializable | 在 SI 基础上加 SSI(可串行化快照隔离),对所有真串行化异常做检测,冲突时报 40001 could not serialize access 需重试 |
1 | SET default_transaction_isolation = 'repeatable read'; -- 会话级改默认 |
重点题: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 | ERROR: could not serialize access due to read/write dependencies |
关键性质:
- 性能远好于两阶段锁:大部分并发场景仍然读写不阻塞,只有”读过的数据随后被改”这种危险模式才触发
- 必须配重试:应用层要捕获 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 | -- 建表与数据 |
易错点与追问
| 易错点 | 正确理解 |
|---|---|
| 认为 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 | 时间线 ─────────────────────────────────────────────────────► |
事务 200 执行 UPDATE users SET age = 21 WHERE id = 1;
1 | [事务 200] |
注意:物理上 age=20 的旧行还躺在表里,只是被打了 xmax 标记。谁来决定读事务看到哪个版本?——Snapshot。
Snapshot 是什么
Snapshot(快照)是读事务获取的一份”事务可见性边界”记录,核心内容:
1 | snapshot = ( xmin snapshot边界, xmax snapshot边界, xip_list ) |
配合 CLOG(commit log,记录每个 xid 是 in-progress / committed / aborted),快照能回答任意事务号的三种状态:已提交、进行中、已回滚。
快照何时取? 由隔离级别决定(衔接 事务隔离级别):
- Read Committed:每条语句开始时取新快照 → 同一事务前后语句看到的世界可以不同
- Repeatable Read / Serializable:事务第一条查询时取快照,全程复用 → 前后一致
可见性判断规则(简化版)
对一个 tuple 版本,判断”我的快照能否看到它”分两步:
第 1 步:xmin 必须对我”已完成可见”
1 | xmin < 快照xmax边界 —— 在我拍快照前就已开始 |
xmin 不满足 → 这个版本在我眼里”还不存在”。
第 2 步:xmax 必须对我”无效”(删除未生效)
1 | xmax = 0 —— 从未被删/改 → 可见 |
用 Snapshot 收尾推演:三个事务,三种答案
数据状态仍是:版本1(xmin=100,xmax=200) + 版本2(xmin=200,xmax=0)。
事务 300 在 200 提交前开启查询:快照 xip_list=[200]
1 | 版本1: xmin=100 已提交 ✓ → xmax=200 在 xip_list(进行中)→ 删除未生效 → 可见 ✓ |
事务 300 在 200 提交后开启查询:快照 xip_list=[],xmax边界=201
1 | 版本1: xmin=100 已提交 ✓ → xmax=200 已提交且 < 201 → 旧版本不可见 |
事务 200 最终回滚了(CLOG: 200 = aborted)
1 | 版本1: xmax=200 aborted → 删除未生效 → 可见 ✓ |
这就是”原子性靠提交状态 + 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(死元组)。它们:
- 占空间:频繁 UPDATE 的表(如计数器、状态字段)膨胀数倍很常见
- 拖慢扫描:Seq Scan、索引扫描都要跳过死元组;索引里新旧版本各有条目,索引也膨胀
- 需要回收: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 | -- 1) 亲眼看见 xmin / xmax / ctid |
易错点与追问
| 易错点 | 正确理解 |
|---|---|
| 认为 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 具体做三件事
- 回收 Dead Tuple 的空间,供本表原地复用:把 Dead Tuple 占用的空间登记为可复用(FSM 空间映射),之后的 INSERT/UPDATE 可以就地使用。注意:空间留给表自己用,一般不还给操作系统(只有文件末尾整段空闲时才可能截断归还,中间的空洞不会移动)。
- 更新 Visibility Map(可见性映射):给每个 8KB 页打上”全可见 / 全冻结”标记。Index-Only Scan(见 索引)依赖它跳过回表;VACUUM 也靠它快速跳过干净页。
- 冻结(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 | SELECT relname, n_live_tup, n_dead_tup, |
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_tables 的 n_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 | -- 场景:电商 orders 表,订单状态被高频更新 |
1 | # Python/FastAPI 侧的配合:短事务纪律 + 事务ID回卷监控 |
易错点与追问
| 易错说法 | 正确理解 |
|---|---|
| “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)、ANALYZE、CREATE INDEX CONCURRENTLY |
SHARE UPDATE EXCLUSIVE | 自相冲突(同时只有一个 VACUUM);不阻塞读写 |
CREATE INDEX(非 CONCURRENTLY) |
SHARE | 阻塞写、不阻塞读 |
ALTER TABLE / DROP TABLE / TRUNCATE / VACUUM FULL |
ACCESS EXCLUSIVE(最强) | 与一切模式冲突,包括 SELECT |
三个最常考的冲突对:
- SELECT vs ALTER TABLE:ACCESS SHARE ↔ ACCESS EXCLUSIVE 冲突 → 建表后一直有查询,
ALTER TABLE就要等所有查询结束。 - DML vs DML:表级都是 ROW EXCLUSIVE,互相兼容;真正的互斥发生在行级锁上 → MVCC 下读写也不互斥(见 MVCC)。
- 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 | T1: 长事务/慢查询持有 users 的 ACCESS SHARE(一直不结束) |
解法(必须会):DDL 前先设置 lock_timeout,拿不到锁就放弃、稍后重试,避免堵队列:
1 | SET lock_timeout = '3s'; -- 只影响当前会话 |
配合运维手段:先查 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 | 悲观锁流程:BEGIN → SELECT stock FROM products WHERE id=42 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 | -- 业务表 |
1 | # FastAPI + SQLAlchemy:两种方案的落地对比 |
易错点与追问
| 易错点 | 正确理解 |
|---|---|
| “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 | 事务A 事务B |
注意前提:单独看每个事务都完全合法,单跑任何一个都不会出问题;死锁是并发时序才暴露的 bug,所以测试环境常复现不出来,线上偶发。
死锁的四个必要条件(数据库语境)
| 条件 | 含义 | PG 中的体现 |
|---|---|---|
| 互斥 | 锁被一个事务持有后,其他事务不能同时持有(写锁) | 行排他锁 |
| 持有并等待 | 已拿到部分锁,还在等剩余的锁 | 事务中途逐条 UPDATE |
| 不可剥夺 | 锁只能由持有者主动释放(COMMIT/ROLLBACK),不能被抢 | PG 行锁不会中途转移 |
| 循环等待 | 等待关系构成环 | 上图 A→B→A |
四个条件同时成立才会死锁。工程上破环最容易下手的就是”循环等待”——统一加锁顺序(见下文)。
PostgreSQL 如何检测死锁
- 每个后端进程在自己被阻塞时启动一个定时器,超过
deadlock_timeout(默认 1000ms)后,唤醒死锁检测:在等待图(谁在等谁)上找环。 - 找到环 → 主动选一个事务作为牺牲者回滚,抛错:
1 | ERROR: deadlock detected |
- 被回滚的是”检测到死锁的那个事务”(自动选择,不是随机挑最大的),应用收到 SQLSTATE
40P01,可以捕获后重试——回滚后环被打破,另一个事务能继续。 - 两个可调参数:
deadlock_timeout:等多久才启动检测。默认 1s 对正常锁等待影响小;调小能更快发现死锁,但在锁竞争激烈时检测本身有 CPU 开销,一般不动。log_lock_waits = on:超过deadlock_timeout的等待都记日志,排查锁问题的常用开关。
- 另外
pg_locks视图 +pg_stat_activity可以实时看谁在等谁(排查”没到死锁程度但互相拖”的锁等待)。
如何避免死锁(面试重点:为什么”统一顺序”有效)
- 统一加锁顺序(最有效):规定所有事务都按同一顺序访问资源,例如”永远先更新 id 小的行”(
WHERE id IN (...) ORDER BY id,或在代码里先排序再操作)。只要顺序全序一致,等待关系只能是”A 等 B”,不可能成环——循环等待条件被结构性破坏,死锁从原理上不可能发生。 - 小事务、短持有时间:事务尽早提交,锁持有时间窗口小,和其他事务交叠的概率小;不要在事务里做 RPC、HTTP、LLM 调用(这对 PG 还有个副作用是长事务阻碍 VACUUM,见 VACUUM)。
- 一次性锁定所需资源:明确知道要改哪些行时,事务开头就用
SELECT ... FOR UPDATE按顺序全部锁住,之后慢慢改,避免”改了一半再去拿新锁”。 - 走索引、减少意外锁行:UPDATE/DELETE 的 WHERE 没走索引时会顺序扫描,可能先锁到不满足条件的行再释放,无谓扩大锁范围和死锁窗口;给过滤条件建索引(见 索引)。
- 应用层兜底:捕获 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 | -- 死锁复现:两个会话按相反顺序更新 users 的两行 |
1 | # FastAPI/SQLAlchemy 侧:捕获死锁并重试的标准模式 |
易错点与追问
| 易错点 | 正确理解 |
|---|---|
| “死锁就是锁等待太久” | 锁等待单向、会自己解开;死锁是环,永不自动解开 |
| “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 | 事务执行 UPDATE 的时间线: |
关键结论:COMMIT 成功 = WAL 已持久化,不代表数据页已持久化。脏页丢失没关系,崩溃恢复会用 WAL 重做;反之如果 WAL 没落盘就写数据页,崩溃后就会出现”磁盘是新页、日志无记录”的无法解释状态。
为什么需要 WAL:三个理由
- 性能:把随机写变成顺序写。不写 WAL 的话,每次 COMMIT 为了持久性必须把涉及的 8KB 数据页立刻随机刷盘;有了 WAL,COMMIT 只需顺序追加、fsync 一小段日志,数据页的随机写被合并、延后给 Checkpoint 批量做。顺序 IO 的吞吐远高于随机 IO,这是”先写日志反而更快”的本质。
- 崩溃恢复的依据:WAL 是所有变更的完备历史,断电重启后重放即可重建内存中丢失的更新。
- 天然是”变更流”:物理复制直接把 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 | 崩溃 → 重启 |
这段有两处高频考点:一是恢复起点是最近一次 Checkpoint(更早的 WAL 已不需要,因为脏页都落盘了),所以 Checkpoint 频率 = 恢复时间 vs 运行时 IO 压力的权衡;二是 PG 不需要 undo,未提交事务的垃圾交给 MVCC + VACUUM(衔接 MVCC、VACUUM),与 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 = on和archive_command,把写满的 WAL 段文件持续归档到外部存储。 - PITR(Point-In-Time Recovery,时间点恢复):恢复 = “基础备份 + 重放归档 WAL 到指定时刻”,可精确到某时间戳/LSN/还原点。典型用途:把误删表恢复到删除前一秒——这是”只靠每天全量备份”做不到的。
- 注意点:归档失败堆积会占满
pg_wal目录(磁盘爆掉),要监控归档延迟;基础备份期间 PG 自动进入 backup 模式记录起始 WAL 位置。
WAL 与主从复制
- 物理流复制:主库把 WAL 记录流式发给备库(
walreceiver接收 → 写入备库pg_wal→startup进程重放),备库是主库的”WAL 重放器”。WAL 就是复制的载体,同步级别由synchronous_commit/synchronous_standby_names控制。 - 逻辑复制:把 WAL 解码成逻辑变更(INSERT/UPDATE 事件,pgoutput 协议)发给订阅端,可跨版本、选择性复制。
- 因此 WAL 相关参数(
wal_level、max_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 | -- ========== 观察 WAL ========== |
1 | # ========== WAL 归档(PITR 的基础设施)========== |
1 | # Python 运维脚本:监控 WAL 堆积与复制槽延迟(长事务/闲置槽会阻碍 VACUUM) |
易错点与追问
| 易错点 | 正确理解 |
|---|---|
| “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 | Backend 进程(每个连接一个) |
- 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 | 1. 用 (tablespace, relation, block) 做 hash 查找 Buffer 映射表 |
- 淘汰策略:Clock Sweep(时钟扫描),近似 LRU 但带
usage_count访问计数——每次访问计数 +1(有上限),扫描时把计数为 0 的页拿走、非 0 的减 1 再放过,避免一次全表扫描把热点页全部冲掉。 - 监控命中率:
pg_statio_user_tables,或CREATE EXTENSION pg_buffercache;直接看每个 buffer 里是什么。
改页流程:
1 | 1. pin 该 buffer + 排他锁 |
Dirty Page(脏页)
定义:在 Shared Buffers 中被修改过、还没有刷到磁盘的页。 内存是新版、磁盘是旧版。
刷脏页的三个角色:
| 角色 | 谁触发 | 特点 |
|---|---|---|
| Checkpointer | checkpoint 触发 | 一次性把全部脏页刷盘,量大 |
| Background Writer | 周期性 | 提前刷”快被淘汰的脏页”,削峰填谷 |
| Backend 自己 | 淘汰 victim 时发现是脏页 | 前台刷,直接拖慢当前查询 |
Checkpoint:做什么、什么时候触发、性能影响
做什么(三件事):
- 把 Shared Buffers 中所有脏页写到磁盘(数据文件恢复到最新状态);
- 在 WAL 中写入一条 CHECKPOINT 记录,记录当时的 redo 起点LSN;
- 更新
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% 内存,其余大部分留给 OS,effective_cache_size设为”Shared Buffers + OS 可用缓存”的估计值(它不分配内存,只告诉优化器”索引页大概率在缓存里,随机读没那么贵”)。 - 若 shared_buffers 给得过大:双重缓存 + PG 自持内存换页,反而整体效率下降。
经典题:COMMIT 之后数据页写到磁盘了吗?
1 | 时间线 |
结论: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 | -- 1. 查看核心参数(会话级演示,生产请改 postgresql.conf) |
易错点与追问
常见误区对比:
| 误区 | 真相 |
|---|---|
| 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 | COMMIT 后数据落盘了吗? |
相关章节
- 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 | 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) × 选择性。这就是EXPLAIN里rows=...的来源。 - 组合条件遵循独立性假设:
A AND B的选择性 = sel(A) × sel(B)。两个条件若实际强相关(如city='北京' AND district='朝阳'),乘积估算会严重偏小——这正是扩展统计CREATE STATISTICS(多列相关性统计)要解决的问题。
Seq Scan vs Index Scan:成本权衡
直觉模型:从 1000 万行的表取 5 万行(0.5%)——
1 | Seq Scan: 顺序读全部页 → 10万页 × 1.0 ≈ 100000 成本(顺序读非常便宜) |
- 经验阈值:取回占比超过百分之几(取决于表大小、相关度、硬件),全表扫往往更划算——索引的价值在于”少访问页”,一旦要访问的页太多,随机 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 扩展),而是给它喂对信息:
- 表统计是最新的(及时/手动
ANALYZE,大批量导入后尤其重要); - 别对索引列做运算或函数包裹(
WHERE upper(name)=...用表达式索引,或改写 SQL); - 连接列、排序列类型一致(隐式类型转换会让索引失效);
- 合理索引本身(覆盖 Index Only Scan 场景、符合最左前缀);
- SSD 上把
random_page_cost调小、effective_cache_size设真实,成本模型才符合硬件; - 选择性差的列适当调大
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 | -- 建一张业务表并制造数据(用户订单) |
易错点与追问
常见误区对比:
| 误区 | 真相 |
|---|---|
| “建了索引就必须走索引” | CBO 按成本选;取回占比大时全表扫更便宜,不走索引是对的 |
| “EXPLAIN rows 数字是实际行数” | 是统计估算值,和实际差很多说明统计过期或独立性假设失效 |
| “没走索引 = 优化器 bug” | 先查三类原因:估算偏差、SQL 写法、成本本来就高 |
| “ANALYZE 会在写入时自动精确更新” | 靠 autovacuum 触发,阈值 10% 变更,大表上非常迟钝 |
| “PG 支持 Oracle 式 hint” | 核心不支持 hint;需要 pg_hint_plan 扩展,首选仍是喂好统计和索引 |
| “多条件 AND 选择性是相乘” | 优化器确实这么算(独立性假设),但条件相关时会严重低估 → 多列统计 |
面试官常见追问链:
1 | 为什么有索引不走? |
相关章节
- 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 | OFFSET 100000 LIMIT 20 的实际执行: |
Keyset Pagination(键集/游标分页,也叫 seek method)
思路:不数行数,记住上一页最后一行的排序键,下一页直接”从它后面”开始。B-tree 索引可以直接 seek 到该位置,无需跳过任何行。
1 | -- 第一页(id 是主键,降序 = 最新的在前) |
- 复合索引
(id)或按其他列排序时对应索引 → 定位成本 O(log n),取 20 行成本恒定 → 第 1 页和第 10 万页一样快。 - 上一页同理(方向取反再翻回来),适合”无限下拉”流。
多列排序的 Keyset:行构造器(Row Constructor)
常见需求是 ORDER BY created_at DESC,但 created_at 有重复值,必须再加唯一列兜底。PG 支持行构造器比较,逐列比较、第一处不等即定结果:
1 | -- 第一页 |
(a, b) < (x, y)等价于a < x OR (a = x AND b < y),但写法简洁且优化器执行更高效。- 配套索引:
(created_at DESC, id DESC)或(created_at, id)(B-tree 可双向扫描,方向可反向利用)。 - 规则:游标列与排序列完全一致、方向一致、最后一列必须唯一(id 兜底)。
Keyset 的局限
- 不能随机跳页:只能顺序”下一页/上一页”,不知道总页数,也不能直接去第 50 页。后台管理类”点页码”的需求仍常用 OFFSET(或干脆缓存总数据做估算)。
- 排序列必须可比较、单调稳定:按”会变的值”分页(如按点赞数)语义会漂移——翻页途中数据变了,游标条件可能漏行。
- 游标需要对前端不透明/不易篡改时,要编码(见下节),实现比 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 | # FastAPI 路由(示意) |
要点:多取 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 | -- 真实业务表:聊天消息 |
易错点与追问
常见写法问题对比:
| 写法 | 问题 | 修正 |
|---|---|---|
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 探测) |
LIMIT 无 ORDER BY |
结果顺序不保证,每页内容可能交叉 | 任何分页必须显式 ORDER BY |
面试官常见追问链:
1 | OFFSET 深分页为什么慢? |
相关章节
- 索引 —— 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 w 配 WINDOW w AS (ORDER BY dt)。
经典题:每个部门工资最高的前 N 名
为什么 GROUP BY 做不了:GROUP BY dept_id 后每部门只剩一行,聚合函数(MAX)只能给”最高工资是多少”,取不出”拿这些工资的明细行(人名、工号)”,更别说前 3 名。标准解法是窗口函数:
1 | SELECT dept_id, emp_name, salary |
要点:子查询里先算 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 | -- 定义与查询 |
常用配套: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 | -- 从某经理出发,找出所有下属(任意层级) |
执行过程时间线:锚定查询跑一次 → 结果放入工作表 → 递归部分对工作表迭代 → 新结果继续入表 → 某轮产出空集则停止。防循环两手: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 | -- 每个部门工资最高的 3 名(与窗口函数版等价,大表上常更快) |
没有 CROSS JOIN LATERAL 时子查询只能独立求值一次;加 LATERAL 后它变成”参数化子查询”。 unnest、generate_series 也常放在 LATERAL 位(对每行展开数组)。
DISTINCT ON:PG 特色,每组取第一行
PG 扩展语法:DISTINCT ON (expr, ...) 保留按 ORDER BY 排序后每组(expr 相同)的第一行。最典型应用——每个用户最新一条消息:
1 | SELECT DISTINCT ON (user_id) |
- 约束:
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 | -- 0. 业务表与数据:员工-部门(贯穿全章) |
易错点与追问
易混淆对比:
| 对比项 | 关键差异 |
|---|---|
| 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 | 排名并列怎么处理? |
相关章节
- 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
json 和 jsonb 是两种不同的类型,不是”格式开关”:
| 维度 | 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 | -- 嵌套对象:两种等价写法 |
GIN 索引:为什么 B-Tree 不行
- B-Tree 要求被索引值有序、定长、可比较,而 jsonb 是一个无序、变长的复合”黑盒”,B-Tree 只能对整块值排序,无法回答”谁的 model 字段等于 gpt-5”这类问题。
- **GIN(Generalized Inverted Index,倒排索引)**一句话原理:把 jsonb 拆成内部元素(键、值、键值对),为每个元素维护一张”元素 → 含该元素的行指针列表”的倒排表;查询
@>、?时先查倒排表定位候选行,再回表校验。
1 | -- 默认 jsonb_ops:支持 @>、?、?|、?& |
- jsonb_ops(默认):支持的运算符全,索引更大;
- jsonb_path_ops:只存键值对的 hash,索引显著更小、查询更快,代价是不支持
?系列。 - 附带特性:GIN 默认开启
fastupdate,更新先进 pending list 批量合并写入(gin_pending_list_limit默认 4MB),写友好但查询偶发抖动。
表达式索引:只查固定字段的更优解
如果永远只按 model 这几个固定字段查,没必要为整个 jsonb 付 GIN 的存储与更新代价,B-Tree 表达式索引更省:
1 | CREATE INDEX idx_messages_model ON messages ((metadata->>'model')); |
查询时写出一模一样的表达式才能命中索引: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 | -- 1) Agent 会话消息表 |
1 | # SQLAlchemy 2.0 中使用 JSONB |
易错点与追问
| 易错点 / 追问 | 正确认识 |
|---|---|
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 | CREATE TABLE employees ( |
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;删除被引用的父行会被阻止或级联。
代价(互联网项目权衡的核心):
- 写入开销:每次 INSERT/UPDATE 子表都要到父表索引上做一次引用检查,并在父行上拿 KEY SHARE 行锁;批量导入、数据迁移时开销放大。
- 锁与死锁风险:外键检查引入跨表行锁,锁竞争面变大,容易参与死锁(见 死锁);
ALTER TABLE ... ADD FOREIGN KEY建约束校验存量数据时要加锁阻塞写入(可用NOT VALID+VALIDATE CONSTRAINT缓解)。 - 分库分表不兼容:水平拆分后父子表可能不在同一实例,数据库无法跨库校验。
级联行为(ON DELETE / ON UPDATE):
| 行为 | 效果 |
|---|---|
| NO ACTION(默认) | 存在引用则报错(约束可延迟到事务结束检查) |
| RESTRICT | 立即检查、存在引用立即报错,不可延迟 |
| CASCADE | 级联删除 / 更新子表中的引用行 |
| SET NULL / SET DEFAULT | 把子表引用列置 NULL / 默认值(列须可空或有默认值) |
重点:为什么很多互联网项目不用数据库外键?
标准答法是”先说结论,再说理由,最后主动补代价”:
- 性能:每次写子表多一次父表索引查找,高并发写入、批量导入场景开销明显。
- 锁与死锁:外键检查引入跨表行锁,增大锁冲突和死锁概率。
- 分库分表:水平拆分后外键跨库失效,约束名存实亡。
- 应用层统一保证:服务层校验、事务内双写、异步对账兜底,逻辑集中、可测试、可灰度。
- 运维灵活性:批量修数、归档、迁移不受约束牵制,DDL 变更更轻。
但不用外键是有代价的:可能出现孤儿行,一致性完全依赖代码质量。成熟团队的补偿手段:应用层强校验 + 定期对账脚本 + 关键报表做孤儿数据监控。加分点:小团队、内部系统、数据正确性优先的场景,直接用外键兜底反而更稳——能说出”什么时候该用”比只会说”不用”更值钱。
ON DELETE CASCADE 的使用与风险
CASCADE 让”删父行 → 自动删子行”一条 SQL 搞定,适合明确的生命周期从属关系(如删除 users 时清理其 sessions)。风险:
- 连锁误删:删一行用户 → 删会话 → 删消息 → …,层级深时影响面远超预期,且是物理删除、难以恢复;
- 大范围级联删除持锁时间长,阻塞并发业务。
生产建议:默认 ON DELETE RESTRICT(阻止误删),需要级联的场景在应用层显式执行(常配合逻辑删除),并对每一层 CASCADE 做代码评审。
唯一约束 + 逻辑删除的经典冲突
逻辑删除(打 deleted 标记)后,用户注销再重新注册同邮箱,UNIQUE (email) 会拒绝——因为”已删除”的旧记录还占着唯一值。
标准解法:partial unique index(部分唯一索引)——只对未删除行保证唯一:
1 | CREATE UNIQUE INDEX uniq_employees_email_alive |
它本质是带 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 | -- 约束与级联综合示例 |
1 | # SQLAlchemy 2.0 模型写法 |
易错点与追问
| 易错点 / 追问 | 正确认识 |
|---|---|
| 以为 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 原理。
相关章节
- 索引:partial unique index、隐式唯一索引的实现基础。
- 锁与并发控制:外键检查引入的跨表行锁。
- 死锁:外键锁参与下的典型死锁场景。
- 数据库设计:主键选型(自增 vs UUID)、逻辑删除建模。
- PostgreSQL与MySQL对比:堆表 vs 聚簇索引、约束实现差异。
第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 | created_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 可枚举,暴露业务量 | 不可枚举,对外更安全 |
| 分布式 | 单库自增,分库分表会冲突 | 天然全局唯一 |
折中方案:
- 时间有序 UUID(UUIDv7):前缀是毫秒时间戳,兼顾全局唯一与趋势有序;PG 18 内置
uuidv7(),低版本可在应用层生成(Python 的 uuid6/uuid7 库)。 - 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 | -- 用户-角色 多对多 + 规范时间字段 + 软删除 + 双 ID |
1 | # SQLAlchemy 2.0:多对多声明 + UUID 列 |
易错点与追问
| 易错点 / 追问 | 正确认识 |
|---|---|
| 把范式当银弹 | 范式是起点不是终点,读路径该冗余就冗余,但要能补偿 |
| 忽略 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。
为什么需要连接池
- 消除建连开销:TCP 握手 + 认证 + 进程 fork 的成本,被成百上千次请求摊薄;
- 限制并发:池的上限就是打到数据库的最大并发,保护 PG 不被打垮;
- 可控排队:拿不到连接时应用侧排队(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_ping 与 recycle 都是对抗”数据库还好着、中间网络把空闲连接掐了”的手段:前者即时检测,后者定期换血,常组合使用。
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 | FastAPI (uvicorn, N 个副本) |
常见坑(排查向)
- max_connections 打满:现象是
too many clients already。用pg_stat_activity按状态、application_name、client_addr聚合看连接分布;解法是降应用池参数 + 上 PgBouncer,而不是无脑调大 max_connections。 - Idle In Transaction:事务开着不提交也不回滚——占坑连接,还阻塞 vacuum 回收死元组(衔接 VACUUM)。用
pg_stat_activity的state = 'idle in transaction'找元凶,配idle_in_transaction_session_timeout兜底。 - 连接泄漏:Session 忘了 close,池被慢慢耗尽,最终大面积
TimeoutError。FastAPI 里用 yield 依赖保证请求结束必然归还。 - 网络断连:云上 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 | # 异步版:FastAPI + SQLAlchemy 2.0(推荐形态) |
1 | # 同步版(脚本 / 传统部署),参数语义完全一致 |
1 | ; PgBouncer 最小配置 /etc/pgbouncer/pgbouncer.ini |
1 | -- 连接排查三件套 1:连接状态分布 |
易错点与追问
| 易错点 / 追问 | 正确认识 |
|---|---|
| 以为 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 怎么来的 → 连接泄漏如何定位 → 故障切换后连接池怎么办。
相关章节
- PostgreSQL基础与整体架构:postmaster 进程模型是本章一切开销的根源。
- 事务ACID / 事务隔离级别:Session 与事务边界、idle in transaction 的由来。
- VACUUM:长事务 / 空闲事务阻塞死元组回收。
- 主从复制与高可用:故障切换后连接池的重连与读写路由。
- FastAPI与PostgreSQL实战:依赖注入 Session 的完整工程实践。
第21章 主从复制与高可用
💡 一句话核心:PG 复制的载体就是 WAL——主库把 WAL 实时流给从库重放;默认异步复制快但主库宕机可能丢最后几毫秒数据,同步复制不丢数据但写延迟高;读多写少就做读写分离(注意写后读问题),主库挂了做 Failover 提升从库并防脑裂。
概念详解
复制原理:主从之间传的就是 WAL
1 | ┌──────────────────┐ 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 复制延迟
原因(按面试好记的顺序):
- 重放是单进程串行的:主库多连接并发写,从库只有一个 startup 进程按序重放,写高峰天然追不上;
- 大事务:主库跑 10 分钟的批量 UPDATE,从库也要重放 10 分钟;
- 从库硬件规格差,或在从库上跑了很重的分析查询抢占 IO;
- 网络带宽不足。
危害:读写分离下读到旧数据(刚下的单在列表里看不到);lag 越大,Failover 时丢的数据越多。
缓解:监控 pg_stat_replication 的 replay_lag 并告警;大事务拆小、分批提交;从库机器规格对齐主库、从库查询限流;一致性敏感的读强制走主库。
Read Replica 读写分离
- 写走主库,读分散到多台从库——读多写少业务(列表页、详情页、报表)收益最大。
- 路由实现:应用层配主/从两套 DSN 自己切(Python 项目最常见),或用代理件——注意 Pgpool-II 才做读写分离;PgBouncer 只做连接池,不做读写分离(见 连接与连接池)。
- 核心难题:写后立刻读(Read Your Own Writes)。用户发帖后马上刷新列表,读走从库可能还没同步到这条数据。
- 方案:写操作后的一小段时间内把该用户会话的读”粘”在主库;关键路径读写主库;或按
replay_lag阈值决定本次读能否走从库。
- 方案:写操作后的一小段时间内把该用户会话的读”粘”在主库;关键路径读写主库;或按
Failover 故障切换与脑裂
- 手动切换流程:确认主库确实挂了 → 从库执行
pg_ctl promote(或 SQLSELECT 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 | -- ============ 主库配置(postgresql.conf)============ |
1 | # ============ 从库搭建:基于主库全量物理备份 ============ |
1 | -- ============ 监控复制状态 ============ |
1 | -- ============ 手动 Failover 演练 ============ |
易错点与追问
| 易错点 / 混淆 | 正确认识 |
|---|---|
| “从库也能写” | 从库只读(Hot Standby),写报错;要可写必须先 promote |
| “PgBouncer 能做读写分离” | 不能,PgBouncer 只管连接池;读写分离靠应用路由或 Pgpool-II |
| “同步复制能消除复制延迟” | 同步只保证”不丢已提交事务”,不消除 lag,且同步从库故障会卡写 |
| “复制槽是免费的” | 槽会阻止 WAL 回收,从库长期宕机把主库磁盘撑爆 |
| “failover 想切就切” | 必须先 fence 旧主再提升,否则双主脑裂;PG 内核无自动 failover |
| 流复制 vs 逻辑复制 | 流=物理、整实例、不能跨大版本;逻辑=表级发布订阅、可跨版本、不复制 DDL/序列 |
常见追问链:复制传什么?(WAL)→ 为什么异步会丢数据?(commit 不等从库)→ 丢了怎么办?(同步复制/业务兜底)→ 复制是实例级还是表级?(物理 vs 逻辑)→ 你们生产怎么做 failover?(Patroni / 云 RDS 高可用 + 演练)。
相关章节
- WAL与数据库恢复 — 复制的载体就是 WAL,先懂崩溃恢复再懂复制
- Buffer与Checkpoint — commit 时 WAL 落盘的语义,同步/异步的分界点
- 连接与连接池 — 读写分离下应用如何管理主/从两套连接
- PostgreSQL基础与整体架构 — walsender 等后台进程在架构中的位置
- PostgreSQL与MySQL对比 — 与 MySQL binlog 复制的横向对比
第22章 分区表
💡 一句话核心:分区表 = 逻辑上一张表、物理上多张子表;两大价值——分区裁剪让带分区键的查询只扫命中的”小表”、DROP 分区秒级归档历史数据;前提是查询必须带分区键、主键必须包含分区键,条件不满足时宁可不分。
概念详解
什么是分区表
逻辑上是一张表(对应用透明,SQL 不用关心子表名),物理上是多张独立的子表(partition):
1 | logs 表(父表,只是一个逻辑入口) |
- 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。
为什么分区:三个核心收益
- 大表变小表:单表 5 亿行时 B-Tree 索引 4~5 层深、缓存命中率差;按月分区后热分区(当月)只有几百万行、索引浅、常驻内存,冷分区几乎不被访问。每个分区有独立的索引,写入也分散。
- Partition Pruning 分区裁剪:
WHERE created_at >= '2026-08-01'时优化器直接排除不相关分区,执行计划里只出现命中的分区——这是分区表性能收益的直接来源,必须用 EXPLAIN 验证(EXPLAIN与SQL优化)。 - 归档/删除按分区做:下线一年前的数据用
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 | -- ============ 1. 按月 Range 分区建表 ============ |
易错点与追问
| 易错点 / 混淆 | 正确认识 |
|---|---|
| “分区表查询一定更快” | 只有带分区键的查询才裁剪;不带分区键等于全分区串一遍,更慢 |
| “主键只用 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 回卷这类专属问题(MVCC、VACUUM);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 有四个具体理由:
- AI 场景硬需求:RAG 的向量检索直接用 pgvector(HNSW 索引),文档元数据(来源、页码、租户、自定义字段)用 JSONB + GIN 存查一体,”先按元数据过滤、再做向量检索”在一条 SQL 里完成,不用在两套存储之间同步数据。
- 复杂查询能力:会话统计、去重、排行这类需求,窗口函数、CTE、LATERAL 写起来简洁清晰;MySQL 8.0 之前连窗口函数都没有。
- 一致性与工程质量:DDL 可以放进事务回滚,迁移失败能整体撤销;约束体系强,比如”同一用户同一资源只允许一条激活记录”用部分唯一索引一条 DDL 就解决。
- 生态契合: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 | -- ============ JSONB + GIN:Agent 元数据存查一体(PG 强项)============ |
易错点与追问
| 易错说法 | 正确说法 |
|---|---|
| “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)。
相关章节
- MVCC / VACUUM — MVCC 实现差异是所有对比题的母题
- JSONB与GIN — 选 PG 最硬的理由之一,算子与索引细节在这里
- 索引 — GIN/GiST/BRIN/部分索引是 PG 的索引优势项
- PostgreSQL高级SQL — 窗口函数、CTE、LATERAL 的具体用法
- 连接与连接池 — 进程模型 → 连接贵 → PgBouncer 的因果链
- FastAPI与PostgreSQL实战 — 把选型理由落到代码与工程实践
- PostgreSQL基础与整体架构 — 进程模型的底层细节
第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 | # db.py —— 引擎与 Session 工厂(模块级,全局只建一次) |
- 为什么用 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错误。 - 出路只有三条:
- 查询时显式预加载(
selectinload/joinedload); - 需要时
await session.refresh(obj, ["posts"]); - 关系上声明
lazy="selectin"(查主表自动带出)或lazy="raise"(忘了预加载就报错,把问题提前暴露到开发期)。
- 查询时显式预加载(
N+1 问题:异步下更致命
- 定义:查列表用 1 条 SQL,再对每条记录逐个查关联,共 1+N 条 SQL。
- 为什么异步更致命:
- 每条查询都是一次独立网络往返,而且常被写成
for u in users: await u.posts式串行 await,延迟线性累加((1+N) × RTT); - 事件循环是单线程的,串行等待会把整个服务的并发吞吐拖垮;
- 同步时代 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 migrations→env.py里配target_metadata = Base.metadata→alembic revision --autogenerate -m "..."生成脚本 →alembic upgrade head/downgrade -1。 - autogenerate 不是万能的:它只对比模型与库结构——改列名会生成 drop+add(丢数据,必须手工改成 rename);不感知纯数据迁移;类型变更检测依赖配置。生成的脚本必须人工 review。
- 生产注意点:
- 先备份;
ALTER TABLE拿 ACCESS EXCLUSIVE 锁:长事务会阻塞它、它也会阻塞这张表的一切查询——低峰执行,并先SET lock_timeout = '3s'拿不到锁就快速失败;- 加列带常量默认值(PG 11+)是元数据级操作、秒完成不重写表;但加 NOT NULL(需全表扫描验证)和函数默认值仍可能长锁——NOT NULL 用
ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID+VALIDATE CONSTRAINT两步走(VALIDATE 只拿共享锁、不阻塞写); - 迁移与代码版本对齐(先迁库再发代码,或 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 | # ============ models.py:模型与关系(防 N+1 意识)============ |
1 | # ============ repository.py:数据访问层 ============ |
1 | # ============ main.py:路由 + 依赖注入 ============ |
1 | # ============ Alembic 迁移流程 ============ |
1 | -- ============ 生产迁移的锁安全写法 ============ |
易错点与追问
| 症状 / 易错点 | 原因 | 修复 |
|---|---|---|
| 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)。
相关章节
- 连接与连接池 — “Session ≠ 连接”的另一半:Pool 与 PgBouncer
- 事务ACID — 事务边界设计的数据库侧原理
- 锁与并发控制 / 死锁 — 长事务与加锁顺序的实际后果
- VACUUM — 为什么必须控制事务长度
- EXPLAIN与SQL优化 — 发现 N+1 与慢 SQL 的手段
- JSONB与GIN — FastAPI 里 JSONB 字段的建模与查询
- PostgreSQL与MySQL对比 — SQLAlchemy 对 PG 适配更好的选型背景
