PostgreSQL 索引里到底存了什么?

很多人一听到"索引",会自然想到:

索引是不是就像 MySQL InnoDB 主键一样,里面直接放着整行数据?

在 PostgreSQL 里,大多数情况下不是。

一句话先说结论:

PostgreSQL 的普通索引里主要存的是:索引字段的值 + 指向 heap 表中真实数据行的 TID。它通常不存完整行数据。

可以把 PostgreSQL 的索引理解成一本书的"目录":

text 复制代码
目录里写着:关键词在哪一页
正文内容:还在真正的书页里

PostgreSQL 里就是:

text 复制代码
索引里写着:某个 key 对应 heap 表里的哪个位置
完整数据:还在 heap 表里

1. 先看一个简单表

假设有一张订单表:

sql 复制代码
CREATE TABLE orders (
    id bigint PRIMARY KEY,
    order_no varchar(64),
    customer_id bigint,
    amount numeric,
    create_time timestamp
);

再建一个普通索引:

sql 复制代码
CREATE INDEX idx_orders_customer_id ON orders(customer_id);

这时 PostgreSQL 会创建一个索引结构。

这个索引不是把整行都复制一份进去,而是大概存:

text 复制代码
customer_id -> TID

例如:

text 复制代码
customer_id = 2001 -> (12, 5)
customer_id = 2002 -> (18, 3)
customer_id = 2003 -> (25, 9)

这里的 (12, 5) 就是一个类似地址的东西,表示这条记录在 heap 表里的位置。


2. PostgreSQL 索引里最重要的两样东西

以最常见的 B-tree 索引为例,索引里最核心的是两类信息:

text 复制代码
1. 索引 key
2. TID

2.1 索引 key

索引 key 就是你建索引的字段值。

例如:

sql 复制代码
CREATE INDEX idx_orders_customer_id ON orders(customer_id);

那么索引 key 就是:

text 复制代码
customer_id 的值

如果是联合索引:

sql 复制代码
CREATE INDEX idx_orders_customer_time ON orders(customer_id, create_time);

那么索引 key 就是:

text 复制代码
(customer_id, create_time)

也就是说,联合索引里不是只存第一个字段,而是按照索引定义顺序,把多个字段组合起来作为 key。


2.2 TID

TID 可以理解成 PostgreSQL heap 表中某一行的物理地址。

它大概长这样:

text 复制代码
(block number, tuple offset)

通俗点说就是:

text 复制代码
第几个数据页 + 这个数据页里的第几条记录

比如:

text 复制代码
(12, 5)

可以理解为:

text 复制代码
第 12 个数据块里的第 5 条记录

所以索引项可以通俗理解为:

text 复制代码
customer_id = 2001 的那条数据,在 heap 表第 12 页第 5 个位置

3. 查询时怎么用索引?

例如执行:

sql 复制代码
SELECT *
FROM orders
WHERE customer_id = 2001;

PostgreSQL 大概会这样做:

text 复制代码
1. 去 idx_orders_customer_id 索引里找 customer_id = 2001
2. 找到对应的 TID,例如 (12, 5)
3. 根据 TID 去 heap 表第 12 页第 5 个位置拿完整行
4. 返回 id、order_no、customer_id、amount、create_time 等字段

也就是说:

text 复制代码
索引负责快速定位
heap 表负责保存完整数据

这就是 PostgreSQL 普通索引查询经常还要访问 heap 的原因。


4. B-tree 索引不是只有一层

PostgreSQL 最常见的索引类型是 B-tree。

它不是一个简单列表,而是类似一棵树。

可以简化理解为三类页面:

text 复制代码
root page     根页面
internal page 中间页面
leaf page     叶子页面

大概长这样:

text 复制代码
                [root page]
                    |
        -------------------------
        |                       |
  [internal page]         [internal page]
        |                       |
  --------------          --------------
  |            |          |            |
[leaf]      [leaf]      [leaf]      [leaf]

查询时,PostgreSQL 从根页面开始,根据 key 的大小一路往下找,最后找到叶子页面。


5. B-tree 的内部页面和叶子页面分别存什么?

