Назад к блогу

PostgreSQL 19: онлайн-перепаковка таблиц без расширений

PostgreSQL 19: онлайн-перепаковка таблиц без расширений

В PostgreSQL 19 наконец-то появляется встроенная онлайн-перепаковка таблиц — команда `REPACK (CONCURRENTLY)`, не требующая установки pg_repack и прочих расширений. Разбираем, как она обходит блокировки, которые делают `CLUSTER` и `VACUUM FULL` неприменимыми на нагруженных системах, и какие ограничения всё ещё придётся учитывать.

В PostgreSQL 19 появляется команда REPACK — перепаковка таблиц силами самого сервера, без внешних расширений. Разбираем, чем она отличается от CLUSTER и VACUUM FULL, как устроена конкурентная перепаковка и какие у неё пределы.

Зачем нужен REPACK и чем он отличается от CLUSTER и VACUUM FULL

CLUSTER и VACUUM FULL держат AccessExclusiveLock на всю операцию: пока идёт перепаковка, таблица недоступна ни на чтение, ни на запись. REPACK вводит режим CONCURRENTLY — его указывают в команде как REPACK (CONCURRENTLY), и он позволяет перепаковывать таблицу, не блокируя её на всё время работы. В этом режиме вместо AccessExclusiveLock на всю операцию берётся ShareUpdateExclusiveLock на большую часть работы, а AccessExclusiveLock — только в конце, для подмены файлов.

У конкурентного режима есть требования к таблице. Ей нужен индекс идентичности реплики. Отложенный (deferrable) первичный ключ не считается, а REPLICA IDENTITY FULL и NOTHING не поддерживаются. Если индекс идентичности не найден, команда завершается ошибкой с указанием, что у отношения нет identity index; для отложенного первичного ключа выдаётся отдельное сообщение с подсказкой назначить другой индекс как replica identity.

Конкурентный режим не поддерживается для секционированных таблиц — нужно указывать конкретную таблицу и запускать на отдельных секциях. Также требуется явное имя таблицы. Наконец, запуск внутри транзакционного блока запрещён: настройка логического декодирования может бесконечно ждать завершения собственного XID, поэтому команда проверяет, что не находится в транзакции, и отказывается работать, если это не так.

Архитектура: ведущий процесс, воркер и логическое декодирование

Работу ведут два процесса. Ведущий backend становится лидером locking group и запускает воркер декодирования. Воркер при старте присоединяется к группе лидера по его номеру процесса и pid; если лидер уже не работает, воркер просто завершается.

Replication slot создаёт сам воркер: имя формируется как pg_repack_<PID>, слот делается временным и удаляется при ошибке. Начальный снапшот воркер получает через построитель снапшота логического декодирования, сериализует его и записывает в файл в разделяемой памяти, после чего увеличивает счётчик экспортированных снапшотов и сигналит условной переменной. Лидер ждёт готовности снапшота и забирает его.

Для ошибок воркер настраивает очередь сообщений в разделяемой памяти и перенаправляет туда свой вывод; лидер подключает свой конец очереди и обрабатывает сообщения в цикле ожидания воркера, вызывая CHECK_FOR_INTERRUPTS().

Фаза 1: подготовка и создание новой копии таблицы

Новая таблица создаётся в каталоге с параметрами хранения (reloptions), скопированными из старой. Имя формируется как pg_temp_<OID старой таблицы>, namespace берётся у старой таблицы (или pg_temp для временных), mapped-флаг наследуется. TOAST-таблица создаётся только если у старой таблицы есть ссылка на неё, с сохранением reloptions старой TOAST-таблицы.

Индексы в конкурентном режиме не копируются, а строятся заново. Новые индексы берутся под AccessExclusiveLock, а существующие — под ShareUpdateExclusiveLock, чего достаточно, чтобы помешать другим менять их.

На этой фазе на старую таблицу берётся только тот режим блокировки, который передан в функцию. На её TOAST-таблицу в неконкурентном случае берётся тот же режим через LockRelationOid, а в конкурентном предполагается, что блокировка уже удерживается.

Фаза 2: копирование данных и параллельная запись изменений

REPACK обрабатывает каждую таблицу в отдельной транзакции — иначе одновременная блокировка всех таблиц легко приводит к взаимоблокировкам. Параллельно с копированием существующих строк воркер декодирует WAL и сохраняет изменения в файл, а затем функция применения читает этот файл и переносит INSERT/UPDATE/DELETE в новую копию.

Для INSERT кортеж вставляется в новую таблицу так, чтобы он не декодировался повторно, — иначе вставка в новую копию сама попала бы в WAL и была бы декодирована ещё раз, — и обновляются индексы. Для UPDATE и DELETE сначала по ключу репликации находится целевая строка в новой таблице, затем выполняется обновление или удаление — тоже без повторного логического декодирования. Кортежи из файла восстанавливаются: читается длина и данные, обнуляются значения удалённых столбцов, восстанавливаются внешние (TOAST) атрибуты, хранящиеся отдельными чанками.

Фаза 2: как воркер находит целевую строку и обрабатывает UPDATE и DELETE

Индекс идентичности реплики получается через RelationGetReplicaIndex и сохраняется в контексте изменений. При обработке UPDATE, если есть OLD-кортеж, ключ берётся из него, иначе — из нового кортежа. По этому ключу ищется целевая строка в новой таблице; если она не найдена, возникает ошибка «could not find target tuple». Перед обновлением корректируются TOAST-указатели, после чего обновление выполняется по физическому идентификатору найденной строки.

Для DELETE поиск идёт по ключу из нового кортежа, и при отсутствии строки возникает та же ошибка; если строка найдена, она удаляется по своему идентификатору. Сравнение ключей идентичности выполняет отдельная функция, а поиск — функция поиска целевого кортежа.

Фаза 2: удержание WAL и взаимодействие с VACUUM

Временный слот логической репликации удерживает WAL от переработки, но REPACK намеренно продвигает позиции слота: при пересечении границы WAL-сегмента он сообщает декодированию о новом подтверждённом LSN и подтверждает полученную позицию, при этом xmin слота не трогается. Так WAL может перерабатываться, но старый снимок, нужный для онлайн-перепаковки, не даёт VACUUM удалять старые версии строк.

Поэтому VACUUM во время перепаковки продолжает работать, но не может удалить эти версии, а ANALYZE, в отличие от него, продолжает обновлять статистику планировщика. Пока REPACK идёт, счётчик n_dead_tup растёт на занятых таблицах во всех базах кластера — и беспокоиться о нём не нужно до завершения операции.

Фаза 3: финальный swap и его блокировки

Подмена физической идентичности двух отношений сохраняет их логические идентичности. Функция открывает pg_class под RowExclusiveLock и берёт копии обоих кортежей. Для обычных отношений местами меняются номер файла, табличное пространство, метод доступа и persistence, а при подмене TOAST по ссылкам — ещё и ссылка на TOAST-таблицу в pg_class. Для mapped-отношений вместо правки pg_class старые номера файлов получаются из relation mapping, новые туда же отправляются, а OID второго отношения добавляется в список mapped-таблиц.

Затем pg_class обновляется, если целью не является сам pg_class; иначе только инвалидируется relcache. Если метод доступа изменился, правятся зависимости от метода доступа. При подмене TOAST по содержимому функция рекурсивно вызывается для TOAST-таблиц.

Фаза 3: что видит клиент в момент swap

После подмены уже открытая транзакция со старым снапшотом видит пустую таблицу: все строки новой копии записаны транзакцией REPACK, которую этот снапшот считает будущей. Долгий экспорт в REPEATABLE READ, читающий таблицу только после подмены, получает пустой результат — и ничто не сообщает ему причину.

Это относится к REPACK (CONCURRENTLY) и pg_squeeze. pg_repack ведёт себя иначе: перед копированием он ждёт все открытые транзакции, включая read-only, поэтому в примере просто дождался коммита старой транзакции. Но это лишь сужает окно: если снять снапшот после ожидания, во время выполняющегося INSERT ... SELECT, то после подмены такой снапшот тоже увидит 0 строк — копия выполняется одним оператором в одной транзакции, ещё не закоммиченной на момент снятия снапшота. Аномалия для клиента — молчаливый пустой результат без объяснения.

Дополнительно во время подмены возможны блокировки: REPACK ждёт за отчётом, а все новые запросы ждут за REPACK. При lock_timeout = 3s REPACK сдаётся через 3 секунды с ошибкой отмены по таймауту блокировки, и запуск отбрасывается.

Точные условия и пороги: replica identity и требования к индексам

Для конкурентного режима таблице нужен индекс идентичности — первичный ключ или индекс, назначенный через REPLICA IDENTITY USING INDEX; REPLICA IDENTITY NOTHING и FULL не поддерживаются, а отложенный первичный ключ не считается подходящим.

Проверка индексов сканирует pg_index по таблице и собирает имена невалидных индексов; если такие есть, выбрасывается ошибка с подсказкой использовать DROP INDEX или REINDEX. Проверка требований конкурентного режима убеждается, что уровень WAL не ниже replica, что таблица использует heap-метод доступа, не является системным каталогом, пользовательской каталог-таблицей, TOAST-таблицей или материализованным представлением, что её persistence — permanent, а режим replica identity не равен NOTHING или FULL. Затем получается индекс идентичности, и если его нет, отдельно обрабатывается случай отложенного первичного ключа. При невыполнении любого условия выбрасывается ошибка с соответствующим кодом и сообщением, а при отсутствии индекса идентичности — ошибка с деталью, что у отношения нет identity index.

