Назад к блогу

min_wal_size в PostgreSQL: как незаметная настройка ломает репликацию

min_wal_size в PostgreSQL: как незаметная настройка ломает репликацию

Настройка `min_wal_size` в PostgreSQL выглядит безобидно и обычно воспринимается как способ зарезервировать место под WAL, но её реальная роль — лишь ограничивать, насколько сильно сервер может сжать каталог `pg_wal`, и она не гарантирует сохранность ни одного сегмента для чтения. Из-за ленивого механизма сокращения пула сегментов эта недооценённая деталь способна привести к отказу реплики с ошибкой о том, что нужный WAL-сегмент уже удалён. В статье разбирается, как устроен пул WAL, как вычисляется его целевой размер и почему проверка параметра срабатывает слишком поздно.

min_wal_size выглядит как настройка хранения WAL: имя намекает, что сервер держит хотя бы столько журнала. На деле это нижняя граница, до которой разрешено сжимать каталог pg_wal, и она не удерживает ни одного байта, который кто-либо мог бы прочитать. Разберём, как устроен пул WAL-сегментов, почему он сокращается лениво, как это приводит к отказу реплики с requested WAL segment ... has already been removed и почему проверка значения срабатывает слишком поздно.

Что такое min_wal_size и почему он «незаметный»

В pg_settings параметр описан точнее, чем в документации: «Sets the minimum size to shrink the WAL to». Это только нижняя граница того, насколько разрешено сжимать pg_wal. Параметр не сохраняет WAL, который можно было бы прочитать, хотя имя провоцирует читать его как настройку удержания.

Два свойства делают его незаметным. Первое: ничего никогда не создаёт файлы, чтобы достичь этого значения. Свежий кластер с min_wal_size = 4GB имеет один 16 MB файл в pg_wal, и новая реплика из pg_basebackup тоже — пул не копируется. Второе: сжатие ленивое. Контрольные точки никогда не пересматривают сегменты впереди точки вставки, поэтому пул уменьшается на один файл на каждый сегмент записанного WAL.

Чтобы нижняя граница вообще сработала, сервер должен медленно записать объём WAL размером с пул за достаточно много чекпойнтов, чтобы оценка упала (она уменьшается примерно вдвое каждые семь чекпойнтов), а затем снова стать занятым.

Значение по умолчанию — 80MB, пять сегментов по 16 MB. Контекст — sighup, единица — мегабайты. Параметр появился в 9.5 вместе с max_wal_size, когда Heikki Linnakangas заменил checkpoint_segments. initdb сам записывает строку в postgresql.conf и масштабирует её с размером сегмента, так что --wal-segsize=64 даёт 320MB.

Как вычисляется целевой размер пула

Целевой размер пула WAL-сегментов вычисляется во внутренней функции, которая решает, до какого сегмента перерабатывать старые WAL-файлы. Сначала по min_wal_size_mb и max_wal_size_mb определяются две границы — номера сегментов, между которыми разрешено перерабатывать сегменты:

minSegNo = lastredoptr / wal_segment_size +
	ConvertToXSegs(min_wal_size_mb, wal_segment_size) - 1;

Верхняя граница задаёт предел, ниже которого нужно удалять сегменты, нижняя — минимум, до которого сегменты всегда перерабатываются. Внутри этих границ число перерабатываемых сегментов оценивается через distance = (1.0 + CheckPointCompletionTarget) * CheckPointDistanceEstimate с добавкой 10%, где CheckPointDistanceEstimate — скользящая оценка объёма WAL, записанного за цикл чекпойнта. Полученный номер сегмента, до которого пул разрешено перерабатывать, зажимается между нижней и верхней границей.

Отдельно вычисляется CheckPointSegments = ConvertToXSegs(max_wal_size_mb, wal_segment_size) / (1.0 + CheckPointCompletionTarget), округляя вниз и не допуская значения меньше 1. Это расстояние, при котором запускается чекпойнт. wal_keep_size в этих вычислениях не участвует.

Как устроен пул WAL-сегментов внутри

RemoveOldXlogFiles вызывается из чекпойнта с номером сегмента, полученным из RedoRecPtr через XLByteToSeg, скорректированным KeepLogSeg и уменьшенным на 1, и удаляет сегменты старше этого горизонта. KeepLogSeg сдвигает этот горизонт вперёд, чтобы не удалить сегменты, которые ещё нужны репликации или слоту; без такой корректировки чекпойнт мог бы удалить WAL, который standby ещё не получил. min_wal_size_mb входит именно в XLOGfileslop как нижняя граница minSegNo, гарантируя, что перерабатывается не меньше сегментов, чем соответствует min_wal_size_mb.

При min_wal_size = 1GB в каталоге pg_wal находится 76 файлов. Один — сегмент, в который идёт запись сейчас, остальные 75 — старые сегменты, переименованные в имена, до которых сервер ещё не дошёл, заполненные устаревшими байтами под перезапись. Переименование в будущие имена позволяет переиспользовать уже существующий файл вместо создания нового: когда запись доходит до этого имени, сегмент просто перезаписывается поверх старых байтов, и не нужно обнулять и синхронизировать новый файл. При сегменте 16 МБ 1 ГБ даёт 64 сегмента, но наблюдаемые 76 файлов включают запас: сервер хранит достаточно свободных файлов, чтобы покрыть 1 + checkpoint_completion_target циклов при текущей оценке, плюс 10%. Именно эта оценка и запас, а не само значение min_wal_size, определяют фактическое число файлов в пуле.

max_wal_size — потолок этого числа, min_wal_size — пол. В сообщении коммита Heikki отмечено, что установка обоих равными отключает автотюнинг.

Почему «shrinking is lazy»

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

После всплеска в 1.5 GB пять чекпойнтов с почти нулевой записью снизили оценку с 1.5 GB до 0.9 GB, но не удалили ни одного файла. Чтобы достичь пяти файлов, потребовалось около 1.5 GB медленной записи по 16 MB на чекпойнт. При установившемся темпе записи пул подстраивается под этот темп, и нижняя граница ничего не меняет, если она не выше.

Цена создания нового сегмента

Когда WAL выходит за пределы пула, процесс, которому нужен следующий сегмент (почти всегда бэкенд), создаёт его: 16 MB нулей, fsync, переименование — всё под WALWriteLock, поэтому все остальные коммиты стоят в очереди.

По измерениям, обнуление и синхронизация каждого нового сегмента занимали 21 мс, не считая собственных вызовов fsync при переименовании. При min_wal_size = 80MB было создано 53 сегмента, суммарно 1 103 мс, и p99.9 составила 22.8 мс. При min_wal_size = 8GB сегменты не создавались, и p99.9 была 11.4 мс. То есть 21 мс на создание сегмента близки к p99.9 в 22.8 мс при min_wal_size = 80MB, а при отсутствии созданий сегментов p99.9 вдвое ниже.

Пул растёт только за счёт переработки уже записанного WAL. При большом min_wal_size сервер сохраняет больше переработанных сегментов, и при последующей нагрузке новые сегменты не создаются, тогда как при 80MB пул успевает исчерпаться.

Как это ломает репликацию

min_wal_size не удерживает WAL, который кто-либо мог бы прочитать. Когда standby отстаёт, а primary перерабатывает старые сегменты, standby при попытке догнать поток получает:

FATAL: could not receive data from WAL stream: ERROR: requested WAL segment 0000000100000002000000CB has already been removed

Чтобы WAL сохранялся для реплики, нужно использовать wal_keep_size или слот репликации. При потоковой репликации без файловой архивации сервер может переработать старые сегменты раньше, чем standby их получит, и тогда standby придётся переинициализировать из новой базовой резервной копии.

Что показывают позиции WAL

До инцидента текущая позиция записи WAL на primary соответствовала сегменту 0000000100000002000000F3, и этот же сегмент был самым старым в pg_wal — запись шла ровно в тот файл, который лежал в каталоге. После инцидента реплика запросила сегмент 0000000100000002000000CB, который младше ...F3, но уже был удалён.

