mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
5101 字
14 分钟
PostgreSQL 深入:MVCC 实现与高级索引
2024-07-18

事务与并发控制中,我们建立了 MVCC 的通用理论框架——多版本并发控制通过保存数据的多个版本来实现读写互不阻塞。但理论到实现之间,存在巨大的鸿沟:MySQL 的 InnoDB 用 Undo Log 回滚段实现 MVCC,而 PostgreSQL 选择了截然不同的路径——在堆表中直接保留多版本

这一设计选择引发了连锁反应:不需要 Undo Log,但需要 VACUUM 回收死元组;不支持原地更新,但发明了 HOT 更新来缓解;B-tree 不够用时,GiST/GIN/BRIN 等高级索引类型填补了空白。本章将深入 PostgreSQL 的核心实现,理解这些机制如何协同工作,以及它们与 MySQL 的根本差异。

前置知识#

一、PostgreSQL 架构概述#

1.1 进程模型:每个连接一个进程#

与 MySQL 的线程模型不同,PostgreSQL 采用**每连接一进程(Process-per-Connection)**模型。客户端发起连接时,Postmaster 进程 fork 一个后端进程(Backend Process)专门服务该连接。

graph TB CLIENT1["客户端 1"] -->|TCP连接| POSTMASTER["Postmaster<br/>主进程<br/>端口监听 & fork"] CLIENT2["客户端 2"] -->|TCP连接| POSTMASTER CLIENT3["客户端 3"] -->|TCP连接| POSTMASTER POSTMASTER -->|fork| BE1["Backend Process 1<br/>服务客户端1"] POSTMASTER -->|fork| BE2["Backend Process 2<br/>服务客户端2"] POSTMASTER -->|fork| BE3["Backend Process 3<br/>服务客户端3"] BE1 --> SHARED["共享内存<br/>Shared Buffers / WAL Buffer / Clog"] BE2 --> SHARED BE3 --> SHARED subgraph 辅助进程["后台辅助进程"] BGWRITER["BgWriter<br/>脏页刷盘"] WALWRITER["WalWriter<br/>WAL 刷盘"] CHECKPOINTER["Checkpointer<br/>检查点"] AUTOVACUUM["AutoVacuum<br/>自动清理"] STATS["Stats Collector<br/>统计信息收集"] end SHARED --> BGWRITER SHARED --> WALWRITER SHARED --> CHECKPOINTER SHARED --> AUTOVACUUM SHARED --> STATS style POSTMASTER fill:#e3f2fd,stroke:#1565c0 style SHARED fill:#fff3e0,stroke:#e65100 style 辅助进程 fill:#e8f5e9,stroke:#2e7d32

这种模型的优势是进程隔离性好——一个后端进程崩溃不会影响其他连接,Postmaster 会检测到并清理。代价是进程比线程更重,连接数上千时内存开销显著。因此 PostgreSQL 社区强烈推荐使用连接池(如 PgBouncer),而不是直接让每个应用线程独占一个数据库连接。

1.2 共享内存结构#

PostgreSQL 启动时向操作系统申请一大块共享内存,所有后端进程和辅助进程共享访问:

内存区域作用关键参数
Shared Buffers数据页缓存,避免频繁磁盘 I/Oshared_buffers(建议 25% 系统内存)
WAL BufferWAL 记录的写入缓冲wal_buffers(默认 -1,自动计算)
Clog(Commit Log)记录事务状态(已提交/已回滚/进行中)pg_xact 目录对应
Lock Table进程间锁信息max_locks_per_transaction
ProcArray所有活跃后端进程的 xmin 快照用于快照可见性判断
Note

Clog 是 PostgreSQL MVCC 的关键组件。每个事务提交后,其状态记录在 Clog 中;判断一个元组是否可见时,需要结合元组头部的 t_xmin/t_xmax 与 Clog 中的事务状态。Clog 在磁盘上对应 pg_xact/ 目录,但活跃数据常驻共享内存。

1.3 核心架构流程#

一个查询从客户端发送到结果返回,经历以下步骤:

flowchart LR A["客户端发送SQL"] --> B["解析器<br/>Parser"] B --> C["分析器<br/>Analyzer<br/>语义分析+重写"] C --> D["规划器<br/>Planner<br/>生成最优执行计划"] D --> E["执行器<br/>Executor<br/>访问堆表/索引"] E --> F["返回结果"] style B fill:#e3f2fd,stroke:#1565c0 style D fill:#fff3e0,stroke:#e65100 style E fill:#e8f5e9,stroke:#2e7d32

其中规划器(Planner)是 PostgreSQL 的”大脑”——它基于统计信息和代价估计器选择最优执行路径。第六节将深入代价估计器的实现。

二、MVCC 实现#

2.1 Tuple 结构:xmin / xmax / ctid#

