Тема 09

Физическое проектирование: типы данных, хранение и полуструктурированные данные

Физическое проектирование: типы данных, хранение и полуструктурированные данные

В темах 7 и 8 схема базы данных строилась на логическом уровне: сущности и связи превращались в отношения, функциональные зависимости подсказывали, как разбить широкую таблицу на несколько узких без потери информации. Логическая схема отвечает на вопрос «какие отношения и с какими ключами нужны», но молчит о том, как эти отношения будут жить на диске. Между тем от решений этого уровня зависят и корректность (сумма платежей, посчитанная в числах с плавающей точкой, может не сойтись с банковской выпиской на копейку), и объём (одна и та же таблица при неудачном порядке столбцов занимает на пятую часть больше места), и скорость (таблица, которую можно отсечь целиком, не нужно читать).

Физическое проектирование (англ. physical design) — этап, на котором логическая схема превращается в объекты конкретной СУБД: выбираются типы данных столбцов, способы генерации ключей, размещение таблиц по схемам и табличным пространствам, разбиение больших таблиц на секции, а также индексы 14. Индексам посвящена отдельная тема 10, а здесь рассматривается всё остальное. В трёхуровневой архитектуре ANSI/SPARC, о которой шла речь в теме 1, это внутренний уровень, и благодаря физической независимости данных его можно менять, не переписывая запросы приложения.

Изложение построено так. Первый раздел посвящён выбору типов данных: числам, строкам, датам, перечислениям, массивам и диапазонам, доменам. Второй — генерации ключей: последовательностям, столбцам идентичности и UUID. Третий раздел спускается на уровень файлов и страниц и объясняет, как PostgreSQL хранит строку таблицы, почему порядок столбцов влияет на размер и что происходит с длинными значениями. Четвёртый посвящён полуструктурированным данным в форматах json и jsonb. Пятый рассматривает схемы, табличные пространства и секционирование таблиц. Все примеры выполнены в PostgreSQL 16 на учебной базе «Книжный узел» (scale=1, база bookstore); эксперименты, создающие собственные таблицы, выполняются в отдельной схеме lab09.

Выбор типов данных

Целые числа, numeric и числа с плавающей точкой

Тип данных столбца — это одновременно ограничение целостности (в столбец integer нельзя записать строку), способ представления значения в памяти и на диске и набор операций над ним. Стандарт SQL определяет несколько семейств числовых типов, и PostgreSQL реализует их все 1.

Целые типы smallint, integer и bigint занимают 2, 4 и 8 байт и хранят числа в диапазонах примерно ±32 тысячи, ±2,1 миллиарда и ±9,2·10¹⁸ соответственно. Арифметика над ними точная, а переполнение — ошибка, а не «заворачивание» значения, как в языке C:

SELECT 2147483647::integer + 1;
ERROR:  integer out of range

Тип numeric(p, s) (синоним decimal) хранит десятичные числа с заданной точностью p — общим числом значащих цифр — и масштабом s — числом цифр после запятой. Без указания p и s он хранит числа практически любой длины: до 131 072 цифр до запятой и до 16 383 после 1. Внутри значение записывается в системе счисления с основанием 10 000 (каждые четыре десятичные цифры — двухбайтовое «слово»), поэтому длина представления переменная: цена 1290.00 из таблицы book занимает на диске 5 байт, а 0.00 — 3 байта. Операции над numeric выполняются программно и медленнее целочисленных, но результат точен в пределах заданного масштаба.

Типы real и double precision — это двоичные числа с плавающей точкой стандарта IEEE 754, 4 и 8 байт, с точностью около 6 и 15 десятичных знаков 15. Главное их свойство, которое нужно понимать при проектировании, состоит в том, что большинство десятичных дробей в двоичной системе непредставимы точно. Число 0,1 в двоичной записи — бесконечная периодическая дробь, и хранится лишь её приближение 6:

SELECT 0.1::float8 + 0.2::float8 AS f,
       0.1::float8 + 0.2::float8 = 0.3::float8 AS f_eq,
       0.1 + 0.2 AS n,
       0.1 + 0.2 = 0.3 AS n_eq;
          f          | f_eq |  n  | n_eq 
---------------------+------+-----+------
 0.30000000000000004 | f    | 0.3 | t

Литерал 0.1 без приведения в PostgreSQL имеет тип numeric, поэтому вторая пара столбцов вычислена точно. Ошибка в одном сложении ничтожна, но она накапливается. Сумма миллиона слагаемых 0,1 должна равняться ровно 100 000:

SELECT sum(0.1::float8) AS f8, sum(0.1::float4) AS f4, sum(0.1::numeric) AS n
FROM generate_series(1, 1000000);
         f8         |    f4     |    n     
--------------------+-----------+----------
 100000.00000133288 | 100958.34 | 100000.0

В double precision ошибка составила тысячные доли копейки, в real — почти тысячу рублей. Отсюда правило физического проектирования: денежные суммы, количества и всё, что должно сходиться при сложении и сравниваться на равенство, хранится в целых числах или в numeric с фиксированным масштабом; именно поэтому столбцы price и amount в «Книжном узле» объявлены как numeric(10, 2). Числа с плавающей точкой уместны для результатов измерений и научных расчётов, где значения изначально приближённые, а скорость и компактность важнее точного равенства. Сравнивать их на равенство не следует вовсе — только с допуском.

Тип money хранит сумму как 8-байтовое целое число минимальных единиц валюты и поэтому точен, но число знаков после запятой и формат вывода зависят от параметра lc_monetary: в учебном кластере с локалью C.UTF-8 запрос SELECT '1290.00'::money выводит $1,290.00, а резервная копия, восстановленная в базу с другой локалью, может быть прочитана неверно 1. Поэтому для денег обычно предпочитают numeric.

Строки, кодировки и правила сортировки

Стандарт SQL определяет строковые типы character(n) (сокращённо char(n)) — строку фиксированной длины, дополняемую пробелами, и character varying(n) (varchar(n)) — строку переменной длины не длиннее n символов. PostgreSQL добавляет к ним тип text — строку произвольной длины, не входящую в стандарт, но поддерживаемую большинством СУБД под тем или иным именем.

В некоторых СУБД выбор между этими типами влияет на производительность: строка фиксированной длины позволяет вычислять смещение следующего столбца без чтения длины. В PostgreSQL все три типа хранятся одинаково — как значение переменной длины с заголовком, — и документация формулирует вывод однозначно: разницы в производительности между ними нет, кроме лишнего места у дополняемого пробелами char(n) и нескольких тактов процессора на проверку длины у varchar(n); char(n) обычно самый медленный из трёх, и в большинстве случаев следует использовать text или varchar 1. Ограничение длины в varchar(n) — это ограничение целостности, а не способ сэкономить место. Если правило предметной области звучит сложнее, чем «не длиннее n», его лучше выразить через CHECK, как сделано для ISBN в учебной базе: столбец isbn проверяется регулярным выражением «ровно 13 цифр».

