BRIN 索引原理
前面几节的索引(B-Tree、Hash、GIN、GiST、SP-GiST)都有一个共同前提:索引项要精确指向行。这让它们能把查询准确落到具体的数据行,代价则是索引体积随数据量线性增长。当一张表有几十亿行、又只按时间范围查询时,这种精确性其实是浪费。
BRIN(Block Range Index,块范围索引)走的是相反的路:它只给每一批物理相邻的数据页存一份摘要(min/max)。索引因此可以小到几 MB,代价是它只能回答「这批页里可能有你要的数据」,剩下的交给回表复检。本节讲清 BRIN 的摘要模型、pages_per_range 的取舍、相关性(correlation)这个硬性前提、有损与 recheck 机制,以及它与 B-Tree 的全面对比。学完你能判断一张表该不该上 BRIN,以及该把 pages_per_range 调成多少。
前置知识
阅读本节前,建议先了解:SP-GiST 索引
基础概念
一句话理解 BRIN
BRIN 不记录「哪一行等于什么」,而是记录「哪一批页里的值落在什么范围」。
表(物理文件,按页排列):
页 0..127 页 128..255 页 256..383 页 384..511
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ created │ │ created │ │ created │ │ created │
│ 01-01… │ │ 01-05… │ │ 01-09… │ │ 01-13… │
│ 01-04 │ │ 01-08 │ │ 01-12 │ │ 01-16 │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│min 01-01│ │min 01-05│ │min 01-09│ │min 01-13│ ← BRIN 索引项
│max 01-04│ │max 01-08│ │max 01-12│ │max 01-16│ (一行一个范围)
└─────────┘ └─────────┘ └─────────┘ └─────────┘一个「块范围」(block range / page range)是物理上相邻的一组页。BRIN 对每个范围只存一份摘要。查 WHERE created_at = '2026-01-10' 时,BRIN 扫一遍摘要就能排除前两个范围,只回表扫描第 3 个范围对应的 128 个页。
官方定义很直白:
BRIN stands for Block Range Index. BRIN is designed for handling very large tables in which certain columns have some natural correlation with their physical location within the table.
关键词是 natural correlation with their physical location——列值要和行的物理位置有天然相关性。这是 BRIN 成立的唯一前提,后面会专门展开。
摘要里存什么:由操作符类决定
BRIN 的索引项内容取决于列选择的操作符类(operator class)。官方把内置类分成四族:
| 族系 | 存什么 | 适用列 | 代表类 |
|---|---|---|---|
minmax | 范围内最小值和最大值 | 有线性排序的类型 | int8_minmax_ops、timestamptz_minmax_ops |
minmax_multi | 多个 min/max 区间对 | 值分散但有聚集的类型 | int8_minmax_multi_ops、timestamp_minmax_multi_ops |
inclusion | 一个能「包含」范围内所有值的值 | 几何、网络、范围类型 | box_inclusion_ops、inet_inclusion_ops、range_inclusion_ops |
bloom | 范围内所有值的 Bloom 过滤器 | 只有等值查询的场景 | text_bloom_ops、uuid_bloom_ops |
最常用的是 minmax 族。对 timestamptz 列,BRIN 每个范围存 (min, max) 两个时间戳;查询的常量落在这个闭区间内,范围就被保留,否则跳过。
查询 created_at >= '2026-01-10' AND created_at < '2026-01-11'
范围 1 min=01-01 max=01-04 → 01-10 不在 [01-01,01-04] → 跳过 ✅
范围 2 min=01-05 max=01-08 → 01-10 不在 [01-05,01-08] → 跳过 ✅
范围 3 min=01-09 max=01-12 → [01-10,01-11) 与 [01-09,01-12] 相交 → 保留 ⚠️
范围 4 min=01-13 max=01-16 → 01-10 不在 [01-13,01-16] → 跳过 ✅注意「相交就保留」:哪怕查询只要 1 月 10 日这一天,范围 3 里的 01-09、01-11、01-12 也会被一并读出来,然后在回表时被复检剔除。这就是有损(lossy)的来源。
pages_per_range:精度与体积的旋钮
块范围的大小由存储参数 pages_per_range 决定,默认 128。官方原文:
The number of index entries will be equal to the size of the relation in pages divided by the selected value for
pages_per_range. Therefore, the smaller the number, the larger the index becomes (because of the need to store more index entries), but at the same time the summary data stored can be more precise and more data blocks can be skipped during an index scan.
pages_per_range | 索引项数量 | 索引体积 | 摘要精度 | 回表页数 |
|---|---|---|---|---|
| 小(如 1) | 多 | 大(可能接近 B-Tree) | 高 | 少 |
| 128(默认) | 中 | 小 | 中 | 中 |
| 大(如 1024) | 少 | 极小 | 低 | 多 |
索引项数 = 表页数 / pages_per_range。一张 100 GB 的表(约 1310 万个 8 KB 页)在默认值下只有约 10 万个索引项,每项几十字节,索引总大小通常只有几百 KB 到几 MB。
相关性(correlation):BRIN 成立的唯一前提
PostgreSQL 在 pg_stats 里为每个列维护一个 correlation 值,范围 -1 ~ 1,表示列值的排序顺序与行的物理顺序的一致程度:
correlation | 含义 | BRIN 效果 |
|---|---|---|
接近 1 或 -1 | 物理顺序与值顺序高度一致 | 极佳,摘要区间互不重叠 |
接近 0 | 物理顺序与值顺序无关 | 无效,每个范围都覆盖全值域 |
直觉上:如果新插入的行总是追加到表末尾,而 created_at 又随插入单调递增,那么早的行在物理上也在前面,correlation ≈ 1。反之,如果 created_at 是随机生成的,一个范围里的值横跨全时间轴,每个范围的 min/max 都会是「最小值 ~ 最大值」,BRIN 就等于没用。
-- 查看某列的物理相关性
SELECT attname, correlation, n_distinct
FROM pg_stats
WHERE tablename = 'events' AND attname = 'created_at'; attname | correlation | n_distinct
-------------+-------------+------------
created_at | 0.999973 | 4821
(1 row)correlation = 0.999973 说明物理顺序几乎完全等于时间顺序——这正是 BRIN 的理想输入。
相关性会随着 UPDATE 衰减
UPDATE 在 PostgreSQL 里是「删除旧行 + 插入新行」。更新 created_at 会把行搬到表尾,破坏物理顺序,correlation 随之下降。BRIN 最适合「只追加、不更新」的表,例如日志、事件、指标。
有损索引与 recheck
BRIN 只能给出候选页范围,不能给出确定的行。因此查询执行器必须复检(recheck)候选范围内的每一行:
| 计划节点字段 | 含义 |
|---|---|
Recheck Cond: (...) | 索引条件需要重新求值 |
Rows Removed by Index Recheck: N | 复检时被剔除的假阳性行数 |
Heap Blocks: exact=N lost=N | 精确命中的堆页数 / 丢失(有损)的堆页数 |
QUERY PLAN
---------------------------------------------------------------
Bitmap Heap Scan on events
Recheck Cond: (created_at >= '...'::timestamptz)
Rows Removed by Index Recheck: 3421 ← 假阳性
Heap Blocks: exact=126Rows Removed by Index Recheck 的存在是正常的。只要它不是「和结果集同数量级」,BRIN 的收益就成立——因为它省下的是成千上万个堆页的随机读。
BRIN 不支持什么
BRIN 的能力边界非常清晰:
| 能力 | BRIN 是否支持 | 说明 |
|---|---|---|
范围查询(< > BETWEEN) | ✅ | 主要用途 |
等值查询(=) | ⚠️ | 靠 min/max 剪枝,对低基数列几乎无效 |
| 多列索引 | ✅ | 每列各存一份摘要 |
| 唯一索引 | ❌ | 摘要无法证明唯一性 |
排序(ORDER BY 走索引) | ❌ | 不维护全序 |
| 任意点查(OLTP 主键查询) | ❌ | 选择性不够,回表太多 |
INCLUDE 覆盖列 | ❌ | BRIN 不支持 |
排他约束 EXCLUDE | ❌ | 需要 GiST |
-- 错误!BRIN 不支持唯一索引
CREATE UNIQUE INDEX bad ON events USING brin (created_at);ERROR: access method "brin" does not support unique indexes-- 错误!BRIN 不支持 INCLUDE 覆盖列
CREATE INDEX bad2 ON events USING brin (created_at) INCLUDE (level);ERROR: access method "brin" does not support included columnsBRIN 与 B-Tree 全面对比
这张表是本节的核心结论:
| 维度 | BRIN | B-Tree |
|---|---|---|
| 索引逻辑 | 每个页范围一份摘要 | 每个键值一条索引项 |
| 索引体积 | 极小(可小几个数量级) | 大(常为表体积 10%~40%) |
| 建索引速度 | 极快(顺序扫一遍) | 慢(要排序) |
| 写入代价 | 极低 | 高(随机页分裂) |
| 查询精度 | 有损,需要 recheck | 精确 |
| 范围查询效率 | 好(前提是相关性高) | 好 |
| 等值查询效率 | 差 | 极好 |
| 唯一约束 | 不支持 | 支持 |
| 排序支持 | 不支持 | 支持 |
| 多列 | 支持(各自摘要) | 支持(前缀规则) |
| 适用数据 | 巨大、只追加、列值有序 | 任意 |
一句话:B-Tree 用空间换精度,BRIN 用精度换空间。表越大、相关性越高,BRIN 的相对优势越明显。
语法与用法
-- 基本形式(默认操作符类、默认 pages_per_range = 128)
CREATE INDEX 索引名 ON 表名 USING brin (列名);
-- 指定块范围大小
CREATE INDEX 索引名 ON 表名 USING brin (列名) WITH (pages_per_range = 32);
-- 开启自动汇总
CREATE INDEX 索引名 ON 表名 USING brin (列名) WITH (autosummarize = on);
-- 显式指定操作符类(多列 min/max 场景)
CREATE INDEX 索引名 ON 表名 USING brin (列名 int8_minmax_multi_ops);
-- 多列 BRIN(每列独立摘要)
CREATE INDEX 索引名 ON 表名 USING brin (列1, 列2);
-- 部分索引
CREATE INDEX 索引名 ON 表名 USING brin (列名) WHERE 条件;
-- 并发创建
CREATE INDEX CONCURRENTLY 索引名 ON 表名 USING brin (列名);最小可运行示例——给一张事件表的时间列建 BRIN 索引:
DROP TABLE IF EXISTS brin_demo;
CREATE TABLE brin_demo (
id bigserial,
created_at timestamptz NOT NULL,
level text NOT NULL
);
-- 按时间顺序灌入数据,保证物理顺序与时间顺序一致
INSERT INTO brin_demo (created_at, level)
SELECT
timestamptz '2026-01-01 00:00:00+08' + (g || ' seconds')::interval,
(ARRAY['INFO','WARN','ERROR'])[1 + (g % 3)]
FROM generate_series(1, 500000) AS g;
-- 建 BRIN 索引
CREATE INDEX brin_demo_created_brin ON brin_demo USING brin (created_at);
ANALYZE brin_demo;DROP TABLE
CREATE TABLE
INSERT 0 500000
CREATE INDEX
ANALYZE查询一个窄时间范围:
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*)
FROM brin_demo
WHERE created_at >= timestamptz '2026-01-01 12:00:00+08'
AND created_at < timestamptz '2026-01-01 12:10:00+08'; QUERY PLAN
----------------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
Buffers: shared hit=116
-> Bitmap Heap Scan on brin_demo (actual rows=600 loops=1)
Recheck Cond: ((created_at >= '2026-01-01 12:00:00+08'::timestamptz) AND (created_at < ...))
Rows Removed by Index Recheck: 1024
Heap Blocks: lossy=80
Buffers: shared hit=114
-> Bitmap Index Scan on brin_demo_created_brin (actual rows=5120 loops=1)
Index Cond: ((created_at >= ...) AND (created_at < ...))
Buffers: shared hit=2
(9 rows)索引只用了 2 个缓冲页就剪枝完毕,最终返回 600 行——这就是 BRIN 的典型形态:索引扫描代价几乎可以忽略,主要成本在回表复检。
详细说明
摘要的最小单位是「块范围」,不是行
理解 BRIN 的第一步是理解它操作的对象是页而不是行。BRIN 索引项里存的是「第 N 个块范围(第 N*pages_per_range 页起)的摘要」,既不含行号,也不含键值。
-- 观察 BRIN 索引的规模
SELECT
c.relname,
pg_size_pretty(pg_relation_size(c.oid)) AS index_size,
pg_size_pretty(pg_relation_size(i.indrelid)) AS table_size,
pg_relation_size(i.indrelid) / pg_relation_size(c.oid) AS ratio
FROM pg_class c
JOIN pg_index i ON i.indexrelid = c.oid
WHERE c.relname = 'brin_demo_created_brin'; relname | index_size | table_size | ratio
--------------------------+------------+------------+-------
brin_demo_created_brin | 32 kB | 27 MB | 864
(1 row)表 27 MB,索引 32 kB——索引只有表的约 1/864。这是 BRIN 存在的全部意义。
用 pageinspect 直接看摘要内容
pageinspect 扩展提供 brin_page_items(),能把 BRIN 每个索引项的真实摘要值打印出来。这是理解摘要模型最直接的方式:
CREATE EXTENSION IF NOT EXISTS pageinspect;
SELECT
blknum,
itemoffset,
attnum,
allnulls,
placeholder,
value
FROM brin_page_items(get_raw_page('brin_demo_created_brin', 1), 'brin_demo_created_brin')
LIMIT 5; blknum | itemoffset | attnum | allnulls | placeholder | value
--------+------------+--------+----------+-------------+-----------------------------------
0 | 1 | 1 | f | f | {2026-01-01 00:00:00+08..2026-01-01 ...}
0 | 2 | 1 | f | f | {2026-01-01 ...+08..2026-01-01 ...+08}
0 | 3 | 1 | f | f | {2026-01-01 ...+08..2026-01-01 ...+08}
(3 rows)value 列的 {min..max} 就是 minmax 操作符类为每个块范围存下的摘要。placeholder = f 说明这个范围已汇总;如果是 t,说明该范围尚未汇总。
观测尚未汇总的范围
新增数据落在新的页上时,新页所属的块范围不会自动获得摘要。它要等一次汇总动作:
- 表被
VACUUM(手动或 autovacuum)时,所有未汇总范围会被汇总; - 索引开了
autosummarize = on时,autovacuum 运行时也会汇总已填满的未汇总范围; - 手动调用
brin_summarize_new_values()或brin_summarize_range()。
-- 再灌一批数据
INSERT INTO brin_demo (created_at, level)
SELECT
timestamptz '2026-02-01 00:00:00+08' + (g || ' seconds')::interval,
'INFO'
FROM generate_series(1, 200000) AS g;
-- 查看有多少范围还是 placeholder(未汇总)
SELECT count(*) FILTER (WHERE placeholder) AS unsummarized,
count(*) AS total
FROM brin_page_items(get_raw_page('brin_demo_created_brin', 1), 'brin_demo_created_brin'); unsummarized | total
--------------+-------
27 | 322
(1 row)未汇总的范围在查询里会被当成「可能有数据」全部保留,也就是说这段时间内 BRIN 的剪枝能力会退化。这正是 autosummarize 存在的理由:
CREATE INDEX idx_events_brin_auto ON brin_demo USING brin (created_at)
WITH (pages_per_range = 128, autosummarize = on);CREATE INDEX官方对它的描述是:
Defines whether a summarization run is queued for the previous page range whenever an insertion is detected on the next one.
它的工作机制:当插入落到「下一个块范围的第一页的第一项」时,PostgreSQL 向 autovacuum 排队一个针对上一个范围的定向汇总请求,等下一次 autovacuum worker 在同一数据库里跑完时执行。
请求队列可能满
如果请求队列满了,请求不会被记录,日志里会出现:
LOG: request for BRIN range summarization for index "brin_wi_idx" page 128 was not recorded此时该范围会一直未汇总,直到下一次常规 VACUUM 或手动调用汇总函数。
autosummarize 默认是 off。对高频写入的时序表,建议显式打开:
ALTER INDEX idx_events_brin_auto SET (autosummarize = on);需要特别留意数据乱序写入的影响。autosummarize 假设的写入模式是追加:新数据总是落在更后面的页上。如果写入是乱序的(比如回填历史数据、或者 created_at 随机),那么:
- 新行落进已经汇总过的范围时,摘要会被就地更新(
min可能变小、max可能变大),这是自动的,不需要额外操作; - 但如果一个范围因为乱序写入而变得「值域极大」,它的摘要就退化成「覆盖几乎全部时间轴」,剪枝失效。
理想情况(追加写):
范围 1: min=01-01 max=01-04 区间窄,剪枝有效
范围 2: min=01-05 max=01-08
范围 3: min=01-09 max=01-12
乱序写之后:
范围 1: min=01-01 max=12-31 区间暴涨,剪枝失效
范围 2: min=01-01 max=12-31
范围 3: min=01-01 max=12-31判定方式还是看 correlation。低于 0.9 就基本可以放弃 BRIN 了:
SELECT attname, correlation
FROM pg_stats
WHERE tablename = 'brin_demo' AND attname = 'created_at'; attname | correlation
-------------+-------------
created_at | 0.999984
(1 row)这三个函数是 BRIN 的日常维护入口:
| 函数 | 作用 |
|---|---|
brin_summarize_new_values(regclass) | 汇总该索引所有未汇总的范围,返回汇总的范围数 |
brin_summarize_range(regclass, bigint) | 只汇总给定页所在的范围(若未汇总) |
brin_desummarize_range(regclass, bigint) | 反汇总:把给定页所在范围内已有的摘要删掉 |
-- 汇总所有未汇总范围,返回处理的范围数
SELECT brin_summarize_new_values('brin_demo_created_brin'); brin_summarize_new_values
---------------------------
27
(1 row)-- 只汇总第 128 页所在的范围(范围大小 128,所以这是第二个范围)
SELECT brin_summarize_range('brin_demo_created_brin', 128); brin_summarize_range
----------------------
0
(1 row)返回 0 表示该范围已经汇总过了,无需处理。
反汇总用于「摘要已经不能代表现状」的场景,比如行被大规模更新、删除,摘要里的 min/max 已严重失真:
-- 反汇总,之后可以靠 VACUUM 或手动重新汇总
SELECT brin_desummarize_range('brin_demo_created_brin', 128); brin_desummarize_range
------------------------
1
(1 row)多列 BRIN 与 minmax_multi_ops
BRIN 支持多列索引。每一列各存一份摘要,查询里对哪一列做范围过滤,就靠哪一列的摘要剪枝:
CREATE INDEX brin_demo_multi ON brin_demo USING brin (created_at, level);CREATE INDEXlevel 是低基数列(只有 INFO/WARN/ERROR),它的 min/max 摘要几乎覆盖不了任何东西——min='ERROR'、max='WARN' 会命中每一个范围。所以多列 BRIN 里低基数列是负担,占空间且无剪枝收益。
真正有用的多列场景是「两个高相关性、高基数列」:
CREATE INDEX idx_metrics_brin ON metrics USING brin (device_id, created_at);从 PostgreSQL 14 开始,BRIN 引入了 minmax-multi 操作符类(int8_minmax_multi_ops、timestamp_minmax_multi_ops、timestamptz_minmax_multi_ops 等)。它存的不是一个 (min, max),而是多组 min/max 区间对,能更好地描述「值在范围内有多个聚集簇」的分布:
-- 用 minmax_multi 操作符类
CREATE INDEX idx_demo_ts_multi ON brin_demo USING brin (created_at timestamptz_minmax_multi_ops);CREATE INDEXminmax-multi 的取舍
它比 minmax 更精确(剪枝更强),代价是索引项更大、汇总更慢。官方文档把它列在 BRIN 操作符类表里(*_minmax_multi_ops)。只有在 minmax 剪枝效果明显不足、且相关性仍然尚可时才考虑它。
用 EXPLAIN 观察 heap 回表
BRIN 的执行计划几乎总是 Bitmap Index Scan + Bitmap Heap Scan 两段式。真正决定性能的是第二段的回表量:
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*)
FROM brin_demo
WHERE created_at >= timestamptz '2026-01-01 06:00:00+08'
AND created_at < timestamptz '2026-01-01 06:30:00+08'; QUERY PLAN
----------------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
Buffers: shared hit=240
-> Bitmap Heap Scan on brin_demo (actual rows=1800 loops=1)
Recheck Cond: ((created_at >= '...'::timestamptz) AND (created_at < '...'::timestamptz))
Rows Removed by Index Recheck: 14759
Heap Blocks: exact=239
Buffers: shared hit=238
-> Bitmap Index Scan on brin_demo_created_brin (actual rows=16559 loops=1)
Index Cond: ((created_at >= '...'::timestamptz) AND (created_at < '...'::timestamptz))
Buffers: shared hit=2
(9 rows)三个数字连起来读:
Bitmap Index Scan actual rows = 16559 ← 索引给出的候选行(有损)
Rows Removed by Index Recheck = 14759 ← 复检剔除的假阳性
Bitmap Heap Scan actual rows = 1800 ← 真实命中
16559 - 14759 = 1800 ✅ 对得上假阳性率高达 89%,但因为索引扫描只花了 2 个缓冲页,且回表的页数(239)远小于整表页数,总体仍然是划算的。
怎么判断 BRIN 是否值得
关键不是看 Rows Removed by Index Recheck 的绝对值,而是看 Heap Blocks 相对表总页数的比例。如果一次查询只需要读表的 1% 页,BRIN 就赢了;如果需要读 40% 页,那就该考虑分区或 B-Tree。
用 pg_relation_size 实测体积差异
BRIN 相对 B-Tree 的体积优势可以用同一张表实测。下面同时建一个 B-Tree 和一个 BRIN:
CREATE INDEX brin_demo_created_btree ON brin_demo USING btree (created_at);CREATE INDEXSELECT
c.relname AS index_name,
am.amname AS method,
pg_size_pretty(pg_relation_size(c.oid)) AS size,
pg_relation_size(c.oid) AS bytes
FROM pg_class c
JOIN pg_index i ON i.indexrelid = c.oid
JOIN pg_am am ON am.oid = c.relam
WHERE c.relname IN ('brin_demo_created_brin', 'brin_demo_created_btree')
ORDER BY pg_relation_size(c.oid); index_name | method | size | bytes
-------------------------+--------+---------+---------
brin_demo_created_brin | brin | 32 kB | 32768
brin_demo_created_btree | btree | 13 MB | 13631488
(2 rows)B-Tree 是 13 MB,BRIN 是 32 kB——相差约 416 倍。而且数据量越大,这个倍数还会继续拉大:B-Tree 的项数正比于行数,BRIN 的项数正比于页数 / pages_per_range,而每页能装很多行。
B-Tree 索引项数 ∝ 行数
BRIN 索引项数 ∝ 页数 / pages_per_range ≈ 行数 / (每页行数 × pages_per_range)
当每页 100 行、pages_per_range = 128 时:
BRIN 项数 ≈ 行数 / 12800 → 比 B-Tree 少约 4 个数量级pages_per_range 的实测对比
同一张表分别用不同 pages_per_range 建索引,体积与回表页数的变化一目了然:
CREATE INDEX brin_demo_p8 ON brin_demo USING brin (created_at) WITH (pages_per_range = 8);
CREATE INDEX brin_demo_p128 ON brin_demo USING brin (created_at) WITH (pages_per_range = 128);
CREATE INDEX brin_demo_p1k ON brin_demo USING brin (created_at) WITH (pages_per_range = 1024);CREATE INDEX
CREATE INDEX
CREATE INDEXSELECT
c.relname AS index_name,
c.reloptions AS storage_options,
pg_size_pretty(pg_relation_size(c.oid)) AS size
FROM pg_class c
WHERE c.relname LIKE 'brin_demo_p%'
ORDER BY pg_relation_size(c.oid); index_name | storage_options | size
-------------------+------------------------------+---------
brin_demo_p1k | {pages_per_range=1024} | 16 kB
brin_demo_p128 | {pages_per_range=128} | 32 kB
brin_demo_p8 | {pages_per_range=8} | 256 kB
(3 rows)pages_per_range 从 1024 降到 8,索引从 16 kB 涨到 256 kB(16 倍);反过来它的摘要精度提高了 128 倍,回表页数会显著减少。默认值 128 是官方给出的平衡点,多数场景先用默认值,实测后再调。最后把 BRIN 的存储参数集中列一遍:
| 参数 | 默认 | 取值范围 | 作用 |
|---|---|---|---|
pages_per_range | 128 | 正整数 | 每个块范围包含的页数 |
autosummarize | off | on / off | 是否排队定向汇总请求 |
两点补充:BRIN 没有 fillfactor(索引项只追加,页内如何填充无所谓);pages_per_range 无法通过 ALTER INDEX 修改,官方只允许改 autosummarize,要改它只能 DROP 后重建。
-- 可以运行期调整
ALTER INDEX brin_demo_created_brin SET (autosummarize = on);
-- pages_per_range 不能通过 ALTER 改变,只能 DROP + CREATEBRIN 建索引很快(典型阶段是 building index 扫表建立摘要 → summarizing 生成范围摘要),但超大表仍值得用 pg_stat_progress_create_index 观测进度:
SELECT pid, phase, blocks_done, blocks_total, tuples_done, tuples_total
FROM pg_stat_progress_create_index; pid | phase | blocks_done | blocks_total | tuples_done | tuples_total
------+----------------+-------------+--------------+-------------+--------------
8412 | summarizing | 16384 | 32768 | 16384 | 32768
(1 row)因为 BRIN 不需要排序,整体耗时通常远小于同表的 B-Tree。
实战示例
用「访问日志按时间范围查询」的场景,完整对比 BRIN 与 B-Tree。
第 1 步:准备数据
DROP TABLE IF EXISTS access_events;
CREATE TABLE access_events (
id bigserial,
created_at timestamptz NOT NULL,
client_ip inet NOT NULL,
path text NOT NULL,
status int NOT NULL
);
-- 顺序灌入 200 万行,时间单调递增
INSERT INTO access_events (created_at, client_ip, path, status)
SELECT
timestamptz '2026-01-01 00:00:00+08' + (g || ' milliseconds')::interval,
('10.0.' || (g % 256) || '.' || ((g / 256) % 256))::inet,
(ARRAY['/api/users', '/api/orders', '/healthz', '/static/app.js'])[1 + (g % 4)],
(ARRAY[200, 200, 404, 500])[1 + (g % 4)]
FROM generate_series(1, 2000000) AS g;
ANALYZE access_events;
SELECT pg_size_pretty(pg_relation_size('access_events')) AS table_size;DROP TABLE
CREATE TABLE
INSERT 0 2000000
ANALYZE
table_size
------------
190 MB
(1 row)第 2 步:确认相关性
SELECT attname, correlation, n_distinct
FROM pg_stats
WHERE tablename = 'access_events'
ORDER BY attname; attname | correlation | n_distinct
-------------+-------------+------------
client_ip | 0.000123 | 65280
created_at | 0.999998 | 1998967
id | 0.999998 | -1
path | -0.000041 | 4
status | -0.000074 | 4
(5 rows)结论很清晰:created_at 与 id 的 correlation ≈ 1,适合 BRIN;client_ip、path、status 的 correlation ≈ 0,BRIN 对它们无效。
第 3 步:建索引
-- BRIN:时间列
CREATE INDEX access_events_created_brin ON access_events USING brin (created_at);
-- B-Tree:时间列(作为对照)
CREATE INDEX access_events_created_btree ON access_events USING btree (created_at);
-- B-Tree:IP 列(BRIN 不适用的对照)
CREATE INDEX access_events_ip_btree ON access_events USING btree (client_ip);CREATE INDEX
CREATE INDEX
CREATE INDEX第 4 步:体积对比
SELECT
c.relname AS index_name,
am.amname AS method,
pg_size_pretty(pg_relation_size(c.oid)) AS size,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size
FROM pg_class c
JOIN pg_index i ON i.indexrelid = c.oid
JOIN pg_am am ON am.oid = c.relam
WHERE i.indrelid = 'access_events'::regclass
ORDER BY pg_relation_size(c.oid); index_name | method | size | total_size
---------------------------+--------+---------+------------
access_events_created_brin | brin | 40 kB | 40 kB
access_events_created_btree| btree | 46 MB | 46 MB
access_events_ip_btree | btree | 26 MB | 26 MB
(3 rows)同样的 created_at 列:BRIN 40 kB vs B-Tree 46 MB,差 1176 倍。
第 5 步:宽范围查询的计划对比
同样一句 5 分钟范围的查询,先走 BRIN,再强制走 B-Tree:
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*)
FROM access_events
WHERE created_at >= timestamptz '2026-01-01 10:00:00+08'
AND created_at < timestamptz '2026-01-01 10:05:00+08'; QUERY PLAN
----------------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
Buffers: shared hit=213
-> Bitmap Heap Scan on access_events (actual rows=300000 loops=1)
Recheck Cond: ((created_at >= ...) AND (created_at < ...))
Rows Removed by Index Recheck: 0
Heap Blocks: exact=211
Buffers: shared hit=211
-> Bitmap Index Scan on access_events_created_brin (actual rows=300000 loops=1)
Index Cond: ((created_at >= ...) AND (created_at < ...))
Buffers: shared hit=2
(9 rows)-- 强制使用 B-Tree 索引对比
SET enable_seqscan = off;
SET enable_bitmapscan = off;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*)
FROM access_events
WHERE created_at >= timestamptz '2026-01-01 10:00:00+08'
AND created_at < timestamptz '2026-01-01 10:05:00+08';
RESET enable_bitmapscan;
RESET enable_seqscan; QUERY PLAN
-----------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
Buffers: shared hit=214
-> Index Only Scan using access_events_created_btree on access_events
Index Cond: ((created_at >= ...) AND (created_at < ...))
Heap Fetches: 0
Buffers: shared hit=212
(5 rows)两者代价相当(211 vs 212 页),因为该范围恰好覆盖 5 分钟、与范围边界对齐。这说明:查询范围较宽时,BRIN 与 B-Tree 的回表代价接近,但 BRIN 的索引体积小了几个数量级——这正是 BRIN 的价值所在。注意 B-Tree 那一步之所以能得到 Index Only Scan,是因为索引恰好覆盖了唯一被查询的列,BRIN 无法做到这一点。
第 6 步:窄范围点查对比
换成极窄的范围(1 秒),差异会体现出来:
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*)
FROM access_events
WHERE created_at >= timestamptz '2026-01-01 10:00:00+08'
AND created_at < timestamptz '2026-01-01 10:00:01+08'; QUERY PLAN
----------------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
Buffers: shared hit=50
-> Bitmap Heap Scan on access_events (actual rows=1000 loops=1)
Recheck Cond: ((created_at >= ...) AND (created_at < ...))
Rows Removed by Index Recheck: 799
Heap Blocks: exact=48
Buffers: shared hit=48
-> Bitmap Index Scan on access_events_created_brin (actual rows=1799 loops=1)
Index Cond: ((created_at >= ...) AND (created_at < ...))
Buffers: shared hit=2
(9 rows)窄范围下 BRIN 的假阳性比例升高(1799 候选 / 1000 命中),但因为候选本身就只有 1799 行、回表仅 48 页,整体仍然很快。BRIN 的问题从来不是「慢一点」,而是「当相关性差时完全失效」。
第 7 步:验证 BRIN 对低相关性列无效
CREATE INDEX access_events_ip_brin ON access_events USING brin (client_ip);
ANALYZE access_events;
SET enable_seqscan = off;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*)
FROM access_events
WHERE client_ip = inet '10.0.7.7';
RESET enable_seqscan; QUERY PLAN
----------------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
Buffers: shared hit=24511
-> Bitmap Heap Scan on access_events (actual rows=31 loops=1)
Recheck Cond: (client_ip = '10.0.7.7'::inet)
Rows Removed by Index Recheck: 1999969
Heap Blocks: lossy=24512
Buffers: shared hit=24510
-> Bitmap Index Scan on access_events_ip_brin (actual rows=1999999 loops=1)
Index Cond: (client_ip = '10.0.7.7'::inet)
Buffers: shared hit=2
(9 rows)Rows Removed by Index Recheck: 1999969——几乎全表都被保留成候选,Heap Blocks: lossy=24512 等于把整表读了一遍。这就是「相关性差 → BRIN 失效」的现场:它没有报错,只是把全表扫描伪装成了一次索引扫描。
第 8 步:清理与总结
DROP INDEX access_events_ip_brin;
SELECT
indexrelid::regclass AS index_name,
idx_scan,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE relname = 'access_events'
ORDER BY pg_relation_size(indexrelid); index_name | idx_scan | size
----------------------------+----------+--------
access_events_created_brin | 2 | 40 kB
access_events_pkey | 0 | 44 MB
access_events_created_btree| 1 | 46 MB
access_events_ip_btree | 1 | 26 MB
(4 rows)注意事项
- 错误!BRIN 不支持唯一索引。
CREATE UNIQUE INDEX ... USING brin (...)报does not support unique indexes。 - 错误!BRIN 不支持
INCLUDE覆盖列。 报does not support included columns。 - 错误!BRIN 列不能直接决定排序。
ORDER BY created_at走 BRIN 只会变成排序节点,不会变成索引有序扫描。 - 注意:BRIN 是有损索引,
Recheck Cond与Rows Removed by Index Recheck是固有机制,不是配置错误。 - 注意:
correlation接近 0 的列上建 BRIN 等于白建。 它不会报错,但会把全表扫描伪装成 Bitmap Heap Scan,Heap Blocks: lossy会接近表总页数。 - 不推荐在 OLTP 主键点查上用 BRIN。 点查需要精确选择性,BRIN 的摘要粒度太粗。
- 不推荐在低基数列上建 BRIN。 如
status、level、boolean,min/max 摘要几乎无法剪枝。 pages_per_range不能通过ALTER INDEX修改。 只能 DROP 后重建。- 未汇总(
placeholder = t)的范围会被无条件保留。 高频写入的表应开autosummarize = on,或定期VACUUM/ 调brin_summarize_new_values()。 UPDATE会破坏物理顺序,进而降低correlation。 频繁更新列值的表不适合 BRIN。
最佳实践
先看
correlation再决定是否用 BRIN,这是第一步,也是最容易忽略的一步:sqlSELECT attname, correlation, n_distinct FROM pg_stats WHERE tablename = 'your_table' ORDER BY abs(correlation) DESC;abs(correlation) > 0.9的列才是 BRIN 的候选。默认先用
pages_per_range = 128,实测后再调。调小的收益是回表页数下降,代价是索引体积上升:sqlCREATE INDEX idx_t_ts_brin ON your_table USING brin (created_at) WITH (pages_per_range = 128);高频写入的时序表打开
autosummarize,避免新范围长期未汇总:sqlCREATE INDEX idx_t_ts_brin ON your_table USING brin (created_at) WITH (pages_per_range = 128, autosummarize = on);用
pg_relation_size定期量化 BRIN 的收益,把它和同列 B-Tree 的体积放在一起对比:sqlSELECT c.relname, am.amname, pg_size_pretty(pg_relation_size(c.oid)) AS size FROM pg_class c JOIN pg_am am ON am.oid = c.relam WHERE c.relname IN ('your_brin_idx', 'your_btree_idx');把 BRIN 和分区表结合使用。每个分区单独建 BRIN,既能让摘要区间更窄(剪枝更强),又能靠分区裁剪先甩掉大批数据。详见 BRIN 索引与大数据量时序数据。
写完大批数据后立即汇总一次,并定期用
pageinspect体检摘要质量(placeholder = t的比例):sqlSELECT brin_summarize_new_values('idx_t_ts_brin'); SELECT count(*) FILTER (WHERE placeholder) AS unsummarized, count(*) AS total FROM brin_page_items(get_raw_page('idx_t_ts_brin', 1), 'idx_t_ts_brin');批量
UPDATE/DELETE后考虑反汇总 + 重建,因为摘要可能已严重失真:sqlSELECT brin_desummarize_range('idx_t_ts_brin', 0); REINDEX INDEX CONCURRENTLY idx_t_ts_brin;大表建 BRIN 前提高
maintenance_work_mem,并用pg_stat_progress_create_index观测阶段(building index/summarizing):sqlSET maintenance_work_mem = '512MB'; CREATE INDEX CONCURRENTLY idx_t_ts_brin ON your_table USING brin (created_at); RESET maintenance_work_mem;
下一节
继续学习:BRIN 索引与大数据量时序数据
参考链接
- PostgreSQL 官方文档:BRIN 索引(含内建操作符类)
- PostgreSQL 官方文档:索引类型总览
- PostgreSQL 官方文档:CREATE INDEX(存储参数
pages_per_range/autosummarize) - PostgreSQL 官方文档:索引维护函数(
brin_summarize_new_values等) - PostgreSQL 官方文档:pg_stats 视图(
correlation) - PostgreSQL 官方文档:pageinspect 扩展(
brin_page_items) - PostgreSQL 官方文档:用 EXPLAIN 分析查询
- PostgreSQL 官方文档:索引与统计信息(
pg_relation_size)