Это показывает, что сервер не хранит WAL до текущей позиции: в каталоге лежат 76 файлов, самый старый — записываемый сейчас, а остальные 75 — старые сегменты, переименованные в ещё не достигнутые имена. Сегменты перерабатываются быстрее, чем реплика успевает их прочитать.

При завершении чекпойнта сегменты старше нового redo-указателя либо переименовываются в будущие сегменты, либо удаляются. В примере primary, чей standby был остановлен 640 MB назад, имел min_wal_size = 1GB, текущий сегмент 0000000100000002000000F3, а в pg_wal — 76 файлов. Нужный реплике ...CB находился ниже текущего и уже был переработан.

Почему проверка срабатывает слишком поздно

Проверка минимума выполняется в коде, который читает управляющий файл при инициализации: значение wal_segment_size берётся из ControlFile->xlog_seg_size, затем сравнивается ConvertToXSegs(min_wal_size_mb, wal_segment_size) < 2, и при нарушении выдаётся ошибка. Этот код вызывается при старте postmaster, а не в обработчике перезагрузки конфигурации.

Поэтому ALTER SYSTEM SET min_wal_size = '16MB' и pg_reload_conf() проходят без ошибки: SHOW min_wal_size показывает 16MB, и сервер работает с этим значением до следующего перезапуска или до переинициализации postmaster после падения любого бэкенда. При такой переинициализации снова читается управляющий файл, срабатывает та же проверка, и в логе появляется:

LOG: all server processes terminated; reinitializing
FATAL: "min_wal_size" must be at least twice "wal_segment_size"
LOG: database system is shut down

При сегментах 64 MB реальный минимум равен 128MB, что выше встроенного значения по умолчанию 80MB, поэтому initdb записывает эту строку в конфигурацию; если её закомментировать на таком кластере, он не запустится.

Проверку нельзя выполнить во время SET, потому что, как указал Tom Lane, check hook не может проверять один параметр относительно другого. Bharath Rupireddy предлагал ввести ограничение на этапе SET в 2022 году, но поведение осталось прежним; 19 beta 4 ведёт себя идентично.

Что видит клиент и DBA

SHOW min_wal_size сразу после ALTER SYSTEM SET и pg_reload_conf() показывает новое значение, например 16MB, — изменение уже видно в текущем сеансе. Фактическое поведение сервера при этом не меняется до следующего перезапуска или до аварийного завершения бэкенда.

В представлении pg_replication_slots поле wal_status показывает, насколько сохранны WAL-файлы, которые слот обязуется удерживать для реплики, — то есть может ли отстающий standby ещё догнать поток, или нужные ему сегменты уже подлежат удалению:

  • extended — слот удерживает файлы сверх max_wal_size: запас выше обычного, файлы сохранны, но занимают больше диска, чем предполагает мягкий лимит;
  • reserved — удерживаемые файлы укладываются в max_wal_size: обычное рабочее состояние;
  • unreserved — слот больше не удерживает требуемые файлы, и часть из них будет удалена при следующей контрольной точке; обычно возникает, когда max_slot_wal_keep_size задан неотрицательным, — реплике грозит разрыв;
  • lost — слот больше не пригоден для использования: удержание потеряно окончательно, standby придётся переинициализировать.

В логе standby сообщению об удалённом сегменте предшествует запись о том, что standby вернулся.

Компромиссы

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

Компромисс по дисковому пространству: сегменты в пуле реально занимают диск. При заполнении тома с полом 80MB сервер записал ещё 78 MB до PANIC: could not write to file "pg_wal/xlogtemp.15129": No space left on device; при 512MB — 506 MB.

Компромисс по задержке: когда WAL выходит за пределы пула, создание нового сегмента занимает 21 мс под WALWriteLock, из-за чего p99.9 удваивается (22.8 мс против 11.4 мс), хотя медиана и пропускная способность почти не меняются. Больший min_wal_size устраняет эти создания сегментов (0 против 53) и снижает хвостовую задержку, но резервирует больше диска.

