В предыдущих уроках мы говорили «таблица лежит в файле», но не объясняли — в каком именно. Этот урок закрывает дыру: где конкретно на диске находится каждая ваша таблица, как Postgres находит её среди сотен тысяч файлов, что лежит рядом с основным heap-файлом и зачем оно нужно.
Это база, без которой невозможно ни восстановление после катастрофы, ни оптимизация I/O между дисками, ни анализ bloat. Когда DBA говорит «таблица занимает 5 файлов по 1 GiB», или «free space map устарел», ты должен понимать, о чём речь, физически.
Что лежит в $PGDATA
$PGDATA — это директория данных кластера PostgreSQL (SHOW data_directory). Внутри неё структура:
$PGDATA/
base/ ← все базы кластера
1/ ← template1
13412/ ← postgres (один из системных oid)
16384/ ← твоя БД
2615 ← системная таблица (pg_namespace)
16400 ← одна из твоих таблиц
16400_fsm ← Free Space Map для неё
16400_vm ← Visibility Map для неё
16400.1 ← второй сегмент heap, если таблица > 1 GiB
global/ ← кластерные таблицы (pg_database и др.)
pg_wal/ ← WAL-журналы
pg_tblspc/ ← символические ссылки на пользовательские tablespaces
Каждая база — это директория с числовым именем (oid из pg_database). Внутри неё — файлы. И вот ключевой момент: имя файла — это relfilenode, а не имя таблицы и не её oid.
Имя таблицы → pg_class.oid и pg_class.relfilenode → файл в base/<dbid>/<relfilenode>.
oid vs relfilenode: важное различие
При создании таблицы oid и relfilenode совпадают. Но операции, которые переписывают таблицу — VACUUM FULL, CLUSTER, REINDEX, TRUNCATE, ALTER TABLE ... SET TABLESPACE — создают новый файл с новым relfilenode и атомарно переключают pg_class.relfilenode. Старый файл удаляется в конце транзакции.
Зачем такая сложность? Атомарность. Если бы Postgres переписывал файл «in-place», а сервер бы упал посередине — данные были бы повреждены. Через relfilenode-indirection всё чисто: либо коммит и новый файл валиден, либо rollback и старый цел.
Практическое следствие: имя файла на диске не стабильно. Если ты хочешь по pid дампа найти таблицу — спрашивай через pg_filenode_relation():
oid и relfilenode для customers и orders. До любых VACUUM FULL — они совпадают:
Демонстрация: после VACUUM FULL relfilenode меняется, oid — нет:
Заметь: oid не изменился (на него ссылаются foreign keys, права, статистика), а relfilenode стал другим. На диске старый файл удалён, новый создан.
Сегменты по 1 GiB
Один heap-файл не может быть больше 1 GiB. Когда таблица перерастает этот лимит, Postgres создаёт следующий файл — сегмент — с суффиксом .1, .2, и т.д.:
$PGDATA/base/16384/
16400 ← сегмент 0 (первый 1 GiB)
16400.1 ← сегмент 1 (следующий 1 GiB)
16400.2 ← сегмент 2
...
Это историческое наследие: некоторые файловые системы (в первую очередь ранние версии ext, FAT32) плохо работали с файлами > 2 GiB. Сегодня все современные FS (ext4, xfs, zfs, apfs) спокойно держат гигабайтные файлы — но Postgres придерживается традиции из соображений совместимости и для удобства массового бэкапа. Логически это один heap; для клиента сегментация полностью прозрачна.
Размер сегмента задаётся на компиляции (--with-segsize, по умолчанию 1 GiB), как и BLCKSZ. В runtime поменять нельзя.
Forks: .fsm, .vm, init
Кроме основного heap-файла рядом с таблицей лежат forks — дополнительные файлы с тем же relfilenode, но суффиксами:
all-visible (на странице нет dead tuples и нет апдейтов от незакоммиченных транзакций) и all-frozen (все tuples заморожены, transaction id уже не имеет значения). VM критична для двух оптимизаций: index-only scan (можно вернуть результат из индекса, не заходя в heap, если страница помечена all-visible) и incremental VACUUM (пропускать страницы all-visible — их и так чистить нечего).
init — пустой fork-маркер, существует только для unlogged-таблиц. Используется для re-init таблицы после crash recovery: вместо WAL-replay просто копируется initial state.
Основной heap, его сегменты, FSM и VM forks. Все имеют одинаковый relfilenode.
Размеры этих forks обычно невелики:
- FSM: ~3 байта на heap-страницу (так ~3 MiB на 1 GiB heap).
- VM: ~2 бита на heap-страницу (так ~32 KiB на 1 GiB heap).
Но они критически важны для производительности. Если FSM «отстал» от реальности (баги, sigkill, очень редко) — INSERT’ы начинают писать в конец, и таблица растёт. Если VM не обновлён — index-only scan вырождается в обычный index scan + heap fetch.
Размеры всех forks таблицы customers — heap, fsm, vm и индексы:
pg_relation_size(relation, fork) принимает второй аргумент — имя fork’а: 'main', 'fsm', 'vm', 'init'. По умолчанию (без аргумента) — main.
Tablespaces: положить таблицу на другой диск
Default tablespaces — pg_default (для пользовательских таблиц, файлы в $PGDATA/base/) и pg_global (для кластерных, в $PGDATA/global/). Но ты можешь создать свой tablespace, указывающий на другую директорию (например, на NVMe-диск отдельно от основного HDD):
CREATE TABLESPACE fast LOCATION '/mnt/nvme/pgdata';
CREATE TABLE hot_orders (...) TABLESPACE fast;
-- или перенести существующую:
ALTER TABLE old_orders SET TABLESPACE fast;
Физически tablespace — это символическая ссылка в $PGDATA/pg_tblspc/<tblspc_oid> на ту директорию, что ты указал. Внутри неё структура <pg_version>/<dbid>/<relfilenode> — такая же, как в base/.
Зачем это нужно:
- Разделение по дискам: горячие OLTP-таблицы на NVMe, архив на HDD.
- Разделение по объёму: главный диск близок к 100%, новые таблицы — на свежем большом массиве.
- Раздельный fs-tuning: на NVMe-диске с короткими
fsync— журналы, на HDD с longer commit — холодный архив. - Read-only mounts: tablespace на read-only диске даёт защиту от случайных DROP.
Когда не нужно:
- Чтобы «сделать таблицу быстрее». Если диск тот же — никакого выигрыша нет.
- Для бэкапа: tablespace требует отдельной обработки в
pg_basebackupи часто ломает простыеcp -r $PGDATA.
Какие tablespaces определены в текущей БД? (В pglite будет только pg_default.)
Обычно в проде ты увидишь 1-3 tablespaces, не больше. Множить их без причины — путь к operational debt.
Поиск файла на диске
Допустим, ты подключился к серверу, видишь $PGDATA/base/16384/16400 и хочешь понять, какая это таблица. Делается через pg_filenode_relation(tablespace_oid, filenode):
Преобразуем relfilenode обратно в имя таблицы:
reltablespace = 0 означает «default tablespace» (либо pg_default, либо pg_global для системных). На реальной БД при инцидентах с поломкой каталога это иногда единственный способ восстановить связь файла с таблицей.
Главные практические следствия
- Имя файла нестабильно. Не пиши скрипты, ссылающиеся на relfilenode по hard-coded числу — он поменяется после первого VACUUM FULL.
- VACUUM FULL — это всегда новый файл. Это значит downtime (ACCESS EXCLUSIVE lock), удвоение диска на время операции, и сброс всей буферной памяти для этой таблицы.
- FSM и VM критичны. Они обновляются автовакуумом. Если ты долго не вакуумишь — index-only scan перестаёт работать, и INSERT’ы начинают аппендить в конец, а не заполнять освободившиеся страницы.
- Tablespaces — для физического разделения дисков, а не для логической организации. Логику оставь schema’м.
Чек-лист
$PGDATA/base/<dbid>/<relfilenode>— путь к heap-файлу таблицы. dbid берётся изpg_database.oid, relfilenode — изpg_class.relfilenode.- Сегменты по 1 GiB:
<relfilenode>,<relfilenode>.1,<relfilenode>.2. Прозрачно для запросов. - oid стабилен, relfilenode — нет. После
VACUUM FULL,CLUSTER,TRUNCATE,ALTER TABLE SET TABLESPACE— новый файл, новый relfilenode, тот же oid. - Forks:
_fsm(Free Space Map — где есть место),_vm(Visibility Map — кто all-visible/all-frozen для index-only scan и быстрого VACUUM),_init(для unlogged). - Tablespace — способ положить таблицу на другой диск.
CREATE TABLESPACE+ALTER TABLE ... SET TABLESPACE. pg_filenode_relation(reltablespace, relfilenode)— функция, преобразующая имя файла обратно в имя таблицы.pg_relation_size(rel, fork)поддерживает второй аргумент:'main'/'fsm'/'vm'/'init'.