Всем прекрасно известно, что при асинхронной репликации failover между узлами кластера — не важно, выполненный средствами Patroni или другими инструментами — может привести к потере данных. Это происходит, если не все транзакции, применённые на лидере кластера, успели передаться на реплику до выполнения failover.

Тем не менее, даже если все транзакции были переданы с лидера на реплику, возможен сценарий потери данных в случае failover, если кроме физической настроена и логическая репликация.

С этой проблемой можно столкнуться в типичной схеме репликации:

Есть кластер PostgreSQL из двух узлов: test-psql-1 и test-psql-2, между которыми настроена физическая репликация. Этот кластер находится под управлением Patroni. Кроме этих двух узлов есть отдельно стоящий узел test-psql-3, на который должна реплицироваться только одна таблица с помощью логической репликации. PostgreSQL на test-psql-3 всегда обращается к лидеру кластера Patroni за счет того, что подключается к этому кластеру через балансировщик нагрузки кластера HAproxy. HAproxy, в свою очередь, определяет текущего лидера кластера Patroni с помощью http-запроса к API Patroni.

В этой статье: 

Как Patroni управляет слотами логической репликации

Начиная с версии PostgreSQL 11, Patroni умеет управлять слотами логической репликации. Это реализовано с помощью функции Patroni «Permanent replication slots». Функционал подробно описан в первом PR , где он был реализован.

Чтобы Patroni автоматически создавал слот логической репликации на узле, принимающем роль лидера, с правильными отметками LSN, достаточно задать в конфигурации Patroni следующие настройки:

slots:
  permanent_logical_slot_name:
    type: logical
    database: my_db
    plugin: pgoutput

При добавлении этого блока в конфигурацию Patroni автоматически создаст на лидере слот логической репликации permanent_logical_slot_name, используя pg_create_logical_replication_slot.

Помимо этого, он создаст такой же слот репликации на всех репликах, для которых в конфигурации Patroni установлен тэг nofailover: false. Для этого Patroni копирует состояние слота с лидера: читает файл state из каталога pg_replslot с помощью pg_read_binary_file и создаёт идентичные файлы на репликах, после чего перезапускает на них PostgreSQL.

После создания слотов Patroni каждые loop_wait секунд (по умолчанию — 10 секунд: параметр конфигурации Patroni задаёт частоту Health Check и обмена данными с DCS) будет перемещать значение confirmed_flush_lsn на репликах в соответствии с данными из DCS,  используя функцию pg_replication_slot_advance.

Возможные проблемы при таком подходе

Казалось бы, при failover, выполненном средствами Patroni, самая большая неприятность, которая может случиться со слотом логической репликации, — это повторная передача в логическую реплику некоторого количества уже переданных прежде данных из-за возможного отставания в DCS зафиксированной позиции confirmed_flush_lsn. Но это не совсем так.

Дело в том, что в продуктовых средах при выборе между доступностью, целостностью данных и производительностью зачастую жертвуют доступностью. То есть для повышения производительности репликация настраивается асинхронной, что чревато потерей данных при автоматическом failover. Поэтому автоматический failover будет отключен, и в случае аварии на лидере кластера БД решение о целесообразности переключения лидера принимает человек.

Но раз автоматический failover отключен (на репликах установлен тэг nofailover: true), Patroni не будет заранее автоматически создавать на этих репликах слоты, перечисленные в slots.

Что же произойдет при таком управляемом failover, если предыдущий лидер недоступен, а логическая реплика в момент failover отставала? Давайте проверим.

Эксперимент с отставшей логической репликой

Исходное состояние кластера под управлением Patroni:

patronictl list
+ Cluster: main (7441886727573043134) ----+-----------+----+-----------+------------------+
| Member      | Host            | Role    | State     | TL | Lag in MB | Tags             |
+-------------+-----------------+---------+-----------+----+-----------+------------------+
| test-psql-1 | 192.168.199.69  | Leader  | running   | 38 |           | clonefrom: true  |
+-------------+-----------------+---------+-----------+----+-----------+------------------+
| test-psql-2 | 192.168.199.106 | Replica | streaming | 38 |         0 | clonefrom: true  |
|             |                 |         |           |    |           | nofailover: true |
+-------------+-----------------+---------+-----------+----+-----------+------------------+

Лидером является test-psql-1.

Создадим на нём тестовую БД processing и в ней таблицу payment:

CREATE DATABASE processing;
\c processing
CREATE TABLE payment (
    id INTEGER PRIMARY KEY,
    customer INTEGER NOT NULL,
    merchant INTEGER NOT NULL,
    sum INTEGER NOT NULL
);

Добавим туда тестовую запись:

INSERT INTO payment (id, customer, merchant, sum) VALUES (1, 0, 1, 1000);
SELECT * FROM payment;
id | customer | merchant | sum
----+----------+----------+------
  1 |        0 |        1 | 1000

И опубликуем эту таблицу для логической репликации:

CREATE PUBLICATION payment_pub FOR TABLE payment;

На сервере test-psql-3 создадим тестовую БД test, в ней таблицу payment с такой же структурой:

CREATE DATABASE test;
\c test
CREATE TABLE payment (
    id INTEGER PRIMARY KEY,
    customer INTEGER NOT NULL,
    merchant INTEGER NOT NULL,
    sum INTEGER NOT NULL
);

И создадим подписку с подключением к лидеру через haproxy:

test=# CREATE SUBSCRIPTION payment_sub CONNECTION 'dbname=processing host=192.168.199.5 user=my_repl_user password=my_repl_password' PUBLICATION payment_pub;
NOTICE:  created replication slot "payment_sub" on publisher

После этого добавим созданный слот репликации в DCS patroni:

slots:
  payment_sub:
    database: processing
    plugin: pgoutput
    type: logical

Проверим содержимое payment:

test=# SELECT * FROM payment;
id | customer | merchant | sum
----+----------+----------+------
  1 |        0 |        1 | 1000
(1 row)

На реплику test-psql-2 данные через физический слот репликации, разумеется, тоже отреплицировались.

Давайте сымитируем отставание логической реплики. Для этого просто временно остановим PostgreSQL на test-psql-3:

pg_ctlcluster 16 main stop

После чего на лидере test-psql-1 добавим запись в тестовую таблицу:

INSERT INTO payment (id, customer, merchant, sum) VALUES (2, 0, 1, 2000);

И после этого спровоцируем аварию. Для этого отрубим сетевые соединения к PostgreSQL и etcd на лидере test-psql-1:

# Блокируем доступ к PostgreSQL:
iptables -A INPUT -p tcp --dport 5432 -j DROP
# Блокируем доступ в Patroni со стороны haproxy:
iptables -A INPUT -p tcp --dport 8008 -j DROP
# Блокируем обращения от Patroni к etcd:
iptables -A OUTPUT -p tcp --dport 2379 -j REJECT

Кластер потерял лидера:

test-psql-2 ~ # patronictl list
+ Cluster: main (7441886727573043134) ----+-----------+----+-----------+------------------+
| Member      | Host            | Role    | State     | TL | Lag in MB | Tags             |
+-------------+-----------------+---------+-----------+----+-----------+------------------+
| test-psql-2 | 192.168.199.106 | Replica | streaming | 38 |         0 | clonefrom: true  |
|             |                 |         |           |    |           | nofailover: true |
+-------------+-----------------+---------+-----------+----+-----------+------------------+

Предположим, в этот момент приходит дежурный инженер, оценивает масштаб проблемы и принимает решение, что оперативно восстановление лидера невозможно. Чтобы отдать разрешение на failover, инженер снимает тэг nofailover с реплики.

Patroni успешно выполнил failover:

test-psql-2 ~ # patronictl list
+ Cluster: main (7441886727573043134) ---+---------+----+-----------+-----------------+
| Member      | Host            | Role   | State   | TL | Lag in MB | Tags            |
+-------------+-----------------+--------+---------+----+-----------+-----------------+
| test-psql-2 | 192.168.199.106 | Leader | running | 39 |           | clonefrom: true |
+-------------+-----------------+--------+---------+----+-----------+-----------------+

И автоматически создал слот репликации на test-psql-2:

SELECT * FROM pg_replication_slots ;
  slot_name  |  plugin  | slot_type | datoid |  database  | temporary | active | active_pid | xmin | catalog_xmin | restart_lsn | confirmed_flush_lsn | wal_status | safe_wal_size | two_phase | conflicting
-------------+----------+-----------+--------+------------+-----------+--------+------------+------+--------------+-------------+---------------------+------------+---------------+-----------+-------------
test_psql_1 |          | physical  |        |            | f         | f      |            |      |              | D/F0033F78  |                     | reserved   |               | f         |
payment_sub | pgoutput | logical   | 188519 | processing | f         | f      |            |      |       169319 | D/F0034DB8  | D/F0036650          | reserved   |               | f         | f
(2 rows)

Реплика test-psql-2 в момент аварии не отставала от лидера, все данные успели на нее отреплицироваться, потерь нет:

processing=# SELECT * FROM payment;
id | customer | merchant | sum
----+----------+----------+------
  1 |        0 |        1 | 1000
  2 |        0 |        1 | 2000
(2 rows)

Теперь вновь включим логическую реплику на test-psql-3 (она как бы «перестает отставать»):

pg_ctlcluster 16 main start

И обнаруживаем, что данные, отсутствующие из-за отставания реплики на test-psql-3 в момент аварии, до этой реплики так и не доехали:

test=# SELECT * FROM payment;
id | customer | merchant | sum
----+----------+----------+------
  1 |        0 |        1 | 1000
(1 row)

И не доедут никогда.

Добавим в тестовую таблицу запись на новом лидере test-psql-2:

INSERT INTO payment (id, customer, merchant, sum) VALUES (3, 0, 1, 3000);

Состояние таблицы на лидере:

processing=# SELECT * FROM payment;
id | customer | merchant | sum
----+----------+----------+------
  1 |        0 |        1 | 1000
  2 |        0 |        1 | 2000
  3 |        0 |        1 | 3000
(3 rows)

Состояние логической реплики:

test=# SELECT * FROM payment;
id | customer | merchant | sum
----+----------+----------+------
  1 |        0 |        1 | 1000
  3 |        0 |        1 | 3000
(2 rows)

Таким образом легко воспроизвести ситуацию, когда в случае ручного переключения роли лидера в кластере Patroni при отставании логической реплики будут потеряны данные, несмотря на то, что в основном кластере потери данных не было.

Если бы предыдущий лидер был доступен, то слот корректно скопировался бы. Но в данной ситуации на новом лидере Patroni создал слот просто с текущим LSN.

Решение проблемы

Решением этой проблемы может быть переход на автоматический failover средствами Patroni. Но при полностью асинхронной репликации в таком случае возникает опасность неконтролируемой потери данных в кластере при срабатывании автоматики.

К счастью, в PostgreSQL начиная с 17-й версии предложен нативный подход к управлению слотами логической репликации на репликах — синхронизация слотов репликации.

Для включения этой функции необходимо выполнить следующий условия:

1. На физической реплике должны быть включены параметры:

sync_replication_slots = on
hot_standby_feedback   = on

При включении синхронизации слотов PostgreSQL запустит специальный процесс slotsync worker, который и будет выполнять синхронизацию слотов с меткой failover.

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

test=# CREATE SUBSCRIPTION payment_sub CONNECTION 'dbname=processing host=192.168.199.5 user=my_repl_user password=my_repl_password' PUBLICATION payment_pub WITH (failover = true);

3. Для предотвращения ситуации, когда логическая реплика может опередить физическую, необходимо добавить в конфигурацию PostgreSQL список синхронизируемых слотов:

synchronized_standby_slots = ‘payment_pub, another_pub, ...’

При установке этого параметра wal sender отдаст данные логической реплике только после того, как физические реплики подтвердят получение соответствующего WAL. Если нарушить этот порядок, возобновление логической репликации с нового лидера может стать невозможным.

Кроме изменений в конфигурации PostgreSQL необходимо также подготовить к работе с failover-слотами Patroni, поскольку при использовании встроенного механизма синхронизации слотов репликации на уровне СУБД в управлении слотами логической репликации средствами Patroni больше нет нужды.

Все слоты логической репликации следует добавить в ignore_slots в конфигурации Patroni. Пример:

ignore_slots:
  - name: payment_sub
    type: logical
    database: processing
    plugin: pgoutput

Иначе Patroni может удалить не описанные в конфигурации слоты.

Но еще лучше – обновить Patroni до версии >= 4.1.0.

Дело в том, что разработчики Patroni любезно включили в релиз 4.1.0 от 23 сентября 2025 года PR, в котором исключается взаимодействие Patroni со слотами, имеющими признак failover.

Таким образом, используя PostrgreSQL версии >= 17 и Patroni >= 4.1.0 администраторы БД теперь имеют возможность настроить отказоустойчивую логическую репликацию только встроенными средствами PostgreSQL, не внося никаких корректировок в конфигурацию Patroni.

Вместо заключения 

Если вы используете логическую репликацию в связке с Patroni — проверьте, как ведут себя слоты логической репликации при ручном failover. И, по возможности, переходите на PostgreSQL 17+ и Patroni 4.1+, чтобы решить проблему раз и навсегда, используя новый функционал для синхронизации слотов.

P. S.

Читайте также в нашем блоге:

Комментарии (2)


  1. Sleuthhound
    23.12.2025 14:30

    Спасибо за статью, очень полезно!


  1. diderevyagin
    23.12.2025 14:30

    Интересный сценарий, действительно можно ногу прострелить ... спасибо большое !