Справочник DBA: базы данных изнутри читается по порядку

Статья 8.4 · раздел 8 «Репликация и высокая доступность» · 58 из 109

Failover в Patroni: ключ лидера с TTL в etcd, 24 секунды до нового primary и pg_rewind

опубликовано
читать
≈ 9 мин
автор
среда и версия
PostgreSQL 18.6, Patroni 4.1.5 (пакет PGDG 4.1.5-2.pgdg13+1) и etcd 3.6.5: три узла etcd (по 256 МБ) и три узла PostgreSQL (по 1 ГБ) в Docker; pgbench -s 5, база 83 МБ; ttl 30, loop_wait 10, retry_timeout 10; опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

Primary умер, и через ~24 секунды другой узел принимает записи: никто не голосует, узлы просто бегут к одному ключу в etcd. Ключ живёт, пока лидер продлевает lease, а узел, потерявший etcd, обязан остановить PostgreSQL раньше, чем lease истечёт, иначе primary окажется два. Реплики, которые Patroni переключает, — те самые, что описаны в статье про потоковую репликацию, а возврат старого лидера держится на том же REDO, что и восстановление после сбоя. Опыт ниже — три узла и три etcd в Docker, база 83 МБ.

Кто решает, что лидер умер

У Patroni нет голосования. Каждый узел раз в loop_wait (10 с) читает из etcd /service/demo/leader. Лидер продлевает lease ключа. Если ключа нет, реплика сначала проверяет, годится ли она: её WAL отстаёт от последней известной позиции primary не больше чем на maximum_lag_on_failover (Ha.is_lagging, по умолчанию 1 048 576 байт), её timeline не меньше кластерной. Годные реплики записывают ключ одной и той же операцией «создать, если нет», и побеждает первая дошедшая (_do_attempt_to_acquire_leader, тег v4.1.5):

        try:
            return _retry(self._client.put, self.leader_path, self._name, self._lease, create_revision='0')

create_revision='0' превращает запись в условие «ключа ещё нет». Роль DCS отсюда ясна: etcd не решает, кто лучше, он решает, кто первым записал. Поэтому etcd сам нужен с кворумом (три узла переживают потерю одного), а время жизни ключа задают три числа из динамической конфигурации: ttl 30 с, loop_wait 10 с, retry_timeout 10 с. Документация требует loop_wait + 2 × retry_timeout ≤ ttl: 10 + 2 × 10 = 30, запас нулевой.

DBA / 01

Ключ лидера с TTL: kill -9 и отрезанный лидер на одной оси времени

Время жизни ключа лидера в etcd: kill -9 лидера и лидер, отрезанный от etcd Ось времени от последнего продления ключа лидера до 35 секунд, ttl 30 секунд. Случай A: kill -9 на 8,3 секунде, новый лидер pg2 на 32,4 секунде. Случай B: сеть отключена на 9,0 секунде, лидер pg2 сам останавливает PostgreSQL на 18,4 секунде, новый лидер pg3 на 32,2 секунде, между остановкой и новым лидером 13,8 секунды без primary. 0 с: лидер продлил ключ 10 с 20 с 30 с: ttl, lease истёк A. kill -9 лидера pg1 kill +8,3 с ключ никто не продлевает, lease тикает pg2 лидер +32,4 с: 24,0 с после kill B. pg2 отрезан от etcd disconnect +9,0 с 17 INSERT, на pg3 их 0 demote +18,4 с pg3 лидер +32,2 с 13,8 с нет ни одного primary pg2 остановил PostgreSQL на 11,6 с раньше, чем истёк ttl
Оба случая стартуют от последнего продления ключа. После kill -9 новый лидер ждёт истечения lease целиком; отрезанный от etcd лидер останавливает PostgreSQL в +18,4 с, до истечения lease в +30 с, поэтому двух primary нет.

Стенд

