文章目录
- 一、介绍
- [二、什么是 BRIN Index?](#二、什么是 BRIN Index?)
- 三、BRIN结构
-
- [3.1 建立测试表及BRIN索引](#3.1 建立测试表及BRIN索引)
- [3.2 揭示BRIN索引的三层结构](#3.2 揭示BRIN索引的三层结构)
- 四、BRIN索引的工作原理
-
- [1. 场景1:顺序扫描所有范围(范围查询)](#1. 场景1:顺序扫描所有范围(范围查询))
- [2. 场景2:revmap页真正发挥作用的时刻](#2. 场景2:revmap页真正发挥作用的时刻)
- [五、BRIN、B-Tree 与全表扫描的性能对比实践](#五、BRIN、B-Tree 与全表扫描的性能对比实践)
-
- [1. 全表扫描](#1. 全表扫描)
- [2. btree scan](#2. btree scan)
- [BRIN index scan](#BRIN index scan)
- [六、局限性 / 约束](#六、局限性 / 约束)
一、介绍
本文将重点介绍 PostgreSQL 中的一种索引类型 ------ BRIN(块范围索引),包括它的设计初衷、核心用途,以及在哪些典型场景下能比其他索引方案显著更高效。我们还会通过具体的性能数据对比,展示 BRIN 与 PostgreSQL 其他常用索引(如 B-tree、GIN、GiST 等)之间的性能差异,说明它在合适的使用场景下为何更具优势。
二、什么是 BRIN Index?
BRIN(Block Range Index)是一种索引类型。在 Postgres 中,block 是最基本的存储单位,默认大小为 8KB。BRIN 会对一段连续的 blocks(默认 128 个)进行采样,记录这一范围内第一个 block 的位置,以及该范围内所有值的最小值和最大值。
记住!BRIN 不是逐行索引,而是以 heap table 的 block range(页范围)为单位记录统计信息,如:
- 最小/最大值(min/max)
- 是否全为 NULL
- 是否存在 NULL
- 其他聚合摘要信息(取决于 opclass)
这种索引对有序的数据集特别适用,不仅能大幅节省存储空间,有时还可以带来比其他索引更好的查询性能。
BRIN Index 是一种颠覆性的索引思想,最早由 PostgreSQL 贡献者 Alvaro Herrera 提出。BRIN 是 "Block Range Index" 的缩写。一个 block range 是一组相邻的页面,在索引中会存储关于这些页面的汇总信息。例如,对于整数、日期等具有线性排序的数据类型,可以只存储该范围内的最小值和最大值。
后来,包括 Oracle 在内的其他数据库系统也陆续推出了类似的功能。BRIN Index 在很多场景下,所带来的性能提升往往可以与对表进行分区(Partitioning)相媲美。
BRIN 是一种轻量级索引,但常常被误解。正确使用时,它可以带来显著的好处,例如节省存储空间和提升查询速度;而使用不当,则可能无法发挥其优势。
BRIN 在对大规模 time-series data 进行高效查询时尤其有用,而且相比标准 B-TREE 索引,占用的磁盘空间要少得多。一个 block range index 条目指向一个页面(Page,这是 PostgreSQL 存储数据的最小单位),并存储两个值:该页面的最小值以及要索引的最大值。
三、BRIN结构
BRIN 索引在磁盘上通常包含以下三类 page:
| Page类型 | 作用 |
|---|---|
| Meta Page (Block 0) | 元数据页(Meta Page)- 第0页,记录索引基础配置,如 magic、pagesPerRange、版本等 |
| Revmap Page | 反向映射页(Revmap Page)-第1页,记录每个 range summary 在哪个 BRIN summary page + offset |
| Summary Page | 汇总页(Summary Page)-第2页, 真正存储每个 block range 的统计摘要(tuple summaries) |
接下来,我们将创建一个测试表以及他的brin索引,用来揭示BRIN索引的内部结构
3.1 建立测试表及BRIN索引
1.创建扩展
bash
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS pageinspect;
- 创建测试表
bash
CREATE TABLE test_brin (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
value INT
);
- 插入测试数据
bash
INSERT INTO test_brin(ts, value)
SELECT
generate_series(
'2020-01-01 00:00:00+07',
'2025-12-31 00:00:00+07',
interval '5 seconds'
),
floor(random()*1000);
插入了约37860481条记录,时间跨度从2020年1月1日到2025年12月31日,每5秒钟一条记录:
bash
select count(1) from test_brin;
count
--------
37860481
(1 row)
- 创建BRIN索引
最后在时间戳字段上创建BRIN索引:
bash
CREATE INDEX test_brin_idx ON test_brin USING BRIN(ts);
- 索引大小
bash
select relpages from pg_class where relname='test_brin_idx';
relpages
----------
3
结果显示:仅需3个页面,BRIN索引的惊人压缩比惊人
对于21万+条记录,传统B-tree索引可能需要数百甚至上千个页面,而BRIN索引只用了3个8KB的页面(共24KB)。这就是BRIN索引的核心优势------极致的空间效率。
3.2 揭示BRIN索引的三层结构
通过pageinspect扩展,我们可以窥探BRIN索引的内部结构。BRIN索引由三个关键部分组成:
- 元数据页(Meta Page)- 第0页
bash
SELECT * from brin_metapage_info(pg_read_binary_file('base/16448/25517',0,8192));
magic | version | pagesperrange | lastrevmappage
------------+---------+---------------+----------------
0xA8109CFA | 1 | 128 | 1
(1 row)
元数据页存储了索引的全局配置信息:
- magic: 魔数0xA8109CFA,用于标识这是一个BRIN索引
- version: 索引格式版本号,当前为1
- pagesperrange: 关键参数!每个范围包含128个数据页,这意味着每个索引条目汇总了128个数据块的信息
- lastrevmappage: 最后一个revmap页的页号
- 反向映射页(Revmap Page)- 第1页
bash
select * from brin_revmap_data(get_raw_page('test_brin_idx',1)) limit 5;
pages
-------
(2,1)
(2,2)
(2,3)
(2,4)
(2,5)
(5 rows)
反向映射页是BRIN索引的"目录"。每一项都是一个指针,格式为(页号, 偏移),指向汇总页中对应块范围的汇总信息。
- (2,1): 第一个128块范围的汇总信息在第2页的第1项
- (2,2): 第二个128块范围的汇总信息在第2页的第2项
依此类推...
这种设计使得PostgreSQL能够快速定位任意数据块对应的汇总信息。
- 汇总页(Summary Page)- 第2页
bash
select * from brin_page_items(get_raw_page('in_ticketing_system_brin',2),'in_ticketing_system_brin') limit 5;
itemoffset | blknum | attnum | allnulls | hasnulls | placeholder | empty | value
------------+--------+--------+----------+----------+-------------+-------+----------------------------------------------------
1 | 0 | 1 | f | f | f | f | {2020-01-01 00:00:00+00 .. 2020-01-01 21:19:55+00}
2 | 128 | 1 | f | f | f | f | {2020-01-01 21:20:00+00 .. 2020-01-02 18:39:55+00}
3 | 256 | 1 | f | f | f | f | {2020-01-02 18:40:00+00 .. 2020-01-03 15:59:55+00}
4 | 384 | 1 | f | f | f | f | {2020-01-03 16:00:00+00 .. 2020-01-04 13:19:55+00}
5 | 512 | 1 | f | f | f | f | {2020-01-04 13:20:00+00 .. 2020-01-05 10:39:55+00}
(5 rows)
汇总页存储了实际的索引数据。每一项包含:
- itemoffset: 条目偏移,对应revmap中的位置
- blknum: 起始数据块号(每隔128递增,与pagesperrange对应)
- attnum: 列号(这里是第1列,即ts字段)
- allnulls/hasnulls: 空值标记
- value: 最关键的部分------该范围内时间戳的最小值和最大值
例如第一项:块0-127的时间戳范围是2020-01-01 00:00:00到2020-01-01 21:19:55。
四、BRIN索引的工作原理
1. 场景1:顺序扫描所有范围(范围查询)
当执行查询时:
bash
SELECT * FROM test_brin
WHERE ts BETWEEN '2025-01-03 00:00:00' AND '2025-01-04 00:00:00';
PostgreSQL的执行流程:
- 读取元数据页:了解每个范围覆盖128个数据块
- 扫描汇总页:检查每个范围的最小/最大值
- 过滤候选范围:
- 第3项:2025-01-02 18:40:00 ... 2025-01-03 15:59:55
- 第4项:2025-01-03 16:00:00 ... 2025-01-04 13:19:55
- 其他范围跳过
- 扫描候选块:只读取块256-383和384-511,跳过其他1600+个块
这就是BRIN索引的"有损"特性------它不能精确定位到行,但能大幅减少需要扫描的数据块数量。
在这个流程中,revmap页确实没有被使用!因为我们是顺序扫描汇总页,不需要"反向查找"。
2. 场景2:revmap页真正发挥作用的时刻
revmap页的作用体现在索引维护和特定块的精确查找场景:
场景A:插入/更新数据时
当向表中插入新数据或更新现有数据时:
bash
INSERT INTO test_brin(ts, value) VALUES ('2025-01-05 12:00:00', 500);
假设这条数据被写入了第640号数据块,PostgreSQL需要:
- 计算块范围编号:640 ÷ 128 = 5(这是第5个范围)
- revmap页:读取第5项,得到(2, 5)
- 定位汇总信息:跳转到第2页的第5项
- 更新汇总值:扩展该范围的最小/最大值
没有revmap页会怎样?PostgreSQL必须扫描整个汇总页,找到blknum=512的那一项------效率低下。
场景B:VACUUM或索引汇总
假设表当前有数据到块767(范围5结束)
汇总页显示:
bash
范围0 (块0-127): {2025-01-01 00:00 .. 2025-01-01 21:19}
范围1 (块128-255): {2025-01-01 21:20 .. 2025-01-02 18:39}
...
范围5 (块640-767): {2025-01-05 10:40 .. 2025-01-06 08:00}```
现在插入新数据:
现在开始插入数据,写入到块768(范围6的开始)
bash
INSERT INTO test_brin(ts, value)
VALUES ('2025-01-06 15:00:00', 500); -- 数据写入块768(范围6的开始)
关键问题 :此时会发生什么?
有两种策略:
策略A:立即更新索引
- 每次INSERT → 计算范围号 → 查revmap → 更新汇总页
- 优点:索引始终最新
- 缺点:每次写入都要更新索引,性能开销大
策略B:延迟汇总(PostgreSQL采用)
- INSERT时 → 只写数据,不更新索引
- 定期通过VACUUM → 批量汇总新数据块
- 优点:写入性能极佳
- 缺点:新数据可能不在索引中
PostgreSQL选择了策略B,这导致了一个现象:
-- 插入新数据后,立即查询:
bash
SELECT * FROM test_brin WHERE ts > '2025-01-06 14:00:00';
- 此时BRIN索引可能无法帮助过滤,因为范围6的汇总还未创建
- PostgreSQL可能需要扫描从块768开始的所有新块
VACUUM的作用:汇总未索引的数据块
bash
VACUUM test_brin;
VACUUM会执行以下流程:
步骤1:扫描表,找出未汇总的块
遍历表的所有数据块:
块0-767: 已有汇总信息 ✓ 跳过
块768: 无汇总信息 ✗ 需要处理
块769: 无汇总信息 ✗ 需要处理
...
块896: 无汇总信息 ✗ 需要处理(范围7开始)
步骤2:为未汇总的块创建/更新汇总
处理块768-895(范围6):
- 计算范围编号
bash
范围号 = 768 ÷ 128 = 6
-
查询revmap页第6项
情况A:项不存在(全新范围)
需要在revmap页添加新项
需要在汇总页添加新汇总条目
情况B:项已存在但汇总过期
读取 revmap[6] = (2, 7)
跳转到第2页第7项
-
扫描块768-895的所有行
读取 ts 列的值:
bash
2025-01-06 08:00:05
2025-01-06 08:01:00
2025-01-06 08:02:15
...
2025-01-06 21:59:45
- 计算汇总值* 最小值: 2025-01-06 08:00:05
bash
最大值: 2025-01-06 21:59:45
- 写入或更新汇总页
- 在第2页第7项写入:
bash
itemoffset=7, blknum=768,
value={2025-01-06 08:00:05 .. 2025-01-06 21:59:45}
- 更新revmap页
bash
revmap[6] = (2, 7)
revmap在这个过程中的关键作用
关键要点
- BRIN索引是"懒惰"的:写入时不更新索引,追求极致的写入性能
- VACUUM是"勤快"的:定期批量汇总新数据,保持索引有效性
- revmap是"聪明"的:让VACUUM能快速定位和更新特定范围的汇总信息
这就是为什么使用BRIN索引的表建议:
bash
-- 启用自动vacuum
ALTER TABLE test_brin SET (autovacuum_enabled = true);
-- 或定期手动vacuum
VACUUM test_brin;
否则,随着新数据不断插入,BRIN索引会逐渐"失效"------它覆盖的数据越来越少,查询性能也会下降。
适用场景
| 推荐 | 不推荐 |
|---|---|
| 大型表(数十GB到TB级) | 小型表(几百MB以下,B-tree更快) |
| 数据按自然顺序插入(如时间序列、日志、事件流) | 数据无序或频繁更新 |
| 范围查询为主,顺序写入 | 需要精确点查询 |
| 存储空间敏感 | 数据分布极不均匀 |
| 例如日志、时序、物联网数据 | 例如用户账户、主键查询 |
调优参数
bash
-- 调整pagesperrange(默认128)
CREATE INDEX idx ON table USING BRIN(column)
WITH (pages_per_range = 64); -- 更小的范围,更精确但索引更大
- 较小值(如32):索引更大,但过滤更精确
- 较大值(如256):索引更小,但可能扫描更多无关数据
维护建议
定期汇总新数据
bash
VACUUM table;
或使用自动汇总(需要配置autovacuum)
bash
ALTER TABLE table SET (autovacuum_enabled = true);
五、BRIN、B-Tree 与全表扫描的性能对比实践
我们将以test_brin为基表,分别创建test_full、test_bree,用来观察他们在被query时的表现
1. 全表扫描
首先,以test_brin为基表,创建test_full(没有索引),看看全表扫描的表现:
bash
demo=# create table test_full as select * from test_brin;
现在我们尝试查询test_full表,以返回一个月内每天的最大ID。
bash
explain (analyze)
SELECT date_trunc('day', ts), max(id) FROM test_full ts WHERE ts BETWEEN '2025-05-01 0:00' AND '2025-05-31 11:59:59' GROUP BY 1 ORDER BY 1;
执行计划输出:
bash
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------------------------------------
Finalize GroupAggregate (cost=502571.88..566446.83 rows=527077 width=16) (actual time=90294.210..90366.007 rows=31 loops=1)
Group Key: (date_trunc('day'::text, ts))
-> Gather Merge (cost=502571.88..557662.22 rows=439230 width=16) (actual time=90294.167..90365.877 rows=57 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Partial GroupAggregate (cost=501571.86..505964.16 rows=219615 width=16) (actual time=90080.387..90181.162 rows=19 loops=3)
Group Key: (date_trunc('day'::text, ts))
-> Sort (cost=501571.86..502120.90 rows=219615 width=16) (actual time=90077.399..90142.239 rows=175680 loops=3)
Sort Key: (date_trunc('day'::text, ts))
Sort Method: external merge Disk: 4104kB
Worker 0: Sort Method: external merge Disk: 4728kB
Worker 1: Sort Method: external merge Disk: 4616kB
-> Parallel Seq Scan on test_full ts (cost=0.00..478331.44 rows=219615 width=16) (actual time=73315.834..89712.102 rows=175680 loops=3)
Filter: ((ts >= '2025-05-01 00:00:00+00'::timestamp with time zone) AND (ts <= '2025-05-31 11:59:59+00'::timestamp with time zone))
Rows Removed by Filter: 12444480
Planning Time: 0.237 ms
JIT:
Functions: 30
Options: Inlining true, Optimization true, Expressions true, Deforming true
Timing: Generation 10.565 ms, Inlining 904.053 ms, Optimization 1371.036 ms, Emission 860.772 ms, Total 3146.426 ms
Execution Time: 90368.850 ms
(21 rows)
s)
在我的 PostgreSQL 环境中,尽管该次查询在后台隐式启用了并行查询,这个查询执行依然大约花了 90368.850 ms。
2. btree scan
接下来,我们同样以test_brin为基表创建test_bree,并在 ts列上创建 BTREE 索引。
bash
demo=# create table test_btree as select * from test_brin;
创建btree索引
bash
create index test_btree_idx on test_btree(ts);
与上面访问test_full同样的query访问test_btree,同样访问一个月内每天最大的ID:
bash
explain(analyze)
SELECT date_trunc('day', ts), max(id) FROM test_btree ts WHERE ts BETWEEN '2025-05-01 0:00' AND '2025-05-31 11:59:59' GROUP BY 1 ORDER BY 1;
执行计划输出:
bash
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Finalize GroupAggregate (cost=478914.66..537358.27 rows=482258 width=16) (actual time=3864.283..3931.407 rows=31 loops=1)
Group Key: (date_trunc('day'::text, ts))
-> Gather Merge (cost=478914.66..529320.63 rows=401882 width=16) (actual time=3861.161..3931.256 rows=93 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Partial GroupAggregate (cost=477914.63..481933.45 rows=200941 width=16) (actual time=3340.994..3420.693 rows=31 loops=3)
Group Key: (date_trunc('day'::text, ts))
-> Sort (cost=477914.63..478416.99 rows=200941 width=16) (actual time=3337.982..3385.741 rows=175680 loops=3)
Sort Key: (date_trunc('day'::text, ts))
Sort Method: external merge Disk: 5080kB
Worker 0: Sort Method: external merge Disk: 4184kB
Worker 1: Sort Method: external merge Disk: 4184kB
-> Parallel Bitmap Heap Scan on test_btree ts (cost=10235.71..456778.34 rows=200941 width=16) (actual time=1475.912..3213.709 rows=175680 loops=3)
Recheck Cond: ((ts >= '2025-05-01 00:00:00+00'::timestamp with time zone) AND (ts <= '2025-05-31 11:59:59+00'::timestamp with time zone))
Heap Blocks: exact=1268
-> Bitmap Index Scan on test_btree_idx (cost=0.00..10115.15 rows=482258 width=0) (actual time=742.093..742.094 rows=527040 loops=1)
Index Cond: ((ts >= '2025-05-01 00:00:00+00'::timestamp with time zone) AND (ts <= '2025-05-31 11:59:59+00'::timestamp with time zone))
Planning Time: 0.270 ms
JIT:
Functions: 30
Options: Inlining true, Optimization true, Expressions true, Deforming true
Timing: Generation 6.848 ms, Inlining 1318.194 ms, Optimization 1279.686 ms, Emission 873.182 ms, Total 3477.911 ms
Execution Time: 3933.677 ms
(23 rows)
整个查询话费大约3933.677 ms,B-Tree 索引的表现超过了full table scan(尽管启用了parellel scan以及bitmap index scan)。现在我们来看看索引的大小。它的大小是 811MB,非常庞大。
bash
select pg_size_pretty(pg_relation_size('test_btree_idx'));
pg_size_pretty
----------------
811 MB
(1 row)
BRIN index scan
最后,是时候测试BRIN索引的性能了。
bash
SELECT date_trunc('day', ts), max(id) FROM test_brin ts WHERE ts BETWEEN '2025-05-01 0:00' AND '2025-05-31 11:59:59' GROUP BY 1 ORDER BY 1;
执行计划输出:
bash
QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Finalize GroupAggregate (cost=469917.11..531009.19 rows=504112 width=16) (actual time=2596.688..2674.786 rows=31 loops=1)
Group Key: (date_trunc('day'::text, ts))
-> Gather Merge (cost=469917.11..522607.32 rows=420094 width=16) (actual time=2596.636..2674.623 rows=92 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Partial GroupAggregate (cost=468917.08..473118.02 rows=210047 width=16) (actual time=2433.890..2535.406 rows=31 loops=3)
Group Key: (date_trunc('day'::text, ts))
-> Sort (cost=468917.08..469442.20 rows=210047 width=16) (actual time=2424.750..2489.887 rows=175680 loops=3)
Sort Key: (date_trunc('day'::text, ts))
Sort Method: external merge Disk: 4544kB
Worker 0: Sort Method: external merge Disk: 4320kB
Worker 1: Sort Method: external merge Disk: 4584kB
-> Parallel Bitmap Heap Scan on test_brin ts (cost=163.86..446757.56 rows=210047 width=16) (actual time=910.053..2291.206 rows=175680 loops=3)
Recheck Cond: ((ts >= '2025-05-01 00:00:00+00'::timestamp with time zone) AND (ts <= '2025-05-31 11:59:59+00'::timestamp with time zone))
Rows Removed by Index Recheck: 11755
Heap Blocks: lossy=1197
-> Bitmap Index Scan on test_brin_idx (cost=0.00..37.83 rows=522492 width=0) (actual time=33.548..33.549 rows=35820 loops=1)
Index Cond: ((ts >= '2025-05-01 00:00:00+00'::timestamp with time zone) AND (ts <= '2025-05-31 11:59:59+00'::timestamp with time zone))
Planning Time: 234.501 ms
JIT:
Functions: 30
Options: Inlining true, Optimization true, Expressions true, Deforming true
Timing: Generation 2.494 ms, Inlining 1264.943 ms, Optimization 828.205 ms, Emission 526.585 ms, Total 2622.227 ms
Execution Time: 2704.947 ms
(24 rows)
使用了BRIN索引的查询花费了2704.947 ms,BTREE 和 BRIN 索引的查询性能很接近,但 BRIN 索引更胜一筹,超过了 BTREE。不仅如此,BRIN 还节省了大量的索引存储空间。此外,BRIN 还能节省很多索引占用的磁盘空间。
bash
select pg_size_pretty(pg_relation_size('test_brin_idx'));
pg_size_pretty
----------------
80 kB
(1 row)
是的,BRIN 索引只占用了 80KB!这说明,与在同一份数据上构建的 B-Tree 索引相比,BRIN 所需的存储空间极小,而且在这种**分析型查询(Analytical Queries)**场景下,它的性能甚至更好 / 还要优于 B-Tree。
现在让我们修改查询语句,计算一天中每小时的最大ID。
使用BTREE index:
bash
explain (analyze) SELECT date_trunc('hour', ts), max(id) FROM test_btree ts WHERE ts BETWEEN '2025-05-01 0:00' AND '2025-05-01 11:59:59' GROUP BY 1 ORDER BY 1;
QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------------------------------------
GroupAggregate (cost=32476.52..32664.62 rows=9405 width=16) (actual time=42.912..55.617 rows=12 loops=1)
Group Key: (date_trunc('hour'::text, ts))
-> Sort (cost=32476.52..32500.04 rows=9405 width=16) (actual time=42.613..44.171 rows=8640 loops=1)
Sort Key: (date_trunc('hour'::text, ts))
Sort Method: quicksort Memory: 722kB
-> Bitmap Heap Scan on test_btree ts (cost=200.97..31855.83 rows=9405 width=16) (actual time=4.652..30.314 rows=8640 loops=1)
Recheck Cond: ((ts >= '2025-05-01 00:00:00+00'::timestamp with time zone) AND (ts <= '2025-05-01 11:59:59+00'::timestamp with time zone))
Heap Blocks: exact=56
-> Bitmap Index Scan on test_btree_idx (cost=0.00..198.62 rows=9405 width=0) (actual time=4.582..4.583 rows=8640 loops=1)
Index Cond: ((ts >= '2025-05-01 00:00:00+00'::timestamp with time zone) AND (ts <= '2025-05-01 11:59:59+00'::timestamp with time zone))
Planning Time: 6.645 ms
Execution Time: 59.472 ms
(12 rows)
使用BRIN index
bash
demo=# explain (analyze) SELECT date_trunc('hour', ts), max(id) FROM test_brin ts WHERE ts BETWEEN '2025-05-01 0:00' AND '2025-05-01 11:59:59' GROUP BY 1 ORDER BY 1;
QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------------------------------------
GroupAggregate (cost=61697.33..61861.95 rows=8231 width=16) (actual time=36.634..40.022 rows=12 loops=1)
Group Key: (date_trunc('hour'::text, ts))
-> Sort (cost=61697.33..61717.90 rows=8231 width=16) (actual time=36.487..37.354 rows=8640 loops=1)
Sort Key: (date_trunc('hour'::text, ts))
Sort Method: quicksort Memory: 722kB
-> Bitmap Heap Scan on test_brin ts (cost=39.09..61162.03 rows=8231 width=16) (actual time=11.307..31.154 rows=8640 loops=1)
Recheck Cond: ((ts >= '2025-05-01 00:00:00+00'::timestamp with time zone) AND (ts <= '2025-05-01 11:59:59+00'::timestamp with time zone))
Rows Removed by Index Recheck: 31169
Heap Blocks: lossy=254
-> Bitmap Index Scan on test_brin_idx (cost=0.00..37.03 rows=20096 width=0) (actual time=4.722..4.722 rows=2540 loops=1)
Index Cond: ((ts >= '2025-05-01 00:00:00+00'::timestamp with time zone) AND (ts <= '2025-05-01 11:59:59+00'::timestamp with time zone))
Planning Time: 0.683 ms
Execution Time: 40.252 ms
(13 rows)
这两种索引的性能几乎相同,但 BRIN 索引在存储空间节省方面带来了极其显著的优势,相比 BTREE 能节省非常、非常多的空间。
我在 1 亿(100M)数据量 上做了性能测试,BRIN 在如此庞大的数据规模下依然表现很好。
首先,正如你可能已经注意到的,当表增长到相当大的规模时,BRIN 索引的优势才会真正显现出来。这也体现了 PostgreSQL 具备**纵向扩展(垂直扩展 / Vertical Scaling)**的能力:在尝试解决的很多问题中,尤其是时间序列分析(Temporal Analytics)类查询,BRIN 索引肯定能帮助你更高效地运行查询。
最关键的一点是存储空间:如果你的数据集适合使用 BRIN,那么将索引占用减少 99% 以上(缩小 99%+)会带来巨大的收益。能把索引体积压缩 99% 以上是非常惊人的。
BRIN 索引的工作方式是:它会返回某个数据范围(page range)内所有页面中的全部元组(tuples),因此它是一种有损(lossy)索引,并且需要额外的计算来进一步过滤真正匹配的记录。所以虽然有人可能会认为这不是很好,但它其实也有一些优点/优势。
- 由于 BRIN 只存储一段连续页面(page range)的摘要信息,因此相比 B-Tree 索引,BRIN 索引通常非常小。可以减少索引本身占用 shared_buffer 的空间,从而让 shared_buffer 里有更多空间留给表(heap/heap page,也就是 heap 数据)
- BRIN 的"有损性(lossiness)"可以通过设置 每个索引范围包含的页面数量(pages per range) 来控制(此内容将在前续已经讨论)。
- BRIN 将摘要信息的构建工作交由 VACUUM 或 autovacuum 处理,因此在事务或 DML(INSERT/UPDATE/DELETE)操作中,索引维护开销非常低、影响很小。
六、局限性 / 约束
当索引键值的排序与存储层中数据块(blocks)的物理组织顺序一致时,BRIN 索引才能发挥高效性能。最理想的情况下,这可能要求 表的物理顺序(通常是行的写入/创建顺序)与索引键的逻辑顺序相匹配。因此,基于递增序列(sequence)、时间字段(如 ts)、按生成顺序增长的数值或日期数据,是 BRIN 索引最适合的场景。
在上述例子中,由于 ts 字段的值是 持续递增写入的,BRIN 的表现会非常好。但如果字段中的日期数据存在 频繁更新或被修改,导致顺序不再与物理存储对齐,那么 BRIN 索引的性能就会下降,无法达到预期效果。