В 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), а не логики БД — для запросов это прозрачно.
Цепочка страниц по 8 KiB, переход к новому сегменту каждые ~131072 страниц (1 GiB).
Что внутри страницы
Страница — это не плоский массив. Внутри у неё фиксированная структура:
Header слева, кортежи растут справа налево, line pointers — слева направо, посередине растёт «дырка» свободного места.
Разберём по частям:
-
PageHeader (24 байта) — метаданные страницы: LSN последней записи (для WAL recovery), checksum (если включён), флаги, указатели на начало свободного места и на конец line pointers. Из них для понимания работы важен LSN: именно он используется для recovery и replication.
-
ItemId array (line pointers) — массив 4-байтных указателей. Каждый указатель ссылается на конкретный
внутри страницы и хранит его смещение от начала страницы и длину. Index указывает не на смещение байта в tuple, а на номер line pointer’а — это слой косвенности, который позволяет сжимать страницу без обновления индексов (см. урок про HOT).heap tuple -
Свободное место — растёт навстречу: line pointers пишутся в начало, tuples — в конец. Pointed lower на header указывает, докуда дошли pointers; pointed upper — откуда начинаются tuples. Между ними — free space.
-
Tuples — собственно данные. Каждый кортеж: 23-байтный header (с MVCC-полями xmin/xmax/cid и infomask) + null bitmap (если есть NULL) + сами данные колонок с alignment-паддингом.
-
Special area — для heap-страниц всегда 0 байт. Используется в индексных страницах (например, в B-tree там хранятся next/prev указатели соседних страниц).
ctid: адрес кортежа
Каждый кортеж в Postgres имеет «физический адрес» —
(block_number, item_offset): номер страницы и номер line pointer внутри.
ctid первых пяти customers — физический адрес каждой строки. Дата-сет инициализируется ~5 секунд (генерируется 10K + 100K строк).
Видишь (0,1), (0,2), (0,3)… — это первые кортежи на странице 0. Дойдём до строки, где первое число станет 1 — значит, страница 0 заполнилась, и следующая запись пошла в страницу 1.
Самый последний ctid таблицы — последняя страница, на которой что-то лежит:
Для 10 000 customers с заголовком ~70 байт мы ожидаем ~90-100 кортежей на страницу, итого ~100-110 страниц. Точное число зависит от padding’а и от того, есть ли свободные line pointers, оставшиеся от UPDATE’ов.
Размер таблицы в страницах можно посмотреть прямо:
Размер таблицы и число страниц:
Сопоставь число страниц с последним 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 файлового ввода-выводаЧек-лист
- Страница — единица IO Postgres, 8192 байта (8 KiB). Изменить нельзя без пересборки.
- Heap — последовательность страниц в файле; сегменты по 1 GiB.
- Внутри страницы:
PageHeader (24 байта) → ItemId array → free space → tuples → special area (0 для heap). - ctid =
(block, offset)— физический адрес кортежа, доступен как псевдоколонка. - Оптимизатор считает в страницах, а не в строках. Это объясняет, почему индекс не всегда выгоден.