АйТи Фреш
Главная / Статьи / 1С и базы данных
1С и базы данных

Запрос на реплике отменён за четыре секунды, хотя в конфиге 30s: как на самом деле работает max_standby_streaming_delay

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~22 мин чтения
Запрос на реплике отменён за четыре секунды, хотя в конфиге 30s: как на самом деле работает max_standby_streaming_delay
Иллюстрация к статье «Запрос на реплике отменён за четыре секунды, хотя в конфиге 30s: как на самом деле работает max_standby_streaming_delay».

Эта статья для тех, кто вынес отчёты на физическую реплику PostgreSQL и теперь ловит «canceling statement due to conflict with recovery» на ровном месте. Разберу, почему тридцать секунд из конфига почти никогда не достаются вашему запросу целиком, какие конфликты бывают и как их различить по одной строке в логе, что я включаю первым делом и чем за это платит основная база. С разбором реального стенда, цифрами до и после и командами, которые можно выполнить прямо сейчас.

Тридцать секунд отсчитываются не от старта вашего запроса

Классическая картина. Реплика поднята под отчётность, аналитик запускает выгрузку, через несколько секунд получает в лицо:

ERROR:  canceling statement due to conflict with recovery
DETAIL:  User query might have needed to see row versions that must be removed.

Администратор лезет в postgresql.conf, видит там max_standby_streaming_delay = 30s, смотрит на секундомер — запрос жил четыре секунды — и делает единственный логичный вывод: параметр не работает. Параметр работает. Он просто измеряет совсем не тот интервал, который вы себе представили.

Документация PostgreSQL 18 (раздел 26.4.2 Handling Query Conflicts) формулирует это прямым текстом: «the delay parameters are compared to the elapsed time since the WAL data was received by the standby server». Ключевые слова — elapsed time и since the WAL data was received. Секундомер запускается в момент, когда порция WAL приехала на standby по стримингу, а не в момент, когда startup-процесс упёрся в ваш SELECT. Пока эта запись WAL стояла в очереди на применение, пока реплика догоняла отставание, пока разбирались с предыдущим конфликтом — время уже шло. К вашему запросу оно приходит частично израсходованным.

Второй множитель — бюджет общий, а не персональный. Мануал добавляет, что отпущенное запросу время «is never more than the delay parameter, and could be considerably less if the standby has already fallen behind as a result of waiting for previous queries to complete». Перевожу на язык практики: если десять минут назад чужой отчёт продержал применение WAL двадцать восемь секунд, следующему конфликтующему запросу останется две. И это не деградация и не баг — это ровно то поведение, которое заложено. Реплика не может позволить себе отставать вечно, поэтому расплачивается за задержку теми запросами, которые подвернулись.

Отсюда третий вывод, который экономит часы бессмысленной отладки: сравнивать «время жизни запроса» с «значением параметра» бессмысленно в принципе. Эти два числа не связаны напрямую никогда. Запрос может прожить час и не быть отменённым, если конфликтующих WAL-записей не приезжало. И может умереть на первой секунде, если бюджет уже выбран. Если вы хотите ограничить именно длительность запроса — для этого есть statement_timeout, и это другой инструмент с другой задачей.

Если хотите одной фразой объяснить это коллеге: max_standby_streaming_delay — это лимит отставания реплики, а не тайм-аут запроса. Побочный эффект этого лимита — отмена запросов, которые мешают его соблюсти.
Цифры и версии: Тридцать секунд отсчитываются не от старта вашего запроса — схема
Цифры и версии: Тридцать секунд отсчитываются не от старта вашего запроса. Открыть схему в полном размере

Пять видов конфликта, и различить их можно по строке DETAIL

Прежде чем что-то крутить, надо понять, из-за чего именно вас отменяют. PostgreSQL честно пишет причину в DETAIL, и по ней сразу видно, куда копать. Конфликтов, которые приводят к принудительной отмене, по сути пять: снятие Access Exclusive Lock на primary (явный LOCK или любой DDL), удаление табличного пространства, которое standby использует под временные файлы, удаление базы, к которой на реплике есть подключения, и две разновидности конфликта с записями очистки от VACUUM — когда снапшот запроса ещё видит строки, которые вычищаются, и когда запрос просто держит буфер той страницы, которую переписывают.

На практике в девяноста с лишним процентах случаев вы увидите строку про row versions that must be removed. Это конфликт снапшота, и он же — самый частый. Мануал прямо называет его «early cleanup»: на primary VACUUM имеет полное право снести мёртвые версии строк, потому что там их уже никто не видит. О том, что на реплике в этот момент крутится отчёт с получасовым снапшотом, primary по умолчанию не знает ничего. Сюда же попадает неочевидная вещь: index-only scan требует, чтобы карта видимости согласовывалась со снапшотом, поэтому конфликт возникает и тогда, когда VACUUM просто помечает страницу как all-visible.

Отдельно стоит конфликт буферного пина — «User was holding shared buffer pin for too long». Он ловится реже, но выглядит особенно обидно: запрос не видит удаляемых данных, он просто держал открытый курсор на странице. И совсем отдельно — DROP DATABASE: в этом случае никакая отмена запроса не поможет, standby рвёт всю сессию целиком. Так же рвётся сессия, если конфликтующая блокировка удерживается транзакцией, которая ничего не делает и сидит в idle in transaction.

Считать конфликты руками не нужно, для этого есть представление pg_stat_database_conflicts — по строке на базу. Смотреть его надо на самой реплике: по документации оно содержит данные только на standby, потому что на primary конфликтов восстановления не бывает, и там все счётчики нулевые.

Первое, что надо сделать при жалобе «отчёты падают» — снять срез pg_stat_database_conflicts, а не лезть в конфиг. Лечение конфликта снапшота и лечение конфликта блокировок — это два разных набора действий, и угадывание тут стоит дороже, чем один SELECT.
Запрос на реплике отменён за четыре секунды, хотя в конфиге 30s: как на самом деле работает max_standby_streaming_delay — схема
Схема к статье. Открыть схему в полном размере

Разбор со стенда: медицинский центр «Эскулап на Соколе», МИС на 210 ГБ и отчёты, которые падали через раз

Медицинский центр «Эскулап на Соколе», 41 рабочее место: регистратура, врачебные кабинеты, лаборатория, бухгалтерия. Медицинская информационная система работает на PostgreSQL 18.6 под Debian 13, primary — 8 vCPU и 32 ГБ памяти, база 210 ГБ, рядом на том же кластере СУБД живёт 1С:Бухгалтерия. Год назад вынесли аналитику на отдельную машину: физическая реплика через streaming replication, туда ходит BI-панель главного врача, выгрузки реестров для страховых компаний и ежемесячная сверка оказанных услуг с бухгалтерией. Схема правильная, primary разгрузился, регистратура перестала жаловаться на подтормаживания в часы пик. Жалоба пришла через месяц и звучала фирменно: «квартальный отчёт по страховым падает через раз, а дневной работает всегда».

Сняли статистику. За одиннадцать дней аптайма реплики картина была такая:

SELECT datname, confl_snapshot, confl_bufferpin, confl_lock, confl_deadlock
  FROM pg_stat_database_conflicts
 WHERE datname NOT LIKE 'template%';

confl_snapshot — 1270, confl_bufferpin — 4, confl_lock — 1, остальное нули. Диагноз читается сразу: никакой экзотики, чистый early cleanup — так этот случай называет сама документация. Дальше стало понятно и почему «через раз». На primary каждую ночь идёт закрытие дня: пересчёт статусов оплат и загрузка результатов из лабораторной системы, UPDATE трогает от полутора до двух миллионов строк в таблице оказанных услуг на 40 миллионов записей, следом просыпается autovacuum и начинает выносить мёртвые версии. Дневной отчёт живёт шесть-восемь секунд и в это окно почти никогда не попадает. Квартальный по страховым живёт от 90 до 200 секунд и попадает в него постоянно.

Отдельно проверил гипотезу «просто поднимем задержку». Включил на реплике логирование ожиданий и посмотрел, сколько startup-процесс реально стоит:

# postgresql.conf на реплике, применяется через pg_reload_conf(), перезапуск не нужен
log_recovery_conflict_waits = on
deadlock_timeout = 1s

Этот параметр пишет в лог сообщение, когда startup-процесс ждёт разрешения конфликта дольше, чем deadlock_timeout. По умолчанию он выключен, и зря — без него вы не видите, кто именно тормозит накат WAL. Логи показали, что в ночное окно реплика уже упиралась в потолок: тридцать секунд бюджета выбирались подчистую, лаг применения доходил до сорока секунд, и следующим по очереди запросам не доставалось ничего. Поднимать max_standby_streaming_delay до трёх минут в такой ситуации означало бы получить реплику, отстающую на три минуты в самый нужный момент — то есть сломать её второе назначение, быть кандидатом на переключение, если primary с МИС ляжет посреди приёма.

Сделали иначе. Включили обратную связь от реплики и закрепили её физическим слотом, чтобы xmin реплики честно доезжал до primary и не сбрасывался при переподключениях. Задержку не трогали вообще — оставили дефолтные 30 секунд. Результат за две недели наблюдения: confl_snapshot вырос за это время всего на 2 — оба случая пришлись на окно планового перезапуска реплики при обновлении минорной версии, пока обратная связь ещё не восстановилась. Отчёты перестали падать, лаг применения на ночном окне снизился до 3-5 секунд, потому что startup-процессу больше не надо было никого ждать.

Отдельный вопрос, который задала бухгалтерия: а нельзя ли и отчёты 1С гонять на этой же реплике? Просто перенаправить информационную базу 1С на read-only standby как на обычную базу не получится — платформе при работе нужна запись в служебные таблицы. У 1С есть собственный механизм «копии баз данных», который, по описанию на v8.1c.ru, может выносить данные для аналитических отчётов на внешний сервер СУБД. Условия его применения (версия и редакция платформы, поддержка конкретной конфигурацией) я каждый раз сверяю по актуальной документации 1С, прежде чем обещать это клиенту; в «Эскулапе» бухгалтерия осталась на primary, её отчёты лёгкие и конфликтов не создают.

Обратите внимание на порядок: сначала мы поняли, что 99,6 % отмен — это конфликт снапшота, и только потом выбрали лекарство. Если бы преобладал confl_lock, hot_standby_feedback не дал бы вообще ничего — он лечит только очистку строк.
Цифры и версии: Разбор со стенда: медицинский центр «Эскулап на Соколе», МИС на 210 ГБ и отчёты, которые падали через раз — схема
Цифры и версии: Разбор со стенда: медицинский центр «Эскулап на Соколе», МИС на 210 ГБ и отчёты, которые падали через раз. Открыть схему в полном размере

Что я включаю первым делом и в каком порядке

Мой порядок действий на отчётной реплике простой и почти всегда одинаковый. Первым идёт hot_standby_feedback = on. Это параметр самой реплики, он заставляет её сообщать primary о том, какие снапшоты сейчас активны, и primary перестаёт вычищать нужные версии строк. Мануал описывает его роль ровно так: «can be used to eliminate query cancels caused by cleanup records, but can cause database bloat on the primary for some workloads». По умолчанию он выключен. Сообщения уходят не чаще, чем раз в wal_receiver_status_interval, у которого дефолт 10 секунд — этого достаточно, крутить его почти никогда не нужно.

Вторым идёт физический слот репликации. Без слота обратная связь живёт ровно до разрыва соединения: реплика отвалилась на минуту, primary забыл её xmin, autovacuum отработал и вычистил всё, что мог. Со слотом информация сохраняется на стороне primary. Конфигурация минимальная:

# на primary
max_slot_wal_keep_size = 64GB

# на реплике
primary_slot_name = 'standby_bi'
hot_standby_feedback = on
wal_receiver_status_interval = 10s

Слот создаётся на primary одной командой SELECT pg_create_physical_replication_slot('standby_bi', true);. Ограничение max_slot_wal_keep_size ставить обязательно — иначе забытый или отвалившийся слот однажды забьёт pg_wal на primary целиком, и это гораздо более неприятная авария, чем отменённый отчёт.

Третьим — тайм-ауты на отчётной роли. Именно на роли, а не глобально, чтобы не задеть репликацию и служебные процессы:

ALTER ROLE bi_reader SET statement_timeout = '15min';
ALTER ROLE bi_reader SET idle_in_transaction_session_timeout = '5min';

Это страховка от того, чтобы одна забытая транзакция в открытом psql не держала xmin реплики сутками. С включённым hot_standby_feedback такая транзакция превращается в тормоз для очистки на primary, и последствия вы увидите не сразу, а через неделю по размеру таблиц.

max_standby_streaming_delay я трогаю последним и редко. Осмысленных сценария всего два. Если реплика — исключительно источник отчётов и никогда не будет промоутиться, можно поставить -1 и разрешить ждать бесконечно; отставание тогда ограничится только вашим терпением и местом под WAL. Если реплика — прежде всего HA-кандидат, значение наоборот стоит опустить до нескольких секунд или до нуля, приняв, что тяжёлые запросы там будут отменяться. Промежуточные значения вроде «поставим 5 минут» обычно означают, что решение не принято: и отставание выросло, и отмены остались.

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

Чем платит primary: раздувание и как его удержать в рамках

Честно про обратную сторону. hot_standby_feedback не бесплатный: запрещая вычищать нужные реплике версии строк, вы откладываете работу VACUUM на primary. Мануал успокаивает разумной формулировкой — ситуация с очисткой не хуже той, что была бы, если бы эти же запросы выполнялись прямо на primary. Это правда, и это важный аргумент против паники. Но в реальности запросы на аналитической реплике живут дольше, чем кто-либо позволил бы им жить на боевой базе, и вот тут раздувание становится заметным.

На стенде «Эскулапа» это выглядело так: за первый месяц после включения обратной связи таблица оказанных услуг прибавила 1,3 ГБ при том, что объём полезных данных почти не изменился. Копали недолго — нашли экономиста, который держал открытый сеанс в DBeaver с незакрытой транзакцией по два-три дня. После ALTER ROLE с idle_in_transaction_session_timeout проблема исчезла, а сам объём вернулся в норму после планового VACUUM FULL в ночное окно, когда центр закрыт.

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

-- на primary: чей xmin держит горизонт
SELECT slot_name, active, xmin, catalog_xmin,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS wal_held
  FROM pg_replication_slots;

SELECT application_name, state, backend_xmin, write_lag, flush_lag, replay_lag
  FROM pg_stat_replication;

SELECT relname, n_live_tup, n_dead_tup, last_autovacuum
  FROM pg_stat_user_tables
 ORDER BY n_dead_tup DESC LIMIT 10;

Если backend_xmin реплики застыл и не двигается часами — у вас не «раздувание из-за hot_standby_feedback», у вас конкретная зависшая транзакция, и лечится она адресно.

Про recovery_min_apply_delay скажу отдельно, потому что его периодически предлагают как средство от отмен. Это не средство от отмен. Он задерживает применение коммитов на заданное время и нужен для другого — чтобы иметь копию данных «пять минут назад» и успеть спасти базу после случайного DELETE. Причём документация прямо предупреждает: hot_standby_feedback будет задержан использованием этой возможности, что может привести к раздуванию на primary, и совмещать их следует осторожно. Плюс отложенный WAL надо где-то хранить, и pg_wal на реплике вырастет пропорционально задержке.

vacuum_defer_cleanup_age, который до сих пор советуют в старых статьях, из PostgreSQL удалён начиная с версии 16 — как ненужный после появления hot_standby_feedback и слотов репликации. Если наткнулись на такой совет, статья устарела минимум на три года, и остальным её рекомендациям тоже стоит не доверять.
Памятка: Чем платит primary: раздувание и как его удержать в рамках — схема
Памятка: Чем платит primary: раздувание и как его удержать в рамках. Открыть схему в полном размере

Мониторинг: перестать гадать за один вечер

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

Минимальный набор такой. На реплике включаем log_recovery_conflict_waits = on и deadlock_timeout в 1 секунду, чтобы видеть, когда накат WAL реально стоит и кого он ждёт. Туда же — логирование самих отмен, они и так пишутся как ERROR, но полезно иметь их отдельно с текстом запроса:

log_recovery_conflict_waits = on
log_min_error_statement = error   # это и есть дефолт, фиксирую явно
log_line_prefix = '%m [%p] %q%u@%d/%a '

В Zabbix или что у вас стоит выносим три метрики: дельту по столбцам pg_stat_database_conflicts, лаг применения через pg_last_xact_replay_timestamp() и возраст горизонта на primary.

Хороший практический приём — считать не абсолютные значения confl_*, а прирост за интервал. Абсолютные бесполезны: они накапливаются с последнего сброса статистики, и большое число там может означать один плохой день полгода назад. Прирост же сразу показывает, отработала правка или нет, и попадает ли всплеск отмен в окно ночного пересчёта.

psql -Atc "SELECT sum(confl_snapshot+confl_bufferpin+confl_lock+confl_deadlock) FROM pg_stat_database_conflicts"

Эту строчку можно повесить на чтение раз в минуту и рисовать по ней производную.

И ещё одно, что часто выпадает из виду: посмотрите, как ведёт себя приложение. Отмена по конфликту с восстановлением — это штатная, ожидаемая и в общем-то восстановимая ошибка, код SQLSTATE 40001 (serialization_failure). Исключение — конфликт с DROP DATABASE: там сессия завершается целиком с кодом 57P04, и повторять запрос уже некуда. Нормально написанный отчётный слой должен уметь её распознать и повторить запрос, а не выкидывать пользователю красное окно. Если у вас BI-инструмент этого не умеет — иногда дешевле обернуть тяжёлые выгрузки в скрипт с одним ретраем, чем неделю тюнить репликацию.

Прежде чем менять параметры, снимите базовую линию хотя бы за сутки. Иначе вы не сможете отличить «стало лучше» от «просто сегодня ночью не было пересчёта остатков».

Чего я не делаю и где мнения расходятся

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

Не путаю max_standby_streaming_delay с max_standby_archive_delay и не правлю их «за компанию». Первый работает по WAL, приехавшему через стриминг, второй — по WAL, вычитанному из архива через restore_command. Второй важен ровно в двух случаях: при первоначальном восстановлении реплики и когда она долго была отключена и догоняет по архиву. В штатном режиме на стриминговой реплике он не срабатывает никогда, и его правка ничего не изменит.

Где действительно нет единого мнения — это в вопросе, включать ли hot_standby_feedback на нагруженных OLTP-системах с очень горячими таблицами. Есть лагерь, который считает, что аналитику вообще нельзя пускать в тот же контур, и вместо обратной связи предлагает логическую репликацию в отдельное хранилище. Аргумент сильный: там горизонт очистки primary вообще не зависит от того, сколько живут отчёты. Мой контраргумент прагматичный — логическая репликация это ещё одна система, которую надо строить, чинить и обслуживать, а для медцентра на 41 рабочее место физическая реплика с обратной связью и парой тайм-аутов закрывает вопрос за один вечер. Если у вас база на терабайты и десятки тысяч транзакций в секунду — да, разговор другой.

И последнее, про приоритеты. Если сейчас у вас падают отчёты и нет времени разбираться глубоко: снимите pg_stat_database_conflicts, и если там преобладает confl_snapshot — включайте hot_standby_feedback со слотом и тайм-аутами на отчётной роли. Это закроет проблему в подавляющем большинстве случаев. На тонкую настройку задержек, разбор буферных пинов и логическую репликацию можно спокойно забить до тех пор, пока цифры не покажут, что они вам действительно нужны.

Практический итог: в девяти случаях из десяти вопрос «куда делись мои тридцать секунд» закрывается не увеличением задержки, а включением обратной связи от реплики и наведением порядка с долгими транзакциями.

Частые вопросы

Почему запрос отменяется за 3 секунды, если max_standby_streaming_delay = 30s?

Потому что эти 30 секунд — не тайм-аут запроса, а общий бюджет задержки применения WAL, который отсчитывается с момента получения WAL репликой. Если предыдущие конфликты уже съели часть бюджета или порция WAL полежала в очереди, вашему запросу достанется остаток. Мануал прямо пишет, что параметры задержки сравниваются с временем, прошедшим с получения WAL репликой (elapsed time since the WAL data was received), а не с длительностью запроса.

Можно ли просто поставить max_standby_streaming_delay = -1 и забыть?

Можно, если реплика используется исключительно под отчётность и никогда не будет промоутиться в primary. Значение -1 разрешает ждать конфликтующие запросы бесконечно, и отставание применения WAL этим параметром больше не ограничивается — реплика может уйти в отставание на десятки минут. Для машины, которая числится горячим резервом, это неприемлемо: в момент переключения придётся ждать, пока она догонит.

hot_standby_feedback точно раздует базу на primary?

Он откладывает очистку мёртвых версий строк ровно на время жизни запросов на реплике. Документация справедливо замечает, что ситуация не хуже той, что была бы, если бы эти запросы выполнялись прямо на primary. Проблемы начинаются не от самой обратной связи, а от долгоживущих и зависших транзакций: поставьте statement_timeout и idle_in_transaction_session_timeout на отчётную роль, и раздувание останется в разумных пределах.

Чем отличается max_standby_archive_delay от max_standby_streaming_delay?

Первый применяется к WAL, который реплика читает из архива — при первоначальном восстановлении или когда догоняет после долгого простоя. Второй применяется к WAL, полученному через streaming replication. У обоих дефолт 30 секунд и у обоих -1 означает бесконечное ожидание. В штатно работающей стриминговой реплике срабатывает только второй.

Как понять, из-за чего именно отменяются запросы?

Посмотреть pg_stat_database_conflicts на самой реплике — заполнено оно только на standby, на primary счётчики нулевые. Преобладает confl_snapshot — это конфликт с очисткой строк, лечится hot_standby_feedback. Преобладает confl_lock — ищите DDL и явные LOCK на primary. confl_bufferpin — долгие открытые курсоры. Дополнительно включите log_recovery_conflict_waits, чтобы видеть, когда накат WAL реально стоит.

Помогает ли recovery_min_apply_delay избавиться от отмен?

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

Столкнулись с похожей задачей? Обращайтесь — решим

Если у вас происходит что-то из описанного в этой статье — или любая другая проблема с ИТ-инфраструктурой, — обращайтесь в любое время. Мои специалисты и я лично разберём ситуацию, найдём настоящую причину и доведём до решения.

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 💬 MAX: +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи