Справочник DBA: базы данных изнутри читается по порядку

Статья 1.3 · раздел 1 «Что делает DBA и как база лежит на диске» · 3 из 109

Каталог PGDATA в PostgreSQL: где лежит таблица, сегменты по 1 ГБ и pg_control

опубликовано
читать
≈ 10 мин
автор
среда и версия
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) в контейнере postgres:18; опыт выполнен 27 октября 2026
содержание решать задачи SQL на собеседовании

Таблица на 1,3 ГБ, а в каталоге данных нет файла с её именем: есть 16384, рядом 16384.1, 16384_fsm и 16384_vm. Так выглядит любая большая таблица PostgreSQL. Имя файла — число из системного каталога, файл режется на куски по 1 ГБ, а рядом лежат служебные карты. Ниже — где искать нужный файл и что лежит в pg_wal, pg_xact, global и в pg_control.

DBA / 01

Таблица t на диске: четыре файла, а рядом то, без чего базу не поднять

Дерево PGDATA с размерами файлов из опытаКорень PGDATA. В base/5, базе postgres с oid 5, таблица t состоит из четырёх файлов: 16384 размером 1 073 741 824 байта, 16384.1 размером 330 604 544 байта, 16384_fsm размером 368 640 байт и 16384_vm размером 49 152 байта. В global лежит pg_control размером 8 192 байта с контрольной точкой 0/B2080870. В pg_wal лежат 64 сегмента по 16 777 216 байт. В pg_xact лежит один файл 0000 размером 8 192 байта.PGDATA/var/lib/postgresql/18/dockerbase/5 — база postgres (pg_database.oid = 5); 16384 — relfilenode таблицы tbase/5/163841 073 741 824сегмент 0: ровно 1 ГБ, 131 072 страницыbase/5/16384.1330 604 544сегмент 1: остаток, 40 357 страницbase/5/16384_fsm368 640карта свободного места, 45 страницbase/5/16384_vm49 152карта видимости, 6 страницglobal/pg_control8 192контрольная точка 0/B2080870pg_wal/64 × 16 777 216журнал; пишем в сегмент …B2pg_xact/00008 192статусы транзакций, 1 страница
Таблица занимает четыре файла: два сегмента данных и две карты. Файл pg_control в 8 КБ указывает, с какого места журнала в pg_wal начинать восстановление, поэтому каталог данных нельзя разбирать по размеру файлов.

Стенд

Контейнер с закреплённой мажорной версией и пустой кластер:

docker run -d --name dba-postgres-data-directory -e POSTGRES_PASSWORD=pg postgres:18
docker exec -i dba-postgres-data-directory psql -U postgres -Atq \
  -c "SELECT version()" -c "SHOW data_directory" \
  -c "SHOW block_size" -c "SHOW segment_size" -c "SHOW wal_segment_size"
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) on x86_64-pc-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit
/var/lib/postgresql/18/docker
8192
1GB
16MB

Каталог данных в образе postgres:18 лежит в /var/lib/postgresql/18/docker, а не в /var/lib/postgresql/data, как в образах старых версий. Размер страницы 8 КБ, сегмент таблицы 1 ГБ, сегмент журнала 16 МБ. Дальше $PGDATA означает этот путь.

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

Создадим таблицу и спросим сервер, где она лежит:

CREATE TABLE t (id int, payload text);
SELECT pg_relation_filepath('t');
SELECT oid, datname FROM pg_database WHERE datname = 'postgres';
SELECT oid, relname, relfilenode, reltablespace FROM pg_class WHERE relname = 't';
 pg_relation_filepath 
----------------------
 base/5/16384
(1 row)

 oid | datname  
-----+----------
   5 | postgres
(1 row)

  oid  | relname | relfilenode | reltablespace 
-------+---------+-------------+---------------
 16384 | t       |       16384 |             0
(1 row)

Путь собирается из трёх чисел: каталог base для табличного пространства по умолчанию, 5 — pg_database.oid базы postgres, 16384 — pg_class.relfilenode таблицы. reltablespace = 0 значит пространство по умолчанию. Для другого пространства путь пойдёт через символическую ссылку в pg_tblspc, как описано в документации. OID 16384 — первый номер, который получает пользовательский объект: ниже заняты системные (FirstNormalObjectId в src/include/access/transam.h, строка 197).

relfilenode и oid совпадают, пока файл не переписывали. После VACUUM FULL, CLUSTER или TRUNCATE файл создаётся заново под новым номером:

