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

COMMIT прошёл, а реплика не видит заказ: разбираю remote_apply в PostgreSQL 18

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~27 мин чтения
COMMIT прошёл, а реплика не видит заказ: разбираю remote_apply в PostgreSQL 18
Иллюстрация к статье «COMMIT прошёл, а реплика не видит заказ: разбираю remote_apply в PostgreSQL 18».

Приложение создаёт заказ, транзакция завершается без ошибки, пользователя переводит в «Мои заказы» — и там пусто. Реплика работает, WAL передаётся, мониторинг не показывает аварии, но новая строка появляется только спустя некоторое время. Я разберу, почему режим synchronous_commit=on не обязан обеспечивать чтение только что записанных данных, что именно гарантирует remote_apply в PostgreSQL 18, где эта гарантия заканчивается и как получить read-after-write без лишней задержки для всех транзакций.

«Синхронная репликация» — это не одна гарантия

Когда разработчик приходит с багом «заказ пропал», я сначала разделяю две вещи: сохранность транзакции и её видимость для запроса на резервном сервере. При synchronous_commit = on первичный сервер ждёт, пока текущие синхронные реплики получат WAL-запись фиксации и сбросят её на устойчивое хранилище. Это защищает данные при отказе одного узла, если хранилище и сама конфигурация синхронной группы работают как задумано. Но такой ответ реплики ещё не означает, что startup-процесс успел воспроизвести запись и сделать новые версии строк доступными запросам.

Передача физической репликации состоит как минимум из нескольких наблюдаемых этапов. WAL sender отправляет данные, WAL receiver на реплике записывает и сбрасывает их, затем startup-процесс последовательно воспроизводит WAL. Между подтверждённым flush и replay бывает короткий интервал, а при конкуренции за процессор, диски или при конфликте recovery — заметная пауза. Именно в этот интервал укладывается последовательность «записали заказ на primary, немедленно прочитали список на standby, получили старый результат».

В таблице 19.1 документации PostgreSQL 18 режимы разведены явно. У on отмечены локальная устойчивость и устойчивость записи на синхронной реплике, но не согласованность запросов на standby. Последняя гарантия отмечена у remote_apply: первичный сервер возвращает успех после того, как требуемые синхронные участники получили WAL, записали его на устойчивое хранилище и сообщили о применении записи фиксации. Для нового запроса на таком standby транзакция уже может быть видна.

Есть важная оговорка: remote_apply не меняет правила MVCC. Запрос в уже открытой транзакции уровня REPEATABLE READ или SERIALIZABLE продолжит работать со старым снимком, даже если нужный WAL давно применён. В обычном READ COMMITTED каждый оператор получает новый снимок, поэтому SELECT, начатый после успешного COMMIT на primary и направленный на подтвердившую применение реплику, увидит результат. Формулировку «видно на реплике» я всегда читаю именно с этой оговоркой.

Ещё одна граница гарантии — состав синхронной группы. При приоритетной схеме primary ждёт выбранные реплики со статусом sync. При кворуме ANY 1 (rep_a, rep_b) достаточно ответа любой одной из двух: это не обещание, что конкретный COMMIT уже применён на обеих. Поэтому remote_apply даёт основу для причинно согласованного чтения, но роутер обязан направить запрос на действительно подтвердивший или проверенный узел. Простого признака sync_state = quorum для конкретной транзакции недостаточно.

Если `synchronous_standby_names` пуст, синхронная репликация выключена. В этой конфигурации `remote_apply`, `remote_write`, `local` и `on` дают один и тот же локальный уровень: все они ждут локального flush WAL и не ждут standby. Одна строка `synchronous_commit = remote_apply` без настроенной синхронной группы не обеспечивает чтение после записи.

Разбор практики: инженерное бюро «Проектсервис», 28 РМ

Стенд, на котором я в последний раз разбирал такой случай, принадлежал условному клиенту — инженерному бюро «Проектсервис», 28 РМ. На primary работал PostgreSQL 18: 16 vCPU, 64 ГБ RAM, NVMe под данные, размер базы — около 700 ГБ. Две физические реплики находились в том же ЦОД МТС, измеренный RTT между узлами составлял 0,2–0,4 мс. Третья реплика в другом городе использовалась для аварийного восстановления. Веб-интерфейс и внутренний портал записывали на primary, а списки и карточки читали с реплик.

