PostgreSQL 核心存储与索引机制
校招考察频度:★ 高频(核心必背·高频生死线) 一句话速记:数据按堆无序放,物理指针靠 CTID;索引统一指向堆,HOT 堆内防膨胀;防回表靠 INCLUDE 与 VM,大翻页用 Keyset 游标。
在校招面试中,数据库底层机制是考察候选人计算机系统功底的核心试金石。不同于常见误区,PostgreSQL 采用的是堆表(Heap Table)+ 物理指针寻址的架构,理解这一底层设计是理解其 MVCC、HOT 与性能优化的基石。
一、为什么索引底层默认要用平衡树(B-Tree)?
面试官经典拷问:“为什么数据库索引底层不用 Hash、二叉搜索树、平衡二叉树(AVL)或红黑树?”
| 数据结构 | 缺点与无法作为通用主索引的原因 |
|---|---|
| Hash 索引 | 仅支持等值查询 O(1),完全不支持范围查询(<、>、BETWEEN)、模糊查询(LIKE)与排序(ORDER BY)。无法满足绝大多数业务场景。 |
| 二叉搜索树 / AVL / 红黑树 | 树的每个节点最多只有 2 个子节点,当数据量达到千万级时,树的高度(Height)极高。数据库数据持久化在磁盘上,访问树的每一个节点都对应一次随机磁盘 I/O(毫秒级)。树太深会导致查询 I/O 次数过多,系统吞吐急剧下降。 |
| B 树(B-Tree) / B+ 树 | 绝对优势:① 多叉平衡树,扇出(Fan-out)极大、树高极低。单个数据页(PG 默认为 8KB)能容纳数百个键值指针,通常 3 |
B-Tree 索引:大扇出、低树高、叶子节点直接保存数据物理指针 CTID
核心推导:树高与容量估算
- PostgreSQL 默认数据页(Page)大小为 8KB。
- 假设索引键为
BIGINT(8 字节),加上指针与头部开销约 16 字节。一个非叶子页可容纳约8192 / 16 ≈ 500个分支路由(扇出系数)。 - 3 层平衡树:根节点(500 分支)× 二级节点(500 分支)× 叶子节点(容纳约 300 条索引项),可支撑:
500 × 500 × 300 ≈ 7500 万行记录 - 结论:在数千万级数据体量下,通过 B-Tree 索引定位记录只需要 3 次页读取(且根节点与二级节点通常常驻内存
shared_buffers,实际只需 1 次磁盘 I/O)。
二、堆表(Heap Table)与 CTID 物理寻址
这是 PostgreSQL 与很多聚簇索引数据库最根本的架构区别:
1. 堆表组织形式
- 无序存储:数据行(元组 Tuple)是按照插入的时间顺序追加写入到数据堆表(Heap Table)的各个 8KB 数据页中的,不按主键的顺序物理排列。
- CTID(Item Pointer):每个元组在物理存储上的唯一坐标,格式为
(BlockNumber, OffsetNumber):BlockNumber:数据块号(第几页);OffsetNumber:页内行指针数组(ItemId)的下标(第几个槽位)。
- 通过
SELECT ctid, * FROM users;可以直接查出每行记录当前的物理存储位置。
2. 索引地位完全平等
- 在 PostgreSQL 中,主键索引和二级索引没有任何物理存储上的特权。
- 无论是主键的 B-tree 索引,还是在普通列上创建的二级索引,它们的叶子节点存放的都是:
索引列键值 (Key) ──> 目标行在堆表中的物理地址 (CTID) - 回表访问:通过索引定位到 CTID 后,直接根据
BlockNumber读入对应的数据页,再根据OffsetNumber取出完整行数据。
查询执行路径:
[SQL: WHERE username = 'alice']
│
▼ (遍历 B-Tree)
[索引页 Leaf Tuple: Key='alice', CTID=(Block 42, Offset 3)]
│
▼ (一次寻址)
[堆表数据页 Block 42 -> 槽位 Offset 3 -> 提取整行数据并检查 MVCC 可见性]三、HOT(Heap-Only Tuples)堆内元组技术(高分分水岭)
1. 堆表架构的原生痛点:写放大与索引膨胀
在传统的堆表更新中,每次 UPDATE 一行数据:
- 原位置装不下或因为 MVCC,必须在堆表中插入一条新的元组(拥有全新的 CTID);
- 因为 CTID 改变了,表上所有的索引(主键索引、联合索引、外键索引等)都必须插入一条新的索引项指向新的 CTID;
- 这会导致巨大的磁盘写 I/O,以及严重的索引膨胀(Index Bloat)。
2. HOT 机制的触发条件与原理
PostgreSQL 8.3 引入了 HOT(堆内元组) 优化。当满足以下两个条件时自动触发:
- 未更新索引列:本次
UPDATE操作没有修改任何建立在表上的索引列; - 同页有空闲空间:原元组所在的同一个 8KB 数据页内,有足够的空闲空间容纳新生成的元组。
HOT 机制:索引指针保持不变,页内元组通过 t_ctid 形成链表
- 执行效果:
- 索引页根本无需任何修改,依然指向最初的根元组
(1, 1); - 堆页内原元组的指针
t_ctid被修改为指向新元组(1, 2); - 查询沿着索引定位到
(1, 1)后,在堆页内部顺着 HOT 链表直接找到最新的(1, 2); - 收益:大幅消除写放大,彻底避免了频繁更新非索引列时的索引膨胀!
- 索引页根本无需任何修改,依然指向最初的根元组
- 生产调优建议:对更新极频繁的表,可在建表时调小
fillfactor(如fillfactor = 80),为每个页预留 20% 空闲空间,专门用于容纳 HOT 更新。
四、覆盖索引(Covering Index)与 Index-Only Scan
1. INCLUDE 子句(覆盖索引利器)
PostgreSQL 11 引入了对 B-tree 索引的 INCLUDE 支持:
CREATE INDEX idx_orders_user_covering
ON orders (user_id)
INCLUDE (amount, status);- 核心原理:
user_id是索引键(Key Column),参与 B-tree 树形分支的排序与二分查找;amount, status是附加列(Non-key Column),仅保存在叶子节点中,不参与树的排序,不增加内部节点的扇出压力;- 当执行
SELECT amount, status FROM orders WHERE user_id = 100;时,所有需要的数据都在索引叶子页中。
2. Index-Only Scan 成立的关键:可见性映射(VM)
在很多系统里覆盖索引直接返回数据即可,但在 PostgreSQL 中,索引元组内部不包含事务可见性信息(没有 xmin/xmax)! 那它是怎么做到零回表的?
- 依赖 Visibility Map(可见性映射,简称 VM) 文件:
- VM 用位图记录每个数据页是否对所有当前事务“全可见(All-Visible)”;
- 如果 VM 标记该数据页为全可见,说明该页中所有元组都早已提交且没有死元组,执行器直接信任索引并返回数据(真正实现零回表);
- 如果 VM 显示该页可能存在未提交或已删除元组,执行器仍必须访问堆页验证可见性(产生 Heap Fetches)。
- 面试要点:若发现 EXPLAIN 中 Index-Only Scan 伴随大量
Heap Fetches,通常是因为该表缺乏及时VACUUM,导致 VM 位图未被更新。
五、最左前缀原则与经典索引失效场景
对联合索引 INDEX idx_order (tenant_id, user_id, create_time):
- 最左前缀原理:索引树首先按
tenant_id排序;在tenant_id相同的前提下再按user_id排序;以此类推。 - 匹配规律:
- 必须包含最左列:
WHERE tenant_id = 1(命中)、WHERE tenant_id = 1 AND user_id = 2(全命中); - 跳过最左列:
WHERE user_id = 2(完全无法使用该索引); - 范围中断效应:
WHERE tenant_id = 1 AND user_id > 10 AND create_time = '2026-01-01',只有tenant_id和user_id走索引检索,create_time无法用于树的二分定位。
- 必须包含最左列:
经典索引失效场景手册(面试必背)
- 索引列被函数包裹或算术运算:
- 错误:
WHERE DATE(created_at) = '2026-01-01'或WHERE age + 1 = 20; - 解决:改写为范围查询
WHERE created_at >= '2026-01-01' AND created_at < '2026-01-02',或直接建立表达式索引:CREATE INDEX idx_user_lower_email ON users (lower(email));
- 错误:
- 隐式类型转换:
- 如
phone字段为VARCHAR,查询写成WHERE phone = 13800000000(数字类型),优化器会进行类型强制转换,导致索引失效。
- 如
- 左前缀模糊查询:
LIKE '%abc'或LIKE '%abc%'无法使用 B-tree 索引。- 解决:如果必须全文模糊检索,使用 PostgreSQL 原生支持的 GIN 倒排索引 + pg_trgm 扩展:
CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX idx_user_name_trgm ON users USING gin (username gin_trgm_ops);
- 部分索引(Partial Index)利用不当:
- PG 支持强大的部分索引,只索引活跃数据:
CREATE INDEX idx_active_orders ON orders (user_id) WHERE status = 'PENDING'; - 只有查询条件显式带上
WHERE status = 'PENDING'时,优化器才会走该索引。
- PG 支持强大的部分索引,只索引活跃数据:
- 优化器代价评估(选择度过低):
- 当查询条件命中的行数超过全表约 20%~30% 时,优化器评估随机 I/O(
random_page_cost)的成本高于连续顺序扫描(seq_page_cost),会主动放弃索引而走 Seq Scan。
- 当查询条件命中的行数超过全表约 20%~30% 时,优化器评估随机 I/O(
六、千万级深分页(Deep Pagination)性能陷阱与优化
面试高频压轴题:“在数千万数据量下,SELECT * FROM orders ORDER BY id LIMIT 10 OFFSET 1000000; 为什么会导致慢 SQL 甚至拖垮数据库?如何优化?”
1. 为什么深分页会极慢?
- 执行代价:即使
id上有索引,执行引擎也必须在索引中顺序扫描前1,000,010个元组,逐一通过 CTID 读取堆表数据并判断 MVCC 可见性; - 然后将前
1,000,000条数据在内存中无情抛弃,仅仅返回最后 10 条; - 这导致前 100 万次磁盘 I/O 和 CPU 解码完全是无用功。
2. 工业界优化三大招
方案一:Keyset 游标分页(Seek Method · 性能极致 · 首选)
利用主键或有序字段的唯一性,每次翻页带上上一页最后一条记录的标识:
-- 查下一页:传入上一页最后一条记录的 id
SELECT * FROM orders
WHERE id > 985623
ORDER BY id ASC
LIMIT 10;- 原理:B-tree 树形查找
O(log N)直接定位到id = 985623对应的叶子节点位置,直接向后拉取 10 条数据即可。 - 耗时:恒定在 1ms 以内,与翻页深度彻底脱钩。
- 缺点:仅支持“上一页/下一页”滚动加载,无法随机跳到“第 500 页”。
方案二:子查询延迟关联(CTE 结合覆盖索引)
若必须支持跳转页码,先在只包含索引列的子查询中完成分页(利用 Index-Only Scan 免读堆表),最后通过 JOIN 回表读取整行:
WITH paged_ids AS (
SELECT id FROM orders
ORDER BY created_at
LIMIT 10 OFFSET 1000000
)
SELECT o.*
FROM orders o
JOIN paged_ids p ON o.id = p.id;- 原理:子查询只扫描索引,无需读取宽表数据;最终仅针对目标 10 条记录读取堆表。
方案三:业务限制与搜索引擎分流
- 限制最大翻页深度(如前 100 页);超大规模深度查询交由数仓或 Elasticsearch 处理。