У char(n) есть и семантическая особенность: хвостовые пробелы считаются незначимыми при сравнении. Это легко приводит к неожиданностям:

SELECT 'abc'::char(5) = 'abc  '::char(5)       AS char_eq,
       'abc'::varchar(5) = 'abc  '::varchar(5) AS varchar_eq,
       length('abc'::char(5))                  AS len,
       'abc'::char(5) || '|'                   AS concat;
 char_eq | varchar_eq | len | concat 
---------+------------+-----+--------
 t       | f          |   3 | abc|

Значение типа char(5) хранится с дополняющими пробелами, но функция length их не считает, а при приведении к text для конкатенации они отбрасываются.

Длина строки в символах и в байтах различается. Сервер учебной базы использует кодировку UTF-8 (SHOW server_encoding), в которой латинская буква занимает один байт, а кириллическая — два:

SELECT title, length(title) AS chars, octet_length(title) AS bytes,
       pg_column_size(title) AS stored
FROM bookstore.book WHERE book_id IN (1, 9);
         title         | chars | bytes | stored 
-----------------------+-------+-------+--------
 Война и мир           |    11 |    20 |     21
 PostgreSQL 16 изнутри |    21 |    28 |     29

Функция pg_column_size показывает размер значения в том виде, в каком оно хранится: 20 байт текста плюс однобайтовый заголовок длины (подробнее о заголовках — в разделе о хранении). Кодировка задаётся при создании базы и в дальнейшем не меняется; для новых баз фактически безальтернативна UTF-8.

С кодировкой тесно связано правило сортировки (англ. collation) — функция, определяющая порядок строк и их равенство при сравнении. Порядок «по кодам символов» (правило C) прост и быстр, но для человека неестествен: все прописные буквы идут раньше строчных, а «ё» оказывается после «я». Лингвистически правильный порядок для русского языка дают правила сортировки библиотеки ICU (англ. International Components for Unicode), реализующей алгоритм сортировки Unicode 11:

-- правило сортировки базы: C.UTF-8
SELECT name FROM (VALUES ('ёж'), ('Ель'), ('ель'), ('яблоко'),
                         ('Яма'), ('арбуз'), ('Банан')) AS v(name)
ORDER BY name;
-- то же с лингвистическим правилом ICU для русского языка
SELECT name FROM (VALUES ('ёж'), ('Ель'), ('ель'), ('яблоко'),
                         ('Яма'), ('арбуз'), ('Банан')) AS v(name)
ORDER BY name COLLATE "ru-RU-x-icu";
 по C.UTF-8 | по ru-RU-x-icu
------------+---------------
 Банан      | арбуз
 Ель        | Банан
 Яма        | ёж
 арбуз      | ель
 ель        | Ель
 яблоко     | яблоко
 ёж         | Яма

(Результаты двух запросов сведены в одну таблицу.) Правило сортировки задаётся для базы при создании (в PostgreSQL 15 и новее базу можно создать сразу с правилом ICU), а также для отдельного столбца или выражения ключевым словом COLLATE 1. Выбор правила — тоже решение физического проектирования: от него зависят результат ORDER BY, работа ограничений уникальности и, как будет показано в теме 10, возможность использовать индекс для поиска по префиксу LIKE 'abc%'. Смена правила сортировки у существующих данных требует перестроения индексов, потому что индекс хранит строки упорядоченными по старому правилу.

Дата и время

Тип date хранит календарную дату (4 байта), time — время суток, timestamp и timestamp with time zone (timestamptz) — момент времени с точностью до микросекунды (8 байт), interval — промежуток времени (16 байт) 1. Самая частая ошибка проектирования в этом семействе — выбор timestamp (полное имя timestamp without time zone) для хранения моментов событий.

Различие между двумя типами не в том, что один «хранит часовой пояс», а другой нет. Ни один из них пояс не хранит. Значение timestamptz при вводе переводится в UTC с учётом указанного или текущего (параметр сеанса TimeZone) часового пояса и хранится как момент в UTC, а при выводе переводится в пояс текущего сеанса 1. Значение timestamp хранится как есть — это «показания настенных часов» без привязки к месту. Эксперимент:

CREATE TEMP TABLE tz_demo (ts timestamp, tstz timestamptz);
SET TimeZone = 'Europe/Moscow';
INSERT INTO tz_demo VALUES ('2024-03-01 12:00', '2024-03-01 12:00');
SET TimeZone = 'Asia/Novosibirsk';
SELECT * FROM tz_demo;
         ts          |          tstz          
---------------------+------------------------
 2024-03-01 12:00:00 | 2024-03-01 16:00:00+07

Заказ, оформленный в полдень по Москве, для сеанса в Новосибирске произошёл в 16:00 местного времени, и столбец timestamptz показывает это верно. Столбец timestamp утверждает, что заказ был в 12:00, — но по каким часам, уже не восстановить. Поэтому моменты событий (создание заказа, платёж, вход пользователя) хранятся в timestamptz, как это сделано в учебной базе для orders.created_at и payment.paid_at. Тип timestamp уместен лишь для «плановых» значений, привязанных к местному времени по смыслу: «открытие магазина в 9:00 по местному времени» должно оставаться 9:00 и после перехода на летнее время.

Арифметика с интервалами тоже учитывает часовые пояса. Интервал хранит отдельно месяцы, дни и микросекунды, поэтому interval '1 day' и interval '24 hours' — разные величины. В поясе с переходом на летнее время они дают разный результат:

SET TimeZone = 'Europe/Berlin';
SELECT timestamptz '2024-03-30 12:00' + interval '1 day'    AS plus_day,
       timestamptz '2024-03-30 12:00' + interval '24 hours' AS plus_24h;
        plus_day        |        plus_24h        
------------------------+------------------------
 2024-03-31 12:00:00+02 | 2024-03-31 13:00:00+02

В ночь на 31 марта 2024 года в Германии часы перевели вперёд, сутки длились 23 часа, и «через день в то же время» не совпадает с «через 24 часа». Аналогично date '2024-01-31' + interval '1 month' даёт 29 февраля: при прибавлении месяцев день обрезается до последнего дня месяца.

Перечисления, массивы, диапазоны и домены

Столбец orders.status может принимать пять значений. Ограничить его можно тремя способами, и выбор между ними — классическое решение физического проектирования.

Первый способ, принятый в учебной базе, — text с ограничением CHECK (status IN ('new', 'paid', ...)). Он прост, значения читаемы, а изменить список можно командой ALTER TABLE, заменив ограничение. Второй — тип-перечисление (англ. enumerated type), CREATE TYPE order_status AS ENUM ('new', 'paid', 'shipped', 'delivered', 'cancelled'). Значение перечисления занимает 4 байта (строка 'delivered' — 10 байт), сравнение идёт в порядке объявления ('paid' < 'shipped' истинно), а добавлять значения можно командой ALTER TYPE ... ADD VALUE; удалить значение из перечисления нельзя 1. Третий — таблица-справочник статусов и внешний ключ на неё. Справочник позволяет хранить при каждом значении дополнительные атрибуты (название для интерфейса, признак «заказ завершён»), менять список обычными командами DML и управлять им без изменения схемы, но требует соединения для получения названия. Если список значений — часть логики приложения и меняется вместе с кодом, подходят CHECK или перечисление; если им управляют пользователи — справочник.