Исходная конфигурация выглядела типично: synchronous_standby_names был задан как кворум ANY 1 (rep_a, rep_b), а глобальный synchronous_commit оставался в значении on. Администратор справедливо считал, что запись защищена синхронной репликацией. Разработчики сделали из этого лишний вывод: будто любой SELECT на rep_a или rep_b после успешного COMMIT обязан видеть заказ. Ни режим on, ни кворум из одного ответа такого обещания не дают.

Симптом проявлялся три-восемь раз в день: сотрудник оформлял заказ, открывал список и не находил новую строку, а позднее она появлялась. Быстрый сценарий выглядел как POST, ответ с перенаправлением и следующий GET через 60–200 мс. Запись выполнялась на primary, а GET уже попадал на одну из реплик. Сначала подозревали Redis и несколько часов проверяли инвалидирование кэша, но прямой SELECT на standby воспроизводил тот же результат.

Десятиминутный замер на primary показал, что flush_lag обычно составлял единицы миллисекунд. replay_lag в этом окне имел медиану около 9 мс, p99 около 420 мс и отдельные всплески до 6,5 секунды. Эти всплески совпадали с отчётами аналитиков на реплике и периодами, когда cleanup-записи VACUUM с primary встречались с recovery-конфликтами. Здесь важно правильно прочитать метрику: документация описывает replay_lag как недавно измеренную задержку, которую внёс бы уровень remote_apply, а не как прогноз времени полного устранения накопленного отставания.

Первым изменением стала липкость к primary: после успешной записи пользовательская сессия в течение следующих 5 секунд отправляла связанные чтения на тот же primary. Вторым — точечный remote_apply для оформления заказа, смены статуса и проведения оплаты. Для этих операций сервис мог читать только с текущей приоритетной синхронной реплики и дополнительно проверял целевой LSN при смене участника; произвольное чтение с любого узла кворума мы гарантией не считали. Третьим изменением стал перенос аналитических отчётов на отдельную асинхронную реплику.

Через две недели жалобы прекратились. p95 времени COMMIT для выбранного класса транзакций вырос с 3,1 до 11 мс, а общий TPS заметно не изменился: такие операции составляли примерно 6 % потока. Эти числа относятся только к стенду инженерного бюро «Проектсервис», 28 РМ, и не являются нормативом PostgreSQL. На другой сети, хранилище и нагрузке результат нужно измерять заново.

Большой `replay_lag` не «лечится» режимом `remote_apply`: этот режим не ускоряет воспроизведение WAL, а переносит ожидание со следующего SELECT на COMMIT. Причину отставания — конфликты recovery, недостаток CPU или I/O, поток WAL, паузы сети — всё равно нужно устранить.
COMMIT прошёл, а реплика не видит заказ: разбираю remote_apply в PostgreSQL 18 — схема
Схема к статье. Открыть схему в полном размере

Как я настраиваю синхронную группу и remote_apply

Синхронная группа определяется на primary параметром synchronous_standby_names. Физическая реплика передаёт своё имя через application_name в строке primary_conninfo. Если имя не задано, PostgreSQL использует cluster_name, когда он настроен, а иначе — walreceiver. Поэтому реплика может исправно передавать WAL и одновременно оставаться async, если её фактическое имя не совпало со списком на primary.

Я не привязываю инструкцию к пути вроде /etc/postgresql/18/main/postgresql.conf: он зависит от пакета, операционной системы и способа запуска кластера. Фактическое расположение основного файла и каталога данных надёжнее спросить у самого сервера. postgresql.auto.conf находится в каталоге данных и заполняется командой ALTER SYSTEM; вручную редактировать этот файл не следует.

SHOW config_file;
SHOW data_directory;
SHOW hba_file;

На standby параметры можно хранить в основном конфигурационном файле либо задавать через ALTER SYSTEM. Пример ниже использует допустимые имена и единицы измерения PostgreSQL 18. Путь к password-файлу — пример для системной учётной записи postgres; в конкретной установке он может быть другим. На Unix файл паролей должен принадлежать нужному пользователю и иметь права не шире 0600, иначе libpq его проигнорирует.

