Таблица в PostgreSQL — это не один файл, а целый набор: сама куча, индексы, последовательности, TOASTмеханизм хранения значений, слишком больших для одной страницы, в отдельной таблице и служебные карты. Чтобы понимать поведение VACUUM FULL, TRUNCATE, unlogged-таблицтаблицы, содержимое которых не пишется в журнал предзаписи и сбрасывается после сбоя и табличных пространств, нужно разобраться, как имя отношения превращается в путь на диске, почему у одной таблицы бывает пять файлов, зачем файлы режутся на гигабайтные куски и как всё это кэшируется в бэкенде.
От имени таблицы к файлу на диске
Путь к файлу отношения собирается из трёх частей: каталог base — это табличное пространствоместо хранения файлов, по умолчанию — каталог base внутри PGDATA по умолчанию, затем число — OIDчисловой идентификатор объекта в системных каталогах, который никогда не меняется базы данных, и последнее число — имя файла, то есть relfilenodeимя физического файла отношения на диске. Последнее число берётся из pg_class.
Функция pg_relation_filepath принимает отношение и возвращает путь к его файлу на диске. Путь строится именно с relfilenode, а не с OID таблицы, потому что OID — это идентификатор отношения, на который ссылаются все записи в каталогах, и он никогда не меняется, а relfilenode — просто имя файла. Обычно они совпадают, так как CREATE TABLE присваивает обоим одно и то же число, но операции, переписывающие таблицу, разрывают эту связь. Поэтому путь на диске определяется текущим значением relfilenode.
Одна таблица с первичным ключом порождает сразу несколько файлов, потому что для слоя хранения последовательность и индекс — это тоже отдельные отношения. Последовательность столбца identity и индекс первичного ключа получают собственные файлы: последовательность — это одностраничное отношение, а индекс — такое же отношение, как любое другое. Если в таблице есть колонка типа text, дополнительно создаётся TOAST и его индекс. Итог: один CREATE TABLE с первичным ключом даёт пять отношений и пять файлов.
Связь числа в имени файла обратно с записью в pg_class выполняет функция pg_filenode_relation: ей передают OID табличного пространства, где 0 означает табличное пространство базы по умолчанию, и relfilenode, а она возвращает соответствующее отношение.
relfilenode против OID
OID — это идентификатор отношения, на который ссылаются все каталоги, и он никогда не меняется. relfilenode — просто имя файла. Пока таблицу не переписывали, оба числа совпадают, потому что CREATE TABLE присваивает им одно и то же значение.
VACUUM FULL записывает компактную копию таблицы в совершенно новый файл и затем переключает запись в каталоге на него. OID остаётся прежним, чтобы ничто, ссылающееся на таблицу, этого не заметило, а relfilenode меняется. Так, при OID 16431 relfilenode после VACUUM FULL становится 16449.
TRUNCATE тоже назначает новый файл вместо опустошения старого — и именно это делает его транзакционным. При откате транзакции строка каталога возвращается к старому relfilenode, а старый файл остаётся нетронутым. Тот же путь используют CLUSTER, REINDEX, ALTER TABLE ... SET TABLESPACE и любой ALTER TABLE, переписывающий строки, потому что каждый из них строит новую копию отношения.
Что происходит со старым файлом после VACUUM FULL: до следующего checkpointточка синхронизации, при которой все грязные страницы сбрасываются на диск он ещё существует, и в листинге видно, что base/5/16431 имеет размер 0 байт. Данные уже лежат в новом файле, а старый доживает до контрольной точки.
Форки: main, fsm, vm, init
У отношения есть основной файл и до трёх сопутствующих, которые делят его relfilenode и различаются суффиксом. PostgreSQL называет их форкамивариант файла отношения, различающийся суффиксом в имени:
- Форк без суффикса — main, фактические страницы таблицы.
_fsm— free space mapкарта свободного места, помогающая INSERT найти страницу без полного сканирования, небольшое дерево, которое примерно записывает, сколько места осталось на каждой странице кучи._vm— visibility mapкарта видимости, по два бита на страницу кучи, где бит all-visible позволяет сканированию пропускать проверки видимости, а all-frozen позволяетVACUUMпропускать страницу для целей wraparound._init— состояние, в которое отношение сбрасывается после сбоя.
Для unlogged-таблицы init-форк создаётся как раз потому, что такая таблица пропускает журнал предзаписи (write-ahead log). После неаккуратного завершения её содержимому нельзя доверять, и PostgreSQL заменяет основной форк копией init-форка. Для кучи эта копия — пустой файл, поэтому 16444_init имеет нулевой размер. У TOAST-кучи и TOAST-индекса той же unlogged-таблицы init-форки создаются так же: две кучи получают нулевой init-форк, а TOAST-индекс — полные 8192 байта, потому что пустому B-tree всё ещё нужна метастраница.
Как md.c хранит дескрипторы открытых файлов
Объект уровня smgr, представляющий открытое отношение, держит дескрипторы открытых сегментов в массивах — по одному массиву на каждый форк. Длина каждого массива записана отдельно. Элемент массива содержит номер дескриптора в пуле fd.c и номер сегмента, начиная с 0.
Важно, что текущая длина массива для форка не означает, что у отношения нет других сегментов: просто следующий сегмент ещё не открыт. Записей для неактивных сегментов не создаётся — как только встречается частичный сегмент, все последующие считаются неактивными. Весь массив выделяется в отдельном контексте памяти, из которого берётся память под эти массивы.
Функция _fdvec_resize меняет размер массива для указанного отношения (reln), форка (forknum) и нового числа сегментов (nseg):
- при
nseg == 0освобождает массив и обнуляет указатель, если ранее размер был больше нуля; - если массив ещё не создан, выделяет память под
nsegэлементов; - если
nsegбольше текущего размера, расширяет массив черезrepalloc_array; - если
nsegменьше, массив не уменьшается — чтобыmdtruncate()мог обещать отсутствие выделений памяти и работать в критической секции.
В конце функция всегда записывает новое значение длины массива. При открытии сегмента _mdfd_openseg проверяет, что запрашиваемый номер сегмента равен текущей длине массива, затем вызывает _fdvec_resize с числом сегментов на единицу больше и заполняет запись.
Сегменты по 1 ГБ
Когда таблица или индекс превышает 1 ГБ, они делятся на сегменты размером в гигабайт. Имя первого сегмента совпадает с filenode, последующие получают суффиксы .1, .2 и так далее. Такое разбиение позволяет обойти ограничения платформ на размер файла. 1 ГБ — лишь значение по умолчанию, его можно изменить опцией --with-segsize при сборке PostgreSQL.
Номер блока ничего не знает о сегментах: блок 131072 — это просто первая страница файла 16466.1. Суффикс сегмента добавляет storage managerслой, отвечающий за операции с файлами отношений при вычислении, в каком файле лежит данный блок; pg_relation_filepath по-прежнему возвращает базовое имя.
Как mdreadv/mdwritev работают с границей сегмента
mdreadv и mdwritev обрабатывают блоки по сегментам. Сначала вычисляется смещение внутри текущего сегмента и число блоков, помещающихся в него:
seekpos = (pgoff_t) BLCKSZ * (blocknum % ((BlockNumber) RELSEG_SIZE));
nblocks_this_segment =
Min(nblocks,
RELSEG_SIZE - (blocknum % ((BlockNumber) RELSEG_SIZE)));Если запрошенный диапазон выходит за границу сегмента, то есть nblocks_this_segment != nblocks, обе функции немедленно завершаются ошибкой: чтение — "read crossing segment boundary", запись — "write crosses segment boundary". Для асинхронного варианта mdstartreadv пересечение границы также запрещено.
После успешной обработки одного сегмента счётчики сдвигаются на nblocks_this_segment, и цикл повторяется для следующего сегмента. Внутри сегмента возможны короткие чтения или записи, и тогда цикл продолжается с обновлением смещения и оставшегося вектора ввода-вывода, пока не будет перенесён весь размер сегмента.
Как _mdfd_getseg открывает сегменты по требованию
_mdfd_getseg находит сегмент по номеру блока: targetseg = blkno / RELSEG_SIZE. Если целевой сегмент уже открыт, работа сразу завершается. Иначе, если задан флаг EXTENSION_DONT_OPEN, функция ничего не открывает. Далее она итерирует от последнего открытого сегмента до целевого — сегменты открываются строго по порядку от младшего к старшему.
Поведение при отсутствии сегмента зависит от флагов:
- если задан
EXTENSION_CREATEлибо идёт recoveryвосстановление после сбоя или из резервной копии и заданEXTENSION_CREATE_RECOVERY, сегмент создаётся, причём при неполном предыдущем сегменте он дополняется нулями; - если создание не разрешено и предыдущий сегмент короче
RELSEG_SIZE, то приEXTENSION_RETURN_NULLфункция сообщает об отсутствии файла, иначе возникает ошибка; - если открыть сегмент не удалось, при
EXTENSION_RETURN_NULLи признаке возможного удаления файла функция сообщает об отсутствии файла, иначе — ошибка.
В обычной работе mdreadv вызывает _mdfd_getseg с EXTENSION_FAIL | EXTENSION_CREATE_RECOVERY, а mdprefetch — с EXTENSION_RETURN_NULL в recovery и EXTENSION_FAIL иначе.
Расширение и запись файлов
mdzeroextend() добавляет к отношению сразу несколько блоков, заполненных нулями, в отличие от mdextend(), который пишет один блок. Внутри цикла по сегментам выбирается способ расширения: если число блоков больше 8 и метод расширения не FILE_EXTEND_METHOD_WRITE_ZEROS, вызывается FileFallocate() — обёртка над posix_fallocate. Это часто эффективнее write(), так как обычно не заставляет ядро выделять page cache для расширенных страниц.
Малые расширения (не более 8 блоков) намеренно не используют fallocate, потому что это ломает delayed allocationотложенное выделение блоков файловой системой на некоторых файловых системах. Вместо этого вызывается FileZero(), который через pg_pwrite_zeros() пишет нули, избегая множественных записей и нулевого буфера на всю длину расширения. После расширения, если не задан skipFsync и отношение не временное, сегмент регистрируется как грязный.
Предел в 2^32-1 блоков
Отношение отказывается расширяться, потому что номер блока имеет тип BlockNumber, и значение InvalidBlockNumber зарезервировано как недопустимый номер блока. В mdextend() проверяется, равен ли запрашиваемый номер блока значению InvalidBlockNumber, и если да, выдаётся ошибка "cannot extend file ... beyond %u blocks" с кодом ERRCODE_PROGRAM_LIMIT_EXCEEDED. В mdzeroextend() аналогичная проверка выполняется для диапазона:
if ((uint64) blocknum + nblocks >= (uint64) InvalidBlockNumber)Так нельзя создать блок, номер которого равен InvalidBlockNumber или больше, — иначе нарушился бы смысл этого специального значения. При достижении 2^32-1 блоков отношение перестаёт расширяться.
Усечение и удаление
mdtruncate обрезает отношение до нужного числа блоков, проходя сегменты с конца. Число блоков во всех сегментах до текущего вычисляется как (curopensegs - 1) * RELSEG_SIZE. Если это число больше целевого, сегмент больше не активен: файл усекается до нуля, регистрируется как грязный, затем закрывается, а массив открытых сегментов уменьшается. Иначе, если сумма блоков до текущего сегмента и RELSEG_SIZE превышает целевое число, это последний сохраняемый сегмент, и его длина усечения в блоках вычисляется как разность целевого числа и блоков до сегмента.
Немедленное закрытие неактивных сегментов выполняется в mdregistersync и mdimmedsync: после mdnblocks, который открывает все активные сегменты, фиксируется граница неактивных, временно открываются неактивные сегменты, а затем в цикле для сегментов за границей вызывается FileClose и уменьшение массива, чтобы сразу освободить дескрипторы.
Tombstone-файл
Tombstone-файл — это пустой первый сегмент отношения, которое уже удалено. Функция register_unlink_tombstone нужна, чтобы запланировать удаление такого файла после следующего контрольного пункта: она формирует тег для главной ветви и нулевого сегмента и регистрирует запрос на удаление.
Удаление через mdunlinkfork не всегда приводит к немедленному исчезновению файла, потому что для обычных (не временных) отношений первый сегмент не удаляется сразу, а только усекается до нуля, и регистрируется запрос на удаление после следующего контрольного пункта. Пустой файл оставляют на месте, чтобы relfilenumber не был переиспользован. Ветка с register_unlink_tombstone выполняется, когда это не redo, не бинарное обновление, форк — главный и отношение не временное; в этой ветке сначала выполняется усечение файла, затем регистрируется запрос на удаление.
Слой smgr и кэш
Каждый бэкенд хранит все существующие объекты SMgrRelation — по сути кэшированные файловые дескрипторы — в хеш-таблице, а объекты без «пинов» дополнительно связаны в список незакреплённых. При первом обращении к отношению таблица создаётся, а затем запись либо находится, либо заводится заново. У новой записи счётчик пинов равен нулю, и она кладётся в хвост списка незакреплённых.
pinпометка объекта как используемого, запрещающая его уничтожение увеличивает счётчик и, если он был равен нулю, удаляет объект из списка незакреплённых, чтобы его нельзя было уничтожить в конце транзакции. Снятие пина уменьшает счётчик и при достижении нуля снова помещает объект в список незакреплённых. Очистка всех незакреплённых объектов проходит по списку и для каждого закрывает все форки, удаляет узел из списка и убирает запись из хеш-таблицы. При этом уничтожаемый объект обязан иметь нулевой счётчик пинов, а сама очистка предполагает, что на объекты нет указателей, кроме закреплённых через pin.
Кэш числа блоков
smgrnblocks_cached возвращает закэшированное число блоков форка отношения только во время восстановления и только если значение не равно InvalidBlockNumber; иначе возвращает InvalidBlockNumber. Причина такого ограничения — отсутствие общего механизма инвалидации для изменений размера файла, поэтому вне восстановления кэш не используется.
Кэш обновляется в smgrtruncate, потому что именно эта функция сама меняет размер файла: перед вызовом усечения кэш сбрасывается в InvalidBlockNumber на случай ошибки, а после успешного усечения записывается новое значение — меньшее из запрошенного и старого числа блоков. Это делается, чтобы локальная копия кэша не была заведомо неверной до прихода инвалидации.
Случай, когда запрошенное число блоков больше старого, возникает при повторных усечениях и перезапуске реплики с restartpointточка перезапуска восстановления на реплике до усечений: на диске файл имеет размер последнего усечения, а усечение ничего не делает, поэтому кэш выставляется в старое значение.
Связь smgr с md и гарантии fsync
smgrreadv, smgrwritev и smgrwriteback — это обёртки уровня smgr, которые через таблицу методов конкретного менеджера хранения вызывают соответствующие операции (для магнитных дисков — mdreadv, mdwritev, mdwriteback). smgrwritev не является синхронной записью: блок не обязательно на диске к моменту возврата, он лишь выгружен в ядро, но предусматривается fsync перед следующим контрольным пунктом.
Гарантия fsync обеспечивается тем, что при записи регистрируется грязный сегмент: при создании отношения после открытия файла вызывается register_dirty_segment, если отношение не временное. Эта функция формирует тег файла и регистрирует запрос синхронизации. register_forget_request, наоборот, отменяет ранее зарегистрированные запросы fsync для указанного сегмента. mdimmedsync — немедленная синхронизация менеджера магнитных дисков.
Табличные пространства
Путь строится из relfilelocatorтройка (табличное пространство, база, relfilenode), уникально именующая файл отношения в кластере: табличное пространство задаётся своим OID, и в каталоге pg_tblspc для него лежит симлинксимволическая ссылка, названная OID табличного пространства и ведущая на реальное место хранения. Под симлинком находится каталог с именем вида PG_18_202506291 — это версия сервера и каталога, что позволяет двум мажорным версиям совместно использовать одно расположение при обновлении. Ниже идёт <database OID>/<relfilenode>, так же как в base/.
Пример: pg_tblspc/16459/PG_18_202506291/5/16460, где 16459 — OID табличного пространства, 5 — OID базы, 16460 — relfilenode. Симлинк 16459 ведёт на /var/lib/postgresql/fast. Тройка (OID табличного пространства, OID базы, relfilenode) уникально именует файл отношения в кластере.
Что видит клиент
После INSERT и VACUUM без контрольной точки файл на диске имеет правильную длину, потому что отношение было расширено нулевым пространством, но сами изменения остались только в shared_buffersобщий буферный пул в разделяемой памяти, где живут грязные страницы до записи на диск: INSERT и VACUUM лишь пометили страницы как грязные, и никто не записал их обратно. Поэтому od показывает нули в начале файла. Выполнение CHECKPOINT заставляет выполнить запись, после чего те же байты содержат уже реальные данные и контрольную сумму.
pg_relation_size получает свои числа, просто выполняя stat файлов. Поскольку размер берётся из метаданных файловой системы, он отражает размер файлов на диске, а не объём полезных данных. Файлы разбиты на сегменты по 1 ГБ, и каждый сегмент учитывается целиком. Кроме того, в размер входят вспомогательные форки — free space map и visibility map: pg_table_size включает TOAST-таблицу, free space map и visibility map. Наконец, при вычислении числа блоков игнорируется неполный блок в конце файла, что тоже влияет на расхождение с реально занятым полезными данными местом.
Что из этого следует на практике
VACUUM FULLне переиспользует старый файл, а строит новый. OID таблицы не меняется, поэтому ссылки в каталогах остаются валидными, а relfilenode переключается на новый файл. Старый файл доживает до контрольной точки, и до неё на диске временно лежат оба.TRUNCATEтранзакционен именно потому, что назначает новый файл. При откате строка каталога возвращается к старому relfilenode, а старый файл остаётся нетронутым. Тот же механизм используютCLUSTER,REINDEXи переписывающиеALTER TABLE.- Одна таблица — это несколько файлов. Индексы, последовательности и TOAST-хранилище — отдельные отношения со своими relfilenode, поэтому размер таблицы на диске не сводится к одному файлу.
- Размер, который показывает
pg_relation_size, — это размер файлов, а не объём данных. В него входят служебные форки, целые гигабайтные сегменты и не учитывается неполный блок в конце файла. - unlogged-таблицы после сбоя сбрасываются к init-форку. Для кучи это пустой файл, поэтому данные unlogged-таблицы после неаккуратного завершения теряются, а не восстанавливаются.
- Изменения видны на диске не сразу. До контрольной точки грязные страницы живут в shared_buffers, и файл на диске может содержать нули там, где в памяти уже есть данные.
- Кэш числа блоков используется только в recovery. Вне восстановления нет общего механизма инвалидации при изменении размера файла, поэтому размер приходится узнавать заново.