Learning Platform
Урок 04.03 · 22 мин
Продвинутый
BRINBlock RangeTime-seriesSequential data

B-tree масштабируется логарифмически от числа строк, но линейно по размеру. На таблице в 500 миллионов строк B-tree-индекс по timestamp будет ~10 GiB. И каждый INSERT нужно протащить через log2(500_000_000) ≈ 29 уровней, на самом деле упираясь в WAL и в обновление leaf-страниц.

При этом если данные физически отсортированы (timestamp растёт, ID-серия растёт, audit-логи) — большая часть мощи B-tree не нужна. Нам не нужен точный адрес каждой строки. Нам достаточно знать: «на странице 17392 лежат события от 2024-06-15 10:00 до 10:05» — и при запросе «дай события за 11:00» страницу 17392 не читать вовсе.

Это и есть идея

BRIN
.

Структура: min/max на range of pages

BRIN режет таблицу на диапазоны по N страниц подряд (по умолчанию pages_per_range = 128, это 1 MiB) и для каждого диапазона хранит summary — min и max значений индексируемой колонки.

BRIN на time-series

События пишутся в порядке времени. BRIN хранит min/max на каждые 128 страниц. Запрос за окно времени проверяет summary каждого диапазона; страницы, чьи min/max не пересекают окно, пропускаются.

Heap-файл events (1 GiB = 131072 страницы = 1024 range при pages_per_range=128)
range 0 (pages 0-127)
min: 2024-06-01 00:00
max: 2024-06-01 02:13
range 1 (pages 128-255)
min: 2024-06-01 02:13
max: 2024-06-01 04:27
range 2 (pages 256-383)
min: 2024-06-01 04:27
max: 2024-06-01 06:38
Query: WHERE ts BETWEEN '04:00' AND '05:00' -> читаем только range 1, 2; range 0 пропущен

Размер BRIN-индекса смешной: для 1 GiB таблицы при pages_per_range = 128 получаем 1024 summary-записи × ~32 байта ≈ 32 KiB. Даже на 500 GiB таблице BRIN весит мегабайты, а не гигабайты.

Демонстрация на events

Датасет medium+timeseries содержит таблицу events на 100K строк, упорядоченных по occurred_at. Маленькая по меркам BRIN, но идею показывает.

Размер таблицы и страниц. На 100K строк events занимает несколько MiB — десятки страниц. Дата-сет инициализируется ~7 секунд.

PostgreSQL

Создаём BRIN и B-tree на одной колонке. Смотрим разницу в размере — BRIN на порядки меньше.

PostgreSQL

Видно: B-tree на 100K строк — несколько MiB, BRIN — пара KiB. На реальной таблице в сотни миллионов строк разница достигает 10000:1.

План запроса по окну времени. EXPLAIN покажет: BRIN использует Bitmap Index Scan с recheck — типично.

PostgreSQL

Обрати внимание: BRIN — это всегда Bitmap Index Scan + Recheck. Индекс возвращает не точный список tid, а список страниц-кандидатов. Дальше Postgres читает каждую страницу и фильтрует строки по точному условию. То есть на 1 совпавшую строку в range он может прочитать 128 страниц.

pages_per_range: главный tuning-параметр

Дефолт pages_per_range = 128 (1 MiB) — компромисс. Чем меньше — тем точнее min/max, меньше recheck, но больше индекс. Чем больше — наоборот.

Сравнение разных pages_per_range. Меньшее значение точнее, но больше по размеру.

PostgreSQL

Правило: чем больше таблица — тем больший pages_per_range можно себе позволить. Для time-series в 100 GiB значение 256 или даже 512 даёт индекс в считанные мегабайты, чего более чем достаточно.

Что внутри BRIN: revmap и tuples

Структурно BRIN состоит из двух уровней:

  1. Meta + revmap (range-map). Это массив указателей: индекс i содержит указатель на BRIN tuple, описывающий range страниц [i*pages_per_range, (i+1)*pages_per_range). Размер revmap — фиксированный, очень маленький.
  2. BRIN tuples — собственно записи summary (min/max или bloom). Хранятся в отдельных страницах индексного файла.
BRIN: revmap + tuples

Revmap — массив 6-байтных указателей, по одному на range. BRIN tuples хранят сами summary. При обновлении строки revmap-указатель меняется in-place.

meta + revmap (массив указателей на summary tuples)фиксированный размер на range
revmap[0] -> tuple A
revmap[1] -> tuple B
revmap[2] -> tuple C
tuple Amin=Jan 01, max=Jan 03
tuple Bmin=Jan 03, max=Jan 05
tuple Cmin=Jan 05, max=Jan 07

Когда вставляется новая строка с большим max, чем уже зафиксирован — нужно обновить tuple. Это single-page update, без перестройки дерева — это и есть причина, по которой BRIN дёшев на write.

Когда BRIN — спасение, а когда — провал

BRIN работает, когда есть корреляция между физическим порядком строк и значением индексируемой колонки. Эта корреляция оценивается в pg_stats как correlation: 1.0 — идеальный порядок, 0.0 — случайный, -1.0 — обратный.

Корреляция в pg_stats. Чем ближе к ±1, тем лучше работает BRIN. На events.occurred_at корреляция почти 1.0 — идеал.

PostgreSQL

Хорошие кандидаты:

  • Time-series: события, логи, метрики — пишутся монотонно по времени.
  • Sequential IDs: serial / bigserial — порядок insert совпадает с порядком id.
  • Append-only: audit-таблицы, where строки только добавляются и не двигаются.

Плохие кандидаты:

  • UPDATE-heavy с MVCC, где строки разлетаются по разным страницам (HOT-чейны эту проблему частично решают).
  • Случайные значения (uuid, hash) — корреляция близка к 0, каждый range покрывает весь диапазон, BRIN бесполезен.
  • После CLUSTER на другую колонку — порядок ломается.

Если корреляция упала до 0.3, BRIN не возвращает выгоды: Bitmap recheck читает почти все страницы.

bloom-opclass: BRIN для equality

В Postgres 14+ есть BRIN-opclass bloom (не путать с pg_bloom расширением). Вместо min/max он хранит bloom filter по значениям range’а — это позволяет отвечать на WHERE col = X через BRIN. Полезно, когда корреляция плохая, но мощность колонки маленькая (например, country_code — 200 значений на 500M строк): сам диапазон может пересекать всю таблицу по min/max, но bloom скажет «в этом range точно нет ‘JP’» и пропустит его.

Multi-range opclass

С Postgres 14 BRIN также получил multi-range opclass. Вместо одного [min, max] он хранит несколько узких диапазонов на range. Зачем? Представь, что данные почти отсортированы, но иногда «прилетают» сильно более старые события (late-arriving data). При обычном min/max один такой outlier расширяет min до старой даты, и range покрывает огромный интервал — теряется выгода. С multi-range outlier хранится как отдельный мини-диапазон, и большая часть данных в range остаётся узко покрытой.

CREATE INDEX events_at_brin_mr ON events
  USING BRIN (occurred_at timestamptz_minmax_multi_ops);

Размер индекса немного больше, чем у простого minmax, но устойчивость к outliers радикально выше — особенно полезно для логов и event-streams с допуском по времени.

Размер и I/O экономия в цифрах

Считаем экономию: какой процент heap-страниц BRIN отсекает на типовом range-запросе? EXPLAIN покажет 'Heap Blocks: lossy=N exact=M' — это страницы, которые пришлось читать.

PostgreSQL

Сопоставь Heap Blocks: lossy=N с общим числом страниц таблицы (pg_relation_size('events')/8192). Это и есть процент heap, который BRIN сэкономил. На хорошо коррелированных данных это часто 95-99%.

Проверка знанийKnowledge check
Таблица user_actions — UUID first column, 200M строк, обновления редкие, запросы по дате создания (created_at). Корреляция created_at около 0.99. Какой индекс лучше: B-tree или BRIN?
ОтветAnswer
BRIN с почти 100% уверенностью. Корреляция 0.99 — идеальный кейс. B-tree займёт 4-6 GiB и будет замедлять каждый insert в десятки раз. BRIN на тех же 200M строк занимает считанные мегабайты и легко переживает потоковую запись. Запросы по диапазону дат отработают через Bitmap Index Scan с минимальным recheck. Единственный риск — если корреляция со временем упадёт (например, начнут массово обновлять старые строки и они «переедут» в конец heap из-за MVCC); в этом случае нужно следить за pg_stats.correlation и при необходимости REINDEX.

Мониторинг и обслуживание

  • pg_stats.correlation для индексируемой колонки — следи, чтобы не падала ниже 0.7.
  • pg_stat_user_indexes.idx_scan — если BRIN перестал использоваться оптимизатором, это сигнал, что статистика устарела или корреляция упала.
  • VACUUM table обновляет BRIN summary для страниц, добавленных после последнего summarize. Иногда полезно вызвать brin_summarize_new_values('events_at_brin') руками, чтобы догнать.
SELECT brin_summarize_new_values('events_at_brin');

Это особенно важно после массовой вставки и сразу перед запросом — иначе новые страницы ещё не в индексе, и Bitmap Scan будет читать их все целиком.

BRIN + партицирование = золотая комбинация

В реальной timeseries-системе с миллиардами строк часто используют declarative partitioning по неделе/месяцу + BRIN на каждой партиции. Логика:

  • Партицирование ограничивает запрос одной (или несколькими) партициями — partition pruning.
  • BRIN внутри партиции дополнительно сужает чтение до конкретных страниц.
  • DROP старых партиций для архивации — атомарный, без VACUUM.

Это даёт O(1) запрос на «события за последние 5 минут» в таблице на 10 ТБ — без B-tree-индекса, который бы съел сотни гигабайт.

Дополнительный плюс: при rolling-window архивации (новая партиция каждую неделю, старые партиции архивируются через pg_dump --table или TimescaleDB compression), BRIN строится по новой партиции мгновенно (мегабайты при сотнях GB heap), и его не приходится REINDEX.

Если используешь TimescaleDB, обрати внимание: hypertables встроенно используют BRIN-подобные стратегии при chunk-exclusion, а на отдельных колонках уже сверху можно класть свой BRIN. Это часто дёшево и эффективно.

Чек-лист

  • BRIN = block range index: min/max (или bloom) на каждый диапазон страниц.
  • Размер на порядки меньше B-tree: KiB вместо GiB на больших таблицах.
  • Всегда даёт Bitmap Index Scan + Recheck — точность ограничена гранулярностью range.
  • Ключевое условие: корреляция физического порядка и значения колонки должна быть высокой (≥ 0.7).
  • Параметр pages_per_range — главный tuning: меньше = точнее, но больше; обычно 32-256.
  • Use cases: time-series, audit-логи, serial/bigserial ID, append-only таблицы.
  • Не использовать для UUID, hash, случайных значений или UPDATE-heavy таблиц с разлетающимися строками.
  • Расширения и комбинации: BRIN отлично работает поверх declarative partitioning и в TimescaleDB hypertables.
  • Постоянно следи за pg_stats.correlation — если падает, BRIN деградирует молча.
Metadata и Statistics в Parquet Разреженный индекс ClickHouse MergeTree
  • BRIN на 100 GiB таблице — обычно индекс размером 1-10 MiB; экономия по сравнению с B-tree часто 1000x.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Что хранит BRIN-индекс?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5