5.1 internal page 里存什么?

internal page 可以理解成"路牌"。

它不主要负责保存最终结果,而是告诉 PostgreSQL:

text 复制代码
小于某个值的去左边
大于某个值的去右边

比如:

text 复制代码
customer_id < 2000  去左边页面
customer_id >= 2000 去右边页面

所以 internal page 里主要是:

text 复制代码
分隔 key + 指向下一层 index page 的指针

5.2 leaf page 里存什么?

leaf page 才是最终定位到具体数据的位置。

叶子页面里的索引项大概是:

text 复制代码
索引 key + TID

例如:

text 复制代码
customer_id = 2001 -> TID (12, 5)
customer_id = 2001 -> TID (13, 8)
customer_id = 2002 -> TID (18, 3)

如果多个订单属于同一个 customer_id,索引里就会有多个 key 相同但 TID 不同的记录。


6. 索引里会不会存完整行?

普通 PostgreSQL B-tree 索引通常不会存完整行。

例如表里有:

text 复制代码
id, order_no, customer_id, amount, create_time

如果只建了:

sql 复制代码
CREATE INDEX idx_orders_customer_id ON orders(customer_id);

那么索引里主要有:

text 复制代码
customer_id + TID

它不会自动把下面这些字段都放进去:

text 复制代码
id
order_no
amount
create_time

所以执行:

sql 复制代码
SELECT *
FROM orders
WHERE customer_id = 2001;

还是需要去 heap 表拿完整行。


7. INCLUDE 字段是什么?

PostgreSQL 支持覆盖索引写法:

sql 复制代码
CREATE INDEX idx_orders_customer_id_include_amount
ON orders(customer_id)
INCLUDE (amount, order_no);

这个索引可以简单理解为:

text 复制代码
用于排序和查找的 key:customer_id
额外顺手带上的字段:amount, order_no

也就是说,这个索引叶子项里会包含:

text 复制代码
customer_id + amount + order_no + TID

注意:

text 复制代码
INCLUDE 字段不是索引查找 key

它主要是为了让某些查询可以直接从索引里拿到字段,减少访问 heap 的机会。

例如:

sql 复制代码
SELECT customer_id, amount, order_no
FROM orders
WHERE customer_id = 2001;

这个查询需要的字段都在索引里,就有机会走:

text 复制代码
Index Only Scan

8. 有 INCLUDE 字段就一定不访问 heap 吗?

不一定。

PostgreSQL 有 MVCC 机制,它要判断一行数据对当前事务是否可见。

问题是:

text 复制代码
可见性信息主要在 heap 表里,不完全在索引里

所以即使查询字段都在索引里,PostgreSQL 有时仍然要去 heap 看一下这条记录是否可见。

只有当 visibility map 显示对应 heap page 是 all-visible 时,PostgreSQL 才能更放心地不访问 heap。

看执行计划时,重点看:

sql 复制代码
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id, amount, order_no
FROM orders
WHERE customer_id = 2001;

如果看到:

text 复制代码
Index Only Scan
Heap Fetches: 0

才说明基本没有访问 heap。


9. 索引里还会有一些"管理信息"

除了 key、TID、INCLUDE 字段,索引页里还会有一些数据库内部使用的信息。

比如:

text 复制代码
页面头信息
页面之间的指针
索引项长度
排序相关信息
空值处理信息
重复 key 压缩或去重相关信息

这些东西一般业务开发不用关心。

你只要记住:

text 复制代码
索引不是只存一列值,它还需要存能找到下一页、找到 heap 行、维护有序结构的一些内部信息。

10. 重复值很多时,索引怎么处理?

比如很多订单的 customer_id 都是 2001

索引里可能逻辑上是:

text 复制代码
customer_id = 2001 -> TID (12, 5)
customer_id = 2001 -> TID (12, 6)
customer_id = 2001 -> TID (13, 2)
customer_id = 2001 -> TID (18, 9)

在较新的 PostgreSQL 版本里,B-tree 对重复 key 可能会做 deduplication,可以简单理解成:

text 复制代码
customer_id = 2001 -> 一组 TID

这样可以减少索引体积。

