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

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

Консенсус в базах: Raft на трёх узлах etcd — кворум, term, выборы лидера и запись без большинства

опубликовано
читать
≈ 10 мин
автор
среда и версия
etcd 3.6.5 (raft v3.6.0) в трёх контейнерах quay.io/coreos/etcd:v3.6.5, по 256 МБ памяти, одна сеть Docker; election-timeout 1000 мс и heartbeat-interval 100 мс по умолчанию; опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

Приложение получило OK на запись, а через секунду узел с этой записью упал. В базе с асинхронной репликацией запись могла остаться только на нём (потоковая репликация PostgreSQL по умолчанию подтверждает коммит после fsync на primary, а реплики догоняют потом). В базах на Raft OK приходит только после того, как запись сброшена на диск у большинства узлов: потерять её можно лишь вместе с дисками большинства узлов. Цена этого — сетевой раунд и fsync на нескольких узлах для каждой записи, а при потере большинства запись останавливается совсем. Ниже — три узла etcd, у которых по очереди убирают follower, лидера и второй узел, плюс код, где считается кворум, и то, как та же идея устроена в MongoDB, ClickHouse Keeper и CockroachDB. Опыт идёт на пустом кластере, нагрузки в сотни записей, больших объёмов статья не измеряет.

Что такое кворум в Raft

У кластера есть лидер текущего срока (term). Он добавляет запись в свой журнал, рассылает её followers и считает запись подтверждённой (committed), когда её сохранило большинство узлов, включая его самого. Если лидер замолчал, follower по истечении election-timeout становится кандидатом, увеличивает term и просит голоса; побеждает набравший большинство. Два лидера одного term невозможны: у узла один голос на срок, а два большинства из одного набора узлов всегда пересекаются.

Индекс, подтверждённый кворумом, в etcd-io/raft считается так: лидер собирает у каждого голосующего узла последний подтверждённый индекс, сортирует и берёт значение, которое есть минимум у n/2+1 узлов (CommittedIndex, тег v3.6.0, его собирает etcd 3.6.5 по server/go.mod):

	slices.Sort(srt)

	// The smallest index into the array for which the value is acked by a
	// quorum. In other words, from the end of the slice, move n/2+1 to the
	// left (accounting for zero-indexing).
	pos := n - (n/2 + 1)
	return Index(srt[pos])

Для трёх узлов n/2+1 равно 2, для пяти — 3: три узла переживают один отказ, пять — два. Лидер без большинства сам уходит с поста: в stepLeader по таймеру MsgCheckQuorum он проверяет, что на связи кворум, и иначе становится follower:

	case pb.MsgCheckQuorum:
		if !r.trk.QuorumActive() {
			r.logger.Warningf("%x stepped down to follower since quorum is not active", r.id)
			r.becomeFollower(r.Term, None)
		}
DBA / 01

Кворум из двух узлов при убитом третьем

Как лидер etcd считает подтверждённый индекс при трёх узлах и одном убитом Три узла etcd, term 4. Лидер e3 и follower e1 подтвердили индекс 214. Узел e2 убит командой docker kill и не отвечает, его известный лидеру индекс равен 0. Лидер сортирует индексы, получает 0, 214, 214, берёт позицию 3 минус 2 равно 1 и считает подтверждённым индекс 214. Для пяти узлов кворум равен трём и допускает два отказа, для трёх кворум равен двум и допускает один. e3 — лидер, term 4 журнал до индекса 214 fsync на своём диске e1 — follower, term 4 ответил: принял до 214 fsync на своём диске e2 — убит, docker kill не отвечает последний индекс до убийства: 212 214 214 0 CommittedIndex, n = 3 srt = [0, 214, 214] индексы e2, e1, e3 по возрастанию pos = 3 − (3/2 + 1) = 1 commit = srt[1] = 214 2 узла из 3 — запись подтверждена 5 узлов: кворум 3, терпит 2 отказа 3 узла: кворум 2, терпит 1 отказ после выборов лидер сбросил прогресс e2 до 0 и ждёт его ответа
Лидер не ждёт убитый узел: из трёх индексов [0, 214, 214] кворум имеет 214. Убери второй узел, и останется [0, 0, 214]: подтверждать нечего.

Стенд

Три узла в одной сети; в образе нет оболочки, поэтому каждая команда запускается через docker exec без sh:

S=dba-consensus-raft-in-databases
docker network create $S-net
CL="e1=http://$S-e1:2380,e2=http://$S-e2:2380,e3=http://$S-e3:2380"
for n in e1 e2 e3; do
docker run -d --name $S-$n --network $S-net --memory 256m quay.io/coreos/etcd:v3.6.5 etcd --name $n \
 --listen-peer-urls http://0.0.0.0:2380 --listen-client-urls http://0.0.0.0:2379 \
 --advertise-client-urls http://$S-$n:2379 --initial-advertise-peer-urls http://$S-$n:2380 \
 --initial-cluster $CL --initial-cluster-state new --initial-cluster-token t1; done
EP=http://$S-e1:2379,http://$S-e2:2379,http://$S-e3:2379
docker exec $S-e1 etcdctl --endpoints=$EP endpoint status -w table \
 | awk -F'|' 'NF>3 {print $2 "|" $10 "|" $12 "|" $13}'

Вывод:

                    ENDPOINT                    | IS LEADER | RAFT TERM | RAFT INDEX 
 http://dba-consensus-raft-in-databases-e1:2379 |      true |         2 |          8 
 http://dba-consensus-raft-in-databases-e2:2379 |     false |         2 |          8 
 http://dba-consensus-raft-in-databases-e3:2379 |     false |         2 |          8 

Лидер — e1, term 2: первый срок ушёл на первые выборы. Фильтр awk оставляет четыре столбца из шестнадцати; дальше он стоит в каждой команде endpoint status.

Опыт 1: каждая запись — fsync на каждом узле

Сотня последовательных etcdctl put, до и после счётчики из /metrics каждого узла (etcd_disk_wal_fsync_duration_seconds_count, _sum и etcd_server_proposals_committed_total):

for i in $(seq 100); do docker exec $S-e1 etcdctl --endpoints=$EP put k$i v$i >/dev/null; done

Вывод (счётчики до и после, затем endpoint status):

e1 fsync=8 sum=0.01s committed=8
e2 fsync=7 sum=0.01s committed=8
e3 fsync=7 sum=0.01s committed=8
100 puts, 54.8 ms per put (with docker exec)
e1 fsync=108 sum=0.20s committed=108
e2 fsync=107 sum=0.21s committed=108
e3 fsync=107 sum=0.21s committed=108

Сто записей — ровно сто новых fsync журнала на каждом из трёх узлов и сто подтверждённых предложений (committed вырос с 8 до 108, RAFT INDEX на всех трёх стал 108). Среднее время fsync — около 2 мс на узел (0,19–0,20 с на сотню). Лидер подтверждает запись, когда fsync закончили двое из трёх, поэтому в latency записи входят fsync лидера, сетевой раунд и fsync самого быстрого follower. Время 54,8 мс на запись включает запуск docker exec, по нему задержку Raft не измерить: здесь важны счётчики. На первом запуске стенда etcdctl check perf --load s (60 секунд нагрузки) закончился PASS: Slowest request took 0.098951s, и за это время каждый узел сделал около 9000 fsync (8988, 8989 и 8988).

Опыт 2: остановка follower

docker stop $S-e3
for i in $(seq 100); do docker exec $S-e1 etcdctl --endpoints=$EP put k$i v$i >/dev/null; done

Вывод endpoint status после сотни записей при остановленном e3 (команда запущена с --command-timeout=3s, недоступный узел в таблицу не попал):

                    ENDPOINT                    | IS LEADER | RAFT TERM | RAFT INDEX 
 http://dba-consensus-raft-in-databases-e1:2379 |      true |         2 |        208 
 http://dba-consensus-raft-in-databases-e2:2379 |     false |         2 |        208 

Записи прошли, term остался 2: лидер жив и кворум из двух узлов есть. После docker start $S-e3 и двух секунд ожидания:

 http://dba-consensus-raft-in-databases-e1:2379 |      true |         2 |        209 
 http://dba-consensus-raft-in-databases-e2:2379 |     false |         2 |        209 
 http://dba-consensus-raft-in-databases-e3:2379 |     false |         2 |        209 

e3 остановился на индексе 108 и догнал 209 за эти две секунды: лидер досылает пропущенные записи из журнала. Индекс вырос на одну запись сверх сотни, причину этой записи опыт не проверял. Время на запись при двух узлах — 44,0 мс против 54,8 мс при трёх; разница в пределах шума docker exec, о цене третьего узла она ничего не говорит.

Опыт 3: остановка лидера, штатная и аварийная

Лидер — e1. Команда docker stop посылает SIGTERM:

docker stop $S-e1
docker exec $S-e2 etcdctl --endpoints=http://$S-e2:2379,http://$S-e3:2379 --command-timeout=1s put after-leader 1

put ответил OK с первой попытки, endpoint status:

 http://dba-consensus-raft-in-databases-e2:2379 |      true |         3 |        211 
 http://dba-consensus-raft-in-databases-e3:2379 |     false |         3 |        211 

term 2 → 3, лидером стал e2, ни одной неудачной записи. В логе e1 видно, почему: etcd при SIGTERM передаёт лидерство сам.

{"ts":"2026-10-28T00:10:51.909343Z","msg":"leadership transfer starting","local-member-id":"cb3f1b2b381bf409","current-leader-member-id":"cb3f1b2b381bf409","transferee-member-id":"69d7384d4a35a767"}
{"ts":"2026-10-28T00:10:52.010095Z","msg":"leadership transfer finished","local-member-id":"cb3f1b2b381bf409","old-leader-member-id":"cb3f1b2b381bf409","new-leader-member-id":"69d7384d4a35a767","took":"100.749834ms"}

Передача заняла 100,7 мс. Так ведёт себя плановая остановка, но не авария. После docker start $S-e1 лидером остался e2, term 3. Теперь убиваем текущего лидера e2 без предупреждения:

docker kill $S-e2
until docker exec $S-e1 etcdctl --endpoints=http://$S-e1:2379,http://$S-e3:2379 --command-timeout=1s put after-crash 1; do :; done
for n in e1 e3; do docker logs --since 2m $S-$n 2>&1 \
 | grep -E 'MsgPreVote|MsgVote|became|elected|lost leader'; done

Вывод: первая попытка не уложилась в секунду, вторая прошла (скрипт-обёртка с date показал 1,10 с от возврата docker kill до первой удачной записи).

Error: etcdserver: request timed out
OK
                    ENDPOINT                    | IS LEADER | RAFT TERM | RAFT INDEX 
 http://dba-consensus-raft-in-databases-e1:2379 |     false |         4 |        214 
 http://dba-consensus-raft-in-databases-e3:2379 |      true |         4 |        214 

term 3 → 4. Лог e3 (строки отфильтрованы grep, поля level, caller и logger убраны sed):

{"ts":"2026-10-28T00:11:17.048663Z","msg":"peer became inactive (message send to peer failed)","peer-id":"69d7384d4a35a767", ...
{"ts":"2026-10-28T00:11:18.448480Z","msg":"c1e4a09b6b6cd10c became pre-candidate at term 3"}
{"ts":"2026-10-28T00:11:18.448921Z","msg":"c1e4a09b6b6cd10c became candidate at term 4"}
{"ts":"2026-10-28T00:11:18.451655Z","msg":"c1e4a09b6b6cd10c became leader at term 4"}

Между потерей связи с лидером (17,048) и избранием нового (18,451) прошло 1,40 с: лидер молчит election-timeout (1000 мс плюс случайная добавка, чтобы кандидаты не стартовали вместе), потом pre-candidate, голосование и победа примерно за 3 мс. Индекс вырос с 212 на 2: пустую запись нового срока лидер добавляет сам при becomeLeader, плюс наш put.

Опыт 4: остался один узел из трёх

e1 остановлен, e2 убит, жив только e3 — лидер term 4:

docker exec $S-e3 etcdctl --endpoints=http://$S-e3:2379 --command-timeout=5s put no-quorum 1
docker exec $S-e3 etcdctl --endpoints=http://$S-e3:2379 --command-timeout=5s get k1
docker exec $S-e3 etcdctl --endpoints=http://$S-e3:2379 get k1 --consistency=s

Вывод (строки warn клиента убраны, 5.048 — время put по time):

Error: etcdserver: request timed out
Error: context deadline exceeded
k1
v1

Запись упала по таймауту через 5,048 с, обычное чтение (get по умолчанию линеаризуемое, идёт через кворум) — context deadline exceeded через 5,050 с. Только --consistency=s (serializable, локальное чтение без кворума) вернул v1: узел жив, но подтвердить, что это последнее значение, не может никто. endpoint status показал IS LEADER false, RAFT TERM 4, индекс 214: оставшийся узел больше не лидер, как и требует MsgCheckQuorum из цитаты выше, а term не растёт — в логе раз в секунду became pre-candidate at term 4. Pre-vote не даёт одинокому узлу накручивать term, пока он не убедился, что может победить.

После docker start $S-e1 $S-e2 и трёх секунд запись снова прошла (OK), состояние: term 5, индекс 219 на всех трёх узлах, лидер e3. Индексы трёх узлов сошлись.

Как это устроено в других базах

Тот же приём — журнал, term, кворум — у большинства баз, где нужен общий порядок записей:

  • etcd — go.etcd.io/raft/v3, этот опыт. CockroachDB держит собственную копию этой библиотеки в pkg/raft.
  • CockroachDB: initRaftGroupRaftMuLockedReplicaMuLocked создаёт raft.NewRawNode на каждую реплику range. Каждый range — отдельная Raft-группа со своим лидером и своими выборами.
  • MongoDB — выборы в replica set идут голосованием со term: _startVoteRequester рассылает запрос голосов с term и флагом dryRun. Параметр dryRun — пробный раунд голосования до настоящих выборов, по назначению это pre-vote из лога etcd. Таймер выборов создаётся в конструкторе ReplicationCoordinatorImpl (replication_coordinator_impl.cpp, _handleElectionTimeoutCallback).
  • ClickHouse Keeper — NuRaft; в KeeperServer.cpp та же арифметика кворума:
        const size_t voting_members = get_num_voting_members();
        const auto not_responding_peers = get_not_responding_peers_count();
        const auto quorum_size = voting_members / 2 + 1;
        const auto max_not_responding_peers = voting_members - quorum_size;

        return not_responding_peers <= max_not_responding_peers;

MongoDB, ClickHouse Keeper и CockroachDB в этой статье не запускались: по ним только код указанных тегов и документация.

PostgreSQL консенсуса не содержит. Его репликация отправляет WAL (статья про потоковую репликацию), а кто теперь primary, решает не сама база. Поэтому HA-кластеры PostgreSQL ставят внешнее распределённое хранилище конфигурации (DCS: etcd, Consul, ZooKeeper): Patroni строится на таком хранилище, а согласованность выбора лидера обеспечивает её консенсус, а не WAL. Следствие из опыта 4: у DCS без большинства нет ни записи, ни линеаризуемого чтения, а выбор лидера опирается именно на них; отказ кворума DCS и отказ самого PostgreSQL надо различать в мониторинге.

Redis Sentinel и Cluster — не Raft. По документации Sentinel quorum задаёт, сколько Sentinel должны согласиться, что мастер недоступен, а для самого переключения лидера выбирает голос большинства процессов Sentinel. Сами записи реплицируются асинхронно: на странице про Cluster прямо сказано, что при долгом разделении сети записи, ушедшие на отрезанного мастера, теряются. Журнала с подтверждением кворумом там нет, поэтому Sentinel и Cluster — про выбор мастера, а не про сохранность подтверждённых записей.

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

  • Узлов — нечётное число. Три терпят один отказ, пять — два (по формуле n/2+1); четыре узла терпят тот же один отказ, что и три. В опыте 3 хватало двух живых из трёх.
  • Плановая остановка не равна аварии. docker stop передал лидерство за 100,7 мс и ни одна запись не упала, docker kill дал 1,40 с выборов и один таймаут клиента. Клиенту нужен повтор запроса на время выборов, а таймаут должен быть больше election-timeout.
  • Диск каждого узла — часть latency записи. Сотня записей — сотня fsync на каждом узле (около 2 мс на стенде). На медленном диске или через далёкий узел все записи кластера идут с этой задержкой; это цена, которую асинхронная репликация не платит.
  • Пять узлов в одном ЦОД не дают устойчивости к его потере. Кворум считают по узлам, не по площадкам: при двух площадках потеря той, где три из пяти, останавливает запись.
  • Без большинства базе нечем отвечать «последнее значение». Линеаризуемое чтение в опыте 4 упало так же, как запись; serializable вернуло значение без гарантий свежести.

Чек-лист

  1. Посчитай кворум своего кластера по n/2+1 и выпиши, сколько узлов ты можешь потерять и на каких площадках они стоят.
  2. Запусти etcdctl endpoint status -w table (или аналог своей базы) и запиши RAFT TERM: рост без плановых действий значит нестабильные выборы.
  3. Проверь, что клиент повторяет запрос и его таймаут больше election-timeout (1000 мс по умолчанию в etcd).
  4. Если у тебя Patroni, проверь, где DCS и сколько узлов терпит он, а не только PostgreSQL.
  5. Убери стенд вместе с данными: docker rm -f -v dba-consensus-raft-in-databases-e1 dba-consensus-raft-in-databases-e2 dba-consensus-raft-in-databases-e3 && docker network rm dba-consensus-raft-in-databases-net.

Источники

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