# standby rep_a
primary_conninfo = 'host=10.10.10.11 port=5432 user=replicator application_name=rep_a passfile=/var/lib/postgresql/.pgpass'
hot_standby = on
hot_standby_feedback = on
wal_receiver_status_interval = 5s
max_standby_streaming_delay = 15s

У hot_standby значение по умолчанию — on, и его изменение требует перезапуска. У hot_standby_feedback значение по умолчанию — off; включение уменьшает число конфликтов с cleanup-записями, но может задерживать очистку мёртвых строк на primary и усиливать раздувание таблиц. Значение wal_receiver_status_interval по умолчанию равно 10 секундам, а max_standby_streaming_delay — 30 секундам. Последний параметр не является тайм-аутом отдельного запроса: это суммарное допустимое время задержки применения уже полученного потокового WAL. Значения 5 и 15 секунд в примере — осознанные настройки разобранного стенда, а не универсальная рекомендация.

На primary я обычно оставляю глобальный уровень on, а кворум собираю как минимум из двух кандидатов при требовании одного ответа. Потеря одного кандидата тогда не останавливает запись, если второй подключён, находится в состоянии streaming и подходит под список. Настройка перечитывается по SIGHUP; перезапуск primary для неё не требуется.

# primary
synchronous_standby_names = 'ANY 1 (rep_a, rep_b)'
synchronous_commit = on

Для отдельной транзакции режим меняется локально. Значение synchronous_commit определяется в момент COMMIT, а SET LOCAL действует только до завершения текущей транзакции. Вне явного транзакционного блока такая команда выдаёт предупреждение и не оказывает нужного эффекта.

BEGIN;
SET LOCAL synchronous_commit = remote_apply;

INSERT INTO orders (customer_id, total, status)
VALUES (10241, 18990.00, 'new')
RETURNING id;

COMMIT;

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

ALTER ROLE order_writer SET synchronous_commit = 'remote_apply';
ALTER DATABASE orders_db SET synchronous_commit = 'remote_apply';

После настройки я проверяю не файл, а фактическое состояние. Реплика может стать синхронной только после подключения и перехода из catchup в streaming. Приоритетная схема показывает выбранный узел как sync, резервных кандидатов — как potential; участники кворумной схемы показываются как quorum. Последнее означает участие в кворуме, но не доказывает, что именно этот узел подтвердил последний COMMIT.

Кворум повышает доступность записи, но усложняет маршрутизацию строгих чтений. При `ANY 1` нельзя без дополнительной проверки считать обе реплики одинаково свежими. Для гарантии на произвольно выбранном узле используйте LSN-токен, читайте с primary либо ждите применение на всех узлах, принимая соответствующую цену.
Памятка: Как я настраиваю синхронную группу и remote_apply — схема
Памятка: Как я настраиваю синхронную группу и remote_apply. Открыть схему в полном размере

Три рабочих способа обеспечить чтение своих записей

Первый способ — липкость к primary. После записи приложение помечает пользовательскую сессию и временно отправляет связанные SELECT на узел, где произошёл COMMIT. В инженерном бюро «Проектсервис», 28 РМ мы использовали окно 5 секунд, потому что оно с запасом перекрывало наблюдавшийся рабочий хвост задержки, а редкие более длинные ситуации ловились мониторингом. Это прикладная политика, а не параметр PostgreSQL.

У липкости нет дополнительного ожидания COMMIT и нет зависимости от состава синхронного кворума. Цена — некоторое число чтений возвращается на primary, а приложение должно сохранить признак записи между HTTP-запросами. Для большинства небольших OLTP-систем это самый понятный старт: сначала закрыть пользовательский сценарий, затем решать, оправдана ли более сложная координация.

Второй способ — remote_apply для выбранных транзакций. Он полезен, когда после записи читает другой процесс, которому неудобно передавать состояние пользовательской сессии: обработчик интеграции, соседний сервис или фоновая задача. База задерживает успешный ответ до подтверждения применения, но гарантия относится только к требуемым участникам синхронной схемы. При ANY 1 произвольная реплика из списка всё ещё может отставать.

Третий способ — передавать позицию WAL как causal token. После COMMIT приложение на том же соединении с primary получает текущую позицию вставки WAL. Она может быть немного дальше записи фиксации нужной транзакции из-за параллельной активности, но это безопасный консервативный барьер: если standby воспроизвёл эту позицию, нужный COMMIT уже воспроизведён. Снимать позицию до COMMIT нельзя — такой токен может предшествовать записи фиксации и дать ложный положительный результат.

-- Выполняется на том же primary-соединении после успешного COMMIT.
SELECT pg_current_wal_insert_lsn() AS causal_lsn;

-- Пример проверки полученного токена на standby.
SELECT pg_last_wal_replay_lsn() >= '3/A21C4F80'::pg_lsn AS fresh_enough,
       GREATEST(
           pg_wal_lsn_diff('3/A21C4F80'::pg_lsn, pg_last_wal_replay_lsn()),
           0
       ) AS bytes_to_target;

В PostgreSQL 18 нет встроенной SQL-функции, которая на физической реплике блокирующе ждёт произвольный целевой LSN. В разделе 9.28.4 есть функции получения последней принятой и воспроизведённой позиции, проверки recovery, паузы и возобновления replay, а также повышения standby до primary, но функции ожидания заданной позиции нет. Поэтому ожидание ограничивают на стороне приложения: несколько коротких проверок, затем чтение с primary или понятная ошибка по истечении общего бюджета.

На стенде из разбора использовали четыре проверки с интервалом 25 мс, то есть не более 100 мс перед переходом на primary. Это не значение из документации, а прикладной бюджет конкретного интерфейса. Для пакетной интеграции можно разрешить больше, для интерактивного запроса — меньше. Бесконечный цикл опасен тем, что при остановленном replay, сетевом разрыве или старом timeline запрос никогда не выполнит бизнес-задачу.

LSN имеет смысл внутри одного происхождения WAL. После переключения primary и расхождения timeline приложение должно инвалидировать старые токены либо сопровождать их идентификатором кластера и эпохой primary. Простое числовое сравнение позиций из разошедшихся ветвей не доказывает причинную связь.

Параметр libpq `target_session_attrs` выбирает сервер по его роли и способности принимать запись. Режимы `any`, `read-write`, `read-only`, `primary`, `standby` и `prefer-standby` не проверяют, воспроизведён ли конкретный COMMIT. Настройка пула соединений сама по себе read-after-write не обеспечивает.

Цена remote_apply: задержка, блокировки и доступность

Минимальная добавка синхронной репликации связана с сетевым round-trip до требуемой реплики. У remote_apply к нему добавляется время записи, flush и воспроизведения WAL до записи фиксации. В одной стойке это может укладываться в миллисекунды, а между городами нижняя граница уже определяется задержкой сети. Значение нельзя честно предсказать по названию режима: его измеряют на реальной топологии и на хвостах распределения, а не только по среднему.

Ожидание подтверждения не крутит процессор в активном цикле, но backend остаётся занят, а транзакционные блокировки продолжают удерживаться до подтверждения. Поэтому проблема часто проявляется не как высокий CPU, а как рост числа ожидающих сессий, более длинные очереди блокировок и исчерпание пула соединений. Чем мельче и конкурентнее OLTP-транзакции, тем заметнее этот эффект.

Read-only транзакции и откаты не требуют ответа синхронной реплики. Коммиты подтранзакций тоже не ждут отдельно — ожидание приходится на верхнеуровневый COMMIT. Двухфазные транзакции ждут на действиях PREPARE и COMMIT PREPARED. Большая загрузка в одной транзакции получает одно финальное ожидание вместо ожидания на каждой строке, но оно может оказаться долгим: standby обязан воспроизвести весь предшествующий WAL до записи фиксации.

Текущее ожидание удалённого подтверждения видно в pg_stat_activity как событие типа IPC с именем SyncRep. Одноимённое событие существует и среди внутренних lightweight locks, поэтому для диагностики ожидания клиента я фильтрую одновременно тип и имя.

SELECT pid,
       usename,
       application_name,
       now() - xact_start AS transaction_age,
       wait_event_type,
       wait_event,
       query
FROM pg_stat_activity
WHERE wait_event_type = 'IPC'
  AND wait_event = 'SyncRep'
ORDER BY xact_start;

Главный риск для доступности возникает, когда доступных синхронных участников становится меньше требуемого числа. При ANY 1 из двух потеря одной реплики не мешает COMMIT, пока вторая подключена и находится в streaming. При ANY 2 из трёх запись остановится после потери двух подходящих участников. В приоритетной схеме единственный текущий sync автоматически заменяется следующим доступным кандидатом после отключения, но подключённая и зависшая реплика может удерживать ожидание до вмешательства автоматики или дежурного.

Аварийное изменение должно быть заранее согласовано, потому что оно ослабляет гарантию сохранности. Команда ALTER SYSTEM записывает значение в postgresql.auto.conf, не выполняется внутри транзакционного блока и требует прав суперпользователя либо отдельной привилегии на параметр. После восстановления я возвращаю исходное значение явно; если штатное значение хранится в основном конфигурационном файле, вместо второго изменения можно удалить override через RESET.

-- Аварийный режим: перестать ждать standby.
ALTER SYSTEM SET synchronous_standby_names = '';
SELECT pg_reload_conf();

-- После восстановления кворума вернуть согласованную настройку.
ALTER SYSTEM SET synchronous_standby_names = 'ANY 1 (rep_a, rep_b)';
SELECT pg_reload_conf();

Есть ещё одна официально описанная ловушка. Если standby нужно пересоздать в момент, когда транзакции уже ждут его появления, вызовы начала и окончания низкоуровневого резервного копирования следует выполнять в сессии с synchronous_commit = off. Иначе сами запросы резервного копирования могут ждать отсутствующий standby. Это относится именно к соответствующей процедуре низкого уровня; для штатного pg_basebackup нужно следовать его отдельной документации и принятому регламенту.

Универсального тайм-аута, после которого синхронный COMMIT сам превратится в асинхронный, нет. Такое автоматическое ослабление гарантии должно выполнять внешнее средство управления кластером по вашей политике либо дежурный по утверждённой процедуре.
Памятка: Цена remote_apply: задержка, блокировки и доступность — схема
Памятка: Цена remote_apply: задержка, блокировки и доступность. Открыть схему в полном размере

Почему standby отстаёт и чего remote_apply не исправляет

WAL на физической реплике воспроизводит startup-процесс. Реплика должна пройти записи в порядке WAL, поэтому тяжёлый поток изменений, массовые обновления, обслуживание индексов и недостаток I/O могут сделать replay узким местом. remote_apply не добавляет вычислительных ресурсов и не распараллеливает этот путь: primary просто ждёт, пока реплика доберётся до нужной записи фиксации.

Отчёт на standby способен влиять двумя путями. Во-первых, он конкурирует за CPU, память и чтение с дисков. Во-вторых, запрос может вступить в recovery-конфликт с WAL-записью, которую нужно применить. При потоковой репликации max_standby_streaming_delay задаёт не максимальную длительность одного SELECT, а допустимую суммарную задержку применения уже полученных WAL-данных. Значение по умолчанию в PostgreSQL 18 — 30 секунд; после исчерпания бюджета конфликтующий запрос может быть отменён.

hot_standby_feedback помогает от конфликтов, связанных с удалением версий строк: standby сообщает upstream информацию о снимках запросов, и VACUUM на primary дольше сохраняет нужные версии. Значение по умолчанию — off. Включение не устраняет конфликты блокировок, удаления табличных пространств и другие виды recovery-конфликтов, зато способно увеличить bloat на primary. Я принимаю это решение отдельно для каждой роли реплики, а причины отмен запросов смотрю в pg_stat_database_conflicts.

SELECT datname,
       confl_tablespace,
       confl_lock,
       confl_snapshot,
       confl_bufferpin,
       confl_deadlock
FROM pg_stat_database_conflicts
ORDER BY datname;

Самый неприятный архитектурный капкан — чтение с узла, который не участвовал в подтверждении. Реплика со статусом async не даёт гарантии remote_apply вообще. Реплика potential является лишь запасным кандидатом приоритетной схемы. Статус quorum означает, что узел входит в набор кандидатов, но при ANY 1 не сообщает, какой кандидат ответил за конкретную транзакцию. Поэтому правило «читаем с любого sync или quorum» слишком грубое.

Даже в приоритетной схеме возможна гонка смены состава. COMMIT мог получить подтверждение от rep_a, после чего она отключилась, а роутер направил SELECT на только что назначенную rep_b, которая ещё не достигла нужной позиции. Для строгого сценария при переключениях я выбираю primary или проверяю causal LSN. Одна лишь периодическая проверка sync_state не связывает конкретный запрос с конкретным COMMIT.

Кворум `ANY 2 (a, b, c)` гарантирует подтверждение любых двух участников, а не всех трёх. Если чтение можно отправить на любой из трёх и нужно строгое read-after-write, проверяйте LSN, читайте с primary или требуйте подтверждения всех трёх, заранее оценив влияние на доступность.

Как правильно читать lag и строить мониторинг

На primary представление pg_stat_replication показывает позиции sent_lsn, write_lsn, flush_lsn и replay_lsn, а также интервалы write_lag, flush_lag и replay_lag. Эти интервалы измеряют время прохождения недавнего WAL до соответствующего этапа и получения ответа WAL sender. Документация прямо связывает flush_lag с задержкой уровня on, а replay_lag — с задержкой уровня remote_apply.

У этих колонок есть ловушка. Они не показывают расчётное время, за которое standby ликвидирует весь накопленный backlog при текущей скорости. На полностью догнавшем, но активном узле значение может оставаться ненулевым как длительность последнего измеренного прохождения WAL. После некоторого времени без WAL-активности значения становятся NULL. Поэтому нельзя бездумно превращать NULL в аварию или трактовать любое ненулевое значение как текущее отставание.

Для оценки накопленного объёма я дополняю интервалы разностью LSN. На primary текущая позиция WAL сравнивается с последней позицией replay, которую сообщил каждый standby. Это количество байтов, а не время: оно хорошо показывает рост очереди, но не предсказывает срок её устранения без знания скорости генерации и воспроизведения WAL.

SELECT application_name,
       client_addr,
       state,
       sync_state,
       sync_priority,
       write_lag,
       flush_lag,
       replay_lag,
       pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replay_gap_bytes
FROM pg_stat_replication
ORDER BY application_name;

Большой flush_lag означает задержку на пути от локального flush primary до подтверждённого flush standby. Причиной могут быть сеть, пропускная способность, планирование процессов, перегруженное хранилище реплики или сочетание факторов — сводить диагноз только к дискам нельзя. Если flush_lag мал, а replay_lag растёт, данные быстро доставляются и сохраняются, но воспроизведение или отправка статуса применения отстаёт.

Порог алерта я привязываю к пользовательскому SLO. Для разобранного стенда предупреждение срабатывало при replay_lag выше 1 секунды, но вместе с ним проверялись разность LSN, состояние streaming, наличие требуемого числа участников и recovery-конфликты. Один фиксированный порог без этих сигналов даёт много ложных выводов.

Наконец, сами интервалы надо собирать достаточно часто и хранить как временной ряд. Разовый снимок после жалобы может показать NULL на догнавшей реплике или нормальное значение между всплесками. Для решения о глобальном remote_apply меня интересуют p95 и p99 задержки в часы рабочей нагрузки, частота SyncRep, длительность блокировок и изменения времени ответа приложения.

`replay_lag` — полезная оценка недавней цены `remote_apply`, но не таймер до полного catch-up. Для диагностики всегда сопоставляйте интервал, LSN-разрыв, состояние репликации и статистику recovery-конфликтов.

Мой порядок внедрения без глобальной авантюры

Я начинаю не с переключателя, а с воспроизводимого маршрута запроса. Нужно доказать, на каком узле произошёл COMMIT, куда ушёл следующий SELECT, какой у него уровень изоляции и не осталась ли транзакция чтения открытой со старым снимком. Без этого легко принять кэш, долгую транзакцию или ошибку маршрутизации за отставание физической реплики.

Затем я неделю собираю flush_lag, replay_lag, LSN-разрыв, состояния реплик и события SyncRep. Одновременно проверяю совпадение application_name со списком synchronous_standby_names. Если пользовательский сценарий закрывается короткой липкостью к primary, внедряю её первой: она не увеличивает длительность пишущих транзакций и остаётся корректной при смене состава реплик.

Точечный remote_apply добавляю для операций, где чтение выполняет другой процесс и где роутер знает подтвердивший узел либо проверяет LSN. После этого сравниваю распределения времени COMMIT и блокировок до и после изменения. Глобальный режим оправдан, когда все записи действительно требуют такой семантики, реплики расположены близко, их replay стабилен, а остановка записи при нехватке участников соответствует требованиям бизнеса.

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

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

Практический вывод: `remote_apply` — не ускоритель реплики и не глобальная гарантия для любого узла балансировщика. Это барьер на COMMIT, который работает вместе с правильно выбранной синхронной группой, свежим снимком запроса и осознанной маршрутизацией.
Порядок действий: Мой порядок внедрения без глобальной авантюры — схема
Порядок действий: Мой порядок внедрения без глобальной авантюры. Открыть схему в полном размере

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

Почему при synchronous_commit=on standby не видит только что закоммиченную строку?

Потому что `on` ждёт получения и устойчивого flush WAL на требуемых синхронных репликах, но не ждёт replay. Применение выполняется позже startup-процессом. Гарантия видимости для нового снимка появляется у `remote_apply`, причём только на участниках, чьё подтверждение удовлетворило выбранную схему.

Гарантирует ли remote_apply свежие данные на любой реплике из ANY 1?

Нет. При `ANY 1 (rep_a, rep_b)` достаточно подтверждения одной из двух реплик. Вторая в этот момент может ещё отставать, хотя обе отображаются как кандидаты `quorum`. Для произвольного выбора узла нужен causal LSN, чтение с primary или подтверждение всех реплик, на которые может попасть запрос.

Насколько remote_apply замедляет COMMIT?

Добавка включает сетевой round-trip, запись и flush на standby, а также replay до записи фиксации. На стенде инженерного бюро «Проектсервис», 28 РМ p95 выбранного класса транзакций вырос с 3,1 до 11 мс при доле около 6 % потока. Это результат конкретного стенда; для своей системы цену оценивают по времени COMMIT, `replay_lag` и хвостам распределения.

Можно ли включить remote_apply только для части транзакций?

Да. `synchronous_commit` является параметром уровня пользователя, а поведение транзакции определяется значением в момент COMMIT. Локальная настройка внутри явной транзакции действует до её завершения; глобальный режим при этом можно оставить `on`.

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

Запись продолжится, если доступных подходящих участников осталось не меньше требуемого числа. Например, `ANY 1` из двух переживает отказ одного подключённого кандидата. Если требуемое число подтверждений получить неоткуда, COMMIT может ждать неограниченно долго, пока участник не вернётся или конфигурацию не изменят.

Есть ли в PostgreSQL 18 функция ожидания заданного LSN на standby?

Нет. PostgreSQL 18 предоставляет `pg_last_wal_replay_lsn()` для проверки применённой позиции, но не блокирующую функцию ожидания произвольного целевого LSN. Приложение должно опрашивать позицию с ограниченным общим тайм-аутом и затем переходить на primary либо завершать запрос контролируемой ошибкой.

Решит ли target_session_attrs проблему свежести?

Нет. Этот параметр libpq выбирает сервер по роли и возможности принимать запись. Он не связывает соединение с конкретным COMMIT и не проверяет replay LSN. Свежесть обеспечивают маршрутизация на primary, `remote_apply` для известного участника или сравнение causal LSN.

Почему нельзя считать replay_lag временем до полного устранения отставания?

Это измеренная длительность прохождения недавнего WAL до replay и получения ответа sender, а не прогноз catch-up. На активной догнавшей реплике значение может быть ненулевым, а после простоя стать NULL. Для backlog дополнительно смотрят разность LSN и скорость её изменения.

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

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

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

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

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

Источники

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