Привет! Меня зовут Григорий Тарасенко, я инженер команды SQL в Авито. Сегодня мы разберем, как построить архивную репликацию в PostgreSQL.

Кратко про репликацию в PostgreSQL

Практически все решения по отказоустойчивости используют потоковую репликацию, например Patroni, Stolon, repmgr. Но есть нюанс: потоковая репликация не гарантирует, что все изменения — WAL-ы (Write Ahead Log) — будут доставлены. Иначе говоря, на лидере могут спокойно удалиться те WAL-ы, которые не успели примениться на реплике.

Да, можно защититься при помощи завышения параметра wal_keep_size. Но у этого метода есть минусы: 

  • Лидер временно может хранить WAL-ов больше, чем указано в параметре wal_keep_size (например, из-за долгой транзакции), которые позже будут удалены. Нет гарантий, что удаленные WAL-ы будут доставлены до реплик;

  • wal_keep_size вынуждает лидера хранить ненужные WAL-ы, которые применены на репликах и просто занимают место.

И чтобы не завышать wal_keep_size, в PostgreSQL 9.4 ввели сущность «слот репликации». Его цель: указать лидеру, чтобы он не удалял WAL-файлы, которые реплика не успела применить.

В каких случаях может использоваться слот репликации

Слоты репликации позволяют нам отработать несколько сценариев, например: 

  • Когда долго работает pg_basebackup, слоты репликации помогут реплике скачать с лидера недостающие WAL-файлы, чтобы запустить процесс потоковой репликации; 

  • Реплика в принципе не успевает за потоковой репликацией, лидер будет удерживать только недошедшие изменения (а не все подряд, как при wal_keep_size);

  • Отработка логической репликации, где используются сторонние решения вроде Debezium, либо при использовании модели публикации PostgreSQL.

История из практики

Но, как говорится, с большой силой приходит и большая ответственность. В моей практике был случай: базу из Москвы необходимо было реплицировать в Новосибирск. Это было сделано с помощью потоковой репликации плюс слоты репликации. База так существовала с самого начала до тех пор, пока не была запущена массовая вставка строк. 

Реплика перестала получать изменения и слот репликации начал удерживать WAL-ы. В какой-то момент WAL-ов накопилось столько, что на лидере просто закончилось место. Пришлось в ручном режиме в 12 ночи выключать реплику и удалять слот репликации.

Архивная репликация

Слоты репликации сами по себе хотя и несут добро, но при неумелом использовании могут привести к аварии в продакшене. Потоковая репликация не гарантирует, что все WAL-ы будут доставлены, но это может гарантировать archive_command при условии, что запускаемая команда в archive_command тоже это гарантирует. В PostgreSQL заложено такое поведение: если при использовании потоковой репликации WAL-ов будет не хватать, то надо получить их через restore_command.

Таким образом мы можем организовать архивную репликацию (передачу изменений через archive_command и restore_command). И, если в базе не используется инструмент логической репликации, и мы имеем относительно быстрое хранилище, куда складируются WAL-ы, то нам ничего не мешает отказаться от слотов репликации полностью.

Как настроить архивную репликацию

Давайте попробуем настроить репликацию в PostgreSQL без слотов репликации, но с аналогичными гарантиями. Я покажу процесс на примере Patroni, так как это основной HA-менеджер PostgreSQL в Авито. Подробнее о том, как организован HA PostgreSQL в Avito.

Шаг 1. Откроем SSH-соединение до лидера и реплики. Они работают на PostgreSQL 14 и объединены в кластер Patroni. Топология выглядит следующим образом:

user@leader:~$ patronictl --config-file /etc/patroni.yml list
+ Cluster: pgsql -----------+---------+---------+----+-----------+
| Member  | Host            | Role    | State   | TL | Lag in MB |
+---------+-----------------+---------+---------+----+-----------+
| leader  | 192.168.122.5   | Leader  | running |  6 |           |
| replica | 192.168.122.254 | Replica | running |  6 |         0 |
+---------+-----------------+---------+---------+----+-----------+

Конфигурация Patroni тривиальна и она выглядит вот так:

scope: pgsql
namespace: /cluster/
name: leader

restapi:
 listen: 0.0.0.0:8008
 connect_address: 192.168.122.5:8008

etcd:
 hosts: replica:2379

