Мы (ну ладно, я) ждали этого больше десяти лет: в PostgreSQL 19 наконец-то завезли штатную онлайн-перепаковку таблиц. Команда REPACK в ядре и теперь больше никаких сторонних расширений и бесконечных согласований с ИБ. Да? Или нет? Эпоха pg_repack подошла к концу? Спойлер: не спешите удалять старые скрипты и утилиты. На моих тестах новая встроенная команда под нагрузкой заблокировала таблицу на три с лишним минуты, в то время как "старичок" pg_repack уложился в 0.6 секунды.
Выяснил: как устроен новый REPACK под капотом, почему он ломает привычный MVCC и в каких сценариях попытка использовать штатный инструмент на проде станет фатальной ошибкой.
Статья написана по PostgreSQL 19beta2. Это бета, детали поведения к релизу могут измениться.
Зачем вообще нужна перепаковка
В условиях интенсивной записи таблица растёт быстрее, чем объём полезных данных. Каждое обновление строки порождает новую версию, старая становится мёртвой, autovacuum её вычищает, но место в файловой системе не возвращается. Освободившееся пространство переиспользуется внутри файла для новых версий строк.
Масштаб проблемы легко увидеть на стенде. Берём таблицу pgbench_accounts (стандартный тест TPC-B pgbench) на 40 млн строк (5.1 ГБ), обновляем первые 90% строк. Новые версии уезжают в хвост, файл раздувается до 9.7 ГБ. Запускаем обычный VACUUM: четыре минуты работы, 36 млн мёртвых строк вычищено, возвращено 0 байт. По pgstattuple в файле 47.95% свободного места, половина таблицы пустует, но диск считает её занятой. Штатный VACUUM тут нам не поможет, нужны другие варианты.
Чем перепаковывали до сих пор
Самый простой вариант — VACUUM FULL. Он переписывает таблицу в новый файл, надёжен и встроен, но всё время работы удерживает ACCESS EXCLUSIVE: таблица недоступна ни для чтения, ни для записи. В случае с 10 ГБ это минуты, в случае сотен — часы. Инструменту нужно согласованное окно обслуживания.
Когда окна нет, берут pg_repack — внешнюю утилиту, которая уже десять лет решает ту же задачу без долгой блокировки. Инструмент работает так: на таблицу вешается триггер, все изменения складываются в лог-таблицу, параллельно строится копия, затем догоняется накопившееся и подменяются файлы. Эксклюзивный лок нужен дважды, в начале и в конце, но оба раза на короткое время. Платить приходится местом под полную копию таблицы с индексами и накладными расходами на каждый DML, пока идёт работа.
Третий инструмент, pgcompacttable, устроен принципиально иначе: копию он не строит вовсе, пачками выполняет точечные UPDATE по ctid, продавливая строки из хвостовых страниц в свободное место ближе к началу файла. Хвост пустеет, и обычный VACUUM его отрезает. Второго объёма на диске не нужно. Платить придётся — скоростью: на моей десятигигабайтной таблице инструмент за полтора часа прошёл 6%, то есть управился бы примерно за сутки.
Выбирая между тремя решениями, администратор взвешивает блокировки, место и третье, о чём пишут реже, — то сколько внимания требует сам инструмент. На дежурстве это обычно и решает. pgcompacttable можно запустить в screen и поглядывать раз в час, у него есть троттлинг (параметр --delay-ratio), чтобы регулировать собственную активность. За pg_repack надо следить, троттлинга у него нет.
REPACK: что это такое
Новая команда собирает всё семейство перепаковки под одним именем. VACUUM FULL теперь помечен в документации как deprecated и описан как REPACK без USING INDEX, CLUSTER — как эквивалент REPACK с USING INDEX. Содержательная документация переехала в sql-repack.
Попытку заменить pg_repack команду делает режим CONCURRENTLY: таблица остаётся доступной, пока идёт копирование, а эксклюзивный лок берётся только на подмену файлов.
Работает это через логическое декодирование, но постоянный wal_level = logical не нужен (это сильный плюс), и тут достаточно replica. В версии 19 появился динамический контроль декодирования: создание слота на лету включает нужное WAL-логирование, после завершения всё возвращается назад. Текущее состояние показывает новый read-only параметр effective_wal_level. Сам слот временный и живёт в отдельном пуле max_repack_replication_slots, так что перепаковка не отберёт слоты у реплик или логических подписок.
Следить за ходом можно через pg_stat_progress_repack: там видны фазы сканирования, перестройки индексов, догона изменений и подмены файлов. Права выдаются привилегией MAINTAIN: перепаковку можно делегировать дежурному, не раздавая права суперпользователя.
Операцию можно прервать в любой момент. В экспериментах я отменял её и на середине копирования, и уже внутри окна блокировки, когда в очереди стояли восемь бэкендов: очередь расходится в ту же секунду, таблица остаётся нетронутой, слот снимается, место освобождается. Но вся проделанная работа пропадает, и начинать придётся сначала.
На моей раздутой таблице результат работы такой: heap уменьшился с 9734 до 5209 МБ, доля свободного места упала с 47.95% до 1.31%, индекс уменьшился с 1628 до 857 МБ, фрагментация снизилась с 47.37% до 0. Блоат снят по максимуму, индексы перестроены той же операцией.
Что это даёт администратору
Ставить и согласовывать больше нечего: нет внешнего бинарника и расширения, а значит нет и разговора с нашей любимой и уважаемой ИБ про стороннее ПО на проде. В закрытых контурах это весомый аргумент сам по себе. Заодно исчезает известная боль с версиями, когда после обновления сервера приходится ждать совместимую сборку pg_repack.
REPACK не трогает DDL таблицы, тогда как pg_repack создаёт триггер и лог-таблицу, то есть меняет схему базы данных. Меньше поводов для сюрпризов с репликацией, аудитом схемы и чужими инструментами. Да и вообще меньше изменений — меньше поверхности для проблем.
При использовании REPACK пишущая нагрузка обходится дешевле. У pg_repack каждый INSERT, UPDATE и DELETE во время работы дублируются в лог-таблицу, это двойная запись плюс её собственный WAL. REPACK читает изменения из WAL, который пишется и так. Но тут без иллюзий: процесс перепаковки всё равно может вызвать просадку в приложении. Перепаковка при любом инструменте обрабатывает всю таблицу и конкурирует за диск, так что просадка будет. Вопрос в её величине и доступности свободных ресурсов (CPU и I/O), и к цифрам мы ещё вернёмся.
Из остального стоит отметить, что кластер не надо перенастраивать, индексы приводятся в порядок той же операцией без отдельного REINDEX CONCURRENTLY, а за ходом работы наконец можно наблюдать. У pg_repack с этим было вообще никак: процесс идёт, и всё.
Чего REPACK пока не умеет
Отличий от pg_repack пока набирается прилично, и именно они определяют, где старая утилита остаётся нужной. Партиционированные таблицы в конкурентном режиме не поддерживаются: обычный REPACK партиции обрабатывает, а REPACK CONCURRENTLY на них запрещён, придётся обходить их вручную: у pg_repack для этого есть --parent-table. Заодно и остальные запреты конкурентного режима: UNLOGGED-таблицы, таблицы без первичного ключа или index-based replica identity, системные каталоги и TOAST, вызов внутри транзакционного блока и исчерпание max_repack_replication_slots. Индексы REPACK строит последовательно, тогда как pg_repack умеет --jobs, поднимая несколько соединений. На стенде у меня был всего один индекс, так что выигрыш я не измерял, но на таблице с полудюжиной он напрашивается сам собой.
Переноса в другое табличное пространство у REPACK нет вовсе, а у pg_repack это --tablespace и --moveidx. Одной операцией можно и дефрагментировать, и мигрировать на другой диск. Порядок строк REPACK задаёт только существующим индексом через USING INDEX, произвольного --order-by нет. Нет и массовых режимов: pg_repack умеет перепаковать всё в схеме, исключив таблицы расширений, а у REPACK выбор бинарный — одна таблица либо вся база, причём вариант «вся база» с CONCURRENTLY несовместим. Наконец, у pg_repack есть встроенная политика ожидания лока (--wait-timeout, --no-kill-backend), а у REPACK её нет.
Ещё два свойства стоит разобрать отдельно.
Конкурентный режим не MVCC-safe. Это особенность именно CONCURRENTLY режима (обычный REPACK, как CLUSTER и VACUUM FULL, безопасен). Транзакция в REPEATABLE READ или SERIALIZABLE, которая взяла снапшот до перепаковки и не обращалась к таблице раньше, увидит её пустой, без каких-либо ошибок или предупреждений. Например в такой сессии count(*) вернёт ноль, тогда как все остальные видели тысячу строк; с обычным REPACK та же проверка отработала корректно. В READ COMMITTED, то есть по умолчанию, риска практически нет, но отчётные задания и pg_dump работают как раз на повышенных уровнях изоляции.
Длительность финальной блокировки не контролируется. Это самое интересное и, на мой взгляд, важное: блокировка берётся один раз, но насколько она затянется — заранее неизвестно. Можно вспомнить про lock_timeout, но он тут не поможет: он ограничивает ожидание лока, а не время удержания.
Цифры со стенда
Все три инструмента я прогнал на раздутой таблице 9.7 ГБ (40 млн строк) под нагрузкой pgbench в 8 клиентов.
инструмент |
длительность |
макс. недоступность |
TPS во время работы |
доп. место |
|---|---|---|---|---|
REPACK CONCURRENTLY |
16–21 мин |
42.6 и 198.7 с |
131–158 |
копия таблицы |
pg_repack 1.5.3 |
28–30 мин |
0.670 и 0.672 с |
102–105 |
копия таблицы |
pgcompacttable |
~сутки (6% за 1.5 ч) |
2.54 с |
~275 |
не требуется |
Базовая пропускная способность до начала работ оказалась на уровне 840–860 TPS в разных прогонах. Первое, что бросается в глаза, — просадка во время перепаковки. Пропускная способность падает в пять-шесть раз с REPACK и в восемь — с pg_repack: приложение работает на 12–20% своей мощности, и длится это десятки минут. Об этом я говорил чуть выше: перепаковка требует ресурсов CPU и I/O, и здесь природа просадки именно ресурсная: перепаковка последовательно вычитывает всю таблицу и пишет копию, забирая дисковую полосу и вытесняя из кэша горячие данные. Избежать этого не может ни один инструмент, и оценивать запас по I/O и планировать просадку надо наравне с окном блокировки.
Разница между 131–158 и 102–105 TPS согласуется с тем, что pg_repack дублирует каждое изменение в лог-таблицу; правда, он ещё и работает в полтора раза дольше. Резкого провала в TPS помог бы избежать pgcompacttable с троттлингом, но растянутый на сутки, он суммарно нагрузит систему не меньше — просто размажет нагрузку тонким слоем (близкий аналог — spread-режим чекпоинтера укладывающийся в checkpoint_completion_target). Выбор тут за DBA: «болезненно, но быстро» или «терпимо, но долго».
У pgcompacttable единственная история с блокировками — 2.54 секунды, но это конфликты между самими клиентами за одни и те же строки. За полтора часа эксперимента ни один клиент ни разу не ждал блокировки таблицы.
Теперь про окно REPACK, ради которого таблица и составлялась. Два прогона под одинаковой нагрузкой дали 42.6 и 198.7 секунды — разброс в 4.7 раза, притом что объём накопленных изменений различался всего на 9%. Три с лишним минуты полной недоступности, включая чтения, против 43 секунд в предыдущем прогоне на той же таблице.
Причина в том, как устроен догон изменений. pg_repack применяет накопленный лог в цикле и берёт эксклюзивный лок только тогда, когда за очередной проход осталось применить всего ничего. REPACK делает ровно один предварительный проход, после чего сразу берёт лок и всё, что накопилось за время этого прохода, доделывает уже под блокировкой. Чем дольше идёт проход и чем интенсивнее запись, тем больше работы остаётся на потом.
Пока вывод такой, что объём накопленных изменений плохо предсказывает длину окна блокировки. Также очень вероятно, что есть факторы, которые я не учёл и не проконтролировал (повод накинуть гипотезы в комментариях).
Как понять, чего ждать на своей базе
Точной формулы нет, но можно оценить порядок величины. Смотреть надо на три вещи.
Сколько времени займёт копирование? Увы, заранее это не посчитать и паспортная скорость диска вряд ли подойдет, так как есть конкуренция с нагрузкой, случайный доступ и перестройка индексов, фоновые процессы. Остаётся только прикинуть на ходу через pg_stat_progress_repack. Можно посмотреть в heap_blks_scanned и heap_blks_total, и через пару минут после старта фактическая скорость уже более-менее понятна. Дальше уже можно решить, ждать или отменить. Отмена срабатывает на любой фазе, в том числе когда таблица уже встала под блокировкой; цена одна — вся проделанная работа пропадает. Час копирования означает час изменений, которые придётся догонять под конец.
Есть ли долгие транзакции по этой таблице? Они и ожидание блокировки удлиняют, и добавляют изменения, которые применятся уже под ней.
И самое практичное — фаза catch-up в pg_stat_progress_repack. Она последняя перед захватом блокировки, и её длительность коррелирует с окном: у меня 240, 389 и 134 секунды до захвата дали окна 42.6, 198.7 и 29.5 секунды (последняя пара — прогон с двумя клиентами, его в таблице выше нет). Стоит учесть, что во время самого окна фаза так и остаётся catch-up, поэтому итоговое значение в progress-view будет больше — ориентироваться надо на момент, когда таблица встала. Отношение гуляет втрое, так что это оценка порядка величины, а не прогноз.
Общее направление такое: чем больше таблица и интенсивнее запись, тем длиннее окно. Но конкретную цифру не определить: при одной и той же нагрузке у меня выходило и 43 секунды, и 3 минуты.
Что это значит для DBA
REPACK не стал полной заменой существующим инструментам, и ниша у альтернатив по-прежнему большая.
pg_repack нужен на всём, что младше версии 19, а это подавляющая часть парка на ближайшие годы. Но и на версии 19 он никуда не девается: партиции в конкурентном режиме, параллельная сборка индексов, перенос в другое табличное пространство и, главное, предсказуемо короткое окно на горячей таблице. Кстати, на версии 19 pg_repack собирается и работает, хотя в документации поддержка заявлена по восемнадцатую версию включительно.
pgcompacttable остаётся единственным вариантом, когда на диске нет второго объёма под копию. Второй его козыри — отсутствие блокировок как класса и возможность регулировать собственную нагрузку через --delay-ratio. Правда, по суммарному воздействию на систему он самый тяжёлый из трёх: сутки работы против получаса у остальных.
Где применим сам REPACK? Если допустимая недоступность таблицы измеряется минутами — ночное окно, относительно тёплая таблица, умеренная запись — он подходит уже сейчас и выигрывает по всему, что касается эксплуатации: ничего не нужно ставить, согласовывать и перенастраивать, обеспечена штатная наблюдаемость, делегирование без суперпользователя. Если счёт идёт на секунды, как это обычно и бывает в highload, измеренные 198.7 секунды говорят сами за себя: до доработки механизма на таких таблицах использовать его пока рано.
Перепаковка в ядре нужна была давно, и хорошо, что она появилась. Это хорошая отправная точка, которая (я уверен) получит развитие в следующих версиях. Пока имеет смысл держать под рукой все три инструмента.
Замеры сделаны на стенде 4 vCPU / 8 ГБ RAM / SSD с PostgreSQL 19beta2: таблица 9.7 ГБ, 40 млн строк, нагрузка pgbench TPC-B.