Мы забыли вовремя создать партиции, и все новые данные полетели в events_default_partition. Default дорос до ~1.1 ТБ, а простое «ATTACH PARTITION» требовало часов сканирования и долгой блокировки. В статье — почему «быстрые» рецепты оказываются медленными, как я перенёс данные в нужные диапазоны, и как мы уложили критическую блокировку в 44 с.
Default-партиция — это не озеро Байкал. Если туда всё сливать, экосистема потом мстит.
Суть проблемы
В нашей базе данных существует таблица events. Данные в неё постоянно дописываются и располагаются в инкрементируемых event_block. В одном event_block может содержаться до нескольких сотен записей. В таблицу постоянно пишутся новые данные и читаются имеющиеся.
Больше всего читаются данные самые новые, но остаётся необходимость в доступе и к старым данным, поэтому эта таблица разбита по event_block на диапазоны по 1 000 000, чтобы ускорить вставку/чтение и упростить обслуживание (вакуум, ретеншн, бэкапы).
Активная партиция — последняя по времени. Каждая такая партиция занимает около 50-100GB.
На первый взгляд всё выглядит хорошо: данных много для одной таблицы, поэтому они разбиты на множество более мелких. Но есть одна загвоздка: партиции создаются вручную нами раз в несколько недель.
Обычно создавали наперёд 5–10 партиций, но однажды окно пропустили — default дорос до ≈ 1.1 ТБ.

Надо было разрезать без многочасовой блокировки записи.
Параметры БД
PostgreSQL: 16.1
Наблюдаемая пропускная способность чтения по EXPLAIN BUFFERS: ~77 MB/s до распила → ~1.5 GB/s после
max_parallel_workers_per_gather: 2
wal_compression: off
Объём default-партиции: 1.1 ТБ → 0 ТБ
Первые попытки решения проблемы
Добавление партиции напрямую
На тот момент в events_default_partition хранились исторические данные (51-64 миллионы) и активно дописываемые данные (65 миллион).
Первая идея была максимально простой: прикрепить к таблице новую партицию на будущий миллион блоков (66) и когда он начнёт наполняться, то все новые данные отправятся в новую партицию, а я постепенно разделю events_default_partition по мелким партициям.
Функция создания партиций уже была:
CREATE OR REPLACE FUNCTION create_events_partition_by_event_block_million( million_start INTEGER ) RETURNS void AS $$ DECLARE partition_start INTEGER; partition_end INTEGER; partition_name TEXT; BEGIN -- Calculate the start and end of the block range based on the input partition_start := million_start * 1000000; partition_end := partition_start + 1000000; -- Generate the partition name based on the million_start partition_name := 'events_partition_m' || million_start || '_m' || (million_start + 1); -- Create the partition EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF events FOR VALUES FROM (%s) TO (%s);', partition_name, partition_start, partition_end); RAISE NOTICE 'Partition % created for range % to %', partition_name, partition_start, partition_end; END; $$ LANGUAGE plpgsql;
Осталось её только вызвать:
Select create_events_partition_by_event_block_million(66);
И что могло пойти не так, всё выглядит просто и казалось, что должно сработать идеально.
Но, прождав около 5 минут (а всё это время таблица была заблокирована), я прервал процесс и понял, что это не дело - длится всё слишком долго и надо найти иной подход.
Как я выяснил позже, при добавлении новой партиции PostgreSQL должен убедиться, что ни одна запись в default-партиции не подходит под диапазон новой. Для этого он начинает полное сканирование всех 1.1 ТБ данных, что и вызывало многочасовую блокировку.
Создание партиции отдельно с дальнейшим прикреплением к таблице
Я обратился к GPT, описав проблему и он мне подсказал "действенный 100%-й способ", который позволит мне за долю секунды добавить новую партицию к таблице.
Состоял он в следующем:
Я создаю будущую партицию
Добавляю
CHECK NOT VALIDна эту партицию, который проверит, что в таблице нет лишних данных, не удовлетворяющих условию ограничения диапазона блоковВключаю валидацию
Прикрепляю эту партицию к главной таблице
Удаляю этот
CHECK
При ATTACH Postgres сам добавляет партиционный constraint дочке; наш предварительный CHECK — это предварительная проверка данных для избежания блокировки при ATTACH.
Код выглядит так:
CREATE TABLE events_partition_m66_m67 (LIKE events INCLUDING ALL); ALTER TABLE events_partition_m66_m67 ADD CONSTRAINT events_partition_m66_m67_partition_check CHECK (event_block >= 66000000 AND event_block < 67000000) NOT VALID; ALTER TABLE events_partition_m66_m67 VALIDATE CONSTRAINT events_partition_m66_m67_partition_check; ALTER TABLE events ATTACH PARTITION events_partition_m66_m67 FOR VALUES FROM (66000000) TO (67000000); ALTER TABLE events_partition_m66_m67 DROP CONSTRAINT events_partition_m66_m67_partition_check;
Выглядит код отлично, рабочим. В итоге запускаю его на выполнение.
После 6 минут ожидания я понимаю, что совет не сработал в наших условиях. Совет GPT был бы идеален, если бы default-партиция была пуста. Команда ATTACH PARTITION накладывает эксклюзивную блокировку на родительскую таблицу и проверяет две вещи:
все данные в присоединяемой таблице (у нас она пустая) соответствуют её новому диапазону;
все данные в default-партиции НЕ соответствуют диапазону новой партиции.
Именно второй пункт и запускал полное сканирование нашего терабайтного "монстра".
В итоге все варианты, что мне предлагал GPT, крутились вокруг этого решения и я подумал, может стоит просто чуть дольше подождать и всё сработает? Для этого я решил посчитать сколько времени потребуется БД для того, чтобы прикрепить новую партицию к родительской таблице.
Оценка затрат по времени
С помощью GPT я разобрался в том, как бы я мог посчитать примерное время простоя таблицы на сканирование default-партиции, чтобы добавить новую.
Сначала надо было посчитать скорость чтения данных из этой таблицы:
EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM events_default_partition WHERE event_block >= 51000000 AND event_block < 52000000;
Результат получился следующий:
Finalize Aggregate (cost=4739280.56..4739280.57 rows=1 width=8) (actual time=99395.925..99396.012 rows=1 loops=1) Buffers: shared hit=25399 read=1001828 -> Gather (cost=4739280.35..4739280.56 rows=2 width=8) (actual time=99395.910..99395.997 rows=1 loops=1) Workers Planned: 2 Workers Launched: 0 Buffers: shared hit=25399 read=1001828 -> Partial Aggregate (cost=4738280.35..4738280.36 rows=1 width=8) (actual time=99395.672..99395.673 rows=1 loops=1) Buffers: shared hit=25399 read=1001828 -> Parallel Index Only Scan using events_default_partition_event_block_index_idx on events_default_partition (cost=0.57..4631941.76 rows=42535435 width=0) (actual time=0.021..93706.088 rows=107919484 loops=1) Index Cond: ((event_block >= 51000000) AND (event_block <= 52000000)) Heap Fetches: 1655 Buffers: shared hit=25399 read=1001828 Planning: Buffers: shared hit=317 read=1 Planning Time: 4.994 ms JIT: Functions: 5 " Options: Inlining true, Optimization true, Expressions true, Deforming true" " Timing: Generation 0.267 ms, Inlining 3.519 ms, Optimization 8.600 ms, Emission 9.538 ms, Total 21.924 ms" Execution Time: 99396.371 ms
Время выполнения: 99396.371 ms ≈ 99.4s
Просканировано 107,919,484 строк
Чтения буфера: 1,001,828 → это ~8 KB * 1,001,828 ≈ 7.64 GB чтения с диска
Это говорит о том, что при чтении основная задержка на диске и 7.64GB были прочитаны за 99.4s, следовательно пропускная способность около 76.9MB/s, что довольно мало.
Это скорость логического чтения по мнению планировщика, а не «сырой диск». На неё влияют OS-кэш, параллелизм, конкуренция и т. д. Видимо, было связано с тем, что таблица сильно разрослась и продолжала свой непрерывный рост, потому как после распила пропускная способность увеличилась до 1500MB/s.
А это значит, что для сканирования потребуется примерно 1100GB / 76.9 (MB/s) = 4 hours

Это очень долгое время блокировки таблицы и такое мы себе позволить не могли. Пришлось искать иные пути решения.
Подход с минимальным временем блокировки таблицы
Нужно было найти решение такое, чтобы таблица была заблокирована минимум времени, так как другие сервисы в неё постоянно пишут и читают, а длительный простой может стоить нам много денег.
В итоге было придумано мною следующее решение:
Я создаю партиции с check и постепенно копирую туда данные из default-партиции
Копирую данные по текущему активному миллиону блоков максимально близко к хвосту (не копирую всё вслепую, так как данные по последним блокам могут меняться и доезжать)
В рамках одной транзакции делаю следующее:
Беру блокировку на родительскую таблицу
eventsКопирую оставшиеся данные в актуальную партицию
Делаю
TRUNCATE events_default_partitionПрикрепляю все новые партиции к родительской таблице
Отдаю блокировку
Я заранее разолью львиную долю строк по целевым диапазонам. В момент транзакции БД почти нечего валидировать/перекладывать — поэтому должны уложиться в небольшое время блокировки.
Единственный минус такого подхода в том, что для этого потребуются дополнительные 1100 GB дискового пространства для новых таблиц. Это мы себе могли позволить.
Реализация подхода
Так как партиций нужно было создать достаточно большое количество, я решил сделать функции в postgres на время распила партиций (P.S. я не сторонник излишней логики на уровне БД, но в формате единовременного решения это выглядело приемлемо)
Копирование данных
Сначала я реализовал функцию, которая бы создавала партицию с проверкой по ограничениям и копировала в неё необходимые данные из default-партиции:
CREATE OR REPLACE FUNCTION create_events_partition_with_copy_data( million_start INTEGER ) RETURNS void AS $$ DECLARE partition_start INTEGER; partition_end INTEGER; partition_name TEXT; constraint_name TEXT; BEGIN -- Calculate the start and end of the block range based on the input partition_start := million_start * 1000000; partition_end := partition_start + 1000000; -- Generate the partition name based on the million_start partition_name := 'events_partition_m' || million_start || '_m' || (million_start + 1); -- Create the partition EXECUTE format('CREATE TABLE IF NOT EXISTS %I (LIKE events INCLUDING ALL);', partition_name); RAISE NOTICE 'Partition % created for range % to %', partition_name, partition_start, partition_end; constraint_name := partition_name || '_partition_check'; EXECUTE format('ALTER TABLE %I ADD CONSTRAINT %I CHECK (event_block >= %s AND event_block < %s);', partition_name, constraint_name, partition_start, partition_end); RAISE NOTICE 'CHECK % created for table %', constraint_name, partition_name; EXECUTE format('INSERT INTO %I SELECT * FROM events_default_partition WHERE event_block >= %s AND event_block < %s;', partition_name, partition_start, partition_end); RAISE NOTICE 'DATA HAVE BEEN INSERTED INTO % FROM events_default_partition for period % <= event_block < %', partition_name, partition_start, partition_end; END; $$ LANGUAGE plpgsql;
После я вызывал эту функцию для всех партиций с копированием данных от 51 до 65 миллиона поочереди (исторические данные). Запускал параллельно до двух процессов, чтобы не перегружать сильно БД.
Пример вызова:
SELECT create_events_partition_with_copy_data(51);
Частичное копирование последнего миллиона
Так как последний миллион является активно наполняемым, то для него партиция создавалась вручную с копированием только в необходимых диапазонах. Было это сделано для того, чтобы в рамках единой транзакции осталось для копирования минимум данных и время блокировки таблицы было минимальным.
Сначала создал последнюю партицию вручную:
CREATE TABLE IF NOT EXISTS events_partition_m65_m66 (LIKE events INCLUDING ALL); ALTER TABLE events_partition_m65_m66 ADD CONSTRAINT events_partition_m65_m66_partition_check CHECK (event_block >= 65000000 AND event_block < 66000000);
Скопировал оставшиеся данные в партицию перед последним шагом (2 вставки, так как первая отрабатывала долго, а блокировать таблицу хотелось на минимум времени):
INSERT INTO events_partition_m65_m66 SELECT * FROM events_default_partition WHERE event_block >= 65000000 AND event_block < 65420000; INSERT INTO events_partition_m65_m66 SELECT * FROM events_default_partition WHERE event_block >= 65420000 AND event_block < 65470000;
Прикрепление партиций к родительской таблице
Сначала я сделал функцию, прикрепляющую партицию и удаляющую check из неё:
CREATE OR REPLACE FUNCTION attach_partition_to_events( million_start INTEGER ) RETURNS void AS $$ DECLARE partition_start INTEGER; partition_end INTEGER; partition_name TEXT; constraint_name TEXT; BEGIN -- Calculate the start and end of the block range based on the input partition_start := million_start * 1000000; partition_end := partition_start + 1000000; -- Generate the partition name based on the million_start partition_name := 'events_partition_m' || million_start || '_m' || (million_start + 1); -- Create the partition EXECUTE format('ALTER TABLE events ATTACH PARTITION %I FOR VALUES FROM (%s) TO (%s);', partition_name, partition_start, partition_end); RAISE NOTICE 'Partition % has been attached to events for range % to %', partition_name, partition_start, partition_end; constraint_name := partition_name || '_partition_check'; EXECUTE format('ALTER TABLE %I DROP CONSTRAINT %I;', partition_name, constraint_name); RAISE NOTICE 'CHECK % has been dropped from partition %', constraint_name, partition_name; END; $$ LANGUAGE plpgsql;
И последним шагом я реализовал транзакцию, в которой взял блокировку на таблицу, скопировал остаток данных, очистил events_default_partition и прикрепил все новые партиции к родительской таблице. Перед этим я также предупредил коллег, что в течение 5-10 минут таблица может быть недоступна.
P.S. на default-секцию нет внешних ключей; иначе TRUNCATE … CASCADE может снести лишнее.
Визуализация подготовки и транзакции:
// Этап 1: Предварительное копирование (часы, без блокировки) events_default_partition (1.1 ТБ) | +-- INSERT INTO new_partition_51 ... (данные за 51 млн) +-- INSERT INTO new_partition_52 ... (данные за 52 млн) ... +-- INSERT INTO new_partition_65 ... (почти все данные за 65 млн) // Этап 2: Критическая секция (44 секунды, с блокировкой) BEGIN; LOCK events; 1. Копируем "хвост" данных из default в new_partition_65. 2. TRUNCATE events_default_partition; 3. ATTACH PARTITION new_partition_51; 4. ATTACH PARTITION new_partition_52; ... 5. ATTACH PARTITION new_partition_65; COMMIT;
DO $$ BEGIN SET LOCAL statement_timeout = 0; SET LOCAL lock_timeout = '600s'; -- Serialize whole operation so nobody else runs it concurrently IF NOT pg_try_advisory_lock('events'::regclass::bigint) THEN RAISE EXCEPTION 'Another maintenance run is active'; END IF; LOCK TABLE ONLY events IN ACCESS EXCLUSIVE MODE; LOCK TABLE events_default_partition IN ACCESS EXCLUSIVE MODE; -- Copy last data for newest partition INSERT INTO events_partition_m65_m66 SELECT * FROM events_default_partition WHERE event_block >= 65470000; RAISE NOTICE 'Last partition data copied'; -- Clean data from default_partition TRUNCATE TABLE ONLY events_default_partition; RAISE NOTICE 'events_default_partition has been truncated'; FOR million IN 51..65 LOOP PERFORM attach_partition_to_events(million); END LOOP; RAISE NOTICE 'partitioning of events_default_partition has been completed'; PERFORM pg_advisory_unlock('events'::regclass::bigint); EXCEPTION WHEN OTHERS THEN RAISE NOTICE 'Error: %', SQLERRM; -- In case of any error, be sure to remove the advisory lock PERFORM pg_advisory_unlock('events'::regclass::bigint); RAISE; END $$;
После переноса и очистки данных мы запускаем ANALYZE, чтобы обновить статистику в планировщике PostgreSQL. Это гарантирует, что оптимизатор запросов будет видеть реальный объём и распределение данных в партициях и сможет строить адекватные планы вместо того, чтобы полагаться на устаревшие оценки, что может привести к существенному замедлению запросов.
ANALYZE events;
Также важно не забыть удалить вспомогательные функции, которые больше не используются — нечего плодить мусор в схеме:
DROP FUNCTION attach_partition_to_events; DROP FUNCTION create_events_partition_with_copy_data; DROP FUNCTION create_events_partition_by_event_block_million;
В итоге выполнения я получил следующее сообщение: completed in 44 s 358 ms, что оказалось быстрее моих ожиданий, чему я был очень рад.
По окончанию копирования скорость получения данных возросла, а events_default_partition осталась пустой, что теперь не вызывает проблем с добавлением новых партиций.
P.S. Не забывайте про EXCEPTION с разблокировкой таблицы.
Если что-то пойдёт не так, то транзакция откатится, а advisory lock может остаться висеть.
Всегда предусматривайте такой вариант в сложных миграциях.
Почему ATTACH/VALIDATE медленно на больших объёмах
ATTACH PARTITION корректен, только если все строки дочерней таблицы попадают в границы и строки в default-партиции не противоречат новым условиям.
Для этого Postgres валидирует диапазон/CHECK — фактически сканирует кандидата (дочернюю таблицу) и default-партицию и держит тяжёлые локи.
На сотнях гигабайт это часы.
Мы вынесли скан/перелив заранее, а в транзакции оставили только «добор хвоста» + TRUNCATE + ATTACH, поэтому уложились в ~44 s.
И то, эти 44s в большей степени ушли на «добор хвоста».
Заключение
Самый главный урок этой истории — автоматизируйте рутинные операции. Процесс создания партиций должен быть автоматическим.
Не забывайте про свои таблицы и не давайте им разрастись до огромных размеров, иначе потом с ними будет очень больно работать и особенно неприятно будет их очищать.
А также не забывайте чуть приостановиться и подумать в разные возможные стороны решений, потому как в таком виде решение ко мне пришло не сразу, а по частям. А ИИ-помощники могут как предложить хорошее решение, так и увести куда-то далеко от них.
Default-секция — не склад временного хранения, а бездонная яма.
