Skip to content

PostgreSQL 深入浅出

作者:Atom
字数统计:108.7k 字
阅读时长:411 分钟

这是一篇尽量讲透的 PostgreSQL 长文。它面向两类读者: 一类是几乎没碰过数据库、连“表”和“行”都还陌生的初学者; 另一类是会写些增删改查、但一遇到索引失效、事务死锁、慢查询就发怵的开发者。无论你属于哪一类, 这篇文章的目标只有一个——带你从零出发, 一路走到能独立设计、优化并运维一套生产级 PostgreSQL 数据库。

全文遵循一条由浅入深的主线。我们先弄清楚 PostgreSQL 到底是什么、凭什么值得学; 再把它装起来、连上去, 用最基础的 SQL 走通增删改查; 然后逐步深入到数据类型、复杂查询、连接与窗口函数这些日常硬功夫; 接着进入真正拉开水平差距的部分——建表设计、索引原理、事务与 MVCC、查询优化; 最后落到视图与分区、JSON 与全文检索、备份复制与高可用、安全与运维这些生产课题, 并以一个完整的电商订单系统实战收尾, 把前面所有最佳实践串成一条可以照着落地的线。

这篇文章有几个刻意的写法, 先说在前面, 方便你阅读:

  • 每个新名词在首次出现时就地讲清楚。看到形如“名词: ……”的提示框, 那是在解释一个刚冒出来的概念, 不必跳走查资料。
  • 案例贯穿始终。全文围绕一套电商与博客的表(usersordersorder_itemsproductsposts 等)展开, 你会看到同一组表在不同章节被反复使用、逐步演进, 而不是每节都换一套互不相干的例子。
  • 示例尽量可直接运行。给出的 SQL 大多贴近真实业务, 并尽量附上具体数据与预期结果, 跑一遍远胜过读十遍。
  • 不回避坑。哪里容易错、哪种写法会超卖、哪个参数调错会出事, 都会专门标出来。

本文的版本基准

全文以 PostgreSQL 18(截至 2026 年的当前稳定大版本)为基准。涉及某个特性是从哪个版本引入时, 文中会专门注明, 方便你对照自己手上的版本。如果你用的是 14 到 17 的某个版本, 绝大多数内容同样适用。

怎么读这篇文章

如果你是初学者, 建议从头顺序读, 每章的代码都亲手敲一遍; 如果你已有基础, 可以直接跳到感兴趣的章节, 文中的交叉引用(例如“详见第十章”)会帮你按需回溯前置知识。准备一个能随时执行 SQL 的环境——第二章会教你用 Docker 在几分钟内搭好——边读边练, 效果最好。

下面正式开始。

一、初识 PostgreSQL: 它是什么、为何选它

如果你打算认真学一门数据库, 并且希望这门手艺在未来十年里都不过时, PostgreSQL 是少有的几个值得长期下注的选择。它不是最容易上手的那个, 也不是营销声量最大的那个, 但它几乎是“工程师们用得越久越离不开”的那一类。这一章我们不写一行业务 SQL, 而是把这件事讲清楚: PostgreSQL 到底是什么、它从哪里来、它凭什么、它适合干什么不适合干什么, 以及你接下来该按什么路线把它学到精通。

1.1 一句话定位: 它是“对象-关系型”数据库

先给一个准确的定位: PostgreSQL 是一个开源的对象-关系型数据库管理系统(Object-Relational Database Management System, 简称 ORDBMS)。

注意“对象-关系”这四个字, 这不是营销词, 而是它和传统关系型数据库(比如经典认知里的 MySQL)在设计哲学上的根本分野。

名词: ORDBMS

ORDBMS(对象-关系型数据库管理系统)是在经典关系模型(表、行、列、SQL)之上, 引入了面向对象数据库的若干能力: 自定义数据类型、类型继承、数组与复合类型、可编程的函数与运算符重载等。简单说, 它既保留了关系型数据库严谨的表结构与 SQL 查询能力, 又允许你像扩展一门编程语言那样去扩展数据库本身。

“关系型”这半边好理解: 数据按二维表组织, 表与表之间通过外键建立关系, 用 SQL 做查询, 用事务保证一致性。这是 1970 年代以来关系数据库的共同地基。

“对象”这半边才是 PostgreSQL 的个性所在。它意味着:

  • 你可以定义自己的数据类型。比如电商里的“金额带币种”, 你能造一个 money_with_currency 复合类型, 而不是用两个散落的列。
  • 你可以给类型定义运算符。比如让两个地理坐标之间能直接用某个运算符算距离。
  • 数据库内置了远超标准 SQL 的类型: 数组(integer[])、范围类型(int4rangetsrange)、网络地址(inetcidr)、几何类型、JSON/JSONB、全文检索的 tsvector 等等。
  • 你可以用多种语言(PL/pgSQLPL/PythonPL/Perl 等)写存储过程与函数, 把计算下推到离数据最近的地方。

这种“可被使用者深度扩展”的特性, 是它能在四十年里不断吞下新需求(地理信息、时序、向量检索)而不需要推倒重来的根本原因。后面讲到生态时你会更直观地体会到这一点。

1.2 它从哪里来: 从 POSTGRES 到 PostgreSQL

了解一个系统的来历, 往往能帮你理解它为什么是今天这个样子。PostgreSQL 的血脉相当“正统”。

故事起点在 1970 年代的加州大学伯克利分校(UC Berkeley)。图灵奖得主 Michael Stonebraker 主导了关系型数据库的早期奠基项目 Ingres。到了 1986 年, 他启动了一个新项目, 目标是解决 Ingres 这一代关系数据库的局限——尤其是数据类型不够丰富、无法支持复杂应用。这个新项目叫 POSTGRES, 意思是“Post-Ingres”(Ingres 的后继者)。“对象-关系”的理念正是从这里开始的。

时间线大致是这样:

时间里程碑
1986伯克利启动 POSTGRES 项目, 首倡对象-关系理念
1994两位伯克利学生为 POSTGRES 加入了 SQL 解释器, 项目更名为 Postgres95
1996项目正式更名为 PostgreSQL, 以体现它对 SQL 的支持, 同时纪念 POSTGRES 血统, 此后由开源社区接管
此后至今由全球分布的开发者社区(PostgreSQL Global Development Group)驱动, 每年发布一个大版本

关于名字怎么读

官方推荐的读法是 “post-gres-Q-L”(波斯特-格瑞斯-Q-L)。社区里很多人干脆简称它 “Postgres”, 这也是官方认可的昵称。所以你在文档、博客、招聘 JD 里看到 “Postgres” 和 “PostgreSQL”, 指的是同一个东西。

版本号规则: 一年一个大版本

PostgreSQL 的版本号在 10.0 之后做过一次重要调整, 这是新手常踩的认知坑, 值得专门说清。

  • 在 9.x 时代, 版本号是三段式的: 9.49.59.6。这里 9.6 相对 9.5 才是一次大版本升级, 而第三位(如 9.6.3)才是小版本(补丁)。这让很多人误以为 9.6 只是 9 的小更新。
  • 从 2017 年的 10 开始, 改成了两段式: 第一位是大版本号, 一年递增一次(101112 ……一直到现在的 18); 第二位才是补丁版本(如 18.118.2)。

所以记住这个规律: 从 10 起, 主版本号每年加一, 当前(2026 年)的稳定版本是 PostgreSQL 18。本教程全程以 18 为基准, 凡涉及版本差异的地方, 我会明确注明某特性“从哪个版本引入”。

大版本升级不是小事

两个大版本之间(比如 17 升 18), 存储格式可能变化, 不能简单替换二进制文件就完事, 需要用 pg_upgrade 或逻辑复制等手段迁移。而同一大版本内的补丁升级(18.118.2)则只含 bug 修复与安全补丁, 不改存储格式, 可以放心快速升级。生产环境务必养成“及时打补丁、谨慎升大版本”的习惯。

开源协议与社区模式

PostgreSQL 使用的是 PostgreSQL License, 这是一个类似 MIT/BSD 的极度宽松的开源协议。它的核心含义是: 你可以免费使用、修改、再分发, 甚至用于闭源的商业产品, 几乎没有附加义务(只需保留版权声明)。

这一点非常关键, 也是它生态如此繁荣的制度基础。正因为协议宽松, 大量商业公司可以放心地基于 PostgreSQL 构建自己的产品(后面会讲到的 Supabase、Citus、TimescaleDB 都是), 而不必担心被协议“传染”。

同样重要的是它的治理模式: PostgreSQL 没有单一的母公司控制, 而是由一个全球分布的核心团队和成百上千的贡献者共同维护, 决策公开、邮件列表透明。这意味着它不会因为某家公司被收购、改变商业策略或倒闭而“变质”(关系型数据库历史上这种事并不少见)。对要把数据托付给它的工程师来说, 这种“没有单点拥有者”的中立性, 本身就是一种长期可靠性的保障。

1.3 核心特性总览

下面逐条展开 PostgreSQL 最值得说的几项核心能力。这些概念后续章节都会深入, 这里先建立全局认知。

标准 SQL 合规度高

PostgreSQL 对 SQL 标准(SQL:2023 等)的遵循程度, 在开源数据库里是名列前茅的。这意味着你在这里学到的 SQL, 大多是“正统”写法, 迁移到其它合规数据库时心智负担小; 反过来, 一些数据库自创的“方言”习惯, 在 PostgreSQL 里反而行不通。

它很早就完整支持了诸如窗口函数(OVER (PARTITION BY ...))、公用表表达式(WITH / CTE)、递归查询、LATERAL 连接、FILTER 子句、GROUPING SETS 等高级 SQL 特性——这些都是写复杂分析查询时的利器, 我们会在第五到第七章专门讲。

ACID 事务: 数据正确性的底线

PostgreSQL 是严格的 ACID 事务型数据库。

名词: ACID

ACID 是衡量事务可靠性的四个属性: 原子性(Atomicity, 一组操作要么全做要么全不做)、一致性(Consistency, 事务前后数据满足所有约束)、隔离性(Isolation, 并发事务互不干扰)、持久性(Durability, 提交后即使断电也不丢)。它是金融、订单、库存这类“一分钱都不能错”的业务的基本盘。

很多数据库会在某些场景下为了性能牺牲一部分 ACID 保证, PostgreSQL 的默认行为则是把正确性放在第一位。它的事务隔离做得非常完整, 支持到可串行化(Serializable)级别, 并且用一种叫 SSI(可串行化快照隔离)的先进算法实现, 在保证强一致的同时尽量不牺牲并发。这部分是第十章的重点。

MVCC: 高并发的核心机制

这是 PostgreSQL 性能与并发能力的基石, 也是它和很多数据库设计上最不同的地方之一。

名词: MVCC

MVCC(Multi-Version Concurrency Control, 多版本并发控制)是指数据库为同一行数据保留多个历史版本。当一个事务在读取时, 看到的是某个“时间点”的一致快照, 而另一个事务此刻正在修改这行——两者互不阻塞。一句话概括它的效果: 读不阻塞写, 写不阻塞读

这带来的直接好处是: 一个长时间运行的报表查询, 不会因为加了读锁而把线上的写入全部卡住; 一个正在更新的事务, 也不会让其它人的查询排队。在高并发的 OLTP 系统里, 这是巨大的优势。

代价是: 旧版本数据不会立刻消失, 需要一个叫 VACUUM 的后台清理机制定期回收。这是 PostgreSQL 运维里绕不开的一个话题, 用得好相安无事, 忽视了就会遇到“表膨胀”。MVCC 与 VACUUM 的原理和调优, 我们放在第十章细讲。

极其丰富的数据类型

这是“对象”特性最直观的体现。除了常规的整数、浮点、字符串、日期时间, PostgreSQL 原生支持的类型多到让人惊讶:

  • 高精度数值 numeric(算钱不丢精度)、布尔 boolean
  • 数组类型: 任何类型都能加 [] 变成数组, 如 text[]integer[]
  • JSONJSONB: 后者是二进制存储、可建索引的 JSON, 让 PostgreSQL 同时具备文档数据库的灵活性。
  • 范围类型 int4rangetsrange: 天然适合表达“预订时间段”“价格区间”这类业务。
  • 网络地址 inet/cidr/macaddr, 几何类型 point/polygon, UUID, 全文检索的 tsvector 等。
  • 枚举 enum、复合类型(自定义结构)、甚至范围之上的多重范围 multirange(PG 14 引入)。

类型选得准, 后面的约束、索引、查询都会顺很多。这是第四章的主题。

强大的可扩展性

PostgreSQL 几乎把“可扩展”刻进了骨子里。你能扩展的远不止数据类型:

  • **扩展(Extension)**机制: 用一句 CREATE EXTENSION 就能把一整套功能装进数据库, 比如地理信息的 PostGIS、加密的 pgcrypto、外部数据访问的 postgres_fdw、向量检索的 pgvector
  • 自定义函数: 用 SQL 或过程语言写函数, 封装复杂逻辑。
  • 自定义运算符、聚合、索引访问方法: 甚至连“如何建索引”这种底层机制都对外开放。

正是这套扩展体系, 让 PostgreSQL 成了一个“数据平台底座”, 而不只是一个数据库。

丰富的索引种类

大多数数据库主要给你 B-tree 一种索引。PostgreSQL 则内置了一整套:

索引类型擅长场景
B-tree默认, 等值与范围查询, 排序
Hash纯等值查询
GiST几何、范围、全文检索等“可重叠”数据
SP-GiST空间分区类数据(如 IP 前缀、点)
GIN数组、JSONB、全文检索等“一个值含多个键”的场景
BRIN超大表中物理有序的数据(如按时间递增的日志), 索引体积极小

有了这么多索引武器, 很多在别的数据库里要靠应用层硬扛的查询, 在 PostgreSQL 里一个合适的索引就解决了。索引是第九章的核心。

1.4 PostgreSQL vs MySQL: 一张客观对比表

PostgreSQL 和 MySQL 是开源关系型数据库里最常被拿来比较的两个。它们都很优秀, 各有侧重。下面这张表尽量保持中立, 帮你建立判断基准, 而不是简单地说谁更好。

维度PostgreSQLMySQL(以 InnoDB 为准)
进程/线程模型每个连接一个独立进程, 隔离性好但连接开销较大, 通常需配连接池每个连接一个线程, 轻量, 高并发短连接下更省资源
并发控制MVCC, 旧版本由 VACUUM 回收MVCC, 通过 undo log 实现
默认隔离级别读已提交(Read Committed), 最高可到真正的可串行化(SSI)可重复读(Repeatable Read)
数据类型极丰富: 数组、范围、网络、几何、JSONB、自定义类型等相对常规, 数组/范围等需变通实现
可扩展性扩展、自定义类型/函数/运算符/索引方法, 扩展生态强插件机制存在但扩展面较窄
索引种类B-tree/Hash/GiST/SP-GiST/GIN/BRIN主要 B-tree(及全文、空间索引)
复制方案物理流复制 + 逻辑复制, 同步/异步可选主从复制成熟, 生态工具多, 上手简单
JSON 支持JSONB 二进制存储, 可建 GIN 索引, 功能强支持 JSON 类型, 功能逐步完善但索引能力较弱
SQL 标准合规度很高, 高级 SQL 特性齐全较好, 但部分行为有自家方言
上手难度概念多、配置项多, 初期门槛略高简单直接, 新手友好
典型适用场景复杂查询、数据分析、地理/时序/向量、强一致业务读多写少的 Web 应用、对简单与生态成熟度敏感的项目

一句话总结这种差异: MySQL 像一把顺手的瑞士军刀, 简单可靠、随处可见; PostgreSQL 更像一个可无限扩展的工作台, 初期要花点时间摆弄, 但越往后越能应对复杂、刁钻的需求。本教程选 PostgreSQL, 正是看中它“天花板更高、越用越顺手”的特质。

不必站队

这两者的差距在过去十年其实一直在缩小——MySQL 在补强 JSON 与 SQL 特性, PostgreSQL 在改善连接开销与易用性。技术选型应当看具体业务, 而不是信仰。理解了 PostgreSQL, 你对数据库的认知会更完整, 再回头看 MySQL 也会更通透。

1.5 它适合干什么, 又不太适合干什么

工具没有银弹。客观说清边界, 比一味吹捧更有价值。

适合的场景

  • 复杂业务系统: 订单、库存、财务、ERP 这类强一致、多表关联、约束复杂的核心系统。ACID 与丰富的约束能力让数据正确性有保障。我们最后一章就会用它从零搭一个电商订单系统。
  • 数据分析与报表: 窗口函数、CTE、GROUPING SETS、并行查询、列式扩展等, 让它能扛相当一部分分析型负载。
  • 地理信息(GIS): 配合 PostGIS, 它几乎是开源 GIS 的事实标准。
  • 时序数据: 配合 TimescaleDB, 处理监控指标、物联网数据得心应手。
  • 向量检索 / AI 应用: 配合 pgvector, 直接在数据库里做相似度检索, 给大模型应用做向量存储, 近两年极为流行。
  • 半结构化数据: JSONB 让你在一个库里同时享受关系型的严谨和文档型的灵活, 避免过早引入第二种数据库。

相对不太适合的场景

  • 海量短连接、极简单的 KV 读写: 比如纯缓存场景。每连接一进程的模型在成千上万短连接下开销偏大(可用连接池如 PgBouncer 缓解), 而极简单的 KV 读写本就该交给 Redis 这类专用系统。
  • 超大规模水平分片的天然需求: 单机 PostgreSQL 纵向扩展能力很强, 但若业务从一开始就注定要分布到几百个节点, 原生方案不如一些为分布式而生的数据库省心(此时可考虑 Citus 扩展或其它分布式 PG 方案)。
  • 嵌入式/极轻量场景: 想在手机 App 或单文件应用里塞个数据库, SQLite 才是对的工具, PostgreSQL 太重了。

记住: 这些“不适合”大多有对应的扩展或外围方案来弥补, 但选型时心里要有数, 别用牛刀杀鸡, 也别拿水果刀砍树。

1.6 在系统架构中的位置

讲了这么多特性, 不如看一眼 PostgreSQL 在真实系统里站在哪个位置。下面这张图是一个典型的生产部署形态。

这张图里有几个值得提前留意的角色:

  • 连接池 PgBouncer: 因为 PostgreSQL 每个连接对应一个进程, 应用直连容易把连接数撑爆, 中间放一个轻量连接池来复用连接, 是生产标配。
  • 主库 Primary: 唯一接受写入的节点, 是数据的权威来源。
  • 只读副本 Replica: 通过流复制实时同步主库数据, 承担读流量, 分担主库压力, 同时也是主库故障时的接班候选。
  • WAL 归档: WAL(预写式日志)是 PostgreSQL 持久性与复制的命脉, 把它持续归档到备份存储, 是做时间点恢复(PITR)的基础。

名词: WAL

WAL(Write-Ahead Logging, 预写式日志)是指: 任何对数据的修改, 在真正写入数据文件之前, 必须先把“我要做这个改动”记录到日志里。这样即使在写数据文件的中途断电, 重启后也能靠 WAL 把状态恢复到一致点。它同时是流复制和备份恢复的底层基础。这些都是第十四章的内容。

现在你只需要建立一个空间感: 我们前面十几章主要在“主库”这个方框里学查询、建表、调优; 到了备份、复制、高可用那几章, 视野才会扩展到整张图。

1.7 生态全景: 站在 PostgreSQL 肩膀上的方案

前面反复强调的“可扩展”, 在生态层面结出了丰硕的果实。下面这些都是工业界耳熟能详的、基于或衍生自 PostgreSQL 的方案, 各点一句, 帮你建立地图:

  • PostGIS: 地理信息扩展。把 PostgreSQL 变成强大的空间数据库, 支持地图、距离、区域等空间运算, 是开源 GIS 的标杆。
  • TimescaleDB: 时序数据库扩展。在 PostgreSQL 之上优化海量时间序列数据的写入与查询, 常用于监控、IoT、金融行情。
  • Citus: 分布式扩展。把单机 PostgreSQL 横向扩展为分布式集群, 让大表自动分片到多个节点, 适合需要水平扩展的场景。
  • pgvector: 向量检索扩展。让 PostgreSQL 支持向量类型与相似度搜索, 是 AI/大模型应用做语义检索、知识库的热门选择。
  • Greenplum: 基于 PostgreSQL 的大规模并行处理(MPP)数据仓库, 面向 PB 级分析型负载。
  • Supabase: 一个开源的 “Firebase 替代品”, 直接把 PostgreSQL 作为后端核心, 自动生成 REST/实时 API、提供认证与存储, 让前端开发者也能快速用上 PostgreSQL。

一个底座, 长出一片森林

这些方案之所以能存在, 正是因为 PostgreSQL 宽松的协议 + 强大的扩展机制。你学会了 PostgreSQL 这个“底座”, 上面长出的这一整片森林(时序、空间、分布式、向量、BaaS)对你来说就都不再陌生, 而只是“同一个数据库 + 一个扩展”。这是学 PostgreSQL 投入产出比极高的深层原因。

1.8 本教程学习路线图

最后, 把整本教程的脉络用一张图交代清楚, 让你知道自己将走过怎样一条从入门到精通的路。这张图对应的就是开头列出的十六章。

这条路线的设计逻辑是这样的: 第一阶段把环境、工具、最基本的 SQL 与类型打牢, 让你能跑起来、敢动手; 第二阶段深入查询, 让你写得出真正解决问题的 SQL; 第三阶段从“能用”跨到“好用”, 学会设计合理的表结构、用索引和优化让系统跑得快、用事务保证它跑得对; 第四阶段则把视野从单库扩展到整个数据平台与生产运维, 最后用一个完整的电商订单系统把所有知识串起来实战。

每一章我都会坚持“是什么、为什么、怎么用、什么时候用、有什么坑”的讲法, 配真实可运行的示例。学到第十六章时, 回头再看这张图, 你应该已经能独立设计、调优并运维一套生产级 PostgreSQL 系统了。

下一章, 我们就动手把 PostgreSQL 装起来, 连上去, 敲下第一条命令。

二、安装、连接与 psql 入门

要让一个数据库工程师真正上手 PostgreSQL, 第一件事不是背命令, 而是把环境跑起来、把连接打通、把它内部的结构看明白。这一章我们从安装开始, 一路走到能创建数据库、建表、插入、查询的完整闭环, 顺带把后面所有章节都会反复用到的 psql 工具和三大配置文件吃透。读完这一章, 你手里应该有一个稳定可用、可随时销毁重建的本地实例, 以及一套不查文档也能盲打的连接姿势。

选择哪种安装方式

PostgreSQL 的安装方式大致分三类: 容器(Docker)、系统包管理器、源码编译。对应的取舍很清晰:

方式适用场景优点代价
Docker本地开发、测试、CI、多版本并存隔离干净、一键销毁重建、版本随意切换需要懂一点卷与网络
包管理器单机长期运行、生产服务器与系统服务集成、随系统启动升级大版本麻烦、多版本共存别扭
源码编译需要特定编译选项、打补丁、研究内核完全可控耗时、依赖多、不适合日常

如果你是来学习和开发的, 我的建议非常明确: 用 Docker。它能让你在十秒内拥有一个干净的 PostgreSQL 18, 把数据玩坏了直接删容器重来, 还能让 151618 几个大版本在同一台机器上互不干扰。源码编译只在极少数情况下才需要——比如你要启用某个默认关闭的编译期选项、给内核打私有补丁、或者在没有官方包的冷门平台上跑——这部分我们只点到为止, 不展开。

用 Docker 安装(首选)

一条 docker run 跑起来

先看最小可用的命令, 把一个 PostgreSQL 18 实例拉起来:

bash
docker run -d \
  --name pg18 \
  -e POSTGRES_PASSWORD=secret \
  -e POSTGRES_USER=app \
  -e POSTGRES_DB=shop \
  -p 5432:5432 \
  -v pg18_data:/var/lib/postgresql/data \
  postgres:18

逐个参数解释清楚, 这些都是后面会反复打交道的:

  • -d: 后台运行(detached), 不占用当前终端。
  • --name pg18: 给容器起名, 后续 docker execdocker logs 都靠它。
  • -e POSTGRES_PASSWORD=secret: 这是官方镜像唯一强制要求的环境变量, 不设它容器会拒绝启动。它是超级用户的密码。
  • -e POSTGRES_USER=app: 指定初始超级用户名, 不设则默认为 postgres
  • -e POSTGRES_DB=shop: 容器初始化时自动创建的数据库, 不设则默认创建一个与用户同名的库。
  • -p 5432:5432: 把容器内的 5432 端口映射到宿主机的 5432。冒号左边是宿主机端口, 右边是容器端口。如果宿主机上已经跑着别的 PostgreSQL, 把左边改成比如 5433:5432 避免冲突。
  • -v pg18_data:/var/lib/postgresql/data: 这是最关键的一行。它把容器内存放数据的目录挂载到一个名为 pg18_data 的命名卷上。

数据卷不能省

如果省略 -v 这一行, 容器一旦被 docker rm 删除, 里面所有的数据库、表、数据全部蒸发。命名卷把数据存在容器之外, 容器删了重建、镜像升级, 数据都还在。生产里这条线是底线, 学习时也强烈建议从第一天就养成挂卷的习惯。

命名卷: 由 Docker 管理的持久化存储区域, 与容器生命周期解耦。用 docker volume ls 可以看到它, 用 docker volume rm pg18_data 才会真正删掉里面的数据。

容器起来后, 看一眼日志确认初始化完成:

bash
docker logs -f pg18
# 看到这一行就代表准备好了:
# database system is ready to accept connections

用 docker-compose 管理

手敲一长串 docker run 参数, 改一个值就得停容器重来, 很不优雅。日常开发更推荐用 docker-compose.yml 把配置固化下来。新建一个目录, 放入下面这个文件:

yaml
# docker-compose.yml
services:
  db:
    image: postgres:18
    container_name: pg18
    restart: unless-stopped
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: shop
      # 让初始化时用 scram-sha-256 做密码加密(PG 14 起的默认值)
      POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256"
    ports:
      - "5432:5432"
    volumes:
      - pg18_data:/var/lib/postgresql/data
      # 把宿主机的初始化脚本目录挂进来, 容器首次启动会自动执行其中的 *.sql / *.sh
      - ./initdb:/docker-entrypoint-initdb.d
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d shop"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  pg18_data:

几个值得说明的点:

  • restart: unless-stopped: 宿主机重启后自动拉起容器, 但你手动停掉它时不会被强行重启。
  • POSTGRES_INITDB_ARGS: 传给底层 initdb 的参数, 这里显式指定密码认证用 scram-sha-256, 后面讲 pg_hba.conf 时会再回到这个话题。
  • /docker-entrypoint-initdb.d: 官方镜像的约定目录。容器在数据目录为空的首次启动时, 会按字母序执行这个目录里所有 .sql.sh 文件。你可以把建表语句、初始数据放进 ./initdb/01_schema.sql, 实现开箱即用。注意——只在数据卷为空时执行, 卷里已有数据就跳过。
  • healthcheck: 用 pg_isready 探测数据库是否真的能接受连接, 比单纯看容器是否运行更准。

启动与关停:

bash
docker compose up -d      # 后台启动
docker compose ps         # 查看状态, healthy 才算真就绪
docker compose logs -f db # 跟踪日志
docker compose down       # 停止并删除容器(卷保留)
docker compose down -v    # 连卷一起删, 数据全没, 慎用

down -v 会删数据

docker compose down -v 里的 -v 会把命名卷一并删除, 等于清空整个数据库。只有在你确定要彻底重置环境时才用它。

用包管理器安装

如果你打算在一台机器上长期运行、或者直接装在服务器上, 包管理器更顺手。

macOS: Homebrew

bash
brew install postgresql@18
brew services start postgresql@18   # 注册为后台服务, 开机自启
# 临时前台启动(调试用):
# /opt/homebrew/opt/postgresql@18/bin/postgres -D /opt/homebrew/var/postgresql@18

Homebrew 装的版本默认会创建一个与当前系统用户同名的超级用户和一个同名数据库, 且本地默认走信任(trust)或对等(peer)认证, 所以你常常可以不带密码直接 psql postgres连上。数据目录在/opt/homebrew/var/postgresql@18(Apple Silicon)或 /usr/local/var/postgresql@18`(Intel)。

Ubuntu/Debian: apt

系统自带仓库的版本往往偏旧, 要装最新的 18, 应该加 PGDG(PostgreSQL Global Development Group)官方仓库:

bash
# 添加官方仓库(Ubuntu 24.04 为例)
sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install -y postgresql-18

# 服务管理
sudo systemctl status postgresql
sudo systemctl restart postgresql

Debian 系的安装有几个工程上必须知道的约定:

  • 数据目录在 /var/lib/postgresql/18/main, 配置文件在 /etc/postgresql/18/main/。注意——这里配置和数据是分开放的, 跟 Docker 镜像把两者都塞进数据目录的做法不同。
  • 安装时自动创建一个名为 postgres 的操作系统用户。要进数据库, 通常先切到这个系统用户再连: sudo -u postgres psql, 因为本地默认是 peer 认证(用操作系统身份核对数据库角色)。
  • pg_lsclusters 可以列出本机所有的实例(集簇), pg_ctlcluster 18 main restart 控制具体某个实例。

源码编译何时需要

正常开发和生产几乎用不到源码编译。只有这些情况才值得: 你需要某个默认未开启的 ./configure 选项(比如特殊的块大小)、给内核源码打补丁做调试、在官方未提供二进制包的平台上运行。流程上就是 ./configure --prefix=... && make && make install 加上手动 initdb 初始化数据目录, 依赖一堆开发库, 升级也得自己重来一遍。对绝大多数读者, 跳过即可。

首次连接

实例跑起来了, 接下来要连上它。PostgreSQL 的官方命令行客户端是 psql, 这是你之后几乎天天要用的工具。

两种连接写法

psql 接受两种等价的连接描述方式。第一种是连接串 URI:

bash
psql "postgresql://app:secret@localhost:5432/shop"

URI 的结构是 postgresql://用户:密码@主机:端口/数据库名, 后面还能跟 ?sslmode=require&connect_timeout=10 这类参数。

第二种是分散的命令行参数:

bash
psql -h localhost -p 5432 -U app -d shop
# 然后按提示输入密码
参数含义对应环境变量
-h主机名或 IP, 也可以是 Unix socket 目录PGHOST
-p端口, 默认 5432PGPORT
-U连接用的角色名PGUSER
-d目标数据库名PGDATABASE
-W强制提示输入密码PGPASSWORD

如果用 Docker 且懒得在宿主机装客户端, 可以直接钻进容器里连:

bash
docker exec -it pg18 psql -U app -d shop

容器内连接走的是 Unix socket, 通常连密码都不用输。

环境变量: 少打字的关键

每次都敲一长串 -h -p -U -d 很烦。psql 和所有 libpq 客户端都会读一组环境变量作为默认值, 设好之后直接 psql 就能连:

bash
export PGHOST=localhost
export PGPORT=5432
export PGUSER=app
export PGDATABASE=shop
export PGPASSWORD=secret   # 见下方安全提示
psql                       # 现在裸命令就能连上 shop 库

libpq: PostgreSQL 官方的 C 客户端库, psql、各语言的驱动(如 Node 的 pg、Python 的 psycopg)底层大多基于它, 所以这些环境变量对它们普遍生效。

PGPASSWORD 不安全

把密码写进 PGPASSWORD 环境变量, 会被 ps、shell 历史、子进程继承等途径泄露。临时调试可以, 但正经做法是用下面的 .pgpass 文件。

.pgpass: 免密又安全

.pgpass 是一个存放连接凭据的本地文件, libpq 会按主机、端口、库、用户去匹配, 命中就自动填密码。它放在用户主目录下, 每行格式是:

text
# ~/.pgpass
# 格式: hostname:port:database:username:password
localhost:5432:shop:app:secret
localhost:5432:*:app:secret      # 通配: app 用户连本机任意库都用这个密码
*:*:*:postgres:rootpass           # 任意主机任意库的 postgres 用户

文件权限必须收紧, 否则 libpq 会直接忽略它:

bash
chmod 600 ~/.pgpass

设好后, 即便没有 PGPASSWORD, psql -h localhost -U app -d shop 也能免密连上。各字段都支持用 * 通配, 但密码字段里若含 :\ 需要用反斜杠转义。

psql 元命令速查

psql 里以反斜杠开头的命令叫元命令(meta-command), 它们不是 SQL, 而是 psql 本地解释执行的, 用来查看结构、控制输出、执行脚本。熟练掌握这一组, 你的效率会成倍提升。

元命令作用
\l列出所有数据库
\c dbname切换到另一个数据库(connect)
\dt列出当前 schema 下的表
\d 表名查看一张表的列、类型、索引、约束
\d+ 表名\d 更详细, 多了存储、压缩、注释、占用大小
\du列出所有角色及其权限属性
\dn列出所有 schema
\df列出函数
\timing开关每条 SQL 的执行耗时显示
\x切换扩展显示(每行竖排), 看宽表神器
\e用外部编辑器写当前查询, 存盘后执行
\i 文件执行一个 SQL 脚本文件
\copy客户端侧的数据导入导出(走 psql 权限, 不需服务端文件权限)
\watch 2每隔 2 秒重复执行上一条查询, 做监控用
\?元命令帮助
\h 关键字查某个 SQL 命令的语法, 如 \h SELECT

几个会改变工作习惯的细节单独说说:

  • \x 配合宽表特别好用。一张列很多的表, 横排会被终端折断成乱码, \x 打开后每条记录竖着排, 字段名和值一一对应, 清爽得多。它还有个 \x auto, 让 psql 自动判断: 宽了就竖排, 窄了就横排。
  • \timing 一旦打开, 每条语句执行完都会附一行耗时, 调优时几乎全程开着。
  • \copy 和 SQL 的 COPY 容易混淆: SQL 的 COPY 读写的是数据库服务器上的文件, 需要服务端文件权限; 而 \copypsql客户端读写文件, 用你本地的权限, 远程连接时只有它能用。
  • \watch 把任意查询变成一个轮询监控, 比如 SELECT count(*) FROM orders; \watch 5 每五秒看一次订单数增长。

三层逻辑结构初识

很多从 MySQL 过来的人会在这里栽跟头, 因为 PostgreSQL 的层级和 MySQL 不一样。它是三层结构: 数据库(database)→ 模式(schema)→ 对象(表、视图、函数、序列等)。

schema: 数据库内部的命名空间, 用来把对象分组。同一个数据库里, 不同 schema 下可以有同名的表, 比如 sales.ordersarchive.orders 互不冲突。它类似目录, 但不是物理目录, 而是逻辑分组。

关键差异在这: 在 MySQL 里, 一个 database 基本就等同于一个 schema, 你 USE dbname 就切换了命名空间。但在 PostgreSQL 里, 这是两层——一个连接只能连到一个 database, 而在这个库内部, 对象又被划进不同的 schema。跨数据库的表不能在同一条 SQL 里直接 JOIN(那是另一个独立的库), 但跨 schema 的表可以随便 JOIN。

每个新建的数据库默认带一个叫 public 的 schema, 你不显式指定时, 建的表都落在这里。引用对象的完整写法是 schema名.对象名, 比如 public.users

那为什么平时写 SELECT * FROM users 不带 schema 也能找到? 靠的是 search_path

search_path: 一个有序的 schema 名列表, 当你不写 schema 前缀引用对象时, PostgreSQL 按这个列表从前往后找, 命中第一个就用。

sql
SHOW search_path;
-- 默认值通常是:  "$user", public
-- 含义: 先找与当前用户同名的 schema, 找不到再找 public

-- 临时改变本次连接的搜索路径
SET search_path TO sales, public;

"$user" 是个占位符, 代表当前连接角色的名字。这个机制在多 schema 的项目里很重要——它决定了同名表到底解析到哪个 schema。后面第十五章讲权限和多租户隔离时, search_path 还会再唱主角。

配置三件套

PostgreSQL 的行为由三个核心配置文件控制, 它们都在数据目录里(Debian 系单独放在 /etc/postgresql/...)。理解这三个文件, 是从“能连上”走向“会运维”的分水岭。

postgresql.conf: 主配置

这是参数总开关, 内存、连接数、日志、WAL、自动清理等几百个参数都在这里。开发阶段最常碰的几个:

ini
# postgresql.conf 节选
listen_addresses = '*'        # 监听哪些网卡, '*' 表示所有, 默认 'localhost' 只允许本机
port = 5432
max_connections = 100         # 最大并发连接数
shared_buffers = 256MB        # 共享缓冲区, 一般设为物理内存的 1/4
work_mem = 16MB               # 单个排序/哈希操作可用内存, 高了会被多个并发放大
maintenance_work_mem = 256MB  # VACUUM、建索引等维护操作可用内存

shared_bufferswork_mem 这些参数会在第十一章查询优化里详细展开, 这里先有个印象: 它们控制 PostgreSQL 拿多少内存来干活。

pg_hba.conf: 客户端认证

这是最容易让新手卡住的文件。HBA 是 Host-Based Authentication 的缩写, 它一行一行地规定: 谁(哪个用户)从哪里(哪个网段)连哪个库, 用什么方式认证。PostgreSQL 收到连接请求后, 从上往下逐行匹配, 命中第一条就按它的认证方法处理, 不再往下看。

每行的格式是:

text
# TYPE   DATABASE   USER    ADDRESS          METHOD
local    all        all                      scram-sha-256
host     all        all     127.0.0.1/32     scram-sha-256
host     shop       app     10.0.0.0/8       scram-sha-256
host     all        all     0.0.0.0/0        reject

逐列看:

  • TYPE: local 指 Unix socket 连接, host 指 TCP/IP 连接, hostssl 要求必须走 SSL。
  • DATABASE / USER: 这条规则作用于哪些库、哪些用户, all 是全部。
  • ADDRESS: 来源 IP 网段(CIDR), 仅 host 类型需要。127.0.0.1/32 是单台本机, 0.0.0.0/0 是任意来源。
  • METHOD: 认证方法, 这是关键。

常见认证方法对比:

方法说明安全性
scram-sha-256基于挑战应答的密码认证, PG 当前推荐高, 密码不在网络上明文传输
md5旧的密码哈希认证, 已不推荐中, 有已知弱点
password明文密码传输低, 仅在 SSL 内才可接受
peer用操作系统用户身份核对数据库角色, 仅 local高但只限本机
trust不做任何认证, 谁来都放行极低, 只能用于完全可信的本地环境
reject直接拒绝——

scram-sha-256: PostgreSQL 10 引入、14 起成为默认的密码认证机制。它用挑战应答方式, 密码本身永不在网络上传输, 即便抓包也拿不到明文, 是目前生产环境的标准选择。

trust 不要出现在生产

trust 意味着任何能连到端口的人, 不用密码就能以指定角色登录。很多入门教程图省事让你用 trust, 然后忘了改, 一旦 listen_addresses 又开成了 *, 数据库就彻底裸奔。生产环境一律 scram-sha-256, trust 最多用于容器内本机调试。

pg_ident.conf: 用户名映射

这个文件配合 peergsscert 等外部认证使用, 作用是把操作系统/外部身份映射到数据库角色。比如你想让操作系统用户 deploy 以数据库角色 app 的身份登录, 就在这里建一条映射, 再在 pg_hba.conf 里用 map=映射名 引用它。日常密码认证用不到它, 知道它是干什么的即可。

改完配置怎么生效

不是所有参数都得重启。区分两类:

sql
-- 大部分参数改完执行 reload 即可热生效, 比如 pg_hba.conf 的改动
SELECT pg_reload_conf();
bash
-- 命令行等价写法
pg_ctl reload                    # 源码/通用
sudo systemctl reload postgresql # Debian 系
docker exec pg18 pg_ctl reload -D /var/lib/postgresql/data  # 容器内

少数参数(如 shared_buffersmax_connectionslisten_addresses)属于需要重启才能改的, 改完得 restart。怎么判断某参数是 reload 生效还是 restart 生效? 查一下:

sql
SELECT name, setting, context FROM pg_settings WHERE name = 'shared_buffers';
-- context 为 postmaster 表示需重启, sighup 表示 reload 即可, user/superuser 表示连接内即时可改

连接认证的完整时序

把上面这些串起来, 一个客户端从发起连接到能跑 SQL, 内部经历了这样一段对话:

这里有个 PostgreSQL 区别于很多数据库的核心特征: 它是一个连接一个进程的模型。postmaster 是常驻主进程, 负责监听端口; 每来一个连接, 它就 fork 出一个独立的 backend 进程专门服务这个连接。这个设计简单稳健, 但也意味着每个连接都有实打实的进程开销——这正是生产环境为什么需要连接池(如 PgBouncer)的根源, 这点会在第十五章细说。

进程与内存结构概览

顺着上面的连接模型, 我们把 PostgreSQL 运行时的整体骨架看一遍。它由一组协作的进程和一块共享内存构成:

几个关键角色:

  • shared buffers: 所有 backend 共享的数据页缓存。读写数据先经过这里, 命中就不必访问磁盘, 是性能的核心。
  • WAL(Write-Ahead Log, 预写日志): 任何修改在落到数据文件之前, 先以日志形式顺序写入 WAL。崩溃后靠重放 WAL 恢复, 这是事务持久性的保证。第十章和第十四章会深入它。
  • checkpointer: 定期把脏页从 shared buffers 刷到磁盘数据文件, 并在 WAL 里打一个检查点标记, 限制崩溃恢复时需要重放的日志量。
  • background workers: 一类后台工作进程, 自动清理(autovacuum)、并行查询的工作进程都属于这一类。

这张图你现在不用全懂, 它是后面好几章的地基。每讲到一个新机制, 都可以回头对照这张图, 看它落在哪个进程、用哪块内存。

图形客户端

不是所有人都爱命令行。两个主流图形工具一句话区分:

  • pgAdmin: PostgreSQL 官方出品, 功能最全、对 PG 特性支持最深, 但界面偏重、只服务 PostgreSQL。
  • DBeaver: 第三方通用数据库工具, 一个界面管 PostgreSQL、MySQL、Oracle 等几十种库, 轻量顺手, 跨库工作的人首选。

学习阶段我建议你坚持用 psql, 因为它能让你真正理解每一步在做什么; 图形工具适合做可视化浏览和偶尔的复杂操作。两者不冲突, 可以并用。

动手: 走通第一个闭环

理论铺到这, 实际跑一遍才算落地。我们用刚建好的 shop 库, 完成建库、建表、插入、查询的完整闭环。

先连进去并打开计时和扩展显示:

bash
psql "postgresql://app:secret@localhost:5432/shop"
sql
-- 在 psql 里
\timing on
\x auto

-- 1) 单独建一个数据库做演示(也可以直接用 shop)
CREATE DATABASE demo;
\c demo                       -- 切换到 demo 库

-- 2) 建表: 一张极简的用户表
CREATE TABLE users (
    id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,  -- 自增主键
    email      text NOT NULL UNIQUE,                             -- 邮箱唯一
    full_name  text NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now()                -- 带时区的创建时间
);

-- 3) 插入几条数据
INSERT INTO users (email, full_name) VALUES
    ('alice@example.com', 'Alice Zhang'),
    ('bob@example.com',   'Bob Li'),
    ('carol@example.com', 'Carol Wang');

-- 4) 查询
SELECT id, email, full_name, created_at
FROM users
ORDER BY id;

预期输出大致如下:

text
 id |       email       | full_name  |          created_at
----+-------------------+------------+-------------------------------
  1 | alice@example.com | Alice Zhang| 2026-06-12 08:30:11.482+00
  2 | bob@example.com   | Bob Li     | 2026-06-12 08:30:11.482+00
  3 | carol@example.com | Carol Wang | 2026-06-12 08:30:11.482+00
(3 rows)

Time: 1.842 ms

再用元命令验证一下表的结构:

sql
\d users
text
                              Table "public.users"
   Column   |           Type           | Collation | Nullable |       Default
------------+--------------------------+-----------+----------+----------------------
 id         | bigint                   |           | not null | generated always as identity
 email      | text                     |           | not null |
 full_name  | text                     |           | not null |
 created_at | timestamp with time zone |           | not null | now()
Indexes:
    "users_pkey" PRIMARY KEY, btree (id)
    "users_email_key" UNIQUE CONSTRAINT, btree (email)

到这里, 你已经完成了从零安装到查询的全过程: 拉起实例、配通连接、用 psql 操作、理解了库/schema/对象三层结构和连接认证的来龙去脉。这套环境会贯穿后续所有章节——下一章我们就在这个基础上, 系统地学习 SQL 的库、表与增删改查。

顺手清理演示环境

如果你只是想练手, 用完可以把演示数据库删掉。注意要先切回别的库, 因为不能删除自己正连着的数据库。

sql
\c shop          -- 先离开 demo
DROP DATABASE demo;

三、SQL 基础: 库、表与增删改查

本章以一个真实的博客系统为主线, 从建库建表开始, 依次覆盖 INSERT、SELECT、UPDATE、DELETE、UPSERT、ALTER TABLE 等核心操作, 并在每个关键环节揭示 PostgreSQL 区别于其他数据库的特色能力。所有代码均可在 psql 或 DBeaver 中直接执行。

3.1 数据库与模式: CREATE DATABASE / SCHEMA

3.1.1 创建数据库

PostgreSQL 以数据库 (database) 作为最大的逻辑隔离单元, 每个连接只能访问一个数据库。

sql
-- 以超级用户身份创建博客数据库
CREATE DATABASE blog_dev
    OWNER      = blog_user
    ENCODING   = 'UTF8'
    LC_COLLATE = 'zh_CN.UTF-8'
    LC_CTYPE   = 'zh_CN.UTF-8'
    TEMPLATE   = template0;

常用参数说明:

参数含义推荐值
ENCODING字符集编码UTF8
LC_COLLATE字符串排序规则与操作系统一致
LC_CTYPE字符分类规则与操作系统一致
TEMPLATE模板数据库template0(无扩展污染)

字符集一旦创建无法修改

ENCODINGLC_COLLATELC_CTYPE 在数据库创建后永久固定, 后期无法用 ALTER DATABASE 修改。对于需要中文排序或正则匹配的系统, 务必在建库时选好 locale。

连接到新库:

bash
psql -U blog_user -d blog_dev

3.1.2 模式 (Schema) 与 search_path

模式是数据库内部的命名空间, 作用类似文件系统中的目录, 允许将表、视图、函数等对象分组管理, 同一个数据库可以有多个模式。

sql
-- 创建应用层模式与审计模式
CREATE SCHEMA app;
CREATE SCHEMA audit;

-- 在指定模式下创建对象
CREATE TABLE app.users (id bigint);
CREATE TABLE audit.change_log (id bigint);

search_path 决定了不带模式前缀时 PostgreSQL 查找对象的顺序, 类似 Unix 的 $PATH

sql
-- 查看当前 search_path
SHOW search_path;
-- 默认: "$user", public

-- 为当前会话切换 search_path
SET search_path TO app, public;

-- 永久修改某个数据库的默认 search_path
ALTER DATABASE blog_dev SET search_path TO app, public;

为什么要用 Schema 而不是多个数据库

跨数据库查询在 PostgreSQL 中不被原生支持(需要 dblinkpostgres_fdw 扩展), 但跨 schema 查询完全透明。多租户系统常见做法是每个租户一个 schema, 所有租户共享同一数据库连接池。

3.2 建表: CREATE TABLE

3.2.1 列定义与常用约束

sql
CREATE TABLE IF NOT EXISTS app.users (
    id          bigserial       PRIMARY KEY,
    username    varchar(64)     NOT NULL UNIQUE,
    email       text            NOT NULL UNIQUE,
    password_hash text          NOT NULL,
    bio         text,
    is_active   boolean         NOT NULL DEFAULT true,
    created_at  timestamptz     NOT NULL DEFAULT now(),
    updated_at  timestamptz     NOT NULL DEFAULT now()
);

COMMENT ON TABLE  app.users               IS '博客用户表';
COMMENT ON COLUMN app.users.password_hash IS 'bcrypt 哈希, 禁止存明文';
COMMENT ON COLUMN app.users.created_at    IS '注册时间, 带时区';

IF NOT EXISTS

加上 IF NOT EXISTS 后, 表已存在时 SQL 静默跳过而不报错。这让建表脚本变成幂等脚本, 可以安全地重复执行, 非常适合放进 CI/CD 流水线。

3.2.2 常用数据类型速查

类型别名/说明典型用途
bigserialbigint + 自增序列主键
text无长度限制字符串正文、URL
varchar(n)最长 n 字符用户名、标题
booleantrue / false开关标志
timestamptz带时区的时间戳所有时间字段
jsonb二进制 JSON扩展属性、配置
uuid128 位标识符分布式主键

时区陷阱: timestamp vs timestamptz

timestamp(无时区) 只存字面时间值, 不记录时区信息。一旦应用部署到多时区环境, 写入与读出的值会产生语义偏差。始终使用 timestamptz, 它在存储时统一转换为 UTC, 读取时按会话时区还原。

3.2.3 同步创建 posts 表

sql
CREATE TABLE IF NOT EXISTS app.posts (
    id          bigserial       PRIMARY KEY,
    author_id   bigint          NOT NULL REFERENCES app.users(id) ON DELETE CASCADE,
    title       varchar(200)    NOT NULL,
    slug        varchar(200)    NOT NULL UNIQUE,
    body        text,
    status      varchar(20)     NOT NULL DEFAULT 'draft'
                                CHECK (status IN ('draft','published','archived')),
    view_count  integer         NOT NULL DEFAULT 0,
    published_at timestamptz,
    created_at  timestamptz     NOT NULL DEFAULT now(),
    updated_at  timestamptz     NOT NULL DEFAULT now()
);

COMMENT ON TABLE  app.posts            IS '博客文章表';
COMMENT ON COLUMN app.posts.slug       IS 'URL 友好标识, 全局唯一';
COMMENT ON COLUMN app.posts.status     IS 'draft | published | archived';

这里用 REFERENCES app.users(id) ON DELETE CASCADE 建立外键: 用户被删除时, 其所有文章级联删除。CHECK 约束直接在列级别限定合法的状态枚举值, 无需额外的枚举类型。

3.3 INSERT: 写入数据

3.3.1 单行插入

sql
INSERT INTO app.users (username, email, password_hash, bio)
VALUES ('alice', 'alice@example.com', '$2b$12$xyz...', '全栈工程师, 热爱开源');

不要依赖列的默认顺序

始终在 INSERT显式列出列名。表结构变更(加列、调整列顺序)后, 省略列名的写法会悄无声息地写错数据甚至报错。

3.3.2 多行批量插入

单条 INSERT 可以携带多个 VALUES 行, 一次网络往返写入所有数据, 比循环单条插入效率高出数倍。

sql
INSERT INTO app.users (username, email, password_hash)
VALUES
    ('bob',     'bob@example.com',     '$2b$12$aaa...'),
    ('carol',   'carol@example.com',   '$2b$12$bbb...'),
    ('dave',    'dave@example.com',    '$2b$12$ccc...');

3.3.3 INSERT ... SELECT

从查询结果直接插入, 常用于数据迁移、历史归档、派生表初始化。

sql
-- 把已注销用户归档到 app.archived_users 表
INSERT INTO app.archived_users (id, username, email, archived_at)
SELECT id, username, email, now()
FROM   app.users
WHERE  is_active = false;

3.3.4 RETURNING 子句 (PostgreSQL 特色)

标准 SQL 的 INSERT 不返回任何行。PostgreSQL 通过 RETURNING 让写操作直接返回刚写入的数据, 无需再发一条 SELECT

sql
-- 插入一条记录, 同时取回数据库自动生成的 id 与 created_at
INSERT INTO app.users (username, email, password_hash)
VALUES ('eve', 'eve@example.com', '$2b$12$ddd...')
RETURNING id, username, created_at;

预期输出:

text
 id | username |          created_at
----+----------+-------------------------------
  5 | eve      | 2026-06-12 10:23:45.123456+08
(1 row)

RETURNING 可以返回任意列, 也支持表达式:

sql
INSERT INTO app.posts (author_id, title, slug, body, status)
VALUES (1, '第一篇文章', 'first-post', '正文内容...', 'draft')
RETURNING id, title, (now() - created_at) AS age;

为什么 RETURNING 很重要

在应用层写完记录后通常需要拿到自增 id 去关联其他操作。传统做法是 INSERT 之后再 SELECT lastval()currval('seq_name'), 存在并发安全隐患并多一次往返。RETURNING 在单条语句内原子地完成"写入 + 取值", 是 PostgreSQL 应用开发的惯用模式。ORM 框架(如 SQLAlchemy、GORM、Prisma)在与 PostgreSQL 对接时会自动使用 RETURNING

3.4 SELECT: 查询基础

3.4.1 投影、列别名、表别名

sql
-- 基础投影: 只取需要的列
SELECT id, username, email
FROM   app.users;

-- 列别名: AS 关键字可省略, 但建议保留以提高可读性
SELECT
    id                                          AS user_id,
    username                                    AS name,
    to_char(created_at, 'YYYY-MM-DD')           AS join_date,
    extract(day FROM now() - created_at)        AS days_since_join
FROM app.users
WHERE is_active = true;

预期输出:

text
 user_id | name  | join_date  | days_since_join
---------+-------+------------+-----------------
       1 | alice | 2026-06-01 |              11
       2 | bob   | 2026-06-05 |               7
(2 rows)

3.4.2 表别名与多表关联预热

表别名让 SQL 更简洁, 在多表 JOIN 时尤为必要:

sql
-- 查询文章列表并附带作者名
SELECT
    p.id,
    p.title,
    p.status,
    u.username   AS author_name,
    p.created_at
FROM  app.posts  AS p
JOIN  app.users  AS u ON u.id = p.author_id
WHERE p.status = 'published'
ORDER BY p.created_at DESC
LIMIT 10;

SELECT * 的代价

SELECT * 在生产代码中是反模式: 表加列后接收方可能因列类型变化而崩溃; 网络传输宽列(如大 textjsonb)会显著增加延迟; 查询计划器也无法做列级索引裁剪。始终显式列出所需列

3.5 UPDATE: 修改数据

3.5.1 基础写法

sql
-- 修改单个用户的 bio
UPDATE app.users
SET    bio       = '资深后端工程师',
       updated_at = now()
WHERE  id = 1;

裸 UPDATE 不带 WHERE

不带 WHEREUPDATE 会修改表中全部行。在执行前务必先用同等条件跑一次 SELECT 验证影响范围。

3.5.2 带 RETURNING 的 UPDATE

INSERT 类似, UPDATE 也支持 RETURNING, 可以直接返回更新后的值:

sql
-- 发布文章: 同时更新状态与发布时间, 取回新值
UPDATE app.posts
SET    status       = 'published',
       published_at = now(),
       updated_at   = now()
WHERE  id = 1
  AND  author_id = 1
RETURNING id, title, status, published_at;

预期输出:

text
 id |    title     |  status   |          published_at
----+--------------+-----------+-------------------------------
  1 | 第一篇文章   | published | 2026-06-12 11:00:00.000000+08
(1 row)

3.5.3 UPDATE ... FROM 联表更新 (PostgreSQL 特色)

标准 SQL 通过子查询实现联表更新, PostgreSQL 扩展了 UPDATE ... FROM 语法, 可以直接 JOIN 其他表:

sql
-- 将 alice 发布的所有文章的 view_count 重置为 0
-- (模拟批量清除某用户的统计数据)
UPDATE app.posts AS p
SET    view_count = 0,
       updated_at = now()
FROM   app.users AS u
WHERE  u.id       = p.author_id
  AND  u.username = 'alice'
RETURNING p.id, p.title, u.username;

预期输出:

text
 id |    title     | username
----+--------------+----------
  1 | 第一篇文章   | alice
(1 row)

UPDATE ... FROM 比相关子查询在执行计划上更易被优化器优化为哈希连接或合并连接, 批量更新场景下性能优势明显。

3.6 DELETE 与 TRUNCATE

3.6.1 基础 DELETE

sql
-- 删除单篇文章
DELETE FROM app.posts
WHERE id = 5
RETURNING id, title;

-- 删除某用户的所有草稿
DELETE FROM app.posts
WHERE author_id = 2
  AND status    = 'draft';

DELETE 同样支持 RETURNING, 可以在删除的同时取回被删行的数据, 常用于"移走并记录"场景。

3.6.2 DELETE vs TRUNCATE 对比

特性DELETETRUNCATE
过滤条件支持 WHERE 子句不支持, 删全表
执行方式逐行标记删除(MVCC)直接回收数据页
大表性能慢(行数越多越慢)极快(近似 O(1))
事务回滚支持支持(PostgreSQL 中 TRUNCATE 是事务安全的)
触发器触发行级 / 语句级触发器只触发语句级触发器, 不触发行级触发器
重置序列不重置RESTART IDENTITY 可重置
外键联动受外键约束检查CASCADE 可级联清空关联表
WAL 量每行都写 WAL只写少量元数据 WAL
sql
-- 清空测试数据, 同时重置自增序列
TRUNCATE TABLE app.posts RESTART IDENTITY CASCADE;

TRUNCATE 在 MySQL 与 PostgreSQL 中的差异

MySQL 的 TRUNCATE 是 DDL 操作, 不可回滚。PostgreSQL 的 TRUNCATE 是完整的事务操作, 可以在 BEGIN 块内执行并 ROLLBACK。迁移数据库时务必注意这一行为差异。

3.7 UPSERT: INSERT ... ON CONFLICT

UPSERT 是 PostgreSQL 9.5 引入的特性, 让"存在则更新, 不存在则插入"变成一条原子语句, 彻底消灭"先 SELECT 再 INSERT/UPDATE"的竞态条件。

3.7.1 基础语法

sql
INSERT INTO app.users (username, email, password_hash)
VALUES ('alice', 'alice@example.com', '$2b$12$new...')
ON CONFLICT (email)          -- 指定冲突目标: 唯一约束列
DO UPDATE SET
    password_hash = EXCLUDED.password_hash,
    updated_at    = now();

EXCLUDED 是一个伪表 (pseudo-table), 代表"试图插入但因冲突被拒绝的那一行"。通过 EXCLUDED.<column> 可以引用冲突行的任意字段值。

3.7.2 DO NOTHING: 幂等写入

当业务语义是"已存在则忽略"时, 使用 DO NOTHING:

sql
-- 幂等插入: 若 slug 已存在则静默跳过
INSERT INTO app.posts (author_id, title, slug, status)
VALUES (1, '重复投递的文章', 'duplicate-post', 'draft')
ON CONFLICT (slug) DO NOTHING
RETURNING id;   -- 若发生冲突则不返回行

这在消息去重、事件溯源、数据同步等幂等场景中极为常见。RETURNING 不返回行可以作为判断是否真正插入的信号: 返回空则说明记录已存在。

3.7.3 实战: 计数器累加

ON CONFLICT DO UPDATE 结合 EXCLUDED 可以实现无锁的计数器累加, 避免"先读后写"的竞态:

sql
-- 文章浏览量 +1: 首次访问则插入计数为 1, 已有记录则累加
INSERT INTO app.post_stats (post_id, view_count)
VALUES (42, 1)
ON CONFLICT (post_id)
DO UPDATE SET
    view_count = app.post_stats.view_count + EXCLUDED.view_count,
    updated_at = now();

这里 app.post_stats.view_count 引用的是表中当前已有的值, EXCLUDED.view_count 是本次尝试插入的值(即 1), 两者相加实现原子递增。相比 UPDATE ... SET view_count = view_count + 1 WHERE post_id = 42, UPSERT 在记录不存在时也能正确初始化, 无需提前保证行存在。

3.7.4 冲突目标的写法

ON CONFLICT 的目标有两种形式:

sql
-- 形式 1: 指定列(列上必须有唯一约束或主键)
ON CONFLICT (email) DO UPDATE SET ...

-- 形式 2: 指定约束名(更精确, 推荐用于多唯一约束的表)
ON CONFLICT ON CONSTRAINT users_email_key DO UPDATE SET ...

版本注意

INSERT ... ON CONFLICTPostgreSQL 9.5+ 引入的功能。9.4 及更早版本需要用 CTE + LOCK 或存储过程模拟, 非常繁琐。如果生产环境尚有旧版 PostgreSQL, 务必在迁移前确认版本。

3.8 ALTER TABLE: 演进表结构

表结构会随业务发展而变化。ALTER TABLE 是在不重建表的前提下修改表定义的标准方式。

3.8.1 加列与删列

sql
-- 新增列: 指定默认值可避免全表重写(PostgreSQL 11+, 对非 volatile 默认值)
ALTER TABLE app.users
    ADD COLUMN avatar_url text,
    ADD COLUMN locale     varchar(10) NOT NULL DEFAULT 'zh-CN';

-- 删除列: 同时删除依赖该列的索引和约束
ALTER TABLE app.users
    DROP COLUMN avatar_url;

PostgreSQL 11 的加列优化

PostgreSQL 11 之前, 带 DEFAULTADD COLUMN 会触发全表重写(对大表可能持续数小时)。11 起, 非易变 (non-volatile) 默认值(常量、字面量)的 ADD COLUMN 只更新系统表元数据, 无需重写数据文件, 瞬间完成。now() 等易变函数仍需全表重写。

3.8.2 修改列类型 (USING)

sql
-- 将 view_count 从 integer 改为 bigint
ALTER TABLE app.posts
    ALTER COLUMN view_count TYPE bigint;

-- 当类型转换无法隐式完成时, 用 USING 提供转换表达式
ALTER TABLE app.posts
    ALTER COLUMN status TYPE text USING status::text;

-- 将存储为 text 的时间字符串转换为 timestamptz
ALTER TABLE app.posts
    ALTER COLUMN published_at TYPE timestamptz
        USING published_at::timestamptz;

3.8.3 修改默认值与约束

sql
-- 修改列的默认值
ALTER TABLE app.posts
    ALTER COLUMN status SET DEFAULT 'draft';

-- 删除默认值
ALTER TABLE app.posts
    ALTER COLUMN status DROP DEFAULT;

-- 添加 CHECK 约束
ALTER TABLE app.posts
    ADD CONSTRAINT chk_view_count_nonneg CHECK (view_count >= 0);

-- 删除约束
ALTER TABLE app.posts
    DROP CONSTRAINT chk_view_count_nonneg;

3.8.4 重命名表与列

sql
-- 重命名列
ALTER TABLE app.users
    RENAME COLUMN bio TO profile_bio;

-- 重命名表
ALTER TABLE app.users
    RENAME TO members;

-- 改回来
ALTER TABLE app.members
    RENAME TO users;

重命名的连锁影响

重命名表或列后, 所有引用该名称的视图、函数、存储过程、外键约束都会失效或需要同步更新。生产环境执行前务必用 \d+ <table> 检查依赖。

3.9 DROP TABLE: 删除表

sql
-- 基础删除
DROP TABLE app.archived_users;

-- IF EXISTS: 表不存在时静默跳过, 适合幂等迁移脚本
DROP TABLE IF EXISTS app.archived_users;

-- CASCADE: 同时删除依赖该表的视图、外键等对象
-- 危险! 会静默删除所有下游依赖, 生产环境谨慎使用
DROP TABLE IF EXISTS app.users CASCADE;

DROP TABLE CASCADE 的连锁删除

CASCADE静默、不可逆地删除所有引用被删表的外键约束、视图、物化视图等对象。在生产环境执行前, 先用 SELECT * FROM pg_depend WHERE refobjid = 'app.users'::regclass 查清楚所有依赖项, 逐一评估影响。

3.10 贯穿案例: 博客系统增删改查闭环

以下用前文建好的 app.usersapp.posts 两张表, 演示一个完整的业务流程。

sql
-- 步骤 1: 注册新用户, 取回自动生成的 id
INSERT INTO app.users (username, email, password_hash, bio)
VALUES ('frank', 'frank@example.com', '$2b$12$fff...', 'PostgreSQL 爱好者')
RETURNING id, username, created_at;

-- 假设返回 id = 10

-- 步骤 2: 创建一篇草稿文章
INSERT INTO app.posts (author_id, title, slug, body)
VALUES (10, 'PostgreSQL UPSERT 实战', 'pg-upsert-guide', '## 正文...')
RETURNING id, status, created_at;

-- 假设返回 id = 20

-- 步骤 3: 查询用户的所有草稿
SELECT
    p.id,
    p.title,
    p.status,
    p.created_at
FROM  app.posts  AS p
WHERE p.author_id = 10
  AND p.status    = 'draft';

-- 步骤 4: 编辑并发布文章
UPDATE app.posts
SET    title        = 'PostgreSQL UPSERT 完全指南',
       body         = '## 更新后的正文...',
       status       = 'published',
       published_at = now(),
       updated_at   = now()
WHERE  id           = 20
  AND  author_id    = 10
RETURNING id, title, status, published_at;

-- 步骤 5: 浏览量幂等累加(无论记录是否存在都安全)
INSERT INTO app.post_stats (post_id, view_count)
VALUES (20, 1)
ON CONFLICT (post_id)
DO UPDATE SET
    view_count = app.post_stats.view_count + 1,
    updated_at = now();

-- 步骤 6: 用户注销 — 级联删除其所有文章(外键 ON DELETE CASCADE)
DELETE FROM app.users
WHERE id = 10
RETURNING id, username;
-- posts 表中 author_id = 10 的行自动级联删除

整个流程中, RETURNING 贯穿始终, 让每一步写操作的结果直接流向下一步, 无需额外的 SELECT 往返。

3.11 INSERT 在 PostgreSQL 内部的写入路径

理解"数据是怎么落盘的"有助于正确设置 synchronous_commitcheckpoint_completion_target 等参数, 也是排查数据库崩溃恢复问题的基础。

写入流程说明:

  1. 解析与执行: 后端进程接收 SQL, 完成解析、重写、规划、执行, 生成要写入的元组。
  2. 写 WAL: 在真正修改数据页之前, 先把变更记录写入 WAL 缓冲区 (WAL Buffer)。事务提交时, WAL 缓冲区的内容通过 fsync 刷入磁盘上的 pg_wal 目录, 这是持久性 (Durability) 的保障。
  3. 修改 Shared Buffers: 在内存中找到(或从磁盘读入)对应的数据页, 修改页面内容, 将该页标记为"脏页 (dirty page)"。此时数据尚未落盘
  4. Checkpointer 刷盘: 后台进程 checkpointer 周期性地(或在 WAL 积累到阈值时)将 Shared Buffers 中的脏页写回数据文件。这是延迟写 (write-behind) 机制, 避免每次提交都做随机 I/O。

"写日志再写数据"的直觉

只要 WAL 已经落盘, 即使数据库崩溃, PostgreSQL 在重启时会重放 WAL 将数据文件恢复到一致状态。这就是 synchronous_commit = on(默认值)保证"提交即持久"的底层机制。而 synchronous_commit = off 允许 WAL 异步落盘, 牺牲少量持久性换取更高的写入吞吐量。

ALTER TABLE 常用操作速查表
操作语句示例
加列(有默认值)ALTER TABLE t ADD COLUMN c text NOT NULL DEFAULT 'x'
删列ALTER TABLE t DROP COLUMN c
改类型ALTER TABLE t ALTER COLUMN c TYPE bigint USING c::bigint
改默认值ALTER TABLE t ALTER COLUMN c SET DEFAULT 42
删默认值ALTER TABLE t ALTER COLUMN c DROP DEFAULT
加 CHECK 约束ALTER TABLE t ADD CONSTRAINT ck CHECK (c > 0)
加唯一约束ALTER TABLE t ADD CONSTRAINT uq UNIQUE (c)
删约束ALTER TABLE t DROP CONSTRAINT ck
重命名列ALTER TABLE t RENAME COLUMN old TO new
重命名表ALTER TABLE t RENAME TO new_name

四、数据类型全解

类型选型是数据库设计的基石。选错了类型, 轻则浪费存储, 重则引入精度错误或性能退化。PostgreSQL 拥有业界最丰富的内置类型体系——从普通整数到网络地址, 从时间戳到范围, 每一种都有其精准的适用场景。本章逐类拆解, 并在结尾给出选型决策树。


4.1 数值类型

4.1.1 整数: smallint / integer / bigint

三种定长整数在存储宽度与取值范围上各有分工:

类型存储范围
smallint2 字节−32 768 ~ 32 767
integer4 字节−2 147 483 648 ~ 2 147 483 647
bigint8 字节−9.2 × 10¹⁸ ~ 9.2 × 10¹⁸

经验法则: 业务主键、计数器、数量字段默认选 integer; 只有在确认数据量极大(超过 20 亿行)或需要表示毫秒级时间戳时才升为 bigint; smallint 适合枚举代码、状态码等值域极小的字段。不要因为"保险起见"就全部用 bigint——4 字节与 8 字节的差距在亿级宽表中会被放大数倍。

4.1.2 定点数: numeric(p, s)

numeric(p, s) 中, p 为总有效位数(precision), s 为小数位数(scale)。例如 numeric(10, 2) 可存 12345678.99。

金额必须用 numeric, 这不是建议, 是规定。 原因在于浮点类型无法精确表示十进制小数。

sql
-- 浮点精度陷阱演示
SELECT 0.1::float8 + 0.2::float8;
-- 结果: 0.30000000000000004  并非 0.3

SELECT 0.1::numeric + 0.2::numeric;
-- 结果: 0.3  精确

在支付、账务、税率等场景下, 一旦使用 float 存金额, 累计误差会造成对账不平。国内某头部电商曾因此引发千万级财务核对问题。

易错点

numeric 不指定精度时等价于"任意精度", 写法为 numericdecimal。虽然灵活, 但计算速度比指定了精度的 numeric(p,s) 慢, 也比整数慢——在高频聚合场景下应当评估影响。

4.1.3 浮点数: real / double precision

real(4 字节, 约 6 位有效十进制位)和 double precision(8 字节, 约 15 位)适合科学计算、统计、地理坐标等对绝对精度要求不高、但对范围和速度要求高的场景。

sql
-- 看似相等, 实则不等
SELECT 1234567.89::real = 1234567.89::double precision;
-- 结果: false

-- 浮点比较应使用误差范围
SELECT abs(a - b) < 1e-9 AS nearly_equal
FROM   (SELECT 1.0/3.0 AS a, 0.333333333 AS b) t;

浮点数与等值比较

永远不要对浮点列做 = 某常量 的精确等值查询, 应改用范围比较或 round() 归一化后再比较。索引在浮点等值查询上也常常失效。


4.2 自增主键: serial 的历史包袱与 IDENTITY 的现代做法

4.2.1 老式 serial / bigserial

serial 并不是真正的类型, 它是语法糖, 展开后等价于:

sql
-- 这两段 DDL 完全等价
CREATE TABLE orders (id serial PRIMARY KEY);

-- 等价于:
CREATE SEQUENCE orders_id_seq;
CREATE TABLE orders (
    id integer NOT NULL DEFAULT nextval('orders_id_seq')
);
ALTER SEQUENCE orders_id_seq OWNED BY orders.id;

serial 有两个隐患:

隐患一: 默认值可被显式覆盖。 任何拥有 INSERT 权限的用户都可以写 INSERT INTO orders(id, ...) VALUES(999, ...), 强行插入任意 id, 绕过序列, 破坏业务逻辑。

隐患二: 序列归属不严格。 若通过 CREATE TABLE ... AS SELECT 或某些备份恢复手段复制表结构, 序列与表的绑定关系可能丢失, 导致两张表共用同一个序列, 产生主键冲突。

4.2.2 现代做法: GENERATED AS IDENTITY

SQL 标准在 PostgreSQL 10 引入了 GENERATED AS IDENTITY 语法, 彻底解决上述问题:

sql
-- GENERATED ALWAYS: 禁止手动插入, 只能由系统生成
CREATE TABLE orders (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    amount numeric(12, 2) NOT NULL
);

-- GENERATED BY DEFAULT: 允许手动指定, 类似旧 serial 行为
-- 适用于数据迁移、测试数据补录等需要指定 id 的场景
CREATE TABLE orders_import (
    id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    amount numeric(12, 2) NOT NULL
);

GENERATED ALWAYS 模式下, 尝试手动插入 id 会报错:

sql
INSERT INTO orders(id, amount) VALUES (1, 100.00);
-- ERROR: cannot insert into column "id"
-- DETAIL: Column "id" is an identity column defined as GENERATED ALWAYS.
-- 如确实需要覆盖(如数据迁移), 可加 OVERRIDING SYSTEM VALUE 子句

推荐策略

新项目一律使用 GENERATED ALWAYS AS IDENTITY。迁移旧表时, 可用 ALTER TABLE t ALTER COLUMN id ADD GENERATED ALWAYS AS IDENTITY 将现有列原地升级, 并通过 SELECT setval(pg_get_serial_sequence('t','id'), max(id)) FROM t 同步序列起点。


4.3 字符类型: char / varchar / text

PostgreSQL 提供三种字符类型:

类型语义存储
char(n)定长, 不足补空格最多 n 字节, 含填充
varchar(n)变长, 最大 n 字符实际长度 + 1~4 字节头
text无限制变长实际长度 + 1~4 字节头

4.3.1 为何通常优先用 text

在 PostgreSQL 内核实现中, varchar(n)text 的底层存储结构完全相同, 唯一差别是 varchar(n) 在写入时多做一次长度校验。换言之, 选 text 不比选 varchar(255) 慢, 也不占更多空间——这与 MySQL 的行为截然不同, 很多从 MySQL 迁移过来的工程师在这里存有误解。

char(n) 则会用空格填充到 n 位, 导致尾部空格被隐式忽略的比较陷阱:

sql
SELECT 'abc'::char(5) = 'abc  ';  -- true, 因为都被视为 'abc'
SELECT 'abc'::text    = 'abc  ';  -- false

除非对接需要定长填充的遗留系统或固定格式协议(如银行报文), 否则没有理由使用 char(n)

4.3.2 长度约束: CHECK 还是类型宽度

有两种方式限制字符长度:

sql
-- 方式 A: 用类型宽度
nickname varchar(50)

-- 方式 B: 用 CHECK 约束
nickname text CHECK (char_length(nickname) <= 50)

方式 A 的问题在于: 若需要将上限从 50 调整为 100, 必须执行 ALTER TABLE ... ALTER COLUMN, 在大表上这会导致表锁甚至全表重写(PostgreSQL 实际上对增大 varchar 上限做了优化——只修改 pg_attribute 不重写数据, 但减小上限仍需重写)。方式 B 用 ALTER TABLE ... DROP CONSTRAINT ... ADD CONSTRAINT 操作约束, 同样有锁, 但语义更显式、更易被应用层感知。

实用建议: 内部系统字段用 text + 无约束, 业务表中对用户可见的、有明确上限要求的字段用 varchar(n), 在 API 层也同步校验。


4.4 日期与时间类型

4.4.1 类型概览

类型存储精度含时区
date4 字节
time8 字节微秒
timetz12 字节微秒是(不推荐)
timestamp8 字节微秒
timestamptz8 字节微秒是(推荐)
interval16 字节微秒

4.4.2 timestamp 与 timestamptz 的本质差异

这是 PostgreSQL 时间处理中最高频的踩坑点, 必须彻底讲清楚。

timestamp(全称 timestamp without time zone)存储的是一个"裸时间值", 不携带任何时区信息。写入什么就存什么, 取出什么就返回什么, 数据库不做任何转换。

timestamptz(全称 timestamp with time zone)存储时, PostgreSQL 会将输入值转换为 UTC 后存入磁盘; 读取时, 再根据当前会话的 TimeZone 参数转换回本地时间展示。注意: 磁盘上永远只存 UTC, 并不存时区标签本身。

sql
-- 模拟时区切换引发的数据错乱
SET TimeZone = 'Asia/Shanghai';
CREATE TABLE demo (ts_naive timestamp, ts_tz timestamptz);

INSERT INTO demo VALUES (now(), now());

-- 切换时区查询
SET TimeZone = 'UTC';
SELECT ts_naive, ts_tz FROM demo;
-- ts_naive: 2024-03-01 14:30:00  (仍是上海时间, 没有转换)
-- ts_tz:    2024-03-01 06:30:00  (正确转回 UTC, 差了 8 小时)

实际事故场景: 应用服务器原本部署在上海机房(UTC+8), timestamp 列存的都是北京时间。后来云迁移到新加坡节点(UTC+8, 暂时没问题), 再扩展到欧洲节点(UTC+1)后, 旧数据的时间对不上, 触发大量报警。

易错点

timestamp 在多时区、多地域部署场景下是定时炸弹。强烈建议: 永远用 timestamptz 存储时间戳。 若仍在用 timestamp, 应确保整个系统(数据库、应用、中间件)全部固定在同一时区且永不更改。

4.4.3 三个"当前时间"函数的差异

sql
-- now() / current_timestamp: 事务开始时的时间, 同一事务内多次调用值相同
SELECT now(), pg_sleep(1), now();
-- 两个 now() 返回同一时刻

-- clock_timestamp(): 语句执行时的实际系统时间, 每次调用都更新
SELECT clock_timestamp(), pg_sleep(1), clock_timestamp();
-- 两个 clock_timestamp() 相差约 1 秒

-- statement_timestamp(): 当前语句开始时的时间

日志、审计字段通常用 now()(记录事务发起时间); 性能测量、长事务内部计时用 clock_timestamp()

4.4.4 interval 区间运算

interval 用于表示一段时长, 支持直观的加减运算:

sql
SELECT now() + interval '7 days' AS next_week;
SELECT now() - interval '1 month 3 days' AS past;

-- 计算用户注册后 30 天内的行为
SELECT * FROM events
WHERE event_time BETWEEN created_at AND created_at + interval '30 days';

4.5 布尔类型 boolean

boolean 在 PostgreSQL 中是一等公民, 占 1 字节存储。它接受多种字面量写法:

真值假值
true, 't', 'true', 'yes', 'on', '1'false, 'f', 'false', 'no', 'off', '0'

字面量写法在 SQL 语句中以字符串形式传入时需要加引号, 直接写 true/false 则不需要:

sql
UPDATE users SET is_active = true  WHERE id = 1;
UPDATE users SET is_active = 'yes' WHERE id = 2;  -- 等价
UPDATE users SET is_active = 'on'  WHERE id = 3;  -- 等价

易错点

boolean 列存在 NULL 时, 三值逻辑(true/false/NULL)可能出人意料:

sql
SELECT NOT NULL;          -- NULL (不是 true)
SELECT true AND NULL;     -- NULL (不是 true)
SELECT false AND NULL;    -- false
SELECT true OR NULL;      -- true
SELECT false OR NULL;     -- NULL

过滤布尔字段时请用 IS TRUE / IS NOT TRUE 而非 = true, 后者会把 NULL 行过滤掉——而这往往正是 bug 来源。


4.6 枚举类型 enum

4.6.1 创建与使用

sql
CREATE TYPE order_status AS ENUM (
    'pending', 'paid', 'shipped', 'delivered', 'cancelled'
);

CREATE TABLE orders (
    id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    status order_status NOT NULL DEFAULT 'pending'
);

INSERT INTO orders(status) VALUES ('paid');
-- 插入不存在的值会报错: invalid input value for enum order_status: "refunded"

enum 的内存表示是 4 字节 OID, 存储比 text 紧凑; 排序按枚举定义顺序而非字母序, 这在状态机流转分析时很有用。

4.6.2 变更的限制

枚举类型最大的痛点是只能增加、不能删除、不能重命名已有值:

sql
-- PostgreSQL 12+ 可以在事务内增加枚举值
BEGIN;
ALTER TYPE order_status ADD VALUE 'refunded' AFTER 'delivered';
COMMIT;

PG12 之前, ADD VALUE 不能在事务块内执行, 意味着无法回滚——这在严格的 schema migration 流程中是个障碍。

sql
-- 想删除或重命名枚举值? 没有直接命令, 必须走以下步骤:
-- 1. 新建枚举类型(不含要删除的值)
-- 2. 更新所有引用列: ALTER TABLE ... ALTER COLUMN ... TYPE new_type USING ...
-- 3. DROP 旧类型
-- 成本极高, 在生产环境操作大表时需申请维护窗口

4.6.3 enum 与字典表方案的取舍

维度enum 类型关联字典表
存储效率高(4 字节 OID)外键 integer 约 4 字节, 查询时需 JOIN
约束强度数据库层强约束外键约束, 同样可靠
值的增删增: 简单; 删/改: 极难增删改均容易
业务可读性直接存 'paid' 等文本, 可读需 JOIN 才能看到描述
多语言/多租户不支持支持(字典表可多语言扩展)
推荐场景值集合非常稳定, 不超过 20 个值需要频繁变更或携带附加属性

结论: 像订单状态、性别、审核结果这类值域稳定、语义明确的字段, 用 enum 非常合适。像"行业分类""产品类目"这类业务人员随时可能新增的字段, 用字典表更灵活。


4.7 UUID 类型

4.7.1 基本用法

sql
-- 启用 uuid-ossp 扩展(PG13 之前的做法)
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
SELECT uuid_generate_v4();

-- PG13+ 内置, 无需扩展
SELECT gen_random_uuid();
-- 结果: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11

uuid 类型占 16 字节, 存储上比 bigint(8 字节)大一倍, 但索引查找性能相近。UUID 的核心价值在于全局唯一、无需协调——分布式系统、多库合并场景下可直接在应用层生成 ID 而不依赖数据库序列。

4.7.2 UUID v4 与 UUID v7 的性能差异

UUID v4 是完全随机的, 这带来一个严重的 B-Tree 索引性能问题: 新行插入时落点随机分布在整个索引树, 导致大量页面随机读写, 缓冲命中率低, 在亿级表上插入 TPS 会比 bigint 自增主键低 3~10 倍。

UUID v7(RFC 9562, PG17 实验性支持, 生产环境可用 pg_uuidv7 扩展)在高 48 位嵌入毫秒级时间戳, 使新生成的 UUID 在时间上单调递增, 插入性能接近自增 ID:

sql
-- 安装 pg_uuidv7 扩展
CREATE EXTENSION IF NOT EXISTS pg_uuidv7;

CREATE TABLE events (
    id uuid DEFAULT uuid_generate_v7() PRIMARY KEY,
    payload jsonb
);

选型建议

  • 单机或小规模分布式系统: 优先 bigint GENERATED ALWAYS AS IDENTITY, 性能最优。
  • 分布式系统、需要应用层生成 ID: 用 UUID v7(有序), 避免 UUID v4(随机)导致的索引碎片化。
  • 对外暴露 API 时用 UUID 而非自增 ID, 可防止枚举攻击。

4.8 数组类型

PostgreSQL 支持任意类型的一维或多维数组, 这是与大多数关系型数据库最显著的差异之一。

4.8.1 定义与字面量

sql
CREATE TABLE tags_demo (
    id      integer PRIMARY KEY,
    tags    text[],          -- 文本数组
    scores  integer[]        -- 整数数组
);

-- 字面量写法: 用花括号, 字符串元素需用双引号
INSERT INTO tags_demo VALUES (1, '{"postgres","sql","database"}', '{95,87,92}');

-- 也可用 ARRAY 构造函数
INSERT INTO tags_demo VALUES (2, ARRAY['backend','api'], ARRAY[80,75]);

下标从 1 开始, 这与大多数编程语言不同:

sql
SELECT tags[1] FROM tags_demo WHERE id = 1;  -- 'postgres'
SELECT tags[1:2] FROM tags_demo WHERE id = 1; -- '{postgres,sql}' (切片)

4.8.2 常用操作符与函数

sql
-- 包含查询: @> 左包含右, <@ 右包含左
SELECT * FROM tags_demo WHERE tags @> ARRAY['sql'];
SELECT * FROM tags_demo WHERE ARRAY['sql','api'] <@ tags;

-- ANY: 数组中任意元素满足条件
SELECT * FROM tags_demo WHERE 'sql' = ANY(tags);

-- ALL: 数组中所有元素满足条件
SELECT * FROM tags_demo WHERE 80 <= ALL(scores);

-- unnest(): 将数组展开为行
SELECT id, unnest(tags) AS tag FROM tags_demo;

-- array_agg(): 聚合列值为数组
SELECT array_agg(DISTINCT tag ORDER BY tag)
FROM   tags_demo, unnest(tags) AS tag;

4.8.3 该不该用数组

数组是把双刃剑。支持使用的场景: 标签、权限列表、一对少量关联(如文章的图片 URL 列表), 这类场景下数组避免了单独建立关联表的复杂度, 且 GIN 索引支持高效的包含查询。

不应该使用的场景: 需要对数组元素做 JOIN、频繁更新单个元素、元素数量不可控(可能增长到数千个)。这些场景意味着需要规范化——用独立的关联表才是正确的设计。

易错点

数组列上的普通 B-Tree 索引对 @> 包含查询无效, 必须建 GIN 索引:

sql
CREATE INDEX idx_tags_gin ON tags_demo USING GIN (tags);

未建 GIN 索引时, tags @> ARRAY['sql'] 会触发全表顺序扫描。


4.9 范围类型

范围类型是 PostgreSQL 的独特功能, 用于表示"从 A 到 B"的连续区间。

4.9.1 内置范围类型

类型元素类型
int4rangeinteger
int8rangebigint
numrangenumeric
tsrangetimestamp
tstzrangetimestamptz
daterangedate

4.9.2 边界语义

范围字面量用 [ 表示闭合边界(含端点), ( 表示开放边界(不含端点):

sql
-- [1, 5): 包含 1, 不包含 5
SELECT '[1,5)'::int4range @> 4;   -- true
SELECT '[1,5)'::int4range @> 5;   -- false

-- 时间段示例
SELECT tstzrange('2024-01-01', '2024-12-31', '[)') AS year_2024;
-- [2024-01-01 00:00:00+08, 2024-12-31 00:00:00+08)

-- 范围操作符
SELECT '[1,5)'::int4range && '[3,8)'::int4range;  -- 重叠? true
SELECT '[1,5)'::int4range * '[3,8)'::int4range;   -- 交集: [3,5)
SELECT '[1,5)'::int4range + '[3,8)'::int4range;   -- 合并: [1,8)

4.9.3 防止时间段重叠: EXCLUDE 约束

范围类型与 GiST 索引配合, 可以在数据库层面强制禁止时间段重叠, 这是会议室预订、排班系统的利器:

sql
CREATE TABLE room_bookings (
    id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    room_id  integer NOT NULL,
    period   tstzrange NOT NULL,
    EXCLUDE USING gist (room_id WITH =, period WITH &&)
);

-- 同一房间, 时间段重叠的预订会被直接拒绝
INSERT INTO room_bookings(room_id, period)
VALUES (101, '[2024-06-01 09:00, 2024-06-01 11:00)');
-- OK

INSERT INTO room_bookings(room_id, period)
VALUES (101, '[2024-06-01 10:00, 2024-06-01 12:00)');
-- ERROR: conflicting key value violates exclusion constraint

GiST 索引与排他约束的详细用法将在第八章索引专题中展开。


4.10 网络地址类型

PostgreSQL 内置了专门处理 IP 地址和 MAC 地址的类型, 这在普通数据库中需要用字符串模拟:

类型语义示例
inetIPv4/IPv6 地址, 可含子网掩码192.168.1.0/24, ::1
cidr网络地址(主机位必须为 0)192.168.0.0/16
macaddr48 位 MAC 地址08:00:2b:01:02:03
macaddr864 位 EUI-64 MAC 地址08:00:2b:ff:fe:01:02:03
sql
-- 判断 IP 是否在某网段内
SELECT '192.168.1.100'::inet << '192.168.0.0/16'::inet;  -- true

-- 查询来自某网段的所有访问记录
SELECT * FROM access_log
WHERE client_ip << '10.0.0.0/8'::inet;

相比用 varchar 存 IP 地址, inet 类型有三个优势: 内置合法性校验(非法 IP 直接报错)、支持网段包含运算符(<< >>)、存储效率更高(4 或 16 字节而非字符串)。


4.11 JSON 与 JSONB

深入内容说明

JSON/JSONB 的完整操作(路径查询、函数、索引策略)在第十三章专题讲解。本节仅覆盖类型选型的核心差异。

PostgreSQL 提供两种 JSON 类型, 外观相同, 内部实现截然不同:

维度jsonjsonb
存储格式原始文本, 原样保存二进制分解, 去除空格、排序键
写入速度快(无解析开销)慢(需解析和重组)
读取/查询速度慢(每次查询都重新解析)快(已是结构化二进制)
键顺序保留原始顺序不保证顺序
重复键保留所有重复键保留最后一个
索引支持不支持 GIN/GiST支持 GIN 索引(极高效)
sql
-- jsonb 的 GIN 索引支持任意键值的高效查询
CREATE INDEX idx_payload_gin ON events USING GIN (payload);

SELECT * FROM events
WHERE payload @> '{"status": "paid", "channel": "wechat"}';
-- 利用 GIN 索引, 无需全表扫描

选型结论: 绝大多数情况下应选 jsonb。唯一选 json 的理由是: 需要原样保留 JSON 文本(含空格、键顺序、重复键), 或者写入极其频繁且从不查询(如日志归档)。


4.12 类型转换

4.12.1 显式转换的两种写法

sql
-- SQL 标准写法
SELECT CAST('123' AS integer);
SELECT CAST(now() AS date);

-- PostgreSQL 简写(更常用)
SELECT '123'::integer;
SELECT now()::date;
SELECT '2024-01-01'::timestamptz;

两种写法完全等价, :: 语法更简洁, 在 PostgreSQL 生态中被广泛使用。

4.12.2 隐式转换的陷阱

PostgreSQL 会在某些情况下自动进行隐式类型转换, 但这可能导致函数参数类型不匹配, 触发全表扫描:

sql
-- 假设 user_id 列类型为 bigint, 且有索引
CREATE INDEX idx_user_id ON orders(user_id);

-- 传入字符串: PostgreSQL 尝试隐式转换, 索引可能失效
SELECT * FROM orders WHERE user_id = '12345';  -- 有时能用索引, 有时不能

-- 传入 integer 字面量: 能隐式提升为 bigint, 索引正常
SELECT * FROM orders WHERE user_id = 12345;

-- 函数参数类型不匹配是更严重的问题
-- 假设有函数 get_orders(p_id bigint)
-- 调用时传入 integer 类型参数可能无法命中函数重载, 或走隐式转换降低性能

最典型的陷阱出现在 ORM 框架中: 框架将 ID 参数绑定为 integer, 但列类型是 bigint, 或将时间参数绑定为 timestamp 但列类型是 timestamptz。这类不匹配不会报错, 却会导致 PostgreSQL 在函数调用处应用隐式转换, 使索引无法被使用。

易错点

排查慢查询时, 若 EXPLAIN 显示在有索引的列上仍走 Seq Scan, 第一反应应检查 WHERE 条件中的参数类型与列类型是否一致。用 :: 显式转换往往能立刻修复。


4.13 主要类型选型速查表

下表汇总了本章涉及的所有主要类型, 每类给出 2~3 句选型建议, 供设计表结构时快速参考。

类型适用场景选型要点
smallint状态码、枚举代码、有限值域的数字值域确定在 ±32 767 以内时使用, 节省存储。
integer业务主键、计数器、数量大多数整数场景的默认选择, 不要无脑升 bigint
bigint超大主键、毫秒时间戳、超 20 亿行的表数量确实超出 integer 范围时才升级。
numeric(p,s)金额、税率、汇率、所有需精确小数的场景金额必须用此类型。指定精度比不指定精度性能更好。
real / double precision科学计算、统计、地理坐标不能用于金额。等值比较须用误差范围替代。
text通用字符串字段PostgreSQL 中首选, 存储效率与 varchar 相同。
varchar(n)需要业务层限制最大长度的字段适合对外 API 字段; 修改上限在大表上有锁风险。
char(n)固定长度协议字段、遗留系统对接一般场景应避免使用, 尾部空格填充易引发比较陷阱。
date生日、节假日、仅需日期精度的字段不含时间分量, 跨时区时不会错位。
timestamptz几乎所有需要存时间的场景推荐默认。多时区系统下 timestamp 是定时炸弹。
timestamp单时区封闭系统、日历计算中的"本地时间"只在整个系统时区固定且明确的情况下使用。
interval时长、有效期、延迟直接用于时间加减运算, 比手动换算秒数更清晰。
boolean是/否标志、开关、已读/未读注意三值逻辑(NULL 行为); 过滤用 IS TRUE
enum有限且稳定的状态集合值域超过 20 个或需要频繁变更时改用字典表。
uuid分布式 ID、对外接口 ID有序需求用 UUID v7; 单机系统优先考虑 bigint 自增。
text[] / integer[]标签、权限列表、少量有序关联需要 GIN 索引支持包含查询; 不适合元素数量无上限的场景。
daterange / tstzrange合同期、预订时段、有效期配合 EXCLUDE 约束可在数据库层防止区间重叠。
inet / cidrIP 地址、访问控制、安全审计varchar 节省空间, 且内置合法性校验与网段运算。
jsonb半结构化数据、动态属性、事件 payload几乎总是优于 json; 支持 GIN 索引, 查询高效。
json纯归档、需原样保留文本的场景只在有明确原样保留需求时才选。

4.14 数据类型选型决策树

决策树从字段存储的内容分类出发, 逐层缩小选型范围。实际工程中还需结合查询模式(是否需要索引)、写入频率、与外部系统的数据交换格式一并评估——类型选型不是孤立的, 它与索引设计、约束设计共同构成表结构的质量基础。

五、查询进阶: 过滤、排序、聚合与分组

从上一章的基础 SELECT 出发, 本章系统拆解 PostgreSQL 查询管道中最核心的五个环节: 精确过滤、排序与分页、去重、聚合函数, 以及多维分组汇总。每一节都附有来自真实业务的可执行示例, 并在关键处揭示 PG 独有的特色语法。阅读完本章后, 你应当能够写出生产级别的统计报表 SQL, 并清晰理解"SQL 逻辑执行顺序"这一核心心智模型。

5.1 WHERE 子句: 从简单比较到复合条件

WHERE 是查询管道的第一道过滤器, 决定哪些行进入后续处理。正确使用 WHERE 不仅影响结果正确性, 也直接影响索引能否被命中。

5.1.1 比较运算符

PostgreSQL 支持标准的六种比较运算符:

运算符含义示例
=等于status = 'active'
<>!=不等于role <> 'admin'
<小于price < 100
>大于score > 90
<=小于等于age <= 18
>=大于等于created_at >= '2024-01-01'
sql
-- 查询 2024 年之后注册的活跃用户
SELECT id, username, email
FROM users
WHERE created_at >= '2024-01-01'
  AND status = 'active';

5.1.2 范围与集合: BETWEEN、IN、NOT IN

BETWEEN low AND high 等价于 col >= low AND col <= high, 两端均包含

sql
-- 查询价格在 50 到 200 元之间的商品
SELECT name, price
FROM products
WHERE price BETWEEN 50 AND 200;

-- 查询指定分类 ID 列表中的商品
SELECT name, category_id
FROM products
WHERE category_id IN (3, 7, 12);

-- 排除已取消和已退款的订单
SELECT order_no, amount
FROM orders
WHERE status NOT IN ('cancelled', 'refunded');

BETWEEN 的边界陷阱

WHERE created_at BETWEEN '2024-01-01' AND '2024-01-31' 在列为 timestamp 类型时, 上界匹配到 2024-01-31 00:00:00, 1 月 31 日的数据几乎全部丢失。正确写法是 created_at >= '2024-01-01' AND created_at < '2024-02-01'

5.1.3 逻辑运算符与优先级

AND 的优先级高于 OR, 这是最容易犯错的地方。

sql
-- 错误: 实际解析为 (role = 'admin') OR (role = 'editor' AND status = 'active')
SELECT * FROM users
WHERE role = 'admin' OR role = 'editor' AND status = 'active';

-- 正确: 用括号明确意图
SELECT * FROM users
WHERE (role = 'admin' OR role = 'editor') AND status = 'active';

NOT 的优先级最高, 然后是 AND, 最后是 OR凡是 OR 与 AND 混用, 都应当加括号, 让意图一目了然, 同时避免未来维护者误读。


5.2 NULL 三值逻辑: 最容易踩的坑

SQL 并非二值逻辑(真/假), 而是三值逻辑: TRUEFALSEUNKNOWN。NULL 代表"未知值", 任何与 NULL 的比较运算结果都是 UNKNOWN

5.2.1 真值表

下表展示 AND、OR、NOT 在三值逻辑下的完整真值:

AND 真值表

A \ BTRUEFALSEUNKNOWN
TRUETRUEFALSEUNKNOWN
FALSEFALSEFALSEFALSE
UNKNOWNUNKNOWNFALSEUNKNOWN

OR 真值表

A \ BTRUEFALSEUNKNOWN
TRUETRUETRUETRUE
FALSETRUEFALSEUNKNOWN
UNKNOWNTRUEUNKNOWNUNKNOWN

NOT 真值表

输入结果
TRUEFALSE
FALSETRUE
UNKNOWNUNKNOWN

WHERE 子句只保留结果为 TRUE 的行。结果为 FALSEUNKNOWN 的行均被过滤掉。

5.2.2 经典错误一: WHERE col = NULL 永远不成立

sql
-- 错误: 永远返回 0 行, 因为 NULL = NULL 结果是 UNKNOWN
SELECT * FROM users WHERE deleted_at = NULL;

-- 正确: 必须使用 IS NULL 或 IS NOT NULL
SELECT * FROM users WHERE deleted_at IS NULL;
SELECT * FROM users WHERE deleted_at IS NOT NULL;

IS NULLIS NOT NULL 是专门处理空值的谓词, 结果只有 TRUEFALSE, 不会产生 UNKNOWN

5.2.3 经典错误二: NOT IN 子查询含 NULL 导致结果集为空

这是生产环境中出现频率极高的 Bug, 必须深入理解。

sql
-- 准备数据
CREATE TABLE a (id int);
INSERT INTO a VALUES (1), (2), (3);

CREATE TABLE b (id int);
INSERT INTO b VALUES (2), (NULL);

-- 问题 SQL: 期望返回 a 中不在 b 里的行
SELECT * FROM a WHERE id NOT IN (SELECT id FROM b);
-- 实际返回: 0 行!

原因分析: NOT IN (2, NULL) 展开后等价于 id <> 2 AND id <> NULL。由于 id <> NULL 永远是 UNKNOWN, 整个 AND 表达式变成 UNKNOWN, 没有任何行能通过 WHERE 过滤。

正确的替代写法有两种:

sql
-- 方案一: 用 NOT EXISTS(推荐, 语义清晰且能利用索引)
SELECT * FROM a
WHERE NOT EXISTS (
    SELECT 1 FROM b WHERE b.id = a.id
);
-- 返回: 1, 3

-- 方案二: 在子查询中显式排除 NULL
SELECT * FROM a
WHERE id NOT IN (SELECT id FROM b WHERE id IS NOT NULL);
-- 返回: 1, 3

NOT IN 子查询的黄金法则

只要子查询结果集中可能含有 NULL, 就绝对不能使用 NOT INNOT EXISTS 是更安全的替代。在 Code Review 中看到 NOT IN (subquery) 时, 应首先确认子查询列是否有 NOT NULL 约束。


5.3 模式匹配: LIKE、ILIKE、SIMILAR TO 与 POSIX 正则

PostgreSQL 提供四种字符串模式匹配方式, 能力依次递增。

5.3.1 LIKE 与通配符

LIKE 支持两个通配符:

  • %: 匹配任意长度(含零长度)的字符序列。
  • _: 精确匹配一个任意字符。
sql
-- 匹配所有以 "张" 开头的姓名
SELECT name FROM users WHERE name LIKE '张%';

-- 匹配姓名为两个字且姓"李"的用户
SELECT name FROM users WHERE name LIKE '李_';

-- 匹配邮件地址包含 "gmail" 的用户
SELECT email FROM users WHERE email LIKE '%gmail%';

-- 使用 ESCAPE 转义通配符本身(查找含 % 的字符串)
SELECT note FROM tasks WHERE note LIKE '%50\%%' ESCAPE '\';

5.3.2 ILIKE: 大小写不敏感匹配(PostgreSQL 特色)

标准 SQL 的 LIKE 区分大小写。PostgreSQL 额外提供了 ILIKE, 在比较前将两端统一转为小写:

sql
-- 匹配 "Admin"、"admin"、"ADMIN" 等所有大小写变体
SELECT username FROM users WHERE username ILIKE 'admin%';

-- 对应的否定形式
SELECT username FROM users WHERE username NOT ILIKE '%test%';

ILIKE 的性能注意事项

ILIKE 无法直接使用普通 B-tree 索引。若需对大表做大小写不敏感的前缀搜索, 应在 lower(col) 上建函数索引: CREATE INDEX idx_users_username_lower ON users (lower(username)), 然后改写查询为 WHERE lower(username) LIKE lower('admin%')

5.3.3 SIMILAR TO: SQL 标准正则子集

SIMILAR TO 支持类 SQL 的正则语法, 比 LIKE 更强但比 POSIX 弱。特殊字符包括 |(或)、*(零次或多次)、+(一次或多次)、?(零次或一次)、{m,n}(重复次数), 以及 ( ) 分组。

sql
-- 匹配以 "cat" 或 "dog" 开头的标签
SELECT tag FROM post_tags WHERE tag SIMILAR TO '(cat|dog)%';

-- 匹配 3 到 6 位纯数字的邮编
SELECT zip FROM addresses WHERE zip SIMILAR TO '[0-9]{3,6}';

5.3.4 POSIX 正则: 最强的模式匹配

PostgreSQL 完整实现了 POSIX 扩展正则, 提供四个操作符:

操作符含义
~大小写敏感匹配
~*大小写不敏感匹配
!~大小写敏感不匹配
!~*大小写不敏感不匹配
sql
-- ~ : 匹配手机号格式(11位数字, 1开头)
SELECT phone FROM users WHERE phone ~ '^1[3-9]\d{9}$';

-- ~* : 大小写不敏感, 匹配含 "postgresql" 的文章标题
SELECT title FROM posts WHERE title ~* 'postgresql';

-- !~ : 排除包含 HTML 标签的评论
SELECT content FROM comments WHERE content !~ '<[^>]+>';

-- !~* : 大小写不敏感排除含广告词的内容
SELECT content FROM comments WHERE content !~* '(广告|推广|限时优惠)';

POSIX 正则功能最全, 但计算开销也最大。对于固定前缀匹配, LIKE 'prefix%' 配合 B-tree 索引效率远高于正则。只在确实需要复杂模式时才选用 POSIX 正则。


5.4 ORDER BY: 排序的方方面面

5.4.1 基本语法与多列排序

sql
-- 单列降序
SELECT id, created_at, amount
FROM orders
ORDER BY amount DESC;

-- 多列排序: 先按区域升序, 同区域内再按销售额降序
SELECT region, salesperson, revenue
FROM sales
ORDER BY region ASC, revenue DESC;

多列排序时, PostgreSQL 从左到右依次比较: 只有前一列相等时才继续比较下一列。

5.4.2 NULL 值的排序位置

在 PostgreSQL 的默认行为中, NULL 在升序(ASC)时排在最后, 在降序(DESC)时排在最前。可以用 NULLS FIRST / NULLS LAST 显式指定:

sql
-- 升序时将 NULL 排到最前(非默认行为)
SELECT name, score
FROM students
ORDER BY score ASC NULLS FIRST;

-- 降序时将 NULL 排到最后(非默认行为)
SELECT name, score
FROM students
ORDER BY score DESC NULLS LAST;

为什么要关心 NULL 的排序位置

当列上有 NULL 且业务需要"未填写的排在最后"时, ORDER BY col NULLS LAST 是最直接的表达。如果不确定数据是否含 NULL 就依赖默认行为, 很容易出现让用户困惑的排序结果。

5.4.3 按表达式排序

ORDER BY 后可以跟任意表达式, 不一定是列名:

sql
-- 按标题字符数升序(短标题优先)
SELECT title FROM posts ORDER BY char_length(title) ASC;

-- 按绝对值排序(正负偏差量按大小展示)
SELECT item, deviation FROM measurements ORDER BY abs(deviation) DESC;

-- 按截取的年份分组排序
SELECT created_at, title
FROM posts
ORDER BY date_trunc('year', created_at) DESC, created_at DESC;

5.4.4 按列序号排序(不推荐)

SQL 允许用 SELECT 列表中的序号代替列名:

sql
-- 等价于 ORDER BY region ASC, revenue DESC
SELECT region, salesperson, revenue
FROM sales
ORDER BY 1 ASC, 3 DESC;

为什么不推荐: 列序号与列名之间没有明确绑定, 一旦 SELECT 列表调整顺序或增减列, ORDER BY 的语义就悄然改变, 极难排查。只有在动态生成 SQL 的极少数场景下才考虑使用。

5.4.5 Collation 排序规则对中文与大小写比较的影响

排序规则(Collation)决定了字符串如何比较大小。PostgreSQL 默认继承数据库创建时的 LC_COLLATE, 也可以在列定义或查询中临时覆盖。

sql
-- 查看当前数据库的 collation
SELECT datcollate FROM pg_database WHERE datname = current_database();

-- 在查询中临时指定 collation
SELECT name FROM users ORDER BY name COLLATE "zh_CN.UTF-8";

en_US.UTF-8zh_CN.UTF-8 的关键差异:

比较项en_US.UTF-8zh_CN.UTF-8
大小写比较大写 < 小写(A < a)与 en_US 相同
中文排序依据Unicode 码点顺序(乱序)拼音顺序(大多数 glibc 版本)
'a' = 'A'FALSEFALSE

实际项目中, 若需要按拼音对中文姓名排序, 应确保数据库 LC_COLLATE 设为 zh_CN.UTF-8, 或使用 ORDER BY convert_to(name, 'GBK') 借助 GBK 编码的拼音特性进行转换排序:

sql
-- 按拼音排序中文姓名(GBK 编码内码与拼音顺序一致)
SELECT name
FROM users
ORDER BY convert_to(name, 'GBK');

5.5 LIMIT、OFFSET 与分页的性能陷阱

5.5.1 基本用法

sql
-- 取前 10 条最新文章
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC
LIMIT 10;

-- 第 3 页(每页 10 条, 跳过前 20 条)
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC
LIMIT 10 OFFSET 20;

LIMIT nOFFSET m 可以单独使用, 也可以组合使用。OFFSET 0 等同于不写 OFFSET。

5.5.2 深分页的性能问题

OFFSET 在语义上要求数据库先扫描并丢弃前 N 行, 再返回 LIMIT 指定的结果。这意味着翻页越深, 代价越高:

sql
-- 看似只取 10 行, 但数据库必须先"走过"前 100000 行
SELECT id, title FROM posts
ORDER BY created_at DESC
LIMIT 10 OFFSET 100000;

在百万级数据量下, OFFSET 100000 通常需要扫描十万行, 即便列上有索引, 也无法跳过中间的行——因为数据库需要按顺序数到第 100001 行才能确定从哪里开始返回。

深分页是隐性性能杀手

OFFSET 分页在 OFFSET 值较小时毫无感知, 但当用户翻到第几百页时查询速度骤降。这是基于游标的 Keyset 分页的典型使用场景: 用上一页最后一条记录的排序键作为下一页的起点, 完全避免 OFFSET 扫描。Keyset 分页将在第十一章详细讲解, 届时你会看到它在千万级数据量下依然毫秒级响应。


5.6 DISTINCT 与 DISTINCT ON: 去重与"每组取第一"

5.6.1 DISTINCT: 全行去重

SELECT DISTINCT 对结果集进行整行去重, 保留唯一的行组合:

sql
-- 查询所有出现过的城市(去除重复)
SELECT DISTINCT city FROM users ORDER BY city;

-- 多列组合去重: 返回不重复的(省份, 城市)对
SELECT DISTINCT province, city FROM users ORDER BY province, city;

DISTINCT 需要对所有返回列做排序比较, 数据量大时有一定性能开销。若只需要快速知道某列有哪些不重复值, 可以结合 GROUP BY 来利用索引。

5.6.2 DISTINCT ON: PostgreSQL 特色的"每组取第一"

DISTINCT ON(expr) 是 PostgreSQL 的非标准扩展, 语义是: 在由 expr 定义的每个分组中, 只保留第一行。结合 ORDER BY 控制"第一行"的选取规则, 可以优雅地解决"每组最新记录"类问题。

语法结构:

sql
SELECT DISTINCT ON (分组表达式)
    列1, 列2, ...
FROM
ORDER BY 分组表达式, 排序表达式;

注意: ORDER BY 的第一组表达式必须与 DISTINCT ON 的表达式一致, 否则 PostgreSQL 会报错。

5.6.3 实战: 每个用户取最新一条操作记录

sql
-- 准备示例表
-- CREATE TABLE user_actions (
--     id         bigserial PRIMARY KEY,
--     user_id    bigint    NOT NULL,
--     action     text      NOT NULL,
--     created_at timestamptz NOT NULL DEFAULT now()
-- );

-- 每个用户取最新一条操作记录
SELECT DISTINCT ON (user_id)
    user_id,
    action,
    created_at
FROM user_actions
ORDER BY user_id, created_at DESC;

执行逻辑: PostgreSQL 先按 (user_id, created_at DESC) 排序整个结果集, 然后在每个 user_id 分组中取第一行——即 created_at 最大的那条记录。

与窗口函数写法的对比:

sql
-- 等价的窗口函数写法(标准 SQL, 更具可移植性)
SELECT user_id, action, created_at
FROM (
    SELECT user_id, action, created_at,
           row_number() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
    FROM user_actions
) t
WHERE rn = 1;

DISTINCT ON 写法更简洁, 但仅限 PostgreSQL; 窗口函数写法可移植到其他支持 SQL:2003 的数据库。在 PostgreSQL 项目中两者性能相当, 选哪种以团队习惯为准。


5.7 聚合函数深入

聚合函数将一组行"压缩"为单一值。理解各聚合函数对 NULL 的处理方式, 是写出正确统计 SQL 的基础。

5.7.1 count(*) vs count(col) vs count(DISTINCT col)

这三种写法的区别频繁出现在面试和代码审查中:

写法含义NULL 处理
count(*)统计行数(含 NULL 行)不忽略任何行
count(col)统计该列非 NULL 的行数跳过 NULL
count(DISTINCT col)统计该列不重复的非 NULL 值数量跳过 NULL 且去重
sql
-- 示例数据: orders 表, amount 列有若干 NULL
SELECT
    count(*)                    AS total_rows,       -- 所有行数
    count(amount)               AS non_null_amounts,  -- amount 非 NULL 的行数
    count(DISTINCT customer_id) AS unique_customers   -- 不重复客户数
FROM orders;

5.7.2 常用聚合函数一览

sql
SELECT
    sum(amount)           AS total_amount,    -- 求和(忽略 NULL)
    avg(amount)           AS avg_amount,      -- 均值(忽略 NULL)
    min(created_at)       AS first_order,     -- 最小值
    max(created_at)       AS last_order,      -- 最大值
    -- 将所有商品名拼接为逗号分隔的字符串
    string_agg(product_name, ', ' ORDER BY product_name) AS products,
    -- 将商品 ID 收集为数组
    array_agg(product_id ORDER BY product_id) AS product_ids,
    -- 检查是否所有订单都已支付
    bool_and(status = 'paid')  AS all_paid,
    -- 检查是否存在任何逾期订单
    bool_or(status = 'overdue') AS has_overdue
FROM orders
WHERE created_at >= '2024-01-01';

string_aggarray_agg 都支持内部 ORDER BY, 可以控制拼接或收集的顺序, 这是 PostgreSQL 聚合函数的一大实用特性。

5.7.3 FILTER 子句: 聚合内的条件统计

FILTER (WHERE condition) 允许在同一个聚合函数内只统计满足条件的行, 彻底告别"多次 JOIN 同一张表做条件统计"的反模式。

sql
-- 在一次扫描中同时统计各状态的金额
SELECT
    sum(amount)                              AS total_amount,
    sum(amount) FILTER (WHERE status = 'paid')     AS paid_amount,
    sum(amount) FILTER (WHERE status = 'pending')  AS pending_amount,
    sum(amount) FILTER (WHERE status = 'refunded') AS refunded_amount,
    count(*)    FILTER (WHERE status = 'overdue')  AS overdue_count
FROM orders
WHERE created_at >= '2024-01-01';

CASE WHEN 写法对比:

sql
-- 传统写法: 等价但更冗长
sum(CASE WHEN status = 'paid' THEN amount END) AS paid_amount

两种写法性能相同, 但 FILTER 语义更清晰, 是 SQL:2003 标准语法, 推荐在新代码中优先使用


5.8 GROUP BY 与 HAVING

5.8.1 GROUP BY 基础

GROUP BY 将结果集按一列或多列分组, 每组产生一行聚合结果。SELECT 列表中非聚合列必须出现在 GROUP BY 中(或是 GROUP BY 列的函数依赖):

sql
-- 统计每个用户的订单数和消费总额
SELECT
    customer_id,
    count(*)       AS order_count,
    sum(amount)    AS total_spent
FROM orders
GROUP BY customer_id
ORDER BY total_spent DESC;

5.8.2 WHERE 过滤行 vs HAVING 过滤组

WHEREGROUP BY 之前执行, 过滤的是原始行; HAVINGGROUP BY 之后执行, 过滤的是聚合结果(组)。

sql
-- WHERE + HAVING 协作: 只统计 2024 年的数据, 且只展示消费超过 1000 元的客户
SELECT
    customer_id,
    count(*)    AS order_count,
    sum(amount) AS total_spent
FROM orders
WHERE created_at >= '2024-01-01'   -- 先过滤行(可利用索引)
GROUP BY customer_id
HAVING sum(amount) > 1000          -- 再过滤组(在聚合之后)
ORDER BY total_spent DESC;

性能原则: 能用 WHERE 过滤的条件就不要放到 HAVING, 因为 WHERE 发生在聚合之前, 减少参与聚合的行数可以显著降低计算量。HAVING 只用于过滤聚合后才能确定的条件。


5.9 多维汇总: GROUPING SETS、ROLLUP 与 CUBE

标准的 GROUP BY 只能产生一种维度的分组。真实报表往往需要同时展示分类小计、维度小计和总计。PostgreSQL 提供了三种多维汇总语法来满足这一需求。

5.9.1 GROUPING SETS: 自定义多组合

GROUPING SETS 允许在一条 SQL 中指定多个独立的分组规则, 结果纵向堆叠:

sql
-- 同时按(区域)和(产品类别)各自分组汇总
SELECT region, category, sum(revenue) AS total
FROM sales
GROUP BY GROUPING SETS (
    (region),       -- 按区域汇总
    (category),     -- 按类别汇总
    ()              -- 全局总计
);

() 空集代表"不分组", 等价于全局总计行, 对应列值为 NULL。

5.9.2 ROLLUP: 层级小计

ROLLUP(a, b, c) 生成 (a,b,c), (a,b), (a), () 四个层级的汇总, 天然适合有层级关系的维度(如年 → 月 → 日、国家 → 省份 → 城市):

sql
-- 按(区域 → 产品类别)层级生成小计与总计
SELECT
    region,
    category,
    sum(revenue)   AS total_revenue,
    count(*)       AS order_count
FROM sales
GROUP BY ROLLUP (region, category)
ORDER BY region NULLS LAST, category NULLS LAST;

结果示意:

text
region  | category  | total_revenue
--------+-----------+--------------
华北    | 电子产品   |    1,200,000
华北    | 服装       |      800,000
华北    | NULL       |    2,000,000  -- 华北小计
华南    | 电子产品   |      900,000
华南    | NULL       |      900,000  -- 华南小计
NULL    | NULL       |    2,900,000  -- 全局总计

5.9.3 CUBE: 所有维度组合

CUBE(a, b) 生成 (a,b), (a), (b), () 四个分组——即所有维度子集的组合, 适合需要从各个角度切片的交叉报表:

sql
-- 生成区域 × 类别的全维度交叉汇总
SELECT
    region,
    category,
    sum(revenue) AS total_revenue
FROM sales
GROUP BY CUBE (region, category)
ORDER BY region NULLS LAST, category NULLS LAST;

CUBE(a, b)ROLLUP(a, b) 多出 (b) 这一维度(单独按类别汇总), 因此结果行数更多。N 个维度的 CUBE 产生 2^N 个分组组合。

5.9.4 完整销售报表实战

以下示例综合运用 ROLLUP 生成一张含小计行的销售报表:

sql
-- 建立示例数据(可直接执行)
WITH sales(region, category, revenue) AS (
    VALUES
        ('华北', '电子', 600000),
        ('华北', '服装', 400000),
        ('华南', '电子', 900000),
        ('华南', '服装', 500000),
        ('西部', '电子', 300000),
        ('西部', '服装', 200000)
)
SELECT
    coalesce(region,   '【全部区域】') AS region,
    coalesce(category, '【全部类别】') AS category,
    sum(revenue)                        AS total_revenue,
    -- 标记该行是否为小计/总计行
    grouping(region)                    AS is_region_subtotal,
    grouping(category)                  AS is_category_subtotal
FROM sales
GROUP BY ROLLUP (region, category)
ORDER BY
    grouping(region),
    grouping(category),
    region NULLS LAST,
    category NULLS LAST;

grouping(col) 是配套函数: 若该列在当前行是"被聚合掉的"(即对应 NULL 是 ROLLUP 产生的), 返回 1, 否则返回 0。配合 CASEcoalesce 可以将 NULL 替换为有意义的标签。


5.10 SELECT 的逻辑执行顺序: 理解 SQL 的核心心智模型

SQL 的书写顺序逻辑执行顺序不同。很多 SQL 错误(如在 WHERE 中引用 SELECT 别名)都源于对执行顺序的误解。下图展示标准 SELECT 语句各子句的逻辑处理顺序:

几个关键推论:

  1. WHERE不能直接引用 SELECT 列表中定义的别名(因为 WHERE 在 SELECT 之前执行)。想复用表达式, 要么重复写, 要么用子查询/CTE 包一层。

  2. HAVING可以使用聚合函数(如 HAVING count(*) > 5), 但 WHERE 不能。

  3. ORDER BY可以引用 SELECT 别名(ORDER BY 在 SELECT 之后执行), 这是为数不多的"后步骤引用前步骤结果"的例外。

  4. LIMITOFFSET 最后执行, 排序在此之前已完成, 所以"先 ORDER BY 再 LIMIT"是正确且高效的写法。

sql
-- 错误示例: WHERE 中引用 SELECT 别名(报错: column "order_total" does not exist)
SELECT customer_id, sum(amount) AS order_total
FROM orders
WHERE order_total > 1000   -- 错误! order_total 在此阶段还不存在
GROUP BY customer_id;

-- 正确做法一: 在 HAVING 中使用聚合表达式
SELECT customer_id, sum(amount) AS order_total
FROM orders
GROUP BY customer_id
HAVING sum(amount) > 1000;

-- 正确做法二: 用子查询或 CTE 包一层
WITH agg AS (
    SELECT customer_id, sum(amount) AS order_total
    FROM orders
    GROUP BY customer_id
)
SELECT * FROM agg WHERE order_total > 1000;

把执行顺序图贴在工位上

初学 SQL 时记住这张顺序图, 能避免 80% 的语法错误和逻辑错误。遇到"为什么我的别名在这里用不了"或"HAVING 和 WHERE 有什么区别"这类问题时, 对照顺序图思考, 答案立刻清晰。

本章覆盖了从精确过滤到多维聚合的完整查询工具箱。下一章将进入多表查询的核心: 各类 JOIN 的语义与执行原理, 以及子查询与公共表表达式(CTE)的高级用法。

六、多表连接与子查询

现实世界的数据从来不会整整齐齐地躺在一张表里。用户、订单、商品、库存分散在不同的关系表中, 数据库设计的核心原则——范式化——正是把这些关联拆散以消除冗余。本章讨论如何用连接和子查询把它们重新拼回来。

贯穿全章的示例数据库包含四张表:

sql
-- 用户表
CREATE TABLE users (
    id          SERIAL PRIMARY KEY,
    name        VARCHAR(100) NOT NULL,
    email       VARCHAR(200) UNIQUE NOT NULL,
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

-- 订单表
CREATE TABLE orders (
    id          SERIAL PRIMARY KEY,
    user_id     INT REFERENCES users(id),
    status      VARCHAR(20) DEFAULT 'pending',
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

-- 订单明细
CREATE TABLE order_items (
    id          SERIAL PRIMARY KEY,
    order_id    INT REFERENCES orders(id),
    product_id  INT REFERENCES products(id),
    quantity    INT NOT NULL,
    unit_price  NUMERIC(10,2) NOT NULL
);

-- 商品表
CREATE TABLE products (
    id          SERIAL PRIMARY KEY,
    name        VARCHAR(200) NOT NULL,
    price       NUMERIC(10,2) NOT NULL,
    category    VARCHAR(50)
);

6.1 INNER JOIN: 只保留两侧都有匹配的行

基本语法

INNER JOIN(可简写为 JOIN)是最常用的连接形式。它通过 ON 子句指定连接条件, 只返回两张表中同时满足条件的行——没有匹配的行会被直接丢弃。

sql
SELECT
    o.id        AS order_id,
    u.name      AS user_name,
    o.status,
    o.created_at
FROM orders AS o
INNER JOIN users AS u ON o.user_id = u.id;

执行结果中, 如果某条订单的 user_idusers 表中找不到对应记录(例如历史遗留的脏数据), 该订单行会直接消失。这正是 INNER JOIN 的语义: 取两个集合的交集

ON 子句的写法

ON 后面可以跟任意布尔表达式, 不局限于等值比较。

等值连接(最常见):

sql
FROM orders o
JOIN users u ON o.user_id = u.id

非等值连接(范围匹配):

sql
-- 查询价格落在某促销区间内的订单明细
SELECT oi.*, p.name
FROM order_items oi
JOIN products p
    ON oi.product_id = p.id
    AND oi.unit_price BETWEEN p.price * 0.8 AND p.price * 1.2;

多条件 ON(用 AND 拼接):

sql
FROM order_items oi
JOIN products p
    ON oi.product_id = p.id
    AND p.category = 'electronics';

把过滤条件写在 ON 还是 WHERE?

对 INNER JOIN 而言, ONWHERE 在逻辑上等价——优化器会合并处理。但约定俗成: 连接条件写 ON, 业务过滤写 WHERE。这样阅读 SQL 时能快速区分"这两张表怎么关联"和"要哪些数据"。对外连接则必须区分(见下节陷阱)。


6.2 LEFT / RIGHT / FULL OUTER JOIN: 保留无匹配的行

LEFT JOIN: 不丢弃左表数据

LEFT OUTER JOIN(通常简写为 LEFT JOIN)返回左表的全部行, 对于在右表中找不到匹配的行, 右侧的所有列填充 NULL

典型场景——查出所有用户, 以及他们可能有也可能没有的订单:

sql
SELECT
    u.id        AS user_id,
    u.name,
    o.id        AS order_id,
    o.status
FROM users AS u
LEFT JOIN orders AS o ON o.user_id = u.id;

从未下过单的用户也会出现在结果集里, 只是 order_idstatus 列为 NULL

经典陷阱: LEFT JOIN + WHERE 右表列

这是新手最容易踩的坑:

sql
-- 意图: 查出所有用户及其"已完成"的订单
-- 实际效果: 等价于 INNER JOIN, 把没有已完成订单的用户踢掉了!
SELECT u.name, o.status
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.status = 'completed';   -- ← 罪魁祸首

原因很简单: 对于没有订单的用户, o.statusNULL; NULL = 'completed' 的结果为 NULL(既不是 TRUE 也不是 FALSE), 该行被 WHERE 过滤掉, LEFT JOIN 的效果荡然无存。

正确写法是把过滤条件移进 ON:

sql
SELECT u.name, o.status
FROM users u
LEFT JOIN orders o
    ON o.user_id = u.id
    AND o.status = 'completed';  -- ← 移到 ON 子句

现在没有已完成订单的用户仍然出现在结果中, 其 o.statusNULL

LEFT JOIN 后 WHERE 右表列 = 值

这会悄悄把 LEFT JOIN 变成 INNER JOIN, 且不报任何错误。每次用 LEFT JOIN 时都要审查 WHERE 子句里是否有右表的非 NULL 过滤条件。

RIGHT JOIN: 与 LEFT JOIN 对称

RIGHT JOIN 保留右表的所有行, 左表无匹配则填 NULL。在实践中几乎不用, 因为任何 RIGHT JOIN 都能通过交换两张表的位置改写为 LEFT JOIN, 而大多数人更习惯从左往右读"主表 + 扩展信息"的结构。

sql
-- 这两条查询等价
FROM users u RIGHT JOIN orders o ON o.user_id = u.id;
FROM orders o LEFT JOIN users u ON o.user_id = u.id;

FULL OUTER JOIN: 两侧都保留

FULL OUTER JOIN 是 LEFT JOIN 和 RIGHT JOIN 的并集。两侧各自无法匹配的行都会出现在结果中, 缺失的一侧填 NULL。

典型用途——对账与差异比较:

sql
-- 比较两个周期的销售数据, 找出只出现在其中一个周期的商品
SELECT
    COALESCE(a.product_id, b.product_id) AS product_id,
    a.total_sold    AS week1_sold,
    b.total_sold    AS week2_sold
FROM (
    SELECT product_id, SUM(quantity) AS total_sold
    FROM order_items
    WHERE order_id IN (SELECT id FROM orders WHERE created_at >= '2025-01-01'
                                                AND created_at < '2025-01-08')
    GROUP BY product_id
) AS a
FULL OUTER JOIN (
    SELECT product_id, SUM(quantity) AS total_sold
    FROM order_items
    WHERE order_id IN (SELECT id FROM orders WHERE created_at >= '2025-01-08'
                                                AND created_at < '2025-01-15')
    GROUP BY product_id
) AS b ON a.product_id = b.product_id;

week1_sold 为 NULL 说明该商品只在第二周有销售; week2_sold 为 NULL 说明只在第一周有销售。

JOIN 类型的直观示意


6.3 CROSS JOIN 与连接语法变体

CROSS JOIN: 笛卡尔积

CROSS JOIN 不需要连接条件, 它返回两张表所有行的全量组合。若左表有 M 行, 右表有 N 行, 结果集就有 M × N 行。

sql
-- 生成所有颜色与尺码的组合(用于预填库存矩阵)
SELECT c.color_name, s.size_label
FROM colors AS c
CROSS JOIN sizes AS s;

CROSS JOIN 的危险性

对大表做 CROSS JOIN 会生成天量数据。执行前务必评估行数乘积。一个 10 万行 × 10 万行的笛卡尔积会产生 100 亿行结果, 足以压垮数据库。

合理使用场景: 生成所有排列组合(商品属性矩阵、日期序列补全、测试数据生成)。

ON vs USING vs NATURAL

PostgreSQL 支持三种连接条件写法:

ON(最通用, 推荐):

sql
FROM orders o JOIN users u ON o.user_id = u.id;

USING(col)(当两表有同名列时可简化):

sql
-- 要求两张表都有名为 user_id 的列
FROM orders JOIN users USING (user_id);
-- 结果集中 user_id 只出现一次(不像 ON 会有两列)

NATURAL JOIN(自动匹配所有同名列, 不推荐):

sql
FROM orders NATURAL JOIN users;
-- PostgreSQL 会自动找到两表都有的列(如 id、created_at 等)并全部作为连接条件

NATURAL JOIN 的风险在于: 一旦其中一张表新增了一个与另一张表同名的列(例如两表都新增了 updated_at), 连接条件会悄悄增加, 查询结果可能在毫无征兆的情况下改变。这类 bug 极难排查。

连接语法推荐

生产代码始终使用 ONUSING 可在代码审查严格的团队中酌情使用。永远不要在生产代码中使用 NATURAL JOIN


6.4 自连接: 同一张表, 两个身份

自连接(self join)是指将同一张表用不同别名连接两次, 用于查询同一实体内部的层级或比较关系。

组织架构树: 查询员工与其直属上级

sql
CREATE TABLE employees (
    id          SERIAL PRIMARY KEY,
    name        VARCHAR(100) NOT NULL,
    manager_id  INT REFERENCES employees(id)  -- 指向同表的 id
);

查询每位员工及其直属上级的姓名:

sql
SELECT
    e.id            AS employee_id,
    e.name          AS employee_name,
    m.name          AS manager_name
FROM employees AS e
LEFT JOIN employees AS m ON e.manager_id = m.id
ORDER BY m.name NULLS FIRST, e.name;

使用 LEFT JOIN 而非 INNER JOIN 是为了保留 CEO 或顶层节点——他们的 manager_id 为 NULL, INNER JOIN 会把他们过滤掉。

查询所有下属(跨层级)需要递归 CTE, 这在第七章介绍 WITH RECURSIVE 时会详细讲解。当前只用一次自连接只能查一层。

查询同级同事(共享同一 manager_id):

sql
SELECT
    e1.name AS employee,
    e2.name AS colleague
FROM employees e1
JOIN employees e2
    ON e1.manager_id = e2.manager_id
    AND e1.id <> e2.id  -- 排除自己和自己配对
ORDER BY e1.name, e2.name;

6.5 多表连接的书写顺序与可读性

优化器与书写顺序

PostgreSQL 的查询优化器(基于代价的 CBO)会自动决定物理连接顺序, 即实际执行时哪张表先扫描、哪张表做驱动。因此, SQL 中 FROM 子句的表顺序不决定性能, 决定性能的是统计信息、索引和 join_collapse_limit 等参数。

书写顺序影响的是可读性。建议遵循以下约定:

  1. 把最重要的"主体"表放在 FROM 后面——通常是你最关心的实体(如 orders)。
  2. 连接顺序按数据流向排列——先连接能缩小结果集的表。
  3. 每条 JOIN 子句独占一行, ON 条件缩进对齐。
  4. 给所有表起短别名, 避免在 SELECT 中重复写长表名。

推荐的格式示例:

sql
SELECT
    o.id            AS order_id,
    u.name          AS user_name,
    p.name          AS product_name,
    oi.quantity,
    oi.unit_price,
    oi.quantity * oi.unit_price AS line_total
FROM orders AS o
    JOIN users       AS u  ON u.id  = o.user_id
    JOIN order_items AS oi ON oi.order_id = o.id
    JOIN products    AS p  ON p.id  = oi.product_id
WHERE o.status = 'completed'
ORDER BY o.created_at DESC;

这条查询以 orders 为核心向外扩展, 阅读时逻辑清晰: "完成的订单, 拼上用户名、明细、商品名"。


6.6 子查询全谱

子查询(subquery)是嵌套在另一条 SQL 语句内部的 SELECT。根据嵌套位置和返回形态的不同, 子查询分为多种类型。

6.6.1 标量子查询

标量子查询放在 SELECT 列表中, 必须返回恰好一行一列——否则 PostgreSQL 会抛出 ERROR: more than one row returned by a subquery used as an expression

sql
-- 查询每条订单及该订单用户的历史消费总额
SELECT
    o.id                AS order_id,
    o.status,
    (
        SELECT SUM(oi2.quantity * oi2.unit_price)
        FROM orders o2
        JOIN order_items oi2 ON oi2.order_id = o2.id
        WHERE o2.user_id = o.user_id        -- 引用外层 o.user_id
    )                   AS user_total_spent
FROM orders AS o
WHERE o.status = 'completed';

这里的子查询引用了外层的 o.user_id, 因此它是一个相关子查询(correlated subquery), 外层每行都会驱动子查询执行一次。数据量大时应考虑改写为 JOIN + 窗口函数。

6.6.2 行子查询

行子查询返回一行多列, 常与行构造器 (col1, col2) 配合使用。

sql
-- 找出与指定订单(id=42)具有相同 user_id 和 status 的其他订单
SELECT id, user_id, status
FROM orders
WHERE (user_id, status) = (
    SELECT user_id, status
    FROM orders
    WHERE id = 42
)
AND id <> 42;

行子查询语法简洁, 但在实际工程中并不常见, 了解即可。

6.6.3 表子查询(派生表)

表子查询出现在 FROM 子句中, 充当一张临时的虚拟表。PostgreSQL 要求它必须有别名。

sql
-- 统计每位用户的订单数和消费总额, 只展示消费超过 1000 的用户
SELECT
    u.name,
    stats.order_count,
    stats.total_spent
FROM users AS u
JOIN (
    SELECT
        o.user_id,
        COUNT(*)                            AS order_count,
        SUM(oi.quantity * oi.unit_price)    AS total_spent
    FROM orders AS o
    JOIN order_items AS oi ON oi.order_id = o.id
    GROUP BY o.user_id
) AS stats ON stats.user_id = u.id
WHERE stats.total_spent > 1000
ORDER BY stats.total_spent DESC;

派生表是将复杂聚合逻辑封装、再与其他表关联的常用手段。现代 PostgreSQL 中这类写法通常可以用公共表表达式(WITH 子句)替代, 可读性更好——第七章会专门讲解。

6.6.4 WHERE 子句中的子查询: IN / NOT IN / EXISTS / NOT EXISTS / ANY / ALL

IN / NOT IN

sql
-- 查询购买过"electronics"类商品的用户
SELECT DISTINCT u.id, u.name
FROM users u
WHERE u.id IN (
    SELECT o.user_id
    FROM orders o
    JOIN order_items oi ON oi.order_id = o.id
    JOIN products p     ON p.id = oi.product_id
    WHERE p.category = 'electronics'
);

EXISTS / NOT EXISTS

EXISTS 检查子查询是否返回至少一行, 一旦找到就停止扫描, 不关心具体列值。

sql
-- 等价于上方 IN 的写法, 通常在子查询结果集大时性能更好
SELECT u.id, u.name
FROM users u
WHERE EXISTS (
    SELECT 1
    FROM orders o
    JOIN order_items oi ON oi.order_id = o.id
    JOIN products p     ON p.id = oi.product_id
    WHERE p.category = 'electronics'
      AND o.user_id = u.id
);

ANY(SOME) / ALL

sql
-- 找出单价高于任意一款"books"类商品价格的 order_items
SELECT * FROM order_items
WHERE unit_price > ANY (
    SELECT price FROM products WHERE category = 'books'
);

-- 找出单价高于所有"books"类商品价格的 order_items(即高于最贵的)
SELECT * FROM order_items
WHERE unit_price > ALL (
    SELECT price FROM products WHERE category = 'books'
);

> ANY 等价于 > MIN(...), > ALL 等价于 > MAX(...), 但 ANY/ALL 写法语义更直白。

6.6.5 NOT IN 与 NULL: 一个危险的陷阱

NOT IN 遇到子查询结果集中含有 NULL 时会产生反直觉的行为:

sql
-- 查询从未下过单的用户
-- 看起来正确, 实则危险:
SELECT id, name FROM users
WHERE id NOT IN (SELECT user_id FROM orders);

如果 orders.user_id 中存在任何一条 NULL 记录(例如匿名订单), 整个 NOT IN 的结果会变成空集——没有任何用户被返回。

原因: NOT IN 在底层展开为 id <> v1 AND id <> v2 AND ...。当列表中有 NULL 时, id <> NULL 的值是 NULL(未知), AND 链上任何 NULL 都使整条表达式为 NULL, 最终没有行满足条件。

安全写法是用 NOT EXISTS:

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

NOT EXISTS 只关心"有没有行", NULL 在这里不会产生歧义。

NOT IN 与 NULL

只要子查询可能返回 NULL(即对应列没有 NOT NULL 约束), 就不要使用 NOT IN, 改用 NOT EXISTSLEFT JOIN ... WHERE right.col IS NULL

6.6.6 相关子查询 vs 非相关子查询

非相关子查询独立于外层查询, 只需执行一次:

sql
-- 子查询不引用外层列, 执行一次, 结果被缓存复用
SELECT * FROM products
WHERE price > (SELECT AVG(price) FROM products);

相关子查询引用外层表的列, 外层每行都要重新执行一次子查询:

sql
-- 对每一行 products, 都执行一次子查询计算该 category 的平均价
SELECT p.name, p.price
FROM products p
WHERE p.price > (
    SELECT AVG(p2.price)
    FROM products p2
    WHERE p2.category = p.category  -- ← 引用外层 p.category
);

相关子查询的潜在性能问题: 若外层返回 10 万行, 子查询就执行 10 万次。优化器有时能自动将其改写为 JOIN 或窗口函数, 但不能依赖这种优化。通常的改写方向是:

sql
-- 将相关子查询改写为窗口函数(性能大幅提升)
SELECT name, price
FROM (
    SELECT
        name,
        price,
        AVG(price) OVER (PARTITION BY category) AS avg_category_price
    FROM products
) t
WHERE price > avg_category_price;

6.7 LATERAL 连接: PostgreSQL 的利器

什么是 LATERAL

在普通的多表连接中, FROM 子句右侧的表(或子查询)不能引用左侧已出现的表的列——每个表表达式在逻辑上是独立求值的。LATERAL 关键字打破了这个限制: 它允许后面的子查询引用前面表的列, 就像 for 循环的循环体可以使用循环变量一样。

可以把 LATERAL 理解为一种相关子查询在 FROM 子句中的合法形式——但它比标量相关子查询强大, 因为它可以返回多列多行。

经典用例: 每个用户取最近 3 笔订单

不用 LATERAL 时, 这个需求很难用单条 SQL 干净地表达——窗口函数 + 子查询可以做, 但结构繁琐。LATERAL 让它变得自然:

sql
SELECT
    u.id        AS user_id,
    u.name,
    recent.id   AS order_id,
    recent.status,
    recent.created_at
FROM users AS u
CROSS JOIN LATERAL (
    SELECT id, status, created_at
    FROM orders
    WHERE user_id = u.id            -- ← 引用外层 u.id
    ORDER BY created_at DESC
    LIMIT 3
) AS recent
ORDER BY u.id, recent.created_at DESC;

这里用 CROSS JOIN LATERAL 是因为要把每个用户和其对应的最近 3 笔订单全部展开。如果某个用户没有任何订单, CROSS JOIN LATERAL 会让该用户从结果中消失(类似 INNER JOIN 的语义)。

若希望保留没有订单的用户(类似 LEFT JOIN 的语义), 改用 LEFT JOIN LATERAL ... ON TRUE:

sql
SELECT
    u.id        AS user_id,
    u.name,
    recent.id   AS order_id,
    recent.created_at
FROM users AS u
LEFT JOIN LATERAL (
    SELECT id, status, created_at
    FROM orders
    WHERE user_id = u.id
    ORDER BY created_at DESC
    LIMIT 3
) AS recent ON TRUE          -- ← LEFT JOIN LATERAL 必须带 ON TRUE
ORDER BY u.id, recent.created_at DESC;

CROSS JOIN LATERAL vs LEFT JOIN LATERAL

写法语义左表无匹配时
CROSS JOIN LATERAL类似 INNER JOIN左表行消失
LEFT JOIN LATERAL ... ON TRUE类似 LEFT JOIN左表行保留, 右侧列为 NULL

LATERAL 的其他应用场景

展开函数返回值: PostgreSQL 很多函数返回集合(set-returning functions), 与 LATERAL 天然配合:

sql
-- 为每个产品类别生成一个 series(演示)
SELECT c.category, gs.n
FROM (SELECT DISTINCT category FROM products) AS c
CROSS JOIN LATERAL generate_series(1, 3) AS gs(n);

配合 json_to_recordset 解析 JSON 数组字段时也经常用到 LATERAL。

LATERAL 的性能

LATERAL 子查询本质上是相关子查询, 外层每行都执行一次内层查询。因此内层查询必须有合适的索引支撑(如上例中 orders(user_id, created_at) 复合索引), 否则会退化为全表扫描 × 外层行数的灾难性代价。


6.8 贯穿案例: 四表联查实战

本节用 users / orders / order_items / products 四张表演示几条有实际业务意义的查询。

案例一: 订单完整明细

查询所有已完成订单的完整明细, 包括用户名、商品名、数量、单价和行小计:

sql
SELECT
    o.id                                        AS order_id,
    o.created_at                                AS order_date,
    u.name                                      AS user_name,
    p.name                                      AS product_name,
    p.category,
    oi.quantity,
    oi.unit_price,
    oi.quantity * oi.unit_price                 AS line_total
FROM orders AS o
    JOIN users       AS u  ON u.id  = o.user_id
    JOIN order_items AS oi ON oi.order_id = o.id
    JOIN products    AS p  ON p.id  = oi.product_id
WHERE o.status = 'completed'
ORDER BY o.created_at DESC, o.id, p.name;

案例二: 用户消费统计

统计每位用户的订单总数、消费总额、平均单笔金额, 按消费总额降序排列, 只展示下过至少 2 单的用户:

sql
SELECT
    u.id                                        AS user_id,
    u.name,
    u.email,
    COUNT(DISTINCT o.id)                        AS order_count,
    SUM(oi.quantity * oi.unit_price)            AS total_spent,
    ROUND(
        SUM(oi.quantity * oi.unit_price)
        / NULLIF(COUNT(DISTINCT o.id), 0),
        2
    )                                           AS avg_order_value
FROM users AS u
    JOIN orders      AS o  ON o.user_id = u.id
    JOIN order_items AS oi ON oi.order_id = o.id
WHERE o.status = 'completed'
GROUP BY u.id, u.name, u.email
HAVING COUNT(DISTINCT o.id) >= 2
ORDER BY total_spent DESC;

NULLIF(COUNT(DISTINCT o.id), 0) 避免除以零(虽然 HAVING 已经过滤了 0 单的情况, 防御性编程是好习惯)。

案例三: 从未下单的用户

找出所有从未下过任何订单的用户(常用于精准营销触达):

sql
-- 方法 A: NOT EXISTS(推荐, 对 NULL 安全)
SELECT u.id, u.name, u.email, u.created_at
FROM users AS u
WHERE NOT EXISTS (
    SELECT 1 FROM orders WHERE user_id = u.id
)
ORDER BY u.created_at DESC;

-- 方法 B: LEFT JOIN + IS NULL(等价, 有时执行计划更优)
SELECT u.id, u.name, u.email, u.created_at
FROM users AS u
LEFT JOIN orders AS o ON o.user_id = u.id
WHERE o.id IS NULL
ORDER BY u.created_at DESC;

两种方法在语义上等价, PostgreSQL 优化器往往会生成相同的执行计划。在有索引的情况下 NOT EXISTS 版本通常略快, 因为找到第一行匹配即可停止。

案例四: 每个用户购买最多的商品类别

用派生表 + 窗口函数求每位用户消费最多的商品类别(Top-1):

sql
SELECT user_id, user_name, category, category_spent
FROM (
    SELECT
        u.id                                AS user_id,
        u.name                              AS user_name,
        p.category,
        SUM(oi.quantity * oi.unit_price)    AS category_spent,
        RANK() OVER (
            PARTITION BY u.id
            ORDER BY SUM(oi.quantity * oi.unit_price) DESC
        )                                   AS rnk
    FROM users AS u
        JOIN orders      AS o  ON o.user_id = u.id
        JOIN order_items AS oi ON oi.order_id = o.id
        JOIN products    AS p  ON p.id = oi.product_id
    WHERE o.status = 'completed'
    GROUP BY u.id, u.name, p.category
) AS ranked
WHERE rnk = 1
ORDER BY category_spent DESC;

这里将窗口函数 RANK() 嵌套在派生表里, 外层再用 WHERE rnk = 1 过滤——这是窗口函数必须包在子查询中才能在 WHERE 里引用的标准写法。


6.9 本章小结

本章系统梳理了 PostgreSQL 多表查询的全部工具箱:

  • INNER JOIN 取交集, LEFT JOIN 保留左表, FULL OUTER JOIN 保留两侧——理解每种 JOIN 的"不匹配行如何处理"是关键。
  • LEFT JOIN 后接 WHERE 右表列过滤是经典陷阱, 务必将该条件移进 ON 子句。
  • NOT IN 遇 NULL 全军覆没, 生产代码优先使用 NOT EXISTS
  • 子查询按位置分为标量、行、表三类; 按是否引用外层分为相关与非相关两类——相关子查询的性能隐患要提前识别并改写。
  • LATERAL 是 PostgreSQL 的特色利器, "每行驱动一个子查询并可返回多行"的语义让"TopN per group"等需求变得优雅。

下一章将介绍公共表表达式(WITH / 递归 CTE)、窗口函数进阶, 以及如何用执行计划分析并优化这些复杂查询。

七、CTE 与窗口函数

公共表表达式(CTE)与窗口函数是 PostgreSQL 中两类极具表达力的特性。前者让复杂查询拥有清晰的层次结构, 后者则在不折叠行数的前提下为每一行计算上下文感知的结果。掌握这两者, 是从"会写 SQL"迈向"写好 SQL"的关键跨越。

7.1 普通 CTE(WITH 子句)

7.1.1 基本语法

CTE 通过 WITH 关键字在主查询前定义一个或多个命名子查询, 语法如下:

sql
WITH cte_name AS (
    SELECT ...
)
SELECT * FROM cte_name;

多个 CTE 之间用逗号分隔, 后定义的 CTE 可以引用前面已定义的 CTE:

sql
WITH
  active_users AS (
      SELECT id, name, created_at
      FROM users
      WHERE status = 'active'
  ),
  recent_orders AS (
      SELECT user_id, COUNT(*) AS order_count
      FROM orders
      WHERE created_at >= NOW() - INTERVAL '30 days'
      GROUP BY user_id
  )
SELECT u.name, COALESCE(o.order_count, 0) AS orders_30d
FROM active_users u
LEFT JOIN recent_orders o ON o.user_id = u.id
ORDER BY orders_30d DESC;

这比将子查询嵌套在 FROM 中更易阅读: 每个 CTE 只做一件事, 主查询负责组合结果。

7.1.2 提升可读性的典型场景

设想一个需求: 找出最近 7 天内下单超过 3 次、且所有订单均已完成的用户, 并按消费金额倒序排列。不用 CTE 时, FROM 子句里层层嵌套, 调试时几乎无法逐步验证中间结果。改用 CTE 后, 每一步都可以单独执行:

sql
WITH
  recent_active AS (
      SELECT user_id
      FROM orders
      WHERE created_at >= NOW() - INTERVAL '7 days'
      GROUP BY user_id
      HAVING COUNT(*) > 3
  ),
  fully_completed AS (
      SELECT user_id
      FROM orders
      WHERE status <> 'completed'
      GROUP BY user_id
      HAVING COUNT(*) = 0  -- 没有非完成订单
  ),
  qualified AS (
      SELECT ra.user_id
      FROM recent_active ra
      JOIN fully_completed fc ON fc.user_id = ra.user_id
  ),
  spending AS (
      SELECT o.user_id, SUM(o.amount) AS total_amount
      FROM orders o
      JOIN qualified q ON q.user_id = o.user_id
      GROUP BY o.user_id
  )
SELECT u.name, s.total_amount
FROM spending s
JOIN users u ON u.id = s.user_id
ORDER BY s.total_amount DESC;

每个 CTE 命名清晰, 可以独立拎出来执行调试, 维护成本大幅降低。

7.1.3 同一 CTE 可多次引用

主查询中可以多次引用同一个 CTE, 无需重写子查询。例如自连接 CTE 来计算用户与其平均值的偏差:

sql
WITH dept_avg AS (
    SELECT department_id, AVG(salary) AS avg_salary
    FROM employees
    GROUP BY department_id
)
SELECT
    e.name,
    e.salary,
    da.avg_salary,
    ROUND(e.salary - da.avg_salary, 2) AS deviation
FROM employees e
JOIN dept_avg da ON da.department_id = e.department_id;

dept_avg 只定义一次, 但在语义上可以被多次 JOIN。是否真正执行多次取决于优化器策略(见下节)。

7.1.4 物化 vs 内联: PG12 前后的重大变化

这是 CTE 最容易踩坑的地方。

PG12 之前: CTE 默认物化(Materialized), 即优化器会先把 CTE 的结果集算出来存入内存临时区, 再供主查询使用。物化的 CTE 相当于一堵"优化屏障": 主查询的过滤条件无法下推进 CTE 内部, 索引也无法被 CTE 内部利用。

sql
-- PG11 及以前, 以下查询会全表扫描 orders 后再过滤
WITH all_orders AS (
    SELECT * FROM orders  -- 优化器不知道外层只需要 user_id = 42
)
SELECT * FROM all_orders WHERE user_id = 42;

PG12 及以后: CTE 默认内联(Not Materialized), 优化器可以把 CTE 当成普通子查询穿透优化, 上例在 PG12+ 会正常走 user_id 的索引。

显式控制关键字:

sql
-- 强制物化(适合 CTE 结果较小且被多次引用, 避免重复计算)
WITH expensive_calc AS MATERIALIZED (
    SELECT id, heavy_function(col) AS result
    FROM big_table
)
SELECT * FROM expensive_calc WHERE result > 100
UNION ALL
SELECT * FROM expensive_calc WHERE result < 0;

-- 强制内联(适合 CTE 仅引用一次, 且希望优化器充分下推)
WITH filtered AS NOT MATERIALIZED (
    SELECT * FROM orders
)
SELECT * FROM filtered WHERE user_id = 42;

何时手动指定 MATERIALIZED

如果同一个 CTE 在主查询中被引用超过一次, 且其内部计算代价较高(如含聚合或复杂函数), 显式加 MATERIALIZED 可以避免重复执行。反之, 若 CTE 只引用一次且主查询有高选择性的过滤条件, 用 NOT MATERIALIZED 或保持默认内联更佳。


7.2 递归 CTE(WITH RECURSIVE)详解

7.2.1 执行模型

递归 CTE 的语法结构固定为:

sql
WITH RECURSIVE cte_name AS (
    -- 锚成员(初始查询, 不引用 cte_name 自身)
    SELECT ...
    UNION ALL
    -- 递归成员(必须引用 cte_name)
    SELECT ... FROM cte_name WHERE <终止条件>
)
SELECT * FROM cte_name;

执行过程如下:

  1. 第 0 轮: 执行锚成员, 得到初始结果集 R₀, 存入工作表(working table)。
  2. 第 1 轮: 以 R₀ 为输入执行递归成员, 得到 R₁; 若 R₁ 非空, 继续下一轮。
  3. 第 k 轮: 以 Rₖ₋₁ 为输入执行递归成员, 直到某轮返回空集, 迭代终止。
  4. 最终结果: R₀ ∪ R₁ ∪ … ∪ Rₖ 的并集(因 UNION ALL), 供外层查询使用。

只能用 UNION ALL, 不能用 UNION

递归部分必须用 UNION ALL 而非 UNIONUNION 会做去重, 但递归中间状态的去重语义复杂且代价高, PostgreSQL 不支持在递归成员中使用 UNION。去重应在外层查询或通过路径追踪实现。

7.2.2 防无限递归

若数据中存在环(如组织架构中的循环汇报关系), 递归 CTE 会陷入无限循环直至报错。两种防护方式:

方式一: 手动 depth 计数器

sql
WITH RECURSIVE traversal AS (
    SELECT id, name, 1 AS depth
    FROM nodes WHERE parent_id IS NULL
    UNION ALL
    SELECT n.id, n.name, t.depth + 1
    FROM nodes n
    JOIN traversal t ON t.id = n.parent_id
    WHERE t.depth < 20  -- 硬限制最大层数
)
SELECT * FROM traversal;

方式二: PG14+ 的 CYCLE 子句

sql
WITH RECURSIVE traversal AS (
    SELECT id, parent_id, name
    FROM nodes WHERE parent_id IS NULL
    UNION ALL
    SELECT n.id, n.parent_id, n.name
    FROM nodes n
    JOIN traversal t ON t.id = n.parent_id
)
CYCLE id SET is_cycle USING path
SELECT * FROM traversal WHERE NOT is_cycle;

CYCLE id 表示用 id 字段检测是否已在当前路径中出现过; is_cycle 是自动生成的布尔列; path 记录从根到当前节点经过的所有 id 数组。语法简洁, 且不需要手写路径追踪逻辑。

7.2.3 实战 1: 生成 1 到 100 的整数序列

sql
WITH RECURSIVE nums AS (
    SELECT 1 AS n
    UNION ALL
    SELECT n + 1 FROM nums WHERE n < 100
)
SELECT n FROM nums;

虽然 PostgreSQL 有 generate_series(1, 100) 更简便, 但此例清晰展示了递归 CTE 最基础的"步进"模式: 锚成员给出起点 1, 递归成员每次加 1, 终止条件 n < 100 确保最后一轮生成 100 后停止。

7.2.4 实战 2: 遍历组织架构树

表结构:

sql
CREATE TABLE employees (
    id         INT PRIMARY KEY,
    name       TEXT NOT NULL,
    manager_id INT REFERENCES employees(id)  -- 根节点此字段为 NULL
);

查询某员工(id = 5)及其所有下属, 并用 path 追踪路径防止环形数据导致死循环:

sql
WITH RECURSIVE org_tree AS (
    -- 锚成员: 起点员工
    SELECT
        id,
        name,
        manager_id,
        0            AS depth,
        ARRAY[id]    AS path   -- 记录路径, 用于防环
    FROM employees
    WHERE id = 5

    UNION ALL

    -- 递归成员: 找下一级直接下属
    SELECT
        e.id,
        e.name,
        e.manager_id,
        ot.depth + 1,
        ot.path || e.id        -- 路径追加
    FROM employees e
    JOIN org_tree ot ON ot.id = e.manager_id
    WHERE NOT (e.id = ANY(ot.path))  -- 防环: 若 id 已在路径中则跳过
)
SELECT
    depth,
    REPEAT('  ', depth) || name AS indented_name,
    path
FROM org_tree
ORDER BY path;

path 列既是防环哨兵, 也可以用于展示完整的汇报链。REPEAT(' ', depth) 按层级缩进, 便于阅读。

7.2.5 实战 3: 展开评论楼中楼

表结构:

sql
CREATE TABLE comments (
    id        SERIAL PRIMARY KEY,
    parent_id INT REFERENCES comments(id),  -- 顶层评论此字段为 NULL
    content   TEXT NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

查询 id = 10 这条评论下的所有子孙评论, 并按层级缩进展示:

sql
WITH RECURSIVE comment_tree AS (
    -- 锚成员: 起始评论本身
    SELECT
        id,
        parent_id,
        content,
        0         AS depth,
        ARRAY[id] AS path
    FROM comments
    WHERE id = 10

    UNION ALL

    -- 递归成员: 直接子评论
    SELECT
        c.id,
        c.parent_id,
        c.content,
        ct.depth + 1,
        ct.path || c.id
    FROM comments c
    JOIN comment_tree ct ON ct.id = c.parent_id
    WHERE NOT (c.id = ANY(ct.path))
)
SELECT
    depth,
    REPEAT('│  ', GREATEST(depth - 1, 0))
        || CASE WHEN depth > 0 THEN '├─ ' ELSE '' END
        || content AS display,
    created_at
FROM comment_tree
ORDER BY path;

path 数组的字典序排列恰好复现评论的深度优先顺序, 用 ORDER BY path 即可得到符合直觉的展示顺序, 无需额外排序字段。


7.3 窗口函数核心概念

7.3.1 与聚合函数的本质区别

聚合函数(如 SUMAVGCOUNT)将多行折叠为一行。窗口函数则不同: 它为每一行计算一个值, 结果集的行数与输入完全相同。

sql
-- 聚合: 5 行数据 → 1 行结果
SELECT department, SUM(salary) FROM employees GROUP BY department;

-- 窗口: 5 行数据 → 5 行结果, 每行附带部门汇总
SELECT
    name,
    salary,
    department,
    SUM(salary) OVER (PARTITION BY department) AS dept_total
FROM employees;

窗口函数通过 OVER(...) 子句标识, 没有 OVER 就不是窗口函数。带 OVER 的聚合函数(如 SUM() OVER(...))也被视为窗口函数。

7.3.2 OVER() 的三个维度

OVER() 括号内可以包含三个可选部分:

sql
函数名() OVER (
    PARTITION BY <分组列>          -- 把结果集切分为若干独立窗口
    ORDER BY <排序列> [ASC|DESC]   -- 在窗口内对行排序
    ROWS|RANGE BETWEEN <起点> AND <终点>  -- 限定每行的计算范围
)
  • PARTITION BY: 类似 GROUP BY, 但不折叠行。每个分区内独立计算。省略则整个结果集视为一个分区。
  • ORDER BY: 决定窗口内行的顺序。对排名函数和取值函数(lag/lead)不可缺少。
  • Frame 子句: 进一步限定当前行"能看到"哪些行参与计算(见下节)。

7.3.3 窗口框架(Frame)详解

Frame 只在有 ORDER BY 的窗口中才有意义。当指定 ORDER BY 时, 默认 Frame 为:

RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW

这意味着"从分区第一行到当前行(包含所有与当前行 ORDER BY 值相同的行)"。

ROWSRANGE 的核心差异:

关键字含义
ROWS BETWEEN n PRECEDING AND CURRENT ROW物理上往前数 n 行
RANGE BETWEEN n PRECEDING AND CURRENT ROW逻辑上值域在 [当前值 - n, 当前值] 之间的所有行

常用边界关键字:

  • UNBOUNDED PRECEDING: 分区第一行
  • CURRENT ROW: 当前行
  • UNBOUNDED FOLLOWING: 分区最后一行
  • N PRECEDING / N FOLLOWING: 物理或逻辑偏移 N

陷阱案例: 默认 Frame 导致移动平均结果不符预期

假设要计算每天销售额的"过去 3 天平均(含当天)":

sql
-- 错误写法: 省略 frame, 使用默认 RANGE UNBOUNDED PRECEDING
SELECT
    sale_date,
    amount,
    AVG(amount) OVER (ORDER BY sale_date) AS wrong_avg
FROM daily_sales;

由于默认 Frame 是 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, 这实际上是从第一天到当天的累计平均, 而非滑动 3 天窗口。

sql
-- 正确写法: 显式指定 ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
SELECT
    sale_date,
    amount,
    AVG(amount) OVER (
        ORDER BY sale_date
        ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
    ) AS moving_avg_3d
FROM daily_sales;

ROWS BETWEEN 2 PRECEDING AND CURRENT ROW 精确地表示"当前行及物理上前两行", 共 3 行参与平均。

上图展示了 PARTITION BY dept 将结果集切分为两个独立窗口, 以及 Frame 如何在窗口 B 中限定行 4 的计算范围仅包含行 3 和行 4。

Frame 陷阱速查

ORDER BY 无显式 Frame → 默认 RANGE UNBOUNDED PRECEDING, 即累计到当前行。想做滑动窗口必须显式写 ROWS BETWEEN ... AND ...。无 ORDER BY 无 Frame → 整个分区参与计算。


7.4 排名函数

排名函数是窗口函数中使用频率最高的一类, 也是面试中的常考题。四个核心函数对同一数据集会给出不同的排名结果, 关键在于如何处理并列(tie)情况。

7.4.1 四种排名函数对比

sql
SELECT
    name,
    score,
    ROW_NUMBER()  OVER (ORDER BY score DESC) AS row_num,
    RANK()        OVER (ORDER BY score DESC) AS rnk,
    DENSE_RANK()  OVER (ORDER BY score DESC) AS dense_rnk,
    NTILE(3)      OVER (ORDER BY score DESC) AS bucket
FROM (VALUES
    ('Alice',   95),
    ('Bob',     88),
    ('Carol',   88),
    ('David',   75),
    ('Eve',     75),
    ('Frank',   60)
) AS t(name, score);

结果:

text
 name  | score | row_num | rnk | dense_rnk | bucket
-------+-------+---------+-----+-----------+--------
 Alice |    95 |       1 |   1 |         1 |      1
 Bob   |    88 |       2 |   2 |         2 |      1
 Carol |    88 |       3 |   2 |         2 |      1
 David |    75 |       4 |   4 |         3 |      2
 Eve   |    75 |       5 |   4 |         3 |      2
 Frank |    60 |       6 |   6 |         4 |      3

四者的差异:

  • row_number(): 无视并列, 每行分配唯一序号。相同分数的行顺序不确定(取决于物理存储), 若要确定性排序需加次要排序列。
  • rank(): 并列行获得相同排名, 但后续排名跳跃。Bob 和 Carol 同为第 2 名, 下一名是第 4(跳过了第 3)。
  • dense_rank(): 并列行获得相同排名, 后续排名连续不跳跃。Bob 和 Carol 同为第 2, David 是第 3。
  • ntile(n): 将结果集尽量均等地分为 n 个桶, 返回当前行所在桶的编号。6 行分 3 桶: 桶 1 有 3 行(前三名), 桶 2 和 3 各 1.5 行取整。

面试高频考点

rank()dense_rank() 的区别: 前者有"空位"(1,2,2,4), 后者无"空位"(1,2,2,3)。记忆口诀: dense = 稠密 = 不留空。

7.4.2 NTILE 的均等分桶规则

当总行数不能被 n 整除时, 前 (总行数 mod n) 个桶比后续桶多一行。例如 7 行分 3 桶: 桶 1 有 3 行, 桶 2 有 2 行, 桶 3 有 2 行。


7.5 取值函数

取值函数用于在当前行的窗口内"取其他行的值", 常用于计算环比、首末值等。

7.5.1 lag() 与 lead()

sql
LAG (column, offset, default)  OVER (PARTITION BY ... ORDER BY ...)
LEAD(column, offset, default)  OVER (PARTITION BY ... ORDER BY ...)
  • lag: 取当前行往前 offset 行的值(默认 offset=1)。
  • lead: 取当前行往后 offset 行的值(默认 offset=1)。
  • default: 当偏移超出分区边界时的替代值, 默认为 NULL
sql
SELECT
    sale_month,
    revenue,
    LAG(revenue, 1, 0) OVER (ORDER BY sale_month) AS prev_revenue,
    revenue - LAG(revenue, 1, 0) OVER (ORDER BY sale_month) AS mom_diff
FROM monthly_sales;

7.5.2 first_value()、last_value() 与 nth_value()

sql
SELECT
    name,
    salary,
    department,
    FIRST_VALUE(name) OVER (PARTITION BY department ORDER BY salary DESC)
        AS highest_earner,
    LAST_VALUE(name)  OVER (
        PARTITION BY department
        ORDER BY salary DESC
        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING  -- 必须显式扩展 Frame!
    ) AS lowest_earner,
    NTH_VALUE(name, 2) OVER (
        PARTITION BY department
        ORDER BY salary DESC
        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
    ) AS second_earner
FROM employees;

last_value() 的 Frame 陷阱: 使用默认 Frame(RANGE UNBOUNDED PRECEDING AND CURRENT ROW)时, last_value() 每一行都只能"看到"自己及之前的行, 因此永远返回当前行自己的值, 而非分区最后一行的值。必须显式指定 ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING 才能让 last_value() 看到整个分区。nth_value() 同理。

first_value() 不受此影响, 因为第一行始终在默认 Frame 范围内。


7.6 聚合函数作为窗口函数使用

普通聚合函数加上 OVER() 子句即可变身窗口函数, 行数不减少但每行都能获得聚合结果。这类用法覆盖了三个最常见的分析场景: 累计求和、移动平均、占比计算。

7.6.1 累计求和

sql
-- 按时间累计销售额(Running Total)
SELECT
    sale_date,
    amount,
    SUM(amount) OVER (
        ORDER BY sale_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS cumulative_sum
FROM daily_sales;

由于此处显式写出了 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, 与默认 Frame 等价, 可以简写为:

sql
SUM(amount) OVER (ORDER BY sale_date)

但为了代码可读性, 建议在累计求和场景下始终显式写出 Frame, 避免他人误以为是省略了窗口范围。

分组内累计求和:

sql
SELECT
    department,
    employee_name,
    hire_date,
    salary,
    SUM(salary) OVER (
        PARTITION BY department
        ORDER BY hire_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS dept_running_total
FROM employees;

每个部门内按入职时间累积薪资, 各部门独立重置, 直观反映部门薪资随入职顺序的增长轨迹。

7.6.2 移动平均

sql
-- 3 日移动平均(含当天)
SELECT
    sale_date,
    amount,
    ROUND(
        AVG(amount) OVER (
            ORDER BY sale_date
            ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
        ), 2
    ) AS ma3
FROM daily_sales
ORDER BY sale_date;

若数据在前两天没有足够的历史行(分区起始位置), PostgreSQL 会自动用实际可用的行数计算平均, 不会报错。第一天的移动平均就是第一天自身, 第二天是前两天的均值。

7 日移动平均只需将 2 PRECEDING 改为 6 PRECEDING

7.6.3 占比计算

行在整体中的占比:

sql
SELECT
    product_name,
    revenue,
    ROUND(
        revenue * 100.0 / SUM(revenue) OVER (),
        2
    ) AS pct_of_total
FROM products;

SUM(revenue) OVER () 没有 PARTITION BY 也没有 ORDER BY, Frame 默认覆盖整个结果集, 因此返回总和。

行在分组中的占比:

sql
SELECT
    department,
    employee_name,
    salary,
    ROUND(
        salary * 100.0 / SUM(salary) OVER (PARTITION BY department),
        2
    ) AS pct_of_dept
FROM employees;

同一部门内每位员工的薪资占部门总薪资的比例。注意此处无 ORDER BY, Frame 默认覆盖整个分区, 正是我们需要的。

占比计算中省略 ORDER BY 的必要性

占比场景中的 SUM() OVER (PARTITION BY ...) 不能加 ORDER BY: 一旦加了 ORDER BY, 默认 Frame 变为 RANGE UNBOUNDED PRECEDING AND CURRENT ROW(累计到当前行), 分母就会随行变化, 得到的是累计占比而非总量占比。


7.7 综合实战案例

7.7.1 每组 Top N: 每个分类销量最高的 3 个商品

这是业务中最高频的窗口函数需求之一。思路是先用 row_number() 在分类内按销量降序编号, 再用外层 WHERE 过滤编号 ≤ 3:

sql
WITH ranked AS (
    SELECT
        category,
        product_name,
        sales_qty,
        ROW_NUMBER() OVER (
            PARTITION BY category
            ORDER BY sales_qty DESC
        ) AS rn
    FROM products
)
SELECT category, product_name, sales_qty
FROM ranked
WHERE rn <= 3
ORDER BY category, rn;

为何用 row_number() 而非 rank(): 若多个商品销量完全相同, rank() 可能让"第 3 名"涵盖 4 条甚至更多记录, 超出"每组恰好 3 个"的需求。row_number() 强制唯一编号, 确保每组精确返回 3 行。若业务上确实希望并列时全部保留, 则改用 rank() 并将 WHERE rn <= 3 改为 WHERE rk <= 3

7.7.2 销售额环比: 本月 vs 上月的差值与增长率

sql
WITH monthly AS (
    SELECT
        DATE_TRUNC('month', order_date)::DATE AS sale_month,
        SUM(amount) AS revenue
    FROM orders
    GROUP BY 1
),
with_lag AS (
    SELECT
        sale_month,
        revenue,
        LAG(revenue) OVER (ORDER BY sale_month) AS prev_revenue
    FROM monthly
)
SELECT
    sale_month,
    revenue,
    prev_revenue,
    revenue - prev_revenue AS mom_diff,
    CASE
        WHEN prev_revenue IS NULL OR prev_revenue = 0 THEN NULL
        ELSE ROUND((revenue - prev_revenue) * 100.0 / prev_revenue, 2)
    END AS mom_growth_pct
FROM with_lag
ORDER BY sale_month;

第一个月 prev_revenueNULL(没有上月数据), CASE 语句防止除零错误, 此行增长率输出 NULLmom_diff 正值表示环比增长, 负值表示环比下降。

7.7.3 用户首次下单到再次下单的间隔

需求: 计算每位用户第一次下单后, 到第二次下单之间间隔了多少天。

sql
WITH order_seq AS (
    SELECT
        user_id,
        created_at,
        LAG(created_at) OVER (
            PARTITION BY user_id
            ORDER BY created_at
        ) AS prev_order_at,
        ROW_NUMBER() OVER (
            PARTITION BY user_id
            ORDER BY created_at
        ) AS order_seq_num
    FROM orders
)
SELECT
    user_id,
    created_at AS second_order_at,
    prev_order_at AS first_order_at,
    EXTRACT(DAY FROM (created_at - prev_order_at)) AS days_between
FROM order_seq
WHERE order_seq_num = 2   -- 只取第二次下单的行
ORDER BY days_between;

PARTITION BY user_id ORDER BY created_at 为每位用户按时间序重置窗口; LAG(created_at) 取同用户上一次下单时间; order_seq_num = 2 精确筛选出每位用户的第二次订单行, 此时 prev_order_at 恰好是第一次下单时间。

若要同时分析所有相邻两次订单间隔(而非仅首次到第二次), 去掉 WHERE order_seq_num = 2 的限制, 并将 LAG 改为 LAG(created_at, 1, NULL) 即可, 此时每行记录的是本次与上次下单的间隔。


7.8 小结与最佳实践

本章覆盖了从 CTE 到窗口函数的完整体系。以下是几条实战中最值得记住的要点:

  1. CTE 提升可读性, 不是性能银弹。PG12+ 默认内联, 若需强制物化(避免重复计算昂贵的子查询), 显式加 MATERIALIZED

  2. 递归 CTE 必须防环。生产数据中环形引用时有发生; PG14+ 优先用 CYCLE 子句, 更简洁且无误写风险; PG13 及以下用 path 数组手动防环或加 depth < N 硬限制。

  3. 窗口函数的 Frame 陷阱是最常见的 Bug 来源。凡是滑动窗口计算, 必须显式写 ROWS BETWEEN ... AND ...; last_value()nth_value() 使用时必须扩展 Frame 到 UNBOUNDED FOLLOWING

  4. 四种排名函数的选择:

    • 需要"每组精确 N 行" → row_number()
    • 需要"并列同名次, 后续有空位" → rank()
    • 需要"并列同名次, 后续连续" → dense_rank()
    • 需要"分桶均分" → ntile(n)
  5. 聚合做窗口的占比场景不要加 ORDER BY, 否则分母变为累计值而非总量。

  6. 先用 CTE 命名中间结果, 再用窗口函数计算 是大多数复杂分析查询的最佳模式, 兼顾了可读性与调试便利性。

八、约束与建表设计最佳实践

数据库不只是存储容器, 它本身就是业务规则的第一道防线。无论上层应用代码多么健壮, 只要数据库层面缺少约束, 脏数据就迟早会悄悄渗入。PostgreSQL 的约束系统极为完整, 覆盖了从最基础的非空检查到具有排他语义的 GiST 级约束。本章从每一类约束的本质出发, 兼顾建表命名规范、主键选型、范式取舍与生产级 DDL 模板, 帮助读者建立一套可以直接落地的建表方法论。


8.1 约束系统详解

约束(Constraint)是附着在表或列上的规则, 由数据库引擎在每次写入时强制执行。违反约束的操作会立即报错并回滚, 使数据天然保持一致性。

8.1.1 NOT NULL — 最轻量的防御

是什么

NOT NULL 声明某列不允许存储 NULL 值。它不会创建任何索引, 执行成本几乎为零。

为什么

NULL 的语义是"未知", 在三值逻辑下会带来意外结果。例如 NULL != 0NULL = 0 都返回 NULL 而非 TRUE/FALSE, 这极易造成统计错误。业务上凡是"必填"的字段都应加 NOT NULL

怎么用

列级声明是最常见的方式:

sql
CREATE TABLE users (
    id      bigint       NOT NULL,
    email   text         NOT NULL,
    phone   text                     -- 允许 NULL, 表示未填写
);

事后追加或移除:

sql
-- 追加 NOT NULL (要求表中无 NULL 值, 否则报错)
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;

-- 移除 NOT NULL
ALTER TABLE users ALTER COLUMN phone DROP NOT NULL;

和 DEFAULT 的关系

这是一个高频生产陷阱。在大表上执行 ALTER TABLE ADD COLUMN 时, 若新列带有 NOT NULL没有 DEFAULT, PostgreSQL 必须遍历全表回填 NULL, 导致长时间表锁。

大表加列必须同时给 DEFAULT

NOT NULL + DEFAULT 组合让 PostgreSQL(≥11)可以只更新元数据, 不回填行数据, 实现近似在线无锁加列。缺少任一条件都会触发全表重写。

sql
-- 安全做法: 同时给出 DEFAULT
ALTER TABLE orders ADD COLUMN is_paid boolean NOT NULL DEFAULT false;

8.1.2 UNIQUE — 唯一性保证

是什么

UNIQUE 约束保证列(或列组合)中不出现重复的非 NULL 值。PostgreSQL 在约束列上自动创建 B-tree 唯一索引

为什么

业务键(如邮箱、手机号、订单号)不允许重复, 依赖应用层去重是不可靠的。唯一约束让数据库在并发写入时也能串行化冲突检测。

怎么用

sql
-- 单列唯一
CREATE TABLE users (
    id    bigserial PRIMARY KEY,
    email text NOT NULL UNIQUE
);

-- 多列联合唯一 (表级约束)
CREATE TABLE order_items (
    order_id   bigint NOT NULL,
    product_id bigint NOT NULL,
    CONSTRAINT uniq_order_items_order_product UNIQUE (order_id, product_id)
);

事后添加:

sql
ALTER TABLE users ADD CONSTRAINT uniq_users_email UNIQUE (email);

NULL 值的特殊规则

UNIQUE 约束允许列中存在多个 NULL, 因为 SQL 标准规定 NULL ≠ NULL。如果需要"空值也唯一", 可以使用部分唯一索引:

sql
-- 只允许一条 phone 为 NULL 的记录 (PostgreSQL 15+: NULLS NOT DISTINCT)
CREATE UNIQUE INDEX uniq_users_phone ON users (phone) NULLS NOT DISTINCT;

-- 或 PostgreSQL 15+ 语法
ALTER TABLE users ADD CONSTRAINT uniq_users_phone
    UNIQUE NULLS NOT DISTINCT (phone);

CREATE UNIQUE INDEX 与 ADD CONSTRAINT UNIQUE

两者最终效果几乎相同——都创建唯一 B-tree 索引。区别在于:

  • ADD CONSTRAINT UNIQUE 创建的约束名可被外键引用, 更"正式"。
  • CREATE UNIQUE INDEX 灵活性更高, 支持 WHERE 子句(部分唯一索引)和表达式索引。
  • CREATE UNIQUE INDEX CONCURRENTLY 可在不锁表的情况下在线构建, 之后可用 ALTER TABLE ... ADD CONSTRAINT ... USING INDEX 转为约束。
sql
-- 在线构建唯一索引, 再升级为约束
CREATE UNIQUE INDEX CONCURRENTLY uniq_users_email_tmp ON users (email);
ALTER TABLE users ADD CONSTRAINT uniq_users_email
    UNIQUE USING INDEX uniq_users_email_tmp;

DEFERRABLE 延迟检查

默认情况下唯一约束在每条语句执行后立即检查。加上 DEFERRABLE INITIALLY DEFERRED 后, 检查推迟到事务提交时, 适合"先插后删"的批量重排场景:

sql
ALTER TABLE users ADD CONSTRAINT uniq_users_email UNIQUE (email)
    DEFERRABLE INITIALLY DEFERRED;

8.1.3 PRIMARY KEY — 行的唯一标识符

是什么

PRIMARY KEYNOT NULL + UNIQUE 的语法糖。每张表只能有一个主键, PostgreSQL 自动在主键列上建立 B-tree 唯一索引

为什么

主键是行的身份证, 外键引用的靶点。没有主键的表在逻辑复制、CDC(Change Data Capture)和某些 ORM 框架下会遇到麻烦。

怎么用

sql
-- 单列主键
CREATE TABLE products (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name text NOT NULL
);

-- 复合主键 (表级声明)
CREATE TABLE order_items (
    order_id   bigint NOT NULL,
    product_id bigint NOT NULL,
    quantity   int    NOT NULL CHECK (quantity > 0),
    PRIMARY KEY (order_id, product_id)
);

复合主键的适用场景与代价

复合主键适用于纯粹的关联表(如 user_rolesorder_items), 它天然就是业务键, 不需要额外唯一约束。

但代价明显:

  • 所有引用该表的外键都要携带多列, 写 JOIN 更繁琐。
  • 若关联表自身还需被第三张表引用, 复合外键更难维护。
  • ORM 框架对复合主键的支持参差不齐。

选型建议

关联表若不被其他表引用, 用复合主键干净利落。若关联表本身还需被引用, 建议加一列代理主键(id bigint GENERATED ALWAYS AS IDENTITY), 同时在业务列上加 UNIQUE 约束。


8.2 FOREIGN KEY — 引用完整性

8.2.1 基本语法

外键约束保证子表的某列值在父表中一定存在, 从而维护引用完整性

sql
CREATE TABLE orders (
    id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id    bigint NOT NULL,
    status     text   NOT NULL DEFAULT 'pending',
    created_at timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT fk_orders_users FOREIGN KEY (user_id) REFERENCES users(id)
);

8.2.2 ON DELETE / ON UPDATE 五种行为

当父表的行被删除或主键被更新时, 子表的关联行有五种处理方式:

行为语义典型场景
NO ACTION事务提交时检查, 若子行存在则报错(默认值)强约束, 需手动处理依赖
RESTRICT语句执行时立即报错, 不可延迟同 NO ACTION, 但不可被 DEFERRABLE 推迟
CASCADE父行删除/更新时自动删除/更新子行订单与订单项、帖子与评论
SET NULL父行删除/更新时子行外键列置为 NULL软关联, 如文章的作者被删后保留文章
SET DEFAULT父行删除/更新时子行外键列置为列默认值较少见, 默认值需是有效的父行主键
sql
-- 删除用户时级联删除其订单
CONSTRAINT fk_orders_users
    FOREIGN KEY (user_id) REFERENCES users(id)
    ON DELETE CASCADE ON UPDATE CASCADE;

-- 删除分类时将商品的 category_id 置 NULL
CONSTRAINT fk_products_categories
    FOREIGN KEY (category_id) REFERENCES categories(id)
    ON DELETE SET NULL;

8.2.3 外键索引陷阱

这是最容易被忽视的生产性能问题:

被引用列: 必须拥有主键或唯一约束(PostgreSQL 强制要求)。

引用方列: PostgreSQL 不会自动为外键列创建索引。若引用方列无索引, 则:

  • 每次删除父表中的一行, PostgreSQL 都要对子表做全表扫描来检查关联行。
  • 在高并发删除场景下, 这会演变成严重锁等待。

引用方外键列必须手动建索引

sql
-- 创建 orders 表后, 务必给 user_id 建索引
CREATE INDEX idx_orders_user_id ON orders (user_id);

忘记这一步是"外键导致慢查询"的最常见根因。

8.2.4 DEFERRABLE 延迟外键检查

批量导入数据时, 行与行之间可能存在"先有子后有父"的顺序问题。DEFERRABLE INITIALLY DEFERRED 将检查推迟到事务提交:

sql
ALTER TABLE orders
    ADD CONSTRAINT fk_orders_users
    FOREIGN KEY (user_id) REFERENCES users(id)
    DEFERRABLE INITIALLY DEFERRED;

-- 批量导入时, 事务内可以乱序插入
BEGIN;
INSERT INTO orders (id, user_id, ...) VALUES (1001, 9999, ...);
INSERT INTO users  (id, ...)          VALUES (9999, ...);
COMMIT; -- 此时才做外键检查

8.3 CHECK 约束 — 业务规则守门人

8.3.1 基本语法

CHECK 约束要求列的值满足一个布尔表达式。返回 FALSE 的插入/更新会被拒绝; 返回 NULL 的则被允许(因为 NULL 不等于 FALSE)。

sql
CREATE TABLE products (
    id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name     text           NOT NULL,
    price    numeric(15,4)  NOT NULL CONSTRAINT chk_products_price CHECK (price >= 0),
    discount numeric(15,4)  NOT NULL DEFAULT 0,
    stock    int            NOT NULL DEFAULT 0,
    -- 跨列约束: 折扣不能超过原价
    CONSTRAINT chk_products_discount CHECK (discount >= 0 AND discount <= price)
);

8.3.2 列级与表级

  • 列级: CHECK 紧跟列定义, 表达式中只能引用该列。
  • 表级: 独立写在末尾, 可引用多列, 实现跨列约束。

8.3.3 NOT VALID — 大表在线加约束

对亿行大表执行 ADD CONSTRAINT CHECK 会锁表扫描全部数据, 造成长时间不可用。NOT VALID 选项仅对新写入数据强制约束, 已有数据不做扫描:

sql
-- 第一步: 快速加约束 (不锁表扫描)
ALTER TABLE orders
    ADD CONSTRAINT chk_orders_amount CHECK (amount >= 0) NOT VALID;

-- 第二步: 后台验证历史数据 (持有 ShareUpdateExclusiveLock, 不阻塞读写)
ALTER TABLE orders VALIDATE CONSTRAINT chk_orders_amount;

NOT VALID 适用场景

适合存量数据已知合法、只需约束增量的场景。两步操作可在业务低峰期分批执行, 大幅降低影响窗口。


8.4 EXCLUDE — PostgreSQL 特色排他约束

8.4.1 原理

EXCLUDE 约束使用 GiST 索引检测行间冲突。它将某列对上的"比较操作"推广到任意运算符(不仅限于 =), 从而实现"任意两行在该条件下不冲突"的语义。

简单说: EXCLUDE USING gist (col WITH op) 表示"表中任意两行的 col 字段, 用 op 运算符比较都不能返回 TRUE"。

8.4.2 防时间段重叠实战

经典场景: 会议室预订系统, 同一房间在同一时间段内只能被一个人预订。

首先安装 btree_gist 扩展(让普通 btree 类型如 int/bigint 也能参与 GiST 约束):

sql
CREATE EXTENSION IF NOT EXISTS btree_gist;

建表:

sql
CREATE TABLE meeting_rooms_bookings (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    room_id     bigint      NOT NULL,
    booked_by   bigint      NOT NULL,  -- 引用 users.id
    time_range  tstzrange   NOT NULL,  -- 预订时间段
    created_at  timestamptz NOT NULL DEFAULT now(),

    -- 同一房间的时间段不能重叠 (&&: 范围类型的"相交"运算符)
    CONSTRAINT excl_bookings_no_overlap
        EXCLUDE USING gist (
            room_id    WITH =,     -- 同一房间
            time_range WITH &&     -- 时间段相交即冲突
        )
);

测试:

sql
-- 插入第一条预订
INSERT INTO meeting_rooms_bookings (room_id, booked_by, time_range)
VALUES (1, 101, '[2026-06-15 09:00, 2026-06-15 11:00)');

-- 尝试插入重叠时间段 → 报错
INSERT INTO meeting_rooms_bookings (room_id, booked_by, time_range)
VALUES (1, 102, '[2026-06-15 10:00, 2026-06-15 12:00)');
-- ERROR: conflicting key value violates exclusion constraint
--   "excl_bookings_no_overlap"

-- 不同房间, 相同时间 → 成功
INSERT INTO meeting_rooms_bookings (room_id, booked_by, time_range)
VALUES (2, 102, '[2026-06-15 10:00, 2026-06-15 12:00)');

8.4.3 适用场景

  • 会议室/座位/车辆预订的时间段不重叠。
  • IP 地址段不重叠(用 inet 类型和范围运算符)。
  • 地理围栏不重叠(用 PostGIS 几何类型)。
  • 有效期区间不重叠的价格策略表。

排他约束 vs 应用层锁

排他约束由数据库保证, 在并发写入时天然串行化, 无需应用层分布式锁。是解决"幂等区间"问题最优雅的方案。


8.5 主键选型对比

主键选什么, 直接影响写入性能、分布式扩展性和可读性。下表横向对比六种常见方案:

方案典型写法优点缺点推荐场景
SERIAL / BIGSERIALid serial PRIMARY KEY写法简单, 兼容老版本本质是序列(sequence), 属于 PostgreSQL 私有语法; SERIAL 上限约 21 亿老项目维护; 新项目不推荐
GENERATED ALWAYS AS IDENTITYid bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEYSQL 标准语法; 防止意外手动 INSERT; 可 OVERRIDING SYSTEM VALUE需 PostgreSQL 10+单机/单体服务首选
UUID v4id uuid DEFAULT gen_random_uuid() PRIMARY KEY全球唯一; 无需中心化分配; 适合跨库合并完全随机, B-tree 页分裂严重; 索引膨胀快; 可读性差需要全球唯一但不在意写入性能的场景
UUID v7id uuid DEFAULT uuidv7() PRIMARY KEY(PG17+)时间戳前缀有序; 写入局部性好; 仍全球唯一需 PostgreSQL 17+ 或第三方扩展; 标准尚在演进分布式服务 + 有序写入首选
业务键phone text PRIMARY KEY自带业务含义; 无需额外唯一索引业务键可能变更(手机号换绑); 外键携带字符串占空间真正不可变的业务标识(如国家代码)
雪花 IDid bigint PRIMARY KEY(应用生成)有序; 分布式友好; bigint 空间小; 可解码时间戳需外部生成器; 时钟回拨风险; 不同服务 worker_id 需协调高并发分布式微服务

8.5.1 SERIAL vs GENERATED AS IDENTITY

两者都基于序列, 行为几乎相同, 但 GENERATED ALWAYS AS IDENTITY 更安全:

sql
-- SERIAL 允许手动插入任意值, 可能绕过序列
INSERT INTO t (id, name) VALUES (9999, 'test');  -- 不报错

-- GENERATED ALWAYS 阻止手动插入
INSERT INTO t (id, name) VALUES (9999, 'test');
-- ERROR: cannot insert into column "id"
-- HINT: Column "id" is an identity column defined as GENERATED ALWAYS.

-- 若确实需要覆盖 (如数据迁移), 可显式声明
INSERT INTO t (id, name)
OVERRIDING SYSTEM VALUE VALUES (9999, 'test');

8.5.2 UUID v7 实战

PostgreSQL 17 内置 uuidv7() 函数:

sql
SELECT uuidv7();
-- 019675a3-2f8c-7000-b1c2-3d4e5f6a7b8c
-- ↑前48位是毫秒时间戳, 保证单调递增写入

PostgreSQL 16 及以下, 可使用 pg_uuidv7 扩展:

sql
CREATE EXTENSION pg_uuidv7;
SELECT uuid_generate_v7();

UUID v4 vs v7 的写入性能差异

UUID v4 完全随机, 每次插入都有 50% 概率命中 B-tree 的"冷页", 触发随机 I/O 和页分裂。UUID v7 时间有序, 新行几乎总是追加到 B-tree 最右叶子节点, 与自增 ID 行为相近, 索引膨胀率低得多。


8.6 范式化与反范式化

8.6.1 从混乱设计到三范式

以订单系统为例, 演示逐步规范化的过程。

违反 1NF 的设计: 将多个商品塞进一个字段。

sql
-- 反面教材: items 列存了多个商品, 违反原子性
CREATE TABLE orders_bad (
    id       bigint PRIMARY KEY,
    user_id  bigint,
    items    text   -- "手机×1,耳机×2,充电器×1" ← 原子性被破坏
);

这种设计导致: 无法用 SQL 统计单个商品的销量; 修改一个商品数量需要解析字符串; 无法加外键约束。

满足 1NF: 每列原子化。但若把商品名、单价都冗余到 order_items 里, 同一商品名改了需要批量更新 → 违反 2NF(非主键列依赖主键的一部分)。

满足 2NF → 3NF: 消除传递依赖, 拆出独立的 products 表。

sql
-- 规范化到 3NF 的订单系统
CREATE TABLE users (
    id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name       text   NOT NULL,
    email      text   NOT NULL
);

CREATE TABLE products (
    id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name       text          NOT NULL,
    price      numeric(15,4) NOT NULL CHECK (price >= 0),
    sku        text          NOT NULL UNIQUE
);

CREATE TABLE orders (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id     bigint        NOT NULL REFERENCES users(id),
    status      text          NOT NULL DEFAULT 'pending',
    created_at  timestamptz   NOT NULL DEFAULT now()
);

CREATE TABLE order_items (
    order_id    bigint        NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    product_id  bigint        NOT NULL REFERENCES products(id),
    quantity    int           NOT NULL CHECK (quantity > 0),
    unit_price  numeric(15,4) NOT NULL CHECK (unit_price >= 0),  -- 下单时快照价格
    PRIMARY KEY (order_id, product_id)
);

注意 unit_price快照, 不是从 products JOIN 来的。这是合理的"必要冗余": 下单时的价格需要永久固化, 不能随商品调价而变化。

8.6.2 BCNF 简述

Boyce-Codd 范式(BCNF)是 3NF 的加强版: 表中每个函数依赖的决定因素都必须是超键。3NF 允许"非主键列决定非主键列"的边缘情况, BCNF 完全消除。BCNF 主要在有多个候选键且候选键相互交叠时才比 3NF 更严格。对于大多数业务表, 达到 3NF 已经足够。

8.6.3 反范式的合理场景

规范化的代价是 JOIN 增多, 在高并发读场景下这会成为性能瓶颈。以下是两类合理的反范式化场景:

场景一: 冗余省份名, 避免频繁 JOIN

sql
-- 规范化: 每次展示地址都要 JOIN provinces 表
SELECT o.id, p.name AS province_name
FROM orders o
JOIN addresses a ON a.id = o.address_id
JOIN provinces p ON p.id = a.province_id;

-- 反范式化: 在 addresses 表中冗余 province_name
ALTER TABLE addresses ADD COLUMN province_name text;
-- 查询直接拿字段, 省去两次 JOIN

代价分析: 若省份名变更(如直辖市改名), 所有地址表中的冗余字段都需要批量更新。数据一致性风险 + 写放大是反范式化的固有代价, 必须显式接受。

场景二: 预计算汇总列

sql
-- 规范化: 每次查询帖子都要实时 COUNT 评论数
SELECT p.id, p.title, COUNT(c.id) AS comment_count
FROM posts p
LEFT JOIN comments c ON c.post_id = p.id
GROUP BY p.id, p.title;

-- 反范式化: 在 posts 表增加冗余计数列
ALTER TABLE posts ADD COLUMN comment_count int NOT NULL DEFAULT 0;

-- 用触发器维护, 每次 INSERT/DELETE comments 时更新

反范式化的使用原则

  1. 先有性能数据, 再做反范式。不要"预优化", 用慢查询日志或 EXPLAIN ANALYZE 证明问题后再引入冗余。
  2. 冗余数据必须有明确的维护机制(触发器或应用层双写), 否则脏数据比慢查询更危险。
  3. 每处反范式化都应有注释说明冗余来源和维护策略。

8.7 命名规范与实践

一套统一的命名规范让 SQL 可读性大幅提升, 也让团队协作时减少"理解摩擦"。以下是一套可直接落地的规范。

8.7.1 表名

  • 使用复数形式: usersordersproductspayments
  • 蛇形命名(snake_case): 全小写, 单词间下划线分隔, 不用驼峰。
  • 关联表用两端表名组合: order_itemsuser_rolesproduct_tags
  • 避免过于宽泛的名字: 用 product_reviews 而非 reviews, 减少歧义。

8.7.2 列名

列类型命名规范示例
主键固定 idid bigint GENERATED ALWAYS AS IDENTITY
外键{引用表单数}_iduser_id, product_id, order_id
创建时间created_attimestamptz NOT NULL DEFAULT now()
更新时间updated_attimestamptz NOT NULL DEFAULT now()
软删除时间deleted_attimestamptz (NULL 表示未删除)
状态statustext NOT NULL DEFAULT 'pending'
布尔标志is_{含义}is_paidis_verifiedis_active
金额{含义}_amountprice/costtotal_amount, unit_price
计数{含义}_countcomment_count, view_count

8.7.3 索引与约束命名

对象命名模式示例
普通索引idx_{表名}_{列名}idx_orders_user_id
唯一索引/约束uniq_{表名}_{列名}uniq_users_email
CHECK 约束chk_{表名}_{含义}chk_products_price_positive
外键约束fk_{表名}_{引用表}fk_orders_users
主键约束pk_{表名}pk_orders (通常自动命名可接受)
排他约束excl_{表名}_{含义}excl_bookings_no_overlap

命名规范的核心原则: 从名字能推断出含义, 不用查表结构


8.8 生产级建表规范

8.8.1 审计字段

任何生产表都应携带以下三个审计字段:

sql
created_at  timestamptz NOT NULL DEFAULT now(),
updated_at  timestamptz NOT NULL DEFAULT now(),
deleted_at  timestamptz                        -- NULL 表示未删除 (软删除)

timestamptz(带时区的时间戳)而非 timestamp: 存储时自动转换为 UTC, 读取时按客户端时区还原, 避免跨时区数据混乱。

8.8.2 用触发器自动维护 updated_at

updated_at 靠应用层更新是不可靠的——漏写、直接 SQL 修改都会导致字段不准。用数据库触发器是唯一可靠的方式。

触发器函数(一次定义, 所有表复用):

plpgsql
CREATE OR REPLACE FUNCTION fn_set_updated_at()
RETURNS TRIGGER
LANGUAGE plpgsql AS $$
BEGIN
    -- 只有行数据真正变化时才更新时间戳, 避免无意义写放大
    IF row(NEW.*) IS DISTINCT FROM row(OLD.*) THEN
        NEW.updated_at = now();
    END IF;
    RETURN NEW;
END;
$$;

为每张需要的表挂载触发器:

sql
-- 以 orders 表为例
CREATE TRIGGER trg_orders_set_updated_at
    BEFORE UPDATE ON orders
    FOR EACH ROW
    EXECUTE FUNCTION fn_set_updated_at();

-- 同理为其他表添加
CREATE TRIGGER trg_users_set_updated_at
    BEFORE UPDATE ON users
    FOR EACH ROW
    EXECUTE FUNCTION fn_set_updated_at();

row(NEW.) IS DISTINCT FROM row(OLD.)

这个条件防止"更新相同值"时无意义地刷新 updated_atIS DISTINCT FROM 对 NULL 安全(不像 !=, NULL != NULL 为 NULL 而非 TRUE)。

8.8.3 软删除与硬删除的取舍

软删除: 将 deleted_at 置为当前时间, 数据仍在表中。

sql
-- 软删除
UPDATE orders SET deleted_at = now() WHERE id = 1001;

-- 查询时过滤已删除行
SELECT * FROM orders WHERE deleted_at IS NULL;

配合部分索引, 软删除不影响常规查询性能:

sql
-- 绝大多数查询只关心未删除数据, 部分索引极大缩减索引体积
CREATE INDEX idx_orders_user_id_active
    ON orders (user_id)
    WHERE deleted_at IS NULL;

软删除 vs 硬删除对比:

维度软删除硬删除
数据可恢复是, 直接 UPDATE 恢复否(依赖备份)
审计合规天然保留操作历史需额外审计日志表
表体积持续增长, 需定期归档行被物理删除, VACUUM 可回收空间
查询复杂度所有查询必须加 WHERE deleted_at IS NULL无额外条件
外键完整性外键约束不感知软删除, 子表仍可引用"已删除"父行父行消失, 外键约束自然生效
建议有审计/恢复需求的核心业务表日志、消息队列等高吞吐临时数据

软删除的最大陷阱

外键约束不理解软删除语义。orders.user_id 引用了 users.id, 即使用户被软删除, 其订单的外键仍然有效。业务层必须自行过滤"已删除用户的订单"。若需要强制级联, 只能在应用层或触发器中处理。

8.8.4 字段选型红线

场景正确类型禁用类型原因
金额/价格numeric(15,4)float/double precision浮点数有精度误差, 0.1+0.2≠0.3
时间timestamptztimestamp(无时区)无时区在跨时区系统中产生歧义
状态枚举text + CHECK 约束enum 类型PostgreSQL enum 加新值需 ALTER TYPE, 无法在事务中回滚
布尔值boolean NOT NULL DEFAULT falseint(0/1)语义清晰; NOT NULL+DEFAULT 避免三态 NULL
大文本textvarchar(n)(不限长)PostgreSQL textvarchar 性能相同; 无意义长度限制徒增维护负担
手机号textbigint手机号有前导零; 国际号码含+; 字符串更安全

8.9 生产级建表模板 — 以 orders 表为例

以下是一份完整的生产级 DDL, 包含所有约束、默认值、审计字段、字段注释和索引:

sql
-- ============================================================
-- 订单表 (生产级模板)
-- ============================================================
CREATE TABLE orders (
    -- 主键: 自增代理键
    id              bigint          GENERATED ALWAYS AS IDENTITY
                                    CONSTRAINT pk_orders PRIMARY KEY,

    -- 业务外键
    user_id         bigint          NOT NULL,
    address_id      bigint,         -- 允许 NULL: 虚拟商品无需地址

    -- 业务字段
    status          text            NOT NULL DEFAULT 'pending',
    total_amount    numeric(15,4)   NOT NULL DEFAULT 0
                                    CONSTRAINT chk_orders_total_amount
                                        CHECK (total_amount >= 0),
    currency        text            NOT NULL DEFAULT 'CNY',
    remark          text,           -- 买家备注, 可为空

    -- 布尔标志
    is_paid         boolean         NOT NULL DEFAULT false,

    -- 审计字段
    created_at      timestamptz     NOT NULL DEFAULT now(),
    updated_at      timestamptz     NOT NULL DEFAULT now(),
    deleted_at      timestamptz,    -- NULL = 未删除 (软删除)

    -- 外键约束 (显式命名)
    CONSTRAINT fk_orders_users
        FOREIGN KEY (user_id)
        REFERENCES users(id)
        ON DELETE RESTRICT,

    -- 状态值约束 (text + CHECK 比 enum 更灵活)
    CONSTRAINT chk_orders_status
        CHECK (status IN ('pending', 'paid', 'shipped', 'completed', 'cancelled'))
);

-- ============================================================
-- 字段注释
-- ============================================================
COMMENT ON TABLE  orders                IS '订单主表';
COMMENT ON COLUMN orders.id             IS '订单 ID, 自增主键';
COMMENT ON COLUMN orders.user_id        IS '下单用户 ID, 引用 users.id';
COMMENT ON COLUMN orders.address_id     IS '收货地址 ID, 虚拟商品可为 NULL';
COMMENT ON COLUMN orders.status         IS '订单状态: pending/paid/shipped/completed/cancelled';
COMMENT ON COLUMN orders.total_amount   IS '订单总金额, 单位: 元, 精度 4 位小数';
COMMENT ON COLUMN orders.currency       IS '币种, ISO 4217 代码, 默认 CNY';
COMMENT ON COLUMN orders.is_paid        IS '是否已支付';
COMMENT ON COLUMN orders.created_at     IS '创建时间 (UTC)';
COMMENT ON COLUMN orders.updated_at     IS '最后更新时间 (UTC), 由触发器自动维护';
COMMENT ON COLUMN orders.deleted_at     IS '软删除时间 (UTC), NULL 表示未删除';

-- ============================================================
-- 索引
-- ============================================================
-- 按用户查询订单 (高频)
CREATE INDEX idx_orders_user_id
    ON orders (user_id)
    WHERE deleted_at IS NULL;

-- 按状态查询 (配合用户 ID 的复合索引)
CREATE INDEX idx_orders_user_id_status
    ON orders (user_id, status)
    WHERE deleted_at IS NULL;

-- 按创建时间降序 (分页列表)
CREATE INDEX idx_orders_created_at
    ON orders (created_at DESC)
    WHERE deleted_at IS NULL;

-- ============================================================
-- 触发器: 自动维护 updated_at
-- ============================================================
CREATE TRIGGER trg_orders_set_updated_at
    BEFORE UPDATE ON orders
    FOR EACH ROW
    EXECUTE FUNCTION fn_set_updated_at();  -- 函数见 8.8.2 节

为什么索引都带 WHERE deleted_at IS NULL

生产系统中 99% 的查询只关心未删除的数据。部分索引体积更小、统计信息更准确、缓存命中率更高。这是软删除场景下索引设计的标准做法。


8.10 规范电商数据库 ER 图

下图展示 usersproductsordersorder_itemspayments 五张表的主外键关系与关键字段, 符合本章所有命名规范:

图中几点设计说明:

  • order_items 使用 (order_id, product_id) 复合主键, 天然保证同一订单内同一商品不重复。
  • order_items.unit_price 是下单时的价格快照, 不依赖 products.price 的当前值。
  • payments.transaction_id 加唯一约束, 防止支付回调幂等问题导致重复入账。
  • paymentsorders 是一对多关系: 一个订单可能有多次支付记录(如部分退款后再支付)。

8.11 本章小结

本章从约束系统出发, 系统地梳理了 PostgreSQL 约束设计的全貌:

约束系统: NOT NULL 防空值; UNIQUE 防重复(允许多 NULL, 可用 NULLS NOT DISTINCT 收紧); PRIMARY KEY 是行的身份证, 自动建唯一索引; FOREIGN KEY 维护引用完整性, 五种删除行为各有适用场景, 引用方列必须手动加索引; CHECK 约束用 NOT VALID 选项可以在大表上在线分步加约束; EXCLUDE 是 PostgreSQL 特色, 用 GiST 索引优雅解决区间不重叠问题。

主键选型: 单机服务优选 GENERATED ALWAYS AS IDENTITY; 分布式服务优选 UUID v7 或雪花 ID; UUID v4 全球唯一但写入性能差。

范式化: 3NF 是常规业务表的目标; 必要的反范式化(冗余字段、预计算列)须有明确的维护机制和代价认知。

命名规范: 表用复数蛇形, 列遵循 id/{}_id/created_at/updated_at/deleted_at/is_xxx 约定, 约束和索引用 chk_/fk_/idx_/uniq_ 前缀。

生产级规范: 所有表带审计字段; updated_at 用触发器维护; 软删除配合部分索引; 金额用 numeric(15,4); 时间用 timestamptz; 状态用 text + CHECK 而非 enum

这些规范不是教条, 而是数十个"踩坑"经验的沉淀。将它们嵌入团队的 DDL Review Checklist, 可以系统性地避免生产数据库的常见陷阱。

九、索引深入: 原理与实战

索引是数据库性能调优中最立竿见影的手段, 也是最容易被误用的手段。本章从 B-tree 的内部结构出发, 逐步拆解 PostgreSQL 支持的全部索引类型, 再深入复合索引、覆盖索引、部分索引、函数索引等实战话题, 最后讲清楚索引失效的常见原因与在线维护策略。读完本章, 你将能够在面对任意查询时做出合理的索引决策。

9.1 索引为何能加速查询

9.1.1 全表扫描的代价

假设 orders 表有 1000 万行, 要查找 user_id = 42 的所有订单。如果没有索引, PostgreSQL 必须从第一个数据页读到最后一个数据页, 逐行比较 user_id 的值。这就是顺序扫描(Sequential Scan), 时间复杂度为 O(n)。对于磁盘 I/O 而言, 10 万个数据页就意味着 10 万次(或批量但仍然庞大的)读取操作。

类比: 想在一本没有目录的百科全书里找"PostgreSQL"这个词条, 唯一的办法就是从第一页翻到最后一页。

9.1.2 B-tree 索引的结构与查找过程

B-tree(平衡多路搜索树)是 PostgreSQL 默认的索引类型, 也是关系数据库中使用最广泛的索引结构。其核心思想是: 把索引键按顺序组织成一棵高度平衡的树, 使得查找任意值的磁盘 I/O 次数仅为 O(log n)。

叶节点存储的不是实际数据行, 而是ctid(行物理位置: 页号 + 行号)。查找 user_id = 42 的过程是: 从根节点出发, 每次比较键值选择正确的子树分支下探, 到达叶节点后取出 ctid, 再回到堆文件(表的物理存储)读取完整的行。这一过程通常只需 3 到 5 次磁盘 I/O(对应树的高度), 与 O(n) 的全表扫描相比提升是数量级的。

类比: 书的目录让你直接翻到第 247 页, 而不是从第 1 页逐页查找。

为什么叶节点还要"回表"

B-tree 叶节点只存索引键和 ctid, 不存整行数据。优化器若判断需要的列不在索引里, 就必须拿着 ctid 去堆文件取完整行, 这一步叫回表(Heap Fetch)。后面讲到的覆盖索引正是为了消除回表而生。

9.1.3 "是否走索引"的判断流程

优化器并非一定选择索引。当表很小、或查询条件的选择率很低(比如 WHERE status IN ('active','inactive') 几乎覆盖全表), 全表扫描反而更快, 因为它是顺序 I/O, 操作系统可以预读。

强制使用索引

PostgreSQL 没有 MySQL 的 FORCE INDEX 语法。如果你确信索引更好但优化器选了全表扫描, 可临时 SET enable_seqscan = off; 来验证, 但生产环境不要长期禁用。更好的做法是检查统计信息是否过时(ANALYZE)或调整 random_page_cost


9.2 PostgreSQL 全部索引类型

PostgreSQL 提供了六种内建索引类型, 各有其适用场景。以下是总览:

索引类型底层结构支持的操作符典型场景
B-tree平衡多路搜索树= < > <= >= BETWEEN IN IS NULL LIKE 'abc%'绝大多数场景, 默认选择
Hash哈希表=纯等值查询, 极少使用
GiST广义搜索树&&(重叠) @>(包含) <@ ~= <->几何类型、全文检索、范围类型、PostGIS
SP-GiST空间分区广义搜索树取决于操作符族四叉树、字典树、非平衡空间数据
GIN倒排索引@> <@ ? ?& ?| @@数组、JSONB、tsvector 全文检索
BRIN块范围索引= < > <= >=(基于范围)超大按物理顺序写入的列, 如时序日志

9.2.1 B-tree: 全能的默认选择

sql
-- 默认即 B-tree, 无需显式指定
CREATE INDEX idx_orders_user_id ON orders (user_id);

-- 显式指定(效果相同)
CREATE INDEX idx_orders_user_id ON orders USING btree (user_id);

B-tree 支持排序, 因此 ORDER BY 可以直接利用索引避免额外的排序步骤。LIKE 'abc%' 前缀匹配能用 B-tree, 但 LIKE '%abc' 后缀匹配不能, 因为 B-tree 是按字典序从左到右组织的。

9.2.2 Hash: 仅限等值查询

sql
CREATE INDEX idx_sessions_token ON sessions USING hash (token);

Hash 索引在等值查询上略快于 B-tree, 但不支持范围查询、排序、多列组合, 且在 PG 10 之前不写 WAL(崩溃后需重建)。实践中很少使用, B-tree 已经足够快。

9.2.3 GiST: 几何与全文的基础设施

GiST(Generalized Search Tree)是一个可扩展的索引框架, PostGIS 地理查询、tsquery 全文检索、int4range 范围类型都依赖它。

sql
-- 地理坐标点的空间索引(需要 PostGIS)
CREATE INDEX idx_locations_geom ON locations USING gist (geom);

-- 全文检索索引
CREATE INDEX idx_articles_tsv ON articles USING gist (to_tsvector('english', body));

GiST 支持的 &&(重叠)、@>(包含)、<->(距离)等操作符是 B-tree 做不到的。

9.2.4 SP-GiST: 非平衡空间数据

SP-GiST(Space-Partitioned GiST)适合天然有层次分区结构的数据, 如 IP 网段(CIDR)、多边形四叉树分解。它在特定场景下比 GiST 更高效, 但适用范围较窄。

sql
-- IP 网段查询
CREATE INDEX idx_ip_ranges ON ip_ranges USING spgist (cidr_range);

9.2.5 GIN: 多值列的倒排索引

GIN(Generalized Inverted Index)是倒排索引, 把每个元素值映射到包含它的行列表。天然适合一列中存有多个值的场景: 数组、JSONB、全文向量。

sql
-- 数组列
CREATE INDEX idx_tags ON articles USING gin (tags);

-- JSONB 列(完整操作符集)
CREATE INDEX idx_attrs ON products USING gin (attributes jsonb_ops);

-- JSONB 列(仅 @> 包含查询, 体积更小)
CREATE INDEX idx_attrs_path ON products USING gin (attributes jsonb_path_ops);

-- 全文检索
CREATE INDEX idx_articles_fts ON articles USING gin (to_tsvector('english', body));

GIN 的构建比 B-tree 慢, 更新代价也更高(因为需要更新倒排列表), 但查询极快。高频写入的场景可以考虑 gin_pending_list_limit 配置来缓冲写入。

9.2.6 BRIN: 超大表的轻量级选择

BRIN(Block Range Index)的思路完全不同: 它不记录每一行的位置, 而是记录每个块范围(默认 128 个连续数据页为一组)内索引列的最小值和最大值。

sql
-- 按时间顺序写入的日志表
CREATE INDEX idx_logs_created ON logs USING brin (created_at);

-- 自定义块范围大小(更小的范围 = 更精确但体积更大)
CREATE INDEX idx_logs_created ON logs USING brin (created_at)
    WITH (pages_per_range = 64);

优点: 索引文件极小(几 KB 到几十 KB), 维护成本极低。缺点: 精度低, 只能排除明显不符合条件的块, 剩余块还需要 Seq Scan 验证。只有当数据按索引列的物理顺序写入时才真正有效, 乱序数据会导致每个块范围的 min/max 跨度很大, 无法过滤任何块。

BRIN 的适用前提

BRIN 索引只有在列值与数据写入顺序高度相关时才有效。按时间追加的日志表、按 ID 自增的订单表是典型场景。若数据是随机写入的, BRIN 几乎没有过滤效果。


9.3 复合索引与最左前缀原则

9.3.1 创建复合索引

复合索引(多列索引)在一个索引结构中同时索引多个列:

sql
-- 订单表按用户和状态过滤
CREATE INDEX idx_orders_user_status ON orders (user_id, status);

-- 三列复合索引
CREATE INDEX idx_orders_multi ON orders (user_id, status, created_at);

9.3.2 最左前缀原则详解

复合索引 (a, b, c) 的物理结构是先按 a 排序, a 相同时再按 b 排序, b 相同时再按 c 排序。因此, 查询能否使用这个索引, 取决于 WHERE 条件是否从最左列开始连续覆盖:

查询条件能否使用 (a, b, c)说明
WHERE a = ?能(使用前缀 a)
WHERE a = ? AND b = ?能(使用前缀 a, b)
WHERE a = ? AND b = ? AND c = ?能(完整使用)
WHERE b = ?不能跳过了最左列 a
WHERE c = ?不能跳过了 a 和 b
WHERE a = ? AND c = ?能用 a, 但 c 无法利用中间跳过了 b
WHERE a > ? AND b = ?a 用范围查询后 b 无效范围条件后的列无法利用
sql
-- 场景: 查某用户的待支付订单, 按创建时间排序
-- 建议索引: (user_id, status, created_at)
EXPLAIN SELECT id, amount FROM orders
WHERE user_id = 42
  AND status = 'pending'
ORDER BY created_at;

优化器会调整 WHERE 条件的顺序

WHERE a = ? AND b = ?WHERE b = ? AND a = ? 在 PostgreSQL 中完全等价, 优化器会自动重排条件顺序来匹配索引。你不需要为此手动调整 SQL 写法。

9.3.3 列顺序的选择原则

复合索引的列顺序对性能影响显著, 一般遵循以下原则:

  1. 等值条件的列放在范围条件之前: (status, created_at)(created_at, status) 更适合 WHERE status = 'active' AND created_at > '2024-01-01'
  2. 高选择性的列放前面: 选择性越高(不同值越多), 越能快速缩小候选集。user_id 通常比 status 选择性高, 应放前面。
  3. 结合查询模式: 如果大多数查询只过滤 status, 就需要单独的 (status) 索引, 而不是依赖复合索引。

9.4 覆盖索引: 让查询彻底不回表

9.4.1 Index Only Scan 的原理

普通索引查询的最后一步是"回表": 拿着叶节点的 ctid 去堆文件读完整行, 以获取 SELECT 列表中索引里没有的列。每次回表都是一次随机 I/O。当结果集较大时, 这些随机 I/O 的累积代价不可忽视。

覆盖索引(Covering Index)的思路: 把 SELECT 需要的所有列都放进索引, 使查询完全在索引结构内完成, 不需要访问堆文件。PostgreSQL 将这种执行方式称为 Index Only Scan

9.4.2 INCLUDE 子句(PG 11+)

在 PG 11 之前, 要实现覆盖索引必须把额外的列加入索引键, 这些列会参与排序并影响索引结构。PG 11 引入了 INCLUDE 子句, 允许将额外列作为非键列附加在叶节点上, 不参与排序, 不影响索引结构, 只是"捎带存储"以避免回表:

sql
-- 订单列表查询: WHERE status = ? ORDER BY created_at, SELECT id, amount, created_at
-- 普通写法: 需要回表取 amount
CREATE INDEX idx_orders_status_time ON orders (status, created_at);

-- 覆盖索引写法: INCLUDE 把 id 和 amount 附加到叶节点
CREATE INDEX idx_orders_covering ON orders (status, created_at)
    INCLUDE (id, amount);

对应查询:

sql
SELECT id, amount, created_at
FROM orders
WHERE status = 'pending'
ORDER BY created_at DESC
LIMIT 20;

执行计划会显示 Index Only Scan using idx_orders_covering, 完全不访问堆文件。

INCLUDE 与索引键的区别

放在 INCLUDE 里的列不能用于 WHERE 过滤和 ORDER BY 排序, 只能被 SELECT 直接读取。不要把所有 SELECT 列都塞进 INCLUDE, 索引会膨胀, 维护成本上升。只把"高频查询中仅在 SELECT 出现、不在 WHERE/ORDER BY 出现"的列放进 INCLUDE。

9.4.3 验证 Index Only Scan

sql
EXPLAIN (ANALYZE, BUFFERS) 
SELECT id, amount, created_at
FROM orders
WHERE status = 'pending'
ORDER BY created_at DESC
LIMIT 20;

执行计划关注两点:

  • 节点类型是否为 Index Only Scan(而非 Index Scan)。
  • Heap Fetches 行: 若为 0, 说明 visibility map 已标记页面为全可见, 完全无回表; 若不为 0, 说明有部分页面尚未 VACUUM, 仍需少量回表确认行可见性。定期 VACUUM 有助于让 Heap Fetches 趋近于 0。

9.5 部分索引: 只索引你真正需要的行

9.5.1 什么是部分索引

部分索引(Partial Index)在 CREATE INDEX 时附加 WHERE 子句, 只为满足条件的行建立索引条目。对于不满足条件的行, 索引中完全不存在, 因此索引体积更小、查询更快、写入开销更低。

9.5.2 典型场景与实战

场景一: 只查询未支付订单

业务中"待支付"订单数量远少于历史订单总量, 且几乎所有查询都只关心待支付状态:

sql
-- 只为 status = 'pending' 的行建索引
CREATE INDEX idx_orders_pending ON orders (created_at)
    WHERE status = 'pending';

-- 查询时必须包含 WHERE status = 'pending' 才能命中此索引
SELECT * FROM orders
WHERE status = 'pending'
  AND created_at < NOW() - INTERVAL '30 minutes';

与全表的 (status, created_at) 复合索引相比, 部分索引的条目数量可能只有 1/100, 索引文件小一到两个数量级。

场景二: 软删除表只索引未删除的行

sql
CREATE INDEX idx_users_active ON users (email)
    WHERE deleted_at IS NULL;

-- 唯一约束: 未删除用户的 email 不能重复
CREATE UNIQUE INDEX idx_users_email_unique ON users (email)
    WHERE deleted_at IS NULL;

这样删除用户(设置 deleted_at)不会影响唯一约束, 多个已删除账号可以共用同一个 email。

场景三: 活跃用户的高频查询

sql
-- 只为过去 30 天内登录过的活跃用户建索引
CREATE INDEX idx_users_active_login ON users (last_login_at DESC)
    WHERE is_active = true;

部分索引的命中条件

查询的 WHERE 子句必须逻辑蕴含索引的 WHERE 条件, 优化器才会选用部分索引。WHERE status = 'pending'WHERE status = 'pending' AND created_at < ? 都能命中 WHERE status = 'pending' 的部分索引。但 WHERE status = 'active' 不能命中。


9.6 表达式与函数索引

9.6.1 对表达式建索引

有时 WHERE 条件不是直接对列比较, 而是对列做某种转换后再比较。这时普通索引无效, 需要对相同的表达式建索引:

sql
-- 错误: 这个查询用不到 email 列上的普通索引
SELECT * FROM users WHERE lower(email) = 'alice@example.com';

-- 正确: 对 lower(email) 建函数索引
CREATE UNIQUE INDEX idx_users_email_lower ON users (lower(email));

-- 现在这个查询能命中索引
SELECT * FROM users WHERE lower(email) = 'alice@example.com';

这个技巧还能实现大小写不敏感的唯一约束: 通过唯一函数索引, 保证 alice@example.comAlice@EXAMPLE.COM 不能同时存在。

9.6.2 对 JSONB 特定路径建索引

sql
-- products 表的 attributes 字段是 JSONB
-- 高频查询: 按品牌过滤
SELECT * FROM products
WHERE attributes->>'brand' = 'Nike';

-- 对 JSONB 路径表达式建 B-tree 索引
CREATE INDEX idx_products_brand ON products ((attributes->>'brand'));

-- 注意: 表达式索引需要加括号 (expression)

对于需要支持多个 JSONB 路径查询的场景, GIN 索引更合适(见 9.7 节)。单路径高频查询用表达式 B-tree 索引更高效。

9.6.3 关键约束: 查询必须完全匹配

函数索引的命中条件

查询中的 WHERE 表达式必须与索引定义的表达式完全一致(文本层面), 优化器才能识别并使用。lower(email)LOWER(email) 虽然等价, 但 PostgreSQL 会将其规范化为相同形式, 通常没有问题。但如果索引是 (date_trunc('day', created_at)), 查询必须也写 WHERE date_trunc('day', created_at) = ?, 而不能写 WHERE created_at::date = ?

9.6.4 常见的函数索引应用

sql
-- 1. 年份提取(日期分区查询)
CREATE INDEX idx_orders_year ON orders ((EXTRACT(year FROM created_at)));

-- 2. 字符串哈希(大文本的等值比较)
CREATE INDEX idx_content_hash ON articles (md5(content));

-- 3. 数组长度(查找有很多标签的文章)
CREATE INDEX idx_tags_count ON articles ((array_length(tags, 1)));

9.7 唯一索引与唯一约束

9.7.1 两者的关系

在 PostgreSQL 中, UNIQUE 约束的底层实现就是一个唯一 B-tree 索引。以下两种写法效果完全等价:

sql
-- 方式一: 通过约束(内部自动创建唯一索引)
ALTER TABLE users ADD CONSTRAINT uq_users_email UNIQUE (email);

-- 方式二: 直接创建唯一索引
CREATE UNIQUE INDEX idx_users_email ON users (email);

区别在于: 通过 ADD CONSTRAINT 创建的索引有约束名, 错误信息更友好; 直接创建的索引更灵活, 可以附加 WHEREINCLUDE 等子句。

9.7.2 NULLS DISTINCT vs NULLS NOT DISTINCT (PG 15+)

在 PG 15 之前, 唯一索引默认认为 NULL ≠ NULL, 即两个 NULL 值不冲突, 可以都被插入。这有时不符合业务需求(例如 phone 字段只允许一个用户不填手机号, 不允许多个用户都不填):

sql
-- PG 15+: NULLS NOT DISTINCT 把 NULL 视为相等
CREATE UNIQUE INDEX idx_users_phone ON users (phone)
    NULLS NOT DISTINCT;

-- 或者通过约束语法
ALTER TABLE users ADD CONSTRAINT uq_users_phone UNIQUE NULLS NOT DISTINCT (phone);
设置行为适用场景
NULLS DISTINCT(默认)NULL 彼此不相等, 可多行都为 NULL可选字段, 允许多行未填
NULLS NOT DISTINCTNULL 视为相等, 只允许一行为 NULL有业务意义的"唯一空值"约束

9.8 GIN 索引用于 JSONB 与数组

9.8.1 两种 JSONB 操作符族

GIN 索引对 JSONB 列有两种操作符族可选, 选择哪种取决于你需要支持哪些操作符:

操作符族支持的操作符索引体积查询速度
jsonb_ops(默认)@> <@ ? ?& ?| @@较大通用
jsonb_path_ops@>(包含查询)更小@> 查询更快
sql
-- 使用默认 jsonb_ops: 支持所有 JSONB 操作符
CREATE INDEX idx_products_attrs ON products USING gin (attributes);
-- 等价写法
CREATE INDEX idx_products_attrs ON products USING gin (attributes jsonb_ops);

-- 使用 jsonb_path_ops: 只需要 @> 包含查询时优先选这个
CREATE INDEX idx_products_attrs_path ON products USING gin (attributes jsonb_path_ops);

9.8.2 实战: 按 JSONB 属性过滤商品

假设 products 表的 attributes 字段存储如下结构:

sql
-- 示例数据
INSERT INTO products (name, attributes) VALUES
('Air Max 90', '{"brand":"Nike","color":"white","size":[40,41,42,43]}'),
('Classic Leather', '{"brand":"Reebok","color":"black","size":[38,39,40]}'),
('Stan Smith', '{"brand":"Adidas","color":"white","size":[39,40,41]}');

-- 建 GIN 索引
CREATE INDEX idx_products_attrs ON products USING gin (attributes jsonb_path_ops);

-- @> 包含查询: 找所有白色 Nike 商品
SELECT id, name FROM products
WHERE attributes @> '{"brand":"Nike","color":"white"}';

-- @> 查询数组元素: 找有 41 码的商品
SELECT id, name FROM products
WHERE attributes @> '{"size":[41]}';

9.8.3 ? 操作符: 检查键是否存在

? 操作符检查 JSONB 对象是否包含某个键, 需要 jsonb_ops 操作符族:

sql
-- 建完整操作符族索引
CREATE INDEX idx_products_attrs_ops ON products USING gin (attributes jsonb_ops);

-- 查找所有有 "discount" 键的商品(正在打折的商品)
SELECT id, name FROM products
WHERE attributes ? 'discount';

-- 查找同时有 "discount" 和 "stock" 键的商品
SELECT id, name FROM products
WHERE attributes ?& ARRAY['discount', 'stock'];

9.8.4 GIN 索引用于数组列

数组列的 GIN 索引用法与 JSONB 类似:

sql
-- tags 是 text[] 类型
CREATE INDEX idx_articles_tags ON articles USING gin (tags);

-- 包含查询: 找所有包含 'postgresql' 标签的文章
SELECT id, title FROM articles
WHERE tags @> ARRAY['postgresql'];

-- 交集查询: 找包含 'postgresql' 或 'database' 标签的文章
SELECT id, title FROM articles
WHERE tags && ARRAY['postgresql', 'database'];

GIN 索引的写入代价

GIN 索引的每次更新需要修改倒排列表, 写入代价高于 B-tree。对于高频写入的表, 可以调整 gin_pending_list_limit(默认 4MB)来积累写入后批量合并, 减少实时合并的开销。代价是查询时可能需要扫描 pending 列表。


9.9 索引的代价分析

索引不是越多越好。每个索引都引入了持续性的维护成本, 在评估是否建索引时必须将收益与代价放在一起权衡。

9.9.1 三种代价

1. 写入开销

每次 INSERTUPDATEDELETE 都需要同步更新所有相关索引。一张有 10 个索引的表, 每次插入就要写 11 个地方(1 个堆文件 + 10 个索引)。对于写密集型的表, 过多的索引会使写入吞吐量显著下降。

sql
-- 可以用 EXPLAIN (ANALYZE) 观察写入语句的索引维护时间
EXPLAIN (ANALYZE, BUFFERS)
INSERT INTO orders (user_id, status, amount) VALUES (1, 'pending', 99.0);

2. 磁盘空间

索引是独立的存储对象, 体积可能与表相当甚至超过表本身。可通过以下查询查看各索引的大小:

sql
SELECT
    schemaname,
    tablename,
    indexname,
    pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC;

3. VACUUM 维护成本

PostgreSQL 的 MVCC 机制会在更新/删除时产生死元组(dead tuples)。VACUUM 在清理堆文件死元组的同时, 也需要清理索引中指向这些死元组的条目。索引越多, VACUUM 耗时越长, 对系统的影响时间越长。

9.9.2 索引选择的原则

索引与查询模式对齐原则

  • 只为高频且响应时间敏感的查询建索引。
  • 评估查询的选择性: 如果条件能过滤掉 95% 以上的行, 索引才有明显价值。
  • 对于写多读少的表(如日志、消息队列), 尽量控制索引数量。
  • 定期用 pg_stat_user_indexes 检查 idx_scan = 0 的废索引并清理。
  • 使用 EXPLAIN (ANALYZE, BUFFERS) 而不是 EXPLAIN 来验证索引是否真正被使用, 以及实际执行时间与估算的差距。

9.10 索引失效常见原因

索引存在, 但查询却走了全表扫描, 这是最令人困惑的性能问题之一。以下是最常见的原因:

9.10.1 列上套函数

这是最高频的索引失效原因。B-tree 索引是对列原始值建立的, 一旦对列施加了函数变换, 原始值的有序性就丢失了, 索引无法使用。

sql
-- 索引失效: 对 created_at 列套了函数
SELECT * FROM orders
WHERE date_trunc('day', created_at) = '2024-06-01';

-- 修复: 改写成范围条件, 让列保持"裸露"
SELECT * FROM orders
WHERE created_at >= '2024-06-01'
  AND created_at < '2024-06-02';

-- 或者: 对表达式建函数索引(如果确实无法改写查询)
CREATE INDEX idx_orders_day ON orders ((date_trunc('day', created_at)));

9.10.2 隐式类型转换

当 WHERE 条件的值类型与列的类型不匹配时, PostgreSQL 会尝试隐式转换。转换方向不对时会导致索引失效:

sql
-- 索引失效: user_id 是 integer, 但传入了字符串
-- PostgreSQL 需要把 user_id 转成 text 再比较, 等于对列套了转换函数
SELECT * FROM users WHERE user_id = '42';

-- 修复: 传入正确类型
SELECT * FROM users WHERE user_id = 42;

隐式类型转换在 ORM 中更隐蔽

在使用 ORM 或参数化查询时, 务必确保传入的参数类型与列类型一致。例如 Java 中用 setString 传 integer 列的值, 或 Python 中传 str 而非 int, 都可能导致隐式类型转换而失效。

9.10.3 前导通配符

B-tree 索引是按键值从左到右排序的, LIKE '%abc' 的前导通配符意味着需要扫描所有行来检查后缀, B-tree 无法利用:

sql
-- 索引失效: 前导通配符
SELECT * FROM users WHERE username LIKE '%alice';

-- 索引有效: 前缀匹配
SELECT * FROM users WHERE username LIKE 'alice%';

-- 后缀匹配的解决方案: 对 reverse(username) 建索引
CREATE INDEX idx_users_username_rev ON users (reverse(username));
SELECT * FROM users WHERE reverse(username) LIKE reverse('%alice');
-- 即: WHERE reverse(username) LIKE 'ecila%'

9.10.4 OR 连接不同列

WHERE a = 1 OR b = 2 不能直接走单个索引, 因为两个条件分别对应不同的列:

sql
-- 可能走不到索引或走 Bitmap OR
SELECT * FROM orders WHERE user_id = 42 OR status = 'pending';

-- 改写一: 用 UNION ALL 分别走各自的索引
SELECT * FROM orders WHERE user_id = 42
UNION ALL
SELECT * FROM orders WHERE status = 'pending' AND user_id != 42;

-- 改写二: 如果两列都有索引, PostgreSQL 可能自动用 BitmapOr 合并
-- 可用 EXPLAIN 观察是否出现 BitmapAnd / BitmapOr 节点

9.10.5 统计信息过期导致优化器误判

这不是严格意义上的"索引失效", 而是优化器根据过期的统计信息错误地估算了行数, 从而选择了全表扫描:

sql
-- 重新收集统计信息
ANALYZE orders;

-- 查看某列的统计信息
SELECT * FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';

9.10.6 选择率过低

如果索引列的值非常集中(例如 gender 只有 'M'/'F' 两个值, 各占 50%), 查询 WHERE gender = 'M' 会返回约 50% 的行, 这时全表顺序扫描比走索引 + 随机 I/O 更快, 优化器会主动放弃索引。

失效原因快速诊断方法修复思路
列上套函数EXPLAIN 看 Filter 条件改写查询或建函数索引
隐式类型转换\d tablename 检查列类型确保参数类型匹配
前导通配符WHERE 中有 LIKE '%...'改前缀查询或全文检索
OR 不同列EXPLAIN 无 Index ScanUNION ALL 或 Bitmap OR
统计信息过期估算行数与实际差距大ANALYZE 更新统计信息
选择率太低查询返回大量行考虑部分索引或接受全表扫描

9.11 在线维护: 不停服管理索引

9.11.1 CREATE INDEX CONCURRENTLY

普通的 CREATE INDEX 在建索引过程中会持有 ShareLock, 阻塞所有对表的写操作(INSERT/UPDATE/DELETE)。对于生产环境的大表, 这是不可接受的。CONCURRENTLY 选项解决了这个问题:

sql
-- 普通建索引: 阻塞写入, 快
CREATE INDEX idx_orders_user_id ON orders (user_id);

-- 并发建索引: 不阻塞写入, 慢(需要多次扫描表)
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders (user_id);

并发建索引的过程分三个阶段:

  1. 第一次全表扫描: 构建初始索引快照, 同时记录构建期间发生的变更。
  2. 等待所有现有事务结束。
  3. 第二次扫描: 合并记录的变更, 确保索引与堆文件一致。

CONCURRENTLY 的注意事项

  • 并发建索引不能在事务块内执行(不能放在 BEGIN...COMMIT 里)。
  • 耗时约是普通建索引的 2 到 3 倍。
  • 若中途失败(例如违反唯一约束), 会留下一个状态为 INVALID 的索引, 必须手动清理:
sql
-- 查找 INVALID 状态的索引
SELECT indexrelid::regclass AS index_name, indisvalid
FROM pg_index
WHERE indisvalid = false;

-- 删除无效索引后重新建
DROP INDEX CONCURRENTLY idx_orders_user_id;
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders (user_id);

9.11.2 REINDEX CONCURRENTLY (PG 12+)

索引随着时间推移可能因为大量更新/删除而产生膨胀(bloat), 需要重建。PG 12 之前只有 REINDEX, 它会阻塞写入。PG 12+ 提供了并发重建:

sql
-- 重建单个索引(不阻塞写入)
REINDEX INDEX CONCURRENTLY idx_orders_user_id;

-- 重建整张表的所有索引(不阻塞写入)
REINDEX TABLE CONCURRENTLY orders;

-- 重建整个数据库的所有索引(谨慎使用)
REINDEX DATABASE CONCURRENTLY mydb;

检查索引是否存在膨胀:

sql
-- 通过 pgstattuple 扩展检查索引膨胀率
CREATE EXTENSION IF NOT EXISTS pgstattuple;

SELECT
    indexrelid::regclass AS index_name,
    pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
    (pgstattuple(indexrelid)).dead_tuple_percent AS dead_pct
FROM pg_index
WHERE schemaname = 'public'
ORDER BY pg_relation_size(indexrelid) DESC;

dead_tuple_percent 超过 20% 的索引建议执行 REINDEX CONCURRENTLY 重建。

9.11.3 查看索引使用情况

pg_stat_user_indexes 视图记录了自上次 pg_stat_reset() 以来每个索引被使用的次数:

sql
-- 查看各索引的扫描次数和回表次数
SELECT
    schemaname,
    tablename,
    indexname,
    idx_scan,            -- 索引被扫描的总次数
    idx_tup_read,        -- 通过索引读取的元组数
    idx_tup_fetch,       -- 通过索引取到的堆元组数(回表次数)
    pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
ORDER BY idx_scan DESC;

9.11.4 废索引清理

idx_scan = 0 说明自统计信息重置以来这个索引从未被使用过。结合索引大小和创建时间综合判断, 确认是废索引后可以安全删除:

sql
-- 找出从未被使用的索引(排除主键和唯一约束, 它们可能很少被直接扫描但必须保留)
SELECT
    s.schemaname,
    s.tablename,
    s.indexname,
    pg_size_pretty(pg_relation_size(s.indexrelid)) AS index_size,
    s.idx_scan,
    i.indisprimary AS is_primary,
    i.indisunique   AS is_unique
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE s.idx_scan = 0
  AND NOT i.indisprimary
  AND NOT i.indisunique
ORDER BY pg_relation_size(s.indexrelid) DESC;

废索引清理前的必要检查

idx_scan = 0 只代表自上次统计重置后未被使用。统计信息可能在数据库重启或手动调用 pg_stat_reset() 后清零。建议:

  1. 确认统计信息已积累足够长的时间(至少一个完整的业务周期, 如一个月)。
  2. 检查是否有定期任务(如月末对账、年度报表)依赖该索引。
  3. 在测试环境先删除, 观察一个业务周期后再在生产环境执行。
sql
-- 查看统计信息的最后重置时间
SELECT pg_stat_get_db_stat_reset_time(oid) AS stats_reset
FROM pg_database
WHERE datname = current_database();

删除废索引:

sql
-- 删除前先记录索引定义(方便回滚)
SELECT indexdef FROM pg_indexes
WHERE indexname = 'idx_orders_old_status';

-- 并发删除, 不阻塞查询
DROP INDEX CONCURRENTLY idx_orders_old_status;

9.12 索引选择决策树

综合本章内容, 在面对一个新的查询优化需求时, 可以按以下思路选择合适的索引:


本章小结

本章系统讲解了 PostgreSQL 索引的原理与实战。核心要点回顾:

原理层面: B-tree 是按键有序的平衡树, 查找复杂度 O(log n) vs 全表扫描 O(n), 叶节点通过 ctid 指向堆文件行。

类型选择: B-tree 是默认选择覆盖 90% 的场景; GIN 用于数组/JSONB/全文; GiST 用于几何/范围; BRIN 用于超大且按顺序写入的时序数据。

索引设计:

  • 复合索引遵循最左前缀原则, 等值条件的列放前面。
  • INCLUDE 子句构建覆盖索引, 实现 Index Only Scan 消除回表。
  • WHERE 子句构建部分索引, 只索引业务关心的子集。
  • 对函数表达式建索引时, 查询必须完全匹配表达式。

索引代价: 每个索引增加写入开销、磁盘空间和 VACUUM 负担。应与查询模式对齐, 定期清理废索引。

常见陷阱: 列上套函数、隐式类型转换、前导通配符是三大高频索引失效原因。

在线维护: 生产环境建索引和重建索引一律加 CONCURRENTLY; 定期查询 pg_stat_user_indexes 监控索引使用情况并清理零扫描的废索引。

下一章将进入查询优化与执行计划, 深入解读 EXPLAIN ANALYZE 的每个节点, 学习如何系统性地优化复杂查询。

十、事务、MVCC 与并发控制

数据库的核心价值之一, 在于能让多个用户同时安全地读写数据, 同时保证即便发生故障也不会丢失已提交的修改。事务(Transaction)是实现这一目标的基本单元, MVCC(多版本并发控制)则是 PostgreSQL 高并发能力的基石。本章从 ACID 四个特性出发, 深入剖析事务语法、隔离级别、MVCC 原理、锁机制与死锁检测, 并给出超卖等真实并发问题的正确解法。

10.1 ACID 四性

ACID 是关系型数据库事务的四个基本保证: 原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。下面以银行转账场景逐一说明。

10.1.1 原子性(Atomicity)

原子性要求事务内的所有操作要么全部成功提交, 要么全部撤销回滚, 不存在"做了一半"的中间态。

转账场景: 将账户 A 的 100 元转给账户 B, 需要执行两条 UPDATE:

sql
BEGIN;

UPDATE accounts SET balance = balance - 100 WHERE id = 'A';
UPDATE accounts SET balance = balance + 100 WHERE id = 'B';

COMMIT;

如果第二条 UPDATE 执行前数据库崩溃, PostgreSQL 重启后会通过 WAL(Write-Ahead Log)日志回滚未提交的修改, 账户 A 的扣款也会被撤销。应用层也可以主动触发回滚:

sql
BEGIN;

UPDATE accounts SET balance = balance - 100 WHERE id = 'A';
-- 发现余额不足, 主动放弃
ROLLBACK;

ROLLBACK 后两条语句的效果都不会持久化, 数据库回到事务开始前的状态。

10.1.2 一致性(Consistency)

一致性要求事务把数据库从一个合法状态变换到另一个合法状态, 所有业务约束和完整性规则在事务结束后仍然成立。

转账场景中, 一致性体现在两个层面:

  • 业务不变量: 转账前后所有账户的余额总和不变。若转出 100 元, 则转入也必须是 100 元。
  • 数据库约束: 若 balance 列定义了 CHECK (balance >= 0), 则当账户 A 余额不足时, UPDATE 会报约束违反错误, 事务被强制回滚, 约束始终成立。
sql
-- 创建带约束的账户表
CREATE TABLE accounts (
    id      TEXT PRIMARY KEY,
    balance NUMERIC NOT NULL CHECK (balance >= 0)
);

BEGIN;
UPDATE accounts SET balance = balance - 200 WHERE id = 'A'; -- A 余额只有 100, 违反 CHECK
-- ERROR: new row for relation "accounts" violates check constraint "accounts_balance_check"
-- 此时事务进入 aborted 状态, 只能 ROLLBACK
ROLLBACK;

一致性与应用层的责任

一致性不仅依赖数据库约束, 更依赖应用程序正确编写事务逻辑。数据库能强制执行的只有声明式约束; 业务不变量(如转入转出金额相等)需要应用代码保证。

10.1.3 隔离性(Isolation)

隔离性要求并发执行的多个事务互不干扰, 每个事务都感觉自己是"独占"数据库在运行。

转账场景: 事务 T1 正在将 A→B 转账, 事务 T2 同时在读账户 B 的余额。若隔离性不够, T2 可能读到 T1 尚未提交的中间值(脏读), 看到一个"虚假"的高余额。PostgreSQL 通过 MVCC 和锁机制实现隔离, 具体的隔离级别和读异常将在 10.3 节展开。

10.1.4 持久性(Durability)

持久性要求事务一旦 COMMIT, 其修改就被永久保存, 即使随后发生断电、操作系统崩溃或硬件故障, 数据也不会丢失。

PostgreSQL 通过 WAL(Write-Ahead Log, 预写日志) 实现持久性。其核心思想是: 在将数据页写入磁盘之前, 必须先将对应的 WAL 日志记录写入磁盘。具体流程如下:

  1. 事务执行期间, 对数据的修改先在内存(shared buffer)中进行, 同时将变更记录追加到 WAL 缓冲区。
  2. COMMIT 时, PostgreSQL 强制将 WAL 缓冲区刷写到 WAL 文件(默认使用 fsync), 此时 COMMIT 才返回成功。
  3. 数据页可以延迟写回磁盘(由 bgwriter/checkpointer 异步完成)。
  4. 若数据库崩溃重启, PostgreSQL 从上一个检查点(checkpoint)开始回放 WAL, 将所有已提交的修改重新应用到数据页, 恢复到崩溃前的一致状态。

WAL 的额外价值

WAL 不仅实现了持久性, 还是流复制(Streaming Replication)和时间点恢复(PITR)的基础。备库通过不断接收和回放主库的 WAL 流来保持同步。


10.2 事务控制语法

10.2.1 基本命令

sql
-- 开始事务 (两种写法等价)
BEGIN;
START TRANSACTION;

-- 提交事务
COMMIT;

-- 回滚事务
ROLLBACK;

PostgreSQL 默认工作在**自动提交(autocommit)**模式: 每条 SQL 语句都隐式地被包裹在一个单语句事务中, 执行完立即提交。只有显式执行 BEGIN 之后, 后续语句才属于同一个事务, 直到遇到 COMMITROLLBACK

10.2.2 保存点(SAVEPOINT): 部分回滚

保存点允许在事务内部设置"回退标记", 出错时只回滚到保存点, 而不必放弃整个事务。这在批量导入等场景下非常实用。

语法:

sql
SAVEPOINT savepoint_name;          -- 设置保存点
ROLLBACK TO SAVEPOINT savepoint_name;  -- 回滚到保存点(事务仍然活跃)
RELEASE SAVEPOINT savepoint_name;  -- 释放保存点(可选, 节省内存)

实战: 批量插入遇错只回滚当条

假设需要批量插入 10000 行用户数据, 其中少数行因为违反唯一约束而失败。使用保存点可以跳过出错行, 继续插入其余行:

sql
BEGIN;

-- 循环插入(伪代码展示逻辑, 实际在应用层或 PL/pgSQL 中实现)
SAVEPOINT sp_row;
INSERT INTO users (email, name) VALUES ('alice@example.com', 'Alice');
RELEASE SAVEPOINT sp_row;   -- 成功, 释放保存点

SAVEPOINT sp_row;
INSERT INTO users (email, name) VALUES ('alice@example.com', 'Alice Duplicate');
-- ERROR: duplicate key value violates unique constraint "users_email_key"
ROLLBACK TO SAVEPOINT sp_row;  -- 只回滚这一行, 前面成功的插入保留
RELEASE SAVEPOINT sp_row;

SAVEPOINT sp_row;
INSERT INTO users (email, name) VALUES ('bob@example.com', 'Bob');
RELEASE SAVEPOINT sp_row;   -- 成功

COMMIT;  -- 最终只有 alice 和 bob 被插入

用 PL/pgSQL 封装后可以处理任意条数的批量导入, 出错行记录到日志, 其余行全部成功提交。

10.2.3 事务 Aborted 状态

这是初学者常遇到的陷阱。事务内某条 SQL 报错后, 事务进入 aborted(中止) 状态。此时 PostgreSQL 拒绝执行任何后续的数据操作语句, 直到显式 ROLLBACKROLLBACK TO SAVEPOINT:

sql
BEGIN;

INSERT INTO orders (id, amount) VALUES (1, 100);  -- 成功

INSERT INTO orders (id, amount) VALUES (1, 200);  -- 报错: 主键冲突
-- ERROR: duplicate key value violates unique constraint "orders_pkey"

-- 此时事务处于 aborted 状态
SELECT * FROM orders;
-- ERROR: current transaction is aborted, commands ignored until end of transaction block

-- 唯一出路: 回滚整个事务或回滚到保存点
ROLLBACK;

不要忽略事务中的错误

在应用代码中执行多语句事务时, 必须检查每条语句的执行结果。如果忽略错误继续执行, 后续所有语句都会静默失败(返回错误但不报告), 最终 COMMIT 也会被当作 ROLLBACK 处理, 导致数据完全未写入却以为成功。


10.3 隔离级别与读异常

10.3.1 三类读异常

在深入隔离级别之前, 先理解并发场景下可能出现的三类异常现象。

脏读(Dirty Read): 事务 T1 读到了事务 T2 尚未提交的修改。若 T2 随后回滚, T1 读到的数据就是"幻影数据"。

sql
-- 事务 T2: 转账中, 尚未提交
BEGIN;
UPDATE accounts SET balance = balance + 100 WHERE id = 'B';
-- 还没 COMMIT

-- 事务 T1(如果允许脏读): 读到了 B 的新余额
SELECT balance FROM accounts WHERE id = 'B';  -- 读到未提交的 +100
-- T2 此后 ROLLBACK, T1 读到的数据是错误的

不可重复读(Non-Repeatable Read): 同一事务内, 对同一行执行两次查询, 结果不同(因为其他事务在两次查询之间提交了修改)。

sql
-- 事务 T1: 两次读账户 A 的余额
BEGIN;
SELECT balance FROM accounts WHERE id = 'A';  -- 第一次: 500

-- 期间事务 T2 提交了: UPDATE accounts SET balance = 400 WHERE id = 'A';

SELECT balance FROM accounts WHERE id = 'A';  -- 第二次: 400 (不同了!)
COMMIT;

幻读(Phantom Read): 同一事务内, 对同一范围条件执行两次查询, 第二次出现了第一次没有的行(因为其他事务插入并提交了新行)。

sql
-- 事务 T1: 统计高余额账户
BEGIN;
SELECT COUNT(*) FROM accounts WHERE balance > 1000;  -- 第一次: 3 行

-- 期间事务 T2 提交了: INSERT INTO accounts VALUES ('C', 2000);

SELECT COUNT(*) FROM accounts WHERE balance > 1000;  -- 第二次: 4 行(幻行出现)
COMMIT;

10.3.2 隔离级别对比

SQL 标准定义了四个隔离级别, 通过允许或禁止上述三类读异常来区分。PostgreSQL 的实现在标准之上更为严格:

隔离级别脏读不可重复读幻读PG 实际实现
Read Uncommitted(读未提交)允许(理论)允许允许等同 Read Committed, 脏读实际不会发生
Read Committed(读已提交)禁止允许允许PG 默认级别
Repeatable Read(可重复读)禁止禁止允许(理论)PG 连幻读也禁止
Serializable(可串行化)禁止禁止禁止PG 用 SSI 实现, 还防写偏斜

10.3.3 各隔离级别详解

Read Committed(读已提交, PG 默认)

每条 SQL 语句执行时都会获取一份新的"快照", 看到的是截至该语句开始时所有已提交的数据。这意味着同一事务内两条相同的 SELECT 语句可能看到不同的结果——前一条执行后, 其他事务提交了修改, 后一条就能看到新值。

sql
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;  -- PG 默认, 可省略
BEGIN;
SELECT balance FROM accounts WHERE id = 'A';  -- 快照1: 500
-- 其他事务提交: UPDATE accounts SET balance = 400 WHERE id = 'A';
SELECT balance FROM accounts WHERE id = 'A';  -- 快照2: 400 (不可重复读发生)
COMMIT;

Repeatable Read(可重复读)

事务开始时固定一份快照, 整个事务期间所有查询都使用这份快照。因此对同一行的多次查询结果一致。更重要的是, PostgreSQL 的可重复读实现基于快照, 连幻读也一并防住了: 其他事务新插入的行对当前事务的快照不可见, 范围查询结果集大小也不会变化。这比 SQL 标准的要求更强。

sql
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT balance FROM accounts WHERE id = 'A';  -- 500
-- 其他事务提交: UPDATE accounts SET balance = 400 WHERE id = 'A';
SELECT balance FROM accounts WHERE id = 'A';  -- 仍然 500 (可重复读)
SELECT COUNT(*) FROM accounts WHERE balance > 100;  -- 快照固定, 幻读也不会发生
COMMIT;

可重复读下的写冲突

虽然读操作看到固定快照, 但写操作仍然基于最新数据。若两个可重复读事务同时修改同一行, 后提交的事务会收到 ERROR: could not serialize access due to concurrent update 并被回滚。这是防止丢失更新的保护机制。

Serializable(可串行化)

最高隔离级别。PostgreSQL 使用 SSI(Serializable Snapshot Isolation) 算法实现, 在可重复读的基础上额外检测写偏斜(Write Skew)

写偏斜是一种可重复读也无法防止的微妙异常: 两个事务各自读取了一些数据并基于读到的结果做出写入决策, 最终的结果违反了业务约束, 但每个事务单独看都是合法的。

经典例子: 医院值班系统要求至少有一名医生在岗。

sql
-- 当前: 医生 A 和医生 B 都在岗(on_call = true)

-- 事务 T1: 医生 A 申请下班
BEGIN;
SELECT COUNT(*) FROM doctors WHERE on_call = true;  -- 结果: 2, 可以下班
UPDATE doctors SET on_call = false WHERE id = 'A';
COMMIT;

-- 事务 T2: 医生 B 同时申请下班
BEGIN;
SELECT COUNT(*) FROM doctors WHERE on_call = true;  -- 也读到 2, 也认为可以下班
UPDATE doctors SET on_call = false WHERE id = 'B';
COMMIT;

-- 结果: 两人都下班了, on_call = 0, 违反业务约束!
-- Repeatable Read 无法防止这种情况
-- Serializable 会检测到这种依赖环, 让其中一个事务报错并回滚

Serializable 模式下, PostgreSQL 会跟踪读写依赖关系, 发现潜在的串行化冲突时主动中止一个事务(ERROR: could not serialize access due to concurrent update)。代价是额外的内存消耗和偶尔的事务中止重试, 适用于对正确性要求极高、业务约束难以用数据库约束表达的场景(如上述值班系统、双重支出检测)。

设置隔离级别的语法:

sql
-- 为单个事务设置
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- 或在 BEGIN 之后、第一条语句之前设置
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- 修改会话默认级别
SET default_transaction_isolation = 'repeatable read';

10.4 MVCC 原理

MVCC(Multi-Version Concurrency Control, 多版本并发控制)是 PostgreSQL 并发架构的核心。它的根本思想是: 读操作不阻塞写操作, 写操作不阻塞读操作, 两者通过访问数据行的不同版本来并发执行, 彻底避免了读写互斥锁。

10.4.1 行版本的隐藏列

PostgreSQL 的每一行数据都携带两个隐藏的系统列, 记录该行版本的"生命期":

  • xmin: 创建(插入或更新产生新版本)该行的事务 ID(Transaction ID, XID)。
  • xmax: 删除或覆盖该行的事务 ID。若该行仍然"存活"(未被删除或更新), 则 xmax 为 0 或无效值。

可以通过查询直接观察这两列:

sql
CREATE TABLE demo (id int, val text);
INSERT INTO demo VALUES (1, 'hello');

SELECT xmin, xmax, id, val FROM demo;
--  xmin | xmax | id |  val
-- ------+------+----+-------
--   745 |    0 |  1 | hello
-- xmin=745 表示由事务 745 创建, xmax=0 表示未被删除

10.4.2 UPDATE 和 DELETE 的多版本机制

这是 MVCC 最关键的部分, 与直觉不同:

  • UPDATE 不修改原行: PostgreSQL 将原行标记为"旧版本"(设置 xmax 为当前事务 ID), 同时插入一行新版本(设置 xmin 为当前事务 ID, xmax 为 0)。物理上, 表中同时存在两个版本的行。
  • DELETE 不立即物理删除: 只将目标行的 xmax 设置为当前事务 ID, 该行继续物理存在于表中, 但对后续事务不可见。
sql
BEGIN;  -- 假设当前事务 XID = 750

UPDATE demo SET val = 'world' WHERE id = 1;
-- 原行: xmin=745, xmax=750 (被标记为"已删除/覆盖")
-- 新行: xmin=750, xmax=0   (新版本)

COMMIT;

SELECT xmin, xmax, id, val FROM demo;
--  xmin | xmax | id |  val
-- ------+------+----+-------
--   750 |    0 |  1 | world
-- 只看到新版本 (原行 xmax=750 已不可见, 但物理上还在表里等待 VACUUM)

10.4.3 快照可见性规则

每个事务开始时(对于 Repeatable Read/Serializable)或每条语句开始时(对于 Read Committed), PostgreSQL 会生成一份快照(Snapshot), 记录当时所有活跃事务的 XID 列表以及当前最大已分配 XID。

判断某一行版本对当前事务是否可见, 遵循以下规则(简化版):

  1. xmin 对应的事务在快照生成时尚未提交(仍在活跃列表中, 或 XID 大于快照的最大值), 则该行对当前事务不可见
  2. xmin 已提交, 且 xmax 为 0 或 xmax 对应的事务在快照生成时尚未提交, 则该行可见
  3. xmax 已提交, 则该行被视为已删除, 不可见

这套规则实现了"每个事务只能看到快照生成前已提交的数据"的语义, 是隔离性的基础。

下面用 Mermaid 时序图直观展示两个隔离级别下的差异:

上图清晰说明了两种隔离级别的核心差异: Read Committed 每条语句刷新快照, 因此能看到其他事务的新提交; Repeatable Read 在事务开始时固定快照, 整个事务期间"时间冻结"。

10.4.4 读写不互斥的原因

理解了多版本机制后, 读写不互斥就很自然了:

  • 读操作通过快照访问旧版本的行, 不需要对行加锁, 也不会阻塞写操作。
  • 写操作创建新版本的行或修改旧版本的 xmax, 不需要等待读操作完成。

两者操作的是数据行的不同版本, 在时间维度上互不冲突, 从而实现了高并发。这与 MySQL InnoDB 的 MVCC 思路相同, 但具体实现细节有差异。

10.4.5 MVCC 的代价: 死元组与 VACUUM

MVCC 的多版本机制带来了一个不可避免的副作用: 旧版本的行(被 UPDATE 覆盖或被 DELETE 标记的行)不会立即从磁盘删除, 而是作为**死元组(Dead Tuples)**继续占用表空间。随着时间推移, 若不清理, 表文件会持续膨胀, 查询性能也会因为需要跳过大量死元组而下降。

VACUUM 命令负责清理死元组:

  1. 扫描表文件, 找到 xmax 已提交且对所有活跃事务都不再可见的死元组。
  2. 回收空间: 将死元组占用的页面标记为可重用, 并更新 FSM(Free Space Map, 空闲空间映射), 供后续 INSERTUPDATE 复用这些页面。注意普通 VACUUM 不会把空间归还给操作系统; 只有 VACUUM FULL 会重写整张表(需要排他锁, 慎用)。
  3. 更新可见性映射(Visibility Map): 标记所有行对所有事务都可见的页面, 供 Index Only Scan 跳过堆访问。
  4. 推进 relfrozenxid: 防止事务 ID 回卷(XID Wraparound), 这是 PostgreSQL 的另一个重要维护任务。

手动触发:

sql
-- 清理指定表的死元组
VACUUM accounts;

-- 同时更新统计信息 (等价于 VACUUM + ANALYZE)
VACUUM ANALYZE accounts;

-- 查看表的死元组数量和上次 VACUUM 时间
SELECT relname,
       n_dead_tup,
       n_live_tup,
       last_autovacuum,
       last_vacuum
FROM pg_stat_user_tables
WHERE relname = 'accounts';

autovacuum: PostgreSQL 内置了自动清理进程, 默认开启。它根据 pg_stat_user_tables 中的 n_dead_tupn_live_tup 比例来判断是否需要触发 VACUUM。关键参数:

sql
-- 触发 autovacuum 的条件: 死元组数 > autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * 总行数
-- 默认: 50 + 0.2 * 总行数
SHOW autovacuum_vacuum_threshold;   -- 默认 50
SHOW autovacuum_vacuum_scale_factor; -- 默认 0.2

长事务是 VACUUM 的天敌

若存在一个长时间运行的事务(如忘记提交的事务), 所有在该事务开始后产生的死元组都无法被 VACUUM 回收, 因为它们对该老事务仍然"可见"。这会导致表空间持续膨胀。生产环境务必监控 pg_stat_activity 中的长事务并设置 idle_in_transaction_session_timeout 参数自动断开空闲超时连接。


10.5 锁机制

MVCC 解决了读写并发问题, 但写写并发仍然需要锁来保证正确性。PostgreSQL 提供了从行级到表级、从强制锁到咨询锁的完整锁体系。

10.5.1 行级锁

行级锁在 SELECT ... FOR ... 语句或 DML 执行时自动获取, 粒度精细, 对并发影响最小。

锁模式语法冲突对象典型用途
FOR UPDATESELECT ... FOR UPDATE其他 FOR UPDATEFOR NO KEY UPDATEFOR SHAREFOR KEY SHARE读后立即修改, 最强行锁
FOR NO KEY UPDATESELECT ... FOR NO KEY UPDATE不冲突 FOR KEY SHAREUPDATE 不涉及主键/唯一键时自动使用
FOR SHARESELECT ... FOR SHAREFOR UPDATEFOR NO KEY UPDATE防止目标行被删除或修改, 允许其他 FOR SHARE
FOR KEY SHARESELECT ... FOR KEY SHAREFOR UPDATE外键检查时使用, 最弱行锁

NOWAITSKIP LOCKED:

sql
-- NOWAIT: 若行已被锁定, 立即报错而不等待
SELECT * FROM orders WHERE id = 1 FOR UPDATE NOWAIT;
-- ERROR: could not obtain lock on row in relation "orders"

-- SKIP LOCKED: 跳过所有被锁定的行, 返回未被锁定的行
-- 非常适合多 Worker 并发消费任务队列
SELECT * FROM task_queue
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;

10.5.2 表级锁

PostgreSQL 定义了 8 种表级锁, 按强度从弱到强排列。大多数情况下由 SQL 语句自动获取, 无需手动操作:

锁模式自动获取时机说明
AccessShareLockSELECT最弱, 只与 AccessExclusiveLock 冲突
RowShareLockSELECT FOR UPDATE/SHARE
RowExclusiveLockINSERTUPDATEDELETE
ShareUpdateExclusiveLockVACUUMANALYZECREATE INDEX CONCURRENTLY
ShareLockCREATE INDEX(非 CONCURRENTLY)
ShareRowExclusiveLockCREATE TRIGGER、某些外键操作
ExclusiveLockREFRESH MATERIALIZED VIEW CONCURRENTLY
AccessExclusiveLockALTER TABLEDROP TABLETRUNCATEVACUUM FULL与所有锁冲突, 阻塞所有 DML

DDL 的 AccessExclusiveLock 影响

ALTER TABLE 等 DDL 操作会申请 AccessExclusiveLock, 该锁与所有其他锁(包括普通 SELECTAccessShareLock)冲突。在高并发生产环境中, 一条 ALTER TABLE ADD COLUMN 可能需要等待所有正在执行的查询完成才能获取锁, 而在等待期间后续所有查询也会排队, 瞬间造成连接堆积和服务不可用。生产环境执行 DDL 应使用 CREATE INDEX CONCURRENTLYADD COLUMN ... DEFAULT 等低影响方式, 并配合 lock_timeout 参数设置等待上限。

查看当前锁情况:

sql
-- 查看当前所有锁及对应的 SQL 语句
SELECT
    l.pid,
    l.relation::regclass AS table_name,
    l.locktype,
    l.mode,
    l.granted,
    a.query,
    a.state,
    a.wait_event_type,
    a.wait_event,
    age(clock_timestamp(), a.query_start) AS query_age
FROM pg_locks l
JOIN pg_stat_activity a ON l.pid = a.pid
WHERE l.relation IS NOT NULL
ORDER BY query_age DESC;

10.5.3 咨询锁(Advisory Lock)

咨询锁是 PostgreSQL 提供的应用层分布式锁机制。锁的"含义"完全由应用程序自定义, PostgreSQL 只负责在内存中维护锁的占用状态, 不与任何数据库对象关联。

sql
-- pg_try_advisory_lock: 非阻塞, 立即返回 true(成功) 或 false(已被占用)
SELECT pg_try_advisory_lock(12345);

-- pg_advisory_lock: 阻塞等待, 直到获取锁
SELECT pg_advisory_lock(12345);

-- pg_advisory_unlock: 释放锁
SELECT pg_advisory_unlock(12345);

-- 使用两个整数参数, 可表达更多语义(如表ID + 行ID)
SELECT pg_try_advisory_lock(42, 100);

典型用法: 防止定时任务重复执行

sql
-- 定时任务脚本开头尝试获取咨询锁
DO $$
BEGIN
    IF NOT pg_try_advisory_lock(hashtext('daily_report_job')) THEN
        RAISE NOTICE '另一个实例正在运行, 退出';
        RETURN;
    END IF;
    -- 执行实际任务逻辑...
    PERFORM pg_advisory_unlock(hashtext('daily_report_job'));
END;
$$;

咨询锁的注意事项

咨询锁在会话结束时自动释放, 无需担心忘记解锁导致死锁。但若使用事务级咨询锁(pg_advisory_xact_lock), 则在事务提交或回滚时自动释放。两种模式不可混用。在连接池场景下, 由于多个业务操作可能复用同一个数据库连接, 会话级咨询锁需要特别小心地管理生命周期。


10.6 死锁

10.6.1 死锁的形成

死锁(Deadlock)发生在两个或多个事务互相等待对方持有的锁, 形成一个循环等待链, 且没有任何一方能主动推进。

最简单的场景: 事务 A 持有行 1 的锁并等待行 2, 事务 B 持有行 2 的锁并等待行 1。

sql
-- 事务 A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- 锁住行 1
-- (等待事务 B 释放行 2 的锁)
UPDATE accounts SET balance = balance + 100 WHERE id = 2;

-- 事务 B (并发执行)
BEGIN;
UPDATE accounts SET balance = balance - 50  WHERE id = 2;  -- 锁住行 2
-- (等待事务 A 释放行 1 的锁)
UPDATE accounts SET balance = balance + 50  WHERE id = 1;
-- -> PostgreSQL 检测到死锁环, 回滚其中一个事务
-- ERROR: deadlock detected
-- DETAIL: Process 1234 waits for ShareLock on transaction 5678;
--         blocked by process 5678.
--         Process 5678 waits for ShareLock on transaction 1234;
--         blocked by process 1234.

PostgreSQL 每隔 deadlock_timeout(默认 1 秒)运行一次死锁检测算法, 在等待图中寻找环。一旦检测到, 选择"代价最小"的事务(通常是最后加入等待链的那个)执行回滚, 并向该事务的客户端返回 ERROR: deadlock detected。被中止的事务需要应用层捕获错误并重试。

10.6.2 预防死锁的根本原则

死锁可以避免, 核心原则是: 多个事务操作同一组资源时, 始终以相同的固定顺序申请锁。

sql
-- 错误写法(可能产生死锁): 不同事务以不同顺序锁行
-- 转账 A->B:
UPDATE accounts SET balance = balance - 100 WHERE id = 'A';  -- 先锁 A
UPDATE accounts SET balance = balance + 100 WHERE id = 'B';  -- 再锁 B

-- 转账 B->A (并发):
UPDATE accounts SET balance = balance - 50  WHERE id = 'B';  -- 先锁 B
UPDATE accounts SET balance = balance + 50  WHERE id = 'A';  -- 再锁 A
-- A->B 持有 A 等 B, B->A 持有 B 等 A -> 死锁

-- 正确写法: 始终按 id 升序锁行
-- 不论是 A->B 还是 B->A, 都先操作 id 较小的账户
-- 转账 A->B (id_A < id_B):
UPDATE accounts SET balance = balance - 100 WHERE id = 'A';  -- id_A 较小, 先锁
UPDATE accounts SET balance = balance + 100 WHERE id = 'B';

-- 转账 B->A (id_A < id_B, 仍然先操作 A):
UPDATE accounts SET balance = balance + 50  WHERE id = 'A';  -- 先锁 A
UPDATE accounts SET balance = balance - 50  WHERE id = 'B';
-- 两个并发事务都先等 A, 不会互相等待

10.6.3 排查死锁与等待链

sql
-- 查找当前所有等待锁的查询及其阻塞者
SELECT
    waiting.pid          AS waiting_pid,
    waiting.query        AS waiting_query,
    blocking.pid         AS blocking_pid,
    blocking.query       AS blocking_query,
    waiting.wait_event
FROM pg_stat_activity AS waiting
JOIN pg_stat_activity AS blocking
    ON blocking.pid = ANY(pg_blocking_pids(waiting.pid))
WHERE waiting.wait_event_type = 'Lock'
ORDER BY waiting.query_start;

10.7 并发实战: 库存超卖问题

库存超卖是并发控制最经典的工程场景, 下面通过三种正确方案全面解析。

10.7.1 错误写法: 先读后写

sql
-- Worker 1 和 Worker 2 并发执行以下逻辑

-- Step 1: 读库存
SELECT stock FROM inventories WHERE product_id = 101;
-- Worker 1 读到: stock = 1
-- Worker 2 读到: stock = 1 (两者同时读, 都看到 1)

-- Step 2: 应用层判断 stock > 0

-- Step 3: 扣减库存
UPDATE inventories SET stock = stock - 1 WHERE product_id = 101;
-- Worker 1 执行: stock 从 1 变为 0
-- Worker 2 执行: stock 从 0 变为 -1  ← 超卖!

问题根源: SELECTUPDATE 之间存在时间窗口, 两次操作不是原子的。在 Read Committed 隔离级别下, Worker 2 的 SELECT 已经完成, 看到的是提交前的旧值, 但 Worker 1 随后提交了修改, Worker 2 不知道。

10.7.2 正确方案一: 悲观锁 FOR UPDATE

sql
BEGIN;

-- FOR UPDATE 锁住库存行, Worker 2 的 SELECT 会阻塞, 直到 Worker 1 完成
SELECT stock FROM inventories WHERE product_id = 101 FOR UPDATE;

-- 此时只有 Worker 1 能执行到这里
DO $$
DECLARE v_stock INT;
BEGIN
    SELECT stock INTO v_stock FROM inventories WHERE product_id = 101 FOR UPDATE;
    IF v_stock > 0 THEN
        UPDATE inventories SET stock = stock - 1 WHERE product_id = 101;
        INSERT INTO orders (product_id, qty) VALUES (101, 1);
    ELSE
        RAISE EXCEPTION '库存不足';
    END IF;
END;
$$;

COMMIT;
-- Worker 2 解除阻塞后重新执行, 此时读到 stock = 0, 正确拒绝下单

优点: 实现简单, 逻辑清晰。缺点: 高并发下排队等锁会增加响应延迟; 若业务流程很长(如调用外部支付接口), 锁持有时间长会成为瓶颈。

10.7.3 正确方案二: 乐观锁(版本号 CAS)

sql
-- 给库存表增加版本号列
ALTER TABLE inventories ADD COLUMN version INT NOT NULL DEFAULT 0;

-- 应用层逻辑(伪代码):
-- 1. 读取当前 stock 和 version
SELECT stock, version FROM inventories WHERE product_id = 101;
-- 得到: stock=5, version=3

-- 2. 应用层判断 stock > 0

-- 3. 带版本号条件的 UPDATE (CAS: Compare-And-Swap)
UPDATE inventories
SET stock = stock - 1,
    version = version + 1
WHERE product_id = 101
  AND version = 3;   -- ← 关键: 只在版本号未变时才更新
-- 受影响行数 = 1: 说明无并发冲突, 成功
-- 受影响行数 = 0: 说明其他事务已修改(version 已变), 应用层重试

优点: 无锁等待, 高并发读多写少场景性能极好。缺点: 写冲突激烈时重试次数多; 重试逻辑需要应用层实现; 不适合写操作非常密集的场景(大量重试带来额外数据库压力)。

10.7.4 正确方案三: SKIP LOCKED 任务队列

当多个 Worker 并发从任务表中"认领"任务时, SKIP LOCKED 是最优雅的实现方式:

sql
-- 任务表结构
CREATE TABLE task_queue (
    id         BIGSERIAL PRIMARY KEY,
    payload    JSONB,
    status     TEXT NOT NULL DEFAULT 'pending',
    created_at TIMESTAMPTZ DEFAULT now()
);

-- 每个 Worker 执行以下语句认领一条任务
-- SKIP LOCKED: 跳过已被其他 Worker 锁定的行, 直接返回未被锁定的行
BEGIN;

WITH claimed AS (
    SELECT id FROM task_queue
    WHERE status = 'pending'
    ORDER BY created_at
    LIMIT 1
    FOR UPDATE SKIP LOCKED   -- ← 核心
)
UPDATE task_queue
SET status = 'processing'
WHERE id = (SELECT id FROM claimed)
RETURNING *;

-- 如果没有可用任务, 返回 0 行; Worker 休眠后重试
COMMIT;

多个 Worker 并发执行时, 每个 Worker 都会原子地"认领"到不同的任务行, 互不干扰, 也不会有任何 Worker 空跑或重复处理。这比使用 Redis 等外部队列更简单, 且事务性更强(认领失败自动回滚)。

三种方案对比选择

  • 悲观锁: 适合并发较低、单次操作不可失败的场景(如金融转账)。
  • 乐观锁: 适合读多写少、偶发冲突的场景(如商品详情编辑)。
  • SKIP LOCKED: 适合多消费者任务队列场景, 不适合需要"恰好处理一次"的强一致性场景(需要额外的幂等保证)。

10.8 本章小结

本章系统讲解了 PostgreSQL 事务与并发控制的完整知识体系:

  • ACID 四性是事务的基本保证, WAL 日志是持久性的物理实现基础。
  • 事务控制语法中, SAVEPOINT 是处理批量操作中部分失败的利器; 事务进入 aborted 状态后必须显式回滚。
  • 隔离级别决定了并发事务之间的可见性边界。PostgreSQL 的 Repeatable Read 比 SQL 标准更强, 已防住幻读; Serializable 通过 SSI 算法防止写偏斜。
  • MVCC 通过 xmin/xmax 隐藏列维护行的多个版本, 实现读写不互斥的高并发; 其代价是死元组累积, 需要 autovacuum 持续清理。
  • 锁机制涵盖行级锁(FOR UPDATE/FOR SHARE/SKIP LOCKED)、表级锁(DDL 的 AccessExclusiveLock 影响最大)和咨询锁(应用层分布式锁)。
  • 死锁由循环等待形成, PostgreSQL 自动检测并回滚代价最小的事务; 预防的根本是固定加锁顺序。
  • 并发实战中, 先读后写是超卖的根源; 悲观锁、乐观锁、SKIP LOCKED 三种方案各有适用场景。

掌握这些内容后, 你将能够设计出既正确又高效的并发访问模式, 避免生产环境中最常见的数据一致性问题。

十一、查询优化与执行计划

数据库性能调优的核心战场在查询层。无论表结构设计多精妙、索引建得多齐全, 一条写法不当的 SQL 都能让整个系统陷入瓶颈。本章从 PostgreSQL 优化器的工作原理出发, 系统拆解执行计划的阅读方法, 梳理扫描节点与连接算法的选择逻辑, 再以一条完整的慢查询优化实战收尾——希望读完之后, 你面对任何慢查询都能有章可循。

11.1 优化器心智模型

PostgreSQL 使用**基于代价(cost-based)**的查询优化器。每当你发送一条 SQL, 优化器并不会直接执行, 而是先枚举所有可行的执行计划(不同的扫描方式、连接顺序、连接算法), 为每个计划估算一个总代价, 然后选择代价最低的那个交给执行引擎。

代价的构成公式可以简化为:

text
总代价 = I/O 代价 + CPU 代价
  • I/O 代价: 读取磁盘页面的开销, 受 random_page_cost(随机读) 和 seq_page_cost(顺序读) 两个参数控制, 单位是"假想的 I/O 页"。
  • CPU 代价: 处理每行数据的 CPU 消耗, 受 cpu_tuple_costcpu_index_tuple_costcpu_operator_cost 等参数控制。

这里有一个极其关键的认知: 代价是估算, 不是测量。优化器依赖统计信息来预测行数和数据分布, 如果统计信息过期, 代价估算就会失真, 优化器就可能选出次优甚至很差的执行计划。这也是为什么我们需要定期 ANALYZE 的根本原因。

优化器的局限性

PostgreSQL 的优化器是全局最优的搜索者, 但它不是万能的。它不知道你的业务含义, 不知道某些列在运行时的真实分布, 也不会因为某个计划"看起来合理"就选它。理解这一点, 能让你在遇到糟糕执行计划时更快定位根因。

11.2 EXPLAIN 详解: 读懂执行计划

EXPLAIN 是 PostgreSQL 调优最重要的工具。它以树状结构输出执行计划, 每个节点代表一个操作步骤。

11.2.1 基础字段含义

sql
EXPLAIN SELECT * FROM orders WHERE user_id = 42;

典型输出:

text
Index Scan using orders_user_id_idx on orders  (cost=0.43..8.45 rows=1 width=128)
  Index Cond: (user_id = 42)

各字段解析:

  • cost=start..total: start 是返回第一行之前的启动代价(例如排序需要先把所有数据读完才能返回第一行, 所以启动代价高); total 是返回全部行的总代价。单位是假想 I/O 页, 方便不同操作横向比较。
  • rows: 优化器估算本节点输出的行数。
  • width: 优化器估算每行的平均宽度, 单位字节。

代价单位的含义

cost 不是毫秒, 也不是字节数。它是一个无量纲的相对值, 只用于计划之间的横向对比。你无法从 cost=8.45 推断"这条查询要 8 毫秒"——实际时间取决于硬件、缓存命中率和并发情况。

11.2.2 EXPLAIN ANALYZE: 加上实际执行数据

EXPLAIN ANALYZE真正执行查询, 并在每个节点旁边附上实际测量结果:

sql
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42;

输出增加了:

text
Index Scan using orders_user_id_idx on orders
  (cost=0.43..8.45 rows=1 width=128)
  (actual time=0.032..0.041 rows=3 loops=1)
  • actual time=start..total: 实际执行时间, 毫秒。
  • actual rows: 实际返回行数。
  • loops: 该节点被执行的次数(Nested Loop 内层节点 loops 会大于 1)。

估算 rows 与实际 rows 偏差大是最常见的性能信号。例如优化器估算 1 行但实际返回 3000 行, 意味着统计信息严重过期, 需要执行 ANALYZE orders

11.2.3 EXPLAIN (ANALYZE, BUFFERS): 加上缓冲区统计

sql
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE user_id = 42;

追加缓冲区信息:

text
Buffers: shared hit=5 read=2
  • shared hit: 从 shared_buffers(内存) 命中的页面数, 代价极低。
  • shared read: 从磁盘(或 OS 页缓存) 读取的页面数, 代价较高。
  • shared dirtied: 被修改的页面数。
  • shared written: 写出到磁盘的页面数。

命中率 = hit / (hit + read), 如果命中率低于 90%, 通常需要增大 shared_buffers 或检查是否有大量冷数据全表扫描。

11.2.4 如何阅读执行计划树

执行计划是从内到外执行的——缩进最深的节点最先执行, 结果向上传递给父节点。每个父节点的代价包含所有子节点的代价, 是累计值而非增量值。

text
Hash Join  (cost=1234.00..5678.00 rows=50000 width=64)
  Hash Cond: (o.user_id = u.id)
  ->  Seq Scan on orders o  (cost=0.00..3100.00 rows=200000 width=40)
  ->  Hash  (cost=500.00..500.00 rows=5900 width=24)
        ->  Seq Scan on users u  (cost=0.00..500.00 rows=5900 width=24)

阅读顺序: 先看最内层的两个 Seq Scan, 再看 Hash(构建哈希表), 最后看 Hash Join(探测)。Hash Join 的代价 5678.00 已经包含了两个 Seq Scan 的代价。

误区: 不要只看顶层代价

初学者容易只看根节点的 cost, 但瓶颈往往藏在某个子节点里——可能是一个意外的 Seq Scan, 也可能是一个 Sort 节点的启动代价极高。养成从叶子节点向上逐层阅读的习惯。

11.3 扫描节点类型与选择逻辑

优化器根据表大小、可用索引和过滤条件的选择率(selectivity, 即满足条件的行数比例), 在几种扫描方式中做选择。

11.3.1 Seq Scan: 顺序全表扫描

text
Seq Scan on orders  (cost=0.00..28450.00 rows=1000000 width=128)

顺序读取整张表的每一个数据页, 不使用任何索引。顺序 I/O 对磁盘友好, 吞吐量高。

优化器选择 Seq Scan 的典型场景:

  1. 表非常小(几百行以内), 走索引反而有额外开销。
  2. 过滤条件选择率高, 需要返回表中 20% 以上的行——此时随机 I/O 的索引扫描成本反而更高。
  3. 根本没有可用的索引。

遇到不期望的 Seq Scan, 先确认是否有合适索引, 再检查统计信息是否准确, 最后审视 random_page_cost 是否合理。

11.3.2 Index Scan: B-tree 索引扫描

text
Index Scan using orders_user_id_idx on orders  (cost=0.43..8.45 rows=3 width=128)
  Index Cond: (user_id = 42)

先走 B-tree 索引找到满足条件的行指针(TID, tuple identifier), 再根据 TID 回表读取堆(heap)中的实际行数据。每次回表是一次随机 I/O, 代价受 random_page_cost 影响。

适合选择率低的场景: 返回行数少, 随机 I/O 次数可控。当返回行数增多, 随机 I/O 总成本超过顺序全表扫描, 优化器就会切换到 Seq Scan。

11.3.3 Index Only Scan: 覆盖索引, 免回表

text
Index Only Scan using orders_user_created_idx on orders
  (cost=0.43..4.45 rows=3 width=16)
  Index Cond: (user_id = 42)
  Heap Fetches: 0

当查询所需的所有列都包含在索引中(覆盖索引), PostgreSQL 可以直接从索引返回数据, 完全不回表。Heap Fetches: 0 表示零次回表, 性能极优。

VACUUM 与 Index Only Scan 的关系

Index Only Scan 依赖visibility map 来判断哪些数据页上的所有行对当前事务都可见。如果 VACUUM 长期未运行, visibility map 中大量页面被标记为"需要验证", 导致 Heap Fetches 不为 0, 实际退化为 Index Scan。高写入频率的表必须保证 autovacuum 正常运行。

使用 INCLUDE 子句可以显式创建覆盖索引, 将非键列附加到索引叶子节点:

sql
-- 把 amount 列包含进索引, 支持 Index Only Scan
CREATE INDEX idx_orders_user_cover
    ON orders (user_id, created_at DESC)
    INCLUDE (amount, status);

11.3.4 Bitmap Index Scan + Bitmap Heap Scan: 中等选择率的折中方案

text
Bitmap Heap Scan on orders  (cost=125.43..3456.78 rows=8000 width=128)
  Recheck Cond: (status = 'pending')
  ->  Bitmap Index Scan on orders_status_idx
        (cost=0.00..123.43 rows=8000 width=0)
        Index Cond: (status = 'pending')

执行分两步:

  1. Bitmap Index Scan: 遍历索引, 把所有满足条件的行指针收集成一个内存位图(bitmap), 每个 bit 对应一个堆数据页是否包含目标行。
  2. Bitmap Heap Scan: 按物理页顺序批量读取位图中标记的数据页, 大幅减少随机 I/O。

这种方式的优势在于: 多个索引的位图可以用 BitmapOr / BitmapAnd 合并, 实现多列条件的联合索引扫描:

sql
-- WHERE status = 'pending' OR user_id = 42
-- 两个索引的结果 BitmapOr 合并
Bitmap Heap Scan on orders
  ->  BitmapOr
        ->  Bitmap Index Scan on orders_status_idx
        ->  Bitmap Index Scan on orders_user_id_idx

选择率阈值参考: 通常返回行数超过表总行数的 5%~20% 时, 优化器会从 Index Scan 切换到 Bitmap Scan, 超过 20%~30% 时切换到 Seq Scan。精确阈值取决于 random_page_costseq_page_cost 的比值。

11.4 连接算法: Nested Loop、Hash Join、Merge Join

当查询涉及多表连接时, 优化器需要为每对表选择连接算法。三种主要算法各有适用场景, 理解它们的代价模型是调优多表查询的关键。

11.4.1 Nested Loop: 嵌套循环

text
Nested Loop  (cost=0.43..45.87 rows=10 width=192)
  ->  Seq Scan on users u  (cost=0.00..1.05 rows=5 width=64)
  ->  Index Scan using orders_user_id_idx on orders o
        (cost=0.43..8.86 rows=2 width=128)
        Index Cond: (o.user_id = u.id)

外表(驱动表)的每一行, 都要扫描一次内表。如果内表有索引, 每次内表扫描变成一次 Index Scan, 代价极低。

  • 最坏情况: O(M × N), 两表都无索引时代价灾难性。
  • 最佳情况: 外表很小(几十行) + 内表有索引, 此时是三种算法中最快的。
  • 典型场景: 主键/外键关联, 结果集小, LIMIT 查询(只需要少量行时可以提前终止)。

Nested Loop 的另一个优点是流水线友好: 外表扫到第一行就能立即开始返回结果, 启动代价(start cost)低, 适合 OLTP 场景下需要快速返回第一条结果的查询。

11.4.2 Hash Join: 哈希连接

text
Hash Join  (cost=500.00..5678.00 rows=50000 width=192)
  Hash Cond: (o.user_id = u.id)
  ->  Seq Scan on orders o  (cost=0.00..3100.00 rows=200000 width=128)
  ->  Hash  (cost=500.00..500.00 rows=5900 width=64)
        ->  Seq Scan on users u  (cost=0.00..500.00 rows=5900 width=64)

分两阶段执行:

  1. Build: 把小表(称为内表或探测表)的数据全部读入内存, 按连接键构建哈希表。
  2. Probe: 扫描大表(外表), 每行数据通过哈希函数在哈希表中查找匹配行。
  • 代价: O(M + N), 适合大表连接且没有合适索引的场景。
  • 内存依赖: 哈希表必须能放进 work_mem。如果放不下, PostgreSQL 会把哈希表分批写到磁盘(Batches > 1), 性能显著下降。
sql
-- 观察 Hash 节点的 Batches 参数
Hash  (cost=500.00..500.00 rows=5900 width=64)
      (actual time=12.3..12.3 rows=5900 loops=1)
  Buckets: 8192  Batches: 4  Memory Usage: 4096kB

Batches: 4 表示哈希表被分成 4 批落盘, 此时应考虑适当提升 work_mem

调大 work_mem 的正确姿势

不要全局设大 work_mem, 因为一个复杂查询可能有多个 Hash/Sort 节点, 每个都会申请一份。正确做法是针对具体慢查询临时调整:

sql
SET work_mem = '256MB';
-- 执行慢查询
RESET work_mem;

11.4.3 Merge Join: 归并连接

text
Merge Join  (cost=0.86..12345.00 rows=50000 width=192)
  Merge Cond: (u.id = o.user_id)
  ->  Index Scan using users_pkey on users u
  ->  Index Scan using orders_user_id_idx on orders o

两侧数据都按连接键排好序之后, 像归并排序的合并阶段一样双指针扫描。

  • 代价: O(M + N), 与 Hash Join 相同。
  • 优势: 不需要额外内存构建哈希表; 如果两侧数据已经通过索引有序, 启动代价接近零。
  • 典型场景: 两侧连接列都有 B-tree 索引, 或者上游节点已经排序(例如 ORDER BY 与连接键相同)。
  • 劣势: 如果数据未有序, 需要先排序(Sort 节点), Sort 的启动代价可能很高。

11.4.4 优化器的选择依据

算法适用场景内存需求代价复杂度
Nested Loop外表小, 内表有索引极低O(M × N) 最坏
Hash Join大表无索引, 等值连接中等(work_mem)O(M + N)
Merge Join两侧已有序, 大数据量O(M + N)

优化器综合考虑表行数估算可用索引当前 work_mem 三个维度, 对每种算法的代价进行估算后做出选择。如果你觉得优化器的选择不合理, 可以临时关闭某种算法来对比:

sql
-- 强制禁用 Hash Join, 观察是否有更好的替代计划
SET enable_hashjoin = off;
EXPLAIN ANALYZE SELECT ...;
RESET enable_hashjoin;

不要在生产环境永久关闭算法

这些 enable_* 参数仅用于调试分析。永久关闭某种算法会让优化器在更多场景下做出次优选择。正确的解决方案是修复统计信息或调整索引, 而不是绕过优化器的判断。

11.5 统计信息: 优化器的"眼睛"

统计信息是优化器估算行数和选择率的数据基础。它准确, 优化器才能做出正确决策; 它过期, 一切代价估算都会失真。

11.5.1 pg_stats 视图

pg_stats 视图展示每一列的统计信息快照:

sql
SELECT attname, null_frac, avg_width, n_distinct,
       most_common_vals, histogram_bounds
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';

关键字段:

字段含义
null_fracNULL 值占比(0.0~1.0)
avg_width列平均字节宽度
n_distinct不重复值数量(负数表示比例, 如 -0.1 = 10% 的行有不同值)
most_common_vals最高频的值列表
most_common_freqs对应频率列表
histogram_bounds直方图桶边界, 用于估算范围查询的选择率

11.5.2 ANALYZE 与 autovacuum

手动收集统计信息:

sql
-- 收集整张表所有列
ANALYZE orders;

-- 只收集某一列
ANALYZE orders (status, created_at);

autovacuum 守护进程在后台自动触发 ANALYZE, 触发条件:

text
触发阈值 = autovacuum_analyze_threshold + autovacuum_analyze_scale_factor × reltuples
           = 50 + 0.2 × 表行数

大表(百万行以上)的 scale_factor 默认 0.2 意味着需要 20 万行变化才触发 ANALYZE——对于高频写入的大表, 这个阈值可能太高, 统计信息更新不及时。可针对具体表降低阈值:

sql
ALTER TABLE orders SET (
    autovacuum_analyze_scale_factor = 0.01,  -- 1% 变化就触发
    autovacuum_analyze_threshold = 1000
);

11.5.3 提高统计精度: statistics_target

default_statistics_target(默认 100) 控制直方图桶数量和最高频值的采样深度。数值越大, 估算越精确, 但 ANALYZE 越慢。

对关键过滤列单独调高:

sql
-- 把 user_id 列的统计目标调到 500
ALTER TABLE orders ALTER COLUMN user_id SET STATISTICS 500;
ANALYZE orders;

11.5.4 多列统计: 处理列间相关性

WHERE a = 1 AND b = 2 的两个条件列之间存在强相关性时(例如 provincecity 几乎总是一起出现), 优化器会把两列的选择率相乘, 严重低估实际返回行数, 导致选错执行计划。

PostgreSQL 10+ 支持创建扩展统计信息:

sql
-- 声明 province 与 city 之间存在函数依赖关系
CREATE STATISTICS stat_orders_region (dependencies)
    ON province, city FROM orders;

-- 也可以同时收集 ndistinct 和 mcv(most common values)
CREATE STATISTICS stat_orders_region (dependencies, ndistinct, mcv)
    ON province, city FROM orders;

ANALYZE orders;

创建后优化器会利用依赖关系更准确地估算多列组合的选择率。可以通过 pg_statistic_extpg_statistic_ext_data 查看扩展统计内容。

11.6 慢查询定位三件套

找到问题查询是优化的第一步。PostgreSQL 提供了三个层次递进的工具, 从日志级的事后感知到统计级的全局画像。

11.6.1 log_min_duration_statement: 日志记录慢查询

postgresql.conf 或会话级设置:

sql
-- postgresql.conf: 记录超过 1 秒的 SQL
log_min_duration_statement = '1000'   -- 单位毫秒, 或写 '1s'

-- 或者只对当前会话开启, 不影响全局
SET log_min_duration_statement = '100';

触发后, PostgreSQL 会在日志中记录:

text
2024-03-15 14:23:01.234 CST [12345] LOG:  duration: 2345.678 ms
    statement: SELECT * FROM orders WHERE ...

优先在开发/测试环境全量开启, 生产环境设置合理阈值(500ms~1s), 避免日志量过大。

11.6.2 auto_explain: 自动记录慢查询执行计划

仅记录 SQL 文本还不够——同一条 SQL 带不同参数可能走完全不同的计划。auto_explain 扩展可以自动记录慢查询的完整执行计划

postgresql.conf 中加载:

text
shared_preload_libraries = 'auto_explain'
auto_explain.log_min_duration = '1s'    # 超过 1 秒触发
auto_explain.log_analyze = on           # 记录实际执行数据(会真正执行)
auto_explain.log_buffers = on           # 记录缓冲区统计
auto_explain.log_format = 'json'        # 可选 text/json/yaml

auto_explain.log_analyze 的性能影响

log_analyze = on 意味着为了生成执行计划报告, 查询会被实际执行并计时。这对系统性能几乎没有额外影响(因为本来就是慢查询), 但对于写操作(INSERT/UPDATE/DELETE), 要注意查询会被真正执行——数据修改是真实的。

重载配置后无需重启:

sql
SELECT pg_reload_conf();

11.6.3 pg_stat_statements: 全局 SQL 性能画像

pg_stat_statements 扩展会持续累计所有 SQL 语句的执行统计, 是找出系统中最耗时 SQL 的最强工具。

启用:

text
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.track = all        # 追踪所有 SQL 包括嵌套

重启后在数据库中创建扩展:

sql
CREATE EXTENSION pg_stat_statements;

找出最耗时的 SQL:

sql
SELECT
    query,
    calls,
    round(total_exec_time::numeric, 2)          AS total_ms,
    round((total_exec_time / calls)::numeric, 2) AS avg_ms,
    round(rows::numeric / calls, 1)              AS avg_rows,
    round(shared_blks_hit::numeric /
          NULLIF(shared_blks_hit + shared_blks_read, 0) * 100, 1) AS cache_hit_pct
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

找出命中率最低的 SQL(频繁触发磁盘 I/O):

sql
SELECT query, calls,
       round(shared_blks_hit * 100.0 /
             NULLIF(shared_blks_hit + shared_blks_read, 0), 1) AS hit_pct
FROM pg_stat_statements
WHERE calls > 100
ORDER BY hit_pct ASC
LIMIT 10;

定期重置统计(例如每天凌晨):

sql
SELECT pg_stat_statements_reset();

11.7 常见优化反模式

以下是生产环境中最高频出现的慢查询反模式, 每条附有具体改法。

11.7.1 深分页: OFFSET 的陷阱

sql
-- 反模式: OFFSET 100000 要扫描并丢弃 10 万行
SELECT id, title, created_at FROM articles
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000;

OFFSET 越大, 需要扫描的数据越多, 性能线性下降。当翻到第 5000 页时, 数据库需要扫描 10 万行再丢弃前面 99980 条。

改法: Keyset 分页(游标分页)

sql
-- 客户端记录上一页最后一行的 (created_at, id)
-- 下一页请求时作为条件传入
SELECT id, title, created_at FROM articles
WHERE (created_at, id) < ('2024-03-10 12:00:00', 98765)
ORDER BY created_at DESC, id DESC
LIMIT 20;

配合复合索引 (created_at DESC, id DESC), 每次查询都是 Index Scan 取前 20 行, 无论翻到第几页性能都稳定。

11.7.2 大表 count(*): 近似替代方案

sql
-- 全表 count(*) 在千万行表上可能耗时数秒
SELECT count(*) FROM orders WHERE status = 'pending';

改法一: 使用 pg_class.reltuples 获取近似总行数(误差在 autovacuum 精度范围内):

sql
SELECT reltuples::bigint AS approx_count
FROM pg_class
WHERE relname = 'orders';

改法二: 对于带过滤条件的 count, 维护一个增量计数器表, 通过触发器在每次 INSERT/DELETE 时更新。

改法三: 如果必须精确, 先用 EXPLAIN 查看是否有更快的执行路径(如 Index Only Scan on 某个覆盖索引)。

11.7.3 OR 跨列: 单索引无法覆盖

sql
-- 反模式: WHERE a=1 OR b=2 无法利用单列索引
SELECT * FROM events WHERE user_id = 42 OR target_id = 42;

改法: 拆成 UNION ALL

sql
SELECT * FROM events WHERE user_id = 42
UNION ALL
SELECT * FROM events WHERE target_id = 42
  AND user_id != 42;  -- 避免重复行

两个子查询分别走 user_idtarget_id 上的索引, 然后合并结果。如果两列值域有交叉需要去重, 用 UNION 代替 UNION ALL(代价更高)。

11.7.4 列上套函数: 索引失效

sql
-- 反模式: 函数包装列, 索引无法直接使用
SELECT * FROM orders WHERE DATE_TRUNC('year', created_at) = '2024-01-01';
SELECT * FROM users WHERE LOWER(email) = 'alice@example.com';

改法一: 改写为等价范围条件

sql
-- 改写为范围条件, 直接走 created_at 上的索引
SELECT * FROM orders
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';

改法二: 创建函数索引, 在索引里存储函数结果

sql
CREATE INDEX idx_users_email_lower ON users (LOWER(email));
-- 查询时也必须用相同的函数表达式
SELECT * FROM users WHERE LOWER(email) = 'alice@example.com';

11.7.5 隐式类型转换: 悄悄失效的索引

sql
-- 反模式: phone 列是 VARCHAR, 但用数字比较
-- PostgreSQL 会对 phone 列每行做类型转换, 导致索引失效
SELECT * FROM users WHERE phone = 13812345678;

改法: 保持类型一致

sql
SELECT * FROM users WHERE phone = '13812345678';

这个问题在 ORM 框架中极为常见——框架自动把整数参数传给字符串列, 悄无声息地让索引形同虚设。

11.7.6 SELECT *: 三重隐患

sql
-- 反模式
SELECT * FROM orders WHERE user_id = 42;

三个问题: (1) 传输不必要的列数据, 增加网络和内存开销; (2) 阻碍 Index Only Scan, 即使索引覆盖了过滤列, 因为 * 需要回表取其他列; (3) 未来加列会导致行为变化, 接口不稳定。

改法: 明确指定需要的列

sql
SELECT id, user_id, amount, status FROM orders WHERE user_id = 42;

11.7.7 ORM N+1: 循环查询

python
# 反模式: 查 100 个用户, 再循环每人查订单 = 101 条 SQL
users = User.query.limit(100).all()
for user in users:
    orders = Order.query.filter_by(user_id=user.id).all()

改法: 一次 JOIN 或预加载

sql
-- 一条 SQL 完成
SELECT u.id, u.name, o.id AS order_id, o.amount
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.id = ANY(ARRAY[1,2,3,...,100]);

ORM 框架通常提供 eager loading / select_related / includes 等机制, 在框架层解决 N+1 问题。

11.8 关键参数对执行计划的影响

执行计划不仅受数据和索引影响, 还受一组 PostgreSQL 配置参数控制。以下是最常见的几个参数及其调优建议。

参数默认值作用调优建议
shared_buffers128MB共享内存缓存数据页设为系统内存的 25%~40%
work_mem4MB每次排序/哈希操作的内存上限根据并发量适当调大, 慎防 OOM
effective_cache_size4GB告知优化器 OS 页缓存有多大设为 OS 可用内存的 50%~75%
random_page_cost4.0随机 I/O 的相对代价SSD 调低至 1.1~1.5
seq_page_cost1.0顺序 I/O 的相对代价通常保持默认

random_page_cost 是调优 SSD 环境最重要的参数。默认值 4.0 是为机械硬盘设计的(随机寻道比顺序读慢约 4 倍)。SSD 的随机 I/O 与顺序 I/O 性能差距远小于此, 将 random_page_cost 调低到 1.1~1.5 后, 优化器会更倾向选择 Index Scan 而非 Seq Scan, 通常能明显改善 OLTP 查询性能。

sql
-- 在 postgresql.conf 中
random_page_cost = 1.2
effective_cache_size = '24GB'   -- 假设服务器内存 32GB
shared_buffers = '8GB'

effective_cache_size 不分配实际内存, 它只是一个"提示值", 告诉优化器 OS 层面大概有多少缓存可用。值越大, 优化器越倾向 Index Scan(因为预期缓存命中率高, 随机读代价低)。设置偏低会导致优化器过度保守地选择 Seq Scan。

11.9 VACUUM 与 autovacuum: 不能忽视的后台维护

VACUUM 是 PostgreSQL 独特的维护机制, 直接影响查询性能和数据库健康状态。

11.9.1 为什么需要 VACUUM

PostgreSQL 使用多版本并发控制(MVCC): UPDATE 不修改原行, 而是写入新版本并标记旧版本为"死元组"(dead tuple); DELETE 同样只标记行为删除, 并不立即物理移除。

死元组带来三个问题:

  1. 空间膨胀: 死元组占用表和索引空间, 表会越来越大, 扫描成本上升。
  2. 查询性能下降: 顺序扫描时仍需跳过死元组, Bitmap Heap Scan 的 Recheck 代价增加。
  3. Transaction ID Wraparound: PostgreSQL 用 32-bit 事务 ID 标记行版本。如果最旧的未清理事务 ID 距当前太远(超过约 20 亿), 会触发数据库强制进入只读模式保护数据完整性。这是最严重的 VACUUM 失效后果。

11.9.2 VACUUM 的三种形式

sql
-- 普通 VACUUM: 标记死元组空间为"可重用", 不释放给 OS
VACUUM orders;

-- VACUUM ANALYZE: 同时收集统计信息(最常用)
VACUUM ANALYZE orders;

-- VACUUM FULL: 重写整张表, 真正将空间还给 OS
-- 需要 AccessExclusiveLock, 期间表完全锁定, 生产环境慎用!
VACUUM FULL orders;

VACUUM FULL 的风险

VACUUM FULL 在执行期间会对表加排他锁, 所有读写操作都被阻塞。对于大表(GB 级), 执行时间可能长达数分钟甚至数小时。生产环境推荐使用 pg_repack 扩展替代——它以在线方式重建表, 不会长时间阻塞业务。

11.9.3 autovacuum 关键参数

autovacuum 是后台守护进程, 自动触发 VACUUM 和 ANALYZE。

text
-- 触发 VACUUM 的阈值:
autovacuum_vacuum_threshold = 50           -- 至少 50 行死元组才触发
autovacuum_vacuum_scale_factor = 0.2       -- 死元组超过表行数的 20% 触发

-- 触发 ANALYZE 的阈值:
autovacuum_analyze_threshold = 50
autovacuum_analyze_scale_factor = 0.2

-- autovacuum 的执行速度限制(防止影响业务 I/O):
autovacuum_vacuum_cost_delay = 2ms         -- 每完成一批操作后暂停 2ms
autovacuum_vacuum_cost_limit = 200         -- 每批允许的最大代价单位

对于高频写入的大表, scale_factor = 0.2 意味着 1000 万行表需要积累 200 万死元组才触发——这通常太迟。针对关键大表单独调整:

sql
ALTER TABLE orders SET (
    autovacuum_vacuum_scale_factor = 0.01,
    autovacuum_vacuum_cost_delay = 1,       -- 加快 autovacuum 执行速度
    autovacuum_vacuum_cost_limit = 400
);

11.9.4 监控膨胀与 VACUUM 状态

sql
-- 查看各表的死元组数量和最后 VACUUM 时间
SELECT
    schemaname,
    relname,
    n_live_tup,
    n_dead_tup,
    round(n_dead_tup * 100.0 / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_pct,
    last_vacuum,
    last_autovacuum,
    last_analyze,
    last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;

dead_pct 超过 10%~20% 通常需要手动 VACUUM ANALYZE; 超过 50% 意味着 autovacuum 未能及时跟上写入速度, 需要调整参数或检查 autovacuum 是否被长事务阻塞。

长事务是 autovacuum 的大敌

autovacuum 无法清理比当前最旧活跃事务还新的死元组——因为那些死元组对该长事务还是"活的"。一个跑了几小时的长事务会让 autovacuum 完全失效, 导致表快速膨胀。定期检查 pg_stat_activity 中的长事务, 并设置合理的 statement_timeoutidle_in_transaction_session_timeout

11.10 完整实战: 一步步优化一条慢查询

11.10.1 场景描述

orders 表有 1000 万行数据, 字段包括 iduser_idstatus(varchar, 枚举值: pending/processing/completed/cancelled)、amount(numeric)、created_at(timestamptz)。

业务需求: 查询过去 30 天内未完成的订单, 按金额降序取前 20 条

初始查询写法:

sql
SELECT id, user_id, amount, status, created_at
FROM orders
WHERE status != 'completed'
  AND created_at >= NOW() - INTERVAL '30 days'
ORDER BY amount DESC
LIMIT 20;

11.10.2 第一步: EXPLAIN ANALYZE 看现状

sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, user_id, amount, status, created_at
FROM orders
WHERE status != 'completed'
  AND created_at >= NOW() - INTERVAL '30 days'
ORDER BY amount DESC
LIMIT 20;

输出(简化):

text
Limit  (cost=285432.10..285432.15 rows=20 width=58)
       (actual time=4823.421..4823.427 rows=20 loops=1)
  ->  Sort  (cost=285432.10..287457.10 rows=810000 width=58)
            (actual time=4823.419..4823.423 rows=20 loops=1)
        Sort Key: amount DESC
        Sort Method: external merge  Disk: 68432kB
        ->  Seq Scan on orders  (cost=0.00..218334.00 rows=810000 width=58)
                                (actual time=0.042..3102.331 rows=823456 loops=1)
              Filter: ((status <> 'completed') AND
                       (created_at >= (now() - '30 days'::interval)))
              Rows Removed by Filter: 9176544
  Buffers: shared hit=2341 read=65993, temp read=8554 written=8554
Planning Time: 1.234 ms
Execution Time: 4824.891 ms

问题分析:

  • Seq Scan 扫描了全部 1000 万行, 过滤掉 917 万行, 只留下约 82 万行。
  • Sort 节点使用了磁盘外部归并(external merge Disk: 68MB), 因为 work_mem 不够大。
  • 总执行时间 4.8 秒, 完全不可接受。

11.10.3 第二步: 创建部分覆盖索引

分析查询特征:

  • status != 'completed'固定过滤条件, 约 80% 的行都是 completed, 所以"未完成"的行只占约 20%——适合部分索引。
  • created_at 是范围过滤, 需要包含在索引中。
  • ORDER BY amount DESC 决定排序方向。
  • 最终只返回 id, user_id, amount, status, created_at, 可以通过 INCLUDE 实现 Index Only Scan。
sql
CREATE INDEX CONCURRENTLY idx_orders_active_recent
    ON orders (created_at DESC, amount DESC)
    INCLUDE (id, user_id, status)
    WHERE status != 'completed';
  • CONCURRENTLY 在线构建, 不阻塞业务写入(构建时间更长, 但生产安全)。
  • 部分索引 WHERE status != 'completed' 使索引只包含约 20% 的行, 体积小, 扫描快。
  • (created_at DESC, amount DESC) 对应查询的过滤和排序顺序, 可能实现 Index Scan 直接返回有序结果。
  • INCLUDE (id, user_id, status) 覆盖查询所有需要的列, 支持 Index Only Scan。

11.10.4 第三步: 再次 EXPLAIN ANALYZE 验证

sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, user_id, amount, status, created_at
FROM orders
WHERE status != 'completed'
  AND created_at >= NOW() - INTERVAL '30 days'
ORDER BY amount DESC
LIMIT 20;

优化后输出:

text
Limit  (cost=0.56..68.42 rows=20 width=58)
       (actual time=0.312..0.487 rows=20 loops=1)
  ->  Index Only Scan Backward using idx_orders_active_recent on orders
        (cost=0.56..275234.00 rows=81000 width=58)
        (actual time=0.310..0.481 rows=20 loops=1)
        Index Cond: (created_at >= (now() - '30 days'::interval))
        Heap Fetches: 0
  Buffers: shared hit=8
Planning Time: 0.891 ms
Execution Time: 0.523 ms

结果对比:

指标优化前优化后提升
扫描方式Seq Scan 全表Index Only Scan-
实际扫描行数1000 万20500,000x
磁盘 I/O 页68,33488,542x
执行时间4824 ms0.5 ms9,648x

4.8 秒 降到 0.5 毫秒, 性能提升近万倍。Heap Fetches: 0 确认了 Index Only Scan 零回表, shared hit=8 说明只读取了 8 个内存页。

为什么不需要显式 Sort 节点

索引定义了 (created_at DESC, amount DESC), 查询条件 created_at >= ... 过滤后, 满足条件的行在索引中已经按 amount DESC 有序——但这里需要注意, 实际上索引首先按 created_at 排序, 因此对于范围内的数据并不天然按 amount 排序。优化器此处选择 Index Only Scan + 内存 Top-N 堆排序取前 20 行, 比全量排序快得多。如果想完全消除 Sort, 可以只保留 (amount DESC, created_at DESC) 并让 created_at 做过滤——但这样索引扫描就无法利用 created_at 的范围条件快速跳过数据, 需要权衡。实际生产中应根据业务数据分布用 EXPLAIN ANALYZE 验证最终方案。

11.11 慢查询排查流程图

本章小结

本章系统梳理了 PostgreSQL 查询优化的核心知识链路:

  • 优化器基于代价估算选择执行计划, 代价估算依赖统计信息的准确性。
  • EXPLAIN / EXPLAIN ANALYZE / EXPLAIN (ANALYZE, BUFFERS) 三个层次提供从静态代价到实际缓冲区的完整诊断视图, 从内到外阅读执行计划树。
  • 扫描节点在 Seq Scan、Index Scan、Index Only Scan、Bitmap Scan 之间的选择由选择率和 random_page_cost 决定; 连接算法在 Nested Loop、Hash Join、Merge Join 之间的选择由表大小、索引和 work_mem 决定。
  • 统计信息是优化器的眼睛, pg_statsANALYZEstatistics_target、多列扩展统计四个工具共同保障估算精度。
  • 慢查询三件套: log_min_duration_statement 发现、auto_explain 定位计划、pg_stat_statements 全局画像。
  • 七大反模式: 深分页、大表 count、OR 跨列、列套函数、隐式类型转换、SELECT *、ORM N+1, 每条都有可落地的改法。
  • VACUUM / autovacuum 不只是空间回收工具, 更是维护统计信息准确性、防止 Transaction ID Wraparound 的基础保障。
  • 实战案例展示了从 4.8 秒到 0.5 毫秒的完整优化路径: EXPLAIN 诊断 → 部分覆盖索引 → 验证。

下一章将进入 PostgreSQL 的高可用与复制架构, 探讨流复制、逻辑复制与故障切换的实现原理。

十二、视图、物化视图与分区表

数据库性能与可维护性的关键, 很多时候不在于某条 SQL 写得多精妙, 而在于架构层面的组织方式。视图让复杂查询对外只呈现为一张"虚拟表", 物化视图把昂贵计算的结果缓存到磁盘, 声明式分区则将百亿行的超大表拆分为可独立管理的子表。三者各有适用场景, 熟练运用可以大幅降低应用代码的复杂度并提升查询效率。


12.1 普通视图

12.1.1 视图的本质与创建

视图(View)在 PostgreSQL 内部本质上是一条存储在系统目录中的 SELECT 语句。每次对视图执行查询时, 数据库都会将该 SELECT 展开并与外层查询合并优化, 然后再执行, 没有任何数据被物理存储。

sql
-- 基本创建语法
CREATE VIEW view_name AS
SELECT ...;

-- 可替换已有视图(等价于先 DROP 再 CREATE, 但保留依赖权限)
CREATE OR REPLACE VIEW view_name AS
SELECT ...;

-- 删除视图
DROP VIEW view_name;
DROP VIEW IF EXISTS view_name CASCADE;  -- CASCADE 同时删除依赖该视图的对象

视图与查询重写

PostgreSQL 的查询规划器会把对视图的引用"内联"到查询树中。例如 SELECT * FROM v_orders WHERE status = 'paid' 实际上执行的是视图定义中的 SELECT 加上 WHERE status = 'paid' 的合并结果, 优化器可以统一处理索引选择与过滤下推。

12.1.2 视图的两大用途

用途一: 封装复杂查询, 简化应用代码

多表关联、窗口函数、复杂过滤条件等逻辑, 若散落在应用代码各处, 维护成本极高。将其封装在视图中, 应用只需 SELECT * FROM v_order_summary WHERE ..., 业务逻辑的变更只改视图定义即可。

sql
-- 将多表关联与聚合封装为视图
CREATE OR REPLACE VIEW v_order_summary AS
SELECT
    o.id          AS order_id,
    u.name        AS customer_name,
    o.created_at,
    SUM(oi.quantity * oi.unit_price) AS total_amount,
    COUNT(oi.id)  AS item_count,
    o.status
FROM orders o
JOIN users  u  ON u.id  = o.user_id
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id, u.name, o.created_at, o.status;

应用只需 SELECT * FROM v_order_summary WHERE status = 'paid', 联结与聚合对上层完全透明。

用途二: 权限隔离, 只暴露部分列

生产数据库中往往有敏感字段, 例如用户表包含 password_hashphoneid_card。通过视图可以仅向特定角色暴露安全列:

sql
-- 创建只包含非敏感列的视图
CREATE VIEW v_users_public AS
SELECT id, username, email, created_at, is_active
FROM users;

-- 创建只读角色并授权
CREATE ROLE app_readonly;
GRANT SELECT ON v_users_public TO app_readonly;
-- 不授予对基表 users 的任何权限
REVOKE ALL ON users FROM app_readonly;

该角色只能看到 v_users_public 中定义的列, 原始敏感字段完全屏蔽。

12.1.3 可更新视图

并非所有视图都只能读, 满足以下条件的视图可以直接接受 INSERTUPDATEDELETE:

  • 视图来自单个基表(无 JOIN、无子查询引用其他表)
  • DISTINCTGROUP BYHAVINGUNIONLIMITOFFSET
  • SELECT 列表无聚合函数、窗口函数、集合运算
  • 基表有主键或唯一非空列(确保行可定位)
sql
-- 满足条件的简单视图, 可更新
CREATE VIEW v_active_users AS
SELECT id, username, email, is_active
FROM users
WHERE is_active = true;

-- 以下操作直接作用于基表 users
INSERT INTO v_active_users (username, email, is_active)
VALUES ('alice', 'alice@example.com', true);

UPDATE v_active_users SET email = 'newalice@example.com'
WHERE username = 'alice';

DELETE FROM v_active_users WHERE username = 'alice';

注意 UPDATE 的隐患

上面的视图定义了 WHERE is_active = true, 但若直接 UPDATE v_active_users SET is_active = false WHERE ..., 该行将从视图中"消失", 却仍存在于基表。若业务上不允许这种情况, 需要使用 WITH CHECK OPTION

12.1.4 WITH CHECK OPTION

WITH CHECK OPTION 强制要求通过视图执行的 INSERTUPDATE 操作, 操作后的行必须仍然满足视图的 WHERE 条件。若违反则抛出错误, 而不是让数据悄悄从视图中"漏出去"。

sql
CREATE OR REPLACE VIEW v_active_users AS
SELECT id, username, email, is_active
FROM users
WHERE is_active = true
WITH CHECK OPTION;

-- 正常插入(is_active = true 满足条件)
INSERT INTO v_active_users (username, email, is_active)
VALUES ('bob', 'bob@example.com', true);  -- 成功

-- 以下插入会被拒绝
INSERT INTO v_active_users (username, email, is_active)
VALUES ('carol', 'carol@example.com', false);
-- ERROR: new row violates check option for view "v_active_users"

-- 尝试将 is_active 改为 false 同样被拒绝
UPDATE v_active_users SET is_active = false WHERE username = 'bob';
-- ERROR: new row violates check option for view "v_active_users"

对于嵌套视图, WITH CASCADED CHECK OPTION(默认)会检查所有层级的条件, WITH LOCAL CHECK OPTION 只检查当前视图的条件。

12.1.5 安全视图与 security_barrier

进阶: 函数注入风险

假设视图定义为 SELECT * FROM users WHERE is_active = true AND check_user(id), 若 check_user 是用户自定义函数, 恶意用户可能通过函数参数推断出被 is_active = false 过滤掉的行数据。PostgreSQL 提供 security_barrier 选项, 强制先执行视图过滤条件再传递给函数, 从而防止此类侧信道攻击。

sql
-- 开启 security_barrier 的视图
CREATE VIEW v_secure_users WITH (security_barrier = true) AS
SELECT id, username, email
FROM users
WHERE is_active = true;

security_barrier 的代价是优化器无法把外层查询条件下推到视图内部, 可能导致轻微性能损失, 仅在视图面向不可信用户或用到用户自定义函数时才有必要启用。


12.2 物化视图

12.2.1 与普通视图的本质区别

普通视图每次查询都重新执行底层 SQL, 计算结果不保存。当底层涉及大量聚合、多表联结或窗口运算时, 每次查询都需要完整扫描数据, 代价极高。

物化视图(Materialized View)把查询结果物理存储在磁盘上, 形成一张真实的表快照。读取时直接扫描这张快照, 完全不重新计算, 速度极快。代价是数据存在时效性问题: 基表数据变更后, 物化视图不会自动更新, 需要手动或定时刷新。

特性普通视图物化视图
数据存储不存储, 每次计算物理存储快照
读取性能取决于底层查询等同于读普通表
数据新鲜度始终最新刷新后才更新
支持索引不可建索引可建任意索引
占用磁盘不占用占用, 与数据量正相关

12.2.2 创建与刷新

sql
-- 创建物化视图并立即填充数据
CREATE MATERIALIZED VIEW mv_order_stats AS
SELECT
    date_trunc('day', created_at) AS stat_date,
    status,
    COUNT(*)                      AS order_count,
    SUM(total_amount)             AS revenue
FROM orders
GROUP BY 1, 2
ORDER BY 1;

-- 仅创建结构, 不填充数据(WITH NO DATA)
-- 首次 REFRESH 前查询会报错
CREATE MATERIALIZED VIEW mv_order_stats
WITH NO DATA
AS SELECT ...;
sql
-- 全量刷新(会持有 ExclusiveLock, 刷新期间阻塞读写)
REFRESH MATERIALIZED VIEW mv_order_stats;

-- 非阻塞刷新(Concurrent Refresh)
-- 先将新结果写入临时结构, 再与旧数据差量替换, 全程允许并发读
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_order_stats;

CONCURRENTLY 的前提条件

REFRESH MATERIALIZED VIEW CONCURRENTLY 要求物化视图上至少存在一个唯一索引, 且该索引不含 NULL 列。满足条件后才能启用, 否则 PostgreSQL 会报错。

sql
-- 为物化视图创建唯一索引(以支持 CONCURRENTLY 刷新)
CREATE UNIQUE INDEX ON mv_order_stats (stat_date, status);

-- 此后可以使用非阻塞刷新
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_order_stats;

物化视图上还可以建普通索引、表达式索引等, 与普通表无异:

sql
-- 加速按日期范围过滤
CREATE INDEX ON mv_order_stats (stat_date);

12.2.3 适用场景与刷新策略

物化视图最适合以下场景:

  • 报表与统计仪表盘: 聚合计算代价高, 但报表数据不需要精确到秒级实时
  • 复杂 JOIN 预计算: 多张大表关联的结果, 业务读多写少
  • 数据仓库近实时同步: 定期同步 OLTP 结果供分析使用

刷新策略一: 定时刷新

借助 pg_cron 扩展或外部调度系统(cron、Kubernetes CronJob 等)定期触发:

sql
-- 使用 pg_cron 每天凌晨 2 点刷新
SELECT cron.schedule(
    'refresh_mv_order_stats',
    '0 2 * * *',
    'REFRESH MATERIALIZED VIEW CONCURRENTLY mv_order_stats'
);

刷新策略二: 事件触发刷新

在关键写入事务提交后, 由应用层发出刷新指令。适合数据变更频次固定且可预测的场景:

sql
-- 应用写入完成后手动触发(伪代码逻辑)
-- BEGIN;
--   INSERT INTO orders ...;
-- COMMIT;
-- 随后调用: REFRESH MATERIALIZED VIEW CONCURRENTLY mv_order_stats;

12.2.4 增量刷新的思路

PostgreSQL 原生不支持增量物化视图(即只把基表新增/修改的部分合并入物化视图)。全量刷新在基表数据量很大时可能耗时较长。有以下几种应对思路:

思路一: 触发器 + 差量表手工实现

在基表上建触发器, 将每次变更记录到一张"增量表"。刷新时只处理增量表中的记录并合并进物化视图对应的普通表, 最后清空增量表。这种方式实现复杂、维护成本高, 但对特定聚合场景可以做到近实时刷新。

思路二: 借助 TimescaleDB 等插件

timescaledb 的 Continuous Aggregates(连续聚合)功能支持基于时序数据的增量物化刷新, 是处理时序报表的推荐方案。若项目已引入该插件, 可优先评估此方案。

权衡提示

在大多数业务场景中, 每小时或每天的全量刷新 + CONCURRENTLY 已经足够。过度追求增量刷新会引入大量额外复杂度, 需要仔细评估投入产出比。


12.3 声明式分区

12.3.1 为什么需要分区

随着业务增长, 订单表、日志表、行为事件表等很容易积累到亿级乃至百亿行。对如此规模的单表执行查询, 即便有索引, 全表扫描和 VACUUM 的代价也会变得难以接受。PostgreSQL 的**声明式分区(Declarative Partitioning)**将一张逻辑大表拆分为若干物理子表, 带来以下收益:

  • 分区裁剪(Partition Pruning): 查询时优化器根据 WHERE 条件只扫描相关子表, 其余子表完全跳过
  • 历史数据快速归档: 直接 DETACHDROP 整个子表, 无需逐行删除, 避免大量写入放大
  • 并行 VACUUM: 各子表可以独立 VACUUM, 不互相阻塞
  • 局部索引与统计: 每个子表的统计信息独立维护, 优化器估算更准确

声明式分区从 PostgreSQL 10 引入, PostgreSQL 11 起在主表上创建索引会自动传播到所有子表, PostgreSQL 12/13 持续优化裁剪性能, 是目前推荐的分区方式。

12.3.2 RANGE 分区 — 按范围拆分

RANGE 分区是最常见的分区方式, 通常按时间(年、月、日)或数字区间划分。

sql
-- 创建分区主表, 指定分区键为 created_at
CREATE TABLE orders (
    id           BIGSERIAL,
    user_id      BIGINT          NOT NULL,
    status       VARCHAR(20)     NOT NULL DEFAULT 'pending',
    total_amount NUMERIC(12, 2)  NOT NULL DEFAULT 0,
    created_at   TIMESTAMPTZ     NOT NULL DEFAULT NOW(),
    PRIMARY KEY (id, created_at)   -- 分区键必须包含在主键中
) PARTITION BY RANGE (created_at);

-- 创建 2024 年 1 月分区
CREATE TABLE orders_2024_01 PARTITION OF orders
    FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');

-- 创建 2024 年 2 月分区
CREATE TABLE orders_2024_02 PARTITION OF orders
    FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');

-- 创建 2024 年 3 月分区
CREATE TABLE orders_2024_03 PARTITION OF orders
    FOR VALUES FROM ('2024-03-01') TO ('2024-04-01');

-- 创建默认分区(捕获不属于任何已定义范围的行)
CREATE TABLE orders_default PARTITION OF orders DEFAULT;

范围区间的边界规则

FOR VALUES FROM (lower) TO (upper)左闭右开区间, 即 lower <= created_at < upper。2024-02-01 00:00:00 的数据落入 orders_2024_02, 而不是 orders_2024_01

插入路由: PostgreSQL 根据 created_at 的值自动将 INSERT 路由到对应子表。若没有匹配分区且没有默认分区, 会抛出错误:

sql
-- 自动路由到 orders_2024_01
INSERT INTO orders (user_id, status, total_amount, created_at)
VALUES (1001, 'paid', 299.00, '2024-01-15 10:30:00+08');

-- 查询时与普通表无异, 分区对应用透明
SELECT * FROM orders WHERE user_id = 1001 AND created_at >= '2024-01-01';

以下是数据路由的示意图:

12.3.3 LIST 分区 — 按枚举值拆分

LIST 分区适用于分区键取值为有限枚举集合的场景, 例如地区、业务线、状态类别等。

sql
-- 按地区分区的客户表
CREATE TABLE customers (
    id      BIGSERIAL,
    name    VARCHAR(100) NOT NULL,
    email   VARCHAR(200) NOT NULL,
    region  VARCHAR(20)  NOT NULL,
    PRIMARY KEY (id, region)      -- region 必须出现在主键中
) PARTITION BY LIST (region);

-- 亚太地区分区
CREATE TABLE customers_apac PARTITION OF customers
    FOR VALUES IN ('CN', 'JP', 'KR', 'SG', 'AU');

-- 欧洲中东非洲分区
CREATE TABLE customers_emea PARTITION OF customers
    FOR VALUES IN ('DE', 'FR', 'GB', 'AE', 'ZA');

-- 美洲分区
CREATE TABLE customers_amer PARTITION OF customers
    FOR VALUES IN ('US', 'CA', 'BR', 'MX');

-- 默认分区捕获其他地区
CREATE TABLE customers_other PARTITION OF customers DEFAULT;

LIST 分区的查询裁剪与 RANGE 类似: WHERE region = 'CN' 只会扫描 customers_apac

12.3.4 HASH 分区 — 按哈希取模拆分

HASH 分区适用于写入量均匀分布且不需要范围裁剪的场景。PostgreSQL 对分区键计算哈希值并取模, 将数据均匀分散到各子表, 避免热点。

sql
-- 按 user_id 哈希分为 4 个分区
CREATE TABLE user_events (
    id          BIGSERIAL,
    user_id     BIGINT       NOT NULL,
    event_type  VARCHAR(50)  NOT NULL,
    payload     JSONB,
    occurred_at TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    PRIMARY KEY (id, user_id)
) PARTITION BY HASH (user_id);

-- MODULUS 为总分区数, REMAINDER 为当前分区编号(从 0 开始)
CREATE TABLE user_events_p0 PARTITION OF user_events
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);

CREATE TABLE user_events_p1 PARTITION OF user_events
    FOR VALUES WITH (MODULUS 4, REMAINDER 1);

CREATE TABLE user_events_p2 PARTITION OF user_events
    FOR VALUES WITH (MODULUS 4, REMAINDER 2);

CREATE TABLE user_events_p3 PARTITION OF user_events
    FOR VALUES WITH (MODULUS 4, REMAINDER 3);

HASH 分区的局限

HASH 分区无法进行范围裁剪。WHERE user_id BETWEEN 1000 AND 2000 必须扫描所有分区。HASH 分区主要用于均衡 I/O 和减少锁竞争, 而非加速范围查询。扩容时需要重新分配数据(MODULUS 变更), 改造成本较高, 选用前需评估未来扩展需求。


12.3.5 分区裁剪(Partition Pruning)

分区裁剪是分区性能提升的核心机制。当 WHERE 子句包含分区键的过滤条件时, 优化器会在执行计划阶段(静态裁剪)或运行时阶段(动态裁剪)排除不相关的子表。

sql
-- 查看裁剪效果: 只扫 2024-03 这一个分区
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders
WHERE created_at >= '2024-03-01' AND created_at < '2024-04-01';
text
Append  (cost=0.00..18.50 rows=1 width=56)
  ->  Seq Scan on orders_2024_03  (cost=0.00..18.50 rows=1 width=56)
        Filter: (created_at >= '2024-03-01' AND created_at < '2024-04-01')
Partitions removed: 3 (表示排除了其余 3 个分区)

若没有裁剪效果, 执行计划会显示对全部子表的 Append 扫描。确认裁剪是否生效有以下排查步骤:

  • 检查参数 enable_partition_pruning 是否为 on(默认开启)
  • 确认 WHERE 条件使用了分区键列, 且没有对分区键施加函数(如 date_trunc('month', created_at) 可能导致裁剪失效)
  • 确认比较类型与分区键类型一致, 避免隐式类型转换阻止裁剪
sql
-- 确认参数开启状态
SHOW enable_partition_pruning;
-- on

-- 反例: 对分区键使用函数导致裁剪失效
-- 以下写法无法裁剪, 会扫描所有分区
SELECT * FROM orders WHERE date_trunc('month', created_at) = '2024-03-01';

-- 正确写法(等价条件, 可以裁剪)
SELECT * FROM orders
WHERE created_at >= '2024-03-01' AND created_at < '2024-04-01';

12.3.6 分区维护

分区表的维护贯穿整个数据生命周期, 主要包含以下操作:

滚动创建新分区

未来的分区需要提前创建, 否则写入时若找不到匹配分区会报错(没有默认分区的情况下)。建议借助 pg_cron 或外部调度自动创建:

sql
-- 使用 pg_cron 在每月 25 号自动创建下月分区
SELECT cron.schedule(
    'create_next_month_partition',
    '0 0 25 * *',
    $$
    DO $$
    DECLARE
        next_month DATE := date_trunc('month', NOW() + INTERVAL '1 month');
        table_name TEXT := 'orders_' || to_char(next_month, 'YYYY_MM');
    BEGIN
        EXECUTE format(
            'CREATE TABLE IF NOT EXISTS %I PARTITION OF orders
             FOR VALUES FROM (%L) TO (%L)',
            table_name,
            next_month,
            next_month + INTERVAL '1 month'
        );
    END;
    $$ LANGUAGE plpgsql;
    $$
);

归档与卸载旧分区

历史数据不再需要在线查询时, 可以将旧分区从主表中分离, 单独备份后删除:

sql
-- 第一步: 将旧分区从主表分离(DETACH 后该表变为普通表, 主表查询不再访问它)
ALTER TABLE orders DETACH PARTITION orders_2023_01;

-- 第二步: 可选 — 单独备份该分区表
-- pg_dump -t orders_2023_01 mydb > orders_2023_01_backup.sql

-- 第三步: 删除旧分区表(释放空间)
DROP TABLE orders_2023_01;

与逐行 DELETE + VACUUM 相比, DETACH + DROP 近乎瞬间完成, 对在线业务无影响。

附加已有表为分区(含 VALIDATE CONSTRAINT 步骤)

迁移场景中常见将现有普通表改造为某个分区主表的子分区。正确流程分三步, 核心在于先添加 CHECK 约束并完成验证, 再执行 ATTACH:

sql
-- 第一步: 为已有普通表添加 CHECK 约束
-- NOT VALID 表示暂不扫描全表验证, 仅对新写入行执行校验(大表时避免长时间锁)
ALTER TABLE existing_orders_2022
    ADD CONSTRAINT chk_range_2022
    CHECK (created_at >= '2022-01-01' AND created_at < '2023-01-01')
    NOT VALID;

-- 第二步: 在后台完成全表验证(VALIDATE 只需 ShareUpdateExclusiveLock, 不阻塞读写)
ALTER TABLE existing_orders_2022
    VALIDATE CONSTRAINT chk_range_2022;

-- 第三步: 附加为分区主表的子分区
-- 因为 CHECK 约束已验证通过, ATTACH 只需短暂的 ShareLock, 几乎不影响线上业务
ALTER TABLE orders
    ATTACH PARTITION existing_orders_2022
    FOR VALUES FROM ('2022-01-01') TO ('2023-01-01');

为什么要先 VALIDATE CONSTRAINT?

若跳过第二步直接 ATTACH, PostgreSQL 需要全扫 existing_orders_2022 来验证所有行都满足分区约束, 期间持有 AccessExclusiveLock 阻塞所有并发读写。对于千万行级别的历史表, 这个窗口可能长达数分钟。先用 NOT VALID + VALIDATE 两步拆分锁粒度, 是零停机迁移的标准姿势。

DEFAULT 分区的用途与风险

sql
-- 默认分区捕获所有不匹配任何已定义范围或枚举值的行
CREATE TABLE orders_default PARTITION OF orders DEFAULT;

-- 查询默认分区中数据分布(便于决定是否需要新建明确分区)
SELECT
    date_trunc('month', created_at) AS month,
    count(*)
FROM orders_default
GROUP BY 1
ORDER BY 1;

-- 将默认分区中 2025 年数据迁移到新建分区
-- 步骤 1: 先将数据复制到新表
CREATE TABLE orders_2025 AS
    SELECT * FROM orders_default
    WHERE created_at >= '2025-01-01' AND created_at < '2026-01-01';

-- 步骤 2: 从默认分区删除已迁移数据
DELETE FROM orders_default
    WHERE created_at >= '2025-01-01' AND created_at < '2026-01-01';

-- 步骤 3: 将新表附加为明确分区(流程同上: 先 ADD CONSTRAINT NOT VALID → VALIDATE → ATTACH)
ALTER TABLE orders_2025
    ADD CONSTRAINT chk_2025
    CHECK (created_at >= '2025-01-01' AND created_at < '2026-01-01')
    NOT VALID;
ALTER TABLE orders_2025 VALIDATE CONSTRAINT chk_2025;
ALTER TABLE orders ATTACH PARTITION orders_2025
    FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');

DEFAULT 分区的两个核心风险

  1. 新增明确分区必须先清空默认分区中的冲突行: 若默认分区中已有 2025 年数据, 直接 CREATE TABLE orders_2025 PARTITION OF orders FOR VALUES FROM ('2025-01-01') TO ('2026-01-01') 会报错。必须先将这些行迁走。
  2. 查询默认分区时无法裁剪: WHERE created_at = '2025-06-01' 对有明确分区的月份可以精准裁剪; 但对落入默认分区的数据, 优化器必须扫描整个默认分区表, 无法进一步定位。默认分区数据量若持续增长, 查询性能会随之下降。建议定期检查默认分区并将积累的数据归入明确分区。

12.3.7 分区上的索引

PostgreSQL 11 及以上版本支持在分区主表上创建索引, 系统会自动在所有现有子表及未来通过 PARTITION OF 创建的子表上同步建立相同索引:

sql
-- 在主表上建索引, 自动传播到所有子表
CREATE INDEX idx_orders_user_id ON orders (user_id);
CREATE INDEX idx_orders_status  ON orders (status);

-- 验证: 子表上已自动建立相应索引
SELECT indexname, tablename
FROM pg_indexes
WHERE tablename LIKE 'orders_%'
ORDER BY tablename, indexname;

分区索引与主键的注意事项

分区键列必须包含在主键和唯一约束的列集合中。例如 PARTITION BY RANGE (created_at), 则主键必须是 (id, created_at) 而不能只是 (id)。这是因为 PostgreSQL 需要保证跨分区的唯一性约束可以通过分区键局部化。若唯一性只在单个分区内有意义, 可以在子表上单独建唯一约束。


12.3.8 分区注意事项

使用声明式分区前, 需了解以下限制与最佳实践:

事项说明
分区键与主键分区键列必须包含在主键和所有唯一约束中, 否则建表报错
跨分区唯一约束PostgreSQL 无法直接保证跨分区的全局唯一性(主键通过将分区键加入约束实现局部唯一); 如确需全局唯一, 需在应用层或通过序列等辅助手段保证
分区数量几十个分区合理, 超过几百至几千个会显著拖慢查询规划阶段(优化器需要枚举并裁剪所有候选分区); 建议评估实际分区粒度
跨分区排序与 JOIN若查询涉及多个分区且有 ORDER BY 或 JOIN, 优化器可能无法消除部分分区, 产生额外归并排序代价
子查询与 IN 列表裁剪限制WHERE created_at IN (SELECT ...) 形式的动态子查询通常无法触发静态裁剪(优化器在规划阶段无法确定子查询结果); 推荐改写为字面量或绑定参数
外键约束PostgreSQL 14 之前, 分区表不能作为外键引用的被引用表; PG14 及以后已解除此限制
触发器行级 BEFORE 触发器对分区表不适用(推荐用规则或逻辑层处理), AFTER 触发器可正常使用

分区数量对查询规划速度的影响

分区数量并不是越多越好。PostgreSQL 优化器在每次执行 SQL 时都要遍历分区列表执行裁剪逻辑, 分区越多, 规划阶段耗时越长:

  • 几十个分区: 规划开销可忽略不计, 推荐范围
  • 几百个分区: 规划时间可能达到毫秒级, 需要结合实际查询评估
  • 几千个分区: 规划时间可能远超实际执行时间, 严重影响 OLTP 场景的 QPS; EXPLAIN 的分析本身也会变慢
sql
-- 查看当前分区数量
SELECT count(*)
FROM pg_inherits
WHERE inhparent = 'orders'::regclass;

-- 查看优化器规划时间占比(若 planning time 远大于 execution time, 分区可能太多)
EXPLAIN (ANALYZE, TIMING)
SELECT * FROM orders WHERE created_at = NOW();
-- 输出示例:
-- Planning Time: 12.345 ms   ← 分区太多时此值偏高
-- Execution Time: 0.082 ms

分区粒度选择建议

按月分区是时序数据最常见的选择: 粒度足够细(每次查询通常只涉及 1-3 个月), 分区数量可控(10 年约 120 个)。若数据量特别大可考虑按天分区, 但需做好自动化创建与归档脚本, 避免手工维护负担过重。HASH 分区建议 4~16 个分区, 过多同样无益。

12.3.9 老式继承分区与声明式分区

在 PostgreSQL 10 引入声明式分区之前, 分区通常通过表继承(INHERITS)加 CHECK 约束手工实现, 有时也叫"传统分区"或"继承分区":

sql
-- 继承分区的老写法(了解即可, 新项目不推荐)
CREATE TABLE orders_legacy (
    id           BIGSERIAL PRIMARY KEY,
    user_id      BIGINT NOT NULL,
    created_at   TIMESTAMPTZ NOT NULL
);

CREATE TABLE orders_legacy_2023 (
    CHECK (created_at >= '2023-01-01' AND created_at < '2024-01-01')
) INHERITS (orders_legacy);

-- 还需要手动建触发器或规则实现插入路由
CREATE OR REPLACE FUNCTION orders_insert_router()
RETURNS TRIGGER AS $$
BEGIN
    IF NEW.created_at >= '2023-01-01' AND NEW.created_at < '2024-01-01' THEN
        INSERT INTO orders_legacy_2023 VALUES (NEW.*);
    END IF;
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

继承分区与声明式分区的主要对比:

特性继承分区(老)声明式分区(PG10+)
语法复杂度高(手写触发器/规则)低(原生语法)
插入路由手动实现自动路由
分区裁剪依赖 CHECK 约束(constraint_exclusion)原生优化器支持
主表索引传播不支持PG11+ 自动传播
ATTACH/DETACH不支持原生支持
维护成本

维护老代码时的建议

若接手使用继承分区的老系统, 评估迁移到声明式分区的成本。直接迁移可能需要停机, 可考虑先新建声明式分区主表, 再逐步将旧子表通过 ATTACH PARTITION 接入, 实现平滑过渡。



12.3.10 范围分区数据路由示意图

下图展示一条 INSERT 语句进入范围分区主表后, PostgreSQL 如何根据 created_at 的值将数据路由到对应子表。主表本身不存储任何行, 仅作为路由层; 实际数据落在各子表中。

图解要点:

  1. 应用层始终面向主表 orders 读写, 无需感知子表的存在
  2. 写入路由在服务端自动完成: PostgreSQL 根据分区键值计算目标子表并直接插入, 不存在多余的中间跳转
  3. SELECT 时若 WHERE 子句含分区键条件, 优化器在规划阶段排除不相关子表(图中灰色节点), 只访问匹配的分区(绿色节点)
  4. DEFAULT 分区是兜底安全网; 若无 DEFAULT 且数据不属于任何已定义区间, INSERT 会报错

12.4 本章小结

本章覆盖了 PostgreSQL 中三个重要的结构化对象:

普通视图本质上是存储的 SQL 查询, 每次访问都重新执行, 适合封装复杂业务查询逻辑与实现列级权限隔离。可更新视图让简单视图直接支持 DML 操作, WITH CHECK OPTION 则保证视图的语义一致性。

物化视图将查询结果物理存储, 以数据时效性换取极高的读取性能。REFRESH MATERIALIZED VIEW CONCURRENTLY 配合唯一索引可实现非阻塞刷新。适合报表、统计仪表盘等对实时性要求不高的聚合场景, 刷新策略可结合 pg_cron 定时或由应用层事件驱动。

声明式分区是处理超大表的核心手段。三种分区方式各有侧重:

  • RANGE 分区: 时序数据按月/年分区, 配合 DETACH 轻松归档历史
  • LIST 分区: 枚举值维度(地区、类型)按值域分区
  • HASH 分区: 均匀分散写入热点, 不适合范围裁剪

分区裁剪是分区带来性能收益的核心, 务必在 WHERE 条件中直接使用分区键列而非对其施加函数。分区维护需要提前规划好滚动创建与历史归档的自动化脚本, 避免线上运行时手忙脚乱。

sql
-- 快速参考: 三类对象的核心操作
-- 视图
CREATE OR REPLACE VIEW v_name AS SELECT ...;
DROP VIEW v_name;

-- 物化视图
CREATE MATERIALIZED VIEW mv_name AS SELECT ...;
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_name;

-- 分区表
CREATE TABLE t (...) PARTITION BY RANGE (col);
CREATE TABLE t_part PARTITION OF t FOR VALUES FROM (...) TO (...);
ALTER TABLE t DETACH PARTITION t_part;
ALTER TABLE t ATTACH PARTITION existing_t FOR VALUES FROM (...) TO (...);

掌握这三类对象的使用时机与维护方式, 是构建高可用、高性能 PostgreSQL 数据层的重要基础。下一章将进入PostgreSQL 的并发控制与锁机制, 深入理解事务隔离级别与多版本并发控制(MVCC)的内部工作原理。

十三、JSON、全文检索与扩展生态

关系型数据库并不只擅长整齐划一的结构化数据。现实业务里总有一部分数据天生是半结构化的——用户自定义属性、第三方 API 快照、不断变化的配置项。PostgreSQL 从 9.2 版本引入 JSON、9.4 版本引入 JSONB, 将文档型能力纳入关系引擎, 配合全文检索、模糊匹配和丰富的扩展生态, 构成了一套完整的"全能型"数据平台。本章从 JSONB 操作符深入到 GIN 索引, 再到全文检索的生产实战, 最后速览常用扩展。


13.1 JSON 与 JSONB 的根本区别

PostgreSQL 同时支持 jsonjsonb 两种类型, 它们的存储策略截然不同。

维度jsonjsonb
存储形式原始文本(保留空格、key 顺序、重复 key)解析后的二进制(去除空格、去重 key)
写入速度更快(不解析)略慢(需解析)
查询速度每次查询都重新解析直接读取二进制, 速度更快
可建索引不支持 GIN 索引支持 GIN 索引
适用场景仅需存储原样输出的日志归档绝大多数业务场景

实践建议

绝大多数情况下使用 jsonb 只有在必须严格保留原始字节序列(例如签名校验、精确审计日志)时才考虑 json。两者 API 完全兼容, 切换类型只需改建表语句。

sql
-- json 保留原始格式: 重复 key 都存进去, 查询时返回最后一个
SELECT '{"a":1,"a":2}'::json -> 'a';     -- 返回 2(保留重复, 取最后)
SELECT '{"a":1,"a":2}'::jsonb -> 'a';    -- 返回 1(去重, 保留首个)

-- jsonb 存储时已去除多余空格
SELECT '{ "name" :  "Alice" }'::jsonb;  -- 输出: {"name": "Alice"}

13.2 JSONB 操作符全览

JSONB 提供了一套丰富的操作符, 分为"取值"和"判断"两大类。

13.2.1 取值操作符

-> — 取字段或数组元素, 返回 jsonb 类型

sql
SELECT '{"user":{"name":"Alice","age":30}}'::jsonb -> 'user';
-- 返回: {"name": "Alice", "age": 30}

SELECT '[10, 20, 30]'::jsonb -> 1;
-- 返回: 20  (索引从 0 开始)

->> — 取字段或数组元素, 返回 text 类型

sql
SELECT '{"name":"Alice"}'::jsonb ->> 'name';
-- 返回文本: Alice (不带引号)

#> — 按路径数组取嵌套字段, 返回 jsonb

sql
SELECT '{"address":{"city":"Beijing","zip":"100000"}}'::jsonb
       #> '{address,city}';
-- 返回: "Beijing"

#>> — 同上, 返回 text

sql
SELECT '{"address":{"city":"Beijing"}}'::jsonb
       #>> '{address,city}';
-- 返回文本: Beijing

13.2.2 判断操作符

@> — 包含(右侧是左侧的子集)

sql
-- 判断文档是否包含指定标签
SELECT '{"tag":"hot","score":9.5}'::jsonb @> '{"tag":"hot"}';
-- 返回: true

-- 实际查询: 找出所有含 "tag": "hot" 的商品
SELECT id, name FROM products
WHERE attributes @> '{"tag":"hot"}';

<@ — 被包含(反向, 左侧是右侧的子集)

sql
SELECT '{"tag":"hot"}'::jsonb <@ '{"tag":"hot","score":9.5}';
-- 返回: true

? — 是否存在某个顶层 key

sql
SELECT '{"email":"a@b.com","name":"Alice"}'::jsonb ? 'email';
-- 返回: true

-- 查询有 email 字段的用户
SELECT id FROM users WHERE profile ? 'email';

?| — 是否存在任意一个** key(OR 语义)**

sql
SELECT '{"phone":"13800000000"}'::jsonb ?| array['email','phone'];
-- 返回: true (存在 phone)

?& — 是否同时存在所有 key(AND 语义)

sql
SELECT '{"email":"a@b.com","phone":"138"}'::jsonb ?& array['email','phone'];
-- 返回: true

|| — 合并两个 JSONB(右侧覆盖左侧相同 key)

sql
SELECT '{"name":"Alice","age":30}'::jsonb
    || '{"age":31,"city":"Shanghai"}'::jsonb;
-- 返回: {"age": 31, "city": "Shanghai", "name": "Alice"}

13.3 JSONPath 与路径查询

PostgreSQL 12 引入了符合 SQL/JSON 标准的 JSONPath, 可以用类似 XPath 的路径语法深入查询嵌套结构。

sql
-- jsonb_path_query: 取数组中所有元素
SELECT jsonb_path_query(
  '{"tags": ["postgresql","database","index"]}',
  '$.tags[*]'
);
-- 依次返回三行: "postgresql"  "database"  "index"

-- 条件过滤: 取 score > 8 的所有评分项
SELECT jsonb_path_query(
  '{"reviews": [{"score":9,"user":"A"},{"score":7,"user":"B"}]}',
  '$.reviews[*] ? (@.score > 8)'
);
-- 返回: {"score": 9, "user": "A"}

-- jsonb_path_exists: 判断路径是否存在
SELECT jsonb_path_exists(
  '{"profile":{"vip":true}}',
  '$.profile.vip'
);
-- 返回: true

JSONPath vs #> 路径

#> 只支持精确路径取值; JSONPath 支持过滤条件、数组遍历、递归下降(.**)等高级用法。对于简单的字段读取, #> 更简洁; 对于复杂的嵌套条件筛选, 优先使用 JSONPath。


13.4 JSONB 修改与构建函数

13.4.1 修改函数

jsonb_set — 设置指定路径的值

sql
-- 修改 address.city, 如果路径不存在则创建
SELECT jsonb_set(
  '{"name":"Alice","address":{"city":"Beijing"}}',
  '{address,city}',
  '"Shanghai"',
  true   -- create_if_missing
);
-- 返回: {"name": "Alice", "address": {"city": "Shanghai"}}

jsonb_insert — 向数组指定位置插入元素

sql
SELECT jsonb_insert(
  '{"tags":["a","c"]}',
  '{tags,1}',       -- 在索引 1 之前插入
  '"b"'
);
-- 返回: {"tags": ["a", "b", "c"]}

jsonb_strip_nulls — 删除值为 null 的所有键

sql
SELECT jsonb_strip_nulls('{"name":"Alice","phone":null,"age":null}');
-- 返回: {"name": "Alice"}

#- 操作符 — 删除指定路径

sql
SELECT '{"a":1,"b":{"c":2,"d":3}}'::jsonb #- '{b,c}';
-- 返回: {"a": 1, "b": {"d": 3}}

13.4.2 构建函数

sql
-- jsonb_build_object: 从键值参数对构建 JSONB
SELECT jsonb_build_object('name', 'Alice', 'age', 30, 'active', true);
-- 返回: {"age": 30, "name": "Alice", "active": true}

-- jsonb_build_array: 构建 JSONB 数组
SELECT jsonb_build_array(1, 'hello', true, null);
-- 返回: [1, "hello", true, null]

-- to_jsonb: 把任意 SQL 值/行记录转为 JSONB
SELECT to_jsonb(ROW('Alice', 30, true));
-- 返回: {"f1": "Alice", "f2": 30, "f3": true}

13.4.3 展开函数

sql
-- jsonb_array_elements: 将 JSON 数组展开为多行
SELECT jsonb_array_elements('[1,2,3]'::jsonb);
-- 返回三行: 1  2  3

-- jsonb_each: 将 JSONB 对象展开为 (key text, value jsonb) 行集
SELECT key, value FROM jsonb_each('{"a":1,"b":2}');
-- 返回: (a, 1)  (b, 2)

-- jsonb_to_recordset: 将 JSONB 数组展开为强类型行集
SELECT * FROM jsonb_to_recordset(
  '[{"name":"Alice","age":30},{"name":"Bob","age":25}]'
) AS t(name text, age int);
-- 返回标准的两行两列结果集

13.5 GIN 索引加速 JSONB 查询

JSONB 字段如果不建索引, @>? 等操作符都会退化为全表顺序扫描。GIN(Generalized Inverted Index, 广义倒排索引)是 JSONB 的专属加速方案, 它将文档中的每个 key-value 对都提取出来作为索引词条。

13.5.1 两种 GIN 索引策略

jsonb_ops(默认操作符类)

sql
-- 为每个 key、每个 key-value 对分别建索引词条
CREATE INDEX idx_products_attrs_ops
  ON products USING GIN (attributes);
-- 等价于: USING GIN (attributes jsonb_ops)

支持的操作符: @>, <@, ?, ?|, ?&

jsonb_path_ops(路径操作符类)

sql
-- 只为完整路径(从根到叶子)建索引词条
CREATE INDEX idx_products_attrs_pathops
  ON products USING GIN (attributes jsonb_path_ops);

支持的操作符: 仅 @>

特性jsonb_opsjsonb_path_ops
索引体积较大较小(约小 20–50%)
支持操作符@> <@ ? `??&`
包含查询速度更快
适用场景需要 key 存在检查主要做包含查询

选择策略

如果业务查询以 @> 包含检查为主(最常见的商品属性筛选场景), 优先选 jsonb_path_ops, 索引更小、查询更快。如果还需要 ? 检查某个 key 是否存在, 则用默认的 jsonb_ops

13.5.2 实战: 商品属性筛选

假设电商平台的商品表结构如下, 每种品类的属性字段各不相同:

sql
CREATE TABLE products (
  id         bigserial PRIMARY KEY,
  name       text NOT NULL,
  price      numeric(10,2),
  attributes jsonb           -- {"brand":"Nike","color":"red","size":"XL"}
);

-- 插入测试数据
INSERT INTO products (name, price, attributes) VALUES
  ('运动鞋 A', 299.00, '{"brand":"Nike","color":"red","size":"42","tag":"hot"}'),
  ('运动鞋 B', 199.00, '{"brand":"Adidas","color":"blue","size":"41"}'),
  ('T恤 C',   89.00,  '{"brand":"Nike","color":"white","material":"cotton","tag":"hot"}');

-- 建 GIN 索引(选 jsonb_path_ops, 因为主要做包含查询)
CREATE INDEX idx_products_attributes
  ON products USING GIN (attributes jsonb_path_ops);

执行包含查询, PostgreSQL 会自动命中 GIN 索引:

sql
-- 查询品牌为 Nike 且标签为 hot 的商品
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, name, price
FROM products
WHERE attributes @> '{"brand":"Nike","tag":"hot"}';
text
Bitmap Heap Scan on products  (cost=8.00..12.01 rows=1 width=...)
  Recheck Cond: (attributes @> '{"brand": "Nike", "tag": "hot"}'::jsonb)
  ->  Bitmap Index Scan on idx_products_attributes
        Index Cond: (attributes @> '{"brand": "Nike", "tag": "hot"}'::jsonb)

GIN 索引维护成本

GIN 索引写入时需要展开所有词条, 高并发写入场景会有一定压力。PostgreSQL 提供了"快速更新"机制(Fast Update, 默认开启), 将新词条暂存于 pending list 后批量合并, 可以平衡写入性能。如果 VACUUM 压力大可考虑 fastupdate = off


13.6 JSONB 建模的权衡与反模式

JSONB 不是万能药, 滥用反而会带来严重的性能和维护问题。

13.6.1 应该使用 JSONB 的场景

用户自定义属性 — 每个用户或商品的字段集合不同, 无法提前用列定义穷举:

sql
-- 不同用户可能有完全不同的扩展属性
-- user_A: {"vip_level": 3, "preferred_lang": "zh"}
-- user_B: {"company": "ACME", "department": "R&D", "employee_id": "E001"}
ALTER TABLE users ADD COLUMN extra_attrs jsonb DEFAULT '{}';

第三方 API 快照存储 — 原样保存外部系统返回的 JSON 响应, 避免解析失败丢数据:

sql
CREATE TABLE api_snapshots (
  id          bigserial PRIMARY KEY,
  service     text,
  fetched_at  timestamptz DEFAULT now(),
  payload     jsonb       -- 完整保存, 按需取字段
);

半结构化配置与日志 — 配置项的 key 随版本迭代增减, 用 JSONB 比频繁加列更灵活。

13.6.2 不该使用 JSONB 的场景

所有行都有相同固定结构 — 这是最常见的反模式。如果每条记录都有 nameemailcreated_at 这三个字段, 就应该建三个普通列:

sql
-- 错误示例: 明明是固定结构却塞进 JSONB
CREATE TABLE users_bad (
  id   bigserial,
  data jsonb  -- {"name":"...","email":"...","created_at":"..."}
);
-- 查询时: WHERE data->>'email' = 'a@b.com'  — 无法利用 B-tree 索引
-- 需要单独建表达式索引, 维护成本高

-- 正确做法
CREATE TABLE users_good (
  id         bigserial PRIMARY KEY,
  name       text,
  email      text UNIQUE,
  created_at timestamptz DEFAULT now()
);

需要按某字段做统计聚合SUM(data->>'price')::numeric 这类写法既丑陋又低效, 无法利用列统计信息, 查询计划估算也不准确。

需要外键完整性约束 — JSONB 内部字段没有外键, 数据一致性只能靠应用层保证, 极易出现孤儿数据:

sql
-- 错误: 把关联 ID 塞进 JSONB
INSERT INTO orders (info) VALUES ('{"user_id": 9999}');
-- 即使 users 表里根本没有 id=9999 的记录, 也不会报错

JSONB 过度使用的代价

把所有业务字段都塞进一个 JSONB 列是"EAV 反模式"(Entity-Attribute-Value)的变体。症状包括: 查询语句充斥 ->> 类型转换、索引覆盖率低导致全表扫描、无法利用数据库约束保证数据质量、EXPLAIN 的行数估算误差达到数量级。一旦数据量过百万, 修复成本极高。


13.7 全文检索基础: tsvector 与 tsquery

PostgreSQL 内置的全文检索系统基于两个核心类型: tsvector(词汇向量)和 tsquery(查询向量), 以及 @@ 匹配操作符。

13.7.1 全文检索处理流程

13.7.2 tsvector — 文档侧词汇向量

to_tsvector(config, text) 将原始文本进行分词、停用词过滤、词干还原, 生成规范化的词汇向量:

sql
SELECT to_tsvector('english', 'PostgreSQL indexes are very powerful');
-- 返回: 'index':3 'postgresql':1 'powerful':5
-- "are" "very" 是英文停用词, 被过滤掉
-- "indexes" 词干化为 "index"

'english' 是文本搜索配置名称, 内置的还有 'simple'(不做词干化, 仅小写化)、'chinese'(需要扩展)等。

13.7.3 tsquery — 查询侧向量

sql
-- to_tsquery: 手动指定逻辑运算符
SELECT to_tsquery('english', 'postgresql & index');
-- 返回: 'postgresql' & 'index'

-- plainto_tsquery: 将自然语言短语转为 AND 查询
SELECT plainto_tsquery('english', 'postgresql index performance');
-- 返回: 'postgresql' & 'index' & 'perform'

-- phraseto_tsquery: 要求词语按顺序相邻出现(短语匹配)
SELECT phraseto_tsquery('english', 'index scan');
-- 返回: 'index' <-> 'scan'  (<-> 表示相邻)

-- websearch_to_tsquery(PG11+): 支持 Google 式语法
SELECT websearch_to_tsquery('english', '"index scan" -sequential');
-- 返回: 'index' <-> 'scan' & !'sequenti'

13.7.4 @@ 匹配操作符

sql
-- 基础匹配
SELECT to_tsvector('english', 'PostgreSQL index is powerful')
    @@ to_tsquery('english', 'postgresql & index');
-- 返回: true

-- 实际表查询
SELECT id, title FROM articles
WHERE to_tsvector('english', title || ' ' || body)
   @@ plainto_tsquery('english', 'postgresql performance');

实时 to_tsvector 的性能陷阱

WHERE to_tsvector(body) @@ query 这种写法每次查询都会对全表文本重新分词, 即使建了 GIN 索引也无法使用(函数输入值在运行时才确定)。正确做法是预计算 tsvector 列并对该列建索, 详见下文的生产实战方案。


13.8 相关性排序与字段加权

全文检索不只是"匹配/不匹配"二元结果, 还需要按相关程度排序返回结果。

13.8.1 ts_rank 相关性评分

sql
SELECT
  title,
  ts_rank(search_vector, query)         AS rank,
  ts_rank_cd(search_vector, query, 32)  AS rank_cd
FROM articles,
     to_tsquery('english', 'postgresql & index') AS query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 10;

ts_rank_cd 的第三个参数是归一化选项: 32 表示"除以文档长度的对数", 避免长文章因包含更多词而获得虚高评分。

13.8.2 setweight — 标题权重高于正文

setweight(tsvector, weight) 给词汇向量中的每个词条附加权重标签(A 最高, D 最低), ts_rank 计算时会考虑权重:

sql
-- 标题词条权重 A, 正文词条权重 C
SELECT setweight(to_tsvector('english', 'PostgreSQL Index'), 'A')
    || setweight(to_tsvector('english', 'How indexes work in detail'), 'C');
-- 标题中的词在排名时得分更高

13.9 生产实战: articles 全文检索

13.9.1 表结构与 tsvector 生成列

PostgreSQL 12 引入了生成列(GENERATED ALWAYS AS ... STORED), 是维护 tsvector 最简洁的方式:

sql
CREATE TABLE articles (
  id            bigserial PRIMARY KEY,
  title         text NOT NULL,
  body          text NOT NULL,
  author_id     bigint,
  published_at  timestamptz DEFAULT now(),
  -- 生成列: 自动维护, 写入时 PostgreSQL 自动更新
  search_vector tsvector GENERATED ALWAYS AS (
    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(body,  '')), 'C')
  ) STORED
);

-- 在生成列上建 GIN 索引
CREATE INDEX idx_articles_fts ON articles USING GIN (search_vector);

生成列 vs 触发器

生成列(PG12+): 声明式, 自动维护, 无需额外触发器代码, 是首选方案。 触发器方案(兼容旧版本): 手动写 BEFORE INSERT OR UPDATE 触发器函数更新 tsvector 列, 逻辑等价但维护成本更高。

触发器方案参考(兼容 PG11 及以下):

sql
-- 手动维护 search_vector 的触发器方案
CREATE OR REPLACE FUNCTION articles_search_trigger()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  NEW.search_vector :=
    setweight(to_tsvector('english', coalesce(NEW.title, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(NEW.body,  '')), 'C');
  RETURN NEW;
END;
$$;

CREATE TRIGGER trig_articles_search
BEFORE INSERT OR UPDATE OF title, body ON articles
FOR EACH ROW EXECUTE FUNCTION articles_search_trigger();

13.9.2 插入测试数据

sql
INSERT INTO articles (title, body, author_id) VALUES
(
  'PostgreSQL Index Deep Dive',
  'This article explores B-tree, GIN, and GiST indexes in PostgreSQL.
   We cover index selection strategies and performance tuning tips.',
  1
),
(
  'Query Performance Optimization in PostgreSQL',
  'Slow queries hurt user experience. Learn how to use EXPLAIN ANALYZE,
   identify bottlenecks, and optimize PostgreSQL query plans.',
  2
),
(
  'Introduction to Full-Text Search',
  'PostgreSQL full-text search uses tsvector and tsquery.
   Learn how to build a search feature for your application.',
  1
);

13.9.3 执行全文检索查询

sql
-- 搜索 "postgresql 性能优化" 相关文章, 按相关性排序
SELECT
  id,
  title,
  ts_rank(search_vector, query)  AS relevance,
  ts_headline(
    'english', body, query,
    'MaxWords=20, MinWords=5, StartSel=<b>, StopSel=</b>'
  )                               AS snippet
FROM
  articles,
  websearch_to_tsquery('english', 'postgresql performance optimization') AS query
WHERE
  search_vector @@ query
ORDER BY
  relevance DESC;
text
 id |               title                        | relevance | snippet
----+--------------------------------------------+-----------+---------
  2 | Query Performance Optimization in PostgreSQL|   0.0985  | ...Slow queries hurt... optimize PostgreSQL <b>query</b> <b>plans</b>...
  1 | PostgreSQL Index Deep Dive                  |   0.0759  | ...<b>performance</b> tuning tips...

ts_headline 函数自动提取命中词周围的上下文片段并高亮标注, 非常适合前端展示搜索摘要。

13.9.4 EXPLAIN 验证索引命中

sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title FROM articles
WHERE search_vector @@ websearch_to_tsquery('english', 'postgresql index');
text
Bitmap Heap Scan on articles
  Recheck Cond: (search_vector @@ ...)
  ->  Bitmap Index Scan on idx_articles_fts
        Index Cond: (search_vector @@ ...)
Planning Time: 0.3 ms
Execution Time: 0.1 ms

13.10 中文全文检索

PostgreSQL 内置分词器基于英文词干算法, 不支持中文分词。中文需要额外安装分词扩展。

主流选项:

扩展特点适用场景
zhparser基于 SCWS, 轻量, 支持自定义词典中小规模应用首选
pg_jieba基于结巴分词, Python 生态友好需要与 Python 应用集成

安装并配置 zhparser 后的示例:

sql
-- 安装扩展并创建中文文本搜索配置
CREATE EXTENSION zhparser;

CREATE TEXT SEARCH CONFIGURATION chinese (PARSER = zhparser);
ALTER TEXT SEARCH CONFIGURATION chinese
  ADD MAPPING FOR n, v, a, i, e, l WITH simple;

-- 使用中文配置
SELECT to_tsvector('chinese', '深入理解 PostgreSQL 索引机制');
-- 分词后词条: 'postgresql':3 '机制':5 '理解':2 '索引':4 '深入':1

-- 查询
SELECT title FROM articles
WHERE to_tsvector('chinese', title) @@ to_tsquery('chinese', '索引');

生产环境注意事项

zhparser 需要在服务器上编译安装, 并在 postgresql.conf 中预加载。RDS 等托管服务通常已提供, 自建服务器需要手动编译。词典质量直接影响搜索体验, 建议根据业务领域补充自定义词典。


13.11 模糊搜索: pg_trgm 扩展

全文检索依赖准确的词汇匹配, 无法处理拼写错误或"模糊包含"场景。pg_trgm 扩展基于 trigram(三字符组) 相似度算法, 专门解决这类需求。

13.11.1 Trigram 相似度原理

Trigram 算法将字符串拆分为所有连续的三字符子串, 用两个字符串共有的 trigram 数量计算相似度:

text
"postgresql" 的 trigrams:
  "  p", " po", "pos", "ost", "stg", "tgr", "gre", "res", "esq", "sql", "ql "

"postgre" 的 trigrams:
  "  p", " po", "pos", "ost", "stg", "tgr", "gre", "re "

共有 7 个 trigram, 相似度 = 2 * 共有数 / (总数A + 总数B) ≈ 0.59

13.11.2 基础用法

sql
CREATE EXTENSION pg_trgm;

-- similarity(): 返回 0~1 之间的相似度分数
SELECT similarity('postgresql', 'postgre');
-- 返回: 0.588235

SELECT similarity('apple', 'aple');
-- 返回: 0.5  (一个字母之差)

-- % 操作符: 相似度超过阈值(默认 0.3)则返回 true
SELECT 'postgresql' % 'postgresq';
-- 返回: true

-- 调整全局相似度阈值
SET pg_trgm.similarity_threshold = 0.4;

13.11.3 核心能力: 让 LIKE '%keyword%' 走索引

这是 pg_trgm 最重要的工程价值。普通 B-tree 索引无法优化前缀通配符(%keyword%), 而 GIN/GiST trigram 索引可以:

sql
-- 在商品名称列建 trigram GIN 索引
CREATE INDEX idx_products_name_trgm
  ON products USING GIN (name gin_trgm_ops);

-- 现在这条查询可以走索引了!
EXPLAIN (ANALYZE)
SELECT id, name FROM products
WHERE name LIKE '%apple%';
-- Bitmap Index Scan on idx_products_name_trgm

-- 同样支持 ILIKE(大小写不敏感)
SELECT id, name FROM products
WHERE name ILIKE '%Apple%';

-- 正则表达式也支持
SELECT id, name FROM products
WHERE name ~ 'appl[ey]';

GIN vs GiST for pg_trgm

  • GIN: 查询速度更快, 但构建和更新成本高。适合读多写少的场景(如商品目录)。
  • GiST: 构建速度更快, 支持 KNN 最近邻查询(ORDER BY similarity() LIMIT N)。适合需要"相似度排序"的拼写纠错场景。

13.11.4 拼写纠错与模糊补全

sql
-- 找出与输入词最相似的商品名, 用于搜索建议
SELECT name, similarity(name, 'postgrsql') AS sim
FROM products
WHERE name % 'postgrsql'        -- 超过相似度阈值的才返回
ORDER BY sim DESC
LIMIT 5;

13.11.5 pg_trgm vs 全文检索

维度pg_trgm全文检索(tsvector)
匹配原理字符级相似度词汇级语义匹配
处理拼写错误能(容错匹配)不能
理解词干不能("run"/"running" 视为不同)能(词干化后相同)
支持 LIKE 走索引不能
中文支持能(直接对字符操作)需要分词扩展
适用场景搜索框自动补全、拼写纠错、模糊文件名搜索文章/文档的语义搜索

两者可以组合使用: 先用 pg_trgm 粗筛相似候选集, 再用全文检索精排相关性。


13.12 常用扩展速览

PostgreSQL 的扩展(Extension)机制让其功能几乎无限可扩展。以下是生产环境中最常用的扩展。

扩展名核心功能安装命令典型用法
pgcrypto加密哈希、对称加密、PGPCREATE EXTENSION pgcrypto;crypt('password', gen_salt('bf')) 存储密码哈希; pgp_sym_encrypt(data, key) 加密敏感字段
uuid-osspUUID 生成函数族CREATE EXTENSION "uuid-ossp";uuid_generate_v4() 生成随机 UUID; PG13+ 推荐直接用内置 gen_random_uuid()
hstore单层键值对类型CREATE EXTENSION hstore;'a=>1, b=>2'::hstore 比 JSONB 更轻量, 适合简单的 KV 存储
citext大小写不敏感文本类型CREATE EXTENSION citext;email citext UNIQUE — 比 lower(email) 表达式索引更简洁, 直接在类型层面忽略大小写
PostGIS地理空间类型与函数CREATE EXTENSION postgis;geometry / geography 类型; ST_Distance(a, b) 计算距离; ST_Contains(polygon, point) 判断包含关系
pg_stat_statementsSQL 级性能统计shared_preload_libraries = 'pg_stat_statements' 后重启SELECT * FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10 — 慢查询分析必备
timescaledb时序数据引擎独立安装后 CREATE EXTENSION timescaledb;SELECT create_hypertable('metrics', 'time') 自动按时间分区; 持续聚合(类物化视图); 列式压缩可减少 90%+ 存储

13.12.1 pgcrypto — 密码安全存储

sql
CREATE EXTENSION pgcrypto;

-- 注册时存储 bcrypt 哈希
INSERT INTO users (email, password_hash)
VALUES ('alice@example.com', crypt('mypassword', gen_salt('bf', 12)));

-- 登录验证
SELECT id FROM users
WHERE email = 'alice@example.com'
  AND password_hash = crypt('mypassword', password_hash);

bcrypt 轮数

gen_salt('bf', 12) 中的 12 是 bcrypt 的工作因子(cost factor), 值越大越安全但越慢。建议根据服务器性能测试, 选择使单次哈希耗时约 100–300ms 的值。

13.12.2 pg_stat_statements — 生产慢查询分析

sql
-- postgresql.conf 配置
-- shared_preload_libraries = 'pg_stat_statements'
-- pg_stat_statements.track = all

CREATE EXTENSION pg_stat_statements;

-- 找出最耗时的 10 条查询
SELECT
  round(total_exec_time::numeric, 2)      AS total_ms,
  calls,
  round(mean_exec_time::numeric, 2)       AS mean_ms,
  round(stddev_exec_time::numeric, 2)     AS stddev_ms,
  left(query, 80)                         AS query_snippet
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

-- 定期重置统计数据
SELECT pg_stat_statements_reset();

13.12.3 citext — 邮箱等场景的大小写不敏感列

sql
CREATE EXTENSION citext;

CREATE TABLE accounts (
  id    bigserial PRIMARY KEY,
  email citext UNIQUE  -- 'Alice@Example.COM' 和 'alice@example.com' 视为同一值
);

INSERT INTO accounts (email) VALUES ('Alice@Example.COM');

-- 查询时无需 lower() 转换, 直接相等比较
SELECT id FROM accounts WHERE email = 'alice@example.com';  -- 能命中

13.13 综合实战: 商品搜索 — 全文检索 + pg_trgm 容错组合方案

真实的电商搜索框需要同时满足两类需求:

  1. 语义精准搜索 — 用户输入"无线蓝牙耳机", 希望匹配到描述中包含"蓝牙"、"耳机"、"无线"的商品(全文检索)
  2. 拼写容错 — 用户输入"蓝牙耳机"时误打成"蓝牙耳极", 或英文商品名拼写有误, 仍能返回相关结果(pg_trgm)

以下是一个完整的生产级方案, 将两者融合成单一查询。

13.13.1 表结构设计

sql
-- 启用所需扩展
CREATE EXTENSION IF NOT EXISTS pg_trgm;

-- 商品表
CREATE TABLE products (
  id             bigserial     PRIMARY KEY,
  sku            text          NOT NULL UNIQUE,
  name           text          NOT NULL,          -- 商品名称
  description    text          NOT NULL DEFAULT '',-- 详细描述
  category       text          NOT NULL,
  price          numeric(12,2) NOT NULL,
  stock          integer       NOT NULL DEFAULT 0,
  created_at     timestamptz   NOT NULL DEFAULT now(),

  -- 全文检索生成列 (PG12+): 名称权重 A, 描述权重 C
  search_vector  tsvector GENERATED ALWAYS AS (
    setweight(to_tsvector('english', coalesce(name, '')),        'A') ||
    setweight(to_tsvector('english', coalesce(description, '')), 'C')
  ) STORED
);

-- 索引 1: GIN 全文检索索引
CREATE INDEX idx_products_fts
  ON products USING GIN (search_vector);

-- 索引 2: GIN trigram 索引 (支持 LIKE '%x%' 走索引 + similarity 容错)
CREATE INDEX idx_products_name_trgm
  ON products USING GIN (name gin_trgm_ops);

-- 索引 3: 普通 B-tree 索引用于分类过滤
CREATE INDEX idx_products_category
  ON products (category);

13.13.2 插入测试数据

sql
INSERT INTO products (sku, name, description, category, price, stock) VALUES
('BT-001', 'Sony WH-1000XM5 Wireless Headphones',
 'Industry-leading noise canceling headphones with exceptional sound quality and 30-hour battery life.',
 'electronics', 399.99, 50),
('BT-002', 'Apple AirPods Pro 2nd Generation',
 'Active noise cancellation earbuds with adaptive transparency and personalized spatial audio.',
 'electronics', 249.99, 120),
('BT-003', 'Bose QuietComfort 45 Bluetooth Headphones',
 'World-class noise cancellation with premium audio performance and comfortable design.',
 'electronics', 329.99, 75),
('KBD-001', 'Logitech MX Keys Wireless Keyboard',
 'Advanced wireless illuminated keyboard with smart backlighting and multi-device connectivity.',
 'peripherals', 109.99, 200),
('MIC-001', 'Blue Yeti USB Microphone',
 'Professional USB microphone for recording, streaming, and podcasting with multiple pickup patterns.',
 'peripherals', 129.99, 88);

13.13.3 核心搜索函数

sql
-- 搜索函数: 接收关键词和可选分类过滤, 返回排序结果
-- 策略: 全文检索精排 + trigram 容错兜底, 两路结果合并去重
CREATE OR REPLACE FUNCTION search_products(
  search_term  text,
  p_category   text    DEFAULT NULL,   -- NULL 表示不限分类
  p_limit      integer DEFAULT 20,
  p_offset     integer DEFAULT 0
)
RETURNS TABLE (
  id           bigint,
  sku          text,
  name         text,
  category     text,
  price        numeric,
  relevance    real,
  match_type   text    -- 'fts' | 'trgm' | 'both'
)
LANGUAGE sql STABLE AS $$
  WITH
  -- 第一路: 全文检索精确匹配
  fts_results AS (
    SELECT
      p.id,
      p.sku,
      p.name,
      p.category,
      p.price,
      -- ts_rank_cd 归一化: 除以文档长度对数, 避免长描述虚高
      ts_rank_cd(p.search_vector, query, 32) AS fts_rank
    FROM
      products p,
      websearch_to_tsquery('english', search_term) AS query
    WHERE
      p.search_vector @@ query
      AND (p_category IS NULL OR p.category = p_category)
  ),

  -- 第二路: pg_trgm 容错匹配 (捕捉拼写错误或全文检索漏网的词)
  trgm_results AS (
    SELECT
      p.id,
      p.sku,
      p.name,
      p.category,
      p.price,
      similarity(p.name, search_term) AS trgm_sim
    FROM products p
    WHERE
      p.name % search_term                          -- % 操作符走 GIN trigram 索引
      AND (p_category IS NULL OR p.category = p_category)
      AND NOT EXISTS (                              -- 排除已被全文检索命中的
        SELECT 1 FROM fts_results f WHERE f.id = p.id
      )
  ),

  -- 合并两路结果, 统一评分
  combined AS (
    SELECT
      id, sku, name, category, price,
      fts_rank    AS relevance,
      'fts'::text AS match_type
    FROM fts_results

    UNION ALL

    SELECT
      id, sku, name, category, price,
      trgm_sim * 0.6  AS relevance,   -- trigram 容错结果整体降权
      'trgm'::text    AS match_type
    FROM trgm_results
  )

  SELECT *
  FROM combined
  ORDER BY relevance DESC
  LIMIT  p_limit
  OFFSET p_offset;
$$;

13.13.4 调用示例与结果

sql
-- 正常搜索: 全文检索命中
SELECT id, name, price, relevance, match_type
FROM search_products('wireless noise canceling headphones');
text
 id | name                                      |  price  | relevance | match_type
----+-------------------------------------------+---------+-----------+------------
  3 | Bose QuietComfort 45 Bluetooth Headphones | 329.99  |  0.1320   | fts
  1 | Sony WH-1000XM5 Wireless Headphones       | 399.99  |  0.0985   | fts
  2 | Apple AirPods Pro 2nd Generation          | 249.99  |  0.0759   | fts
sql
-- 容错搜索: 用户拼写错误 "Headphnes" → trigram 兜底命中
SELECT id, name, price, relevance, match_type
FROM search_products('Headphnes');
text
 id | name                                      |  price  | relevance | match_type
----+-------------------------------------------+---------+-----------+------------
  1 | Sony WH-1000XM5 Wireless Headphones       | 399.99  |  0.3882   | trgm
  3 | Bose QuietComfort 45 Bluetooth Headphones | 329.99  |  0.3529   | trgm
sql
-- 带分类过滤的搜索
SELECT id, name, price, relevance, match_type
FROM search_products('keyboard wireless', 'peripherals');
sql
-- 生成搜索结果摘要片段(前端展示用)
SELECT
  p.id,
  p.name,
  p.price,
  ts_rank_cd(p.search_vector, query, 32) AS relevance,
  ts_headline(
    'english',
    p.description,
    query,
    'MaxWords=15, MinWords=5, StartSel=<mark>, StopSel=</mark>, HighlightAll=false'
  ) AS snippet
FROM
  products p,
  websearch_to_tsquery('english', 'noise canceling bluetooth') AS query
WHERE
  p.search_vector @@ query
ORDER BY relevance DESC;

13.13.5 执行计划验证

sql
-- 验证两个索引都被正确使用
EXPLAIN (ANALYZE, COSTS OFF, FORMAT TEXT)
SELECT id, name
FROM products
WHERE search_vector @@ websearch_to_tsquery('english', 'wireless headphones');
text
Bitmap Heap Scan on products
  Recheck Cond: (search_vector @@ websearch_to_tsquery('english', 'wireless headphones'))
  ->  Bitmap Index Scan on idx_products_fts
        Index Cond: (search_vector @@ ...)
Planning Time: 0.4 ms, Execution Time: 0.08 ms
sql
EXPLAIN (ANALYZE, COSTS OFF, FORMAT TEXT)
SELECT id, name
FROM products
WHERE name % 'Headphnes';
text
Bitmap Heap Scan on products
  Recheck Cond: (name % 'Headphnes'::text)
  ->  Bitmap Index Scan on idx_products_name_trgm
        Index Cond: (name % 'Headphnes'::text)
Planning Time: 0.3 ms, Execution Time: 0.05 ms

两条查询路径均走索引, 无全表扫描。

13.13.6 全文检索完整流程

方案扩展思路

  • 多语言支持: 对不同语言字段分别维护 search_vector_en / search_vector_zh, 查询时按用户语言选择对应列。
  • 搜索日志: 记录每次搜索词和结果数, 分析零结果查询以优化同义词词典或调整相似度阈值。
  • 缓存热词: 对高频搜索词的结果缓存到 Redis, 降低数据库压力。
  • ES 替代: 数据量超过千万级或需要更复杂的相关性调优时, 可考虑迁移到 Elasticsearch/OpenSearch, 但对于百万级以下的应用 PostgreSQL 内置方案完全够用且运维成本极低。

13.14 本章小结

本章系统梳理了 PostgreSQL 在半结构化数据、搜索和生态扩展三个维度的能力:

JSONB 以二进制格式存储 JSON, 支持 GIN 索引加速, 操作符体系覆盖取值(->, ->>, #>, #>>)、包含判断(@>, <@)、key 存在检查(?, ?|, ?&)和合并(||)。使用 JSONB 的核心原则是: 半结构化或变长字段用 JSONB, 固定结构字段用规范列。

全文检索 的核心链路是: to_tsvector 分词 → 预计算存入生成列 → 建 GIN 索引 → @@ 操作符匹配 → ts_rank 相关性排序。setweight 给标题赋予更高权重, ts_headline 生成高亮摘要。中文场景需要 zhparserpg_jieba 扩展。

pg_trgm 基于三字符组相似度, 核心价值是让 LIKE '%keyword%' 走 GIN 索引, 并支持拼写容错的模糊搜索。与全文检索互补, 可组合使用。

常用扩展中, pg_stat_statements 是慢查询分析的必备工具, pgcrypto 处理密码安全存储, PostGIS 赋予空间计算能力, timescaledb 解决时序数据的分区与压缩问题。

下一章预告

第十四章将深入 PostgreSQL 的高可用与复制架构: 流复制(Streaming Replication)的原理与配置、同步/异步复制的权衡、逻辑复制(Logical Replication)的应用场景, 以及 Patroni 等高可用方案的实践。

十四、备份恢复、复制与高可用

数据库运维的核心命题只有两个: 数据不丢、服务不停。本章从最基础的逻辑备份出发, 逐步深入到物理备份、时间点恢复、流复制、逻辑复制、连接池, 最后落到高可用架构的整体设计。每一层都服务于同一个目标: 在故障发生时把损失控制在可接受范围内。


14.1 逻辑备份

逻辑备份把数据库对象导出为 SQL 语句或特定二进制格式, 不依赖底层文件系统布局, 是最通用的备份手段。

14.1.1 pg_dump 详解

pg_dump 备份单个数据库, 备份期间数据库可正常读写。它在内部开启一个可重复读事务, 获取一致性快照, 保证整个备份文件反映同一时刻的状态。

输出格式(-F)

bash
# 自定义格式(推荐): 二进制压缩, 支持并行恢复和选择性恢复
pg_dump -Fc -d mydb -f mydb.dump

# 纯 SQL 文本: 可直接用 psql 执行, 可读性好但文件大
pg_dump -Fp -d mydb -f mydb.sql

# 目录格式: 每张表一个文件, 支持 -j 并行备份
pg_dump -Fd -d mydb -f mydb_dir/ -j 4

格式选择建议

生产环境首选 -Fc(自定义格式), 体积小、支持 pg_restore -j 并行恢复, 还能在恢复时选择性地只恢复某些表或序列。目录格式 -Fd 配合 -j 适合超大数据库的并行备份。纯文本 -Fp 仅在需要人工审查 SQL 或跨平台兼容时使用。

常用选项

bash
# -Z 压缩级别(0-9, 仅对自定义格式和目录格式生效)
pg_dump -Fc -Z 6 -d mydb -f mydb.dump

# -j 并行备份(仅目录格式)
pg_dump -Fd -j 8 -d mydb -f mydb_dir/

# -t 只备份指定表(可重复使用)
pg_dump -Fc -d mydb -t orders -t order_items -f orders.dump

# -T 排除指定表
pg_dump -Fc -d mydb -T logs -T audit_trail -f mydb_no_log.dump

# --schema-only 只备份结构(不含数据)
pg_dump -Fc --schema-only -d mydb -f mydb_schema.dump

# --data-only 只备份数据(不含 DDL)
pg_dump -Fc --data-only -d mydb -f mydb_data.dump

14.1.2 pg_dumpall: 备份整个实例

pg_dumpall 备份整个 PostgreSQL 实例, 包括所有数据库、角色(ROLE)和表空间定义, 输出纯 SQL 文本。它的典型用途是跨实例迁移或完整实例的文档存档。

bash
# 备份整个实例
pg_dumpall -h localhost -U postgres -f full_instance.sql

# 只备份全局对象(角色和表空间, 不含数据库内容)
pg_dumpall --globals-only -f globals.sql

pg_dumpall 的局限

pg_dumpall 输出纯 SQL, 无法并行备份, 恢复速度慢。对于大型实例, 更推荐对每个数据库单独运行 pg_dump -Fc, 全局对象(角色/表空间)用 pg_dumpall --globals-only 单独保存。

14.1.3 pg_restore: 从备份恢复

pg_restore 用于从自定义格式(-Fc)或目录格式(-Fd)的备份文件恢复数据。纯文本格式直接用 psql -f 执行即可。

恢复前需要先创建目标数据库:

bash
# 先建库
createdb -U postgres newdb

# 从自定义格式恢复
pg_restore -d newdb -U postgres mydb.dump

# -j 并行恢复(多线程, 适合大库)
pg_restore -d newdb -j 8 mydb.dump

# --clean 恢复前先 DROP 已有对象(配合 --if-exists 防止对象不存在时报错)
pg_restore -d newdb --clean --if-exists mydb.dump

# -t 只恢复指定表
pg_restore -d newdb -t orders mydb.dump

# -n 只恢复指定 schema
pg_restore -d newdb -n public mydb.dump

也可以先用 -l 列出备份内容, 编辑后再用 -L 指定恢复列表, 实现精细化恢复:

bash
pg_restore -l mydb.dump > restore.list
# 编辑 restore.list, 注释掉不需要的对象
pg_restore -d newdb -L restore.list mydb.dump

14.1.4 各方式适用场景对比

工具备份范围输出格式并行在线备份适用场景
pg_dump -Fc单库二进制压缩恢复并行生产环境日常备份首选
pg_dump -Fd单库目录(多文件)备份+恢复并行超大库的快速备份
pg_dump -Fp单库纯 SQL需要人工审查或跨平台
pg_dumpall整实例纯 SQL跨实例迁移含全局对象
pg_basebackup整实例文件/tar物理备份/流复制初始化

备份一致性保证

pg_dump 使用可重复读快照, 备份期间其他事务的写入不影响备份结果。备份文件中的数据反映备份开始时刻的一致状态, 即使大表备份需要数小时也如此。


14.2 物理备份与 pg_basebackup

物理备份直接复制数据目录的二进制文件, 速度快, 但只能整实例级别恢复, 且目标实例的大版本必须相同。

14.2.1 pg_basebackup 详解

bash
# 基础用法: 备份到目录
pg_basebackup -h localhost -U replicator -D /backup/base -P

# -Ft: 压缩为 tar 归档(base.tar + pg_wal.tar)
pg_basebackup -h localhost -U replicator -Ft -Z 5 \
  -D /backup/base -P

# -R: 自动生成 standby.signal 和 postgresql.auto.conf
#     用于流复制备库初始化, 省去手动配置
pg_basebackup -h localhost -U replicator -R \
  -D /data/pg_standby -P

# -c fast: 立即触发检查点(备份更快但瞬间 I/O 冲击大)
# -c spread: 分散检查点压力(生产环境推荐)
pg_basebackup -h localhost -U replicator -c spread \
  -D /backup/base -P

-R 选项是初始化流复制备库的利器, 它会在目标目录写入:

  • standby.signal: 标志该实例以备库模式启动
  • postgresql.auto.conf: 写入 primary_conninfo 连接字符串
text
# postgresql.auto.conf 自动生成内容示例
primary_conninfo = 'host=192.168.1.10 port=5432 user=replicator password=xxx'

物理备份特点总结:

  • 快速: 直接文件拷贝, 大库也在分钟级完成
  • 恢复简单: 停库 → 还原目录 → 启动, 无需重建对象
  • 版本限制: 主备必须是相同的大版本(如同为 PostgreSQL 16)
  • 整体性: 无法只恢复某张表, 只能整实例恢复
  • 需要流复制权限: 连接用户必须有 REPLICATION 属性
sql
-- 创建专用复制用户
CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'strongpassword';

同时需要在 pg_hba.conf 中允许该用户的复制连接:

text
# pg_hba.conf
host  replication  replicator  192.168.1.0/24  scram-sha-256

14.3 WAL 与时间点恢复(PITR)

14.3.1 WAL 是什么

WAL(Write-Ahead Log, 预写日志) 是 PostgreSQL 最核心的机制之一。其基本原则是: 所有数据变更必须先写入 WAL 日志, 再写入数据页。这一设计保证了崩溃安全性——即使数据库在写数据页时宕机, 重启后只需重放 WAL 就能恢复到崩溃前的一致状态。

WAL 文件存储在 $PGDATA/pg_wal/ 目录下, 每个段文件默认 16MB, 命名格式如 000000010000000000000001(时间线 + LSN)。LSN(Log Sequence Number)是 WAL 流中的全局单调递增位置标识。

14.3.2 WAL 归档

默认情况下, 旧的 WAL 段文件会被循环覆盖。开启WAL 归档可以将每个 WAL 段在被覆盖前复制到安全位置, 这是 PITR 的前提条件。

ini
# postgresql.conf
archive_mode = on
archive_command = 'cp %p /backup/wal/%f'
# 归档到 S3 的示例(使用 aws cli)
# archive_command = 'aws s3 cp %p s3://mybucket/wal/%f'
  • %p: WAL 段文件的完整路径
  • %f: WAL 段文件名

archive_command 返回 0 表示归档成功。若命令失败, PostgreSQL 会重试, 并不会覆盖该 WAL 段——归档失败不会导致数据丢失, 但会导致 pg_wal 目录持续增长。

14.3.3 PITR 原理

PITR(Point-In-Time Recovery, 时间点恢复)的核心公式:

text
完整恢复能力 = 基础备份(T0 时刻的数据快照)
             + T0 之后连续的 WAL 归档
             → 可恢复到 T0 与当前时刻之间的任意时间点

关键理解

基础备份本身并不完整——它是一个"模糊检查点"期间的文件快照, 需要通过重放 WAL 才能达到一致状态。这就是为什么物理备份恢复时也需要 WAL, 而不是简单地复制文件。

14.3.4 PITR 恢复配置

PostgreSQL 12 以后, 恢复参数直接写入 postgresql.conf(或 postgresql.auto.conf), 并在数据目录放置 recovery.signal 文件触发恢复模式:

ini
# postgresql.conf(恢复时写入)
restore_command = 'cp /backup/wal/%f %p'
# 从 S3 恢复的示例
# restore_command = 'aws s3 cp s3://mybucket/wal/%f %p'

# 恢复目标: 三选一
recovery_target_time = '2026-06-12 08:00:00'   # 时间点
# recovery_target_lsn  = '0/15D1000'            # 精确 LSN
# recovery_target_name = 'before_delete'         # 命名还原点

# 到达目标后的动作
recovery_target_action = 'promote'   # 成为主库(推荐)
# recovery_target_action = 'pause'   # 暂停等待手动确认
# recovery_target_action = 'shutdown' # 到达目标后关库

命名还原点需要在误操作前提前创建:

sql
-- 在关键操作前创建命名还原点
SELECT pg_create_restore_point('before_major_migration');

14.3.5 完整 PITR 演练

以下是一次完整的误删数据恢复演练流程:

第一步: 准备基础备份和 WAL 归档

bash
# 确认 archive_mode = on 已生效
psql -c "SHOW archive_mode;"

# 执行基础备份
pg_basebackup -h localhost -U replicator \
  -D /backup/basebackup_$(date +%Y%m%d) -P -Ft -Z 5

第二步: 模拟误删操作

sql
-- 记录当前时间, 作为恢复目标时间
SELECT now();          -- 假设输出 2026-06-12 08:00:00

-- 误操作: 删除订单表全部数据
DELETE FROM orders;
COMMIT;

第三步: 停库并恢复基础备份

bash
# 停止 PostgreSQL
pg_ctl stop -D /var/lib/postgresql/data

# 清空数据目录(或重命名备份)
mv /var/lib/postgresql/data /var/lib/postgresql/data_broken

# 解压基础备份
mkdir /var/lib/postgresql/data
tar -xzf /backup/basebackup_20260612/base.tar.gz \
  -C /var/lib/postgresql/data
tar -xzf /backup/basebackup_20260612/pg_wal.tar.gz \
  -C /var/lib/postgresql/data/pg_wal/

第四步: 配置恢复参数并启动

bash
# 写入恢复配置
cat >> /var/lib/postgresql/data/postgresql.conf << 'EOF'
restore_command = 'cp /backup/wal/%f %p'
recovery_target_time = '2026-06-12 07:59:00'
recovery_target_action = 'promote'
EOF

# 创建恢复信号文件
touch /var/lib/postgresql/data/recovery.signal

# 启动, 观察日志中的恢复进度
pg_ctl start -D /var/lib/postgresql/data -l /var/log/postgresql/recovery.log
tail -f /var/log/postgresql/recovery.log

第五步: 验证恢复结果

sql
-- 确认恢复已完成(recovery.signal 文件消失, 实例进入正常模式)
SELECT pg_is_in_recovery();   -- 应返回 false

-- 验证数据已恢复
SELECT COUNT(*) FROM orders;

恢复时间窗口

PITR 只能恢复到基础备份时刻之后的时间点。如果基础备份是昨天做的, 今天凌晨的误操作才能用 PITR 恢复; 但上周的误操作就无法恢复(除非上周也有基础备份)。因此建议每天做一次基础备份, 并永久保留 WAL 归档(或至少保留与备份保留策略匹配的时间窗口)。


14.4 流复制配置详解

14.4.1 原理

流复制(Streaming Replication)是 PostgreSQL 内置的主备同步机制。主库(Primary)将 WAL 实时通过 TCP 流式传输给备库(Standby), 备库持续回放 WAL 保持与主库同步。与 WAL 归档不同, 流复制的延迟通常在毫秒级。整个过程无需外部工具介入, 是生产环境高可用架构的基础。

14.4.2 主库关键参数

postgresql.conf 中配置:

ini
# 必须设置为 replica 或 logical(logical 是 replica 的超集)
wal_level = replica

# 允许同时连接的最大 walsender 进程数(每个备库占用一个)
max_wal_senders = 10

# 为每个备库预留的 WAL 保留量(防止备库落后时 WAL 被回收)
wal_keep_size = 1GB

# 启用复制槽(可替代 wal_keep_size, 更精确)
max_replication_slots = 10

listen_addresses = '*'

pg_hba.conf 中为备库用户授权复制权限:

host    replication     replicator      10.0.1.0/24     scram-sha-256

14.4.3 用 pg_basebackup 初始化备库

备库的数据目录必须从主库的基础备份初始化。-R 参数会自动写入 standby.signalprimary_conninfo, 省去手动配置步骤:

bash
pg_basebackup \
  -h 10.0.1.10 \
  -U replicator \
  -D /var/lib/postgresql/data \
  -P \
  -Xs \      # 流式传输 WAL
  -R         # 自动生成 standby.signal 与 primary_conninfo

初始化完成后 postgresql.auto.conf 会自动写入:

ini
primary_conninfo = 'host=10.0.1.10 port=5432 user=replicator password=secret application_name=standby1'

14.4.4 synchronous_commit 各级别对比

synchronous_commit 控制事务提交时主库等待 WAL 写入/应用到哪个层级后再返回成功, 直接影响数据安全性与写入延迟的权衡:

级别主库落盘备库接收备库落盘备库应用丢数据风险写入延迟
off异步最高最低
local等待主库崩溃可丢
on(默认)等待备库未接收则丢
remote_write等待OS 缓冲备库 OS 崩溃可丢中高
remote_apply等待等待等待等待极低
quorum(PG 10+)等待多数确认极低

该参数可在会话级动态调整, 无需重启。金融核心交易推荐 remote_apply; 报表写入场景可短暂设为 off

14.4.5 hot_standby 只读查询

ini
# postgresql.conf (备库)
hot_standby = on              # 允许备库接受只读连接
hot_standby_feedback = on     # 备库将未清理的最小事务 XID 反馈给主库
                              # 防止主库 VACUUM 删除备库查询还在读的行版本

启用 hot_standby_feedback 时需注意: 长查询会阻止主库 autovacuum 回收死元组, 可能导致主库表膨胀。建议同时设置 max_standby_streaming_delay 限制备库查询等待时间。

14.4.6 复制槽与 WAL 堆积风险

复制槽是精确的 WAL 保留机制: 只要槽存在, PostgreSQL 就不会回收该槽消费位点之后的 WAL。

sql
-- 在主库创建物理复制槽
SELECT pg_create_physical_replication_slot('standby1_slot');
-- 备库 postgresql.auto.conf 中引用: primary_slot_name = 'standby1_slot'

WAL 堆积是头号运维事故

备库长时间断连但复制槽仍存在时, 主库 WAL 目录会无限增长, 直到磁盘耗尽导致主库崩溃。

sql
-- 监控复制槽积压量
SELECT slot_name,
       pg_size_pretty(
         pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
       ) AS lag_size,
       active,
       wal_status   -- 'reserved'/'extended'/'unreserved'/'lost'
FROM pg_replication_slots;

-- 紧急情况: 手动删除不再使用的复制槽
SELECT pg_drop_replication_slot('standby1_slot');

建议配置告警: 任意复制槽积压超过 10 GB 时立即通知运维。

14.4.7 pg_stat_replication 监控复制延迟

sql
SELECT
    application_name,
    client_addr,
    state,          -- 'startup'/'catchup'/'streaming'/'backup'/'stopping'
    sent_lsn,       -- 主库已发送到此 LSN
    write_lsn,      -- 备库已写入 OS 缓冲
    flush_lsn,      -- 备库已落盘
    replay_lsn,     -- 备库已应用
    write_lag,
    flush_lag,
    replay_lag,     -- 最直观指标: 超 30s 需排查备库瓶颈
    sync_state      -- 'async'/'potential'/'sync'/'quorum'
FROM pg_stat_replication;

主从流复制与故障切换架构图:


14.5 逻辑复制

14.5.1 原理与适用场景

逻辑复制基于 WAL 解码, 以行级变更(INSERT/UPDATE/DELETE)而非物理 WAL 块进行数据同步。核心优势是可以跨 PostgreSQL 大版本、跨操作系统平台, 选择性复制部分表

对比维度物理流复制逻辑复制
复制粒度整个实例指定表或发布集合
跨版本不支持支持(PG 10+)
目标端可写否(只读)
复制 DDL是(隐含在 WAL)
复制序列
延迟毫秒级毫秒~秒级
典型用途HA 主备、读扩展大版本升级、数据集成

14.5.2 配置语法

发布端(源库):

sql
-- wal_level 必须为 logical(需重启)
-- ALTER SYSTEM SET wal_level = logical;

-- 对指定表创建发布
CREATE PUBLICATION my_pub FOR TABLE orders, order_items;

-- 发布整个 schema(PG 15+)
CREATE PUBLICATION my_pub FOR TABLES IN SCHEMA public;

-- 只发布特定操作
CREATE PUBLICATION insert_only_pub FOR TABLE logs WITH (publish = 'insert');

订阅端(目标库):

sql
-- 目标库需预先存在同名表结构(逻辑复制不自动建表)
CREATE SUBSCRIPTION my_sub
  CONNECTION 'host=10.0.1.10 port=5432 dbname=mydb user=replicator password=secret'
  PUBLICATION my_pub;

-- 查看订阅状态
SELECT * FROM pg_stat_subscription;

-- 向发布追加表 / 暂停 / 删除
ALTER PUBLICATION my_pub ADD TABLE new_table;
ALTER SUBSCRIPTION my_sub DISABLE;
DROP SUBSCRIPTION my_sub;  -- 同时删除远端复制槽

14.5.3 大版本升级中的逻辑复制方案

利用逻辑复制可实现停机时间极短的大版本升级(通常仅需分钟级切换):

  1. 旧版本主库创建 PUBLICATION
  2. pg_dump --schema-only 在新版本实例建好同名表结构
  3. 新版本实例创建 SUBSCRIPTION, 等待全量数据同步
  4. 确认 pg_stat_subscription.latest_end_lsn 接近主库 pg_current_wal_lsn()
  5. 停止应用流量, 等待最后一批变更同步
  6. 切换应用连接字符串至新版本, 恢复流量

整个停机窗口(步骤 5-6)通常在 1 分钟内完成。

注意事项

逻辑复制不同步序列值, 切换后需手动调整各序列起始值防止主键冲突:

sql
SELECT setval('orders_id_seq', (SELECT MAX(id) FROM orders) + 10000);

14.6 高可用方案概述

14.6.1 Patroni

Patroni 是目前生产环境最广泛使用的 PostgreSQL 高可用框架, 架构由三层组成:

  • 分布式配置存储(DCS): 通常使用 etcd(也支持 Consul、ZooKeeper)。etcd 提供强一致性分布式锁用于选主仲裁, 防止脑裂。
  • Patroni Agent: 每台节点运行一个 Patroni 进程, 负责健康检测、执行选主命令、管理 PostgreSQL 实例的启停与角色切换。
  • 集群状态机: Patroni 持续向 etcd 续约(默认 10 秒 TTL), 若主库未能续约, etcd leader key 过期, 备库竞争获取锁并发起选主。

故障切换流程:

  1. 主库节点故障, 停止向 etcd 续约
  2. etcd leader key 在 TTL 到期后消失
  3. 所有备库 Patroni 竞争写入新的 leader key
  4. 胜出的备库执行 pg_ctl promote 提升为新主库
  5. 其余备库通过 pg_rewind 回滚分叉 WAL 后重新连接新主库
  6. Patroni 触发 VIP 漂移或通知 PgBouncer 更新后端地址

14.6.2 repmgr

repmgr 是另一个主流 HA 工具, 设计更轻量, 不依赖外部 DCS, 通过 repmgrd 守护进程监控集群状态, 使用 PostgreSQL 自身连接作为心跳。适合中小规模部署, 但在网络分区场景下脑裂风险高于 Patroni + etcd 方案。

14.6.3 脑裂风险与仲裁

脑裂(Split Brain)是 HA 场景最危险的问题: 两个节点同时认为自己是主库并接受写入, 导致数据不可合并的分叉。防护措施:

  • 使用奇数节点 etcd 集群(3 或 5 节点): 少数派无法写入, 自然防止脑裂
  • STONITH: 仲裁节点判定旧主库为僵尸时, 主动向其发送关机指令(IPMI/SSH)
  • 同步复制 + promote 前校验: Patroni 默认要求候选主库的 replay_lsn 不低于最后已知的 flush_lsn

14.6.4 VIP 漂移与 PgBouncer 集成

生产环境通常组合使用虚拟 IP 和连接池实现应用层透明切换:

应用 → PgBouncer(固定地址) → VIP(漂移到当前主库) → PostgreSQL 主库

Patroni 支持通过 callbacks 钩子在角色切换时执行自定义脚本:

yaml
# patroni.yml
callbacks:
  on_role_change: /etc/patroni/callbacks/update_vip.sh

update_vip.sh 执行 ip addr add/del 实现 VIP 漂移, 并重载 PgBouncer:

bash
psql -p 6432 -U pgbouncer pgbouncer -c "RELOAD;"

14.7 连接池 PgBouncer

14.7.1 为何必须使用连接池

PostgreSQL 采用每连接一个 OS 进程的架构(fork-per-connection)。每个连接消耗约 5~10 MB 内存, 并发连接达到数百时进程调度开销显著增大, max_connections 超限后新连接直接报错。

典型 Web 应用: 100 个应用实例 × 每池 10 连接 = 1000 并发连接, 远超 PostgreSQL 的最优承受范围(通常 100~300)。PgBouncer 在应用侧维护大量长连接, 在数据库侧维护少量真实连接, 实现连接复用。

14.7.2 三种池化模式对比

模式连接复用时机适用场景主要限制
session客户端断开时归还长连接、依赖会话级特性复用效率最低
transaction事务结束时归还最常用, OLTP 标准选择不支持 SET 保留、Advisory Lock、LISTEN/NOTIFY
statement每条语句后归还极高并发纯查询不支持多语句事务, 实用性极低

transaction 模式不可用的操作:

sql
SET search_path = myschema;         -- 不会持久到下一条语句
LISTEN my_channel;                  -- 不支持
SELECT pg_advisory_lock(1234);      -- 连接归还后锁丢失
PREPARE my_stmt AS SELECT ...;      -- Prepared Statement 跨连接失效

如果应用依赖上述特性, 应使用 session 模式或在应用层显式处理。

14.7.3 PgBouncer 基础配置示例

ini
; pgbouncer.ini
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
listen_port = 6432
listen_addr = 0.0.0.0
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt

pool_mode = transaction
max_client_conn = 1000       ; 应用侧最大连接数
default_pool_size = 20       ; 每个 database/user 对的后端连接数
reserve_pool_size = 5
server_idle_timeout = 600
sql
-- 通过管理接口查看运行状态
-- psql -p 6432 -U pgbouncer pgbouncer
SHOW POOLS;    -- 各连接池使用情况
SHOW STATS;    -- QPS、延迟统计
SHOW CLIENTS;  -- 当前客户端连接

14.8 版本升级策略

14.8.1 小版本升级(安全补丁)

小版本(如 16.1 → 16.4)仅修复 Bug 和安全漏洞, 不修改数据目录格式, 升级极为简单:

bash
# 1. 升级二进制(数据目录不动)
apt upgrade postgresql-16

# 2. 重启加载新二进制
systemctl restart postgresql-16

# 3. 验证版本
psql -c "SELECT version();"

整个过程通常在 30 秒内完成。建议在每个小版本发布后尽快跟进, 因为发布说明往往披露已修复的安全漏洞。

14.8.2 大版本升级方案对比

大版本升级(如 PG 15 → PG 16)涉及数据目录格式变更, 有两种主流方案:

方案一: pg_upgrade

bash
# --check 模式先做预检
pg_upgrade \
  -b /usr/lib/postgresql/15/bin \
  -B /usr/lib/postgresql/16/bin \
  -d /var/lib/postgresql/15/main \
  -D /var/lib/postgresql/16/main \
  --check

# 预检通过后执行升级
pg_upgrade \
  -b /usr/lib/postgresql/15/bin \
  -B /usr/lib/postgresql/16/bin \
  -d /var/lib/postgresql/15/main \
  -D /var/lib/postgresql/16/main \
  --jobs 4 \    # 并行加速
  --link        # 硬链接模式(几乎零拷贝)

方案二: 逻辑复制滚动升级(参见 14.5.3)

对比维度pg_upgrade逻辑复制升级
停机时间分钟~数小时(取决于库大小)通常 < 1 分钟
实施复杂度低(单命令)高(需手动同步表结构、序列)
回滚难度较难(--link 后旧目录已修改)易(切换连接字符串即可)
适用库大小中小型库大型库或对停机敏感场景

升级后务必重建统计信息(pg_upgrade 不迁移统计信息):

bash
vacuumdb --all --analyze-in-stages -j 4

PITR 时间线图:


本章小结

  • 备份: pg_basebackup 配合 WAL 归档是 PITR 基础; pg_dump 适合逻辑导出与跨平台迁移
  • 流复制: 毫秒级延迟; synchronous_commit = remote_apply 提供最强数据安全保证; 复制槽需监控防止 WAL 堆积
  • 逻辑复制: 跨大版本升级和选择性同步的利器; DDL 和序列不自动复制
  • 高可用: Patroni + etcd 是生产首选; 任何 HA 方案都需定期演练故障切换
  • PgBouncer: 大规模并发场景的必备组件; transaction 模式覆盖 90% 的场景
  • 升级: 小版本直接替换二进制; 大版本用 pg_upgrade 或逻辑复制方案, 根据停机容忍度选择

14.4.2 主库配置

ini
# postgresql.conf
wal_level = replica          # 至少 replica, 逻辑复制需要 logical
max_wal_senders = 10         # 最多允许几个备库同时连接
wal_keep_size = 1GB          # 保留多少 WAL 供备库追赶(不用复制槽时)
hot_standby = on             # 备库允许只读查询
text
# pg_hba.conf(主库)
host  replication  replicator  192.168.1.11/32  scram-sha-256
host  replication  replicator  192.168.1.12/32  scram-sha-256

14.4.3 备库初始化

bash
# 用 pg_basebackup -R 自动生成备库配置
pg_basebackup -h 192.168.1.10 -U replicator -R \
  -D /var/lib/postgresql/data -P -c spread

# 启动备库
pg_ctl start -D /var/lib/postgresql/data

备库启动后自动进入恢复模式, standby.signal 文件存在时保持备库状态持续接收主库 WAL。

14.4.4 同步 vs 异步复制

异步复制(默认):

ini
synchronous_commit = off   # 或 local

主库 COMMIT 立即返回, 不等待备库确认。吞吐量高, 但故障切换时最多丢失几十毫秒的 WAL 数据(RPO > 0)。

同步复制:

ini
# 主库 postgresql.conf
synchronous_standby_names = 'standby1'  # 或 'ANY 1 (standby1, standby2)'
synchronous_commit = remote_apply       # 等待备库回放完成才返回

同步复制保证 COMMIT 成功后数据一定在备库落盘并回放(RPO = 0), 但每次写入延迟增加一个网络 RTT。remote_apply 是最强一致性级别; on 等待备库刷盘; remote_write 只等待写入备库 OS 缓冲。

生产建议

对于金融、订单等核心库, 配置至少一个同步备库保证 RPO = 0。对于日志、统计等库, 异步复制即可。可配置 ANY 1 (standby1, standby2) 表示两个备库中任意一个确认即可, 兼顾安全性和可用性。

14.4.5 复制槽

复制槽(Replication Slot)防止主库在备库追上之前清除 WAL, 是持久化追踪备库消费进度的机制。

sql
-- 创建物理复制槽
SELECT pg_create_physical_replication_slot('standby1_slot');

-- 备库配置中引用复制槽
-- primary_slot_name = 'standby1_slot'  (写入 postgresql.auto.conf)

-- 查看复制槽状态
SELECT slot_name, active, restart_lsn,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS lag
FROM pg_replication_slots;

复制槽的风险

如果备库长时间下线或严重落后, 复制槽会阻止主库清理 WAL, 导致**pg_wal 目录无限增长直至磁盘写满**, 影响主库正常运行。必须监控 pg_replication_slots 中的 lag 字段。长期不活跃的复制槽应及时删除:

sql
SELECT pg_drop_replication_slot('standby1_slot');

14.4.6 监控复制延迟

sql
-- 在主库上查看复制状态
SELECT
    application_name,
    state,
    sent_lsn,
    write_lsn,
    flush_lsn,
    replay_lsn,
    write_lag,
    flush_lag,
    replay_lag,
    sync_state
FROM pg_stat_replication;
字段含义
sent_lsn主库已发送到备库的 WAL 位置
flush_lsn备库已刷盘的 WAL 位置
replay_lsn备库已回放的 WAL 位置
replay_lag主库 COMMIT 到备库完成回放的时间延迟
sync_stateasync / sync / quorum

14.5 逻辑复制

14.5.1 物理复制 vs 逻辑复制

维度物理复制逻辑复制
传输内容WAL 字节流(底层页变更)逻辑行变更(INSERT/UPDATE/DELETE)
跨大版本不支持支持
选择性复制不支持(整实例)支持(表级)
目标库可写不可写(只读备库)可写
复制 DDL自动不支持, 需手动同步
复制序列自动不支持
wal_level 要求replicalogical

14.5.2 配置逻辑复制

主库需设置 wal_level = logical, 然后创建发布(Publication):

sql
-- 主库: 创建发布, 包含指定表
CREATE PUBLICATION my_pub FOR TABLE orders, order_items;

-- 发布整个数据库所有表
CREATE PUBLICATION full_pub FOR ALL TABLES;

-- 查看发布
SELECT * FROM pg_publication;
SELECT * FROM pg_publication_tables;

备库(订阅端)创建订阅(Subscription):

sql
-- 备库: 创建订阅
CREATE SUBSCRIPTION my_sub
  CONNECTION 'host=192.168.1.10 dbname=mydb user=replicator password=xxx'
  PUBLICATION my_pub;

-- 查看订阅状态
SELECT * FROM pg_stat_subscription;

-- 暂停/恢复订阅
ALTER SUBSCRIPTION my_sub DISABLE;
ALTER SUBSCRIPTION my_sub ENABLE;

订阅创建后, PostgreSQL 自动将主库的现有数据复制到订阅端(初始同步), 之后持续同步增量变更。

14.5.3 典型用途

跨大版本零停机升级是逻辑复制最重要的用途:

text
1. 在旧版本主库(v16)创建 PUBLICATION
2. 在新版本实例(v17)创建 SUBSCRIPTION, 等待初始同步完成
3. 监控 pg_stat_subscription 中的 received_lsn 追上主库
4. 将应用流量切换到新版本实例
5. 删除 SUBSCRIPTION, 完成升级

选择性数据同步:

sql
-- 只把订单表同步到分析库(不影响分析库其他表)
CREATE PUBLICATION orders_pub FOR TABLE orders, order_items;

14.5.4 逻辑复制的限制

使用前必知

  • 不复制 DDL: 在主库执行 ALTER TABLE 后, 必须手动在订阅端执行相同 DDL, 否则复制会中断。
  • 不复制序列: 序列当前值不同步, 切换主库后需要手动调整序列值。
  • 不复制 TRUNCATE(除非显式配置): TRUNCATE 默认不通过逻辑复制传播。
  • 冲突处理: 如果订阅端有独立写入, 可能产生主键冲突, 需要手动解决或配置冲突策略。

14.6 高可用架构

14.6.1 高可用的核心问题

单纯的流复制提供了数据冗余, 但不提供自动故障切换。当主库宕机时, 需要人工判断、提升备库、切换连接——这个过程可能需要数分钟甚至更长, 期间服务中断。高可用方案要解决的正是这个问题: 将故障切换时间压缩到秒级, 并保证切换过程的安全性。

14.6.2 Patroni: 工业级高可用方案

Patroni 是目前生产环境最主流的 PostgreSQL 高可用框架。它的核心设计:

  • etcd / Consul / ZooKeeper 等分布式一致性存储作为仲裁(DCS, Distributed Configuration Store), 保证在网络分区时只有多数派能选出主库
  • 每个节点运行 Patroni 守护进程, 定期向 DCS 更新心跳和状态
  • 主库故障时, Patroni 自动选举延迟最小的备库提升为新主, 通常在 30 秒内完成
yaml
# patroni.yml 关键配置片段
scope: postgres-cluster
name: node1

etcd3:
  hosts: etcd1:2379,etcd2:2379,etcd3:2379

postgresql:
  listen: 0.0.0.0:5432
  connect_address: 192.168.1.10:5432
  data_dir: /var/lib/postgresql/data
  parameters:
    wal_level: replica
    max_wal_senders: 10
    synchronous_commit: "on"

bootstrap:
  dcs:
    ttl: 30
    loop_wait: 10
    retry_timeout: 10
    maximum_lag_on_failover: 1048576  # 备库最大允许落后 1MB WAL 才参与选举

完整故障切换流程:

14.6.3 脑裂(Split-Brain)与仲裁

脑裂是高可用场景中最危险的问题: 网络分区导致两个节点都认为自己是主库并接受写入, 产生数据冲突且难以合并。

Patroni 通过 DCS 多数派仲裁规避脑裂:

  • DCS 必须是奇数节点(3 或 5), 保证任何网络分区下只有一个多数派
  • Patroni 只有在持有 DCS Leader Key(需定期续约)的情况下才能作为主库运行
  • 主库失去 DCS 连接超过 ttl 时间后, 主动停止接受写入(自我 STONITH), 等待新主产生
text
节点数  最多容忍故障节点数
  3           1
  5           2
  7           3

14.6.4 VIP 与连接路由

应用程序应通过虚拟 IP(VIP) 连接数据库, 而非直接连接某台物理机。故障切换时 VIP 由 Patroni/Keepalived 飘移到新主, 应用无感知。

PgBouncer 作为连接池和路由层:

  • 维护少量到数据库的真实连接(通常几十个)
  • 对应用暴露大量"虚拟连接"(可达数千)
  • 故障切换后 PgBouncer 重新连接到新 VIP, 对应用透明
ini
# pgbouncer.ini 关键配置
[databases]
mydb = host=vip.example.com port=5432 dbname=mydb

[pgbouncer]
pool_mode = transaction         # 事务模式(推荐)
max_client_conn = 2000          # 最大客户端连接
default_pool_size = 50          # 每个数据库的连接池大小
server_idle_timeout = 600

14.6.5 repmgr: 轻量级替代

repmgr 是比 Patroni 更轻量的复制管理工具, 不依赖外部 DCS, 通过守护进程(repmgrd)轮询主库状态。配置简单, 但在复杂网络分区场景下的脑裂防护不如 Patroni 严格。适合团队没有运维 etcd 集群能力的中小规模场景。

bash
# 注册主库
repmgr -h localhost -U repmgr -d repmgr primary register

# 注册备库
repmgr -h primary -U repmgr -d repmgr standby clone --upstream-node-id=1
repmgr -h localhost -U repmgr -d repmgr standby register

# 查看集群状态
repmgr cluster show

14.7 连接池(PgBouncer)

14.7.1 为什么需要连接池

PostgreSQL 的每个连接对应服务端一个独立 OS 进程, 内存开销约 5-10MB。1000 个并发连接就需要 5-10GB 内存, 且大量进程的上下文切换会严重降低 CPU 效率。PgBouncer 通过连接复用解决这个问题: 应用保持 1000 个到 PgBouncer 的"连接", 而 PgBouncer 只维持少量到 PostgreSQL 的真实连接。

14.7.2 池化模式

模式连接归还时机推荐度适用场景
session客户端断开时兼容性要求高, 需要会话级特性
transaction事务提交/回滚时绝大多数 Web 应用
statement每条语句执行后慎用不支持多语句事务

transaction 模式是生产环境首选, 连接复用率最高。

14.7.3 transaction 模式的限制

sql
-- 以下操作在 transaction 模式下不可靠或不支持

-- 1. 命名 Prepared Statement 跨事务持久化
PREPARE my_plan AS SELECT * FROM orders WHERE id = $1;  -- 不推荐

-- 2. SET 修改会话参数(事务结束归还连接后丢失)
SET search_path = myschema;  -- 不可靠

-- 3. Advisory Lock(会话级锁在事务结束后无法持有)
SELECT pg_advisory_lock(123);  -- 不可靠

应对 transaction 模式限制

  • SET LOCAL(事务内有效)替代 SET(会话内有效)
  • ORM 框架(如 Hibernate)的命名 Prepared Statement 需要禁用或改用服务端准备模式
  • 需要会话级 Advisory Lock 的场景改用 session 模式或直连数据库

14.8 版本升级

14.8.1 小版本升级(18.1 → 18.2)

小版本升级只修复 Bug 和安全漏洞, 不改变数据文件格式。升级过程:

bash
# 停止 PostgreSQL
pg_ctl stop -D /var/lib/postgresql/data

# 替换二进制文件(包管理器方式)
apt-get install --only-upgrade postgresql-18

# 直接启动, 无需任何数据迁移
pg_ctl start -D /var/lib/postgresql/data

整个过程通常在 1-2 分钟内完成, 风险极低。

14.8.2 大版本升级(17 → 18)

大版本升级涉及数据文件格式变化, 有两种主流方案:

方案一: pg_upgrade 原地升级(需要停机)

bash
# 安装新版本(与旧版本并存)
apt-get install postgresql-18

# 初始化新版本数据目录
/usr/lib/postgresql/18/bin/initdb -D /var/lib/postgresql/18/data

# 停止旧版本
pg_ctl stop -D /var/lib/postgresql/17/data

# 执行升级检查
/usr/lib/postgresql/18/bin/pg_upgrade \
  -b /usr/lib/postgresql/17/bin \
  -B /usr/lib/postgresql/18/bin \
  -d /var/lib/postgresql/17/data \
  -D /var/lib/postgresql/18/data \
  --check   # 只检查, 不实际升级

# 执行实际升级
/usr/lib/postgresql/18/bin/pg_upgrade \
  -b /usr/lib/postgresql/17/bin \
  -B /usr/lib/postgresql/18/bin \
  -d /var/lib/postgresql/17/data \
  -D /var/lib/postgresql/18/data \
  -j 8      # 并行处理

# 启动新版本
pg_ctl start -D /var/lib/postgresql/18/data

pg_upgrade 默认使用硬链接(--link 选项)避免数据拷贝, 大库也能在分钟级完成。

方案二: 逻辑复制升级(零停机)

text
1. 在 v17 主库创建 PUBLICATION(设置 wal_level = logical)
2. 搭建 v18 新实例, 创建 SUBSCRIPTION 指向 v17 主库
3. 等待初始数据同步完成, 监控 pg_stat_subscription
4. 手动同步 DDL 变更(序列、视图、存储过程等逻辑复制不传播的对象)
5. 在低峰期: 暂停写入 → 确认备库追上(lag = 0) → 切换应用连接到 v18
6. 删除 v17 上的 PUBLICATION, 下线旧实例

方案选择

  • 停机窗口可接受: pg_upgrade --link 是最快最安全的选择, 大多数场景停机时间在 5-15 分钟。
  • 必须零停机: 逻辑复制升级, 但需要处理逻辑复制不支持的对象(DDL、序列), 操作复杂度显著更高。

14.9 本章小结

本章从三个维度系统梳理了 PostgreSQL 的数据保护体系:

数据保护层:

  • 逻辑备份(pg_dump/pg_dumpall/pg_restore): 灵活、可移植, 适合日常备份和对象级恢复
  • 物理备份(pg_basebackup): 快速、完整, 是流复制和 PITR 的基础
  • PITR: 基础备份 + WAL 归档 = 任意时间点恢复能力, 是应对误操作的最后防线

数据同步层:

  • 流复制(物理): 低延迟、自动、整实例级同步, 是高可用的基础
  • 逻辑复制: 跨版本、跨架构、表级选择性同步, 是零停机升级的利器

服务可用层:

  • Patroni + etcd: 自动故障切换, 通过多数派仲裁防止脑裂
  • VIP + PgBouncer: 对应用屏蔽拓扑变化, 提供连接复用和路由层

三层能力叠加, 才能真正做到数据不丢(RPO → 0)、服务不停(RTO → 秒级)。

最佳实践清单

  • 每日 pg_basebackup, 永久 WAL 归档, 定期演练 PITR 恢复
  • 流复制至少一个同步备库, 监控 pg_stat_replication 中的 replay_lag
  • 复制槽必须监控 lag, 设置磁盘告警阈值
  • 生产环境使用 Patroni + etcd(3节点) 自动故障切换
  • 应用通过 PgBouncer(transaction 模式)连接, 不直连 PostgreSQL
  • 大版本升级: 优先 pg_upgrade --link, 零停机需求用逻辑复制方案

十五、安全、权限与生产运维

数据库的价值在于数据本身, 而保护数据的第一道防线是正确的权限设计, 第二道是严密的认证配置, 第三道是持续的监控与运维。许多线上安全事故的根本原因不是 PostgreSQL 本身有漏洞, 而是权限过于宽松、认证形同虚设、缺乏对异常的感知。本章从权限模型讲起, 覆盖行级安全、认证配置、生产参数调优、监控体系, 最后给出一份上线前的安全 Checklist。


15.1 角色与权限系统

15.1.1 角色即用户、角色即组

PostgreSQL 没有独立的"用户"概念——CREATE USER 只是 CREATE ROLE ... LOGIN 的语法糖。角色是权限系统的唯一主体, 既可以作为登录账号使用, 也可以作为权限集合(组)被其他角色继承。

sql
-- 创建可登录的应用账号
CREATE ROLE app_user LOGIN PASSWORD 'StrongP@ssw0rd!';

-- 创建不可登录的权限集合(组角色)
CREATE ROLE readonly NOLOGIN;

-- 将 readonly 组授予 app_user
-- app_user 将继承 readonly 的所有权限
GRANT readonly TO app_user;

角色成员关系构成一棵有向无环图。SET ROLE readonly 可让当前会话切换到该角色的权限视角; RESET ROLE 恢复原始身份。

15.1.2 重要角色属性

属性含义典型用途
LOGIN允许作为数据库用户登录所有需要连接数据库的账号
NOLOGIN不能直接登录, 仅用于权限聚合组角色, 如 readonlyrw_business
SUPERUSER绕过所有权限检查(含 RLS)仅 DBA 紧急操作账号
CREATEDB允许创建新数据库少数需要自建库的工具账号
CREATEROLE允许创建/修改角色账号管理服务
REPLICATION允许发起流复制连接主备复制专用账号
BYPASSRLS绕过行级安全策略内部数据导出脚本(需谨慎)

创建角色时尽量只赋予最少属性:

sql
-- 错误示范: 应用账号不应有 SUPERUSER
CREATE ROLE bad_app SUPERUSER LOGIN PASSWORD '...';

-- 正确做法: 普通登录账号, 无任何高危属性
CREATE ROLE good_app LOGIN PASSWORD 'Secur3!Pass' CONNECTION LIMIT 50;

15.1.3 对象级权限

PostgreSQL 的权限体系按对象层级划分: 数据库 → Schema → 表/视图/函数 → 列。访问一张表, 角色必须在每一层都有对应权限。

sql
-- 授予 Schema 的使用权(允许"进入"该 Schema)
GRANT USAGE ON SCHEMA business TO readonly;

-- 授予具体表的查询权
GRANT SELECT ON business.orders TO readonly;
GRANT SELECT ON business.products TO readonly;

-- 批量授予 Schema 内所有表的权限
GRANT SELECT ON ALL TABLES IN SCHEMA business TO readonly;

-- 读写账号额外授予 DML 权限
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA business TO app_user;

-- 如果表里有自增序列, 还需要序列权限
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA business TO app_user;

常用权限类型及含义:

权限适用对象含义
SELECT表、视图、序列读取数据
INSERT插入新行
UPDATE表、列更新已有行
DELETE删除行
TRUNCATE清空整张表(危险!)
REFERENCES表、列创建外键引用
TRIGGER在表上创建触发器
CREATESchema、数据库在其中创建对象
USAGESchema、序列、语言"进入"Schema 或使用序列
EXECUTE函数、存储过程调用函数
ALL PRIVILEGES任意上述所有权限的简写

注意 TRUNCATE 权限

TRUNCATE 不在 SELECT/INSERT/UPDATE/DELETE 的覆盖范围内, 需要单独授予。应用账号几乎不应拥有 TRUNCATE 权限——误操作会瞬间清空整张表且难以恢复。

15.1.4 PUBLIC 伪角色的危险

PUBLIC 是一个特殊的伪角色, 所有角色默认都是 PUBLIC 的成员。PostgreSQL 在初始化时会给 public Schema 授予 PUBLIC 一些默认权限, 历史上这造成了严重的安全隐患:

  • PG 14 及更早: public Schema 默认给 PUBLIC 授予 USAGECREATE 权限, 意味着任何登录用户都能在 public Schema 下建表。
  • PG 15+: 默认只给 PUBLIC 授予 USAGE, 不再授予 CREATE

无论使用哪个版本, 生产环境都应主动撤销这些默认权限:

sql
-- 撤销 public schema 对所有人的默认权限
REVOKE ALL ON SCHEMA public FROM PUBLIC;
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM PUBLIC;

-- 同时撤销新建对象的默认权限(历史存量)
REVOKE CREATE ON SCHEMA public FROM PUBLIC;

不要忽视 PUBLIC 权限

一个看似"只读"的低权限账号, 如果 public schema 对 PUBLIC 有 CREATE 权限, 它实际上可以在 public schema 里建表、建函数——甚至可以通过替换函数来提权。这是历史上多起 PostgreSQL 提权漏洞的根源。

15.1.5 ALTER DEFAULT PRIVILEGES

GRANT ... ON ALL TABLES 只作用于已存在的对象。如果之后又创建了新表, 需要再次执行 GRANT。ALTER DEFAULT PRIVILEGES 解决了这个问题——它为未来将要创建的对象提前设置权限。

sql
-- 以后 migrator 角色在 business schema 里创建的所有表,
-- 都自动给 readonly 角色 SELECT 权限
ALTER DEFAULT PRIVILEGES FOR ROLE migrator
  IN SCHEMA business
  GRANT SELECT ON TABLES TO readonly;

-- 同理, 自动给 app_user 完整 DML 权限
ALTER DEFAULT PRIVILEGES FOR ROLE migrator
  IN SCHEMA business
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;

-- 序列也要单独处理
ALTER DEFAULT PRIVILEGES FOR ROLE migrator
  IN SCHEMA business
  GRANT USAGE, SELECT ON SEQUENCES TO app_user;

谁创建对象很重要

ALTER DEFAULT PRIVILEGES FOR ROLE migrator 中的 FOR ROLE 指的是执行建表操作的角色, 不是当前会话用户。如果表由 migrator 创建, 就需要用 FOR ROLE migrator 来设置默认权限; 换一个角色建表, 则需要另外设置。


15.2 最小权限实践

"最小权限原则"(Principle of Least Privilege)是数据库安全设计的核心: 每个账号只拥有完成其工作所必需的最少权限。下面给出一套完整的三角色权限方案。

15.2.1 完整建权 SQL

sql
-- ============================================================
-- 第一步: 创建数据库和 Schema
-- ============================================================
CREATE DATABASE myapp;
\c myapp

CREATE SCHEMA business;

-- ============================================================
-- 第二步: 创建三个功能角色
-- ============================================================

-- 应用账号: 业务 DML, 无 DDL
CREATE ROLE app_user
  LOGIN
  PASSWORD 'AppUser_Str0ng!'
  CONNECTION LIMIT 100;

-- 只读账号: 适合 BI 工具、数据分析师接入
CREATE ROLE readonly_user
  LOGIN
  PASSWORD 'Readonly_Str0ng!'
  CONNECTION LIMIT 20;

-- 迁移账号: 负责 DDL 变更, 迁移完成后禁用
CREATE ROLE migrator
  LOGIN
  PASSWORD 'Migrator_Str0ng!'
  CREATEDB
  CONNECTION LIMIT 5;

-- ============================================================
-- 第三步: 撤销危险的默认权限
-- ============================================================
REVOKE ALL ON SCHEMA public FROM PUBLIC;
REVOKE ALL ON DATABASE myapp FROM PUBLIC;

-- ============================================================
-- 第四步: 授予 Schema 级权限
-- ============================================================

-- app_user 可以进入 business schema
GRANT USAGE ON SCHEMA business TO app_user;

-- readonly_user 只能进入, 不能建对象
GRANT USAGE ON SCHEMA business TO readonly_user;

-- migrator 可以在 business schema 建对象
GRANT USAGE, CREATE ON SCHEMA business TO migrator;

-- ============================================================
-- 第五步: 授予表级权限(针对已有表)
-- ============================================================
GRANT SELECT, INSERT, UPDATE, DELETE
  ON ALL TABLES IN SCHEMA business TO app_user;

GRANT USAGE, SELECT
  ON ALL SEQUENCES IN SCHEMA business TO app_user;

GRANT SELECT
  ON ALL TABLES IN SCHEMA business TO readonly_user;

-- ============================================================
-- 第六步: 设置默认权限(针对未来新建的表)
-- ============================================================
ALTER DEFAULT PRIVILEGES FOR ROLE migrator
  IN SCHEMA business
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;

ALTER DEFAULT PRIVILEGES FOR ROLE migrator
  IN SCHEMA business
  GRANT USAGE, SELECT ON SEQUENCES TO app_user;

ALTER DEFAULT PRIVILEGES FOR ROLE migrator
  IN SCHEMA business
  GRANT SELECT ON TABLES TO readonly_user;

-- ============================================================
-- 第七步: migrator 迁移完成后禁用登录
-- ============================================================
-- ALTER ROLE migrator NOLOGIN;
-- 需要再次迁移时: ALTER ROLE migrator LOGIN;

15.2.2 三账号职责对比

账号USAGE on SchemaSELECTINSERT/UPDATE/DELETEDDL(CREATE/DROP)TRUNCATE
app_user
readonly_user
migrator是(+CREATE)

为什么 migrator 不需要 SUPERUSER

迁移工具(Flyway、Liquibase、golang-migrate 等)只需要在特定 Schema 内执行 DDL, 给它 CREATE ON SCHEMA 和对应表的权限就足够了。赋予 SUPERUSER 意味着迁移脚本中的任何错误都能造成灾难性后果。


15.3 行级安全(RLS)

对象级权限粒度是"表", 但很多场景需要粒度细到"行": 多租户 SaaS 的每个租户只能看到自己的数据, 员工只能查看本人的工资单, 地区销售只能访问本地区订单。行级安全(Row Level Security, RLS)在 PostgreSQL 引擎层实现这一能力, 无需在应用层堆砌 WHERE 条件。

15.3.1 RLS 基本语法

sql
-- 第一步: 在表上启用 RLS
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- 第二步: 创建策略
CREATE POLICY policy_name
  ON table_name
  AS PERMISSIVE          -- 或 RESTRICTIVE
  FOR SELECT             -- 或 INSERT / UPDATE / DELETE / ALL
  TO role_name           -- 省略则对所有角色生效
  USING (condition)      -- 过滤已有行(SELECT/UPDATE/DELETE)
  WITH CHECK (condition); -- 检查新写入行(INSERT/UPDATE)
  • USING 子句: 决定哪些已有行对当前会话可见。相当于隐式追加到 WHERE 子句。
  • WITH CHECK 子句: 决定 INSERT/UPDATE 写入的行是否合法。写入的行不满足条件时报错。
  • PERMISSIVE: 多个此类策略之间是 OR 关系——满足任意一个即可访问。
  • RESTRICTIVE: 多个此类策略之间是 AND 关系——必须同时满足所有策略。

15.3.2 多租户实战

以 SaaS 订单系统为例, 每个租户有独立的 tenant_id, 策略通过会话变量传递当前租户身份:

sql
-- 订单表结构
CREATE TABLE orders (
  id         bigserial PRIMARY KEY,
  tenant_id  bigint NOT NULL,
  user_id    bigint NOT NULL,
  amount     numeric(12, 2) NOT NULL,
  created_at timestamptz DEFAULT now()
);

-- 为 tenant_id 建索引, RLS 策略过滤会走这个索引
CREATE INDEX ON orders(tenant_id);

-- 启用 RLS
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- 创建多租户隔离策略
-- current_setting('app.tenant_id') 从当前会话的 GUC 变量中读取租户 ID
CREATE POLICY tenant_isolation ON orders
  AS PERMISSIVE
  FOR ALL
  TO app_user
  USING (tenant_id = current_setting('app.tenant_id')::bigint)
  WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);

应用层在**每个连接(或每个事务开始时)**设置会话变量:

sql
-- 连接建立后, 立即设置当前租户 ID
SET app.tenant_id = '42';

-- 之后的所有查询自动带上 WHERE tenant_id = 42
SELECT * FROM orders;         -- 只返回 tenant_id = 42 的行
INSERT INTO orders (tenant_id, user_id, amount)
  VALUES (99, 1, 100.00);    -- 报错: 违反 WITH CHECK 条件

使用事务级变量更安全

SET app.tenant_id = '42' 是会话级设置, 连接池可能复用连接。更安全的做法是使用 SET LOCAL 在事务内设置, 事务结束后自动重置:

sql
BEGIN;
SET LOCAL app.tenant_id = '42';
-- ... 执行业务操作 ...
COMMIT;

15.3.3 RLS 的边界与注意事项

sql
-- 表属主默认绕过 RLS, 需要强制启用
ALTER TABLE orders FORCE ROW LEVEL SECURITY;

-- SUPERUSER 始终绕过 RLS, 无法通过策略限制
-- BYPASSRLS 属性的角色也绕过 RLS
-- 验证: 以 app_user 身份测试策略是否生效
SET ROLE app_user;
SET app.tenant_id = '42';
SELECT count(*) FROM orders; -- 应只返回 tenant_id=42 的行数
RESET ROLE;

策略叠加规则:

sql
-- 假设同一张表有两条 PERMISSIVE 策略:
-- 策略 A: USING(tenant_id = get_tenant())
-- 策略 B: USING(is_public = true)
-- 实际效果: WHERE tenant_id = get_tenant() OR is_public = true

-- 假设再加一条 RESTRICTIVE 策略:
-- 策略 C (RESTRICTIVE): USING(deleted_at IS NULL)
-- 实际效果: WHERE (tenant_id = get_tenant() OR is_public = true)
--              AND deleted_at IS NULL

RLS 性能考量

RLS 策略中的 USING 条件会被追加到每条查询的执行计划中。如果策略涉及的列没有索引, 全表扫描的代价会倍增。务必为 tenant_iduser_id 等过滤列创建索引, 并用 EXPLAIN ANALYZE 验证策略是否走索引。


15.4 认证与传输安全

15.4.1 pg_hba.conf 认证方法

pg_hba.conf(Host-Based Authentication)是 PostgreSQL 的连接认证规则文件。每条规则的格式为:

text
TYPE  DATABASE  USER  ADDRESS  METHOD  [OPTIONS]

规则从上到下逐行匹配, 第一条匹配的规则立即生效, 后续规则不再检查。

各认证方法对比:

方法安全性适用场景备注
trust极低绝对禁止用于生产无需密码, 任何人都能连接
reject明确拒绝特定来源用于黑名单
md5历史遗留系统MD5 有碰撞风险, 已不推荐
scram-sha-256生产推荐PG 10+ 支持, 密码不明文传输
peer高(本地)本地 Unix socket 连接OS 用户名必须与 PG 用户名匹配
cert极高高安全场景需客户端提供 SSL 证书, 配置复杂
ldap企业 AD/LDAP 环境委托外部目录服务认证
password明文密码, 禁止使用

推荐的生产 pg_hba.conf 配置示例:

ini
# TYPE  DATABASE        USER            ADDRESS                 METHOD

# 本地 Unix socket: 超级用户用 peer 认证(OS 用户匹配)
local   all             postgres                                peer
local   all             all                                     scram-sha-256

# IPv4 本机回环
host    all             all             127.0.0.1/32            scram-sha-256

# IPv6 本机回环
host    all             all             ::1/128                 scram-sha-256

# 应用服务器(特定 IP 段)必须使用 SSL + SCRAM
hostssl myapp           app_user        10.10.1.0/24            scram-sha-256

# 只读账号只允许从数据分析网段连接
hostssl myapp           readonly_user   10.10.2.0/24            scram-sha-256

# 明确拒绝所有其他连接
host    all             all             0.0.0.0/0               reject

永远不要在生产环境使用 trust 认证

trust 方法意味着任何能连接到指定地址的人都可以以任意用户身份(包括 postgres 超级用户)登录, 无需密码。即使只开放给 127.0.0.1, 服务器上任何一个进程都能成为 DBA。

15.4.2 SSL/TLS 配置

postgresql.conf 中启用 SSL:

ini
# 启用 SSL
ssl = on

# 证书和密钥文件(相对于 data 目录)
ssl_cert_file = 'server.crt'
ssl_key_file  = 'server.key'

# 可选: CA 证书(用于验证客户端证书)
ssl_ca_file = 'root.crt'

# 推荐: 禁用老旧协议
ssl_min_protocol_version = 'TLSv1.2'

生成自签名证书(开发/测试环境):

bash
# 生成私钥和自签名证书
openssl req -new -x509 -days 365 \
  -nodes -text \
  -out server.crt \
  -keyout server.key \
  -subj "/CN=mypostgres"

# 设置正确的文件权限(密钥文件必须是 600)
chmod 600 server.key
chown postgres:postgres server.key server.crt

客户端连接时指定 SSL 模式:

bash
# sslmode=require: 必须使用 SSL, 但不验证服务端证书(防窃听, 不防中间人)
psql "host=db.example.com dbname=myapp user=app_user sslmode=require"

# sslmode=verify-ca: 验证服务端证书由受信 CA 签发
psql "host=db.example.com dbname=myapp user=app_user \
      sslmode=verify-ca sslrootcert=root.crt"

# sslmode=verify-full: 验证证书 + 验证主机名(最安全)
psql "host=db.example.com dbname=myapp user=app_user \
      sslmode=verify-full sslrootcert=root.crt"

15.4.3 监听地址限制

ini
# postgresql.conf

# 开发环境: 只监听本地回环
listen_addresses = 'localhost'

# 生产环境: 只监听特定内网 IP, 不要用 '*'
listen_addresses = '10.10.0.5'

# 如需多个地址
listen_addresses = '10.10.0.5, 127.0.0.1'

防火墙是第一道墙

即使 listen_addresses 设置正确, 也应在网络层用防火墙限制 5432 端口的访问来源。数据库端口绝不应暴露在公网上。PostgreSQL 自身的认证机制是纵深防御的一部分, 而非唯一防线。

修改 pg_hba.conflisten_addresses 后需要重载配置:

bash
# 重载 pg_hba.conf(不需要重启)
pg_ctl reload -D /var/lib/postgresql/data
-- 或在 SQL 中执行
SELECT pg_reload_conf();

# 修改 listen_addresses 后需要完整重启
pg_ctl restart -D /var/lib/postgresql/data

15.5 生产参数调优

PostgreSQL 的默认配置非常保守, 适合在任何硬件上跑起来, 但绝不是生产环境的最优配置。下面按功能分组列出关键参数。

15.5.1 内存参数

参数默认值推荐值含义与注意事项
shared_buffers128 MB系统内存的 25%PostgreSQL 自己管理的数据页缓存池。8 GB 内存建议设 2 GB。超过 40% 通常收益递减, 因为 OS 页缓存也会缓存数据文件。
work_mem4 MB按公式计算单次排序或哈希操作可用的内存。公式: RAM * 0.25 / max_connections。设太大会导致高并发时内存溢出(每个连接可能同时进行多次排序)。
maintenance_work_mem64 MB256 MB ~ 1 GBVACUUMCREATE INDEXALTER TABLE ADD FOREIGN KEY 等维护操作的内存上限。可临时在 session 级调大再执行 REINDEX。
effective_cache_size4 GB系统可用内存的 75%不实际分配内存, 只是告诉查询优化器 OS 页缓存预计有多大, 影响是否选择 Index Scan。设置偏低会导致优化器过度偏向 Seq Scan。
huge_pagestryon(内存 > 32 GB 时)使用大页内存, 减少 TLB miss, 提升大内存场景性能。需提前配置 OS 大页。

15.5.2 连接参数

参数默认值推荐值含义与注意事项
max_connections100视实际需求每个连接消耗约 5~10 MB 内存。不要盲目增大, 配合连接池(PgBouncer)使用时保持 100~200 即可。
superuser_reserved_connections33~5保留给超级用户的连接数, 防止普通连接耗尽时 DBA 无法登录处理故障。
idle_in_transaction_session_timeout0(不限)60000(60 秒)超过该时间仍处于 idle in transaction 状态的连接会被强制断开, 防止长事务持锁。
statement_timeout0(不限)30000(30 秒)单条 SQL 超时自动终止, 防止失控查询拖垮数据库。可在应用层按需调整。

15.5.3 WAL 与检查点参数

参数默认值推荐值含义与注意事项
wal_levelreplicareplica生产至少用 replica 以支持流复制; 需要逻辑复制(CDC)时改为 logicalminimal 会丢失复制能力。
synchronous_commitononoffon 保证提交即持久化(WAL 落盘后才返回)。off 可提升写吞吐约 30%, 代价是实例崩溃时最多丢失几十毫秒的事务。金融/支付场景必须 on
max_wal_size1 GB1 ~ 4 GBWAL 段文件总大小上限。太小会导致频繁触发检查点, 加重 I/O 抖动。OLTP 高峰期建议 2~4 GB。
checkpoint_timeout5 min10 ~ 30 min两次检查点之间的最大时间间隔。拉长间隔减少检查点频率, 但崩溃恢复时间相应增加。
checkpoint_completion_target0.90.9将检查点 I/O 分散在下一个检查点间隔的 90% 时间内, 避免集中写入造成 I/O 尖峰。
wal_compressionoffon压缩 WAL 中的全页镜像, 通常可减少 40%~60% WAL 体积, CPU 开销极低。PG 15+ 支持更多压缩算法。

15.5.4 日志参数

参数默认值推荐值含义与注意事项
log_min_duration_statement-1(关闭)500 ~ 1000 ms记录执行时间超过阈值的 SQL, 是慢查询分析的基础数据。生产环境必须开启。
log_checkpointsoffon记录每次检查点的时间和 I/O 统计, 用于监控检查点是否过于频繁。
log_lock_waitsoffon等锁时间超过 deadlock_timeout(默认 1s)时记录日志, 帮助排查锁竞争。
log_autovacuum_min_duration250 ms0 ~ 250 ms记录耗时较长的 autovacuum 操作, 用于监控表膨胀和 autovacuum 效率。
log_connectionsoffon(审计需要)记录每次连接建立。高并发下日志量大, 按需开启。
log_line_prefix%m [%p] %t [%p]: [%l-1] user=%u,db=%d,app=%a,client=%h 日志行前缀, 包含时间戳、进程 ID、用户名、数据库名等, 便于日志分析工具解析。

完整的推荐配置片段:

ini
# postgresql.conf - 生产推荐配置(以 16 GB 内存服务器为例)

# 内存
shared_buffers                = 4GB
work_mem                      = 16MB
maintenance_work_mem          = 512MB
effective_cache_size          = 12GB
huge_pages                    = try

# 连接
max_connections               = 200
superuser_reserved_connections = 5
idle_in_transaction_session_timeout = 60000
statement_timeout             = 0          # 在应用层按需设置

# WAL
wal_level                     = replica
synchronous_commit            = on
max_wal_size                  = 2GB
checkpoint_timeout            = 15min
checkpoint_completion_target  = 0.9
wal_compression               = on

# 日志
log_min_duration_statement    = 500
log_checkpoints               = on
log_lock_waits                = on
log_autovacuum_min_duration   = 0
log_line_prefix               = '%t [%p]: [%l-1] user=%u,db=%d,app=%a,client=%h '

# 统计
track_io_timing               = on         # 开启 I/O 耗时统计(有极小 CPU 开销)
track_counts                  = on

15.6 监控指标与工具

"没有监控的数据库是盲目飞行。" PostgreSQL 内置了丰富的统计视图, 几乎所有你需要的指标都能从系统目录中查到。

15.6.1 核心统计视图

pg_stat_activity — 当前连接状态

sql
-- 查看所有活动连接
SELECT pid,
       usename,
       application_name,
       client_addr,
       state,
       wait_event_type,
       wait_event,
       now() - xact_start    AS xact_duration,
       now() - query_start   AS query_duration,
       left(query, 100)      AS query_snippet
FROM pg_stat_activity
WHERE pid <> pg_backend_pid()
ORDER BY xact_start NULLS LAST;

-- 找出长事务(超过 1 分钟未提交)
SELECT pid, usename, state,
       now() - xact_start AS duration,
       left(query, 100)   AS last_query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
  AND now() - xact_start > INTERVAL '1 minute'
ORDER BY xact_start;

state 字段的含义:

  • active: 正在执行 SQL。
  • idle: 连接空闲, 等待下一条命令。
  • idle in transaction: 事务已开启但没有执行任何 SQL——最危险的状态, 持有锁但不干活。
  • idle in transaction (aborted): 事务内出错后未 ROLLBACK。

pg_stat_database — 数据库级统计

sql
SELECT datname,
       numbackends                                          AS connections,
       xact_commit,
       xact_rollback,
       blks_hit,
       blks_read,
       round(blks_hit::numeric / NULLIF(blks_hit + blks_read, 0) * 100, 2)
                                                           AS cache_hit_ratio,
       deadlocks,
       temp_files,
       pg_size_pretty(temp_bytes)                          AS temp_size
FROM pg_stat_database
WHERE datname = current_database();

缓存命中率应保持在 99% 以上。如果持续低于 95%, 说明 shared_buffers 太小或者存在大量随机 I/O 的查询。

pg_stat_user_tables — 表级统计

sql
SELECT schemaname,
       relname,
       seq_scan,
       idx_scan,
       n_dead_tup,
       n_live_tup,
       round(n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0) * 100, 2)
                            AS bloat_ratio,
       last_autovacuum,
       last_autoanalyze
FROM pg_stat_user_tables
ORDER BY seq_scan DESC
LIMIT 20;
  • seq_scan 远大于 idx_scan: 说明该表上缺少适合当前查询的索引。
  • n_dead_tup 占比高(> 20%): 表膨胀严重, 需要检查 autovacuum 配置。
  • last_autovacuum 是 NULL 或时间很久: autovacuum 未正常运行。

pg_stat_user_indexes — 索引统计

sql
-- 找出从未被使用的索引(候选废弃)
SELECT schemaname,
       relname      AS table_name,
       indexrelname AS index_name,
       idx_scan,
       pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
  AND schemaname NOT IN ('pg_catalog', 'pg_toast')
ORDER BY pg_relation_size(indexrelid) DESC;

废弃索引的代价

idx_scan = 0 的索引不仅占用磁盘空间, 每次写操作都要维护它, 白白消耗 I/O 和 CPU。定期执行上述查询, 确认后删除多余索引。删除前先用 EXPLAIN 验证是否真的没有查询依赖它。

15.6.2 关键健康指标 Checklist

指标查询来源健康阈值告警阈值
总连接数 / max_connectionspg_stat_activity< 70%> 90%
活跃连接数(state='active')pg_stat_activity持续 > CPU 核数的 2 倍
idle in transaction 连接数pg_stat_activity0> 5
缓存命中率pg_stat_database> 99%< 95%
死锁数/分钟pg_stat_database0> 0
最久未提交事务年龄pg_stat_activity< 1 分钟> 5 分钟
表膨胀率(n_dead_tup占比)pg_stat_user_tables< 10%> 20%
复制延迟(主备架构)pg_stat_replication< 1 s> 30 s
临时文件大小pg_stat_database0> 1 GB/天

15.6.3 Prometheus + postgres_exporter

postgres_exporter 是最流行的 PostgreSQL 监控组件, 它将上述统计视图的数据暴露为 Prometheus 格式的指标。

bash
# 使用 Docker 运行 postgres_exporter
docker run -d \
  -e DATA_SOURCE_NAME="postgresql://monitor_user:password@localhost:5432/myapp?sslmode=require" \
  -p 9187:9187 \
  prometheuscommunity/postgres-exporter

postgres_exporter 创建专用的低权限监控账号:

sql
CREATE ROLE monitor_user LOGIN PASSWORD 'Monitor_Str0ng!';

-- 授予查看统计视图所需的权限
GRANT pg_monitor TO monitor_user;  -- PG 10+ 内置监控角色

-- PG 9.x 的替代方案
-- GRANT SELECT ON pg_stat_database TO monitor_user;
-- GRANT SELECT ON pg_stat_activity TO monitor_user;
-- GRANT SELECT ON pg_stat_user_tables TO monitor_user;

常用 Grafana Dashboard:

  • Dashboard ID 9628: PostgreSQL Database 综合监控(推荐首选)。
  • Dashboard ID 455: PostgreSQL Overview, 更简洁的版本。

两个 Dashboard 均可在 Grafana 官网直接导入, 覆盖连接数、缓存命中率、事务速率、锁等待、表膨胀等核心指标。


15.7 长事务的危害与排查

长事务是 PostgreSQL 生产环境中最常见、危害最深的问题之一, 但也是最容易被忽视的。

15.7.1 为什么长事务如此危险

PostgreSQL 使用多版本并发控制(MVCC), 旧版本的行数据(dead tuple)要等到没有任何事务还在引用它时才能被 VACUUM 回收。一个长事务, 即使它什么都不做(idle in transaction), 也持有一个事务快照, 导致:

  1. VACUUM 无法回收垃圾: 表持续膨胀, 磁盘占用增加, 查询变慢。
  2. 持有行锁: 阻塞其他事务的 UPDATE/DELETE。
  3. 最严重的情况: Transaction ID Wraparound

PostgreSQL 的事务 ID(XID)是一个 32 位无符号整数, 最大约 42 亿。当数据库累计执行了约 21 亿个事务后, 如果有一个非常老的未提交事务一直存在, 新事务的 XID 会"追上"它, 导致数据库将旧数据错误地标记为"未来"数据而不可见——这是 PostgreSQL 最严重的灾难性故障, 数据库会自动停止接受写入并报错:

text
ERROR: database is not accepting commands to avoid wraparound data loss

避免 wraparound 的关键是不要有长事务, 同时确保 autovacuum 正常运行。

15.7.2 排查与处置

sql
-- 1. 找出最老的活跃事务
SELECT pid,
       usename,
       application_name,
       state,
       now() - xact_start                AS xact_duration,
       now() - query_start               AS query_duration,
       wait_event_type,
       wait_event,
       left(query, 200)                  AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start
LIMIT 10;

-- 2. 查看当前最老未清理的事务 ID(监控 wraparound 风险)
SELECT datname,
       age(datfrozenxid)                 AS xid_age,
       pg_size_pretty(pg_database_size(datname)) AS db_size
FROM pg_database
ORDER BY age(datfrozenxid) DESC;
-- xid_age 超过 15 亿时需要立即关注

-- 3. 终止长事务(谨慎操作, 先确认 pid 是否为业务关键进程)
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
  AND now() - xact_start > INTERVAL '10 minutes'
  AND pid <> pg_backend_pid();

终止连接前务必确认

pg_terminate_backend 会强制断开连接并回滚事务。在生产环境执行前, 务必确认该 pid 对应的业务场景和影响。优先联系应用侧修复长事务问题, 而非直接 kill。

15.7.3 预防配置

ini
# postgresql.conf

# 超过 60 秒仍处于 idle in transaction 状态, 自动断开
idle_in_transaction_session_timeout = 60000   # 毫秒

# 单条 SQL 执行超过 30 秒自动终止(可在会话级覆盖)
statement_timeout = 30000

# 死锁检测超时(默认 1s 通常足够)
deadlock_timeout = 1000

在连接池(PgBouncer)层也应设置连接的最大生命周期:

ini
# pgbouncer.ini
server_lifetime = 3600       # 连接最长存活 1 小时后复用
server_idle_timeout = 600    # 空闲连接超过 10 分钟回收

15.8 上生产前安全与配置 Checklist

以下清单涵盖一个 PostgreSQL 实例从测试环境晋升至生产环境前必须完成的所有关键检查项。建议将其纳入 CI/CD 发布流程或 Runbook, 每次上线前逐项确认。

建议做成团队 Wiki

将下方清单复制到团队的发布 Runbook 中, 每项由负责人签字确认, 留存审计记录。

  1. 超级用户密码已修改: postgres 账号的默认密码必须修改为强密码, 格式建议包含大小写字母、数字和特殊字符, 长度不低于 16 位。
  2. 禁止 trust 认证: 检查 pg_hba.conf, 确认所有规则均使用 scram-sha-256peercert, 不存在任何 trustpassword(明文)方法。
  3. 已启用 SSL/TLS: postgresql.confssl = on, 证书文件权限为 600, 并已配置 ssl_min_protocol_version = 'TLSv1.2'
  4. 最小权限原则已落实: 应用账号不使用 postgres 超级用户; 各账号(app_userreadonly_usermigrator)的权限已按最小化原则授予, 无多余的 SUPERUSER 或 CREATEDB 属性。
  5. 行级安全(RLS)已启用: 多租户或含用户隔离需求的表已创建 RLS 策略, 并已执行 ALTER TABLE ... FORCE ROW LEVEL SECURITY 防止表属主绕过。
  6. 备份已测试可恢复: 不仅要有备份作业在运行, 还必须定期执行全量恢复演练, 确认 pg_restore 或 PITR 流程可在 RTO 内完成。
  7. 监控已接入: postgres_exporter 或等效组件已就绪, Grafana Dashboard 可以看到连接数、缓存命中率、复制延迟等核心指标的实时数据。
  8. 慢查询日志已开启: log_min_duration_statement 已设置为 500–1000 ms, 日志输出到可持久化的存储位置, 并接入日志分析工具(如 pgBadger)。
  9. 连接池已配置: 应用层通过 PgBouncer(transaction 模式)或等效中间件连接数据库, 数据库侧 max_connections 设置与连接池大小匹配。
  10. autovacuum 已调优: 对高频写入的大表已按实际情况配置表级 autovacuum 参数(autovacuum_vacuum_scale_factorautovacuum_vacuum_cost_delay 等), 确认 last_autovacuum 时间戳正常。
  11. pg_stat_statements 已安装: 在 postgresql.conf 中加入 shared_preload_libraries = 'pg_stat_statements' 并执行 CREATE EXTENSION pg_stat_statements, 为 SQL 性能分析提供基础数据。
  12. listen_addresses 已限制: 不使用 listen_addresses = '*'; 只监听内网 IP 或 localhost, 数据库端口不暴露在公网上。
  13. 公网访问已关闭: 网络层防火墙已限制 5432 端口仅对应用服务器所在的内网网段开放, 已通过端口扫描工具验证外部不可达。
  14. PostgreSQL 版本已打最新安全补丁: 当前运行版本已升级至所用大版本的最新小版本(如 16.x 的最新 x), 已订阅 PostgreSQL 安全公告。
  15. idle_in_transaction_session_timeout 已设置: 值建议 30000–60000 ms, 防止应用 bug 导致的僵尸事务持续持锁。
  16. statement_timeout 已设置: 根据业务 SLA 在应用层或全局设置合理的超时阈值, 防止失控查询拖垮整个实例。
  17. 日志格式已统一配置: log_line_prefix 包含时间戳、进程 ID、用户名、数据库名和客户端 IP, 便于与应用日志关联分析。
  18. pg_hba.conf 全面使用 scram-sha-256: 所有远程主机认证条目均已从 md5 迁移至 scram-sha-256, 并执行了 ALTER ROLE ... PASSWORD '...' 重置密码以触发新哈希算法。
  19. 表和索引已执行 ANALYZE: 生产数据导入后、大批量写操作后, 执行 ANALYZE 更新统计信息, 确保查询规划器能生成最优执行计划。
  20. 生产回滚计划已准备: 本次变更(DDL/数据迁移/配置修改)有对应的回滚脚本或步骤文档, 已经过演练, 可在 15 分钟内执行完毕。

清单不是一次性任务

安全配置会随业务发展而失效——新增的账号可能带来过宽权限, 旧索引可能变成废弃索引, 版本补丁需要持续跟进。建议每季度执行一次全量复核, 或将关键项纳入自动化巡检脚本。


15.9 架构总览

15.9.1 角色权限层级模型

下图展示了 PostgreSQL 生产环境中推荐的角色继承与权限分层模型。

15.9.2 监控指标体系

下图按四个层次梳理 PostgreSQL 生产监控的关键指标。

从哪里开始监控

如果只能选三件事: 先接入连接数告警(防止连接耗尽导致应用无法建立新连接), 再接入缓存命中率(快速发现内存不足或大扫描查询), 最后接入长事务告警(防止 VACUUM 阻塞和 wraparound 风险)。这三项覆盖了 80% 的常见生产故障根因。


15.8 上生产前安全与配置 Checklist

以下每一项都应在部署前逐条核对。未通过的项目应视为阻塞上线的问题。

身份认证与访问控制

  • postgres 超级用户已设置强密码, 且不用于任何应用连接。
  • pg_hba.conf 中无 trust 认证方法(本地 Unix socket 除外且仅限 postgres 用户)。
  • 所有外部连接使用 scram-sha-256 认证方法, 禁止 md5password
  • 应用账号(app_user)无 SUPERUSERCREATEDBCREATEROLE 属性。
  • 迁移账号(migrator)在迁移完成后已执行 ALTER ROLE migrator NOLOGIN
  • 已执行 REVOKE ALL ON SCHEMA public FROM PUBLICREVOKE CREATE ON SCHEMA public FROM PUBLIC

网络与传输安全

  • listen_addresses 已限制为特定内网 IP, 不使用 '*'
  • 数据库端口(5432)已在防火墙层限制访问来源, 未对公网开放。
  • ssl = on 已启用, 外部连接规则使用 hostssl 而非 host
  • 客户端连接串已配置 sslmode=verify-full(或至少 verify-ca)。

权限与数据安全

  • 每个应用账号已按最小权限原则配置, 确认无多余的表权限。
  • 已用 ALTER DEFAULT PRIVILEGES 配置未来新建对象的默认权限。
  • 敏感表(如 userspayments)已启用 RLS 或有明确的不启用理由。
  • 已验证 RLS 策略在应用账号下按预期过滤数据(使用 SET ROLE 测试)。

高可用与备份

  • 已配置主备流复制, 并验证备库处于追赶状态(pg_stat_replication)。
  • wal_level = replica(或 logical)已确认。
  • 已配置并验证过备份恢复流程(至少执行过一次完整恢复演练)。
  • 备份保留策略已明确(如 WAL 归档 + 每日全量, 保留 7 天)。

性能与稳定性

  • shared_buffers 已调整为系统内存的 25%(而非默认 128 MB)。
  • max_connections 已结合连接池评估, 未盲目设为超大值。
  • idle_in_transaction_session_timeout 已设置合理超时(建议 60 秒)。
  • statement_timeout 已在应用层按业务场景配置。
  • autovacuum 已确认在运行, 并按表的写入频率调整 autovacuum_vacuum_scale_factor
  • log_min_duration_statement 已设置(建议 500 ms), 慢查询日志已落地。

监控与运维

  • Prometheus + postgres_exporter 已接入, Grafana Dashboard 已配置告警规则。
  • 连接数、缓存命中率、最久未完成事务等关键指标已设置告警阈值。
  • log_checkpoints = onlog_lock_waits = on 已启用。
  • 已安装最新安全补丁版本, 并制定定期更新计划。

15.9 权限与监控体系全景图

15.9.1 角色权限层级模型

15.9.2 监控指标体系


本章小结

PostgreSQL 的安全与运维是一个需要从设计阶段就考虑的系统工程。权限设计遵循最小权限原则、用 RLS 在引擎层实现行级隔离、用 scram-sha-256 和 SSL 保护认证链路、按实际硬件调优生产参数、建立完善的监控体系、主动排查和预防长事务——这六个方面共同构成一个健壮的 PostgreSQL 生产系统的基础。

安全不是一次性的配置任务, 而是持续运营的过程: 定期轮换密码、定期审计权限、定期检查慢查询、定期验证备份可用性。本章的 Checklist 可以作为每次上线和季度巡检的起点。

十六、综合实战: 从零设计一个电商订单系统

经过前面十五章的铺垫, 我们已经掌握了 PostgreSQL 从基础到高级的全部核心知识: 数据类型与约束、索引原理与优化、事务与并发控制、窗口函数、全文检索、分区表、性能调优、高可用方案……本章将把所有最佳实践串联成一个真实的电商订单系统, 从需求拆解、建表设计、核心流程实现, 到索引规划、分区改造、演进策略, 完整走一遍数据库工程师在生产项目中要做的每一步。

本章目标

读完本章, 你应当能够独立完成一个中型电商的数据库设计, 写出并发安全的下单事务函数, 并针对真实流量制定索引与分区策略。

16.1 需求分析与模块划分

在动笔写第一行 SQL 之前, 先把业务边界梳理清楚。电商订单系统可以拆分为以下七个模块:

用户模块: 支持手机号与邮箱两种注册方式, 两者均要保证全局唯一; 用户有等级字段用于差异化运营。

商品模块: 商品是一个"父子"两层结构——products 存放名称、描述、类别等基础信息; product_skus 存放每个具体规格(颜色、尺寸、版本等), 每条 SKU 记录有独立的价格和库存。这是电商系统最常见的"SPU + SKU"模型。

购物车模块: 每位用户拥有且仅拥有一个购物车(carts), 购物车内可有多个商品条目(cart_items), 同一购物车中同一 SKU 不能重复出现(用唯一约束保证)。

订单模块: 订单由头信息(orders)和明细行(order_items)组成。头信息记录总金额、折扣金额、收货地址(JSONB)和当前状态; 明细行记录每个 SKU 的购买数量与成交单价。成交单价必须快照到订单明细中, 而不是关联 SKU 价格——商品价格会变, 历史订单不能跟着变。

支付模块: payments 记录每一次支付尝试, 包括支付方式、支付状态、第三方流水号和实际付款时间。一个订单可能经历多次支付(首次失败后重试), 因此 order_id 不做唯一约束。

库存模块: 单独维护 inventories 表, 字段分为 available_stock(可售库存)和 reserved_stock(已锁定但未发货的库存), 并引入 version 字段支持乐观锁。

优惠券模块: coupons 定义券的类型(百分比折扣或固定金额减免)、面值、有效期和使用门槛; user_coupons 记录每位用户领取的每张券及其核销状态。

16.2 完整 DDL 设计

建表顺序遵循外键依赖: 先建被引用表, 再建引用表。所有表均遵循第八章规范: 主键使用 GENERATED ALWAYS AS IDENTITY、审计字段统一使用 timestamptz、每列均附带注释、约束在列级或表级明确声明。

16.2.1 基础与用户表

sql
-- 商品类别(被 products 引用, 必须先建)
CREATE TABLE categories (
    id          INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name        VARCHAR(64)  NOT NULL,
    parent_id   INTEGER      REFERENCES categories(id),
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT now()
);
COMMENT ON TABLE  categories              IS '商品类别';
COMMENT ON COLUMN categories.parent_id   IS '父类别, 顶级类别为 NULL';

-- 用户表
CREATE TABLE users (
    id            BIGINT       GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email         VARCHAR(255) UNIQUE,
    phone         VARCHAR(20)  UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    nickname      VARCHAR(64)  NOT NULL DEFAULT '',
    grade         SMALLINT     NOT NULL DEFAULT 1
                               CHECK (grade BETWEEN 1 AND 10),
    created_at    TIMESTAMPTZ  NOT NULL DEFAULT now(),
    updated_at    TIMESTAMPTZ  NOT NULL DEFAULT now(),
    CONSTRAINT users_contact_check
        CHECK (email IS NOT NULL OR phone IS NOT NULL)
);
COMMENT ON TABLE  users               IS '用户表';
COMMENT ON COLUMN users.grade         IS '用户等级 1-10';
COMMENT ON COLUMN users.password_hash IS 'bcrypt 哈希, 禁止存明文';

手机号与邮箱的唯一约束

UNIQUE 约束对 NULL 值是豁免的——两条记录都可以让 email = NULL 而不违反唯一约束。因此必须用 CHECK 额外保证"至少有一种联系方式"。

16.2.2 商品与库存表

sql
-- 商品 SPU 表
CREATE TABLE products (
    id          BIGINT       GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name        VARCHAR(255) NOT NULL,
    description TEXT,
    category_id INTEGER      NOT NULL REFERENCES categories(id),
    status      VARCHAR(16)  NOT NULL DEFAULT 'draft'
                             CHECK (status IN ('draft','on_sale','off_sale')),
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ  NOT NULL DEFAULT now()
);
COMMENT ON TABLE  products         IS '商品 SPU';
COMMENT ON COLUMN products.status  IS 'draft=草稿, on_sale=在售, off_sale=下架';

-- 商品 SKU 表
CREATE TABLE product_skus (
    id          BIGINT          GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    product_id  BIGINT          NOT NULL REFERENCES products(id),
    sku_code    VARCHAR(64)     NOT NULL UNIQUE,
    attributes  JSONB           NOT NULL DEFAULT '{}',
    price       NUMERIC(12, 2)  NOT NULL CHECK (price >= 0),
    stock       INTEGER         NOT NULL DEFAULT 0 CHECK (stock >= 0),
    created_at  TIMESTAMPTZ     NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ     NOT NULL DEFAULT now()
);
COMMENT ON TABLE  product_skus            IS '商品 SKU, 每行对应一个具体规格';
COMMENT ON COLUMN product_skus.attributes IS '规格属性, 例如 {"color":"红色","size":"M"}';
COMMENT ON COLUMN product_skus.stock      IS '冗余库存字段(快速读取用), 权威数据在 inventories';

-- 库存表(权威库存, 支持并发安全扣减)
CREATE TABLE inventories (
    id               BIGINT       GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    sku_id           BIGINT       NOT NULL UNIQUE REFERENCES product_skus(id),
    available_stock  INTEGER      NOT NULL DEFAULT 0 CHECK (available_stock >= 0),
    reserved_stock   INTEGER      NOT NULL DEFAULT 0 CHECK (reserved_stock >= 0),
    version          BIGINT       NOT NULL DEFAULT 0,
    updated_at       TIMESTAMPTZ  NOT NULL DEFAULT now()
);
COMMENT ON TABLE  inventories                  IS '库存权威表';
COMMENT ON COLUMN inventories.available_stock  IS '可售库存';
COMMENT ON COLUMN inventories.reserved_stock   IS '已锁定(待发货)库存';
COMMENT ON COLUMN inventories.version          IS '乐观锁版本号';

16.2.3 购物车表

sql
CREATE TABLE carts (
    id          BIGINT       GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id     BIGINT       NOT NULL UNIQUE REFERENCES users(id),
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ  NOT NULL DEFAULT now()
);
COMMENT ON TABLE  carts         IS '购物车(每用户一个)';
COMMENT ON COLUMN carts.user_id IS 'UNIQUE 保证一用户一购物车';

CREATE TABLE cart_items (
    id          BIGINT       GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    cart_id     BIGINT       NOT NULL REFERENCES carts(id),
    sku_id      BIGINT       NOT NULL REFERENCES product_skus(id),
    quantity    INTEGER      NOT NULL CHECK (quantity > 0),
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ  NOT NULL DEFAULT now(),
    UNIQUE (cart_id, sku_id)
);
COMMENT ON TABLE  cart_items          IS '购物车条目';
COMMENT ON COLUMN cart_items.quantity IS '必须大于 0';

16.2.4 优惠券表

sql
CREATE TABLE coupons (
    id           BIGINT          GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    code         VARCHAR(32)     NOT NULL UNIQUE,
    type         VARCHAR(8)      NOT NULL CHECK (type IN ('PERCENT','FIXED')),
    value        NUMERIC(10, 2)  NOT NULL CHECK (value > 0),
    min_amount   NUMERIC(12, 2)  NOT NULL DEFAULT 0 CHECK (min_amount >= 0),
    valid_from   TIMESTAMPTZ     NOT NULL,
    valid_until  TIMESTAMPTZ     NOT NULL,
    total        INTEGER         NOT NULL CHECK (total > 0),
    used_count   INTEGER         NOT NULL DEFAULT 0 CHECK (used_count >= 0),
    created_at   TIMESTAMPTZ     NOT NULL DEFAULT now(),
    CONSTRAINT coupons_valid_range CHECK (valid_until > valid_from)
);
COMMENT ON TABLE  coupons            IS '优惠券定义';
COMMENT ON COLUMN coupons.type       IS 'PERCENT=折扣券(value为折扣率0-1), FIXED=满减券(value为减免金额)';
COMMENT ON COLUMN coupons.min_amount IS '使用门槛, 订单金额须达到此值';

CREATE TABLE user_coupons (
    id           BIGINT       GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id      BIGINT       NOT NULL REFERENCES users(id),
    coupon_id    BIGINT       NOT NULL REFERENCES coupons(id),
    status       VARCHAR(16)  NOT NULL DEFAULT 'AVAILABLE'
                              CHECK (status IN ('AVAILABLE','USED','EXPIRED')),
    received_at  TIMESTAMPTZ  NOT NULL DEFAULT now(),
    used_at      TIMESTAMPTZ,
    order_id     BIGINT,
    UNIQUE (user_id, coupon_id)
);
COMMENT ON TABLE  user_coupons          IS '用户领券记录';
COMMENT ON COLUMN user_coupons.order_id IS '核销时关联的订单, 领券时为 NULL';

16.2.5 订单与支付表

sql
CREATE TABLE orders (
    id               BIGINT          GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_no         VARCHAR(32)     NOT NULL UNIQUE,
    user_id          BIGINT          NOT NULL REFERENCES users(id),
    status           VARCHAR(16)     NOT NULL DEFAULT 'pending'
                                     CHECK (status IN (
                                         'pending','paid','shipped',
                                         'completed','cancelled','refunding')),
    total_amount     NUMERIC(12, 2)  NOT NULL CHECK (total_amount >= 0),
    discount_amount  NUMERIC(12, 2)  NOT NULL DEFAULT 0 CHECK (discount_amount >= 0),
    coupon_id        BIGINT          REFERENCES coupons(id),
    shipping_address JSONB           NOT NULL DEFAULT '{}',
    created_at       TIMESTAMPTZ     NOT NULL DEFAULT now(),
    updated_at       TIMESTAMPTZ     NOT NULL DEFAULT now()
) PARTITION BY RANGE (created_at);
COMMENT ON TABLE  orders                  IS '订单头信息(按月分区)';
COMMENT ON COLUMN orders.order_no         IS '业务订单号, 对外展示用';
COMMENT ON COLUMN orders.shipping_address IS '收货地址快照, 防止用户改地址后影响历史订单';
COMMENT ON COLUMN orders.discount_amount  IS '优惠券减免金额';

-- 2025 年示例分区(生产中应提前用脚本批量预建)
CREATE TABLE orders_2025_01 PARTITION OF orders
    FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE orders_2025_02 PARTITION OF orders
    FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
-- ... 按需继续建

CREATE TABLE order_items (
    id          BIGINT          GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id    BIGINT          NOT NULL REFERENCES orders(id),
    sku_id      BIGINT          NOT NULL REFERENCES product_skus(id),
    quantity    INTEGER         NOT NULL CHECK (quantity > 0),
    unit_price  NUMERIC(12, 2)  NOT NULL CHECK (unit_price >= 0),
    subtotal    NUMERIC(12, 2)  NOT NULL
                                GENERATED ALWAYS AS (quantity * unit_price) STORED,
    created_at  TIMESTAMPTZ     NOT NULL DEFAULT now()
);
COMMENT ON TABLE  order_items           IS '订单明细行';
COMMENT ON COLUMN order_items.unit_price IS '下单时的成交单价快照';
COMMENT ON COLUMN order_items.subtotal   IS '行小计 = quantity × unit_price, 自动计算列';

CREATE TABLE payments (
    id          BIGINT          GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id    BIGINT          NOT NULL REFERENCES orders(id),
    amount      NUMERIC(12, 2)  NOT NULL CHECK (amount > 0),
    method      VARCHAR(16)     NOT NULL CHECK (method IN ('alipay','wechat','card','wallet')),
    status      VARCHAR(16)     NOT NULL DEFAULT 'pending'
                                CHECK (status IN ('pending','success','failed','refunded')),
    paid_at     TIMESTAMPTZ,
    trade_no    VARCHAR(64)     UNIQUE,
    created_at  TIMESTAMPTZ     NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ     NOT NULL DEFAULT now()
);
COMMENT ON TABLE  payments          IS '支付记录(一个订单可多次尝试)';
COMMENT ON COLUMN payments.trade_no IS '第三方支付流水号, 成功后唯一';

生成列的妙用

order_items.subtotalGENERATED ALWAYS AS (...) STORED 声明为存储生成列, PostgreSQL 会在插入或更新时自动计算并持久化, 查询时无需重复算术运算, 也不会出现应用层手工计算出的数值与 unit_price × quantity 对不上的 bug。

16.3 关键流程的 SQL 实现

16.3.1 下单事务函数 create_order()

下单是整个系统并发压力最高、逻辑最复杂的操作。必须在一个事务内完成: 锁库存、核销优惠券、生成订单、扣减库存、清空购物车——任何一步失败都要整体回滚。

plpgsql
CREATE OR REPLACE FUNCTION create_order(
    p_user_id       BIGINT,
    p_cart_item_ids BIGINT[],
    p_coupon_id     BIGINT DEFAULT NULL
)
RETURNS BIGINT          -- 返回新订单 ID
LANGUAGE plpgsql
AS $$
DECLARE
    v_cart_id        BIGINT;
    v_order_id       BIGINT;
    v_order_no       VARCHAR(32);
    v_total_amount   NUMERIC(12,2) := 0;
    v_discount       NUMERIC(12,2) := 0;
    v_coupon         RECORD;
    v_item           RECORD;
    v_inv            RECORD;
BEGIN
    -- ① 确认购物车归属
    SELECT id INTO v_cart_id
    FROM   carts
    WHERE  user_id = p_user_id;

    IF v_cart_id IS NULL THEN
        RAISE EXCEPTION '用户 % 的购物车不存在', p_user_id;
    END IF;

    -- ② 逐条加载购物车项, 并用 FOR UPDATE 锁定对应库存行(防止并发超卖)
    FOR v_item IN
        SELECT ci.id          AS cart_item_id,
               ci.quantity,
               s.id           AS sku_id,
               s.price        AS unit_price
        FROM   cart_items  ci
        JOIN   product_skus s ON s.id = ci.sku_id
        WHERE  ci.id = ANY(p_cart_item_ids)
          AND  ci.cart_id = v_cart_id
        ORDER  BY s.id        -- 按 sku_id 升序加锁, 规避死锁
    LOOP
        -- 锁定对应库存行
        SELECT * INTO v_inv
        FROM   inventories
        WHERE  sku_id = v_item.sku_id
        FOR UPDATE;

        IF v_inv IS NULL THEN
            RAISE EXCEPTION 'SKU % 库存记录不存在', v_item.sku_id;
        END IF;

        IF v_inv.available_stock < v_item.quantity THEN
            RAISE EXCEPTION 'SKU % 库存不足(当前 %, 需要 %)',
                v_item.sku_id, v_inv.available_stock, v_item.quantity;
        END IF;

        v_total_amount := v_total_amount + v_item.unit_price * v_item.quantity;
    END LOOP;

    -- ③ 验证并核销优惠券
    IF p_coupon_id IS NOT NULL THEN
        SELECT uc.*, c.type, c.value, c.min_amount, c.valid_until
        INTO   v_coupon
        FROM   user_coupons uc
        JOIN   coupons c ON c.id = uc.coupon_id
        WHERE  uc.coupon_id = p_coupon_id
          AND  uc.user_id   = p_user_id
          AND  uc.status    = 'AVAILABLE'
        FOR UPDATE;                     -- 锁定券行, 防止并发核销

        IF NOT FOUND THEN
            RAISE EXCEPTION '优惠券 % 不可用(不存在、已使用或不属于当前用户)', p_coupon_id;
        END IF;
        IF now() > v_coupon.valid_until THEN
            RAISE EXCEPTION '优惠券 % 已过期', p_coupon_id;
        END IF;
        IF v_total_amount < v_coupon.min_amount THEN
            RAISE EXCEPTION '订单金额 % 未达到优惠券使用门槛 %',
                v_total_amount, v_coupon.min_amount;
        END IF;

        -- 计算折扣金额
        IF v_coupon.type = 'PERCENT' THEN
            v_discount := ROUND(v_total_amount * v_coupon.value, 2);
        ELSE
            v_discount := v_coupon.value;
        END IF;
        v_discount := LEAST(v_discount, v_total_amount);  -- 折扣不超过总额
    END IF;

    -- ④ 生成业务订单号: YYYYMMDD + 6位随机数
    v_order_no := to_char(now(), 'YYYYMMDD')
               || lpad(floor(random() * 1000000)::TEXT, 6, '0');

    -- ⑤ 插入订单头
    INSERT INTO orders (
        order_no, user_id, status,
        total_amount, discount_amount, coupon_id,
        shipping_address
    )
    VALUES (
        v_order_no, p_user_id, 'pending',
        v_total_amount, v_discount, p_coupon_id,
        '{"note":"调用方应传入收货地址"}'::JSONB
    )
    RETURNING id INTO v_order_id;

    -- ⑥ 插入订单明细 + 扣减库存 + 清空购物车项
    FOR v_item IN
        SELECT ci.id AS cart_item_id, ci.quantity,
               s.id AS sku_id, s.price AS unit_price
        FROM   cart_items  ci
        JOIN   product_skus s ON s.id = ci.sku_id
        WHERE  ci.id = ANY(p_cart_item_ids)
          AND  ci.cart_id = v_cart_id
        ORDER  BY s.id
    LOOP
        INSERT INTO order_items (order_id, sku_id, quantity, unit_price)
        VALUES (v_order_id, v_item.sku_id, v_item.quantity, v_item.unit_price);

        UPDATE inventories
        SET    available_stock = available_stock - v_item.quantity,
               version        = version + 1,
               updated_at     = now()
        WHERE  sku_id = v_item.sku_id;
    END LOOP;

    DELETE FROM cart_items
    WHERE  id = ANY(p_cart_item_ids)
      AND  cart_id = v_cart_id;

    -- ⑦ 核销优惠券
    IF p_coupon_id IS NOT NULL THEN
        UPDATE user_coupons
        SET    status   = 'USED',
               used_at  = now(),
               order_id = v_order_id
        WHERE  user_id    = p_user_id
          AND  coupon_id  = p_coupon_id;

        UPDATE coupons
        SET    used_count = used_count + 1
        WHERE  id = p_coupon_id;
    END IF;

    RETURN v_order_id;
END;
$$;

COMMENT ON FUNCTION create_order IS '原子下单: 锁库存→核销券→生成订单→扣减库存→清购物车';

调用示例

sql
-- 用户 101 将购物车中 id 为 5、6、7 的条目下单, 使用优惠券 23
SELECT create_order(101, ARRAY[5, 6, 7], 23);

函数内部已经是一个完整的原子操作。若调用方需要在更大的事务中组合其他操作, 可在显式 BEGIN ... COMMIT 块内调用。

16.3.2 库存扣减——悲观锁防超卖

错误写法: 先查询库存, 在应用层判断是否充足, 再发出 UPDATE

sql
-- 错误: 两条 SQL 之间存在窗口期, 并发时会超卖
SELECT available_stock FROM inventories WHERE sku_id = 101;
-- 应用层判断 available_stock >= qty
UPDATE inventories
SET    available_stock = available_stock - 5
WHERE  sku_id = 101;

在高并发场景下, 两个请求几乎同时读取到库存 = 10, 各自判断"够了", 然后各自减去 8, 库存就会变成 -6, 严重超卖。

正确写法: 在同一事务内 SELECT ... FOR UPDATE 锁行, 再检查, 再扣减。

sql
-- 正确: FOR UPDATE 确保同一时刻只有一个事务能修改该行
BEGIN;

SELECT available_stock
FROM   inventories
WHERE  sku_id = 101
FOR UPDATE;            -- 行锁, 其他事务的同类操作将阻塞于此

-- 检查 available_stock >= 购买数量, 不足则 ROLLBACK
-- 足够则继续:

UPDATE inventories
SET    available_stock = available_stock - 5,
       version        = version + 1,
       updated_at     = now()
WHERE  sku_id = 101;

COMMIT;

死锁规避

当一次下单需要锁定多个 SKU 的库存时, 务必按 sku_id 升序 依次加锁。若 A 事务锁 SKU 1 再锁 SKU 2, B 事务锁 SKU 2 再锁 SKU 1, 就会形成死锁。create_order() 函数中的 FOR 循环已在查询中加了 ORDER BY s.id 来规避此问题。

16.3.3 订单列表 Keyset 分页

电商用户的订单历史页面是一个典型的"无限滚动"场景, OFFSET 分页在翻到深页时会随偏移量线性增大扫描量, 对大表性能极差。

传统 OFFSET 分页的问题:

sql
-- 翻到第 1000 页时 PG 要扫描并丢弃前 20000 行
SELECT id, order_no, total_amount, created_at
FROM   orders
WHERE  user_id = 101
ORDER  BY created_at DESC, id DESC
LIMIT  20
OFFSET 20000;

Keyset(游标)分页:

sql
-- 首页(没有游标):
SELECT id, order_no, total_amount, status, created_at
FROM   orders
WHERE  user_id = 101
ORDER  BY created_at DESC, id DESC
LIMIT  20;

-- 第 N+1 页(传入上一页末尾的 last_created_at 和 last_id):
SELECT id, order_no, total_amount, status, created_at
FROM   orders
WHERE  user_id = 101
  AND  (created_at, id) < ($last_created_at, $last_id)
ORDER  BY created_at DESC, id DESC
LIMIT  20;

配套索引:

sql
-- 覆盖 user_id 过滤 + 排序列, 避免回表
CREATE INDEX idx_orders_user_cursor
    ON orders (user_id, created_at DESC, id DESC);

为什么 Keyset 更快

OFFSET 要求 PG 从头扫描并数出 N 行再丢弃; Keyset 利用 B-tree 索引直接定位到游标位置, 时间复杂度从 O(N) 降到 O(log N + page_size), 无论翻到多深的页都是常数时间。

16.3.4 销量统计——窗口函数

统计近 30 天内各类别下销量排名 Top 5 的 SKU:

sql
WITH sales AS (
    SELECT oi.sku_id,
           s.product_id,
           p.category_id,
           SUM(oi.quantity)  AS total_qty,
           SUM(oi.subtotal)  AS total_revenue
    FROM   order_items  oi
    JOIN   orders       o  ON o.id = oi.order_id
    JOIN   product_skus s  ON s.id = oi.sku_id
    JOIN   products     p  ON p.id = s.product_id
    WHERE  o.created_at >= now() - INTERVAL '30 days'
      AND  o.status IN ('paid','shipped','completed')
    GROUP  BY oi.sku_id, s.product_id, p.category_id
),
ranked AS (
    SELECT *,
           row_number() OVER (
               PARTITION BY category_id
               ORDER BY total_qty DESC
           ) AS rn
    FROM   sales
)
SELECT category_id, sku_id, total_qty, total_revenue, rn
FROM   ranked
WHERE  rn <= 5
ORDER  BY category_id, rn;

16.3.5 商品全文检索

为商品表添加 tsvector 生成列并建立 GIN 索引, 实现高效中英文混合搜索:

sql
-- 添加全文检索向量列(存储型生成列, 自动维护)
ALTER TABLE products
ADD COLUMN search_vector TSVECTOR
    GENERATED ALWAYS AS (
        setweight(to_tsvector('simple', coalesce(name, '')), 'A') ||
        setweight(to_tsvector('simple', coalesce(description, '')), 'B')
    ) STORED;

-- 建 GIN 索引加速全文查询
CREATE INDEX idx_products_search
    ON products USING GIN (search_vector);

-- 按关键词搜索并按相关性排序
SELECT id, name,
       ts_rank(search_vector, query) AS relevance
FROM   products,
       to_tsquery('simple', '运动 & 鞋') AS query
WHERE  status       = 'on_sale'
  AND  search_vector @@ query
ORDER  BY relevance DESC
LIMIT  20;

中文分词

内置的 simple 配置不做词干还原, 适合精确匹配。若需要中文分词, 可安装 zhparserpg_jieba 扩展并创建对应的文本搜索配置。全文检索详见第十二章。

16.4 索引设计清单

索引是把"查询模式"翻译成"存储结构"的桥梁。下表覆盖本系统所有高频查询的索引方案, 每条都标明了对应的查询场景和索引类型。

表名索引定义覆盖的查询类型
usersCREATE UNIQUE INDEX ON users(email) WHERE email IS NOT NULL邮箱登录查用户B-tree (部分索引)
usersCREATE UNIQUE INDEX ON users(phone) WHERE phone IS NOT NULL手机号登录查用户B-tree (部分索引)
productsCREATE INDEX ON products(category_id, status)按类别+状态过滤商品列表B-tree
productsCREATE INDEX ON products USING GIN (search_vector)全文搜索商品GIN
product_skusCREATE INDEX ON product_skus(product_id)查某商品的所有 SKUB-tree
product_skusCREATE INDEX ON product_skus USING GIN (attributes)按规格属性过滤 SKUGIN
cart_itemsCREATE INDEX ON cart_items(cart_id)加载用户购物车B-tree
cart_itemsCREATE INDEX ON cart_items(sku_id)批量查 SKU 被加购情况B-tree
ordersCREATE INDEX ON orders(user_id, created_at DESC, id DESC)用户订单列表 Keyset 分页B-tree
ordersCREATE INDEX ON orders(status, created_at)后台按状态+时间范围查订单B-tree
order_itemsCREATE INDEX ON order_items(order_id)查订单明细行B-tree
order_itemsCREATE INDEX ON order_items(sku_id)统计 SKU 销量B-tree
paymentsCREATE INDEX ON payments(order_id)查订单支付记录B-tree
paymentsCREATE INDEX ON payments(status, created_at)对账: 按状态+时间查支付流水B-tree
inventories(sku_id 已有 UNIQUE 索引, 无需额外建)按 SKU 查库存B-tree
user_couponsCREATE INDEX ON user_coupons(user_id, status)查用户可用优惠券列表B-tree
couponsCREATE INDEX ON coupons(valid_until) WHERE used_count < total清理/查询未用完的有效券B-tree (部分索引)

部分索引节省空间

users 表中邮箱和手机号允许为 NULL, 用 WHERE email IS NOT NULL 的部分索引可以只索引有值的行, 在稀疏列上节省约 30%–50% 的索引体积, 同时不影响唯一性语义(NULL 本身不参与唯一检查)。

索引不是越多越好

每个索引都会在 INSERT/UPDATE/DELETE 时带来额外写开销。在购物车和订单表上不要盲目添加索引——优先根据慢查询日志(pg_stat_statements + auto_explain)识别真实的热点查询, 再按需补建。

16.5 订单表按月分区

16.5.1 分区设计

orders 表在 DDL 中已声明 PARTITION BY RANGE (created_at)。生产环境需要提前为未来月份预建分区, 通常由定时任务在每月末自动执行。

sql
-- 建立 2025 年全年分区(示例)
DO $$
DECLARE
    y     INT := 2025;
    m     INT;
    start DATE;
    stop  DATE;
BEGIN
    FOR m IN 1..12 LOOP
        start := make_date(y, m, 1);
        stop  := start + INTERVAL '1 month';
        EXECUTE format(
            'CREATE TABLE IF NOT EXISTS orders_%s_%s
             PARTITION OF orders
             FOR VALUES FROM (%L) TO (%L)',
            y,
            lpad(m::TEXT, 2, '0'),
            start::TIMESTAMPTZ,
            stop::TIMESTAMPTZ
        );
    END LOOP;
END $$;

-- 为分区表上的常用查询单独建索引
-- (分区表的全局索引会自动传播到各子分区)
CREATE INDEX IF NOT EXISTS idx_orders_user_cursor
    ON orders (user_id, created_at DESC, id DESC);

CREATE INDEX IF NOT EXISTS idx_orders_status_time
    ON orders (status, created_at);

16.5.2 分区的注意事项

默认分区兜底: 建议保留一个 DEFAULT 分区, 防止未预建的时间范围数据写入失败:

sql
CREATE TABLE orders_default PARTITION OF orders DEFAULT;

外键约束限制: PostgreSQL 分区表的子分区不支持被其他表的外键直接引用。order_items.order_id 引用的是父表 orders, 这在 PG 14+ 中已支持; 若使用更早版本, 需在应用层保证引用完整性。

分区裁剪生效条件: 查询 WHERE 子句必须包含分区键(created_at)才能触发分区裁剪(Partition Pruning)。仅按 user_id 查询不会裁剪, 会全分区扫描。可用 EXPLAIN 确认:

sql
EXPLAIN SELECT * FROM orders
WHERE user_id = 101
  AND created_at >= '2025-06-01'
  AND created_at <  '2025-07-01';
-- 应只看到 orders_2025_06 被扫描

历史分区归档: 超过保留期的分区可以直接 DETACH 后冷存, 或 DROP 删除, 速度远快于按行 DELETE:

sql
-- 摘除旧分区(不删数据, 变为普通表)
ALTER TABLE orders DETACH PARTITION orders_2024_01;

-- 或直接删除(不可恢复, 操作前备份)
DROP TABLE orders_2024_01;

分区 vs 归档

按月分区解决的是当前热数据的查询性能问题。如果业务需要保留全部历史数据, 可将摘除的旧分区通过 pg_dump 导出到对象存储(如 S3), 再从主库删除, 实现冷热分离。

16.6 演进与权衡

  • 读写分离: 主库接写, 只读副本接报表/列表查询; PgBouncer 连接池分流; 应用层数据源路由。
  • 何时考虑分库分表: 单表超 5 亿行且分区已无法缓解; 先评估 Citus(PG 原生水平扩展, 数据按 shard key 分布到多节点)再考虑应用层分库; 非必要不引入分库复杂度。
  • PostgreSQL 能撑多久: 合理建表 + 索引 + 读写分离, 日订单百万级/累计数十亿行的中型业务完全可以支撑; PG 是被低估的数据库, 先把它用到极限再说换。

读写分离路由陷阱

刚下单后立刻查询订单详情属于"写后读"场景, 必须路由到主库, 否则用户会看到"订单不存在"。可在 Session 级别用 synchronous_commit = remote_apply 强制同步, 或在应用层标记事务后一定时间内强制走主库。

16.7 数据库上线 Checklist

上线前逐条核查以下 20 项:

[权限安全]

  1. 超级用户已改密, 禁止使用默认 postgres 密码。
  2. pg_hba.conf 中禁止 trust 认证方式。
  3. 已启用 scram-sha-256 作为统一认证方式。
  4. SSL 已开启, 客户端连接串使用 sslmode=require
  5. listen_addresses 已限制为内网 IP, 禁止监听公网。
  6. 应用账号已按最小权限原则授权, 无 SUPERUSER
  7. 多租户场景已评估并配置 RLS 行级安全策略。

[备份与恢复]

  1. 备份脚本已部署并接入定时任务。
  2. 恢复流程已演练测试通过, 确认备份可用。
  3. PITR 归档已启用, archive_mode = on 且归档命令可达。
  4. 备份保留策略已定(如保留 7 天全量 + 30 天 WAL)。

[监控与告警]

  1. pg_stat_statements 已安装并加入 shared_preload_libraries
  2. 慢查询日志已开启(log_min_duration_statement = 500)。
  3. 连接数告警已配置, 超过 max_connections 80% 时触发。
  4. 复制延迟告警已配置, 超过 30 s 时触发。
  5. 磁盘空间告警已配置, 超过 80% 时触发。

[性能基线]

  1. ANALYZE 已对所有表执行, 统计信息为最新。
  2. 关键索引已建, 对照 16.4 节清单逐一确认。
  3. 分区已预建未来 3 个月, 定时创建任务已部署。
  4. PgBouncer 已配置, work_mem / shared_buffers 已按实例内存调优。

[运维安全]

必须设置超时参数

idle_in_transaction_session_timeout = 30000(30 s)和 statement_timeout = 30000 必须在 postgresql.conf 中设置, 防止僵尸事务长期持锁。同时确认已打最新安全补丁, autovacuum 已针对高频更新表调优。

16.8 全景图

表关系总览

下单流程时序图

16.9 全章小结

本章以一个完整的电商订单系统为载体, 将前面十五章的核心知识落地成可以直接用于生产的 SQL 代码。

  • 建表规范: 11 张业务表, 每张均遵循类型选型、约束声明、审计字段、注释的完整规范。
  • 事务与并发: create_order()FOR UPDATE 悲观锁解决库存超卖, ORDER BY sku_id 规避死锁, 优惠券核销用行锁防止并发重复使用。
  • 分页性能: 用 Keyset 分页替代 OFFSET, 将深翻页的 O(N) 降为 O(log N)。
  • 分析查询: 窗口函数 row_number() OVER (PARTITION BY ...) 实现类别销量排名。
  • 全文检索: 存储型 tsvector 生成列 + GIN 索引, 支撑商品搜索并按相关性排序。
  • 分区表: 按月 RANGE 分区控制单分区体积, 支持历史分区快速归档。
  • 索引规划: 18 条索引, 每条都对应明确的查询模式, 用部分索引减少稀疏列的索引体积。
  • 演进路径: 读写分离 → Citus 分布式扩展, 拒绝过早分库分表带来的复杂度爆炸。

PostgreSQL 是工程师工具箱里最有价值的工具之一。掌握它, 你不需要在业务初期就引入 Kafka、Redis、MongoDB 的复杂组合——一个调优良好的 PostgreSQL 实例, 往往能以更低的成本、更高的可靠性撑过绝大多数业务的整个生命周期。

学习路径建议

  • 动手: 把本章的 DDL 全部执行一遍, 用 pgbench 或自己编写的测试脚本模拟并发下单。
  • 进阶: 研读 pg_stat_activitypg_lockspg_stat_bgwriter 的实时监控指标。
  • 深入: 阅读 PostgreSQL 官方文档的 "Internals" 部分, 理解 MVCC、WAL、Buffer Manager 的实现原理。

结语

我们从“PostgreSQL 是什么”一路走到了亲手设计一套电商订单系统, 这条线很长, 但回头看会发现它其实只讲了一件事: 如何把数据可靠、高效、可持续地管起来。

如果要把全文浓缩成几条最该记住的东西, 我会选这些:

  • 正确性优先于性能。金额用 numeric、时间用 timestamptz、并发写用合适的锁, 这些不是教条, 而是无数线上事故换来的底线。先把数据存对, 再谈快。
  • 理解原理, 而不是背命令。懂了 MVCC, 就明白为什么死元组会膨胀、为什么长事务危险; 懂了 B-tree 与执行计划, 就不会再对着慢查询瞎试。索引、事务、优化这三章, 是把你和“只会写 CRUD”的人区分开的地方, 值得反复回看。
  • 建表设计是地基。第八章那份建表模板、范式与反范式的权衡、主键选型, 决定了系统往后好不好维护。地基歪了, 上层再优化也是补救。
  • 让数据库替你兜底。约束、外键、事务、行级安全, 能在数据库层保证的一致性, 就不要只依赖应用代码的“自觉”。多一道约束, 少一类脏数据。
  • 上生产前过一遍清单。备份能不能恢复、连接池有没有配、慢查询日志开没开、权限是不是最小化——这些第十四、十五章讲过的事, 出事时每一条都是救命的。

PostgreSQL 的能力远不止这十六章。当你把这些基础打牢之后, 还有很多值得继续深入的方向: 用 PostGIS 处理地理数据, 用 TimescaleDB 扛时序场景, 用逻辑复制做异构同步与不停机迁移, 用 Citus 走向分布式, 乃至近年很热的向量检索(pgvector)把它用进 AI 应用。它最迷人的地方就在于此——你几乎总能在同一个数据库里找到下一个问题的答案, 而不必急着换一套技术栈。

学数据库没有捷径, 但有正确的路。把这篇文章里的例子都亲手跑一遍, 在自己的项目里真正用上一次事务、设计一次索引、读懂一次执行计划, 你对它的理解会比读十遍都深。愿你用得越久, 越觉得当初选它是对的。