CREATE TABLE s AS SELECT 1 AS x;
SELECT oid, relfilenode, pg_relation_filepath('s') FROM pg_class WHERE relname = 's';
VACUUM FULL s;
SELECT oid, relfilenode, pg_relation_filepath('s') FROM pg_class WHERE relname = 's';
  oid  | relfilenode | pg_relation_filepath 
-------+-------------+----------------------
 16393 |       16393 | base/5/16393
(1 row)

  oid  | relfilenode | pg_relation_filepath 
-------+-------------+----------------------
 16393 |       16396 | base/5/16396
(1 row)

Таблица та же, oid прежний, а файл другой. Поэтому путь вроде base/5/16393 из чужого скрипта или старой заметки нельзя считать адресом таблицы: перед тем как смотреть на диск, спрашивай pg_relation_filepath заново.

Почему файлов стало четыре

Заполним t данными больше гигабайта: 6 млн строк по 192 символа текста. Строки короче 2 КБ остаются внутри страниц таблицы, TOAST не вмешивается.

INSERT INTO t SELECT i, repeat(md5(i::text), 6) FROM generate_series(1, 6000000) i;
VACUUM t;
SELECT pg_relation_filepath('t'), pg_size_pretty(pg_relation_size('t')), pg_relation_size('t');
INSERT 0 6000000
VACUUM
 pg_relation_filepath | pg_size_pretty | pg_relation_size 
----------------------+----------------+------------------
 base/5/16384         | 1339 MB        |       1404346368
(1 row)

pg_relation_filepath вернул прежний путь: функция называет только первый сегмент основного форка. Остальное видно в каталоге:

docker exec dba-postgres-data-directory bash -c "cd /var/lib/postgresql/18/docker/base/5 && ls -la 16384*"
-rw-------. 1 postgres postgres 1073741824 Oct 27 12:19 16384
-rw-------. 1 postgres postgres  330604544 Oct 27 12:20 16384.1
-rw-------. 1 postgres postgres     368640 Oct 27 12:20 16384_fsm
-rw-------. 1 postgres postgres      49152 Oct 27 12:20 16384_vm

Первый файл ровно 1 073 741 824 байта, 131 072 страницы по 8192. Остаток 330 604 544 байта лёг в 16384.1. Сумма 1 073 741 824 + 330 604 544 = 1 404 346 368 совпадает с pg_relation_size('t'), то есть 171 429 страниц. _fsm и _vm — отдельные форки того же отношения: карта свободного места в 45 страниц (368 640 байт) и карта видимости в 6 страниц (49 152 байта). Суффиксы берутся из таблицы имён форков forkNames в src/common/relpath.c. Обе карты нужны ради VACUUM и index-only scan, в самих данных их нет, и pg_relation_size('t') их не считает, а du и ls считают.

Границу в 1 ГБ задаёт константа RELSEG_SIZE. Это число страниц в одном файле, и объявлена она в src/include/pg_config.h.in:

/* RELSEG_SIZE is the maximum number of blocks allowed in one disk file. Thus,
   the maximum size of a single file is RELSEG_SIZE * BLCKSZ; relations bigger
   than that are divided into multiple files. RELSEG_SIZE * BLCKSZ must be
   less than your OS' limit on file size. This is often 2 GB or 4GB in a
   32-bit operating system, unless you have large file support enabled. By
   default, we make the limit 1 GB to avoid any possible integer-overflow
   problems within the OS. A limit smaller than necessary only means we divide
   a large relation into more chunks than necessary, so it seems best to err
   in the direction of a small limit. A power-of-2 value is recommended to
   save a few cycles in md.c, but is not absolutely required. Changing
   RELSEG_SIZE requires an initdb. */
#undef RELSEG_SIZE

Здесь только #undef: значение подставляет сборка. В meson.build оно считается как segsize = (get_option('segsize') * 1024 * 1024 * 1024) / blocksize при segsize по умолчанию 1 (строка 451), для 8192 это те же 131 072. Читателю сервера число доступно без исходников: SHOW segment_size дал 1GB, а pg_controldata печатает Blocks per segment of large relation: 131072. Изменить его можно только при сборке (--with-segsize) и с новым initdb, поэтому в готовом пакете или облачном образе оно фиксировано.

