· 15 мин чтения

Синхронная репликация в PostgreSQL 18: когда COMMIT ещё не значит «данные видны на реплике»

У одного из наших клиентов интернет-магазин на PostgreSQL 18 с синхронной репликой отдавал клиенту «заказ не найден» в первые 100-300 мс после успешной оплаты — притом что COMMIT в логах приложения был зелёный, а деньги списаны. Причина оказалась не в багах бэкенда и не в кэше, а в том, что synchronous_commit был выставлен в <code>on</code>, а команда была уверена, что это и есть «полная синхронность». Разбираю, что именно гарантирует каждый уровень synchronous_commit, почему «записано на резервный сервер» и «применено и видно SELECT-ом» — разные события, и когда без remote_apply обойтись нельзя. Материал построен на нашей внутренней методологии настройки PostgreSQL-кластеров для клиентов с высоконагруженными веб-сервисами и опирается на официальную документацию PostgreSQL 18.

Симптом: деньги списаны, а заказ «не найден»

Схема у клиента типовая: приложение пишет заказ и статус оплаты в основной сервер PostgreSQL 18, коммит подтверждён, тут же балансировщик запросов на чтение (pgbouncer + собственный роутер read/write) отправляет следующий GET-запрос карточки заказа на реплику — чтобы не грузить мастер. В 2-4% случаев GET возвращал 404: реплика ещё не применила транзакцию. Проблема проявлялась не постоянно, а волнами — в часы пиковой нагрузки (вечерние распродажи, рассылки с промокодами), что сразу увело первую версию расследования в сторону кэша Redis и таймаутов CDN — там ничего не нашли.

Мы подняли pg_stat_replication на мастере и увидели ожидаемую картину: write_lsn и flush_lsn у реплики совпадали с LSN коммита практически сразу, а replay_lsn отставал на десятки-сотни миллисекунд под нагрузкой. synchronous_commit = on ждёт именно flush на резервном сервере, а не применение (replay) — то есть WAL гарантированно долетел и лёг на диск реплики, но воркер восстановления (startup process в режиме hot standby) ещё не успел проиграть его в файлы данных. SELECT после такого COMMIT видит старую версию строки или не видит строку вовсе.

Важная деталь для тех, кто впервые сталкивается с этой картиной: с точки зрения durability (сохранности данных) всё было настроено корректно — при обрыве сети или падении мастера ни одна подтверждённая транзакция не терялась, WAL уже лежал на диске реплики. Проблема была ровно в одном узком месте — consistency чтения сразу после записи (read-your-writes), а это отдельная гарантия, за которую отвечает не сохранность WAL, а момент его применения.

Что на самом деле проверяет каждый уровень synchronous_commit

В PostgreSQL 18 параметр synchronous_commit — это не переключатель «вкл/выкл синхронность», а шкала из пяти уровней, каждый из которых фиксирует момент подтверждения клиенту на определённой стадии пути WAL от мастера до данных на реплике: локальная запись → отправка → получение → флаш на диск реплики → применение (replay) к данным.

УровеньЧто должно произойти до ответа клиенту COMMIT OKРиск при отказе мастера
offWAL записан только в буфер локально, fsync можно не ждатьПотеря последних транзакций даже без отказа реплики — это про производительность, не про DR
localWAL сброшен на диск (fsync) на самом мастере, реплики не участвуютТранзакция подтверждена, но могла не долететь ни до одной реплики
remote_writeСинхронная реплика подтвердила, что приняла WAL и передала его операционной системе (write), но не обязательно на дискПри падении ОС реплики (не только PostgreSQL) до fsync данные можно потерять
on (значение по умолчанию)Синхронная реплика подтвердила flush_lsn — WAL сброшен на её диск через fsyncWAL переживёт падение реплики, но ещё не применён — SELECT на ней может не видеть строку
remote_applyСинхронная реплика подтвердила replay_lsn — транзакция уже проиграна в данные и видна для read-only запросов на этой репликеСамый высокий уровень задержки коммита, зато read-after-write на реплике гарантирован

Ключевой вывод для архитектуры read/write-разделения: всё, что ниже remote_apply, гарантирует сохранность WAL, а не готовность строки к чтению. Документация PostgreSQL прямо описывает remote_apply как режим, в котором коммит ждёт, пока синхронные реплики отрапортуют о применении транзакции, «делая её видимой для запросов пользователя» — это единственный уровень с таким определением.

На практике мы регулярно видим одну и ту же путаницу в головах разработчиков: слово «синхронная» в названии режима репликации воспринимается как синоним «мгновенно согласованная». Это не так — «синхронная» здесь означает только то, что мастер ждёт ответа от реплики перед подтверждением коммита, а не то, на каком именно шаге конвейера этот ответ получен. Уровень on был выбран разработчиками PostgreSQL значением по умолчанию для synchronous_commit ровно потому, что для абсолютного большинства сценариев (durability при отказе мастера) применения на реплике ждать не нужно — и это осознанный компромисс между задержкой и гарантиями, а не недоработка.

Анатомия LSN: write, flush, replay — три разных финиша

Чтобы объяснить это разработчикам без раскопок в исходниках, мы используем упрощённую модель трубопровода. У каждой транзакции есть позиция в журнале — LSN (log sequence number), которую можно получить на мастере функцией pg_current_wal_lsn(). Дальше она проходит четыре стадии, и каждая фиксируется отдельным столбцом в pg_stat_replication на мастере:

Разрыв между flush_lsn и replay_lsn — это и есть «слепая зона», в которой живёт баг read-after-write. Под нагрузкой на нашем стенде (PostgreSQL 18, 4 vCPU, NVMe, ~1200 tps на мастере) этот разрыв в среднем держался в пределах 5-15 мс, но при пиках batch-обработки (массовое обновление остатков склада) разрастался до 300-800 мс — этого достаточно, чтобы фронтенд, дергающий реплику сразу после ответа API, поймал пустой результат.

Причина разрастания разрыва именно на batch-нагрузке объясняется тем, что процесс восстановления на реплике (startup process) в режиме hot standby однопоточный для последовательности применения WAL — он не может распараллелить применение записей одной временной шкалы так же свободно, как параллельные backend-процессы на мастере пишут данные. Если на мастере параллельно работают десятки соединений, генерирующих WAL, а на реплике этот поток проигрывается последовательно одним процессом, отставание накапливается именно в пиковые интервалы, а не растёт линейно с общей нагрузкой.

Проверить разрыв руками можно одним запросом на мастере:

SELECT application_name, client_addr, write_lsn, flush_lsn, replay_lsn, write_lag, flush_lag, replay_lag FROM pg_stat_replication;

А на самой реплике — сверить, где она сейчас находится относительно мастера:

SELECT pg_last_wal_replay_lsn(), pg_last_wal_receive_lsn();

Если первое значение меньше второго — WAL получен, но ещё не весь применён; именно эта разница в байтах (через pg_wal_lsn_diff()) и есть та часть отставания, из-за которой SELECT на реплике не видит только что подтверждённую транзакцию.

Когда remote_apply обязателен, а когда — избыточен

Мы не ставим remote_apply по умолчанию везде — это самый дорогой по задержке уровень, он ждёт не только сеть и диск реплики, но и скорость её процесса восстановления. Решение принимаем по сценарию использования реплики:

Частая ошибка, которую мы правим на аудитах: synchronous_commit выставляют глобально в postgresql.conf, хотя это параметр уровня сессии/транзакции. Правильный паттерн — оставить on глобальным дефолтом для обычной нагрузки и явно повышать до remote_apply только для конкретной транзакции, где это нужно:

BEGIN; SET LOCAL synchronous_commit = remote_apply; INSERT INTO orders(id, status) VALUES (42, 'paid'); COMMIT;

Так тяжёлая транзакция чекаута ждёт применения на реплике, а массовые фоновые вставки продолжают коммититься на уровне on без лишней задержки. Второй рабочий приём — задавать нужный уровень не в самом SQL, а в connection string пула на уровне отдельной пары хостов приложения, которые обслуживают именно «горячие» ручки чекаута; так изменение локализовано в конфигурации инфраструктуры, а не разбросано по коду бизнес-логики.

Отдельно стоит сценарий, который мы встречаем у клиентов с собственной разработкой мобильных API: read-your-writes нужен не всей странице, а одному конкретному полю — например, счётчику непрочитанных уведомлений, который клиент дергает сразу после отправки события. В таких случаях мы иногда вместо remote_apply на всю транзакцию делаем короткий retry с backoff на уровне API-хендлера (2-3 попытки SELECT с интервалом 20-50 мс) — это дешевле по инфраструктуре, если случаи чтения-сразу-после-записи единичные, а не системные.

Конфигурация: synchronous_standby_names и кворум

synchronous_commit работает только вместе с непустым synchronous_standby_names — без него remote_write, on и remote_apply дают один и тот же локальный уровень синхронности, как если бы синхронных реплик не было вовсе (это прямо отмечено в документации: при пустом synchronous_standby_names все «remote»-режимы деградируют до эквивалента on без реплик). Настройка делается в postgresql.conf на мастере, применяется без перезапуска (reload-параметр):

synchronous_standby_names = 'FIRST 1 (replica_msk_1, replica_msk_2)' synchronous_commit = on

Форма FIRST задаёт приоритетный список: синхронной становится первая по порядку доступная реплика из скобок, вторая остаётся резервной (potential) и подхватывает роль синхронной автоматически, если первая отвалится. Для сценариев с несколькими равноправными репликами (например, две реплики в разных стойках одного ЦОД) удобнее кворумная форма — коммит ждёт подтверждения от любых N из списка, без жёсткого приоритета:

synchronous_standby_names = 'ANY 1 (replica_msk_1, replica_msk_2, replica_msk_3)'

Имя в скобках — это не hostname, а значение application_name, которое реплика передаёт в строке подключения walreceiver. На стороне реплики это задаётся в её primary_conninfo:

primary_conninfo = 'host=10.10.0.11 port=5432 user=replicator application_name=replica_msk_1'

После изменения synchronous_standby_names обязательно проверяем, что реплика реально попала в список синхронных, а не осталась потенциальной (candidate). Признак — непустой sync_state = sync в pg_stat_replication на мастере; значение potential означает, что реплика в списке, но пока не активна как синхронная (например, ещё не подключилась или впереди неё по приоритету стоит другая реплика).

Ещё одна деталь, которую пропускают при первой настройке: если synchronous_standby_names ссылается на application_name, а не на реальный hostname, то при клонировании реплики (например, через pg_basebackup для добавления третьего узла) новый сервер по умолчанию наследует старое имя из primary_conninfo исходной реплики — и тогда на мастере оказываются два подключения с одинаковым application_name, что ломает и приоритетную, и кворумную схему непредсказуемым образом. Это правим на этапе клонирования, до первого подключения нового узла к мастеру.

Побочные эффекты remote_apply: чем платим за гарантию чтения

Поднимая уровень до remote_apply, мы меняем не только задержку коммита — вместе с ней в игру вступают ещё три параметра, которые обязательно проговариваем с клиентом до включения в проде.

ПараметрЧто делаетНаша практика
hot_standby_feedbackРеплика сообщает мастеру о своих активных снимках (snapshot), чтобы автовакуум на мастере не чистил строки, ещё нужные читающим транзакциям на репликеВключаем (on) на репликах с remote_apply — иначе долгие SELECT на реплике начинают ловить ошибку «canceling statement due to conflict with recovery»; отслеживаем раздувание (bloat) на мастере отдельно
max_standby_streaming_delayСколько реплика готова откладывать применение WAL ради завершения конфликтующего read-only запроса, прежде чем всё равно его прерватьДержим порядка 30 секунд как оценку по практике — баланс между «не рвать длинные отчётные запросы» и «не наращивать отставание replay_lsn, от которого зависит remote_apply»
recovery_min_apply_delayИскусственная задержка применения WAL на реплике — используется для «реплики-страховки от человеческой ошибки» (DROP TABLE прилетит с задержкой)Никогда не ставим на реплику, которая участвует в synchronous_standby_names с remote_apply — это напрямую и сознательно увеличивает время коммита на мастере на величину задержки, легко получить таймауты приложения

Отдельно считаем стоимость самого remote_apply в миллисекундах. На нашем эталонном стенде (мастер и синхронная реплика в одном ЦОД, сеть <1 мс) разница между on и remote_apply на простой одностроковой транзакции составила 3-9 мс дополнительно — в основном это время работы процесса восстановления реплики, а не сети. Если реплика в другом ЦОД с сетевой задержкой 10-20 мс, remote_apply может удвоить время коммита — это нужно закладывать в SLA API до включения, а не после жалоб клиента.

Есть и менее очевидный побочный эффект: при remote_apply рост нагрузки на CPU реплики (например, конкурентный отчётный запрос, съедающий процессор) напрямую замедляет коммиты на мастере, потому что процессу восстановления не хватает вычислительных ресурсов для своевременного применения WAL. Мы рекомендуем на репликах с remote_apply держать отдельный, явно ограниченный пул для тяжёлых аналитических запросов (через statement_timeout и отдельную роль с ограничением по work_mem), чтобы один неудачный отчёт не превратился в замедление всего чекаута на мастере.

Мониторинг: как поймать отставание раньше, чем его поймает клиент

Одного факта «remote_apply включён» недостаточно — если синхронная реплика упадёт или начнёт деградировать, поведение мастера зависит от режима отказа, который тоже нужно решить заранее. Мы ставим в Zabbix (у части клиентов — Zabbix 7.0, у части свой скрипт-агент) три проверки поверх стандартного мониторинга PostgreSQL:

Именно последний пункт — самая частая причина, по которой клиенты боятся remote_apply: «а вдруг реплика упадёт и весь сайт встанет». Это управляемый риск, а не побочный эффект синхронности как таковой — он одинаково касается уровня on. Лечится либо кворумной записью с запасом реплик (ANY 2 (r1, r2, r3) — сайт продолжит принимать заказы, пока жива любая пара из трёх), либо явным планом деградации на случай отказа синхронной реплики, который мы прописываем в runbook клиента отдельным пунктом.

Полезная практика, которую мы внедрили после разбора инцидента у одного клиента: помимо порогового алерта на абсолютное значение replay_lag, добавляем алерт на скорость роста этой величины за последние 5 минут. Разовый скачок на 200-300 мс из-за фонового batch-задания — это ожидаемо и не требует реакции дежурного; а устойчивый линейный рост отставания на протяжении 5-10 минут почти всегда означает, что реплика не успевает за потоком WAL по ресурсам (диск, CPU), и без вмешательства разрыв только увеличится вплоть до полного отключения реплики от синхронной группы.

Пошаговый сценарий внедрения, который мы используем у клиентов

  1. Инвентаризация сценариев чтения с реплики. Выписываем все места кода, где GET или SELECT идёт на реплику в течение 1-2 секунд после записи на мастере тем же пользовательским запросом или соседним микросервисом — это и есть кандидаты на remote_apply.
  2. Замер текущего разрыва flush_lsn/replay_lsn под реальной нагрузкой в течение суток-двух через pg_stat_replication, чтобы понимать масштаб проблемы до изменений, а не гадать.
  3. Явное разделение соединений read/write на уровне пула (pgbouncer/приложение), если его ещё нет — без этого невозможно точечно поднять synchronous_commit только для чекаут-транзакций.
  4. Включение hot_standby_feedback = on на репликах, задействованных в remote_apply, и настройка алертинга на bloat/autovacuum на мастере — эту связку нельзя разрывать.
  5. Точечный SET LOCAL synchronous_commit = remote_apply для конкретных транзакций (оформление заказа, подтверждение платежа, создание брони) — не глобальный конфиг.
  6. Нагрузочный прогон с измерением p95/p99 времени коммита до и после — на практике закладываем 15-20% запаса по времени ответа API для этих ручек.
  7. Отдельный план на отказ синхронной реплики: кворумная схема с запасом либо runbook с ручным переключением synchronous_standby_names.
  8. Постоянный мониторинг replay_lag и числа реплик в состоянии sync в Zabbix с порогами, согласованными с бизнес-SLA конкретного клиента.

