PostgreSQL 索引里到底存了什么?
很多人一听到“索引”会自然想到索引是不是就像 MySQL InnoDB 主键一样里面直接放着整行数据在 PostgreSQL 里大多数情况下不是。一句话先说结论PostgreSQL 的普通索引里主要存的是索引字段的值 指向 heap 表中真实数据行的 TID。它通常不存完整行数据。可以把 PostgreSQL 的索引理解成一本书的“目录”目录里写着关键词在哪一页 正文内容还在真正的书页里PostgreSQL 里就是索引里写着某个 key 对应 heap 表里的哪个位置 完整数据还在 heap 表里1. 先看一个简单表假设有一张订单表CREATETABLEorders(idbigintPRIMARYKEY,order_novarchar(64),customer_idbigint,amountnumeric,create_timetimestamp);再建一个普通索引CREATEINDEXidx_orders_customer_idONorders(customer_id);这时 PostgreSQL 会创建一个索引结构。这个索引不是把整行都复制一份进去而是大概存customer_id - TID例如customer_id 2001 - (12, 5) customer_id 2002 - (18, 3) customer_id 2003 - (25, 9)这里的(12, 5)就是一个类似地址的东西表示这条记录在 heap 表里的位置。2. PostgreSQL 索引里最重要的两样东西以最常见的 B-tree 索引为例索引里最核心的是两类信息1. 索引 key 2. TID2.1 索引 key索引 key 就是你建索引的字段值。例如CREATEINDEXidx_orders_customer_idONorders(customer_id);那么索引 key 就是customer_id 的值如果是联合索引CREATEINDEXidx_orders_customer_timeONorders(customer_id,create_time);那么索引 key 就是(customer_id, create_time)也就是说联合索引里不是只存第一个字段而是按照索引定义顺序把多个字段组合起来作为 key。2.2 TIDTID可以理解成 PostgreSQL heap 表中某一行的物理地址。它大概长这样(block number, tuple offset)通俗点说就是第几个数据页 这个数据页里的第几条记录比如(12, 5)可以理解为第 12 个数据块里的第 5 条记录所以索引项可以通俗理解为customer_id 2001 的那条数据在 heap 表第 12 页第 5 个位置3. 查询时怎么用索引例如执行SELECT*FROMordersWHEREcustomer_id2001;PostgreSQL 大概会这样做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 等字段也就是说索引负责快速定位 heap 表负责保存完整数据这就是 PostgreSQL 普通索引查询经常还要访问 heap 的原因。4. B-tree 索引不是只有一层PostgreSQL 最常见的索引类型是 B-tree。它不是一个简单列表而是类似一棵树。可以简化理解为三类页面root page 根页面 internal page 中间页面 leaf page 叶子页面大概长这样[root page] | ------------------------- | | [internal page] [internal page] | | -------------- -------------- | | | | [leaf] [leaf] [leaf] [leaf]查询时PostgreSQL 从根页面开始根据 key 的大小一路往下找最后找到叶子页面。5. B-tree 的内部页面和叶子页面分别存什么5.1 internal page 里存什么internal page 可以理解成“路牌”。它不主要负责保存最终结果而是告诉 PostgreSQL小于某个值的去左边 大于某个值的去右边比如customer_id 2000 去左边页面 customer_id 2000 去右边页面所以 internal page 里主要是分隔 key 指向下一层 index page 的指针5.2 leaf page 里存什么leaf page 才是最终定位到具体数据的位置。叶子页面里的索引项大概是索引 key TID例如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 索引通常不会存完整行。例如表里有id, order_no, customer_id, amount, create_time如果只建了CREATEINDEXidx_orders_customer_idONorders(customer_id);那么索引里主要有customer_id TID它不会自动把下面这些字段都放进去id order_no amount create_time所以执行SELECT*FROMordersWHEREcustomer_id2001;还是需要去 heap 表拿完整行。7. INCLUDE 字段是什么PostgreSQL 支持覆盖索引写法CREATEINDEXidx_orders_customer_id_include_amountONorders(customer_id)INCLUDE(amount,order_no);这个索引可以简单理解为用于排序和查找的 keycustomer_id 额外顺手带上的字段amount, order_no也就是说这个索引叶子项里会包含customer_id amount order_no TID注意INCLUDE 字段不是索引查找 key它主要是为了让某些查询可以直接从索引里拿到字段减少访问 heap 的机会。例如SELECTcustomer_id,amount,order_noFROMordersWHEREcustomer_id2001;这个查询需要的字段都在索引里就有机会走Index Only Scan8. 有 INCLUDE 字段就一定不访问 heap 吗不一定。PostgreSQL 有 MVCC 机制它要判断一行数据对当前事务是否可见。问题是可见性信息主要在 heap 表里不完全在索引里所以即使查询字段都在索引里PostgreSQL 有时仍然要去 heap 看一下这条记录是否可见。只有当 visibility map 显示对应 heap page 是all-visible时PostgreSQL 才能更放心地不访问 heap。看执行计划时重点看EXPLAIN(ANALYZE,BUFFERS)SELECTcustomer_id,amount,order_noFROMordersWHEREcustomer_id2001;如果看到Index Only Scan Heap Fetches: 0才说明基本没有访问 heap。9. 索引里还会有一些“管理信息”除了 key、TID、INCLUDE 字段索引页里还会有一些数据库内部使用的信息。比如页面头信息 页面之间的指针 索引项长度 排序相关信息 空值处理信息 重复 key 压缩或去重相关信息这些东西一般业务开发不用关心。你只要记住索引不是只存一列值它还需要存能找到下一页、找到 heap 行、维护有序结构的一些内部信息。10. 重复值很多时索引怎么处理比如很多订单的customer_id都是2001。索引里可能逻辑上是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可以简单理解成customer_id 2001 - 一组 TID这样可以减少索引体积。不过从业务理解上你仍然可以把它看成一个 key 对应多条 heap 记录的位置11. 主键索引里有什么主键索引本质上也是一个唯一 B-tree 索引。例如CREATETABLEorders(idbigintPRIMARYKEY,order_novarchar(64),amountnumeric);PostgreSQL 自动建出来的主键索引大概是id - TID也就是主键值 heap 行位置不是主键值 完整行数据这点和 MySQL InnoDB 非常不一样。MySQL InnoDB 主键索引叶子节点里是完整行数据而 PostgreSQL 主键索引叶子节点里通常还是 key TID。12. 不同索引类型里存的东西也不完全一样上面主要讲的是 B-tree因为它最常见。PostgreSQL 还有其他索引类型。12.1 GIN 索引GIN 常用于数组、JSONB、全文检索。它更像一个“倒排索引”。例如 JSONB 里有{tags:[java,postgresql]}GIN 索引可能更像java - 哪些行包含 java postgresql - 哪些行包含 postgresql它适合回答哪些记录包含某个词 / 某个元素12.2 GiST 索引GiST 常用于地理位置、范围、相似度等场景。它不一定是简单的等值查找而是存一些能帮助判断范围、相交、距离的信息。可以粗略理解为存的是某种“范围摘要”或“空间边界”12.3 BRIN 索引BRIN 适合特别大的表并且数据天然有顺序的场景比如按时间不断追加的数据。它不会给每一行都存一个索引项。它更像是这一批数据页里create_time 最小是多少最大是多少例如第 1 ~ 128 个 blockcreate_time 从 2026-01-01 到 2026-01-03 第 129 ~ 256 个 blockcreate_time 从 2026-01-04 到 2026-01-06所以 BRIN 很小但它更适合过滤大范围不适合像 B-tree 那样精准定位单行。13. 一张表总结 PostgreSQL 索引里有什么内容是否常见通俗解释索引 key常见建索引的字段值例如customer_idTID常见heap 表中真实数据行的位置INCLUDE 字段可选额外放进索引里的字段用来减少访问 heap页面指针常见B-tree 从上一层找到下一层要用页面/元数据常见PostgreSQL 维护索引结构需要的内部信息完整行数据通常没有PostgreSQL 普通索引一般不存完整行MVCC 完整可见性信息通常没有所以 Index Only Scan 有时仍要访问 heap14. 常见误区误区一索引里有完整行PostgreSQL 普通索引里通常没有完整行。它主要是key TID完整行在 heap 表里。误区二主键索引就一定不用访问表在 PostgreSQL 里不一定。主键索引通常还是主键值 TID如果查询SELECT*FROMordersWHEREid1001;通常还要根据 TID 去 heap 表取完整行。误区三有覆盖索引就一定不访问 heap也不一定。PostgreSQL 还要考虑 MVCC 可见性。如果 visibility map 条件不满足即使字段都在索引里也可能有 heap fetch。15. 最通俗的一句话可以这样记PostgreSQL 的索引大多数时候不是“数据副本”而是“目录 地址”。目录里有索引字段值地址就是 TID真正完整的数据行还在 heap 表里。再简单一点索引 key我要找什么 TID找到后去 heap 表哪里拿完整数据 INCLUDE 字段为了少跑一趟顺手放在索引里的附加字段所以 PostgreSQL 索引里最核心的东西就是key TID如果有覆盖索引再加上INCLUDE 字段