Приложение получило 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)
}
Кворум из двух узлов при убитом третьем
[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 вернуло значение без гарантий свежести.
Чек-лист
- Посчитай кворум своего кластера по
n/2+1и выпиши, сколько узлов ты можешь потерять и на каких площадках они стоят. - Запусти
etcdctl endpoint status -w table(или аналог своей базы) и запишиRAFT TERM: рост без плановых действий значит нестабильные выборы. - Проверь, что клиент повторяет запрос и его таймаут больше
election-timeout(1000 мс по умолчанию в etcd). - Если у тебя Patroni, проверь, где DCS и сколько узлов терпит он, а не только PostgreSQL.
- Убери стенд вместе с данными:
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.