Назад к блогу

Два таймера горячего резерва: как задержка репликации отменяет запросы

Два таймера горячего резерва: как задержка репликации отменяет запросы

На горячем резерве PostgreSQL применение WAL регулярно вступает в конфликт с выполняющимися запросами, и разрешается он двумя таймерами — `max_standby_archive_delay` и `max_standby_streaming_delay`. Разбираем, почему параметров именно два, как на самом деле тикает их отсчёт (спойлер: от момента получения WAL, а не от начала конфликта) и кто внутри движка выносит приговор запросу.

На горячем резерве запросы и применение WAL конкурируют за одни и те же данные. Когда применение WAL требует удалить версии строк, которые ещё нужны выполняющемуся запросу, или захватить блокировку, которую тот держит, PostgreSQL должен решить: подождать запрос или отменить его. Этим управляют два параметра — max_standby_archive_delay и max_standby_streaming_delay. Их нетривиальность в том, что отсчёт ведётся не от начала конфликта, а от момента получения свежего WAL, поэтому при отставании репликации бюджет задержки может оказаться уже израсходованным к моменту, когда конфликт только возник.

Почему таймеров два

Оба параметра задают, сколько ждать перед отменой запросов, конфликтующих с применяемыми записями WAL, но обслуживают разные источники WAL. max_standby_archive_delay применяется, когда WAL читается из архива WAL и потому не является текущим. max_standby_streaming_delay — когда WAL получен через потоковой репликации.

Разделение возникло потому, что два способа поступления WAL по-разному сбрасывают часы получения: потоковая передача — по чанкам, архив — по сегментным файлам (16 МБ, если не изменено при initdb). Поэтому max_standby_archive_delay — это бюджет на применение одного восстановленного сегмента, и он начинается при открытии файла.

На практике архивная задержка управляет резервом, догоняющим из архива после разрыва потокового соединения, переигрывающим собственный pg_wal после перезапуска (процесс запуска обращается с сегментом из pg_wal так же, как с сегментом из архива), и тем, которому вообще не задан primary_conninfo.

Значения по умолчанию, единицы и -1

Оба параметра по умолчанию равны 30 секундам; если значение указано без единиц, оно трактуется как миллисекунды. Значение -1 позволяет standby ждать завершения конфликтующих запросов бесконечно.

Оба параметра задают максимальное суммарное время, отведённое на применение данных одного WAL-сегмента (или данных, полученных от primary), а не максимальную длительность выполнения запроса.

Кто принимает решение об отмене

Startup-процесс не сам обнаруживает конфликт: решение принимается в конкретном rmgr, а standby.c лишь исполняет приговор — «Judgement has already been passed on it within a specific rmgr. Here we just issue the orders to the procs.»

Для конфликтов по снимку определяется горизонт конфликта и признак каталога, после чего собирается список конфликтующих виртуальных транзакций и разрешается конфликт с причиной RECOVERY_CONFLICT_SNAPSHOT. Общий исполнитель ждёт завершения перечисленных виртуальных транзакций и, если ожидание затянулось, сигнализирует соответствующим backend-процессам, чтобы те прервали свою работу. Если горизонт конфликта невалиден, конфликт не создаётся.

Для табличных пространств берутся все пользователи временных файлов и конфликт разрешается с причиной RECOVERY_CONFLICT_TABLESPACE. Для баз данных применяется особый порядок: транзакции не ожидаются, а в цикле, пока CountDBBackends(dbid) > 0, шлётся сигнал и делается пауза 10000 микросекунд.

Конфликты по буферному пину обрабатываются через таймауты: при срабатывании таймаута задержки отправляется конфликт RECOVERY_CONFLICT_BUFFERPIN, а при срабатывании таймаута дедлока — RECOVERY_CONFLICT_BUFFERPIN_DEADLOCK.

Всего выделяются типы конфликтов, различающиеся тем, что именно мешает восстановлению: RECOVERY_CONFLICT_BUFFERPIN — удержание буферного пина, RECOVERY_CONFLICT_LOCK — удержание блокировки отношения, RECOVERY_CONFLICT_TABLESPACE — использование временных файлов табличного пространства, RECOVERY_CONFLICT_SNAPSHOT — слишком старый снимок, видящий удаляемые строки, RECOVERY_CONFLICT_LOGICALSLOT — конфликт с логическим слотом репликации, RECOVERY_CONFLICT_STARTUP_DEADLOCK — взаимоблокировка с startup-процессом, RECOVERY_CONFLICT_BUFFERPIN_DEADLOCK — взаимоблокировка из-за буферного пина и RECOVERY_CONFLICT_DATABASE — активные подключения к базе, которую нужно удалить.

Три исполнителя и их сценарии

