在事务与并发控制中,我们建立了 MVCC 的通用理论框架——多版本并发控制通过保存数据的多个版本来实现读写互不阻塞。但理论到实现之间,存在巨大的鸿沟:MySQL 的 InnoDB 用 Undo Log 回滚段实现 MVCC,而 PostgreSQL 选择了截然不同的路径——在堆表中直接保留多版本。
这一设计选择引发了连锁反应:不需要 Undo Log,但需要 VACUUM 回收死元组;不支持原地更新,但发明了 HOT 更新来缓解;B-tree 不够用时,GiST/GIN/BRIN 等高级索引类型填补了空白。本章将深入 PostgreSQL 的核心实现,理解这些机制如何协同工作,以及它们与 MySQL 的根本差异。
前置知识
- Ch04 事务与并发控制:MVCC 的通用理论框架是理解 PostgreSQL MVCC 实现的前提
- Ch03 索引原理:B+ 树索引原理,PostgreSQL 在此基础上扩展了 GiST/GIN/BRIN
- Ch06 MySQL 深入:对比 MySQL 的 MVCC 实现(Undo Log)有助于理解 PostgreSQL 的设计选择
一、PostgreSQL 架构概述
1.1 进程模型:每个连接一个进程
与 MySQL 的线程模型不同,PostgreSQL 采用**每连接一进程(Process-per-Connection)**模型。客户端发起连接时,Postmaster 进程 fork 一个后端进程(Backend Process)专门服务该连接。
这种模型的优势是进程隔离性好——一个后端进程崩溃不会影响其他连接,Postmaster 会检测到并清理。代价是进程比线程更重,连接数上千时内存开销显著。因此 PostgreSQL 社区强烈推荐使用连接池(如 PgBouncer),而不是直接让每个应用线程独占一个数据库连接。
1.2 共享内存结构
PostgreSQL 启动时向操作系统申请一大块共享内存,所有后端进程和辅助进程共享访问:
| 内存区域 | 作用 | 关键参数 |
|---|---|---|
| Shared Buffers | 数据页缓存,避免频繁磁盘 I/O | shared_buffers(建议 25% 系统内存) |
| WAL Buffer | WAL 记录的写入缓冲 | wal_buffers(默认 -1,自动计算) |
| Clog(Commit Log) | 记录事务状态(已提交/已回滚/进行中) | 与 pg_xact 目录对应 |
| Lock Table | 进程间锁信息 | max_locks_per_transaction |
| ProcArray | 所有活跃后端进程的 xmin 快照 | 用于快照可见性判断 |
Clog 是 PostgreSQL MVCC 的关键组件。每个事务提交后,其状态记录在 Clog 中;判断一个元组是否可见时,需要结合元组头部的
t_xmin/t_xmax与 Clog 中的事务状态。Clog 在磁盘上对应pg_xact/目录,但活跃数据常驻共享内存。
1.3 核心架构流程
一个查询从客户端发送到结果返回,经历以下步骤:
其中规划器(Planner)是 PostgreSQL 的”大脑”——它基于统计信息和代价估计器选择最优执行路径。第六节将深入代价估计器的实现。
二、MVCC 实现
2.1 Tuple 结构:xmin / xmax / ctid
PostgreSQL 的堆表(Heap Table)中,每一行数据称为一个元组(Tuple)。每个元组的头部(HeapTupleHeaderData)包含三个关键字段:
typedef struct HeapTupleHeaderData { TransactionId t_xmin; // 插入该元组的事务ID TransactionId t_xmax; // 删除或更新该元组的事务ID CommandId t_cid; // 插入/删除该元组的命令ID(同事务内) ItemPointerData t_ctid; // 指向新版本元组的指针(更新时) // ... 其他字段} HeapTupleHeaderData;三个字段的含义:
| 字段 | 含义 | 何时设置 |
|---|---|---|
t_xmin | 创建该元组的事务 ID | INSERT 时设为当前事务 ID |
t_xmax | 删除/更新该元组的事务 ID | DELETE 时设为当前事务 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_ctidFROM 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 可见性判断的基础:
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 可见性判断规则
给定一个元组和快照,可见性判断遵循以下规则:
简化口诀:元组可见 = 插入事务在快照前已提交 AND(未删除 OR 删除事务在快照中仍活跃或已回滚)。
2.5 与 MySQL Undo Log 对比
PostgreSQL 和 MySQL 都实现了 MVCC,但实现路径截然不同:
| 维度 | PostgreSQL | MySQL InnoDB |
|---|---|---|
| 多版本存储 | 堆表中直接保留旧版本(Append-Only) | Undo Log 中保留旧版本,数据页只存最新版 |
| 更新方式 | INSERT 新版本 + 标记旧版本 xmax | 原地更新数据页 + 写 Undo Log |
| 回滚机制 | 无 Undo Log,依赖 Clog 判断可见性 | 从 Undo Log 重建旧版本 |
| 空间回收 | VACUUM 扫描清理死元组 | Undo Log 段自动清理 |
| 回滚段膨胀 | 无回滚段,但堆表膨胀 | 长事务导致 Undo Log 膨胀 |
| 读旧版本 | 直接读堆表中的旧元组 | 从 Undo Log 链重建旧版本 |
| 热点数据页 | 多版本共存导致页分裂频繁 | 数据页始终是最新版,更紧凑 |
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; -- 默认 50SHOW autovacuum_vacuum_scale_factor; -- 默认 0.2 (20%)SHOW autovacuum_analyze_threshold; -- 默认 50SHOW 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 流程
VACUUM 的核心步骤:
- 扫描:遍历堆表页面,识别 Dead Tuple
- 标记:将 Dead Tuple 的空间标记为可用,更新 FSM(Free Space Map)
- 更新 VM:更新可见性映射(Visibility Map),标记全干净的页
- 截断:如果文件末尾的页完全为空,截断文件释放磁盘空间
可见性映射(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 链找到新版本。
4.2 HOT 的触发条件
HOT 更新必须同时满足两个条件:
- 新版本与旧版本在同一数据页:PostgreSQL 在更新时会优先尝试在同一页中分配空间。如果页已满,则退化为普通更新。
- 更新的列不被任何索引引用:如果更新了索引列,索引条目必须更新,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_ratioFROM pg_stat_user_tablesWHERE relname = 'accounts';
-- 示例输出:-- n_tup_ins | n_tup_upd | n_tup_hot_upd | hot_ratio-- -----------+-----------+---------------+------------- 10000 | 8000 | 7200 | 90.0090% 的 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-tree | B-tree | B+ 树 | 等值、范围、排序、前缀匹配 | 中等 |
| GiST | Generalized Search Tree | 可定制树 | 地理空间、范围、全文搜索 | 中等 |
| GIN | Generalized Inverted Index | 倒排索引 | 数组、JSONB、全文搜索 | 较大 |
| BRIN | Block Range Index | 块范围摘要 | 有序大表的范围查询 | 极小 |
| SP-GiST | Space-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 reservationsWHERE during && '[2026-04-01, 2026-04-30]'::daterange;
-- 查找附近的位置SELECT * FROM locationsWHERE 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 documentsWHERE to_tsvector('english', content) @@ to_tsquery('english', 'postgresql & index');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 logsWHERE created_at BETWEEN '2026-04-01' AND '2026-04-30';
-- BRIN 索引大小对比SELECT indexname, pg_size_pretty(pg_relation_size(indexname::regclass)) AS sizeFROM 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_tableWHERE ip_range >>= '192.168.1.0/24'::inet;5.7 索引类型选择决策
六、代价估计器
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_widthFROM pg_statsWHERE 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 | 需要从磁盘读取的页数 |
当
EXPLAIN的预估行数与实际行数差距很大时(如预估 1 行实际 10000 行),说明统计信息失真,需要执行ANALYZE或增大统计目标。这是查询性能突然下降的最常见原因。
七、PostgreSQL vs MySQL 对比
在MySQL 深入中,我们详细分析了 InnoDB 的实现。以下从多个维度对比两个数据库的核心差异:
7.1 存储引擎与 MVCC
| 维度 | PostgreSQL | MySQL InnoDB |
|---|---|---|
| 存储引擎 | 统一存储引擎(Heap Table) | 可插拔存储引擎(InnoDB/MyISAM/etc.) |
| MVCC 实现 | 堆表多版本(xmin/xmax) | Undo Log 回滚段 |
| 更新方式 | Append-Only(INSERT 新版本) | 原地更新 + Undo Log |
| 回滚 | 无 Undo Log,依赖 Clog | Undo Log 链 |
| 垃圾回收 | VACUUM(手动/自动) | Undo Log 自动清理 |
| 表膨胀风险 | 高(VACUUM 不及时) | 低(Undo 自动管理) |
| 长事务影响 | 阻止 VACUUM 回收死元组 | Undo Log 膨胀 |
7.2 索引与查询
| 维度 | PostgreSQL | MySQL InnoDB |
|---|---|---|
| 聚簇索引 | 无(堆表组织,CTID 寻址) | 有(主键即数据,二级索引回主键) |
| 索引类型 | B-tree/GiST/GIN/BRIN/SP-GiST/Hash | B-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 并发控制
| 维度 | PostgreSQL | MySQL InnoDB |
|---|---|---|
| 默认隔离级别 | Read Committed | Repeatable Read |
| RR 实现 | 快照(首次读时获取) | Gap Lock + Next-Key Lock |
| Serializable | SSI(Serializable Snapshot Isolation) | 两阶段锁(2PL) |
| DDL 锁 | MVCC 式 DDL(ALTER 不阻塞读) | MDL 锁(ALTER 阻塞读写) |
| 死锁检测 | 自动检测 + 回滚代价小的事务 | 自动检测 + 回滚代价小的事务 |
7.4 运维与生态
| 维度 | PostgreSQL | MySQL |
|---|---|---|
| 连接模型 | 进程模型(需连接池) | 线程模型 |
| 扩展系统 | 丰富的扩展生态(PostGIS/pg_trgm等) | 插件较少 |
| 逻辑复制 | 原生逻辑复制 + WAL 解码 | Binlog 复制 |
| 分区表 | 声明式分区(10+) | 声明式分区(8.0+) |
| 在线 DDL | 部分支持(ALTER ... CONCURRENTLY) | Online 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_updFROM 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_tupFROM pg_stat_user_tables WHERE relname = 'hot_test'; relname | n_dead_tup | n_live_tup----------+------------+------------ hot_test | 0 | 1003. 表大小
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)提供了强大的查询能力,但每种都有特定的适用场景和限制。
本章核心要点:
- 进程模型:每连接一进程,隔离性好但需要连接池;共享内存是所有进程协作的枢纽
- MVCC 实现:xmin/xmax/ctid 三元组 + Clog 事务状态 + SnapshotData 快照,共同构成可见性判断体系;与 MySQL Undo Log 是两种截然不同的 MVCC 路径
- VACUUM:Append-Only 的必然代价——Dead Tuple 必须由 VACUUM 回收;AutoVacuum 的触发阈值需要根据表大小调优;VACUUM FULL 是紧急修复手段而非日常工具
- HOT 更新:同页 + 非索引列更新时跳过索引维护,是 PostgreSQL 缓解更新开销的关键优化;
fillfactor调优可提升 HOT 命中率 - 高级索引:GiST 用于空间/范围搜索,GIN 用于数组/JSONB/全文搜索,BRIN 用于有序大表的范围查询——选择正确的索引类型比调优 B-tree 更有效
- 代价估计器:优化器的”大脑”,基于统计信息和 I/O/CPU 代价模型选择执行路径;
EXPLAIN (ANALYZE, BUFFERS)是排查性能问题的第一工具
理解 PostgreSQL 的这些核心机制,不仅能帮助你写出更高效的 SQL,更能在遇到性能问题时快速定位根因——是统计信息失真?是 VACUUM 不及时?还是索引类型选错了?这些问题的答案,都藏在 PostgreSQL 的内部实现中。
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