Деление делает слой хранения, файл src/backend/storage/smgr/md.c. Номер сегмента для блока вычисляется целочисленным делением:

	targetseg = blkno / ((BlockNumber) RELSEG_SIZE);

	/* if an existing and opened segment, we're done */
	if (targetseg < reln->md_num_open_segs[forknum])
	{
		v = &reln->md_seg_fds[forknum][targetseg];
		return v;
	}

Это начало _mdfd_getseg (строки 1756-1763): блок 131 072 попадёт в сегмент 1, блок 171 428 тоже. Смещение внутри файла берётся остатком, blocknum % RELSEG_SIZE, как в mdextend (строка 508). Имя файла из номера сегмента строит _mdfd_segpath:

static MdPathStr
_mdfd_segpath(SMgrRelation reln, ForkNumber forknum, BlockNumber segno)
{
	RelPathStr	path;
	MdPathStr	fullpath;

	path = relpath(reln->smgr_rlocator, forknum);

	if (segno > 0)
		sprintf(fullpath.str, "%s.%u", path.str, segno);
	else
		strcpy(fullpath.str, path.str);

	return fullpath;
}

Нулевой сегмент называется как отношение, остальные получают суффикс .1, .2 (строки 1680-1694). mdopenfork (строка 664) при открытии отношения берёт только сегмент 0, следующие подключаются по мере обращений. Это объясняет вид каталога: сервер не знает файла «таблица целиком», он знает пары «номер форка, номер сегмента».

Что лежит рядом

Размеры корневых каталогов:

docker exec dba-postgres-data-directory bash -c "cd /var/lib/postgresql/18/docker && du -sh base global pg_wal pg_xact && ls -l global/pg_control pg_xact"
1.4G	base
556K	global
1.1G	pg_wal
12K	pg_xact
global/pg_control	8192

base почти целиком наша таблица. Но global с 556 КБ и pg_wal с 1,1 ГБ — не мусор.

global. Здесь лежат объекты, общие для всех баз кластера: каталоги pg_database, pg_authid, pg_tablespace с их файлами 1262, 1260 и 1213, а ещё pg_control. Сами базы template1, template0 и postgres с oid 1, 4 и 5 лежат в base/1, base/4 и base/5, а список баз хранит global.

pg_xact. Статус каждой транзакции, зафиксировала она изменения или откатилась. Один файл 0000 в 8192 байта — одна страница, а на статус отведено 2 бита (CLOG_BITS_PER_XACT в src/backend/access/transam/clog.c), то есть на странице помещается 32 768 транзакций. В нашем кластере была занята малая часть этой страницы: NextXID в pg_controldata равен 0:755.

pg_wal. Журнал предзаписи: 64 файла по 16 777 216 байт, то есть ровно 1 ГиБ; du показал 1,1G, потому что считает ещё и служебные подкаталоги. Сумма совпала с max_wal_size = 1GB по умолчанию: при крупной вставке сервер держит примерно столько сегментов, сколько разрешает этот предел, а отработавшие переиспользует. Имя файла кодирует положение: 0000000100000000000000B2 — это timeline 00000001, старшая часть адреса 00000000 и номер сегмента 000000B2. Сервер писал в сегмент B2, но в каталоге есть и …F1: ненужные сегменты не удаляются, а переименовываются в будущие (recycle), как описано в документации по WAL. Поэтому по имени самого нового файла текущую позицию определять нельзя — спрашивай pg_current_wal_lsn() и pg_walfile_name().

pg_control: с какого места начинать восстановление

Файл global/pg_control занимает 8192 байта. Читать его руками не нужно, для этого есть pg_controldata. Сначала принудительная контрольная точка, чтобы файл отражал свежее состояние:

docker exec -i dba-postgres-data-directory psql -U postgres -c "CHECKPOINT"
docker exec dba-postgres-data-directory pg_controldata /var/lib/postgresql/18/docker | grep -E "cluster state|Latest checkpoint location|REDO location|REDO WAL|TimeLineID|Time of latest|Blocks per segment|Data page checksum"
CHECKPOINT
Database cluster state:               in production
Latest checkpoint location:           0/B2080870
Latest checkpoint's REDO location:    0/B2080818
Latest checkpoint's REDO WAL file:    0000000100000000000000B2
Latest checkpoint's TimeLineID:       1
Latest checkpoint's PrevTimeLineID:   1
Time of latest checkpoint:            Tue 27 Oct 2026 12:20:20 PM UTC
Blocks per segment of large relation: 131072
Data page checksum version:           1

Четыре строки, которые читает DBA в первую очередь:

Database cluster state: in production. Сервер работает. После корректной остановки здесь будет shut down; если процесс убили, а состояние осталось рабочим, значит при старте пойдёт восстановление после сбоя.

Latest checkpoint’s REDO location: 0/B2080818. Адрес в журнале, с которого воспроизводят записи после сбоя. Всё, что записано в журнал раньше, уже сброшено в файлы данных. Сервер при старте открывает pg_control, берёт этот адрес и ищет сегмент, поэтому REDO WAL file называет файл, без которого запуск невозможен. Адрес 0/B2080818 лежит в сегменте B2: при сегменте в 16 МБ номер сегмента — это старшие два шестнадцатеричных знака смещения, и он же назван в строке ниже.

Latest checkpoint location: 0/B2080870. Адрес самой записи о контрольной точке. Она стоит чуть дальше REDO: между началом контрольной точки и записью о её завершении сервер продолжал работать, и в журнал попало 0x58 = 88 байт других записей.

TimeLineID: 1. Номер ветки истории журнала. После продвижения реплики или восстановления на момент времени он увеличивается, и имена сегментов получают другую первую часть. Идентификатор входит в имя файла 0000000100000000000000B2, и по нему отличают журнал основной базы от журнала после переключения.

Ещё одна строка из вывода связана с версией: Data page checksum version: 1. Начиная с PostgreSQL 18 initdb включает контрольные суммы страниц по умолчанию, это записано в заметках о выпуске. Для обновления старого кластера без сумм есть initdb --no-data-checksums: pg_upgrade требует, чтобы настройка совпадала.

Размер файла в 8 КБ намеренно больше нужного. Структура ControlFileData ограничена 512 байтами, чтобы запись файла была атомарной на уровне одного сектора:

/*
 * Maximum safe value of sizeof(ControlFileData).  For reliability's sake,
 * it's critical that pg_control updates be atomic writes.  That generally
 * means the active data can't be more than one disk sector, which is 512
 * bytes on common hardware.  Be very careful about raising this limit.
 */
#define PG_CONTROL_MAX_SAFE_SIZE	512

Это строки 242-247 src/include/catalog/pg_control.h. Ниже StaticAssertDecl проверяет это на этапе сборки. Потерять один этот файл — значит потерять знание о том, откуда продолжать журнал.

То же самое доступно из SQL без доступа к диску: SELECT checkpoint_lsn, redo_lsn, timeline_id FROM pg_control_checkpoint(). Для облачной базы это единственный способ: к $PGDATA там нет доступа.

Что из этого следует

Размер файла не равен размеру таблицы. du по base/5 сложит сегменты, обе карты, индексы и TOAST всех таблиц. Размер конкретной таблицы спрашивай у сервера: pg_relation_size для одного форка, pg_table_size с картами и TOAST, pg_total_relation_size с индексами.

Не удаляй «лишнее» по размеру. Папка pg_wal в 1 ГБ на пустой на вид базе выглядит как мусор, но из неё сервер берёт записи от REDO-адреса из pg_control. Если заканчивается диск, причину ищи через pg_replication_slots и archive_command, а не через rm.

Путь к файлу не постоянен. Скрипты и мониторинг, которые запоминают имя файла таблицы, ломаются после VACUUM FULL или TRUNCATE: relfilenode меняется.

Сегмент 1 ГБ не ограничивает размер таблицы, а режет её на файлы. Таблица в 3 ТБ состоит примерно из трёх тысяч файлов; это видно в ls и в списках, которые строят утилиты копирования по файлам.

Чек-лист

  1. Путь к файлу таблицы получай через pg_relation_filepath, а не по имени: номер в имени — relfilenode, он меняется при перезаписи.
  2. Размер таблицы смотри pg_relation_size, pg_table_size или pg_total_relation_size; du по каталогу включает форки, индексы и чужие таблицы.
  3. pg_wal, pg_xact и global/pg_control не трогай руками: без них сервер не поднимется или потеряет данные после сбоя.
  4. При аварии первым делом прочитай pg_controldata: состояние кластера, REDO-адрес и timeline. Проверь, что нужный сегмент журнала на месте.
  5. Размер сегмента и размер страницы фиксируются при сборке: сверь SHOW segment_size и SHOW block_size перед тем, как переносить рецепты между кластерами.

Каталог данных читается как небольшая база данных на файловой системе: имена файлов — это номера из системных таблиц, размеры — страницы по 8192 байта, а точка отсчёта для восстановления записана в одном файле в 8 КБ.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru