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

Patroni — стандарт де‑факто для создания HA конфигураций PostgreSQL. Преимуществом Patroni является то, что в нём учтено множество граничных условий и выявлены особенности работы PostgreSQL, которые в других системах даже не упоминаются. Например, Patroni использует внешнюю базу DCS (Distributed Configuration Store). Почему разработчики Patroni не используют встроенный DCS, ведь это бы упростило внедрение Patroni?

В Patroni был встроен DCS и он до сих пор есть, хотя и не рекомендуется к использованию и объявлен deprecated. Можно сконфигурировать Patroni без внешнего DCS (etcd). Однако, Patroni прошел долгий путь развития и разработчики пришли к тому, что лучше использовать внешний DCS.

Почему? Допустим, на каждом узле Patroni, был бы встроенный (Built‑in) узел кластера DCS. Всё работало бы прекрасно, пока узлов немного — 3 или 5. При увеличении числа узлов DCS, время прихода к консенсусу существенно возрастает. В документации к etcd написано «Вероятно, кластер etcd, не должен состоять более чем из семи узлов. Опыт эксплуатации сервиса блокировок Google Chubby, аналогичного etcd и используемого Google на протяжении многих лет, рекомендует использование пяти узлов.»

Число же реплик может исчисляться десятками, как у OpenAI (50 реплик). Вряд ли работоспособность встроенного DCS, при большом числе узлов, останется приемлемой.

Patroni способен пережить полный отказ DCS, это режим failsafe: Primary работает, пока доступны все Standby.

Второй пример. В документации Patroni написано об особенности PostgreSQL:

«Из‑за особенностей реализации синхронной репликации в PostgreSQL, возможна потеря транзакций даже при использовании synchronous_mode_strict. Если работа серверного процесса PostgreSQL прерывается во время ожидания подтверждения от реплики (по любой причине: прерывание запроса, сессии, тайм‑аута клиента, сбоя процесса), результат транзакции становится видимым для других сессий мастера. Запись же о фиксации транзакции может быть ещё реплицирована и, если реплика станет мастером, результат транзакции на новом мастере будет потерян.»

На вопрос Jepsen, есть ли способы настроить Patroni так, чтобы не терять транзакции, Александр Кукушкин (Микрософт, ранее Zalando), автор Patroni, ответил, что это поведение PostgreSQL и нет приемлемого способа устранить это средствами Patroni.

При использовании логической репликации происходит то же самое.

Фиксация транзакции физически многошаговая, а не атомарная, а с синхронными репликами, ещё и протяжённая во времени:

  1. Журнальная запись, содержащая COMMIT, сохраняется в WAL‑файл (writeback, fdatasync)

  2. Устанавливается бит о фиксации транзакции в журнале транзакций pg_xact (CLOG)

  3. WALSenders отправляют содержимое WAL файлов репликам и ждут подтверждения о приёме (synchronous_commit=remote_write) или сохранении (on)

  4. Транзакция помечается как видимая другим сессиям

  5. Клиенту возвращается подтверждение о фиксации транзакции.

Эти действия неатомарны и могут быть прерваны в любой момент.

Более того, при задержке в передаче журнальных записей синхронной реплике может пройти много времени и вероятность прерывания на шагах 4–6 увеличивается.

В результате есть вероятность, что:

  1. изменения записаны на диск, но не видны клиентам

  2. транзакция реплицирована и видна сессиям реплики, но не видна сессиям мастера

  3. результат транзакции виден сессиям мастера до того, как сессия, выполнившая транзакцию, получит подтверждение о фиксации своей транзакции

Bruce Momjian рекомендует использовать тот же самый метод, который используется Oracle в опции Oracle Transaction Guard — функции txid_current() и txid_status().

Как появляется транзакция, не переданная на реплики

Клиенты могут:

  1. прервать собственные запросы, послав сетевой пакет CancelRequest(pid, secret). psql посылает такой пакет при нажатии комбинации клавиш ctrl+c.

  2. В других сессиях клиенты могут вызвать функцию pg_cancel_backend()

  3. Суперпользователи и члены роли pg_signal_backend могут вызвать функцию pg_terminate_backend()

  4. Сессия прервётся по таймауту transaction_timeout или таймауту на клиенте (серверный процесс получит уведомление о закрытии сетевого сокета).

  5. экземпляр может перезапуститься после сбоя или пропадания питания.

В этих случаях, в локальном журнале транзакция, ожидающая подтверждения (SyncRepWaitForLSN) от синхронной реплики (реплик), будет зафиксирована, а синхронные реплики могут не получить LSN с фиксацией транзакции.

Рассмотрим пример. При прерывании запроса в режиме автофиксации, серверный процесс, находящиеся в ожидании подтверждения от синхронной реплики (SyncRep), выдаст сообщение о том, что транзакция зафиксирована (INSERT 0 1), единственно только добавит предупреждение (предупреждение доступно, как минимум, клиентам, использующим драйвера libpq и jdbc):

insert into t1 values (1); 
WARNING:  canceling wait for synchronous replication due to user request 
DETAIL:  The transaction has already committed locally, but might not have been replicated to the standby.
INSERT 0 1

Изменения тут же станут видны в других сессиях мастера, при том, что реплика, из‑за медленной работы сети, могла не получить изменения. Если мастер сбойнёт и реплика станет мастером, изменений прерванной транзакции на реплике не будет. Что хорошо, увидев изменения, транзакции не смогут поменять данные так, чтобы изменения (сделанные на основе увиденных данных) были доступны на новом мастере. Запись в WAL последовательная — если запись о COMMIT не принята репликой, то не приняты и все последующие записи. То есть ситуации, когда транзакция считывает баланс счёта и вносит изменения на основе неверного баланса не будет. Единственное нарушение в том, что сессии к бывшему мастеру могут на короткое время (до остановки бывшего мастера) увидеть данные, которые на новом мастере не были зафиксированы.

В PostgreSQL предлагалось добавить параметр конфигурации, которым можно было бы запретить прерывать транзакции, ожидающие подтверждения от реплики, в течение какого‑то времени. Этого не сделали, так как это не устраняет проблему (например, можно убить серверный процесс).

Что можно сделать?

Не прерывать подвисшие запросы и сессии. В случае, если мастер не продлит лизинг, он будет остановлен и реплика станет новым мастером. При этом параллельные сессии не увидят данных неподтверждённых синхронной репликой транзакций. То есть не прерывать запросы и не устанавливать transaction_timeout в значения, меньшие 30 секунд (Patroni TTL, время на распознавание кто сбойнул — мастер или реплика). В случае, если сбойнула реплика или сеть, реплика станет отставшей и Patroni её не сделает её мастером. После восстановления связи, реплика получит недостающие журнальные записи.

В обсуждении сообществом разработчиков PostgreSQL обсуждали, можно ли использовать распределённые транзакции (2PC) для устранения потери транзакций и пришли к консенсусу, что: «COMMIT PREPARED также необходимо реплицировать, что приводит к той же проблеме, что и обычный COMMIT: если он выполняется во время переключения на реплику, его можно отменить, и зафиксированные данные могут быть неправильно отображены и записаны. 2PC не является решением проблемы, связанной с тем, что PostgreSQL молча отменяет ожидание синхронной репликации. Проблема возникает при наличии любого „commit“. А „commit“ присутствует, если существуют транзакции.»

PostgreSQL Transaction Guard

Он есть в PostgteSQL и работает точно так же, как в Oracle. Для критичных транзакций можно использовать txid_current() для получения номера транзакции, результат которой важен. В случае разрыва сессии, которое произойдёт при переключении на нового мастера, проверять статус транзакции функцией txid_status():

insert into t1 values (1) returning txid_current();
 txid_current 
--------------
        6767
WARNING:  canceling wait for synchronous replication due to user request 
DETAIL:  The transaction has already committed locally, but might not have been replicated to the standby.
INSERT 0 1

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

select txid_status(6767); 
 txid_status 
-------------
committed

Такой способ используется в опции Oracle Transaction Guard, появившийся в 12 версии Oracle Database — приложение получает логический номер транзакции и проверяет статус, в случае разрыва сессии и переподсоединения к резервной базе данных.

Проблему потери транзакций сложно воспроизвести, вероятность её возникновения низка, если только не прерывать запросы или сессии вручную или по таймауту. Вероятность увеличивает замедление работы сети, большой поток коротких транзакций, перед тем, как реплика станет мастером. Если пакеты проходят по сети без замедления или в транзакции несколько команд, вероятность проблемы невелика. Вероятность возрастает при использовании огромного числа кластеров PostgreSQL, которые (или контейнеры, в которых они работают) часто перезапускаются, что приводит к частым продвижениям реплик до мастера.

Тантор Лабс приглашает читателей Хабра на конференцию Tantor JAM, которая пройдёт в Москве 10 сентября 2026г. Участие в конференции, как очное, так и дистанционное - бесплатно.