存储模型
PostgreSQL 的存储是“堆表 + 索引 + TOAST”的组合,和 MySQL InnoDB 的聚簇索引组织有根本差异。理解堆表,才能理解为什么 PG 的索引结构长这样、为什么 UPDATE 会产生死元组。
堆表:行的无序集合
PG 的表是堆(heap):行按插入顺序追加存放,不按主键有序。每张表物理上对应三个文件:
| 文件 | 作用 |
|---|---|
| 主文件 | 实际的行数据 |
_fsm |
空闲空间映射,记录每个页的空闲空间,插入时快速找页 |
_vm |
可见性映射,标记哪些页全部可见,加速 VACUUM 和仅索引扫描 |
基本单位是 8KB 的页(page)。行在页内的位置用 CTID(页号 + 行指针) 标识,类似物理地址。
与 MySQL 的关键差异
| 维度 | PostgreSQL | MySQL InnoDB |
|---|---|---|
| 表组织 | 堆表,数据按插入顺序 | 聚簇索引,数据按主键有序 |
| 二级索引叶子 | 存 CTID(物理指针) | 存主键值 |
| 主键变更 | 不影响数据位置,只改索引 | 移动数据行,代价高 |
| 更新方式 | 插入新版本元组(旧版本留待清理) | 聚簇索引原地更新,undo log 记旧值 |
PG 二级索引存 CTID,回表是按 CTID 一次定位堆页;只查索引列时靠 Index-Only Scan(依赖可见性映射)完全免回表。MySQL 二级索引存主键,回表是二次 B+ 树查找。但 PG 的 CTID 会随 UPDATE 变化,索引需要维护。
元组与 MVCC
每行(元组)头部带隐藏字段:xmin(插入该版本的事务 ID)、xmax(删除或锁定该版本的事务 ID)、ctid(版本链指针)。UPDATE 不是原地修改,而是插入新版本并更新旧版本的 xmax 和 ctid,旧版本留在页里成为死元组,直到 VACUUM 清理。
TOAST:处理超大字段
PG 的元组不能跨页存储(8KB 页的限制)。当一行超过约 2KB 时,TOAST(The Oversized-Attribute Storage Technique)介入:
- 先压缩(默认 EXTENDED 策略,LZ 压缩)
- 压缩后仍超阈值,切成块存入专门的 TOAST 表,主表元组里只留指针
收益:避免跨页存储;查询只选非 TOAST 列时完全跳过 TOAST 表,不用读大字段;压缩对应用透明。存储策略可用 SET STORAGE 调整:PLAIN 禁止 TOAST、EXTENDED 压缩加外存(默认)、EXTERNAL 只外存不压缩、MAIN 优先压缩。
面试追问
- 堆表和聚簇索引的区别? 堆表数据按插入顺序存放不按主键有序,二级索引存 CTID;聚簇索引数据按主键组织,二级索引存主键值
- CTID 是什么? 行的物理位置(页号加行指针)。UPDATE 会改变,不能当持久业务标识
- TOAST 什么时候触发? 行超过约 2KB(页 8KB 的四分之一)。先压缩再外存,主表只留指针
- 为什么 PG 的 UPDATE 会产生死元组? MVCC 机制:更新插入新版本,旧版本留在页里等 VACUUM。这是 PG 与 MySQL undo log 方案的根本差异
- FSM 和 VM 干什么? FSM 记录页空闲空间加速插入找页,VM 标记全可见页加速 VACUUM 和仅索引扫描