Replica Set: отказоустойчивость

Один сервер БД — это одна точка отказа: упал mongod, и приложение лежит. Replica set убирает эту проблему, держа несколько копий данных и переключаясь на живую автоматически.

Replica set — группа процессов mongod, хранящих одинаковый набор данных. Один из них — PRIMARY (принимает записи), остальные — SECONDARY (копируют данные и готовы заменить primary при сбое).

Зачем это на практике

В проде MongoDB почти всегда запускают не одиночным сервером, а replica set. Причин две: отказоустойчивость (high availability) и сохранность данных (redundancy). Если в продакшене один mongod упадёт из-за сбоя диска, перезагрузки хоста или ошибки ядра, приложение без реплик потеряет доступ к базе — а возможно, и к данным. С replica set данные лежат на нескольких машинах, и при падении primary кластер сам выбирает нового. Приложение этого почти не замечает: драйвер обнаруживает смену primary и продолжает работать.

Минимальная боевая конфигурация — три узла с данными (схема PSS: primary + два secondary). Три — потому что для выборов нужно большинство голосов, а с тремя узлами кластер переживёт падение любого одного и сохранит большинство (2 из 3).

Роли участников

PRIMARY — единственный узел, принимающий записи. Все insert/update/delete идут на него. В replica set в любой момент времени ровно один primary.

SECONDARY — реплика. Постоянно копирует операции с primary и держит идентичную копию данных. С secondary можно читать (об этом ниже) и любой из них может стать primary при сбое.

ARBITER — особый узел: он не хранит данные, а только участвует в голосовании, чтобы разбить «ничью» при чётном числе узлов. Арбитр дешёвый (не нужен диск под данные), но он не повышает сохранность — это просто голос. Современная рекомендация — избегать арбитров и держать нечётное число узлов с данными; схема P-S-A коварна (об этом в «Частых ошибках»).

Поднимаем replica set

Узлы запускают с общим именем набора (--replSet), а затем инициализируют конфигурацию из mongosh:

// каждый mongod стартует с одинаковым именем набора
mongod --replSet rs0 --port 27017 --dbpath /data/db1
mongod --replSet rs0 --port 27018 --dbpath /data/db2
mongod --replSet rs0 --port 27019 --dbpath /data/db3
// один раз — инициализация набора (подключившись к любому узлу)
rs.initiate({
  _id: "rs0",
  members: [
    { _id: 0, host: "mongo1:27017" },
    { _id: 1, host: "mongo2:27018" },
    { _id: 2, host: "mongo3:27019" }
  ]
})

rs.status()   // увидим, кто PRIMARY, кто SECONDARY
rs.conf()     // текущая конфигурация набора
rs.add("mongo4:27020")  // добавить узел позже

Приложение подключается не к одному узлу, а ко всему набору через параметр replicaSet — драйвер сам найдёт primary:

mongodb://mongo1:27017,mongo2:27018,mongo3:27019/shop?replicaSet=rs0&w=majority

Чтение со вторичных узлов

По умолчанию все операции (и записи, и чтения) идут на primary — это даёт строгую согласованность. Но чтение можно разрешить и с secondary через read preference:

ЗначениеОткуда читать
primaryтолько primary (по умолчанию, всегда свежие данные)
primaryPreferredprimary, а если его нет — secondary
secondaryтолько secondary (разгрузить primary)
nearestближайший по сети узел (минимальная задержка)

Важная оговорка: secondary отстаёт от primary на время репликации (миллисекунды, но иногда секунды). Поэтому чтение с secondary — это eventual consistency: можно прочитать слегка устаревшие данные. Для аналитики, отчётов и георазнесённых читателей это нормально; для «прочитать только что записанное» — нет.

Как это работает под капотом

Сердце репликации — oplog (operations log), специальная capped-коллекция local.oplog.rs на primary. Каждая изменяющая операция записывается в oplog в идемпотентной форме: например, $inc на единицу превращается в «установить поле в конкретное значение», чтобы повторное применение не сломало данные. Secondary постоянно «тянут» (tail) oplog primary и применяют операции у себя. Размер oplog определяет окно репликации: если secondary отстанет дальше, чем хранит oplog, ему потребуется полная пересинхронизация.

За живучесть отвечают heartbeat-сообщения: узлы пингуют друг друга каждые ~2 секунды. Если primary не отвечает дольше electionTimeoutMillis (по умолчанию 10 секунд), оставшиеся узлы запускают выборы. Побеждает узел, набравший большинство голосов и имеющий самые свежие данные; он становится новым primary. Протокол выборов (protocol version 1) построен на идеях Raft. Весь failover занимает обычно около 10–12 секунд, в течение которых записи временно невозможны — это плата за автоматику.

С сохранностью записи связан write concern. Значение w: 1 (умолчание во многих драйверах) подтверждает запись, как только её принял primary — но если primary упадёт до репликации, такая запись может быть откатена (rollback). w: "majority" ждёт подтверждения от большинства узлов: такая запись переживёт failover. Для важных данных используйте w: "majority".

Частые ошибки

  • Схема P-S-A и ожидание durability. Если у вас primary + один secondary + arbiter и secondary выходит из строя, записи с w: "majority" начнут блокироваться: большинство узлов с данными недостижимо. Арбитр голос даёт, а данные — нет. Безопаснее три узла с данными.
  • Чтение с secondary и ожидание свежих данных. Пользователь сохранил профиль, тут же открыл его и видит старую версию — классика. Для «read-your-own-writes» читайте с primary или используйте подходящий read/write concern.
  • Полагаться на w: 1 для критичных данных. Платёж подтверждён primary, primary упал — платёж потерян. Используйте w: "majority" там, где потеря недопустима.
  • Чётное число голосующих узлов. При сетевом разделении пополам ни одна половина не получит большинство — кластер останется без primary. Держите нечётное число голосов.
  • Не следить за окном oplog. Под нагрузкой маленький oplog «прокручивается» быстро; отставший secondary вываливается в полную пересинхронизацию. Мониторьте лаг репликации.

Итоги

  • Replica set — стандарт для прода: несколько копий данных + автоматическое переключение при сбое.
  • Один PRIMARY принимает записи; SECONDARY реплицируют и готовы его заменить; ARBITER только голосует и данных не хранит.
  • Минимум для боя — три узла с данными; число голосующих узлов держите нечётным.
  • Выборы запускаются при недоступности primary и занимают ~10–12 секунд; репликация идёт через идемпотентный oplog.
  • Чтение с secondary разгружает primary, но даёт eventual consistency; для сохранности записей используйте w: "majority".
Проверьте себя
1. Сколько узлов в replica set могут одновременно быть PRIMARY и принимать записи?
AРовно один
BДва (для балансировки)
CВсе SECONDARY одновременно
DЗависит от числа арбитров
2. Чем ARBITER отличается от SECONDARY?
AАрбитр хранит вторую копию данных, а secondary — нет
BАрбитр только участвует в выборах и не хранит данные, поэтому не повышает сохранность
CАрбитр всегда становится новым primary при сбое
DНикакой разницы, это синонимы
3. Что произойдёт с записью, подтверждённой с write concern w:1, если primary упадёт сразу после ответа клиенту?
AНичего, запись гарантированно сохранена на всех узлах
BЗапись может быть откатена (rollback), если не успела реплицироваться
CКластер автоматически восстановит её из арбитра
DЗапись превратится в чтение