Образ на основе postgres:18 и пакеты patroni, python3-etcd из подключённого в нём репозитория PGDG (без python3-etcd Patroni отвечает «Can not find suitable configuration of distributed configuration store»). Три etcd и три узла в одной сети:

S=dba-failover-patroni-dcs
mkdir w && cd w
printf 'FROM postgres:18\nRUN apt-get update -qq && apt-get install -y -qq patroni python3-etcd iproute2 procps\nUSER postgres\nENTRYPOINT []\n' > Dockerfile
docker build -t ${S}:local .
docker network create ${S}-net
CL=etcd1=http://${S}-etcd1:2380,etcd2=http://${S}-etcd2:2380,etcd3=http://${S}-etcd3:2380
for i in 1 2 3; do
  docker run -d --name ${S}-etcd$i --network ${S}-net --memory 256m quay.io/coreos/etcd:v3.6.5 etcd \
    --name etcd$i --data-dir /etcd-data \
    --listen-client-urls http://0.0.0.0:2379 --advertise-client-urls http://${S}-etcd$i:2379 \
    --listen-peer-urls http://0.0.0.0:2380 --initial-advertise-peer-urls http://${S}-etcd$i:2380 \
    --initial-cluster $CL --initial-cluster-state new
done

Конфиг узла pg1.yml; для остальных: sed 's/pg1/pg2/g' pg1.yml > pg2.yml и то же для pg3:

scope: demo
name: pg1
restapi: {listen: 0.0.0.0:8008, connect_address: dba-failover-patroni-dcs-pg1:8008}
etcd3: {hosts: 'dba-failover-patroni-dcs-etcd1:2379,dba-failover-patroni-dcs-etcd2:2379,dba-failover-patroni-dcs-etcd3:2379'}
bootstrap:
  dcs:
    ttl: 30
    loop_wait: 10
    retry_timeout: 10
    maximum_lag_on_failover: 1048576
    postgresql: {use_pg_rewind: true, parameters: {wal_log_hints: 'on'}}
  initdb: [data-checksums, encoding: UTF8]
  pg_hba: ['host replication repl 0.0.0.0/0 scram-sha-256', 'host all all 0.0.0.0/0 scram-sha-256']
postgresql:
  listen: 0.0.0.0:5432
  connect_address: dba-failover-patroni-dcs-pg1:5432
  data_dir: /var/lib/postgresql/pgdata
  bin_dir: /usr/lib/postgresql/18/bin
  authentication:
    superuser: {username: postgres, password: pg}
    replication: {username: repl, password: repl}
    rewind: {username: rewind, password: rewind}

Запуск: docker run -d --name ${S}-pg$i --network ${S}-net --memory 1g -v $PWD/pg$i.yml:/patroni.yml:ro ${S}:local patroni /patroni.yml. Через 45 секунд patronictl -c /patroni.yml list:

+ Cluster: demo (7701503083569365024) --+---------+-----------+----+-------------+-----+------------+-----+
| Member | Host                         | Role    | State     | TL | Receive LSN | Lag | Replay LSN | Lag |
+--------+------------------------------+---------+-----------+----+-------------+-----+------------+-----+
| pg1    | dba-failover-patroni-dcs-pg1 | Leader  | running   |  1 |             |     |            |     |
| pg2    | dba-failover-patroni-dcs-pg2 | Replica | streaming |  1 |   0/4000060 |   0 |  0/4000060 |   0 |
| pg3    | dba-failover-patroni-dcs-pg3 | Replica | streaming |  1 |   0/4000060 |   0 |  0/4000060 |   0 |
+--------+------------------------------+---------+-----------+----+-------------+-----+------------+-----+

Опыт 1: ключ лидера и kill -9

Что лежит в etcd, пока всё живо:

docker exec ${S}-etcd1 etcdctl get --prefix /service/ --keys-only | grep .
docker exec ${S}-etcd1 etcdctl --write-out=fields get /service/demo/leader | egrep -i 'Key|Value|Lease|Rev'
docker exec ${S}-etcd1 etcdctl lease timetolive $(printf %x 5707927148084689157)
/service/demo/config
/service/demo/initialize
/service/demo/leader
/service/demo/members/pg1
/service/demo/members/pg2
/service/demo/members/pg3
/service/demo/status

"Revision" : 21
"Key" : "/service/demo/leader"
"CreateRevision" : 12
"ModRevision" : 12
"Value" : "pg1"
"Lease" : 5707927148084689157
lease 4f36a17fd1657905 granted with TTL(30s), remaining(21s)

Ключ leader привязан к lease с TTL 30 с; пока pg1 жив, его lease обновляется, и осталось 21 секунда из тридцати. Ключ config хранит те же ttl, loop_wait, retry_timeout и maximum_lag_on_failover, что заданы в bootstrap.dcs. Теперь нагрузка (pgbench -T 25 -c 4) и docker kill -s KILL лидера. Время убийства записано до команды, строки patronictl list на pg2 через 3, 12 и 38 секунд (с точностью до задержки docker exec):

kill at: 00:06:29.957272309
== около +12 с после kill
| pg1    | dba-failover-patroni-dcs-pg1 | Leader  | running |  1 |             |     |            |     |
| pg2    | dba-failover-patroni-dcs-pg2 | Replica | running |  1 |   0/8023878 |   0 |  0/8023878 |   0 |
| pg3    | dba-failover-patroni-dcs-pg3 | Replica | running |  1 |   0/8023878 |   0 |  0/8023878 |   0 |
== около +38 с после kill
| pg2    | dba-failover-patroni-dcs-pg2 | Leader  | running   |  2 |             |     |            |     |
| pg3    | dba-failover-patroni-dcs-pg3 | Replica | streaming |  2 |   0/8023A88 |   0 |  0/8023A88 |   0 |

Через 12 секунд мёртвый pg1 всё ещё Leader: ключ жив, пока не истёк lease. Логи pg2 и pg3 с метками времени и последняя строка pg1 до убийства:

pg1  00:06:21,613 INFO: no action. I am (pg1), the leader with the lock
pg3  00:06:53,951 INFO: Could not take out TTL lock
pg3  00:06:53,972 INFO: following new leader after trying and failing to obtain lock
pg2  00:06:53,982 INFO: promoted self to leader by acquiring session lock
pg2  00:06:54,071 INFO: updated leader lock during promote
pg3  00:06:55,397 INFO: primary_timeline=2
pg3  00:06:55,397 INFO: primary: history=1	0/8023878	no recovery target specified

pg1 продлил ключ в 00:06:21,6, значит lease истёк бы в 00:06:51,6. Новый лидер появился в 00:06:53,98: через 2,4 с после номинального истечения и через 24,0 с после kill (00:06:29,96). Оба оставшихся узла пошли за ключом, pg3 проиграл гонку и стал следовать за pg2. Новый timeline — 2: pg3 с позиции 0/8023878 на timeline 1 видит primary_timeline=2 и историю 1 0/8023878 и переходит за pg2.

Опыт 2: возвращение старого лидера и pg_rewind

docker start старого лидера в 00:07:39,09. Patroni на нём не запускает PostgreSQL как primary: видит чужой ключ, в 00:07:39,8 доводит базу до согласованного состояния в однопользовательском режиме, сравнивает историю timeline и запускает pg_rewind:

00:07:39,751 WARNING: Postgresql is not running.
00:07:39,800 INFO: doing crash recovery in a single user mode
00:07:40,275 INFO: Local timeline=1 lsn=0/9000028
00:07:40,289 INFO: primary_timeline=2
00:07:40,289 INFO: primary: history=1	0/8023878	no recovery target specified
00:07:40,337 INFO: running pg_rewind from pg2
00:07:41,614 INFO: pg_rewind exit code=0
00:07:41,614 INFO:  stderr=pg_rewind: servers diverged at WAL location 0/8023878 on timeline 1
00:07:41,659 INFO: starting as a secondary
2026-10-28 00:07:42.115 UTC [59] LOG:  started streaming WAL from primary at 0/8000000 on timeline 2