Из восьми шагов первые три обычно закрывают 80% случаев «клиент не видит свой заказ» без единой строчки в synchronous_commit — просто потому что вскрывается, что разделение read/write было сделано случайно, без учёта именно этого паттерна чтения сразу после записи. У упомянутого в начале статьи клиента итоговое решение заняло два дня: один день на инвентаризацию и замеры, один — на точечное внедрение SET LOCAL для трёх эндпоинтов (оформление заказа, подтверждение оплаты, обновление статуса доставки) и настройку мониторинга. Частота ошибки «заказ не найден» на затронутых ручках упала до нуля за две недели наблюдения после внедрения.

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

Чем remote_apply отличается от простого on, если оба — «синхронная репликация»?
Оба уровня ждут подтверждения от синхронной реплики перед ответом клиенту COMMIT OK, но ждут разного. on ждёт flush_lsn — WAL сброшен на диск реплики и переживёт её перезапуск, но ещё не применён к таблицам. remote_apply ждёт replay_lsn — транзакция уже проиграна в данные и видна обычному SELECT на этой реплике. Для устойчивости к потере данных достаточно on; для того, чтобы SELECT сразу после COMMIT видел результат на реплике, нужен именно remote_apply.
Можно ли включить remote_apply только для части запросов, а не глобально?
Да, и мы всегда рекомендуем именно так. synchronous_commit — параметр уровня сессии/транзакции, а не только postgresql.conf. Оставляйте on глобальным дефолтом и повышайте до remote_apply точечно командой SET LOCAL synchronous_commit = remote_apply внутри BEGIN/COMMIT конкретной транзакции — например, только для оформления заказа или подтверждения платежа.
Что произойдёт с мастером, если единственная синхронная реплика упадёт?
Если synchronous_standby_names не пуст и синхронных реплик не осталось, новые транзакции с synchronous_commit выше off будут ждать (зависать) до восстановления связи или до вмешательства администратора — это поведение по умолчанию, не баг. Чтобы не терять доступность сайта, используем кворумную запись с запасом реплик (например, ANY 2 из трёх) или заранее прописанный runbook на экстренное изменение synchronous_standby_names.
hot_standby_feedback обязателен при remote_apply?
Формально нет, но на практике — да. Без hot_standby_feedback = on долгие read-only запросы на реплике с высокой нагрузкой начинают прерываться конфликтами с автовакуумом мастера («canceling statement due to conflict with recovery»), особенно при пороге max_standby_streaming_delay ниже реальной длительности запросов. Включение снимает эти обрывы, но требует отдельного контроля раздувания таблиц на мастере.
Как быстро проверить текущее отставание реплики без сложного мониторинга?
Один запрос на мастере — SELECT application_name, write_lsn, flush_lsn, replay_lsn, replay_lag FROM pg_stat_replication — сразу покажет и байтовое, и временное отставание по каждой реплике. На самой реплике для сверки годится SELECT pg_last_wal_replay_lsn(), pg_last_wal_receive_lsn() — разница между ними и есть непрочитанный до конца поток WAL.
📄
Скачайте подробный разбор в PDF Кейсы, статистика, типовые ошибки и чек-лист самопроверки — 12 страниц
Скачать PDF

Подпишитесь на разборы ITfresh

Раз в неделю — практичные материалы по ИТ для бизнеса: без спама, только польза.

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