不过从业务理解上,你仍然可以把它看成:

text 复制代码
一个 key 对应多条 heap 记录的位置

11. 主键索引里有什么?

主键索引本质上也是一个唯一 B-tree 索引。

例如:

sql 复制代码
CREATE TABLE orders (
    id bigint PRIMARY KEY,
    order_no varchar(64),
    amount numeric
);

PostgreSQL 自动建出来的主键索引大概是:

text 复制代码
id -> TID

也就是:

text 复制代码
主键值 + heap 行位置

不是:

text 复制代码
主键值 + 完整行数据

这点和 MySQL InnoDB 非常不一样。

MySQL InnoDB 主键索引叶子节点里是完整行数据,而 PostgreSQL 主键索引叶子节点里通常还是 key + TID。


12. 不同索引类型里存的东西也不完全一样

上面主要讲的是 B-tree,因为它最常见。

PostgreSQL 还有其他索引类型。

12.1 GIN 索引

GIN 常用于数组、JSONB、全文检索。

它更像一个"倒排索引"。

例如 JSONB 里有:

json 复制代码
{
  "tags": ["java", "postgresql"]
}

GIN 索引可能更像:

text 复制代码
java       -> 哪些行包含 java
postgresql -> 哪些行包含 postgresql

它适合回答:

text 复制代码
哪些记录包含某个词 / 某个元素?

12.2 GiST 索引

GiST 常用于地理位置、范围、相似度等场景。

它不一定是简单的等值查找,而是存一些能帮助判断范围、相交、距离的信息。

可以粗略理解为:

text 复制代码
存的是某种"范围摘要"或"空间边界"

12.3 BRIN 索引

BRIN 适合特别大的表,并且数据天然有顺序的场景,比如按时间不断追加的数据。

它不会给每一行都存一个索引项。

它更像是:

text 复制代码
这一批数据页里,create_time 最小是多少,最大是多少

例如:

text 复制代码
第 1 ~ 128 个 block:create_time 从 2026-01-01 到 2026-01-03
第 129 ~ 256 个 block:create_time 从 2026-01-04 到 2026-01-06

所以 BRIN 很小,但它更适合过滤大范围,不适合像 B-tree 那样精准定位单行。


13. 一张表总结 PostgreSQL 索引里有什么

内容 是否常见 通俗解释
索引 key 常见 建索引的字段值,例如 customer_id
TID 常见 heap 表中真实数据行的位置
INCLUDE 字段 可选 额外放进索引里的字段,用来减少访问 heap
页面指针 常见 B-tree 从上一层找到下一层要用
页面/元数据 常见 PostgreSQL 维护索引结构需要的内部信息
完整行数据 通常没有 PostgreSQL 普通索引一般不存完整行
MVCC 完整可见性信息 通常没有 所以 Index Only Scan 有时仍要访问 heap

14. 常见误区

误区一:索引里有完整行

PostgreSQL 普通索引里通常没有完整行。

它主要是:

text 复制代码
key + TID

完整行在 heap 表里。


误区二:主键索引就一定不用访问表

在 PostgreSQL 里不一定。

主键索引通常还是:

text 复制代码
主键值 + TID

如果查询:

sql 复制代码
SELECT * FROM orders WHERE id = 1001;

通常还要根据 TID 去 heap 表取完整行。


误区三:有覆盖索引就一定不访问 heap

也不一定。

PostgreSQL 还要考虑 MVCC 可见性。

如果 visibility map 条件不满足,即使字段都在索引里,也可能有 heap fetch。


15. 最通俗的一句话

可以这样记:

PostgreSQL 的索引大多数时候不是"数据副本",而是"目录 + 地址"。目录里有索引字段值,地址就是 TID;真正完整的数据行还在 heap 表里。

再简单一点:

text 复制代码
索引 key:我要找什么
TID:找到后去 heap 表哪里拿完整数据
INCLUDE 字段:为了少跑一趟,顺手放在索引里的附加字段

所以 PostgreSQL 索引里最核心的东西就是:

text 复制代码
key + TID

如果有覆盖索引,再加上:

text 复制代码
INCLUDE 字段