Старый лидер локально дошёл до 0/9000028, а новая primary ответвилась от timeline 1 на 0/8023878: позиция его журнала ушла за точку расхождения на 16 631 728 байт, и этот хвост есть только у него. Как pg_rewind находит эту точку, видно в findCommonAncestorTimeline (тег REL_18_6): он идёт по историям обоих кластеров, пока совпадают номер timeline и начало, и берёт конец последней общей:

	n = Min(a_nentries, b_nentries);
	for (i = 0; i < n; i++)
	{
		if (a_history[i].tli != b_history[i].tli ||
			a_history[i].begin != b_history[i].begin)
			break;
	}

	if (i > 0)
	{
		i--;
		*recptr = MinXLogRecPtr(a_history[i].end, b_history[i].end);
		*tliIndex = i;
		return;
	}

Дальше pg_rewind по документации копирует с новой primary только блоки, изменённые на старом лидере после точки расхождения, а не весь кластер; от docker start до started streaming прошло 3,0 секунды. Он требует контрольные суммы страниц или wal_log_hints: на стенде включены оба (show wal_log_hints и show data_checksums дают on). Файлы .history на кластере теперь хранят цепочку переключений:

$ tail -n +1 pg_wal/00000004.history     # узел pg3 в конце всех опытов, timeline 4
1	0/8023878	no recovery target specified

2	0/8039F88	no recovery target specified

3	0/803BF10	no recovery target specified

Опыт 3: отрезанный лидер сам себя останавливает

Убить контейнер просто, опаснее живой лидер без связи с etcd. pg2 (лидер, timeline 2) отключён от сети командой docker network disconnect. Пока он отрезан, каждые 0,5 с на него шёл INSERT в таблицу probe с localhost внутри контейнера:

docker exec -e PGPASSWORD=pg ${S}-pg2 psql -h localhost -U postgres -qc "create table probe(t timestamptz)"
echo "disconnect at: $(date -u +%T.%N)"        # 00:08:44.367294312
docker network disconnect ${S}-net ${S}-pg2
# в цикле: insert into probe values (clock_timestamp()) returning 'insert ok'

Результат цикла: insert ok на каждой итерации с 00:08:44 по 00:08:53 (17 вставок), с 00:08:54 psql: error: connection to server at "localhost" ... failed. Лог pg2:

00:08:35,340 INFO: no action. I am (pg2), the leader with the lock
00:08:45,297 INFO: Lock owner: pg2; I am pg2
00:08:53,701 ERROR: Error communicating with DCS
00:08:53,701 INFO: demoting self because DCS is not accessible and I was a leader
00:08:53,701 INFO: Demoting self (offline)
2026-10-28 00:08:53.723 UTC [31] LOG:  received fast shutdown request

Лог pg3 при том же событии: 00:09:07,508 INFO: promoted self to leader by acquiring session lock. Решение принимает Ha._handle_dcs_error: если etcd недоступен, а узел был primary, он гасит PostgreSQL (demote('offline')), если не включён failsafe_mode (тогда лидер продолжает, пока видит остальные узлы):

    def _handle_dcs_error(self) -> str:
        if not self.is_paused() and self.state_handler.is_running():
            if self.state_handler.is_primary():
                if self.is_failsafe_mode() and self.check_failsafe_topology():
                    self.set_is_leader(True)
                    self._failsafe.set_is_active(time.time())
                    self.watchdog.keepalive()
                    self._sync_replication_slots(True)
                    return 'continue to run as a leader because failsafe mode is enabled and all members are accessible'
                self._failsafe.set_is_active(0)
                logger.info('demoting self because DCS is not accessible and I was a leader')
                self.demote('offline')
                return 'demoted self because DCS is not accessible and I was a leader'
            else:
                self._sync_replication_slots(True)