Ленивое сжатие выбрано потому, что чекпойнты не пересматривают сегменты впереди точки вставки. Риск для репликации в том, что сегменты, которые ещё нужны реплике, могут быть удалены: при попытке получить уже удалённый сегмент возникает ошибка. Для сохранения WAL для реплики нужно использовать wal_keep_size или слот, а не полагаться на min_wal_size.

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

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

Нагрузка задавалась через pgbench: масштаб 50, восемь клиентов, две минуты, версия 18.6. Перед запуском выполнялась запись 3 ГБ, затем через сервер прокачивалось по 16 МБ на каждый контрольный пункт, пока пул не опустел до нижней границы.

Значение параметра меняли через ALTER SYSTEM SET min_wal_size = '16MB' с последующим SELECT pg_reload_conf() и проверкой через SHOW min_wal_size. Число созданных сегментов фиксировали счётчиком файлов в каталоге (ls pg_wal | grep -c ^0000), объём — через du -sh pg_wal. Суммарное время создания сегментов указано прямо в строке результата.

Время создания сегмента измерялось как 21 мс на обнуление и синхронизацию каждого нового сегмента, не считая собственных вызовов fsync при переименовании. Новый сегмент заполняется нулями блоками buffer, а незаполненная часть добивается нулями через memset, после чего файл синхронизируется на диск.

Результаты

min_wal_sizeпулtpsp50p99.9создано сегментов
80MB5 сегментов3 3962.1 мс22.8 мс53 за 1 103 мс
8GB227 сегментов3 4392.1 мс11.4 мс0

Медиана и p99 не изменились, пропускная способность отличалась на 1–2%, что примерно равно шуму между запусками. p99.9 удвоился. Вторая пара замеров дала те же значения: 22.6 мс против 11.2 мс.

При создании сегмента шесть из семи клиентов ждали LWLock:WALWrite.

Оговорки

Цифры задержек вычисляются по разнице позиций WAL. Они не показывают сетевую задержку до standby как таковую: разница между sent_lsn и pg_last_wal_receive_lsn может указывать и на сетевую задержку, и на нагрузку standby, то есть не разделяет эти причины.

Потерю сегментов при краше эти цифры тоже не отражают: для защиты от удаления сегментов, ещё не полученных standby, служат отдельные механизмы — слоты репликации, wal_keep_size, archive_command/archive_library.

Поведение при синхронной репликации эти цифры не показывают: при synchronous replication коммит ждёт подтверждения, и минимальное время ожидания равно времени кругового обмена между primary и standby. Режимы synchronous_commit различаются тем, какого подтверждения ждёт коммит: remote_apply — самый строгий и самый медленный; on; remote_write; local — самый слабый и самый быстрый. Чем строже подтверждение, тем больше задержка коммита.

Тест проводился на диске с fsync 0.3 мс. На сетевом томе, ограниченном 125 МБ/с, только обнуление занимает 128 мс. На продакшне с другим fsync, сетевой задержкой или иным размером сегмента абсолютные задержки и число создаваемых сегментов будут иными.

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

  • min_wal_size — не настройка удержания WAL. Он не создаёт файлы и не сохраняет журнал для чтения. Всё, что он делает, — задаёт нижнюю границу сжатия pg_wal.
  • Для реплики нужен wal_keep_size или слот репликации. Полагаться на min_wal_size нельзя: сегменты перерабатываются быстрее, чем отстающая реплика успевает их прочитать, и она получает FATAL об уже удалённом сегменте.
  • Снижение min_wal_size не освобождает место немедленно. Пул сокращается на один файл за каждый записанный сегмент WAL, поэтому после всплеска записи снижение параметра не удаляет сегменты сразу.
  • Маленький min_wal_size бьёт по хвостовой задержке. Когда пул исчерпан, создание сегмента под WALWriteLock занимает 21 мс и удваивает p99.9, хотя медиана и пропускная способность почти не меняются.
  • Значение ниже двух сегментов принимается молча и убивает сервер позже. ALTER SYSTEM SET и pg_reload_conf() проходят без ошибки, но при следующем перезапуске или после краша любого бэкенда сервер не поднимается с FATAL. При сегментах 64 MB реальный минимум — 128MB, и initdb пишет эту строку в конфигурацию не просто так.

Источники

Похожее