Skip to content

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 不记录「哪一行等于什么」,而是记录「哪一批页里的值落在什么范围」。

text
表(物理文件,按页排列):
  页 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) 两个时间戳;查询的常量落在这个闭区间内,范围就被保留,否则跳过。

text
查询 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 就等于没用。

sql
-- 查看某列的物理相关性
SELECT attname, correlation, n_distinct
FROM pg_stats
WHERE tablename = 'events' AND attname = 'created_at';
text
   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精确命中的堆页数 / 丢失(有损)的堆页数
text
                          QUERY PLAN
---------------------------------------------------------------
 Bitmap Heap Scan on events
   Recheck Cond: (created_at >= '...'::timestamptz)
   Rows Removed by Index Recheck: 3421        ← 假阳性
   Heap Blocks: exact=126

Rows Removed by Index Recheck 的存在是正常的。只要它不是「和结果集同数量级」,BRIN 的收益就成立——因为它省下的是成千上万个堆页的随机读。

BRIN 不支持什么 ​

BRIN 的能力边界非常清晰:

能力BRIN 是否支持说明
范围查询(< > BETWEEN)✅主要用途
等值查询(=)⚠️靠 min/max 剪枝,对低基数列几乎无效
多列索引✅每列各存一份摘要
唯一索引❌摘要无法证明唯一性
排序(ORDER BY 走索引)❌不维护全序
任意点查(OLTP 主键查询)❌选择性不够,回表太多
INCLUDE 覆盖列❌BRIN 不支持
排他约束 EXCLUDE❌需要 GiST
sql
-- 错误!BRIN 不支持唯一索引
CREATE UNIQUE INDEX bad ON events USING brin (created_at);
text
ERROR:  access method "brin" does not support unique indexes
sql
-- 错误!BRIN 不支持 INCLUDE 覆盖列
CREATE INDEX bad2 ON events USING brin (created_at) INCLUDE (level);
text
ERROR:  access method "brin" does not support included columns

BRIN 与 B-Tree 全面对比 ​

这张表是本节的核心结论:

维度BRINB-Tree
索引逻辑每个页范围一份摘要每个键值一条索引项
索引体积极小(可小几个数量级)大(常为表体积 10%~40%)
建索引速度极快(顺序扫一遍)慢(要排序)
写入代价极低高(随机页分裂)
查询精度有损,需要 recheck精确
范围查询效率好(前提是相关性高)好
等值查询效率差极好
唯一约束不支持支持
排序支持不支持支持
多列支持(各自摘要)支持(前缀规则)
适用数据巨大、只追加、列值有序任意

一句话:B-Tree 用空间换精度,BRIN 用精度换空间。表越大、相关性越高,BRIN 的相对优势越明显。

语法与用法 ​

text
-- 基本形式(默认操作符类、默认 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 索引:

sql
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;
text
DROP TABLE
CREATE TABLE
INSERT 0 500000
CREATE INDEX
ANALYZE

查询一个窄时间范围:

sql
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';
text
                                    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 页起)的摘要」,既不含行号,也不含键值。

sql
-- 观察 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';
text
         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 每个索引项的真实摘要值打印出来。这是理解摘要模型最直接的方式:

sql
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;
text
 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()。
sql
-- 再灌一批数据
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');
text
 unsummarized | total
--------------+-------
           27 |   322
(1 row)

未汇总的范围在查询里会被当成「可能有数据」全部保留,也就是说这段时间内 BRIN 的剪枝能力会退化。这正是 autosummarize 存在的理由:

sql
CREATE INDEX idx_events_brin_auto ON brin_demo USING brin (created_at)
    WITH (pages_per_range = 128, autosummarize = on);
text
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 在同一数据库里跑完时执行。

请求队列可能满

如果请求队列满了,请求不会被记录,日志里会出现:

text
LOG: request for BRIN range summarization for index "brin_wi_idx" page 128 was not recorded

此时该范围会一直未汇总,直到下一次常规 VACUUM 或手动调用汇总函数。

autosummarize 默认是 off。对高频写入的时序表,建议显式打开:

sql
ALTER INDEX idx_events_brin_auto SET (autosummarize = on);

需要特别留意数据乱序写入的影响。autosummarize 假设的写入模式是追加:新数据总是落在更后面的页上。如果写入是乱序的(比如回填历史数据、或者 created_at 随机),那么:

  1. 新行落进已经汇总过的范围时,摘要会被就地更新(min 可能变小、max 可能变大),这是自动的,不需要额外操作;
  2. 但如果一个范围因为乱序写入而变得「值域极大」,它的摘要就退化成「覆盖几乎全部时间轴」,剪枝失效。
text
理想情况(追加写):
  范围 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 了:

sql
SELECT attname, correlation
FROM pg_stats
WHERE tablename = 'brin_demo' AND attname = 'created_at';
text
   attname   | correlation
-------------+-------------
 created_at  |    0.999984
(1 row)

这三个函数是 BRIN 的日常维护入口:

函数作用
brin_summarize_new_values(regclass)汇总该索引所有未汇总的范围,返回汇总的范围数
brin_summarize_range(regclass, bigint)只汇总给定页所在的范围(若未汇总)
brin_desummarize_range(regclass, bigint)反汇总:把给定页所在范围内已有的摘要删掉
sql
-- 汇总所有未汇总范围,返回处理的范围数
SELECT brin_summarize_new_values('brin_demo_created_brin');
text
 brin_summarize_new_values
---------------------------
                        27
(1 row)
sql
-- 只汇总第 128 页所在的范围(范围大小 128,所以这是第二个范围)
SELECT brin_summarize_range('brin_demo_created_brin', 128);
text
 brin_summarize_range
----------------------
                    0
(1 row)

返回 0 表示该范围已经汇总过了,无需处理。

反汇总用于「摘要已经不能代表现状」的场景,比如行被大规模更新、删除,摘要里的 min/max 已严重失真:

sql
-- 反汇总,之后可以靠 VACUUM 或手动重新汇总
SELECT brin_desummarize_range('brin_demo_created_brin', 128);
text
 brin_desummarize_range
------------------------
                      1
(1 row)

多列 BRIN 与 minmax_multi_ops ​

BRIN 支持多列索引。每一列各存一份摘要,查询里对哪一列做范围过滤,就靠哪一列的摘要剪枝:

sql
CREATE INDEX brin_demo_multi ON brin_demo USING brin (created_at, level);
text
CREATE INDEX

level 是低基数列(只有 INFO/WARN/ERROR),它的 min/max 摘要几乎覆盖不了任何东西——min='ERROR'、max='WARN' 会命中每一个范围。所以多列 BRIN 里低基数列是负担,占空间且无剪枝收益。

真正有用的多列场景是「两个高相关性、高基数列」:

sql
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 区间对,能更好地描述「值在范围内有多个聚集簇」的分布:

sql
-- 用 minmax_multi 操作符类
CREATE INDEX idx_demo_ts_multi ON brin_demo USING brin (created_at timestamptz_minmax_multi_ops);
text
CREATE INDEX

minmax-multi 的取舍

它比 minmax 更精确(剪枝更强),代价是索引项更大、汇总更慢。官方文档把它列在 BRIN 操作符类表里(*_minmax_multi_ops)。只有在 minmax 剪枝效果明显不足、且相关性仍然尚可时才考虑它。

用 EXPLAIN 观察 heap 回表 ​

BRIN 的执行计划几乎总是 Bitmap Index Scan + Bitmap Heap Scan 两段式。真正决定性能的是第二段的回表量:

sql
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';
text
                                    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)

三个数字连起来读:

text
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:

sql
CREATE INDEX brin_demo_created_btree ON brin_demo USING btree (created_at);
text
CREATE INDEX
sql
SELECT
    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);
text
       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,而每页能装很多行。

text
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 建索引,体积与回表页数的变化一目了然:

sql
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);
text
CREATE INDEX
CREATE INDEX
CREATE INDEX
sql
SELECT
    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);
text
     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_range128正整数每个块范围包含的页数
autosummarizeoffon / off是否排队定向汇总请求

两点补充:BRIN 没有 fillfactor(索引项只追加,页内如何填充无所谓);pages_per_range 无法通过 ALTER INDEX 修改,官方只允许改 autosummarize,要改它只能 DROP 后重建。

sql
-- 可以运行期调整
ALTER INDEX brin_demo_created_brin SET (autosummarize = on);

-- pages_per_range 不能通过 ALTER 改变,只能 DROP + CREATE

BRIN 建索引很快(典型阶段是 building index 扫表建立摘要 → summarizing 生成范围摘要),但超大表仍值得用 pg_stat_progress_create_index 观测进度:

sql
SELECT pid, phase, blocks_done, blocks_total, tuples_done, tuples_total
FROM pg_stat_progress_create_index;
text
 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 步:准备数据 ​

sql
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;
text
DROP TABLE
CREATE TABLE
INSERT 0 2000000
ANALYZE
 table_size
------------
 190 MB
(1 row)

第 2 步:确认相关性 ​

sql
SELECT attname, correlation, n_distinct
FROM pg_stats
WHERE tablename = 'access_events'
ORDER BY attname;
text
   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 步:建索引 ​

sql
-- 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);
text
CREATE INDEX
CREATE INDEX
CREATE INDEX

第 4 步:体积对比 ​

sql
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);
text
        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:

sql
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';
text
                                    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)
sql
-- 强制使用 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;
text
                                 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 秒),差异会体现出来:

sql
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';
text
                                    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 对低相关性列无效 ​

sql
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;
text
                                    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 步:清理与总结 ​

sql
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);
text
         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,这是第一步,也是最容易忽略的一步:

    sql
    SELECT 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,实测后再调。调小的收益是回表页数下降,代价是索引体积上升:

    sql
    CREATE INDEX idx_t_ts_brin ON your_table USING brin (created_at) WITH (pages_per_range = 128);
  • 高频写入的时序表打开 autosummarize,避免新范围长期未汇总:

    sql
    CREATE 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 的体积放在一起对比:

    sql
    SELECT
        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 的比例):

    sql
    SELECT 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 后考虑反汇总 + 重建,因为摘要可能已严重失真:

    sql
    SELECT 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):

    sql
    SET maintenance_work_mem = '512MB';
    CREATE INDEX CONCURRENTLY idx_t_ts_brin ON your_table USING brin (created_at);
    RESET maintenance_work_mem;

参考链接 ​