Точные условия и пороги: память и лимиты на хранение изменений

Память под накопленные конкурентные изменения хранит по одной записи на изменение — это combo command IDs. Структура удваивается при заполнении, начиная со 100 записей. При достижении фиксированного предела следующее удвоение запрашивает больше, чем PostgreSQL позволяет в одной аллокации, что даёт ошибку ERROR: invalid memory alloc request size 1677721600. Если памяти меньше, чем нужно примерно для 105 млн изменений (около 5 GB), на Linux с default overcommit или в cgroup-лимите контейнера происходит OOM kill и crash restart, а при vm.overcommit_memory=2 — ошибка «out of memory».

Пограничные случаи: одновременные кандидаты и конкурентные DDL

Поскольку в конкурентном режиме REPACK держит ShareUpdateExclusiveLock на большую часть работы и берёт AccessExclusiveLock только в конце, DDL вроде TRUNCATE, ALTER TABLE или DROP, требующий AccessExclusiveLock, будет ждать завершения основной фазы и может выполниться лишь на коротком финальном этапе. В неконкурентном режиме REPACK сразу держит AccessExclusiveLock всю операцию, поэтому конфликтующий DDL просто блокируется до конца.

Если таблицу удалили или заменили между транзакциями, REPACK молча пропускает её и переходит к следующей. Для TRUNCATE во время декодирования изменений в конкурентном режиме предполагается, что этого не произойдёт: TRUNCATE берёт AccessExclusiveLock и не должен случиться во время REPACK (CONCURRENTLY). В конкурентном режиме REPACK также блокирует TOAST-таблицу, чтобы её номер файла не изменился, например из-за VACUUM FULL или REPACK над ней.

Пограничные случаи: сетевой разрыв, потеря воркера и возврат старого узла

Ведущий процесс узнаёт о смерти воркера через очередь сообщений об ошибках: он вызывает CHECK_FOR_INTERRUPTS(), который обрабатывает сигнал, устанавливаемый воркером при завершении. Воркер при этом сначала отсоединяется от разделяемой памяти — это отсоединяет и очередь, — и только затем посылает сигнал, чтобы ведущий при чтении очереди увидел её отсоединённой. Если воркер не успел присоединиться к очереди, ведущий сам сообщает об ошибке «REPACK decoding worker failed to start»; если воркер присоединился и послал ErrorResponse, ведущий перебрасывает эту ошибку. При аварийном завершении воркера ведущий при чтении очереди получает SHM_MQ_DETACHED и сообщает «lost connection to REPACK decoding worker».

Слот логического декодирования создаётся как временный, поэтому при ошибке он автоматически удаляется, а разделяемая память освобождается при остановке воркера. Слот не сохраняется, поэтому при сбое вся работа начинается заново — незавершённые изменения не фиксируются, и консистентность гарантируется.

Пограничные случаи: партиционированные таблицы и системные каталоги

Когда REPACK или CLUSTER применяется к партиционированной таблице, возвращается список листовых партиций для обработки; родительская таблица уже открыта вызывающим и здесь закрывается с освобождением блокировки. Если задан USING INDEX, но имя индекса не указано, для CLUSTER выдаётся ошибка об отсутствии ранее кластеризованного индекса, а для REPACK — ошибка о невозможности выполнить команду на партиционированной таблице USING INDEX без имени индекса. Иначе индекс определяется и проверяется на пригодность для кластеризации.

Дочерние отношения находятся через find_all_inheritors с NoLock: при работе по индексу берутся только листовые индексы, иначе — только листовые таблицы. Для каждой партиции проверяется право, и если пользователь не имеет прав на листовую партицию, она пропускается. Конкурентный режим для партиционированных таблиц не поддерживается — в комментарии прямо сказано, что он пока не реализован.

Системные каталоги запрещены для обмена TOAST-файлами по ссылкам, чтобы не менять каталог, от которого зависят сами изменения зависимостей.

Почему сделано именно так: компромиссы дизайна

Логическое декодирование выбрано потому, что даёт первые два шага бесплатно: создаётся временный слот репликации, который отдаёт снапшот. Слот делается временным, чтобы удаляться при ошибке, а имя уникально по PID: backend не может выполнять несколько команд REPACK одновременно, поэтому PID достаточно для уникальности имени.

Это накладывает требования к replica identity: таблица не должна иметь REPLICA IDENTITY NOTHING или FULL, и должен существовать индекс идентичности, иначе операция запрещена. При REPLICA IDENTITY NOTHING WAL не содержит старого кортежа, поэтому сопоставить изменение со строкой невозможно, а FULL пока не поддерживается.

Компромисс по памяти и задержке виден в том, что воркер декодирует WAL и складывает изменения в файл, а не в память. При этом REPACK освобождает WAL по мере обработки, в отличие от pg_squeeze.

Подмена делается в одной короткой транзакции, потому что только в этот момент таблица недоступна: в самом конце REPACK берёт ACCESS EXCLUSIVE, применяет последние изменения, меняет файлы и коммитит. Всё — от снапшота до подмены — это одна транзакция.

Как сравнивали и что получилось

Стенд и методология

Измерения проводились на двух типах железа: облачные VM у Hetzner и облачная VM, а меньшие демонстрации — в Docker на ноутбуке Apple M-класса. Тестовая таблица: 90 миллионов строк, три индекса, затем удалена каждая третья строка и выполнен обычный VACUUM — 60 миллионов живых строк в 27.8 GB (22 GB кучи). Все тесты REPACK выполнялись на 19beta4, сравнения расширений — на 18.6; сборки для большой таблицы — pg_repack master 8212031 и pg_squeeze d7eded0. На облачной VM пропускная способность диска составляла 240 MiB/s на чтение и 240 MiB/s на запись.

Результаты

СценарийВремяПолная остановка писателей
28 GB, 500 updates/s499 s13 s
100 GB, 500 updates/s2 273 s231 s
28 GB, нагрузка «as many as the disk would take»1 209 s432 s
pg_repack, 500 updates/s604 sникогда

При том же потоке 500 updates/s pg_repack задерживал 31% обновлений дольше секунды против 15% у REPACK. pg_repack был убит после 83 минут catch-up, когда его лог показывал 760 rows/s применённых против 2 200 rows/s записанных.

По памяти: при лимите 1 GB бэкенд был убит OOM-killer с перезапуском кластера примерно на 18,0M изменений; при 4 GB — тоже OOM-killed примерно на 84,3M; при 8 GB получена ошибка invalid memory alloc request size 1677721600 на 104 820 740 изменениях. Достижение 105 миллионов изменений требует около 5 GB памяти.

Формула оценки времени до лимита

Оценка допустимой скорости изменений на таблице:

max (updates + deletes) per second ≈ 29,000 / REPACK duration in hours

В основе — фиксированный предел: REPACK (CONCURRENTLY) не может завершиться, если во время прогона обновлено или удалено более 105 млн строк, а около 105 млн изменений требуют примерно 5 GB памяти. Деление бюджета на длительность прогона в секундах даёт допустимую скорость. Считать надо именно строки, а не операторы: один UPDATE на 50 строк — это 50, обычные вставки не считаются.

Для конкретной таблицы берутся две выборки счётчиков из pg_stat_user_tables в одной сессии за репрезентативный период, затем вычисляется changes_per_sec как прирост n_tup_upd + n_tup_del, делённый на прошедшее время, и hours_until_ceiling как 104857600.0 / changes_per_sec / 3600. Полученное значение показывает, как долго REPACK может идти на этой таблице при такой скорости.

Как повторить замер

Первый сэмпл сохраняется во временную таблицу chg_sample с полями relid, changes (сумма n_tup_upd и n_tup_del) и sampled_at (текущее время). Затем нужно подождать, после чего второй запрос соединяет текущее состояние pg_stat_user_tables с chg_sample по relid и вычисляет changes_per_sec и hours_until_ceiling.

Что цифры не показывают

Усреднение скрывает реальный риск: одна ночная задача, обновляющая 120 миллионов строк, сама по себе завершает REPACK. Влияние на другие базы различается: слоты принадлежат всему серверу, а не одной базе, поэтому REPACK и pg_squeeze удерживали таблицы во всех базах кластера, тогда как pg_repack — только в перепаковываемой базе.

Что из этого следует на практике

Конкурентная перепаковка доступна только для таблиц с индексом идентичности реплики — первичным ключом или индексом, назначенным через REPLICA IDENTITY USING INDEX. Отложенный первичный ключ, REPLICA IDENTITY FULL и NOTHING не подходят, а секционированные таблицы нужно перепаковывать по секциям.

Главный предел — объём изменений за прогон: примерно 105 млн обновлённых или удалённых строк требуют около 5 GB памяти, и после этого прогон падает. Чем дольше идёт перепаковка, тем меньше строк в секунду можно обновлять и удалять. Оценить запас для конкретной таблицы можно по счётчикам pg_stat_user_tables, но усреднение скрывает пики: одна ночная задача на 120 млн строк завершит REPACK сама.

Во время перепаковки VACUUM не может удалять старые версии строк, потому что нужен старый снимок, поэтому n_dead_tup растёт на занятых таблицах во всех базах кластера — это ожидаемо до завершения операции. Клиенты со старыми снапшотами после подмены увидят пустую таблицу без объяснения причины, а при lock_timeout REPACK может сдаться на финальной фазе, и запуск придётся повторять.

Где смотреть в коде

Источники

Похожее