Назад к блогу

Как PostgreSQL хранит таблицы на диске: relfilenode, форки и сегменты

Как PostgreSQL хранит таблицы на диске: relfilenode, форки и сегменты

В статье разбирается, как PostgreSQL раскладывает таблицу по файлам: почему путь на диске определяется не OID, а relfilenode, откуда у одной таблицы берётся сразу несколько файлов и зачем нужны форки main, fsm, vm и init. Это помогает понять, что на самом деле происходит при VACUUM FULL, TRUNCATE и смене табличного пространства, и почему старые файлы не исчезают мгновенно.

Таблица в PostgreSQL — это не один файл, а целый набор: сама куча, индексы, последовательности, TOAST и служебные карты. Чтобы понимать поведение VACUUM FULL, TRUNCATE, unlogged-таблиц и табличных пространств, нужно разобраться, как имя отношения превращается в путь на диске, почему у одной таблицы бывает пять файлов, зачем файлы режутся на гигабайтные куски и как всё это кэшируется в бэкенде.

От имени таблицы к файлу на диске

Путь к файлу отношения собирается из трёх частей: каталог base — это табличное пространство по умолчанию, затем число — 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, небольшое дерево, которое примерно записывает, сколько места осталось на каждой странице кучи.
  • _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: табличное пространство задаётся своим OID, и в каталоге pg_tblspc для него лежит симлинк. Под симлинком находится каталог с именем вида 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. Вне восстановления нет общего механизма инвалидации при изменении размера файла, поэтому размер приходится узнавать заново.

Где смотреть в коде

Источники

Похожее