bootstrap:
 dcs:
   ttl: 100
   loop_wait: 10
   retry_timeout: 10
   maximum_lag_on_failover: 1048576
   postgresql:
     use_pg_rewind: true
     use_slots: true
     parameters:
       archive_command: wal-g --config /var/lib/postgresql/.walg.json wal-push %p
       archive_mode: always
       checkpoint_timeout: 30
       hot_standby: 'on'
       log_destination: csvlog,syslog
       log_directory: /var/log/postgresql
       log_disconnections: true
       log_file_mode: 420
       log_filename: postgresql.log
       log_line_prefix: '%n,%r,%c,%l,%s,%v,%x,%e,%u,%d,%i,%a,'
       max_replication_slots: 5
       max_wal_senders: 5
       wal_keep_segments: 20
       wal_level: replica

 initdb:
 - encoding: UTF8
 - data-checksums
 - locale: en_US.UTF8
 pg_hba:
 - local all all   trust
 - local replication all trust
 - host replication postgres 127.0.0.1/8 trust
 - host replication postgres 0.0.0.0/0 md5
 - host all all 0.0.0.0/0 md5


postgresql:
 listen: 0.0.0.0:5432
 connect_address: 192.168.122.5:5432
 data_dir: /var/lib/postgresql/14/main
 bin_dir:  /usr/lib/postgresql/14/bin
 pgpass: /tmp/pgpass
 authentication:
   replication:
     username: postgres
     password: postgres
   superuser:
     username: postgres
     password: postgres
 parameters:
   archive_command: wal-g --config /var/lib/postgresql/.walg.json wal-push %p
   archive_mode: always
   checkpoint_timeout: 30
   hot_standby: 'on'
   log_destination: csvlog,syslog
   log_directory: /var/log/postgresql
   log_disconnections: true
   log_file_mode: 420
   log_filename: postgresql.log
   log_line_prefix: '%n,%r,%c,%l,%s,%v,%x,%e,%u,%d,%i,%a,'
   max_replication_slots: 5
   max_wal_senders: 5
   unix_socket_directories: '/var/run/postgresql'
   wal_keep_segments: 20
   wal_level: replica

И конфигурация WAL-G, который используется в качестве инструмента для резервного копирования, выглядит вот так:

user@leader:~$ cat /var/lib/postgresql/.walg.json  
{"PGDATA":"/var/lib/postgresql/14/main","AWS_ACCESS_KEY_ID":"admin",
"AWS_SECRET_ACCESS_KEY":
"password","WALE_S3_PREFIX":"s3://backup-bucket",
"AWS_ENDPOINT":"http://leader:9000","AWS_S3_FORCE_PATH_STYLE":
"True","AWS_REGION":"us-east-1"}

В качестве S3 используется локально развернутый minio. 

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

user@leader:~$ psql --username postgres --command 'select * from pg_replication_slots' --expanded
-[ RECORD 1 ]-------+----------
slot_name           | replica
plugin              | 
slot_type           | physical
datoid              | 
database            | 
temporary           | f
active              | t
active_pid          | 
xmin                | 
catalog_xmin        | 
restart_lsn         | 4/D50001F8
confirmed_flush_lsn | 
wal_status          | reserved
safe_wal_size       | 
two_phase           | f

Шаг 2. Отредактируем конфигурацию Patroni следующим образом:

user@leader:~$ EDITOR=vim patronictl --config-file /etc/patroni.yml edit-config
---  
+++  
@@ -2,6 +2,9 @@
maximum_lag_on_failover: 1048576
postgresql:
  parameters:
+    min_wal_size: 32MB
+    max_wal_size: 48MB
+    wal_keep_segments: 1
    archive_command: wal-g --config /var/lib/postgresql/.walg.json wal-push %p
    archive_mode: always
    checkpoint_timeout: 30
@@ -16,6 +19,8 @@
    max_replication_slots: 5
    max_wal_senders: 5
    wal_level: replica
+  recovery_conf:
+    restore_command: wal-g --config /var/lib/postgresql/.walg.json wal-fetch %f %p
-  use_slots: true
+  use_slots: false
retry_timeout: 10
ttl: 100

Apply these changes? [y/N]: y
Configuration changed
  • EDITOR=vim указан только для удобства, так как иначе patronictl может открыть для редактирования конфигурации в текстовом редакторе по умолчанию (например nano);

  • Параметры max_wal_size, min_wal_size, wal_keep_segments в секции postgresql.parameters нужны для демонстрации архивной репликации, чтобы на лидере не копились WAL-ы;

  • Параметр restore_command в секции postgresql.recovery_conf подсказывает: откуда реплике достать недостающие WAL-файлы;

  • И отключаем использование слотов репликации через параметр postgresql.use_slots.

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

user@leader:~$ psql --username postgres --command 'select * from pg_replication_slots' --expanded
(0 rows)

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

user@leader:~$ pgbench --transactions 1000 --jobs 2 --client 6 --username postgres testdb
pgbench (14.8 (Debian 14.8-1.pgdg110+1))
starting vacuum...end.
transaction type: <builtin: TPC-B (sort of)>
scaling factor: 250
query mode: simple
number of clients: 6
number of threads: 2
number of transactions per client: 1000
number of transactions actually processed: 6000/6000
latency average = 1.760 ms
initial connection time = 5.194 ms
tps = 3409.123838 (without initial connection time)
user@leader:~$ patronictl -c /etc/patroni.yml list
+ Cluster: pgsql -----------+---------+---------+----+-----------+
| Member  | Host            | Role    | State   | TL | Lag in MB |
+---------+-----------------+---------+---------+----+-----------+
| leader  | 192.168.122.5   | Leader  | running |  6 |           |
| replica | 192.168.122.254 | Replica | running |  6 |         0 |
+---------+-----------------+---------+---------+----+-----------+


Реплика в состоянии running и лага репликации нет. 

Шаг 4. Теперь остановим реплику.

user@replica:~$ sudo systemctl stop patroni

Проверим состояние кластера:

user@leader:~$ patronictl --config-file /etc/patroni.yml list
+ Cluster: pgsql -----------+---------+---------+----+-----------+
| Member  | Host            | Role    | State   | TL | Lag in MB |
+---------+-----------------+---------+---------+----+-----------+
| leader  | 192.168.122.5   | Leader  | running |  6 |           |
| replica | 192.168.122.254 | Replica | stopped |    |   unknown |
+---------+-----------------+---------+---------+----+-----------+

Реплика в состоянии stopped, потому что мы выполнили graceful-shutdown. 

Шаг 5. Теперь дадим больше тестовой нагрузки, чтобы сгенерировать больше WAL-ов.

user@leader:~$ pgbench --transactions 10000 --jobs 2 --client 6 --username postgres testdb
pgbench (14.8 (Debian 14.8-1.pgdg110+1))
starting vacuum...end.
transaction type: <builtin: TPC-B (sort of)>
scaling factor: 250
query mode: simple
number of clients: 6
number of threads: 2
number of transactions per client: 10000
number of transactions actually processed: 60000/60000
latency average = 1.675 ms
initial connection time = 4.289 ms
tps = 3581.818403 (without initial connection time)

Самое главное — это убедиться, что у нас в системе хранится достаточно маленькое число WAL-ов (из-за того, что выставили малые max_wal_size, min_wal_size, wal_keep_segments) и они все уходили в minio:

user@leader:~$ sudo -u postgres du -sh /var/lib/postgresql/14/main/pg_wal
49M     /var/lib/postgresql/14/main/pg_wal

Может возникнуть ситуация, что WAL-ов не 48 Мегабайт, а чуть больше. Но на самом деле на процесс это влиять не должно, потому что у нас и так малое число WAL-журналов. Если WAL-ов все же больше 48 Мегабайт, то поможет либо команда CHECKPOINT, либо повторный прогон pgbench.

Шаг 6. Теперь поднимем реплику назад и увидим, что WAL-G стягивает недостающие журналы из minio.

user@replica:~$ ps -fu postgres
UID          PID    PPID  C STIME TTY          TIME CMD
user@replica:~$ sudo systemctl start patroni
user@replica:~$ ps -fu postgres
UID          PID    PPID  C STIME TTY          TIME CMD
postgres    1396       1  6 14:12 ?        00:00:00 /usr/bin/python3 /usr/local/bin/patroni /etc/patroni.yml
postgres    1407       1  0 14:12 ?        00:00:00 /usr/lib/postgresql/14/bin/postgres -D /var/lib/postgresql/14/main --config-file=/var/lib/postgresql/14/main/postgresql.conf --listen_addresses=0.0.0.0 --port=5432 --cluster_name=pgsql --wal_level=replica --hot_standby=
postgres    1409    1407 12 14:12 ?        00:00:00 postgres: pgsql: startup waiting for 0000000600000004000000EA
postgres    1437    1407 16 14:12 ?        00:00:00 postgres: pgsql: checkpointer  
postgres    1438    1407  0 14:12 ?        00:00:00 postgres: pgsql: background writer  
postgres    1439    1407  0 14:12 ?        00:00:00 postgres: pgsql: archiver archiving 0000000600000004000000E5
postgres    1440    1407  0 14:12 ?        00:00:00 postgres: pgsql: stats collector  
postgres    1597    1407  0 14:12 ?        00:00:00 postgres: pgsql: postgres postgres 127.0.0.1(47362) idle
postgres    1665    1439  0 14:12 ?        00:00:00 sh -c wal-g --config /var/lib/postgresql/.walg.json wal-push pg_wal/0000000600000004000000E5
postgres    1666    1665  5 14:12 ?        00:00:00 wal-g --config /var/lib/postgresql/.walg.json wal-push pg_wal/0000000600000004000000E5
postgres    1673    1409  0 14:12 ?        00:00:00 sh -c wal-g --config /var/lib/postgresql/.walg.json wal-fetch 0000000600000004000000EA pg_wal/RECOVERYXLOG
postgres    1674    1673  1 14:12 ?        00:00:00 wal-g --config /var/lib/postgresql/.walg.json wal-fetch 0000000600000004000000EA pg_wal/RECOVERYXLOG

Если мы сделаем patronictl list, то увидим, что у реплики есть некий лаг репликации:

user@leader:~$ patronictl --config-file /etc/patroni.yml list
+ Cluster: pgsql -----------+---------+---------+----+-----------+
| Member  | Host            | Role    | State   | TL | Lag in MB |
+---------+-----------------+---------+---------+----+-----------+
| leader  | 192.168.122.5   | Leader  | running |  6 |           |
| replica | 192.168.122.254 | Replica | running |  6 |       250 |
+---------+-----------------+---------+---------+----+-----------+

В скором времени он уменьшится и превратиться в ноль.

user@leader:~$ patronictl --config-file /etc/patroni.yml list
+ Cluster: pgsql -----------+---------+---------+----+-----------+
| Member  | Host            | Role    | State   | TL | Lag in MB |
+---------+-----------------+---------+---------+----+-----------+
| leader  | 192.168.122.5   | Leader  | running |  6 |           |
| replica | 192.168.122.254 | Replica | running |  6 |         0 |
+---------+-----------------+---------+---------+----+-----------+

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

А как в вашей структуре устроена отказоустойчивость да и репликации между базами в принципе? Поделитесь своим опытом в комментариях!

Предыдущая статья: Как обычному фронтендеру стать по-настоящему классным фронтендером

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


  1. Melkij
    22.12.2023 10:35

    Как обычно, какую задачу решаем?

    Если вам нужна гарантия, что все WAL сохранены где-то ещё - то начать нужно с вопроса, что вы хотите что бы делала база когда это невозможно. Ну вот буквально, транзакция хочет сделать insert данных. Что делать базе? Разрешить выполнить запись сразу и закоммитить? Всё, тут мы уже нарушили гарантию доставки WAL. У нас есть кусочек WAL, который существует только локально на этой базе и ещё не был никуда скопирован. Для этой задачи нужна синхронная репликация. При том, иметь в виду, что insert в postgresql выполнен будет в любом случае при активной синхронной репликации, синхронная репликация гарантирует что ответ базы "commit выполнен" будет получен после доставки WAL на указанную конфигурацию синхронных реплик. При проблемах с синхронной репликацией останавливается запись данных, но раз просили гарантию сохранности - то вот она цена.

    Хотим чтобы реплика могла догнать после существенного отставания репликации?
    гарантированно? слот репликации без ограничения, либо архив WAL если он очищается с учётом отставания этой реплики. Хочу обратить внимание, что если archive_command перестаёт работать - то база будет накапливать WAL безгранично, подобно слотам репликации max_slot_wal_keep_size. Зато скорей всего под архив WAL получится выделить куда более вместительное и более дешёвое хранилище. Цена той гарантии, что реплика догонит - потенциально неограниченный расход места на хранение WAL.
    после разумного объёма отставания? wal_keep_size либо слот репликации с max_slot_wal_keep_size, либо архив WAL если тот очищается независимо от отставания реплики

    Но вообще архив WAL обычно заводят когда нужен point-in-time-recovery, а не для репликации. И это другая задача. А потом уже, раз всё равно пишем архив WAL, - то можно и задействовать restore_command чтобы из него подтягивать недостающие WAL для отстающих реплик, попутно.

    В большинстве production систем действительно лучше потерять реплику и переналить заново, чем уронить мастер с переполнением диска. Поэтому мы почти всегда используем только wal_keep_size. Если реплика отстала до requested WAL segment ... has already been removed и ничего не могли сделать после того как мониторинг сказал что реплика отстаёт - то переналить заново. Зато не потеряли мастер.

    restore_command, кстати, имеет одно не очень явно задокументированное свойство: он имеет безусловный приоритет над WAL уже сохранёнными в pg_wal. То есть даже если у вас все WAL есть локально у базы во время старта, то всё равно они будут заново читаться из архива. Поэтому если у вас архив wal не очень быстрый на чтение - то restore_command будет замедлять все рестарты баз.


    1. gle4er Автор
      22.12.2023 10:35

      Как обычно, какую задачу решаем?

      Чтобы лидер меньше зависел от состояния реплик.

      По сути, вот это:

      Хотим чтобы реплика могла догнать после существенного отставания репликации?
      гарантированно?

      Но чтобы еще лидер от этого не помер. Опять же: либо из-за забитого места WAL-ами, либо из-за переутилизации сети. Соответственно, слот репликации без ограничения - сразу нет, wal_keep_size имеет недостатки (которые описаны в статье):

      Но у этого метода есть минусы: 

      • Лидер временно может хранить WAL-ов больше, чем указано в параметре wal_keep_size (например, из-за долгой транзакции), которые позже будут удалены. Нет гарантий, что удаленные WAL-ы будут доставлены до реплик;

      • wal_keep_size вынуждает лидера хранить ненужные WAL-ы, которые применены на репликах и просто занимают место.

      Но, идея с max_slot_wal_keep_size валидна: нивелируется второй недостаток (вынужденно хранить ненужные WAL-ы), но имеет первый недостаток (который можно попробовать нивелировать "архивной репликацией" кстати).

      Поэтому, по сути, "архивная репликация" работает, как вы и написали:

      раз всё равно пишем архив WAL, - то можно и задействовать
      restore_command чтобы из него подтягивать недостающие WAL для отстающих
      реплик, попутно.

      По поводу этого:

      Поэтому если у вас архив wal не очень быстрый на чтение - то restore_command будет замедлять все рестарты баз

      Тоже валидное замечание, спасибо, что отметили. Поэтому, в статье отмечено:

      и мы имеем относительно быстрое хранилище, куда складируются WAL-ы, то
      нам ничего не мешает отказаться от слотов репликации полностью.

      Но да, не отмечено: что такое "относительно быстрое хранилище", поэтому, тут могут быть споры. Я понимаю, что если тащить WAL-ы из той же Москвы в Новосибирск, то идея с архивной репликацией быстро умрет :)

      А по поводу поведения restore_command : спасибо, не знал. Век живи - век учись... :)

      И спасибо за ценный развернутый комментарий!


      1. Melkij
        22.12.2023 10:35

        Лидер временно может хранить WAL-ов больше, чем указано в параметре wal_keep_size (например, из-за долгой транзакции)

        Мастер может в любой конфигурации хранить WAL'ов больше чем указано где-либо потому что ограничения на максимальный объём WAL просто нет.
        И нет, wal_keep_size не имеет никакого отношения к долгим транзакциям. У нас REDO, не волнует возраст транзакций ни для crash recovery ни для удержания WAL, нас волнует LSN который записали при чекпойнте и соответственно откуда стартовать recovery. А уже при crash recovery смотрим, встретили ли commit запись в WAL или нет.

        Вот то, что wal_keep_size держит постоянно занятый объём, я, впрочем, и вовсе как недостаток не рассматриваю - это вполне неплохой удобный резерв наравне с reserved space в ext4. Надо освободить место - оговариваем, что тогда могут отвалиться дальние/медленные реплики. Зато когда медленные реплики действительно отстают - у нас не начинает исчезать свободное место.

        Я понимаю, что если тащить WAL-ы из той же Москвы в Новосибирск, то идея с архивной репликацией быстро умрет :)

        Я бы кстати наоборот ожидал что на фиговой сети file shipping репликация будет менее проблемная. WAL'ы в виде файликов неплохо сжимаются и сжатие wal просто добавить для архива (по-моему, это вообще дефолтный режим в wal-g). А вот streaming репликация гоняет несжатые данные. При том, потоковая репликация ходит строго одним TCP коннектом. Если нет своих сетевых инженеров для тюнинга сетевого стека, то кучка маленьких параллельных TCP сессий на нестабильной сети наверняка будут работать лучше чем одно долгоживущее.

        Ну и в плюсы репликации через архив можно добавить, что для этого способа не надо прорубать сетевое окно на подключение до рабочих баз через всевозможные VPN и файрволы по пути. Базу можно изолировать в закрытом сетевом контуре, куда извне не подключиться.
        Плюс гибкость выбора транспортировки WAL, хоть по RFC 1149 кидать.


  1. vesper-bot
    22.12.2023 10:35

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