ResolveRecoveryConflictWithVirtualXIDs — главный исполнитель для бэкенда, конфликтующего с восстановлением: решение уже принято в конкретном rmgr, а функция лишь раздаёт приказы процессам, которые затем выбрасывают нужную ошибку. Она вызывается из ResolveRecoveryConflictWithLock, когда время ожидания уже истекло, чтобы отменить бэкенды, держащие конфликтующие блокировки, с причиной RECOVERY_CONFLICT_LOCK.

ResolveRecoveryConflictWithLock вызывается из ProcSleep для разрешения конфликтов с другими бэкендами, держащими блокировки отношений; она либо сразу разрешает конфликт, либо ставит таймаут, а также проверяет взаимоблокировки с участием Startup-процесса.

ResolveRecoveryConflictWithBufferPin вызывается из LockBufferForCleanup() для разрешения конфликтов с бэкендами, удерживающими пины буферов: она посылает PROCSIG-сигнал всем бэкендам, чтобы те проверили, не держат ли они блокирующий Startup-процесс пин, и при необходимости завершились с ERROR или FATAL. Она проверяет взаимоблокировки только после ожидания не менее deadlock_timeout, поскольку проверка редка и дорога; защита от обратной последовательности (запрос ждёт, затем Startup спит) выполняется до сна запроса в CheckRecoveryConflictDeadlock().

От какого момента идёт отсчёт

Отсчёт таймера задержки начинается не от старта запроса и не от начала конфликта, а от приватной отметки времени последнего получения свежего WAL — XLogReceiptTime. Эта отметка обновляется при стриминге, когда startup-процесс снова обращается к walreceiver и обнаруживает, что уже обработал всё до последнего сброшенного им чанка, а при чтении из архива — при открытии нового сегментного файла. Порог для конфликта равен этой отметке плюс задержка.

Отсюда два режима. Если standby успевает, отметка свежая (миллисекунды), и запрос получает полные 30 секунд. Если отстаёт, отметка не двигается, и льготный период — это остаток уже потраченного бюджета.

Предельное время ожидания вычисляет GetStandbyLimitTime. Пока текущее время не достигло этого предела, ожидание продолжается; как только предел достигнут, конфликт разрешается отменой бэкендов. Для блокировок waitStart фиксируется один раз при первом входе в ожидание и не обновляется при каждом вызове ResolveRecoveryConflictWithLock().

Пошаговая механика отмены

ResolveRecoveryConflictWithVirtualXIDs получает список виртуальных транзакций и сначала делает быстрый выход, если список пуст. Затем, если нужно отчитываться об ожидании и включён log_recovery_conflict_waits или update_process_title, запоминается момент начала ожидания в waitStart. Далее в цикле по каждому vxid сбрасывается начальная задержка сна, и во внутреннем цикле ожидается исчезновение vxid через VirtualXactLock.

Внутри ожидания вызывается WaitExceedsMaxStandbyDelay, и если лимит превышен — SignalRecoveryConflictWithVirtualXID, после чего при успешной сигнализации делается пауза 5000 микросекунд, чтобы не заваливать сигналами неотвечающий backend при высокой нагрузке.

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

standbyWait_us *= 2;
if (standbyWait_us > 1000000)
	standbyWait_us = 1000000;

Внутри ожидания при waitStart != 0 может обновляться ps-заголовок (суффикс «waiting» после 500 мс) и логироваться конфликт после DeadlockTimeout через LogRecoveryConflict; по завершении ожидания, если конфликт логировался, вызывается LogRecoveryConflict со still_waiting=false, и снимается суффикс ps-заголовка.

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

Клиент получает два разных сообщения в зависимости от причины конфликта.

При конфликте с удалением версий строк выдаётся FATAL с кодом 40001: terminating connection due to conflict with recovery и DETAIL User query might have needed to see row versions that must be removed., а также HINT о возможности переподключиться и повторить команду. Соединение разрывается.

При конфликте из-за удержания блокировки отношения выдаётся ERROR: canceling statement due to conflict with recovery с DETAIL User was holding a relation lock for too long.. Отменяется только текущий запрос.

Сообщение canceling statement due to conflict with recovery формируется в самом backend при обработке полученного сигнала; текст должен совпадать с ProcessInterrupts(), но ProcessInterrupts не вызывается, потому что в этот момент прерывание не обрабатывается. Это ERROR с кодом ERRCODE_T_R_DEADLOCK_DETECTED и DETAIL User transaction caused buffer deadlock with recovery.

Что происходит с транзакцией клиента

При конфликте с восстановлением сервер отменяет текущую транзакцию, а не всю сессию: если блокировка удерживается родительской подтранзакцией, процесс восстановления продолжит ждать.

Один SAVEPOINT достаточен, чтобы ORM, открывающая точку сохранения вокруг каждого вложенного блока, превратила конфликт в разрыв соединения, а не в повторяемую ошибку. Django-овский atomic() внутри другого atomic() делает именно это. Поэтому после отмены нельзя просто откатиться к SAVEPOINT и продолжить: соединение будет разорвано.

В горячем резерве транзакции всегда read-only, и попытки записи (DML, DDL, блокировки выше ROW EXCLUSIVE и т.п.) вызывают ошибки.

Пограничные случаи

Уже завершившийся backend. В цикле по списку виртуальных транзакций proc может быть NULL, если целевой backend уже не активен; тогда блок с добавлением PID пропускается, но счётчик vxids всё равно увеличивается. Поэтому в detail-сообщении перечисляются только PID активных конфликтующих backend'ов, а если все они неактивны, detail не пишется вовсе. Если ожидание продолжается, пишется LOG recovery still waiting after %ld.%03d ms: %s с описанием причины конфликта, а detail добавляется только при nprocs > 0. Если ожидание завершилось, пишется LOG recovery finished waiting after %ld.%03d ms: %s без detail.

Сетевой разрыв. При разрыве streaming-соединения процесс восстановления переходит из состояния XLOG_FROM_STREAM в XLOG_FROM_ARCHIVE. Перед этим walreceiver останавливается, чтобы не перезаписывать WAL, восстанавливаемый из архива. После этого применяется таймер max_standby_archive_delay, поскольку он применяется, когда WAL читается из архива и потому не является текущим. Сообщение вида recovery still waiting after 1024.342 ms: recovery conflict on snapshot фиксирует, что ожидание из-за конфликта по снимку продолжается указанное число миллисекунд, а следующее recovery finished waiting after 2029.996 ms — что ожидание завершилось.

Несколько кандидатов на отмену. Startup-процесс обрабатывает конфликты по одному: получает список виртуальных транзакций, подлежащих отмене, и ждёт их завершения в цикле, сбрасывая задержку ожидания для каждой транзакции заново. Ожидание ограничено общим лимитом времени, который вычисляется GetStandbyLimitTime() и проверяется в WaitExceedsMaxStandbyDelay(); как только текущее время превышает этот лимит, ожидание прекращается и бэкенд отменяется.

В примере с max_standby_streaming_delay = '10s' query 1 получила свои десять секунд, а query 2 была отменена примерно через тридцать миллисекунд после query 1, потому что к моменту, когда startup-процесс дошёл до записей второй vacuum, десять секунд уже были израсходованы на query 1, а метка времени получения не сдвинулась. Тот же эффект даёт standby под тяжёлой нагрузкой записи: replay отстаёт от walreceiver, метка времени перестаёт двигаться, и 30s сводятся к нулю.

Снаружи ожидание видно как pg_stat_replication.replay_lag на primary и wait_event = RecoveryConflictSnapshot в строке startup в pg_stat_activity на standby, а отмены учитываются в pg_stat_database_conflicts по причине.

Возврат старого узла. ResolveRecoveryConflictWithLock использует GetStandbyLimitTime() для получения предельного времени ожидания и сравнивает его с текущим временем; если предел достигнут, конфликтующие бэкенды отменяются через ResolveRecoveryConflictWithVirtualXIDs. Иначе устанавливаются два таймаута: STANDBY_LOCK_TIMEOUT (по абсолютному времени ltime) и STANDBY_DEADLOCK_TIMEOUT (через DeadlockTimeout), после чего процесс ждёт сигнала ProcWaitForSignal. При срабатывании deadlock-таймаута рассылаются сигналы RECOVERY_CONFLICT_STARTUP_DEADLOCK держателям конфликтующих блокировок, а при logging_conflict=true функция выходит, чтобы вызывающий залогировал конфликт.

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

В источнике описан пример с max_standby_streaming_delay = '10s' и двумя запросами vacuum. Query 1 получила свои десять секунд. Query 2 была отменена примерно через тридцать миллисекунд после query 1, потому что к моменту, когда startup-процесс дошёл до записей второй vacuum, десять секунд уже были израсходованы на query 1, а метка времени получения не сдвинулась.

Отдельно описан эффект тяжёлой нагрузки записи: replay отстаёт от walreceiver, метка времени перестаёт двигаться, и 30s сводятся к нулю.

В примере с hot_standby_feedback при включённом параметре и открытой транзакции REPEATABLE READ на standby тот же DELETE плюс VACUUM VERBOSE сообщает 50000 are dead but not yet removable, а removable cutoff равен backend_xmin, который primary теперь показывает в pg_stat_replication, и транзакция standby доходит до конца.

Цифры показывают поведение таймера при конкретных конфигурациях и не являются универсальными порогами: они зависят от того, насколько standby отстаёт от primary в момент конфликта.

Компромиссы

Задержка измеряется от старта запроса, потому что стартовый процесс хранит приватную метку времени последнего получения свежего WAL — XLogReceiptTime, и именно к ней прибавляется задержка для определения момента отмены конфликтующего запроса. Это даёт компромисс: когда standby успевает за primary, метка времени свежая (миллисекунды), и запрос получает полные 30 секунд; когда standby отстаёт, метка не двигается, и льготный период — это остаток уже потраченного бюджета. Для длинных аналитических запросов это означает, что при отставании репликации они могут быть отменены почти сразу.

Кроме того, отмена затрагивает все бэкенды в базе, чьи снапшоты видят удаляемые строки, независимо от того, какую таблицу они читают. Ошибка имеет SQLSTATE 40001 (serialization_failure), и правильной реакцией является повторный запуск запроса.

Разные параметры нужны потому, что задержка отсчитывается от момента получения WAL-данных, а способ их получения различается. Рекомендация из документации — короткие задержки для HA-standby, длинные или бесконечные для отчётных — согласуется с реализацией: в standby.c функция WaitExceedsMaxStandbyDelay решает, пора ли прерывать ожидание, и тогда SignalRecoveryConflictWithVirtualXID посылает сигнал конфликтующему бэкенду. Для конфликтов по buffer pin используется GetStandbyLimitTime: если текущее время уже превысило лимит, конфликт разрешается немедленно, иначе ставится таймаут STANDBY_TIMEOUT на момент ltime. Проверка дедлока откладывается минимум на deadlock_timeout через STANDBY_DEADLOCK_TIMEOUT.

Взаимодействие с другими GUC

hot_standby_feedback — средство, которое standby использует, чтобы сообщать свой самый старый снимок наверх, не чаще одного раза за wal_receiver_status_interval, и тогда VACUUM на primary не трогает эти строки. Это исправляет конфликты очистки. Цена — раздувание на primary пропорционально самой длинной транзакции standby, ровно та же цена, как если бы транзакция выполнялась на primary.

Чего feedback не предотвращает — так это конфликтов блокировок, и забываемый конфликт блокировок — сам VACUUM: когда он находит пустые страницы в конце таблицы, он их усекает, усечение берёт AccessExclusiveLock, и эта блокировка WAL-логируется и воспроизводится как блокировка на standby.

Archive delay управляет догоняющим standby, потому что в каждом случае standby отстаёт, а отстающий standby должен догонять, а не обслуживать отчёты, поэтому именно этот из двух параметров можно держать коротким со спокойной совестью.

wal_receiver_status_interval задаёт максимальный интервал между отправками сообщений о прогрессе репликации от процесса walreceiver на standby к primary; значение по умолчанию 10 секунд, а если указано без единиц — секунды. wal_receiver_timeout задаёт время, по истечении которого неактивное соединение репликации разрывается; значение по умолчанию 60 секунд, без единиц — миллисекунды, ноль отключает механизм. Когда срок, отсчитываемый от времени последнего приёма данных от primary и соответствующий значению wal_receiver_timeout, истекает, выбрасывается ошибка terminating walreceiver due to timeout. Отправка статуса не влияет на точность отсчёта этого таймера: таймер срабатывает по времени с момента последнего приёма от primary, а не по времени отправки статуса. Задержка отправки статуса может влиять только на то, как часто primary получает сведения о прогрессе, но не на отсчёт таймера отмены на standby.

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

Отсчёт задержки привязан к получению свежего WAL, а не к началу конфликта. Поэтому при отстающем standby бюджет задержки может быть уже израсходован к моменту возникновения конфликта, и запрос будет отменён почти сразу — даже если параметр выставлен в 30 секунд.

Архивная задержка управляет догоняющим standby: после разрыва потокового соединения, после перезапуска при переигрывании собственного pg_wal, а также на standby без primary_conninfo. Именно этот параметр можно держать коротким, потому что отстающий standby должен догонять, а не обслуживать отчёты.

hot_standby_feedback предотвращает конфликты очистки, но не конфликты блокировок, в частности от VACUUM, который при усечении пустых страниц берёт AccessExclusiveLock.

Ошибка отмены имеет код 40001 (serialization_failure), и правильной реакцией клиента является повторный запуск запроса. При конфликте с удалением версий строк соединение разрывается (FATAL), а не просто отменяется текущий оператор, поэтому ORM, открывающая SAVEPOINT вокруг каждого вложенного блока, превращает конфликт в разрыв соединения, а не в повторяемую ошибку.

Наблюдать ожидание можно через pg_stat_replication.replay_lag на primary и wait_event = RecoveryConflictSnapshot в строке startup в pg_stat_activity на standby; отмены учитываются в pg_stat_database_conflicts по причине.

Источники

Похожее