PostgreSQL 的堆表(Heap Table)中,每一行数据称为一个元组(Tuple)。每个元组的头部(HeapTupleHeaderData)包含三个关键字段:

src/include/access/htup_details.h
typedef struct HeapTupleHeaderData {
TransactionId t_xmin; // 插入该元组的事务ID
TransactionId t_xmax; // 删除或更新该元组的事务ID
CommandId t_cid; // 插入/删除该元组的命令ID(同事务内)
ItemPointerData t_ctid; // 指向新版本元组的指针(更新时)
// ... 其他字段
} HeapTupleHeaderData;

三个字段的含义:

字段含义何时设置
t_xmin创建该元组的事务 IDINSERT 时设为当前事务 ID
t_xmax删除/更新该元组的事务 IDDELETE 时设为当前事务 ID;UPDATE 时设为旧版本的当前事务 ID
t_ctid指向该元组的最新版本UPDATE 时旧版本指向新版本(block + offset)
-- 使用 pageinspect 扩展查看元组头部信息
CREATE EXTENSION IF NOT EXISTS pageinspect;
-- 假设表 test 的数据在 0 号数据块
SELECT lp_off, t_xmin, t_xmax, t_ctid
FROM heap_page_items(get_raw_page('test', 0));
-- 示例输出:
-- lp_off | t_xmin | t_xmax | t_ctid
-- --------+--------+--------+--------
-- 816 | 100 | 0 | (0,1) -- 由事务100插入,未被修改
-- 800 | 100 | 102 | (0,3) -- 由事务100插入,被事务102更新,新版本在(0,3)
-- 784 | 102 | 0 | (0,3) -- 由事务102插入(更新的新版本)

2.2 Clog:事务状态记录#

Clog(Commit Log)记录每个事务的最终状态,是 MVCC 可见性判断的基础:

src/include/access/xact.h
typedef enum {
TRANSACTION_STATUS_IN_PROGRESS = 0x00, // 进行中
TRANSACTION_STATUS_COMMITTED = 0x01, // 已提交
TRANSACTION_STATUS_ABORTED = 0x02, // 已回滚
TRANSACTION_STATUS_SUB_COMMITTED = 0x03 // 子事务已提交
} XidStatus;

Clog 在磁盘上以 8KB 页为单位存储在 pg_xact/ 目录中,每个事务仅占 2 bit。活跃的 Clog 页常驻共享内存,判断事务状态时通常不需要磁盘 I/O。

2.3 SnapshotData:快照#

快照(Snapshot)是某一时刻所有活跃事务的”照片”,用于判断元组对当前事务是否可见:

// 简化的快照结构
typedef struct SnapshotData {
TransactionId xmin; // 当前所有活跃事务中最小的事务ID
TransactionId xmax; // 下一个将分配的事务ID
TransactionId *xip; // 活跃事务ID列表(xmin ~ xmax 之间)
uint32 xcnt; // 活跃事务数量
} SnapshotData;

获取快照时,PostgreSQL 遍历 ProcArray(所有后端进程的当前事务信息),收集所有正在执行的事务 ID,构建 xip 数组。

2.4 可见性判断规则#

给定一个元组和快照,可见性判断遵循以下规则:

flowchart TD START["判断元组是否可见"] --> XMIN{"t_xmin 的状态?"} XMIN -->|"进行中"| INV1["不可见<br/>事务未提交"] XMIN -->|"已回滚"| INV2["不可见<br/>事务已撤销"] XMIN -->|"已提交"| XMIN_VIS{"t_xmin < snapshot.xmin<br/>或不在 xip 中?"} XMIN_VIS -->|"否"| INV3["不可见<br/>插入事务在快照中仍活跃"] XMIN_VIS -->|"是"| XMAX{"t_xmax == 0?"} XMAX -->|"是"| V1["可见<br/>元组未被删除/更新"] XMAX -->|"否"| XMAX_STATUS{"t_xmax 的状态?"} XMAX_STATUS -->|"进行中"| V2["可见<br/>删除事务尚未提交"] XMAX_STATUS -->|"已回滚"| V3["可见<br/>删除事务已撤销"] XMAX_STATUS -->|"已提交"| XMAX_VIS{"t_xmax < snapshot.xmin<br/>或不在 xip 中?"} XMAX_VIS -->|"否"| V4["可见<br/>删除事务在快照中仍活跃"] XMAX_VIS -->|"是"| INV4["不可见<br/>元组已被删除/更新"] style V1 fill:#c8e6c9,stroke:#2e7d32 style V2 fill:#c8e6c9,stroke:#2e7d32 style V3 fill:#c8e6c9,stroke:#2e7d32 style V4 fill:#c8e6c9,stroke:#2e7d32 style INV1 fill:#ffcdd2,stroke:#c62828 style INV2 fill:#ffcdd2,stroke:#c62828 style INV3 fill:#ffcdd2,stroke:#c62828 style INV4 fill:#ffcdd2,stroke:#c62828

简化口诀:元组可见 = 插入事务在快照前已提交 AND(未删除 OR 删除事务在快照中仍活跃或已回滚)

2.5 与 MySQL Undo Log 对比#

PostgreSQL 和 MySQL 都实现了 MVCC,但实现路径截然不同:

维度PostgreSQLMySQL InnoDB
多版本存储堆表中直接保留旧版本(Append-Only)Undo Log 中保留旧版本,数据页只存最新版
更新方式INSERT 新版本 + 标记旧版本 xmax原地更新数据页 + 写 Undo Log
回滚机制无 Undo Log,依赖 Clog 判断可见性从 Undo Log 重建旧版本
空间回收VACUUM 扫描清理死元组Undo Log 段自动清理
回滚段膨胀无回滚段,但堆表膨胀长事务导致 Undo Log 膨胀
读旧版本直接读堆表中的旧元组从 Undo Log 链重建旧版本
热点数据页多版本共存导致页分裂频繁数据页始终是最新版,更紧凑
Warning

PostgreSQL 的 Append-Only MVCC 有一个显著缺点:频繁更新的表会产生大量死元组,导致表膨胀(Bloat)。如果 VACUUM 跟不上更新速度,查询性能会急剧下降。这是 PostgreSQL 运维中最常见的问题之一,第三节将详细讨论。

三、VACUUM#

3.1 Dead Tuple 的产生#

在 PostgreSQL 中,UPDATE 和 DELETE 不会立即回收旧版本的空间:

-- 事务 T1:插入一行
INSERT INTO accounts (id, balance) VALUES (1, 1000);
-- 堆表中产生元组:(t_xmin=100, t_xmax=0, balance=1000)
-- 事务 T2:更新该行
UPDATE accounts SET balance = 2000 WHERE id = 1;
-- 堆表中现在有两个元组:
-- 旧版本:(t_xmin=100, t_xmax=102, t_ctid=(0,2)) ← Dead Tuple
-- 新版本:(t_xmin=102, t_xmax=0, balance=2000) ← 当前可见版本
-- 事务 T3:删除该行
DELETE FROM accounts WHERE id = 1;
-- 新版本也变成 Dead Tuple:
-- (t_xmin=102, t_xmax=103, t_ctid=(0,2)) ← Dead Tuple

**Dead Tuple(死元组)**是指对所有当前和未来快照都不可见的元组。它们占据磁盘空间却无法被任何查询访问,必须由 VACUUM 回收。

3.2 AutoVacuum 触发条件#

PostgreSQL 的 AutoVacuum 守护进程定期检查各表是否需要清理。触发条件基于两个阈值:

-- 查看 AutoVacuum 相关参数
SHOW autovacuum_vacuum_threshold; -- 默认 50
SHOW autovacuum_vacuum_scale_factor; -- 默认 0.2 (20%)
SHOW autovacuum_analyze_threshold; -- 默认 50
SHOW autovacuum_analyze_scale_factor;-- 默认 0.1 (10%)
-- VACUUM 触发条件:
-- dead_tuples > autovacuum_vacuum_threshold +
-- autovacuum_vacuum_scale_factor * reltuples
-- 即:死元组数 > 50 + 20% × 表行数
-- ANALYZE 触发条件:
-- changed_tuples > autovacuum_analyze_threshold +
-- autovacuum_analyze_scale_factor * reltuples
-- 即:变更行数 > 50 + 10% × 表行数

对于大表(如 1 亿行),默认 20% 的阈值意味着需要积累 2000 万死元组才触发 VACUUM——这往往太迟了。生产环境通常需要调低 scale_factor

-- 对频繁更新的大表设置更激进的 VACUUM 策略
ALTER TABLE hot_table SET (
autovacuum_vacuum_scale_factor = 0.05, -- 5% 死元组即触发
autovacuum_analyze_scale_factor = 0.02 -- 2% 变更即更新统计信息
);

3.3 VACUUM 流程#

flowchart TD START["AutoVacuum 触发"] --> SCAN["扫描表的每一页"] SCAN --> CHECK{"发现 Dead Tuple?"} CHECK -->|"否"| NEXT["下一页"] CHECK -->|"是"| MARK["标记 Dead Tuple<br/>在 fsm 中记录可用空间"] MARK --> REFRACT["更新 fsm(空闲空间映射)<br/>和 vm(可见性映射)"] REFRACT --> NEXT NEXT --> MORE{"还有更多页?"} MORE -->|"是"| SCAN MORE -->|"否"| TRUNCATE{"末尾页全空?"} TRUNCATE -->|"是"| TRUNC["TRUNCATE 空页<br/>将磁盘空间归还操作系统"] TRUNCATE -->|"否"| DONE["VACUUM 完成"] TRUNC --> DONE style START fill:#e3f2fd,stroke:#1565c0 style MARK fill:#fff3e0,stroke:#e65100 style TRUNC fill:#e8f5e9,stroke:#2e7d32 style DONE fill:#c8e6c9,stroke:#2e7d32

VACUUM 的核心步骤:

  1. 扫描:遍历堆表页面,识别 Dead Tuple
  2. 标记:将 Dead Tuple 的空间标记为可用,更新 FSM(Free Space Map)
  3. 更新 VM:更新可见性映射(Visibility Map),标记全干净的页
  4. 截断:如果文件末尾的页完全为空,截断文件释放磁盘空间
Tip

可见性映射(Visibility Map, VM)是 VACUUM 的加速器。VM 中每个数据页占 1 bit,标记该页是否”全部元组对所有人可见”。VACUUM 可以跳过 VM 标记为干净的页,大幅减少扫描量。索引扫描也能利用 VM 跳过不必要的堆表回查(Index-Only Scan)。

3.4 VACUUM FULL vs LAZY#

特性VACUUM (LAZY)VACUUM FULL
锁类型共享锁,不阻塞读写排他锁,阻塞所有操作
空间处理标记空间可重用,不归还操作系统重写整表,归还空间给操作系统
执行速度快,增量处理慢,全表重写
额外空间不需要需要约等于表大小的临时空间
索引处理不重建索引重建所有索引
适用场景日常维护,AutoVacuum严重膨胀后的紧急修复
-- 日常维护:使用普通 VACUUM(不阻塞)
VACUUM accounts;
-- 紧急修复:使用 VACUUM FULL(阻塞 + 重写)
VACUUM FULL accounts;
-- 更好的替代方案:pg_repack(在线重建,不阻塞)
-- 需要安装扩展:CREATE EXTENSION pg_repack;
-- pg_repack -d mydb -t accounts;

3.5 参数调优#

-- 核心 VACUUM 调优参数
autovacuum_max_workers = 4 -- AutoVacuum 工作进程数(默认3)
autovacuum_naptime = 30s -- 检查间隔(默认1min)
autovacuum_vacuum_cost_limit = 2000 -- 每轮 I/O 限额(默认200)
autovacuum_vacuum_cost_delay = 2ms -- 达到限额后的休眠时间(默认20ms)
-- 手动 VACUUM 的 I/O 节流
vacuum_cost_limit = 200 -- 每轮 I/O 限额
vacuum_cost_delay = 0 -- 默认不休眠(手动执行优先级高)

vacuum_cost_limit 是 VACUUM 的”油门”——它限制每轮 VACUUM 的 I/O 开销,避免 VACUUM 占满磁盘带宽影响业务查询。达到限额后 VACUUM 会休眠 vacuum_cost_delay 毫秒,然后继续。

四、HOT 更新#

4.1 Heap Only Tuple 原理#

PostgreSQL 的 UPDATE 是”删除旧版本 + 插入新版本”,这意味着每次更新都会产生一条新的索引条目——如果表有 5 个索引,一次更新就要写 5 条新索引记录。这在频繁更新的场景下极其低效。

HOT(Heap Only Tuple)更新是 PostgreSQL 的优化方案:当新版本和旧版本在同一个数据页中,且更新的列不被任何索引引用时,不需要更新索引——索引仍然指向旧版本,通过旧版本的 t_ctid 链找到新版本。

flowchart LR subgraph 更新前["更新前"] IDX1["索引条目<br/>key=1 → (0,1)"] TUPLE1["旧版本 (0,1)<br/>t_xmin=100<br/>t_xmax=0<br/>name='Alice'<br/>age=30"] IDX1 --> TUPLE1 end subgraph 更新后["HOT 更新后(age 从 30→31)"] IDX2["索引条目<br/>key=1 → (0,1)<br/>(未变化!)"] TUPLE2["旧版本 (0,1)<br/>t_xmin=100<br/>t_xmax=102<br/>t_ctid=(0,2)<br/>name='Alice'<br/>age=30"] TUPLE3["新版本 (0,2)<br/>t_xmin=102<br/>t_xmax=0<br/>name='Alice'<br/>age=31"] IDX2 --> TUPLE2 TUPLE2 -->|"t_ctid 链"| TUPLE3 end 更新前 --> 更新后 style IDX2 fill:#c8e6c9,stroke:#2e7d32 style TUPLE3 fill:#c8e6c9,stroke:#2e7d32

4.2 HOT 的触发条件#

HOT 更新必须同时满足两个条件:

  1. 新版本与旧版本在同一数据页:PostgreSQL 在更新时会优先尝试在同一页中分配空间。如果页已满,则退化为普通更新。
  2. 更新的列不被任何索引引用:如果更新了索引列,索引条目必须更新,HOT 无法跳过。