PostgreSQL поддерживает массивы любого встроенного или пользовательского типа (по стандарту SQL тип записывается как text ARRAY, integer ARRAY) с операторами включения @> и пересечения && 1. Массив нарушает первую нормальную форму в её строгом понимании (тема 8), и для сущностей, на которые нужно ссылаться внешними ключами, он не годится: нельзя объявить, что каждый элемент массива авторов ссылается на author. Уместен массив для коротких списков значений, которые читаются и записываются целиком, — например, набора меток книги «хит» и «новинка».

Диапазонные типы (int4range, numrange, daterange, tstzrange и др.) представляют интервал значений с включёнными или исключёнными границами. Они удобны для периодов действия цен и акций: оператор @> проверяет попадание точки в диапазон, && — пересечение двух диапазонов, * вычисляет пересечение. В сочетании с ограничением исключения EXCLUDE (тема 2) диапазон позволяет декларативно запретить пересекающиеся периоды акций для одной книги; для этого нужен индекс GiST, о котором пойдёт речь в теме 10.

Тип bytea хранит двоичные данные (обложки, файлы); альтернатива — хранить файл во внешнем хранилище, а в базе только путь и контрольную сумму.

Наконец, домен (англ. domain) — именованный тип на основе существующего с дополнительными ограничениями. Домен позволяет один раз описать правило и использовать его во многих таблицах:

CREATE DOMAIN isbn13 AS char(13) CHECK (VALUE ~ '^[0-9]{13}$');
SELECT '978516'::isbn13;
ERROR:  value for domain isbn13 violates check constraint "isbn13_check"

Сводка размеров основных типов приведена в таблице. Значения получены функцией pg_column_size в PostgreSQL 16 на 64-разрядной платформе.

ТипРазмерЗамечание
boolean1 байт
smallint, integer, bigint2, 4, 8 байтточная арифметика, ошибка при переполнении
real, double precision4, 8 байтприближённые, IEEE 754
numericпеременныйточный; 1290.00 — 5 байт на диске
date4 байта
timestamp, timestamptz8 байтмикросекунды; timestamptz хранится в UTC
interval16 байтмесяцы, дни, микросекунды отдельно
uuid16 байт
text, varchar(n), char(n), bytea, jsonbпеременныйзаголовок 1 или 4 байта, далее данные
перечисление4 байта

Генерация ключей

Последовательности и столбцы идентичности

Суррогатный ключ (тема 2) нужно откуда-то брать. Классический источник в PostgreSQL — последовательность (англ. sequence), отдельный объект базы, выдающий возрастающие числа функцией nextval. Стандарт SQL:2003 ввёл столбцы идентичности (англ. identity columns), и в PostgreSQL 10 появился синтаксис GENERATED ALWAYS AS IDENTITY, который используется во всех таблицах учебной базы. За столбцом идентичности стоит та же последовательность, но она принадлежит столбцу, удаляется вместе с ним и не позволяет случайно вставить значение вручную 1:

CREATE TABLE lab09.seq_demo (
    id   integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name text
);
INSERT INTO lab09.seq_demo (name) VALUES ('первая');
BEGIN;
INSERT INTO lab09.seq_demo (name) VALUES ('откатится');
ROLLBACK;
INSERT INTO lab09.seq_demo (name) VALUES ('третья');
SELECT * FROM lab09.seq_demo;
 id |  name  
----+--------
  1 | первая
  3 | третья

Значение 2 потеряно: последовательность нетранзакционна, и выданное nextval число не возвращается при откате. Это сделано сознательно — иначе параллельные транзакции ждали бы друг друга при каждой вставке. Следствие для проектирования: суррогатный ключ гарантирует уникальность, но не отсутствие пропусков, и строить на нём «сквозную нумерацию счетов без пропусков» нельзя. Попытка вставить явное значение в столбец GENERATED ALWAYS завершается ошибкой с подсказкой OVERRIDING SYSTEM VALUE; вариант GENERATED BY DEFAULT разрешает явные значения, но тогда легко получить конфликт с будущими значениями последовательности.

Предел integer — около 2,1 миллиарда — кажется недостижимым, но таблица событий, получающая тысячу строк в секунду, исчерпает его за 25 дней, а смена типа ключа большой таблицы с внешними ссылками требует перезаписи таблиц и индексов. Для таблиц, рост которых трудно предсказать, разумно сразу выбирать bigint.

UUID: случайные и упорядоченные по времени

Последовательность выдаёт уникальные значения только в пределах одной базы. Когда идентификатор должен создаваться в приложении до обращения к базе, в нескольких независимых базах или на клиенте, используют универсальный уникальный идентификатор (англ. Universally Unique Identifier, UUID) — 128-битное число, вероятность совпадения которого пренебрежимо мала. Современная спецификация UUID — RFC 9562 (2024) — описывает несколько версий 7. Версия 4 состоит из 122 случайных бит. Версия 7 начинается с 48-битной метки времени в миллисекундах от начала эпохи Unix, за которой идут случайные биты, поэтому идентификаторы версии 7, созданные позже, больше созданных раньше.

В PostgreSQL 16 тип uuid занимает 16 байт, а встроенная функция gen_random_uuid() (в ядре с версии 13) генерирует UUID версии 4. Функции для версии 7 в PostgreSQL 16 нет:

SELECT uuidv7();
ERROR:  function uuidv7() does not exist

Встроенная функция uuidv7() появилась только в PostgreSQL 18 3; в более ранних версиях UUID версии 7 генерирует приложение или функция на SQL (в окружении лабораторной работы есть учебная функция uuid_v7_like(), собирающая такое значение из метки времени и gen_random_uuid()).

Почему версия имеет значение? Первичный ключ поддерживается индексом-B-деревом (тема 10), в котором ключи хранятся упорядоченно. Возрастающие ключи (последовательность или UUIDv7) всегда вставляются в самый правый лист дерева, страницы заполняются плотно, а «горячей» остаётся лишь небольшая правая часть индекса. Случайные ключи UUIDv4 попадают в произвольные листья: при каждой вставке нужна новая страница индекса, листья делятся пополам и в среднем оказываются заполнены примерно на 70 %. Эксперимент: в три таблицы с первичным ключом разного вида вставлено по 2 миллиона строк, после чего измерены размер индекса первичного ключа и средняя плотность его листьев (функция pgstatindex из расширения pgstattuple).

КлючРазмер индексаПлотность листьев
bigint из последовательности43 МиБ90,1 %
uuid версии 4 (gen_random_uuid())77 МиБ70,7 %
uuid версии 7 (учебная функция)60 МиБ90,0 %

Индекс по UUID больше индекса по bigint уже потому, что ключ вдвое длиннее, но случайный порядок добавляет к этому ещё почти треть. Пока индекс помещается в оперативную память, разница сказывается в основном на размере; когда индекс многократно больше буферного кеша, случайная вставка означает чтение случайной страницы с диска на каждую строку, и скорость вставки падает в разы. Время вставки в нашем опыте составило около 5 с для bigint и около 21 с для UUIDv4 (из них около 4 с — сама генерация двух миллионов случайных UUID); на другой машине числа будут другими, но соотношение сохраняется. Отсюда рекомендация: если UUID нужен, предпочтительна версия 7; если не нужен — достаточно bigint с идентичностью. У UUIDv7 есть и обратная сторона: метка времени в ключе раскрывает момент создания записи, что не всегда допустимо для идентификаторов, видимых пользователю.

Как PostgreSQL хранит таблицу

Файлы, слои и страницы

На внутреннем уровне PostgreSQL хранит каждое отношение — таблицу, индекс, последовательность, материализованное представление — в отдельном наборе файлов в каталоге данных кластера 2. Путь к основному файлу относительно каталога данных возвращает функция pg_relation_filepath:

SELECT pg_relation_filepath('bookstore.orders');
 pg_relation_filepath 
----------------------
 base/16384/16605

Здесь 16384 — идентификатор (OID) базы данных, 16605 — номер файла таблицы (relfilenode); у вас числа будут другими. Файл делится на сегменты по 1 ГиБ (16605, 16605.1, ...), чтобы не упираться в ограничения файловых систем. Кроме основного слоя (англ. main fork) с данными у таблицы есть служебные слои: карта свободного пространства (англ. free space map, файл с суффиксом _fsm), где для каждой страницы записано, сколько места на ней свободно, и карта видимости (англ. visibility map, _vm) — по два бита на страницу, отмечающие, что все строки страницы видимы всем транзакциям и что все они «заморожены» 2. Карта видимости создаётся очисткой (VACUUM); она понадобится в теме 10 для сканирования только индекса, а её смысл подробно раскрывается в теме 12.

Размеры слоёв и отношения в целом показывают функции pg_relation_size(rel, fork), pg_table_size (таблица со всеми слоями и TOAST), pg_indexes_size и pg_total_relation_size (всё вместе):

SELECT pg_relation_size('bookstore.orders')         AS main,
       pg_relation_size('bookstore.orders', 'fsm')  AS fsm,
       pg_size_pretty(pg_table_size('bookstore.orders'))          AS tbl,
       pg_size_pretty(pg_indexes_size('bookstore.orders'))        AS idx,
       pg_size_pretty(pg_total_relation_size('bookstore.orders')) AS total;
  main   |  fsm  |   tbl   |  idx   |  total  
---------+-------+---------+--------+---------
 1212416 | 24576 | 1216 kB | 456 kB | 1672 kB

Основной слой занимает 1 212 416 байт — ровно 148 страниц по 8192 байта (в выводе pg_size_pretty единицы kB и MB двоичные, то есть это КиБ и МиБ).

Файл отношения — последовательность страниц (англ. page, также block) фиксированного размера. В PostgreSQL размер страницы задаётся при компиляции и по умолчанию равен 8 КиБ (SHOW block_size выводит 8192); все операции ввода-вывода и весь буферный кеш работают страницами 2. Устройство страницы таблицы (англ. heap page) показано на рисунке ниже.

Устройство страницы таблицы PostgreSQL
Страница 8 КиБ: заголовок 24 байта, массив указателей растёт от начала, версии строк записываются с конца, свободное место — между ними

Страница начинается с заголовка (PageHeaderData) длиной 24 байта: в нём LSN — номер последней записи журнала, изменившей страницу (тема 13), контрольная сумма, флаги и три смещения: pd_lower — конец массива указателей, pd_upper — начало области данных, pd_special — начало специальной области (у таблиц она пуста, у индексов в ней хранятся служебные данные). За заголовком идёт массив указателей строк (англ. line pointers, ItemIdData) по 4 байта: каждый содержит смещение и длину кортежа на странице. Сами кортежи записываются с конца страницы к началу, так что свободное место всегда находится посередине, между pd_lower и pd_upper 2.

Адрес строки в таблице — пара «номер страницы, номер указателя», которая называется ctid. Ссылка идёт на указатель, а не на байтовое смещение кортежа, поэтому кортеж можно перемещать внутри страницы при уплотнении, не меняя его адрес; индексы хранят именно такие адреса.

Заглянуть внутрь страницы позволяет расширение pageinspect 4. Оно устанавливается командой CREATE EXTENSION pageinspect и доступно суперпользователю. Функция get_raw_page читает страницу как массив байтов, page_header разбирает заголовок, heap_page_items — указатели и заголовки кортежей:

SELECT lower, upper, special, pagesize
FROM page_header(get_raw_page('bookstore.orders', 0));
 lower | upper | special | pagesize 
-------+-------+---------+----------
   568 |   576 |    8192 |     8192
SELECT lp, lp_off, lp_len, t_hoff, t_ctid
FROM heap_page_items(get_raw_page('bookstore.orders', 0)) LIMIT 3;
 lp | lp_off | lp_len | t_hoff | t_ctid 
----+--------+--------+--------+--------
  1 |   8136 |     56 |     24 | (0,1)
  2 |   8080 |     56 |     24 | (0,2)
  3 |   8024 |     56 |     24 | (0,3)

Первый кортеж занимает последние 56 байт страницы (смещение 8136), второй — предыдущие 56 и так далее. Всего на странице 136 кортежей: массив указателей заканчивается на байте 24 + 136 · 4 = 568, а данные начинаются с байта 8192 − 136 · 56 = 576. Свободными остались 8 байт — меньше, чем нужно для ещё одного кортежа с указателем.

Заголовок кортежа и выравнивание

Каждая версия строки на странице начинается с заголовка кортежа (HeapTupleHeaderData), занимающего 23 байта 2. В нём хранятся номера транзакций t_xmin и t_xmax, создавшей и удалившей версию, номер команды, адрес t_ctid (у последней версии он указывает на саму строку, у устаревшей — на более новую версию), число столбцов и флаги (t_infomask), а также смещение начала данных t_hoff. Назначение этих полей — многоверсионное управление параллельным доступом, которое подробно рассматривается в теме 12; для физического проектирования важно, что каждая строка таблицы несёт около 24 байт служебной информации плюс 4 байта указателя. Для узких таблиц это больше, чем сами данные 13.

За заголовком может следовать битовая карта NULL (англ. null bitmap) — по одному биту на столбец, — если в строке есть хотя бы одно неопределённое значение. Данные начинаются со смещения t_hoff, кратного 8 байтам (величине MAXALIGN на 64-разрядных платформах). Поэтому 23-байтовый заголовок фактически занимает 24 байта, и в этот лишний байт помещается битовая карта таблицы, в которой не больше 8 столбцов. Если столбцов 9 и в строке есть NULL, карта занимает 2 байта, и t_hoff становится 32: в эксперименте с таблицей из девяти столбцов integer строка без NULL занимает 60 байт, а строка с одним NULL — 64, хотя данных в ней на 4 байта меньше.

Внутри области данных каждое значение выравнивается по границе, которую требует его тип (столбец attalign каталога pg_attribute): smallint — по 2 байтам, integer — по 4, bigint, timestamptz и double precision — по 8; boolean и короткие строки выравнивания не требуют. Между значениями вставляются байты-заполнители (англ. padding). Разберём строку таблицы orders длиной 56 байт:

СмещениеДлинаСодержимое
023 + 1заголовок кортежа и выравнивание до 24
244order_id integer
284customer_id integer
328created_at timestamptz (выровнен по 8 — смещение 32 подходит)
4010status text: однобайтовый заголовок и 9 байт 'delivered'
502заполнитель до границы 4 байт
524warehouse_id integer

Порядок столбцов в таблице влияет на число заполнителей. Сравним две таблицы с одинаковым набором столбцов — трёх boolean, двух bigint, integer, smallint и timestamptz, — объявленных в «неудачном» порядке (чередование однобайтовых и восьмибайтовых) и в «удачном» (от больших к меньшим), по 100 тысяч строк в каждой. Раскладка одной строки показана на рисунке.

Выравнивание столбцов в строке при двух порядках объявления
Одни и те же восемь столбцов: при чередовании boolean и bigint строка занимает 80 байт, из них 23 — заполнители; при порядке от широких к узким — 57 байт
CREATE TABLE lab09.bad  (a boolean, b bigint, c boolean, d bigint,
                         e boolean, f integer, g smallint, h timestamptz);
CREATE TABLE lab09.good (b bigint, d bigint, h timestamptz, f integer,
                         g smallint, a boolean, c boolean, e boolean);
-- вставка 100 000 строк в каждую
  t   | row_bytes |  size   | pages 
------+-----------+---------+-------
 bad  |        80 | 8248 kB |  1031
 good |        57 | 6672 kB |   834

Строка «неудачной» таблицы занимает 80 байт, «удачной» — 57, а вся таблица уменьшилась на 19 %. Заметим, что выигрыш в размере таблицы меньше выигрыша в длине строки: следующий кортеж на странице тоже начинается с границы 8 байт, поэтому 57-байтовая строка фактически занимает 64 байта, и на странице помещается 120 строк вместо 97. Правило, вытекающее из эксперимента: в широких таблицах с большим числом строк полезно объявлять сначала столбцы фиксированной длины с выравниванием 8, затем 4 и 2, затем однобайтовые и столбцы переменной длины. Для маленьких справочников экономия несущественна, и порядок лучше выбирать из соображений читаемости. Переставить столбцы существующей таблицы в PostgreSQL можно только пересоздав её.

TOAST: хранение длинных значений

Кортеж должен целиком помещаться на одной странице: PostgreSQL не разрывает строки между страницами. Как же тогда хранится text длиной в мегабайт? Ответ — механизм TOAST (англ. The Oversized-Attribute Storage Technique) 2.

Все типы переменной длины («varlena») начинаются с заголовка длины. Значения короче 127 байт хранятся на диске с однобайтовым заголовком и без выравнивания, более длинные — с четырёхбайтовым. Два бита заголовка TOAST резервирует для своих целей, поэтому максимальная длина одного значения — 1 ГиБ 2. Когда вставляемая строка таблицы длиннее порога TOAST_TUPLE_THRESHOLD, механизм TOAST начинает её «ужимать»: сжимает длинные значения и, если этого мало, выносит их в отдельную TOAST-таблицу, пока строка не станет короче TOAST_TUPLE_TARGET. Оба значения по документации «обычно 2 кБ» 2; точнее, порог вычисляется так, чтобы на странице помещалось четыре кортежа, и для страницы 8 КиБ составляет 2032 байта. Вынесенное значение режется на фрагменты (англ. chunks) примерно по 2000 байт, каждый хранится отдельной строкой TOAST-таблицы, а в основной строке остаётся указатель длиной 18 байт. Схема показана на рисунке.

Вынесение длинного значения в TOAST-таблицу
Строка длиннее 2032 байт: значение сжимается или выносится в TOAST-таблицу фрагментами по 1996 байт, в строке остаётся 18-байтовый указатель

Проверим порог экспериментом. В таблицу lab09.toast_demo (id integer, body text) вставлены строки со случайным (несжимаемым) текстом длиной от 1000 до 10 000 символов и строки из повторяющейся буквы a. Для каждой строки выведены длина текста, размер хранимого значения и длина кортежа на странице:

 текст              | length | stored | lp_len 
--------------------+--------+--------+--------
 случайный          |   1000 |   1004 |   1032
 случайный          |   1990 |   1994 |   2022
 случайный          |   2000 |   2004 |   2032
 случайный          |   2010 |   2010 |     46
 случайный          |   3000 |   3000 |     46
 случайный          |  10000 |  10000 |     46
 повтор 'a'         |   3000 |     44 |     72
 повтор 'a'         |  10000 |    125 |    153
 повтор 'a'         | 100000 |   1156 |   1184

Кортеж длиной ровно 2032 байта (24 байта заголовка, 4 байта id, 4 байта заголовка значения и 2000 байт текста) ещё хранится целиком. При 2010 символах кортеж превысил бы порог, текст сжать не удалось, и он вынесен в TOAST: на странице остался кортеж длиной 46 байт = 24 + 4 + 18. Текст из повторяющихся символов, напротив, прекрасно сжимается (алгоритмом pglz, заданным параметром default_toast_compression; с PostgreSQL 14 доступен и lz4, если сервер собран с его поддержкой) и остаётся в строке: сто тысяч букв a заняли 1156 байт. TOAST-таблица — обычная таблица в схеме pg_toast со столбцами chunk_id, chunk_seq и chunk_data; значение длиной 10 000 байт хранится в ней шестью фрагментами, максимальный из которых — 1996 байт.

Для каждого столбца задаётся стратегия хранения (столбец attstorage каталога): plain — без сжатия и выноса (для типов фиксированной длины), extended — сжатие, затем вынос (по умолчанию для text, jsonb, bytea), external — вынос без сжатия (ускоряет substring над длинными значениями), main — сжатие, а вынос только в крайнем случае (по умолчанию у numeric) 2. Меняется она командой ALTER TABLE ... ALTER COLUMN ... SET STORAGE.

Благодаря TOAST длинные описания не мешают запросам, которые их не читают: основная таблица остаётся компактной. Обратная сторона — SELECT * по таблице с длинными значениями заметно дороже выборки нужных столбцов, потому что каждое вынесенное значение собирается из фрагментов. Обновление строки без изменения вынесенных значений их не копирует.

Fillfactor и оценка размера таблицы

Параметр хранения fillfactor (от 10 до 100, по умолчанию 100) задаёт, до какой доли страницы заполнять её при вставке; остаток резервируется для будущих обновлений строк этой страницы 10. Когда обновлённая версия строки помещается на ту же страницу, а изменённые столбцы не входят ни в один индекс, PostgreSQL выполняет HOT-обновление (англ. Heap-Only Tuple), не трогая индексы; об этом — в теме 10. Таблица orders, скопированная с fillfactor = 70, занимает 211 страниц вместо 148: на странице 95 строк вместо 136. Уменьшать fillfactor имеет смысл для таблиц с частыми обновлениями (остатки на складе, счётчики), а не для таблиц, в которые только добавляют строки.

Знание устройства страницы позволяет оценить размер таблицы до её создания — это и есть подробно разобранный пример раздела. Оценим таблицу orders при scale=50, где в ней миллион строк.

  1. Длина кортежа: 24 байта заголовка и 32 байта данных (по таблице выше) — 56 байт; с указателем — 60 байт. Длина 56 кратна 8, поэтому дополнительного выравнивания между кортежами нет.
  2. Полезное место страницы: 8192 − 24 = 8168 байт. Строк на странице: ⌊8168 / 60⌋ = 136 — ровно столько, сколько показал heap_page_items.
  3. Страниц: ⌈1 000 000 / 136⌉ = 7353, размер: 7353 · 8192 байт ≈ 57,4 МиБ.

Проверка на базе bookstore50 (таблица после загрузки и автоматической очистки):

SELECT relpages, pg_size_pretty(pg_relation_size('bookstore.orders'))
FROM pg_class WHERE oid = 'bookstore.orders'::regclass;
 relpages | pg_size_pretty 
----------+----------------
     7353 | 57 MB

Расчёт совпал со страницей. Если бы status был перечислением (4 байта с выравниванием по 4) вместо text, строка заняла бы 24 + 4 + 4 + 8 + 4 + 4 = 48 байт, на страницу поместилось бы 157 строк, и таблица уменьшилась бы примерно на 13 %. Для проектировщика такая оценка — быстрый способ сравнить варианты схемы до загрузки данных. При этом надо помнить, что в реальной таблице есть и «мёртвые» версии строк, оставшиеся после обновлений и удалений (тема 12), поэтому фактический размер со временем превышает расчётный.

Полуструктурированные данные: JSON и JSONB

json и jsonb

Не все атрибуты сущности известны заранее. У бумажной книги есть переплёт, у электронной — формат файла, у детской — возрастная маркировка, у многотомника — число томов. Можно завести по столбцу на каждый возможный атрибут (большинство будут NULL), можно вынести атрибуты в таблицу «сущность — атрибут — значение» (англ. entity-attribute-value, EAV), а можно хранить их в столбце полуструктурированного формата. В «Книжном узле» выбран третий путь: столбец book.attrs типа jsonb.

PostgreSQL предлагает два типа для данных в формате JSON 8. Тип json хранит исходный текст документа без изменений и при каждом обращении разбирает его заново. Тип jsonb (появился в PostgreSQL 9.4) хранит документ в разобранном двоичном виде: ключи объектов отсортированы, повторяющиеся ключи схлопнуты (остаётся последний), пробелы не сохраняются. Различие видно сразу:

SELECT '{"b": 1, "a": 2, "a": 3}'::json  AS j,
       '{"b": 1, "a": 2, "a": 3}'::jsonb AS jb;
            j             |        jb        
--------------------------+------------------
 {"b": 1, "a": 2, "a": 3} | {"a": 3, "b": 1}

Ввод в jsonb чуть медленнее, но обработка намного быстрее, и только jsonb поддерживает операторы включения и индексы GIN (тема 10). Документация рекомендует jsonb для большинства приложений; json оправдан, если нужно сохранить документ байт в байт, например подписанный ответ внешней системы 8. По размеру типы близки: документ {"binding": "soft", "series": "Миры Стругацких"} занимает 66 байт как json и 70 байт как jsonb.

Операторы и язык путей SQL/JSON

Основные операторы над jsonb 9:

ОператорСмыслПример
->элемент по ключу (или индексу массива) как jsonbattrs -> 'binding' → "hard"
->>элемент по ключу как textattrs ->> 'binding' → hard
#>, #>>элемент по путиattrs #>> '{dims,height}'
@>левый документ содержит правыйattrs @> '{"binding": "soft"}'
?есть ли ключ верхнего уровняattrs ? 'age'
||, -слияние документов, удаление ключаattrs || '{"bestseller": true}'
@?, @@проверка выражения пути SQL/JSONattrs @? '$.age ? (@ == "18+")'

Различие между -> и ->> существенно: первый возвращает jsonb (строка в кавычках), второй — text, который можно сравнивать с обычной строкой и приводить к числу:

SELECT book_id, attrs -> 'binding' AS j, attrs ->> 'binding' AS t,
       attrs -> 'volumes' AS volumes
FROM bookstore.book WHERE book_id IN (1, 4, 9);
 book_id |   j    |  t   | volumes 
---------+--------+------+---------
       1 | "hard" | hard | 2
       4 | "soft" | soft | 
       9 | "pdf"  | pdf  | 

Отсутствующий ключ даёт NULL. Оператор включения @> проверяет, что документ содержит указанную структуру, и именно его поддерживают индексы GIN:

SELECT book_id, title FROM bookstore.book
WHERE attrs @> '{"series": "Миры Стругацких"}';
 book_id |       title       
---------+-------------------
       4 | Пикник на обочине
       5 | Трудно быть богом

Стандарт SQL:2016 ввёл язык путей SQL/JSON (англ. SQL/JSON path language) для навигации по документам, и PostgreSQL поддерживает его с версии 12 9. Выражение пути начинается с $ (весь документ), спускается по ключам через точку, по массивам — через индексы и *, а фильтр записывается после ? в скобках, где @ обозначает текущий элемент. Функция jsonb_path_query возвращает найденные элементы, jsonb_path_exists и оператор @? проверяют, что что-то найдено:

SELECT count(*) FROM bookstore.book
WHERE attrs @? '$.age ? (@ == "18+" || @ == "16+")';

SELECT jsonb_path_query(
    '{"formats": [{"type": "pdf", "size": 12}, {"type": "epub", "size": 5}]}',
    '$.formats[*] ? (@.size > 10).type');
 count 
-------
   295

 jsonb_path_query 
------------------
 "pdf"

Документ можно изменять функциями jsonb_set (заменить значение по пути), операторами || и -. Изменение даже одного ключа записывает новую версию всего документа (а если он вынесен в TOAST — и всех его фрагментов), поэтому для больших документов с частыми точечными изменениями jsonb — дорогой выбор.

Когда JSONB уместен, а когда это скрытая денормализация

JSONB даёт гибкость, но платой за неё служат гарантии, которые реляционная модель предоставляет бесплатно. У ключа в документе нет типа (в одной книге "volumes": 2, в другой — "volumes": "два"), нет ограничения NOT NULL, нет внешнего ключа, нет статистики распределения отдельных ключей для планировщика (тема 11), а опечатка в имени ключа в запросе не вызывает ошибки — просто ничего не находится. Документ, в который встроены данные других сущностей (например, имена авторов книги), — это денормализация в чистом виде со всеми аномалиями обновления из темы 8: переименование автора потребует обойти все документы.

Разумное правило таково. Атрибуты, по которым есть бизнес-логика, связи, частые фильтры и агрегаты, — столбцы со своими типами и ограничениями. В JSONB уместно хранить действительно разнородные и редко используемые в условиях атрибуты (характеристики товаров разных категорий), данные внешних систем, которые сохраняются как есть, и настройки. Если атрибут из JSONB со временем «повзрослел» и стал использоваться повсеместно, его выносят в столбец; в лабораторной работе это делается командами ALTER TABLE ... ADD COLUMN и UPDATE. Промежуточный вариант — генерируемый столбец, вычисляемый из документа:

ALTER TABLE lab09.book_j
    ADD COLUMN binding text GENERATED ALWAYS AS (attrs ->> 'binding') STORED;

Часть гарантий можно вернуть и для документа — ограничением CHECK над выражениями jsonb:

ALTER TABLE lab09.book_j ADD CONSTRAINT attrs_shape
    CHECK (jsonb_typeof(attrs) = 'object'
           AND attrs ->> 'binding' IN ('soft', 'hard', 'pdf'));
UPDATE lab09.book_j SET attrs = attrs || '{"binding": "spiral"}' WHERE book_id = 1;
ERROR:  new row for relation "book_j" violates check constraint "attrs_shape"
DETAIL:  Failing row contains (1, Война и мир, {"binding": "spiral", "volumes": 2}).

Сравнение документной модели с реляционной на более широком материале — с документными СУБД вроде MongoDB — проводится в теме 16 12.

Схемы, табличные пространства и секционирование

Схемы и путь поиска

Схема (англ. schema) — пространство имён внутри базы данных: в разных схемах могут быть таблицы с одинаковыми именами 1. Все таблицы учебной базы находятся в схеме bookstore, системные — в pg_catalog, временные — в отдельной схеме сеанса, а схема public создаётся в каждой базе по умолчанию. Имя без указания схемы разрешается по пути поиска — параметру search_path:

SHOW search_path;
SELECT count(*) FROM book;
SET search_path = bookstore, public;
SELECT count(*) FROM book;
   search_path   
-----------------
 "$user", public

ERROR:  relation "book" does not exist

 count 
-------
  2010

По умолчанию путь включает схему с именем текущего пользователя и public; каталог pg_catalog неявно просматривается первым. Схемы используются для разделения модулей в одной базе, для экспериментов, не затрагивающих основные данные (lab09), и для разграничения прав (тема 15). Путь поиска — источник известной уязвимости: объект с «чужим» именем в схеме, стоящей раньше в пути, перехватит обращение без указания схемы, поэтому в функциях с повышенными правами путь задают явно (темы 14 и 15).

Табличные пространства

Табличное пространство (англ. tablespace) — каталог файловой системы, в котором PostgreSQL размещает файлы отношений 1. По умолчанию их два: pg_default (каталог base кластера) и pg_global (общие для кластера каталоги). Команда CREATE TABLESPACE fast LOCATION '/mnt/nvme/pg' позволяет вынести, например, горячие индексы на быстрый накопитель. Табличное пространство — не самостоятельная единица хранения: без каталога кластера и журнала его файлы бесполезны, и резервное копирование должно охватывать их все (тема 13).

Декларативное секционирование

Когда таблица вырастает до сотен миллионов строк, дорогими становятся очистка всей таблицы, удаление старых данных командой DELETE и работа с индексами, не помещающимися в память. Секционирование (англ. partitioning) делит одну логическую таблицу на несколько физических — секций (англ. partitions) — по значению ключа секционирования 10. Запросы обращаются к родительской таблице, а СУБД направляет строки в нужные секции и пропускает секции, которые заведомо не содержат подходящих строк.

PostgreSQL с версии 10 поддерживает декларативное секционирование трёх видов 10:

  • по диапазону (PARTITION BY RANGE) — секция получает интервал значений ключа, типично по дате: заказы по годам или месяцам;
  • по списку (PARTITION BY LIST) — секция получает перечень значений: заказы по регионам;
  • по хешу (PARTITION BY HASH, с версии 11) — строка попадает в секцию по остатку от деления хеша ключа на число секций (MODULUS, REMAINDER); это способ равномерно разделить данные без естественного диапазона.

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

CREATE TABLE lab09.orders_p (
    order_id     integer NOT NULL,
    customer_id  integer NOT NULL,
    created_at   timestamptz NOT NULL,
    status       text NOT NULL,
    warehouse_id integer,
    PRIMARY KEY (order_id, created_at)
) PARTITION BY RANGE (created_at);
CREATE TABLE lab09.orders_2022 PARTITION OF lab09.orders_p
    FOR VALUES FROM ('2022-01-01') TO ('2023-01-01');
CREATE TABLE lab09.orders_2023 PARTITION OF lab09.orders_p
    FOR VALUES FROM ('2023-01-01') TO ('2024-01-01');
CREATE TABLE lab09.orders_2024 PARTITION OF lab09.orders_p
    FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
INSERT INTO lab09.orders_p SELECT * FROM bookstore.orders;
SELECT tableoid::regclass AS partition, count(*) FROM lab09.orders_p GROUP BY 1 ORDER BY 1;
     partition     | count 
-------------------+-------
 lab09.orders_2022 |  6559
 lab09.orders_2023 |  6766
 lab09.orders_2024 |  6675

Нижняя граница диапазона включается, верхняя — нет, поэтому полночь 1 января 2023 года попадает во вторую секцию. Первичный ключ секционированной таблицы обязан включать ключ секционирования: уникальность проверяется индексами каждой секции по отдельности, и только так СУБД может гарантировать её для всей таблицы. Строка, для которой нет подходящей секции, вызывает ошибку no partition of relation "orders_p" found for row; чтобы её избежать, создают секцию по умолчанию (PARTITION OF ... DEFAULT) или заранее — секции на будущие периоды. Структура секционированной таблицы и отсечение секций показаны на рисунке.

Секционирование таблицы заказов по годам и отсечение секций
Запрос за март 2023 года читает только секцию orders_2023; остальные секции отсечены при планировании

Главная выгода для запросов — отсечение секций (англ. partition pruning): если условие запроса ограничивает ключ секционирования, планировщик исключает из плана секции, которые не могут содержать нужных строк 10. Как именно читается план выполнения, подробно разбирается в теме 11; здесь достаточно увидеть, какие секции в нём остались:

EXPLAIN SELECT count(*) FROM lab09.orders_p
WHERE created_at >= '2023-03-01' AND created_at < '2023-04-01';
                                QUERY PLAN
--------------------------------------------------------------------------
 Aggregate  (cost=152.99..153.00 rows=1 width=8)
   ->  Seq Scan on orders_2023 orders_p  (cost=0.00..151.49 rows=600 width=0)
         Filter: ((created_at >= '2023-03-01 00:00:00+03'::timestamp with time zone)
                   AND (created_at < '2023-04-01 00:00:00+03'::timestamp with time zone))

В плане одна секция — orders_2023 (длинная строка условия перенесена для читаемости). Если же условие не касается ключа секционирования, читаются все секции:

EXPLAIN SELECT count(*) FROM lab09.orders_p WHERE status = 'cancelled';
 Aggregate  (cost=409.69..409.70 rows=1 width=8)
   ->  Append  (cost=0.00..406.12 rows=1425 width=0)
         ->  Seq Scan on orders_2022 orders_p_1  (cost=0.00..130.99 rows=488 width=0)
               Filter: (status = 'cancelled'::text)
         ->  Seq Scan on orders_2023 orders_p_2  (cost=0.00..134.57 rows=463 width=0)
               Filter: (status = 'cancelled'::text)
         ->  Seq Scan on orders_2024 orders_p_3  (cost=0.00..133.44 rows=474 width=0)
               Filter: (status = 'cancelled'::text)

Узел Append объединяет результаты сканирования трёх секций. Отсечение возможно и во время выполнения, если граница известна только тогда — например, задана функцией now() или параметром подготовленного оператора; в плане это видно как строка Subplans Removed: N.

Не менее важна выгода для обслуживания. Удалить данные за 2022 год можно командой DROP TABLE lab09.orders_2022 или отсоединить секцию ALTER TABLE ... DETACH PARTITION — это мгновенная операция над метаданными вместо DELETE миллионов строк. Очистка и построение индексов выполняются по секциям, и старые, неизменяемые секции не нужно обрабатывать повторно.

Секционирование — не бесплатное ускорение: запрос без условия по ключу обходит все секции, при сотнях секций растут затраты на планирование, а уникальность по столбцам вне ключа секционирования обеспечить нельзя. Документация рекомендует выбирать ключ секционирования по самым частым условиям запросов и операциям удаления данных и избегать чрезмерного числа секций 10. Для таблицы в десятки тысяч строк, как orders при scale=1, секционирование ничего не даёт; оно оправдано, когда таблица измеряется десятками гигабайт или когда данные регулярно удаляются целыми периодами. Распределение секций по разным серверам — шардирование — рассматривается в теме 16.

Итоги темы

Физическое проектирование превращает логическую схему в объекты конкретной СУБД, и почти каждое его решение — компромисс между корректностью, объёмом и скоростью. Тип данных — это одновременно ограничение и способ хранения. Денежные суммы требуют точной арифметики numeric или целых чисел, потому что двоичная плавающая точка не представляет десятичные дроби и накапливает ошибку. Моменты событий хранятся в timestamptz, который переводит время в UTC и обратно, а не в timestamp, теряющем привязку к часовому поясу. В PostgreSQL char(n) и varchar(n) не быстрее text, а ограничение длины — это правило целостности, не оптимизация. Выбор между перечислением, CHECK и справочником определяется тем, кто и как меняет список значений.

Суррогатные ключи из последовательностей уникальны, но допускают пропуски. Случайные UUID версии 4 рассыпают вставки по всему индексу, отчего индекс становится больше и медленнее; UUID версии 7, упорядоченные по времени, ведут себя почти как последовательность. В PostgreSQL 16 встроенной функции для них нет — она появилась в версии 18.

На внутреннем уровне таблица — набор файлов из страниц по 8 КиБ. Каждая строка несёт 23-байтовый заголовок, выровненный до 24 байт, и 4-байтовый указатель; столбцы выравниваются по границам своих типов, поэтому порядок столбцов влияет на размер таблицы. Длинные значения сжимаются и выносятся в TOAST-таблицу, когда строка превышает порог около 2 КиБ. Зная эти числа, можно с точностью до страницы рассчитать размер таблицы до её создания.

Тип jsonb даёт гибкость для разнородных атрибутов и поддерживает операторы включения и язык путей SQL/JSON, но лишает данные типов, ограничений и связей; атрибуты, по которым есть логика и связи, должны быть столбцами. Секционирование делит большую таблицу на части по ключу, позволяя пропускать ненужные секции при запросах и удалять старые данные целыми секциями.

Остался главный инструмент физического проектирования, без которого ни одна большая таблица не обходится, — индекс. Как устроено B-дерево, почему его высота почти не растёт с ростом таблицы, какие ещё типы индексов есть в PostgreSQL и чего стоит каждый индекс при изменении данных, рассматривается в теме 10.

Литература

  1. PostgreSQL Global Development Group. PostgreSQL 16 Documentation. Chapter 8. Data Types; 5.9. Schemas; 23.6. Tablespaces; 24.2. Collation Support. — 2024. https://www.postgresql.org/docs/16/datatype.html
  2. PostgreSQL Global Development Group. PostgreSQL 16 Documentation. Chapter 73. Database Physical Storage. — 2024. https://www.postgresql.org/docs/16/storage.html
  3. PostgreSQL Global Development Group. PostgreSQL 18 Documentation. 9.14. UUID Functions. — 2025. https://www.postgresql.org/docs/18/functions-uuid.html
  4. PostgreSQL Global Development Group. PostgreSQL 16 Documentation. F.25. pageinspect — low-level inspection of database pages. — 2024. https://www.postgresql.org/docs/16/pageinspect.html
  5. IEEE 754-2019. IEEE Standard for Floating-Point Arithmetic. — IEEE, 2019, DOI: 10.1109/IEEESTD.2019.8766229.
  6. Goldberg D. What Every Computer Scientist Should Know About Floating-Point Arithmetic. — ACM Computing Surveys, 1991, Т. 23, № 1, С. 5–48, DOI: 10.1145/103162.103163.
  7. Davis K., Peabody B., Leach P. Universally Unique IDentifiers (UUIDs). — RFC 9562, IETF, 2024. https://www.rfc-editor.org/rfc/rfc9562
  8. PostgreSQL Global Development Group. PostgreSQL 16 Documentation. 8.14. JSON Types. — 2024. https://www.postgresql.org/docs/16/datatype-json.html
  9. PostgreSQL Global Development Group. PostgreSQL 16 Documentation. 9.16. JSON Functions and Operators. — 2024. https://www.postgresql.org/docs/16/functions-json.html
  10. PostgreSQL Global Development Group. PostgreSQL 16 Documentation. 5.11. Table Partitioning; CREATE TABLE (Storage Parameters). — 2024. https://www.postgresql.org/docs/16/ddl-partitioning.html
  11. Davis M., Whistler K., Scherer M. Unicode Technical Standard #10: Unicode Collation Algorithm. — Unicode Consortium. https://www.unicode.org/reports/tr10/
  12. Kleppmann M. Designing Data-Intensive Applications. — O'Reilly Media, 2017. (Рус. пер.: Клеппман М. Высоконагруженные приложения. Программирование, масштабирование, поддержка. — СПб.: Питер, 2018.)
  13. Рогов Е. В. PostgreSQL 16 изнутри. — М.: ДМК Пресс, 2024. https://postgrespro.ru/education/books/internals
  14. Silberschatz A., Korth H. F., Sudarshan S. Database System Concepts. 7th ed. — McGraw-Hill, 2019.