Таблица на 1,3 ГБ, а в каталоге данных нет файла с её именем: есть 16384, рядом 16384.1, 16384_fsm и 16384_vm. Так выглядит любая большая таблица PostgreSQL. Имя файла — число из системного каталога, файл режется на куски по 1 ГБ, а рядом лежат служебные карты. Ниже — где искать нужный файл и что лежит в pg_wal, pg_xact, global и в pg_control.
Таблица t на диске: четыре файла, а рядом то, без чего базу не поднять
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 и в списках, которые строят утилиты копирования по файлам.
Чек-лист
- Путь к файлу таблицы получай через
pg_relation_filepath, а не по имени: номер в имени —relfilenode, он меняется при перезаписи. - Размер таблицы смотри
pg_relation_size,pg_table_sizeилиpg_total_relation_size;duпо каталогу включает форки, индексы и чужие таблицы. pg_wal,pg_xactиglobal/pg_controlне трогай руками: без них сервер не поднимется или потеряет данные после сбоя.- При аварии первым делом прочитай
pg_controldata: состояние кластера, REDO-адрес и timeline. Проверь, что нужный сегмент журнала на месте. - Размер сегмента и размер страницы фиксируются при сборке: сверь
SHOW segment_sizeиSHOW block_sizeперед тем, как переносить рецепты между кластерами.
Каталог данных читается как небольшая база данных на файловой системе: имена файлов — это номера из системных таблиц, размеры — страницы по 8192 байта, а точка отсчёта для восстановления записана в одном файле в 8 КБ.