-- 查看表的 HOT 更新统计
SELECT n_tup_ins, n_tup_upd, n_tup_hot_upd,
round(n_tup_hot_upd::numeric / NULLIF(n_tup_upd, 0) * 100, 2) AS hot_ratio
FROM pg_stat_user_tables
WHERE relname = 'accounts';
-- 示例输出:
-- n_tup_ins | n_tup_upd | n_tup_hot_upd | hot_ratio
-- -----------+-----------+---------------+-----------
-- 10000 | 8000 | 7200 | 90.00

90% 的 HOT 比率说明大部分更新都走了 HOT 路径。如果 HOT 比率低,可以考虑:

  • 增大 fillfactor(默认 100%,留出空间给 HOT 更新)
  • 避免更新索引列
-- 设置 fillfactor 为 80%,预留 20% 页空间给 HOT 更新
ALTER TABLE accounts SET (fillfactor = 80);
VACUUM FULL accounts; -- 需要重建表使 fillfactor 生效

4.3 与 MySQL 原地更新对比#

维度PostgreSQL HOT 更新MySQL InnoDB 原地更新
更新方式插入新版本 + ctid 链直接修改数据页中的行
索引更新HOT 时不更新索引始终需要更新索引(如果索引列变化)
Undo Log不需要必须写 Undo Log
空间效率同页内新旧版本共存,页利用率下降原地更新,空间紧凑
适用条件同页 + 非索引列更新任何更新
回滚旧版本仍在堆表中从 Undo Log 重建

五、高级索引类型#

索引原理中,我们详细分析了 B+ 树索引的原理。PostgreSQL 除了标准的 B-tree 索引外,还提供了 GiST、GIN、BRIN、SP-GiST 等高级索引类型,每种针对特定查询场景优化。

5.1 索引类型总览#

索引类型全称核心数据结构最佳场景空间开销
B-treeB-treeB+ 树等值、范围、排序、前缀匹配中等
GiSTGeneralized Search Tree可定制树地理空间、范围、全文搜索中等
GINGeneralized Inverted Index倒排索引数组、JSONB、全文搜索较大
BRINBlock Range Index块范围摘要有序大表的范围查询极小
SP-GiSTSpace-Partitioned GiST空间分区树电话号码、路由等非平衡结构中等

5.2 B-tree 索引#

PostgreSQL 的默认索引类型,适用于等值查询、范围查询、排序和前缀匹配:

-- 创建 B-tree 索引(默认类型)
CREATE INDEX idx_users_name ON users (name);
-- 支持的查询模式
SELECT * FROM users WHERE name = 'Alice'; -- 等值
SELECT * FROM users WHERE name > 'Alice'; -- 范围
SELECT * FROM users WHERE name LIKE 'Ali%'; -- 前缀
SELECT * FROM users ORDER BY name; -- 排序
SELECT * FROM users WHERE name = 'Alice' AND age > 20; -- 多列

5.3 GiST 索引#

GiST 是一种可扩展的索引框架,它不定义具体的数据结构,而是提供一套通用的树形搜索接口,由操作符类(Operator Class)决定具体行为。PostGIS 的地理空间索引就基于 GiST。

-- 地理空间索引(需要 PostGIS 扩展)
CREATE INDEX idx_locations_geo ON locations USING gist (geom);
-- 范围类型索引
CREATE INDEX idx_reservations_period ON reservations USING gist (during);
-- 查询示例:查找与某区域重叠的记录
SELECT * FROM reservations
WHERE during && '[2026-04-01, 2026-04-30]'::daterange;
-- 查找附近的位置
SELECT * FROM locations
WHERE ST_DWithin(geom, ST_MakePoint(116.4, 39.9)::geography, 5000);

GiST 的核心思想是近似 + 精确验证:索引存储每个子树的”近似边界”,搜索时先通过近似边界快速排除不可能匹配的子树,再对候选结果做精确验证。

5.4 GIN 索引#

GIN(Generalized Inverted Index)是倒排索引,适用于包含多个元素的数据类型——数组、JSONB、全文搜索。它为每个元素值维护一个包含所有包含该元素的行 ID 列表。

-- 数组索引
CREATE INDEX idx_articles_tags ON articles USING gin (tags);
-- 查询:包含特定标签的文章
SELECT * FROM articles WHERE tags @> ARRAY['postgresql', 'database'];
-- JSONB 索引
CREATE INDEX idx_events_data ON events USING gin (data);
-- 查询:JSONB 中包含特定键值对
SELECT * FROM events WHERE data @> '{"type": "login"}'::jsonb;
-- 全文搜索索引
CREATE INDEX idx_docs_content ON documents USING gin (to_tsvector('english', content));
-- 全文搜索查询
SELECT * FROM documents
WHERE to_tsvector('english', content) @@ to_tsquery('english', 'postgresql & index');
Note

GIN 索引的构建速度较慢(需要收集所有元素后批量构建),但查询速度极快。对于频繁写入的表,可以使用 fastupdate = on(默认开启)——将新条目暂存到待处理列表中,查询时合并,延迟索引更新以提升写入性能。

5.5 BRIN 索引#

BRIN(Block Range Index)是一种超轻量级索引,它不存储每行的索引值,而是存储每个连续数据块范围的摘要信息(最小值、最大值)。适用于物理有序的大表——如按时间追加写入的日志表。

-- 创建 BRIN 索引(指定块范围大小)
CREATE INDEX idx_logs_time ON logs USING brin (created_at)
WITH (pages_per_range = 32);
-- 查询:时间范围查询
SELECT * FROM logs
WHERE created_at BETWEEN '2026-04-01' AND '2026-04-30';
-- BRIN 索引大小对比
SELECT indexname, pg_size_pretty(pg_relation_size(indexname::regclass)) AS size
FROM pg_indexes WHERE tablename = 'logs';
-- 示例输出:
-- indexname | size
-- ------------------------+--------
-- idx_logs_time_btree | 214 MB ← B-tree
-- idx_logs_time_brin | 288 kB ← BRIN(小 700 倍!)

BRIN 的代价是精度低——它只能排除确定不包含目标值的块范围,但无法精确定位行,仍需在块范围内线性扫描。对于有序数据,这种”粗筛”已经能排除 99%+ 的数据块。

5.6 SP-GiST 索引#

SP-GiST(Space-Partitioned GiST)适用于非平衡的数据结构,如电话号码前缀树、路由表等:

-- 电话号码前缀索引
CREATE INDEX idx_phones_number ON phones USING spgist (phone_number);
-- 路由前缀匹配
SELECT * FROM routing_table
WHERE ip_range >>= '192.168.1.0/24'::inet;

5.7 索引类型选择决策#

flowchart TD START["选择索引类型"] --> Q1{"查询类型?"} Q1 -->|"等值/范围/排序"| BTREE["B-tree"] Q1 -->|"包含/数组/全文"| Q2{"数据特征?"} Q1 -->|"地理空间/范围重叠"| GIST["GiST"] Q1 -->|"有序大表范围查询"| BRIN_IDX["BRIN"] Q2 -->|"高基数(大量不同值)"| GIN["GIN"] Q2 -->|"前缀/非平衡结构"| SPGIST["SP-GiST"] BTREE --> DEFAULT["默认选择,覆盖 90% 场景"] GIST --> POSTGIS["PostGIS / 范围类型"] GIN --> JSONB["JSONB / 数组 / 全文搜索"] BRIN_IDX --> LOG["日志表 / 时序数据<br/>空间开销极小"] SPGIST --> PREFIX["电话号码 / IP 路由"] style BTREE fill:#c8e6c9,stroke:#2e7d32 style GIST fill:#e3f2fd,stroke:#1565c0 style GIN fill:#fff3e0,stroke:#e65100 style BRIN_IDX fill:#fce4ec,stroke:#c62828 style SPGIST fill:#f3e5f5,stroke:#6a1b9a

六、代价估计器#

PostgreSQL 的查询优化器(Planner)基于**代价估计(Cost Estimation)**选择执行计划。代价不是真实的时间,而是一个无量纲的相对值,用于比较不同执行路径的优劣。

6.1 代价模型#

PostgreSQL 的代价模型由三部分组成:

代价类型含义计算基础
seq_page_cost顺序读取一个数据页的代价默认 1.0
random_page_cost随机读取一个数据页的代价默认 4.0
cpu_tuple_cost处理一行的 CPU 代价默认 0.01
cpu_index_tuple_cost处理一条索引条目的 CPU 代价默认 0.005
cpu_operator_cost执行一个操作符的 CPU 代价默认 0.0025

6.2 扫描代价#

-- 顺序扫描代价 ≈ 顺序读页数 × seq_page_cost + 行数 × cpu_tuple_cost
-- 假设表有 10000 页、500000 行
-- Seq Scan Cost = 10000 × 1.0 + 500000 × 0.01 = 15000
-- 索引扫描代价 ≈ 随机读页数 × random_page_cost + 索引条目数 × cpu_index_tuple_cost
-- + 堆表行数 × cpu_tuple_cost
-- 假设 B-tree 深度 3、返回 100 行
-- Index Scan Cost = 3 × 4.0 + 100 × 0.005 + 100 × 0.01 = 13.5

对于 SSD,random_page_cost 应调低到 1.1~1.5,因为 SSD 的随机读性能远好于机械硬盘:

-- SSD 环境下调低随机读代价
ALTER SYSTEM SET random_page_cost = 1.1;
SELECT pg_reload_conf();

6.3 Join 代价#

PostgreSQL 支持三种 Join 策略,每种有不同的代价计算:

Join 方式代价模型适用场景
Nested Loop外表行数 × 内表扫描代价小表驱动大表,有索引
Hash Join构建哈希表代价 + 探测代价等值连接,中等大小表
Merge Join两侧排序代价 + 合并代价等值连接,数据已排序
-- 强制使用特定 Join 方式(仅调试用)
SET enable_nestloop = off;
SET enable_hashjoin = off;
-- 此时优化器只能选 Merge Join
-- 恢复默认
RESET enable_nestloop;
RESET enable_hashjoin;

6.4 统计信息#

代价估计的准确性取决于统计信息。PostgreSQL 通过 ANALYZE 命令收集表的统计信息,存储在 pg_statistic 系统表中:

-- 查看表的统计信息
SELECT attname, n_distinct, null_frac, avg_width
FROM pg_stats
WHERE tablename = 'users';
-- 示例输出:
-- attname | n_distinct | null_frac | avg_width
-- ---------+------------+-----------+----------
-- id | -1 | 0 | 8
-- name | -0.25 | 0 | 16
-- city | 100 | 0.05 | 12
-- n_distinct < 0 表示比例(-0.25 = 25% 不同值)
-- n_distinct > 0 表示绝对数量
-- 手动触发统计信息收集
ANALYZE users;
-- 增加统计信息精度(默认 100,最大 10000)
ALTER TABLE users ALTER COLUMN city SET STATISTICS 500;
ANALYZE users;

统计信息的精度直接影响优化器的选择。default_statistics_target 默认为 100,意味着对每列最多采样 100 × 300 = 30000 行来构建直方图。对于数据分布不均匀的列,增大统计目标可以避免优化器误判。

6.5 EXPLAIN 实战#

-- 基本执行计划
EXPLAIN SELECT * FROM users WHERE city = 'Beijing';
-- 带实际执行时间
EXPLAIN ANALYZE SELECT * FROM users WHERE city = 'Beijing';
-- 带 I/O 统计(最常用)
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM users WHERE city = 'Beijing';
-- 示例输出:
-- QUERY PLAN
-- ---------------------------------------------------------------
-- Index Scan using idx_users_city on users
-- (cost=0.29..8.31 rows=1 width=72)
-- (actual time=0.015..0.016 rows=1 loops=1)
-- Index Cond: (city = 'Beijing'::text)
-- Buffers: shared hit=4
-- Planning Time: 0.089 ms
-- Execution Time: 0.032 ms

解读要点:

字段含义
cost=0.29..8.31启动代价..总代价(估计值)
rows=1预估返回行数
width=72预估每行平均字节数
actual time=0.015..0.016实际启动时间..实际总时间(毫秒)
rows=1 (actual)实际返回行数
shared hit=4命中 Shared Buffers 的页数(无磁盘 I/O)
shared read=0需要从磁盘读取的页数
Warning

EXPLAIN 的预估行数与实际行数差距很大时(如预估 1 行实际 10000 行),说明统计信息失真,需要执行 ANALYZE 或增大统计目标。这是查询性能突然下降的最常见原因。

七、PostgreSQL vs MySQL 对比#

MySQL 深入中,我们详细分析了 InnoDB 的实现。以下从多个维度对比两个数据库的核心差异:

7.1 存储引擎与 MVCC#

维度PostgreSQLMySQL InnoDB
存储引擎统一存储引擎(Heap Table)可插拔存储引擎(InnoDB/MyISAM/etc.)
MVCC 实现堆表多版本(xmin/xmax)Undo Log 回滚段
更新方式Append-Only(INSERT 新版本)原地更新 + Undo Log
回滚无 Undo Log,依赖 ClogUndo Log 链
垃圾回收VACUUM(手动/自动)Undo Log 自动清理
表膨胀风险高(VACUUM 不及时)低(Undo 自动管理)
长事务影响阻止 VACUUM 回收死元组Undo Log 膨胀

7.2 索引与查询#

维度PostgreSQLMySQL InnoDB
聚簇索引无(堆表组织,CTID 寻址)有(主键即数据,二级索引回主键)
索引类型B-tree/GiST/GIN/BRIN/SP-GiST/HashB-tree/Hash(8.0)/Full-text/SPATIAL
覆盖索引支持 Index-Only Scan(需 VM)支持(直接从二级索引取值)
部分索引支持 WHERE 条件过滤不支持
表达式索引支持 CREATE INDEX ON t (lower(col))8.0+ 支持函数索引
JSON 索引GIN 索引(原生 JSONB)8.0+ 函数索引 + 虚拟列
查询优化器代价模型 + 遗传算法(GEQO)代价模型 + 启发式规则

7.3 并发控制#

维度PostgreSQLMySQL InnoDB
默认隔离级别Read CommittedRepeatable Read
RR 实现快照(首次读时获取)Gap Lock + Next-Key Lock
SerializableSSI(Serializable Snapshot Isolation)两阶段锁(2PL)
DDL 锁MVCC 式 DDL(ALTER 不阻塞读)MDL 锁(ALTER 阻塞读写)
死锁检测自动检测 + 回滚代价小的事务自动检测 + 回滚代价小的事务

7.4 运维与生态#

维度PostgreSQLMySQL
连接模型进程模型(需连接池)线程模型
扩展系统丰富的扩展生态(PostGIS/pg_trgm等)插件较少
逻辑复制原生逻辑复制 + WAL 解码Binlog 复制
分区表声明式分区(10+)声明式分区(8.0+)
在线 DDL部分支持(ALTER ... CONCURRENTLYOnline DDL(8.0+ 改进)
监控pg_stat_* 系列视图Performance Schema + Sys Schema

·附、实践:VACUUM 与 HOT 更新验证#

本节用 PostgreSQL 观察 HOT(Heap-Only Tuple)更新和 VACUUM 的效果。需要 PostgreSQL 环境。

1. 创建测试表并观察 HOT 更新#

CREATE TABLE hot_test (
id SERIAL PRIMARY KEY,
status TEXT,
updated_at TIMESTAMP DEFAULT NOW()
);
INSERT INTO hot_test (status) SELECT 'pending' FROM generate_series(1, 100);
-- 更新所有行
UPDATE hot_test SET status = 'shipped', updated_at = NOW();
-- 查看 HOT 更新统计
SELECT relname, n_dead_tup, n_live_tup, n_tup_upd, n_tup_hot_upd
FROM pg_stat_user_tables WHERE relname = 'hot_test';
relname | n_dead_tup | n_live_tup | n_tup_upd | n_tup_hot_upd
----------+------------+------------+-----------+---------------
hot_test | 0 | 100 | 100 | 57

关键观察

  • n_tup_upd = 100:总共更新了 100 行
  • n_tup_hot_upd = 57:其中 57 次是 HOT 更新——新版本与旧版本在同一数据页中,不需要更新二级索引
  • n_dead_tup = 0:当前没有死元组(Autovacuum 可能已经自动清理)

2. 手动 VACUUM#

VACUUM hot_test;
SELECT relname, n_dead_tup, n_live_tup
FROM pg_stat_user_tables WHERE relname = 'hot_test';
relname | n_dead_tup | n_live_tup
----------+------------+------------
hot_test | 0 | 100

3. 表大小#

SELECT pg_size_pretty(pg_relation_size('hot_test')) as size;
size
-------
16 kB

关键观察:尽管更新了 100 行,表大小仍然只有 16 kB——HOT 更新避免了表膨胀,因为新版本复用了旧版本的空间。如果没有 HOT 更新,每次 UPDATE 都会在新页中插入新版本,表大小会翻倍。

注意:HOT 更新的前提是更新不涉及索引列。如果更新了索引列(如 id),PostgreSQL 必须在索引中插入新的索引条目,无法使用 HOT 更新。

八、总结#

PostgreSQL 的设计哲学可以概括为**“正确性优先,性能其次”**——Append-Only MVCC 保证了事务语义的简洁与正确,但代价是 VACUUM 的复杂性和表膨胀风险;丰富的索引类型(GiST/GIN/BRIN)提供了强大的查询能力,但每种都有特定的适用场景和限制。

本章核心要点:

  1. 进程模型:每连接一进程,隔离性好但需要连接池;共享内存是所有进程协作的枢纽
  2. MVCC 实现:xmin/xmax/ctid 三元组 + Clog 事务状态 + SnapshotData 快照,共同构成可见性判断体系;与 MySQL Undo Log 是两种截然不同的 MVCC 路径
  3. VACUUM:Append-Only 的必然代价——Dead Tuple 必须由 VACUUM 回收;AutoVacuum 的触发阈值需要根据表大小调优;VACUUM FULL 是紧急修复手段而非日常工具
  4. HOT 更新:同页 + 非索引列更新时跳过索引维护,是 PostgreSQL 缓解更新开销的关键优化;fillfactor 调优可提升 HOT 命中率
  5. 高级索引:GiST 用于空间/范围搜索,GIN 用于数组/JSONB/全文搜索,BRIN 用于有序大表的范围查询——选择正确的索引类型比调优 B-tree 更有效
  6. 代价估计器:优化器的”大脑”,基于统计信息和 I/O/CPU 代价模型选择执行路径;EXPLAIN (ANALYZE, BUFFERS) 是排查性能问题的第一工具

理解 PostgreSQL 的这些核心机制,不仅能帮助你写出更高效的 SQL,更能在遇到性能问题时快速定位根因——是统计信息失真?是 VACUUM 不及时?还是索引类型选错了?这些问题的答案,都藏在 PostgreSQL 的内部实现中。

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

PostgreSQL 深入:MVCC 实现与高级索引
https://blog.souloss.com/posts/database/postgresql-deep-dive/
作者
Souloss
发布于
2024-07-18
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时