Раздел 33 · PostgreSQL с нуля

Партиционирование таблиц

junior~20 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В прошлом уроке мы закончили разбор того, как 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 ₽.

Оплата пока не подключена: премиум и курсы открываются промокодом.