В PostgreSQL 19 появляется команда REPACK — перепаковка таблиц силами самого сервера, без внешних расширений. Разбираем, чем она отличается от CLUSTER и VACUUM FULLкоманда, которая переписывает таблицу целиком и возвращает освободившееся место файловой системе, как устроена конкурентная перепаковка и какие у неё пределы.
Зачем нужен REPACK и чем он отличается от CLUSTER и VACUUM FULL
CLUSTER и VACUUM FULL держат AccessExclusiveLockблокировка, несовместимая с любым другим доступом к таблице на всю операцию: пока идёт перепаковка, таблица недоступна ни на чтение, ни на запись. REPACK вводит режим CONCURRENTLY — его указывают в команде как REPACK (CONCURRENTLY), и он позволяет перепаковывать таблицу, не блокируя её на всё время работы. В этом режиме вместо AccessExclusiveLock на всю операцию берётся ShareUpdateExclusiveLockблокировка, совместимая с чтением и записью, но не с другим обслуживанием таблицы на большую часть работы, а AccessExclusiveLock — только в конце, для подмены файлов.
У конкурентного режима есть требования к таблице. Ей нужен индекс идентичности репликииндекс, по которому можно однозначно найти строку: первичный ключ или индекс, назначенный через REPLICA IDENTITY USING INDEX. Отложенный (deferrable) первичный ключ не считается, а REPLICA IDENTITY FULL и NOTHING не поддерживаются. Если индекс идентичности не найден, команда завершается ошибкой с указанием, что у отношения нет identity index; для отложенного первичного ключа выдаётся отдельное сообщение с подсказкой назначить другой индекс как replica identity.
Конкурентный режим не поддерживается для секционированных таблиц — нужно указывать конкретную таблицу и запускать на отдельных секциях. Также требуется явное имя таблицы. Наконец, запуск внутри транзакционного блока запрещён: настройка логического декодирования может бесконечно ждать завершения собственного XID, поэтому команда проверяет, что не находится в транзакции, и отказывается работать, если это не так.
Архитектура: ведущий процесс, воркер и логическое декодирование
Работу ведут два процесса. Ведущий backend становится лидером locking groupпроцессом, который владеет блокировками группы и распределяет их между её участниками и запускает воркер декодированияфоновый процесс, который читает поток изменений таблицы из WAL и сохраняет их, чтобы ведущий процесс мог применить эти изменения к новой копии таблицы. Воркер при старте присоединяется к группе лидера по его номеру процесса и pid; если лидер уже не работает, воркер просто завершается.
Replication slotслот логической репликации, удерживающий позицию в WAL и отдающий поток изменений создаёт сам воркер: имя формируется как 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, а через отдельное отображение «OID → номер файла» вместо правки pg_class старые номера файлов получаются из relation mapping, новые туда же отправляются, а OID второго отношения добавляется в список mapped-таблиц.
Затем pg_class обновляется, если целью не является сам pg_class; иначе только инвалидируется relcacheкэш описаний отношений в памяти backend'а, который сбрасывается, чтобы изменения каталога стали видны. Если метод доступа изменился, правятся зависимости от метода доступа. При подмене 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-метод доступаспособ хранения и чтения строк таблицы, при котором данные лежат в обычных кучах-файлах PostgreSQL, не является системным каталогом, пользовательской каталог-таблицей, 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журнал предзаписи, в который PostgreSQL пишет изменения до их применения к файлам данных не содержит старого кортежа, поэтому сопоставить изменение со строкой невозможно, а 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/s | 499 s | 13 s |
| 100 GB, 500 updates/s | 2 273 s | 231 s |
| 28 GB, нагрузка «as many as the disk would take» | 1 209 s | 432 s |
| pg_repack, 500 updates/s | 604 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 может сдаться на финальной фазе, и запуск придётся повторять.