Бэкфилл — заполнение нового столбца значениями во всех строках большой таблицы — выглядит как одна операция UPDATE, но её последствия для диска определяются не логикой запроса, а механикой версий строк и индексов. Ниже разобрано, что именно раздувается, при каких условиях обновление остаётся на своей странице, почему порядок обработки строк важнее размера батча и какие приёмы действительно уменьшают рост файлов.
Что именно раздувается: heap, индексы, WAL
Рост измеряется по трём независимым величинам: heap (основной файл таблицы), indexes (суммарный размер индексов) и WAL (журнал предзаписи). Для сценария с диапазонными батчами при fillfactorпроцент от 10 до 100, задающий, до какой доли страницы INSERT-ы заполняют страницы таблицы, а остаток резервируется под обновления строк на той же странице 100 получено 548 MB heap, 70 MB indexes и 876 MB WAL. При fillfactor 90 с VACUUMкоманда, удаляющая мёртвые версии строк в таблицах и индексах и помечающая освобождённое пространство доступным для будущего переиспользования после каждого батча — 312 MB heap, 36 MB indexes и 455 MB WAL. Для чередования по id % 10 — 309 MB heap, 61 MB indexes и 690 MB WAL, при этом из 1 000 000 обновлений HOT-обновленийобновление, при котором новая версия строки помещается на ту же страницу, что и старая, и новые записи в индексах не требуются было 0 (hot_pct 0.0). Side-tableотдельная таблица, в которую выносятся обновляемые столбцы, чтобы не трогать большую таблицу выросла до 69.5 MB, а tickets сохранил 269.3 MB heap и 35.6 MB indexes, и ни одна другая стратегия не записала меньше WAL, чем 138.0 MB в этом запуске. Hybrid-сценарийчередование остатков внутри одного среза таблицы: обход десяти диапазонов по 100 000 id, внутри каждого выполняются все десять остатков, с VACUUM после каждого прохода (вставка через insert ... select в новую tickets_new с последующим созданием первичного ключа и обоих индексов) записал 330.7 MB WAL и дал 314.7 MB таблицы и индексов, при этом старые heap и indexes оставались на диске до удаления.
Почему heap содержит живую и мёртвую копию каждой строки
Обычный UPDATE не меняет строку на месте: Postgres пишет полную новую версию строки, а старую оставляет до тех пор, пока VACUUM или pruningочистка страницы, выполняемая при обращении к ней запроса, когда становится видно, что ни один снимок не нуждается в старых версиях не покажет, что ни один снимок её больше не видит. Поэтому после бэкфилла в heap лежат живая и мёртвая копия каждой строки. «Удвоение» в заголовке — это удвоение числа версий строк, а не обязательно удвоение размера файла: в выводе heap 548 MB при 70 MB индексов и 876 MB WAL.
Разбиение работы на десять операторов само по себе ничего не дало для heap, так как между пакетами ничто не освобождало мёртвые версии, и один UPDATE по всем миллиону строк дал тот же размер heap. VACUUM после каждого пакета возвращает место мёртвых версий в free space mapкарту свободного места, по которой Postgres выбирает, куда положить новую версию, и новые версии следующего пакета ложатся в это место, поэтому heap почти не растёт. Обновления, оставляющие новую версию на той же странице, при этом не учащаются: освобождённое VACUUM место находится на страницах, строки которых уже помечены, а следующий пакет переписывает строки на ещё плотно заполненных страницах, и новые версии ложатся как обычные обновления с записью в каждый индекс.
Механика HOT: когда версия остаётся на странице
HOT-обновление возможно только при выполнении двух условий: новая версия помещается на ту же страницу и UPDATE не меняет ни одного столбца, от которого зависит индекс.
Проверка размера в heap_update()
Сначала вычисляется свободное место на странице: pagefree = PageGetHeapFreeSpace(page), а требуемый размер новой версии — newtupsize = MAXALIGN(newtup->t_len). Если требуется TOASTвынесение больших значений во внешнее хранилище или newtupsize > pagefree, новая версия не помещается на ту же страницу, и её запрашивают на другой странице. Если же условие не выполняется, новая версия остаётся на той же странице. Для версии, остающейся на своей странице, сравнение идёт только со свободным местом страницы, а резерв fillfactor (saveFreeSpace = RelationGetTargetPageFreeSpace(relation, HEAP_DEFAULT_FILLFACTOR), targetFreeSpace = len + saveFreeSpace) применяется к версии, переезжающей на другую страницу. Когда новая версия уходит на другую страницу, старая страница помечается как кандидат на prune/defrag, а HOT-обновление невозможно, поскольку оно допускается только при размещении новой версии на той же странице и отсутствии пересечения изменённых атрибутов с hot_attrsнабором столбцов, от которых зависят индексы таблицы: изменение любого из них требует новых записей в индексах и запрещает HOT.
Почему версия на другой странице не может быть HOT
HOT рассматривается только когда новая версия идёт на ту же страницу. Если места на странице нет, новая версия пишется на другую страницу, и тогда HOT невозможен: в этом случае старая страница лишь помечается как кандидат на очистку.
При HOT-обновлении старая версия помечается как HOT-updated, а новая как heap-only, и в t_ctid старой версии записывается адрес новой. Цепочка t_ctid позволяет при сканировании переходить от одной версии строки к следующей, пока не будет найдена видимая. Поскольку новая версия на другой странице не является heap-only, для неё требуются новые записи в каждом индексе, тогда как при HOT новые записи в индексах не нужны.
Как fillfactor влияет на запас свободного места
fillfactor задаёт, сколько места остаётся под обновления. При вставке желаемый запас вычисляется как saveFreeSpace = RelationGetTargetPageFreeSpace(relation, HEAP_DEFAULT_FILLFACTOR), и для кортежа, переезжающего на другую страницу, требуется его собственная длина плюс этот резерв: targetFreeSpace = len + saveFreeSpace. Поэтому при цели 90 строки, приходящие извне, не попадают в маленькие щели, открытые VACUUM, и щели остаются свободными для HOT-обновлений строк, живущих на этих страницах; при цели 100 резерв равен нулю, и ушедшая со своей страницы строка может упасть в любую подходящую щель.
ALTER TABLE ... SET (fillfactor = 90) меняет параметр хранения, но оставляет каждую существующую страницу как была, поэтому без перезаписи таблицы эффекта загрузки с fillfactor 90 не будет. Чтобы получить тот же результат, нужна переупаковка: на 18.6 VACUUM FULL после ALTER упаковал каждую страницу на 90, и последующий backfill дал те же 961 194 HOT-обновления, что и таблица, созданная с 90, ценой ACCESS EXCLUSIVE-блокировки на всю перезапись; в PostgreSQL 19 добавлен REPACK, и на 19 Beta 4 его конкурентная форма была запущена после такого же ALTER.
Что проверяет heap_update() при решении об индексах
heap_update() решает, какие индексы обновлять, по итоговому признаку HOT-обновления: если обновление HOT, то при изменении колонок, входящих в «суммирующие» индексы, обновляются только они, иначе индексы не обновляются вовсе; если обновление не HOT, обновляются все индексы. Набор изменённых колонок вычисляется сравнением старого и нового кортежей по «интересным» колонкам — объединению наборов столбцов, от которых зависят индексы таблицы, столбцов, входящих в суммирующие индексы (единственный такой метод в ядре — BRIN), столбцов, входящих в ключ реплика-идентичности, и столбцов, входящих в индекс реплика-идентичности, — и попутно выставляется признак внешнего хранения, если немодифицированный ключевой атрибут реплика-идентичности хранится внешне. Само сравнение значений выполняется побайтово: различающиеся NULL считаются неравными, оба NULL — равными, для системных колонок сравниваются OID. Если изменённые колонки не пересекаются с ключом реплика-идентичности, берётся более слабая блокировка строки, иначе — более строгая.
Почему индекс по обновляемому столбцу отключает HOT
HOT-обновление возможно, когда UPDATE не меняет ни одного столбца, на который ссылается индекс таблицы (не считая суммирующих индексов, единственный такой метод в ядре — BRIN), и на странице старой строки достаточно свободного места. Поэтому индекс по обновляемому столбцу делает его «индексируемым» и отключает HOT: изменение такого столбца даёт пересечение изменённых атрибутов с hot_attrs. В эксперименте частичный индекс create index tickets_review_idx on tickets (id) where confidence < 0.6; «ничего не хранит», пока все confidence равны NULL, но всё равно обнулил HOT, потому что Postgres считает столбец, названный в предикате индекса, индексируемым при решении о возможности HOT, а backfill меняет confidence в каждой строке. В результате с этим индексом прогон при fillfactor 90 не записал ни одного HOT-обновления.
Почему индексы ломают HOT
HOT-обновление возможно, когда UPDATE не меняет ни одного столбца, на который ссылается индекс таблицы, и на странице старой строки достаточно свободного места. Исключение — summarizing-индексиндекс, который хранит не отдельные значения строк, а сводку по группе страниц; в ядре PostgreSQL такой метод только один — BRIN. Поэтому индекс по обновляемому столбцу делает его «индексируемым» и отключает HOT: изменение такого столбца затрагивает столбец, от которого зависит индекс.
В эксперименте частичный индекс create index tickets_review_idx on tickets (id) where confidence < 0.6; «ничего не хранит», пока все confidence равны NULL, но всё равно обнулил HOT, потому что Postgres считает столбец, названный в предикате индекса, индексируемым при решении о возможности HOT, а backfill меняет confidence в каждой строке. В результате «With that index in place, the interleaved run at fillfactor 90 recorded no HOT updates at all.»
Что именно проверяет heap_update() при решении об индексах
heap_update() решает, какие индексы обновлять, по итоговому признаку HOT-обновления: если обновление HOT, то при summarized_update возвращается TU_Summarizing, иначе TU_None; если обновление не HOT, возвращается TU_All. Признак summarized_update означает, что были изменены колонки, входящие в «суммирующие» индексы (sum_attrs), а use_hot_update — что новую версию можно поместить на ту же страницу без новых записей во все индексы.
Набор изменённых колонок modified_attrs вычисляет HeapDetermineColumnsInfo(), сравнивая старый и новый кортежи по «интересным» колонкам (объединение hot_attrs, sum_attrs, key_attrs, id_attrs) и попутно выставляя id_has_external, если немодифицированный ключевой атрибут реплика-идентичности хранится внешне. Само сравнение значений выполняет heap_attr_equals(): при различающихся NULL — не равны, при обоих NULL — равны, для системных колонок сравниваются OID, иначе — побайтовое datumIsEqual. Если modified_attrs не пересекается с key_attrs, берётся более слабая блокировка LockTupleNoKeyExclusive и key_intact = true, иначе LockTupleExclusive и key_intact = false.
Почему частичный индекс без строк всё равно обнулил HOT
Индекс создаётся как частичный: create index tickets_review_idx on tickets (id) where confidence < 0.6; — то есть он строится по столбцу id, но включает только строки, у которых confidence меньше 0.6. Пока у всех строк confidence равно NULL, ни одна строка не попадает в индекс, и он «holds nothing». Тем не менее Postgres при решении, можно ли сделать обновление HOT, считает столбец, названный в предикате индекса, индексируемым столбцом: «Postgres counts a column named in an index predicate as an indexed column when it decides whether an update can be HOT». Поскольку backfill меняет confidence в каждой строке, каждое такое обновление затрагивает столбец, который считается индексируемым, и HOT становится невозможным. Поэтому в interleaved-прогоне с этим индексом n_tup_hot_upd равно 0, хотя сам индекс не содержит ни одной строки.
Порядок обновления и распределение по страницам
При загрузке в порядке id страница содержит непрерывный участок последовательных id, поэтому одно диапазонное обновление просит страницу принять новые версии сразу для всех её строк, а запас в десятую часть страницы вмещает лишь несколько из них — эти несколько и становятся HOT. Остальные строки покидают страницу так же, как при fillfactor 100, добавляя запись в каждый индекс, а следующий VACUUM освобождает их старое место слишком поздно, потому что следующий диапазон живёт на других страницах.
При чередовании по id % 10 каждый остаток берёт по одной строке из десяти с каждой страницы; эта десятая часть в основном помещается в запас, и к моменту прихода следующего остатка VACUUM уже освободил старые версии предыдущего, так что место на той же странице возвращается. Поэтому на 18.6 из 1 000 000 обновлений HOT-обновлениями были 961 194, тогда как при диапазонах — только 115 059 (11.5%). При fillfactor 100 то же чередование достигло лишь 20.6% HOT, то есть запас всё же важен.
Запрос с order by id limit 100000 for update skip locked дал тот же профиль HOT, что и range-батчи, потому что он выдаёт непрерывный блок идентификаторов, а на этой таблице непрерывный блок id соответствует непрерывному блоку страниц. Это показывает, что порядок обработки строк определяет, будут ли обновления HOT. При fillfactor 100 то же чередование достигло лишь 20,6% HOT и позволило индексам вырасти до 52,5 МБ, поэтому резерв важен. Долю HOT можно прочитать в pg_stat_user_tables сразу после первого батча, пока смена плана ещё дёшева.
VACUUM между батчами и поведение под чекпоинтами
При обычном VACUUM между батчами освобождённое место возвращается в free space map, и новые версии строк следующего батча занимают это место, поэтому heap почти не растёт: «A plain VACUUM tickets after every range batch at fillfactor 100 kept the heap to 306.0 MB. After the first batch, each batch's new versions went into the space the previous batch's dead versions had left, which VACUUM had just handed back to the free space map, so the heap hardly grew from there on.» Это работает потому, что стандартный VACUUM «removes dead row versions in tables and indexes and marks the space available for future reuse», но «will not return the space to the operating system». Однако индексы всё равно растут, так как «a plain VACUUM does not shrink a B-tree index file», и освобождённое в индексе место остаётся выделенным. Поэтому утверждение про «heap in that output holds a live and a dead copy of every row» относится к случаю без VACUUM между батчами: «Splitting the work into ten statements did nothing for the heap on its own, since nothing between the batches reclaimed the dead versions». При VACUUM между батчами место мёртвых версий переиспользуется, и heap не требует второй копии таблицы.
Чекпоинты и full-page images
чекпоинтточка, после которой первое изменение любой страницы заставляет записать в WAL полную копию этой страницы вместе с включённым по умолчанию full_page_writesрежим записи полной копии страницы в WAL при первом её изменении после чекпоинта определяет рост WAL под нагрузкой. Поэтому при чекпоинте после каждых 100 000 строк WAL у range-батчей вырос лишь умеренно — с 850 MB до 1 025 MB, тогда как у interleaved-прогона он почти утроился до 3 022 MB, потому что interleaved-батч меняет каждую heap-страницу (на каждой странице лежат строки всех остатков), и каждый чекпоинт готовил свежий образ всего heap для следующего батча. Range-батчи платили меньше, так как каждый меняет лишь свою десятую часть heap плюс страницы, куда попадают перемещённые строки. Включение wal_compression = lz4 сжало эти образы, но не закрыло разрыв: range-батчи 753 MB, interleaved 1 011 MB.
Hybrid, который чередует остатки внутри одного среза таблицы, дешевле под чекпоинтами, потому что после чекпоинта между диапазонами свежий образ нужен только страницам следующего диапазона плюс немногим страницам, куда попадают перемещённые строки; его WAL вырос с 455.4 MB без чекпоинтов лишь до 544 MB при чекпоинте после каждого диапазона.
Пороги, счётчики и метрики
Из pg_stat_user_tables читают пять счётчиков: n_tup_upd (сколько строк обновлено), n_tup_hot_upd (сколько из этих обновлений прошло как HOT), hot_pct (их доля в процентах, вычисляемая как round(100.0 * n_tup_hot_upd / nullif(n_tup_upd, 0), 1)), n_dead_tup (мёртвые версии строк) и n_live_tup (живые строки). n_live_tup исключают из выводов, потому что это оценка, а pg_stat_reset() в начале каждого прогона обнулил её. Одной партии достаточно, чтобы различить два прогона, а на чередующемся прогоне доля HOT между партиями никогда не опускалась ниже итоговых 96.1%. Поскольку pg_stat_reset() очищает счётчики всей базы, на общем сервере нужен pg_stat_reset_single_table_counters('tickets'::regclass), сбрасывающий только эту таблицу, либо запись n_tup_upd и n_tup_hot_upd до первой партии с последующим вычитанием.
WAL измеряется через временную таблицу _lsn, в которую перед первым батчем записывается текущая позиция WAL функцией pg_current_wal_lsn(); после батча размер WAL вычисляется как разность текущей позиции WAL и сохранённого значения l из _lsn. Размеры heap и indexes берутся функциями pg_relation_size('tickets') и pg_indexes_size('tickets') и выводятся через pg_size_pretty.
Для interleaved-прогона приводятся значения: n_tup_upd = 1000000, n_tup_hot_upd = 961194, hot_pct = 96.1, n_dead_tup = 0, n_live_tup = 1000075. Для range-батчей на идентичной таблице тот же запрос вернул: n_tup_upd = 1000000, n_tup_hot_upd = 115059, hot_pct = 11.5, n_dead_tup = 0, n_live_tup = 683916. Отличие состоит в доле HOT-обновлений: 96.1% против 11.5%, при одинаковом n_tup_upd = 1000000 и n_dead_tup = 0.
Практические приёмы: fillfactor, side-table, REPACK
Side-table
Большая таблица tickets не трогается, а метки пишутся в отдельную таблицу labels с полями id bigint primary key, category text not null, confidence real not null, заполняемую через insert into labels select ... from tickets where id between :lo and :hi. При этом side-таблица выросла до 69.5 MB, а tickets сохранил 269.3 MB heap и 35.6 MB индексов, и этот прогон записал меньше всего WAL — 138.0 MB. Цена такого подхода — join на каждом чтении, которому нужна метка, и путь удаления на tickets, который должен доходить и до labels. В другом варианте, когда метка и confidence пишутся в tickets, а вероятности — в ticket_probs (id bigint primary key, probs real[] not null), HOT-доля вернулась, tickets остался на 96.1%, а side-таблица заняла 134.2 MB. Для сравнения: хранение вектора вероятностей в строке как real[] дало heap 387.6 MB и HOT 77.7%, а как 12-ключевой jsonb — heap 601.0 MB и HOT 50.0%.
REPACK (CONCURRENTLY)
REPACK (CONCURRENTLY)команда, которая перезаписывает таблицу, отслеживая изменения через логическое декодирование, и в отличие от VACUUM FULL не удерживает AccessExclusiveLock на всю перезапись перезаписывает таблицу, используя логическое декодирование для отслеживания изменений во время перезаписи, поэтому он требует, чтобы таблица поддерживала логическое декодирование. Это накладывает ограничения: операция поддерживается только для heap-таблиц, не для системных каталогов, пользовательских каталогов, TOAST-таблиц и материализованных представлений, а также только для постоянных (permanent) отношений. Кроме того, replica identityнабор столбцов, по которым логическое декодирование идентифицирует строку при изменениях не должна быть NOTHING или FULL, и должна существовать identity-индекс, иначе таблицу нельзя перепаковать конкурентно. Если primary key является deferrable и нет другого identity-индекса, операция не поддерживается.
При перезаписи новые кортежи вставляются через heap_insert с флагом HEAP_INSERT_NO_LOGICAL, чтобы пропустить логическое декодирование, поскольку после подмены файлов отношения старые данные больше не нужны подпискам. Это позволяет избежать длительной блокировки: в конкурентном режиме используется ShareUpdateExclusiveLock, а не AccessExclusiveLock, и запрещено выполнение внутри транзакционного блока, чтобы не ждать собственный XID. REPACK (CONCURRENTLY) не поддерживается для секционированных таблиц и требует явного имени таблицы.
Почему ALTER без rewrite не помогает
ALTER TABLE ... SET (fillfactor = 90) меняет параметр хранения, но оставляет уже существующие страницы как были, поэтому на таблице, загруженной при fillfactor 100, эффект почти отсутствует: «ALTER TABLE ... SET (fillfactor = 90) changes the storage parameter while leaving every existing page as it was». Без перезаписи страницы остаются плотно упакованными, и почти ни одна из первых правок не может остаться на своей странице, что и объясняет недостающую долю HOT: «With every page still packed, almost none of the first batch's updates were HOT, which accounts for the missing tenth». Рабочие альтернативы, дающие перезапись: VACUUM FULL на 18.6 и REPACK (CONCURRENTLY) на 19 Beta 4, которые подняли HOT до 96.1%: «a rewrite by VACUUM FULL on 18.6 or REPACK (CONCURRENTLY) on 19 Beta 4 took it to 96.1%». Также рабочим признан вариант с side-table: отдельная таблица labels, при котором tickets сохраняет 269.3 MB heap и 35.6 MB индексов, а WAL составил 138.0 MB — меньше, чем у других стратегий: «no other strategy we tried wrote less WAL than this run's 138.0 MB». Перезагрузка через insert ... select в новую tickets_new с последующим созданием первичного ключа и обоих индексов тоже описана как рабочий путь, но требует блокировки или воспроизведения записей и ACCESS EXCLUSIVE на переименование: «Writes that reach the old table during the copy have to be blocked or replayed before a rename swaps the two, and the rename itself takes an ACCESS EXCLUSIVE lock».
Пограничные случаи и отказ диска
На маленьком tmpfs-диске в 420 MB после загрузки таблицы с fillfactor 90 и всех её индексов было занято 337M и свободно 84M, а затем бэкфилл выполнялся как один UPDATE по каждой строке. Когда UPDATE упёрся в «could not extend file ... No space left on device», оператор откатился и ничего не пометил, но файловая система осталась заполненной на 100%. Последующий VACUUM вернул её только к 355M, что всё ещё больше исходных 337M. Причина в том, что откат транзакции не освобождает место: старые версии строк остаются в куче до VACUUM, а сам VACUUM не возвращает всё до исходного уровня. На свежем контейнере с тем же tablespace все десять батчей id % 10 завершились, с VACUUM после каждого, потому что каждый VACUUM отдавал место старых версий до того, как следующему батчу требовалось место, и прогон ни разу не запрашивал у диска место под вторую копию таблицы.
В примере с tmpfs файловая система показана как 420M всего, 349M занято, 72M доступно, 84% использования, смонтирована в /mnt/small. В тексте то же состояние описано как «420 MB tmpfs, which was 81% full after the load with 337M used and 84M available». Это иллюстрирует риск удвоения: обычный UPDATE пишет новую версию строки, оставляя старую до VACUUM, и для таблицы с тремя индексами добавляет по одной новой записи в каждый индекс на каждую строку, «unless the update qualifies as HOT».
Как сравнивали и что получилось
Стенд и методология
Измерения проводились на PostgreSQL Beta 4, вышедшей 24 сентября 2026 года. Схема таблицы tickets включает поля id bigint generated always as identity primary key, created_at timestamptz not null, customer_id int not null, subject text not null, body text not null, а также добавленные позже category text и confidence real. Данные загружаются в объёме 1 000 000 строк через generate_series(1, 1000000), с created_at в пределах 700 часов назад, customer_id в диапазоне до 50000 и subject из шести категорий. Конфигурация таблицы задаётся параметром fillfactor = 90, при этом в baseline-прогонах используется 100. Создаются индексы tickets_created_at_idx по created_at и tickets_customer_id_idx по customer_id, после чего выполняется vacuum (analyze) tickets.
Сравнивались три порядка батчей: range-батчи (диапазоны id), interleaving по остатку id % 10 и hybrid, который обходит десять диапазонов по 100 000 id и внутри каждого выполняет все десять остатков с VACUUM после каждого прохода. Range-батчи выполнялись при fillfactor 100 и fillfactor 90, а также с VACUUM после каждого батча и без него; interleaving использовал тот же SET-список, но заменял диапазон на остаток и запускался по одному разу для каждого r от 0 до 9 с VACUUM после каждого запуска. Чекпоинты задавались двумя способами: один чекпоинт перед первым батчем либо CHECKPOINT после каждых 100 000 строк, что для hybrid означало один чекпоинт на диапазон. Дополнительно в сессии включали wal_compression = lz4, что уменьшало full-page images.
Замер выполняется пакетами по 100 000 идентификаторов: каждый пакет обновляет строки в диапазоне id между :lo и :hi, присваивая category одно из 12 значений через хеш от subject и confidence через хеш от body, после чего выполняется vacuum tickets. Перед первым пакетом сохраняется позиция WAL командой create table _lsn as select pg_current_wal_lsn() l, а статистика сбрасывается через pg_stat_reset() в начале каждого прогона. После прогона размеры и WAL измеряются одним запросом: pg_size_pretty(pg_relation_size('tickets')) для heap, pg_size_pretty(pg_indexes_size('tickets')) для индексов и pg_size_pretty(pg_current_wal_lsn() - (select l from _lsn)) для WAL. Счётчики обновлений читаются из pg_stat_user_tables по relname = 'tickets'.
Результаты по сценариям
| Сценарий | fillfactor | HOT | heap | indexes | WAL |
|---|---|---|---|---|---|
| Range, no VACUUM | 100 | 0.0 | 548 MB | 70 MB | 876 MB |
| Range, VACUUM after each batch | 100 | 0.0 | 306 MB | 65 MB | — |
| Range, VACUUM after each batch | 90 | 11.5 | 338 MB | 64 MB | 850 MB |
| id % 10, VACUUM after each batch | 90 | 96.1 | 312 MB | 36 MB | 454 MB |
| Range, VACUUM and CHECKPOINT after each batch | 90 | 11.5 | 338 MB | 64 MB | 1 025 MB |
| id % 10, VACUUM and CHECKPOINT after each batch | 90 | 96.1 | 312 MB | 36 MB | 3 022 MB |
| Hybrid (id % 10 inside each id range), VACUUM after each pass, CHECKPOINT after each range | 90 | 96.1 | 312 MB | — | 544 MB |
Отдельно: hybrid записал 455.4 MB WAL без промежуточных контрольных точек, примерно столько же, сколько обычное чередование, а контрольная точка после каждого диапазона подняла это лишь до 544 MB. Side-table: side table выросла до 69.5 MB, tickets сохранил 269.3 MB heap и 35.6 MB indexes, и ни одна другая стратегия не записала меньше WAL, чем 138.0 MB в этом запуске.
Оговорки
n_live_tup нельзя использовать для выводов, так как это оценка, а pg_stat_reset() в начале каждого прогона обнулил его. pg_stat_reset() очищает счётчики для всей базы данных, поэтому на общем сервере нужно либо сбрасывать только для конкретной таблицы через pg_stat_reset_single_table_counters('tickets'::regclass), либо записывать n_tup_upd и n_tup_hot_upd до первого батча и вычитать после. Результаты на tmpfs 420M не масштабируются напрямую на большие диски, потому что эксперимент проводился на маленьком диске: таблица и индексы были помещены в tablespace на 420 MB tmpfs, который был заполнен на 81% после загрузки (337M использовано, 84M доступно), а затем выполнялся backfill одним UPDATE по всем строкам. После отката утверждения файловая система осталась заполненной на 100%, а VACUUM вернул только до 355M, что всё ещё больше 337M до попытки.
Что из этого следует на практике
- Порядок обработки строк важнее размера батча. Range-батчи и
order by id limit ... for update skip lockedдают непрерывный блок страниц, поэтому почти все строки страницы обновляются одновременно и не помещаются в резерв. Чередование по остаткуid % 10берёт по одной строке из десяти с каждой страницы, и эта десятая часть в основном помещается в запас — отсюда 96.1% HOT против 11.5%. - VACUUM между батчами удерживает heap, но не индексы. Освобождённое место возвращается в free space map и переиспользуется следующей партией, поэтому heap почти не растёт. Но обычный VACUUM не уменьшает файл B-tree, и освобождённое в индексе место остаётся выделенным.
- fillfactor помогает только при загрузке или после переупаковки.
ALTER TABLE ... SET (fillfactor = 90)без перезаписи не меняет уже существующие страницы, поэтому эффекта загрузки с 90 не будет. Нужна переупаковка: `VACU