Хронология относительно последнего продления lease (00:08:35,34): отключение в +9,0 с, остановка PostgreSQL на pg2 в +18,4 с, номинальное истечение ключа в +30 с, новый лидер pg3 в +32,2 с. pg2 ушёл из primary на 13,8 с раньше появления нового: двух primary не было, но в течение этих 13,8 секунд не было ни одного. Остановка случилась через 9,3 с после отключения сети. Документация говорит, что сбои DCS короче retry_timeout (здесь 10 с) лидера не демоутят.

Цена асинхронной репликации видна в данных: на pg2 в окне между отключением и остановкой принято 17 вставок, на pg3 в probe их 0. После docker network connect в 00:09:51 pg2 вернулся через pg_rewind (servers diverged at WAL location 0/8039F88 on timeline 2, локально 0/803A700 — хвост в 1 912 байт) и в 00:09:55,6 уже стримил с pg3:

$ select pg_is_in_recovery(), (select count(*) from probe)    -- pg1, pg2, pg3
t|0
t|0
f|0

Те же 17 подтверждённых клиенту INSERT пропали при откате. Если такая потеря недопустима, Patroni умеет синхронный режим (synchronous_mode); он вне этого опыта. Ещё одна проверка: два из трёх etcd убиты в 00:10:51,05; лидер pg3 потерял кворум DCS, не смог прочитать ключ и в 00:11:13,30 (через 22,2 с) вывел себя из primary тем же сообщением demoting self because DCS is not accessible and I was a leader.

Что из этого следует

  • Время failover ограничено ttl. Новый лидер появляется через ttl после последнего продления ключа плюс около двух секунд: в опыте 32,2–32,4 с от продления и 24,0 с от kill. Меняя ttl, соблюдай loop_wait + 2 × retry_timeout ≤ ttl, иначе лидер не успеет продлить ключ до истечения.
  • Fencing здесь — это дисциплина лидера, а не принуждение. Узел, потерявший DCS, останавливает PostgreSQL до того, как ключ освободится, но делает это сам Patroni. Если завис он (OOM, остановленный процесс), остановки не будет; в цитате выше рядом стоит watchdog.keepalive(), и этот механизм в опыте не проверялся.
  • Асинхронный failover теряет подтверждённые транзакции. maximum_lag_on_failover ограничивает только отставание кандидата, но не данные, которые до реплики не дошли: в опыте 3 это 17 вставок.
  • etcd — часть отказоустойчивости. Без кворума DCS здоровый лидер тоже уходит в демоут: нужны три узла etcd и мониторинг их доступности отдельно от PostgreSQL.
  • pg_rewind нужны условия, заданные заранее. wal_log_hints или контрольные суммы страниц, пользователь rewind и сеть до новой primary. Без них pg_rewind не запустится.

Чек-лист

  1. Запиши patronictl show-config и проверь loop_wait + 2 × retry_timeout ≤ ttl; сравни maximum_lag_on_failover с объёмом WAL в секунду на своей нагрузке.
  2. В etcd найди ключ /service/<scope>/leader, его lease и оставшееся время: убедись, что значение совпадает с patronictl list.
  3. Проверь, что wal_log_hints = on или включены контрольные суммы, а пользователь rewind может подключиться к любому узлу.
  4. Повтори docker kill -s KILL на лидере своего стенда и измерь время от убийства до promoted self to leader; сравни с ttl.
  5. Убери стенд вместе с данными: docker rm -f -v ${S}-pg1 ${S}-pg2 ${S}-pg3 ${S}-etcd1 ${S}-etcd2 ${S}-etcd3 && docker network rm ${S}-net && docker rmi ${S}:local.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru