В прошлом уроке мы закончили разбор того, как PostgreSQL уживается с одновременными транзакциями. Последний кластер раздела про рост: что делать, когда данных или пользователей становится так много, что одного обычного сервера с одной обычной таблицей уже мало. Начнём с самой большой таблицы магазина, журнала продаж, и научимся резать её на части.
Папки по месяцам
Бухгалтерия хранит первичные документы не одной стопкой, а в папках по месяцам. Нужны продажи за сентябрь: открываешь одну папку и не листаешь остальные. Через пять лет старые документы можно уничтожить: выносишь коробку за нужный год целиком, а не вычёркиваешь листы по одному.
Партиционирование делает с таблицей то же самое. Снаружи это одна таблица sales: в неё вставляют и из неё читают как обычно. Внутри это несколько отдельных таблиц, секций, и каждая строка лежит ровно в одной из них. Какой именно, решает ключ секционирования: у журнала продаж это дата.
Разбиение по диапазону
Заведём журнал продаж и скажем PostgreSQL, что делить его нужно по диапазонам дат:
CREATE TABLE sales (
id bigint GENERATED ALWAYS AS IDENTITY,
book_id integer NOT NULL,
sold_at date NOT NULL,
amount numeric(8, 2) NOT NULL
) PARTITION BY RANGE (sold_at);
Строка PARTITION BY RANGE (sold_at) делает таблицу секционированной. Но данных она пока не примет: у неё нет ни одной секции, а сама она строки не хранит. Создадим три месяца:
CREATE TABLE sales_2026_08 PARTITION OF sales
FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
CREATE TABLE sales_2026_09 PARTITION OF sales
FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
CREATE TABLE sales_2026_10 PARTITION OF sales
FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
Граница FROM входит в диапазон, а граница TO нет. Поэтому 1 сентября попадает в sales_2026_09, а не в август, и одну дату можно писать концом одной секции и началом следующей без дыр и пересечений.
Нальём 30 тысяч продаж за три месяца и посмотрим, где они оказались. Служебная колонка tableoid хранит номер таблицы, из которой пришла строка, а приведение ::regclass превращает номер в имя:
INSERT INTO sales (book_id, sold_at, amount)
SELECT 1 + g % 9, DATE '2026-08-01' + g % 92, 100 + g % 900
FROM generate_series(1, 30000) AS g;
SELECT tableoid::regclass AS partition, count(*) AS rows
FROM sales
GROUP BY 1
ORDER BY 1;
partition | rows
---------------+-------
sales_2026_08 | 10114
sales_2026_09 | 9780
sales_2026_10 | 10106
(3 rows)
Мы вставляли в sales, а PostgreSQL сам разложил строки по месяцам.
Строка, которой некуда лечь
Что будет с продажей за декабрь, для которого секции ещё нет?
INSERT INTO sales (book_id, sold_at, amount) VALUES (1, '2026-12-05', 690);
ERROR: no partition of relation "sales" found for row
DETAIL: Partition key of the failing row contains (sold_at) = (2026-12-05).
Вставка падает. Это честно: база не знает, куда положить строку, и не придумывает за тебя. Есть два выхода. Первый: заранее создавать секции на будущее, обычно задачей по расписанию, которая каждый месяц заводит следующий. Второй: секция по умолчанию, куда попадает всё, что не подошло под другие:
CREATE TABLE sales_default PARTITION OF sales DEFAULT;
INSERT INTO sales (book_id, sold_at, amount) VALUES (1, '2026-12-05', 690);
Теперь вставка проходит. Но у секции по умолчанию есть цена, к ней вернёмся ниже.
Отсечение секций
Ради чего всё это? Посчитаем выручку за сентябрь и спросим план, как в уроке про EXPLAIN:
EXPLAIN SELECT sum(amount) FROM sales
WHERE sold_at >= '2026-09-01' AND sold_at < '2026-10-01';
QUERY PLAN
--------------------------------------------------------------------------------------
Aggregate (cost=234.15..234.16 rows=1 width=32)
-> Seq Scan on sales_2026_09 sales (cost=0.00..209.70 rows=9780 width=5)
Filter: ((sold_at >= '2026-09-01'::date) AND (sold_at < '2026-10-01'::date))
(3 rows)
В плане только одна секция, sales_2026_09. Остальные PostgreSQL даже не открывал: по условию на ключ он понял, что там нужных строк быть не может. Это называется отсечением секций, та самая «открыть одну папку».
А если условие не на ключ?
EXPLAIN SELECT sum(amount) FROM sales WHERE book_id = 7;
QUERY PLAN
--------------------------------------------------------------------------------------
Aggregate (cost=594.02..594.03 rows=1 width=32)
-> Append (cost=0.00..585.68 rows=3334 width=5)
-> Seq Scan on sales_2026_08 sales_1 (cost=0.00..191.43 rows=1123 width=5)
Filter: (book_id = 7)
-> Seq Scan on sales_2026_09 sales_2 (cost=0.00..185.25 rows=1088 width=5)
Filter: (book_id = 7)
-> Seq Scan on sales_2026_10 sales_3 (cost=0.00..191.32 rows=1122 width=5)
Filter: (book_id = 7)
-> Seq Scan on sales_default sales_4 (cost=0.00..1.01 rows=1 width=5)
Filter: (book_id = 7)
(10 rows)
Узел Append склеивает результаты всех четырёх секций: книга номер 7 могла продаваться в любой месяц. Отсюда главное правило: ключ секционирования выбирают по тому, как фильтруют самые частые и тяжёлые запросы. Если почти все отчёты идут «за период», делят по дате. Если по дате не фильтрует никто, секции только добавят работы.
урок из курса
Урок из курса «PostgreSQL с нуля». Курс покупается один раз и остаётся навсегда: 500 ₽.
Оплата пока не подключена: премиум и курсы открываются промокодом.