Learning Platform
Урок 02.01 · 22 мин
Продвинутый
StoragePagesHeapctidTuple layout

В Fundamentals мы говорили: реляция — это множество кортежей. Это полезная абстракция для написания запросов, но для понимания производительности она бесполезна. Когда твой запрос работает 30 секунд, а должен бы за 30 миллисекунд, ты не сможешь ему помочь, пока не знаешь, как именно Postgres лежит на диске. Поэтому с этого модуля и до конца курса — мы спускаемся на уровень байтов.

Первый камень фундамента — страница (page).

8 KiB и почему именно столько

В PostgreSQL единица ввода-вывода — это страница размером 8 KiB (8192 байта). Не 4 KiB, не 16 KiB. И это число намертво прибито в исходниках: чтобы его поменять, надо пересобрать PostgreSQL с другим BLCKSZ (что в продакшене делают единицы и обычно жалеют).

Почему 8 KiB?

  • 4 KiB — это типичный размер OS page и сектор SSD. Если бы Postgres страница была 4 KiB, то одно её чтение совпадало бы с одним системным IO. Но при 4 KiB на странице помещается слишком мало кортежей — и overhead на page header становится значимым.
  • 16 KiB — пробовали MySQL (InnoDB), но в Postgres эксперименты показали худший результат: на случайных INSERT’ах страница чаще «протекает» (нужно делать split), и обновления горячих строк требуют переписывания большей страницы.
  • 8 KiB — компромисс. Помещается 50-200 кортежей среднего размера, page header амортизирован. Это число выбрано в 1996-м и не пересматривалось.

Главное практическое следствие: любая операция чтения или записи в Postgres округляется до 8 KiB. Прочитать 100 байт = прочитать 8 KiB. Обновить один atom = переписать 8 KiB. Это меняет интуицию.

Heap: файл из страниц

Таблица в Postgres физически — это последовательность страниц в файле, который называется heap (по аналогии с unordered-heap из computer science, не имеет отношения к heap-allocator из C). Имя файла — числовой relfilenode, лежит он в $PGDATA/base/<dbid>/.

Файл растёт сегментами по 1 GiB. Когда он переполняется — Postgres создаёт следующий: <relfilenode>.1, .2, и так далее. Это деталь файловой системы (некоторые FS плохо справляются с файлами свыше 2 GiB), а не логики БД — для запросов это прозрачно.

Анатомия heap-файла

Цепочка страниц по 8 KiB, переход к новому сегменту каждые ~131072 страниц (1 GiB).

heap-файл relfilenode 12345 (сегмент .0)до 1 GiB = 131072 страниц
page 08 KiB
page 18 KiB
page 28 KiB
......
page 131071последняя 8 KiB
сегмент .1следующий 1 GiB файл рядом

Что внутри страницы

Страница — это не плоский массив. Внутри у неё фиксированная структура:

Layout страницы 8 KiB

Header слева, кортежи растут справа налево, line pointers — слева направо, посередине растёт «дырка» свободного места.

PageHeader24 байта
ItemId arrayпо 4 байта на кортеж
свободное месторастёт навстречу
Tuplesрастут с конца страницы
Special area0 байт для heap

Разберём по частям:

  1. PageHeader (24 байта) — метаданные страницы: LSN последней записи (для WAL recovery), checksum (если включён), флаги, указатели на начало свободного места и на конец line pointers. Из них для понимания работы важен LSN: именно он используется для recovery и replication.

  2. ItemId array (line pointers) — массив 4-байтных указателей. Каждый указатель ссылается на конкретный

    heap tuple
    внутри страницы и хранит его смещение от начала страницы и длину. Index указывает не на смещение байта в tuple, а на номер line pointer’а — это слой косвенности, который позволяет сжимать страницу без обновления индексов (см. урок про HOT).

  3. Свободное место — растёт навстречу: line pointers пишутся в начало, tuples — в конец. Pointed lower на header указывает, докуда дошли pointers; pointed upper — откуда начинаются tuples. Между ними — free space.

  4. Tuples — собственно данные. Каждый кортеж: 23-байтный header (с MVCC-полями xmin/xmax/cid и infomask) + null bitmap (если есть NULL) + сами данные колонок с alignment-паддингом.

  5. Special area — для heap-страниц всегда 0 байт. Используется в индексных страницах (например, в B-tree там хранятся next/prev указатели соседних страниц).

ctid: адрес кортежа

Каждый кортеж в Postgres имеет «физический адрес» —

ctid
, и его можно посмотреть как обычную колонку. Это пара (block_number, item_offset): номер страницы и номер line pointer внутри.

ctid первых пяти customers — физический адрес каждой строки. Дата-сет инициализируется ~5 секунд (генерируется 10K + 100K строк).

PostgreSQL

Видишь (0,1), (0,2), (0,3)… — это первые кортежи на странице 0. Дойдём до строки, где первое число станет 1 — значит, страница 0 заполнилась, и следующая запись пошла в страницу 1.

Самый последний ctid таблицы — последняя страница, на которой что-то лежит:

PostgreSQL

Для 10 000 customers с заголовком ~70 байт мы ожидаем ~90-100 кортежей на страницу, итого ~100-110 страниц. Точное число зависит от padding’а и от того, есть ли свободные line pointers, оставшиеся от UPDATE’ов.

Размер таблицы в страницах можно посмотреть прямо:

Размер таблицы и число страниц:

PostgreSQL

Сопоставь число страниц с последним ctid: должно совпасть в пределах 1.

Почему это важно

Когда оптимизатор смотрит, какой план выбрать — Seq Scan или Index Scan — он считает в страницах, а не в строках. Seq Scan стоимостью = N страниц × seq_page_cost (по умолчанию 1.0). Index Scan = K страниц × random_page_cost (по умолчанию 4.0 для HDD, обычно 1.1 для SSD) + чтение индекса. Чем меньше страниц нужно прочитать — тем дешевле план.

Это объясняет несколько интуитивных вещей:

  • На маленькой таблице (≤ 1 страницы) Postgres почти всегда выбирает Seq Scan — даже при наличии индекса. Потому что прочитать одну страницу = одна I/O, всё равно как.
  • SELECT count(*) без WHERE — это Seq Scan всей таблицы. Index не помогает, потому что нужно посчитать строки, а это требует прочитать каждую (хотя бы посмотреть на visibility map, см. урок про MVCC).
  • Если у тебя таблица 100 GiB, а запрос трогает 1% строк через WHERE indexed_col = X, индекс даёт ~100x ускорение. На таблице в 8 KiB — никакого.

В следующем уроке посмотрим, что внутри одного кортежа — как Postgres хранит сами данные, что такое TOAST, как считается выравнивание.

Зачем нужны индексы: от sequential scan к log(N) Иерархия памяти: L1/L2/L3, RAM, диск open/read/write/close — четыре syscall файлового ввода-вывода
Проверка знанийKnowledge check
Таблица содержит 100 000 строк по ~80 байт каждая. Какой ожидаемый размер таблицы в страницах? Возьми оценочно, без VACUUM bloat.
ОтветAnswer
Считаем. Размер кортежа на диске: ~80 байт данных + 23 байта заголовка + 4 байта line pointer (учитываем!) + padding (обычно ~5 байт до 8-байтного alignment) ≈ 112 байт суммарно. На странице 8192 байт − 24 PageHeader = 8168 байт полезного. Получаем ~73 кортежа на страницу. 100 000 / 73 ≈ 1370 страниц = 11.2 MiB. Реальное число будет ±10% в зависимости от точной структуры колонок. Сравни через pg_relation_size('table') / 8192 в реальной БД.

Чек-лист

  • Страница — единица IO Postgres, 8192 байта (8 KiB). Изменить нельзя без пересборки.
  • Heap — последовательность страниц в файле; сегменты по 1 GiB.
  • Внутри страницы: PageHeader (24 байта) → ItemId array → free space → tuples → special area (0 для heap).
  • ctid = (block, offset) — физический адрес кортежа, доступен как псевдоколонка.
  • Оптимизатор считает в страницах, а не в строках. Это объясняет, почему индекс не всегда выгоден.
Row vs Columnar: чем строковое хранение отличается от колоночного

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Какой размер страницы в PostgreSQL по умолчанию, и можно ли его поменять без пересборки?

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

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

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

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