Репликация в MongoDB держится на трёх вещах, которые нужно понимать вместе, а не по отдельности: raft-подобный протокол выборов primary, oplog как единственный источник истины для secondary-узлов, и concerns — настройки, которыми приложение явно выбирает баланс между скоростью и гарантией сохранности записи. Здесь всё это разобрано на живом replica set с реальным failover, включая внутренности multi-document транзакций — snapshot и concerns, — а не изоляционный угол, который уже разобран в серии про транзакции. Контрастная нить продолжается: oplog против стриминговой и логической репликации PostgreSQL, с числами из живого контейнера.
Предыдущая статья серии — Aggregation pipeline вглубь — крутилась вокруг одного узла: как планировщик выбирает индекс, где $lookup платит за join. Здесь узлов три. Всё измеренное ниже снято на живом replica set rs0 (3 узла, mongo:8.2.11) рядом с postgres:18 для контраста, на сквозном датасете серии (seed=42: 50 000 пользователей, 5000 товаров, 200 000 заказов), стенд mongodb/replication. Прогон бинарника replication/main.go идёт в две фазы: сначала весь разбор concerns и причинной согласованности на здоровом кластере, затем — запись уже ПОСЛЕ того, как demo-скрипт убил primary и дождался перевыборов.
В статье
- Выборы primary: raft-подобный протокол
- Механика oplog — единственный источник истины для secondary
- Write concern: w:1 против w:majority
- Read concern, причинная согласованность и read preference
- Rollback и живой failover на трёхузловом replica set
- Внутренности multi-document транзакций: snapshot и concerns
- PG-нить: oplog против стриминговой и логической репликации PostgreSQL
Выборы primary: raft-подобный протокол
Replica set — это множество узлов с одной и той же копией данных, из которых ровно один в каждый момент времени является primary (принимает записи), а остальные — secondary (реплицируют oplog primary и обслуживают чтения, если приложение это разрешает). Кто именно primary — решает не администратор и не конфиг, а сам кластер через протокол выборов.
Протокол — raft-подобный (в терминологии MongoDB это protocol version 1, pv1). Ключевые понятия те же, что в Raft: term (эпоха выборов, монотонно растущий счётчик) и кворум большинства. Узел, переставший в течение electionTimeoutMillis (по умолчанию 10 секунд) слышать heartbeat’ы от текущего primary, объявляет себя кандидатом, увеличивает term и просит голоса у остальных. Чтобы стать primary, кандидат должен собрать голоса большинства узлов, включая свой. Отсюда прямое следствие: в кластере из трёх узлов кластер переживает потерю одного (2 из 3 — большинство), но не двух (1 из 3 — большинства нет, записи останавливаются). Именно поэтому нечётное число узлов — норма: три узла дают ту же отказоустойчивость по одному отказу, что и два, но с реальным кворумом.
Term — не абстракция ради протокола, он физически записан в каждую oplog-запись (поле t, см. ниже), и по нему кластер отличает записи «свежего» primary от записей узла, который был primary в прошлую эпоху и об этом ещё не знает. На этом же поле строится rollback.
Механика oplog — единственный источник истины для secondary
Oplog (operations log) — это capped-коллекция local.oplog.rs, куда primary пишет каждую меняющую данные операцию в идемпотентной форме. Secondary не «реплицируют запросы» — они тянут записи из oplog primary и применяют их у себя по порядку. Oplog — это и есть протокол репликации; всё остальное (concerns, causal consistency, failover) построено поверх него.
Один insertOne в коллекцию cookbook.replication_demo порождает ровно одну oplog-запись. Стенд прочитал её напрямую из local.oplog.rs на primary (readpref.Primary() — oplog это собственный журнал узла, а не общий ресурс) сразу после вставки. Вот она дословно, в extended JSON:
{
"op": "i",
"ns": "cookbook.replication_demo",
"ui": { "$binary": { "base64": "9C52/zeWT7iRJn54T6DoQA==", "subType": "04" } },
"o": {
"_id": { "$oid": "6a52589a63c5afbaa8279058" },
"marker": "oplog-demo",
"seq": 1,
"created_at": { "$date": "2026-07-11T14:52:10.799Z" }
},
"o2": { "_id": { "$oid": "6a52589a63c5afbaa8279058" } },
"ts": { "$timestamp": { "t": 1783781530, "i": 3 } },
"t": 1,
"v": 2,
"wall": { "$date": "2026-07-11T14:52:10.82Z" },
"lsid": { "id": "…", "uid": "…" },
"txnNumber": 1,
"stmtId": 0,
"prevOpTime": { "ts": { "$timestamp": { "t": 0, "i": 0 } }, "t": -1 }
}Разберём поля, существенные для понимания репликации:
| Поле | Значение (этот прогон) | Смысл |
|---|---|---|
op |
"i" |
тип операции — insert (u — update, d — delete, c — команда) |
ns |
cookbook.replication_demo |
namespace: db.collection |
o |
{_id, marker:"oplog-demo", seq:1, created_at} |
сам вставленный документ — операция самодостаточна, secondary применяет её без доступа к оригинальному запросу |
o2 |
{_id} |
ключ, идентифицирующий документ (у insert — _id); у update здесь был бы фильтр по _id |
ts |
Timestamp(1783781530, 3) |
позиция в oplog: секунды epoch + ординал внутри секунды. Реплики применяют операции строго по возрастанию ts |
t |
1 |
term выборов, в котором запись сделана — тот самый счётчик эпох из протокола выборов |
wall |
2026-07-11T14:52:10.82Z |
wall-clock время применения (для людей и мониторинга, не для упорядочивания) |
v |
2 |
версия формата oplog-записи |
lsid/txnNumber/stmtId |
метаданные retryable-write | сессия + номер операции для идемпотентного повтора записи драйвером |
Три поля здесь несут всю механику: o делает запись самодостаточной (secondary не нужен исходный запрос — только его результат), ts задаёт глобальный порядок применения, t привязывает запись к эпохе выборов. Идемпотентность — не деталь, а требование: если secondary переприменит уже применённую запись (например, после переподключения), результат не должен измениться. Поэтому update в oplog хранится не как «увеличь поле на 1», а как готовое новое значение.
Латентность самого insertOne в этом прогоне — 27.0 ms, но это первая запись в свежесозданную коллекцию, и в неё входит автосоздание коллекции; на устоявшейся коллекции цифры совсем другие — их даёт следующий раздел про write concern.
Oplog — capped-коллекция фиксированного размера: старые записи вытесняются новыми. Отсюда понятие oplog window — временной интервал, который в нём умещается. Если secondary отстал больше, чем на это окно (был выключен дольше, чем в oplog помещается история), он уже не может догнать инкрементально и требует полной ресинхронизации. Размер oplog — это, по сути, запас прочности на то, как долго узел может быть offline.
Write concern: w:1 против w:majority
Write concern — это ответ на вопрос «когда считать запись состоявшейся». Он не меняет, что записано, он меняет, чего дождаться перед тем, как вернуть приложению ok.
w:1— primary подтвердил запись у себя. Быстро, но если primary упадёт до того, как secondary успели реплицировать эту запись, она может быть потеряна при перевыборах (см. rollback ниже).w:majority— запись подтверждена большинством узлов кластера. Медленнее (ждём кворум), но такая запись durable: она переживёт потерю любого одного узла, потому что уже есть у большинства, а любой будущий primary избирается большинством — значит, обязан её содержать.
Стенд прогнал 200 одиночных insertOne каждым concern’ом. Именно одиночных, а не bulk: ack ждётся на КАЖДУЮ запись отдельно, bulk-вставка смазала бы разницу, амортизировав ожидание кворума по всей пачке.
// w:1 — ждём только primary
db.replication_demo.insertOne(doc, { writeConcern: { w: 1 } })
// w:majority — ждём подтверждения большинства узлов
db.replication_demo.insertOne(doc, { writeConcern: { w: "majority" } })| Write concern | avg-латентность на запись | total на 200 записей |
|---|---|---|
w:1 (ack только от primary) |
549.7 µs | 109.9 ms |
w:majority (ack от большинства узлов) |
4.10 ms | 820.1 ms |
w:majority вышел дороже w:1 в 7.46× (549.7 µs → 4.10 ms) — и это на одном docker-хосте, где все три узла на одной машине. Разница есть даже здесь, потому что кворумные ack всё равно идут через сетевой стек контейнеров. На реальной сети между зонами доступности или датацентрами разрыв только вырастет: majority ждёт самый медленный узел из кворума, и его латентность — это межрегиональный round-trip. Это и есть цена durable-записи в явном виде: не «включить надёжность бесплатно», а конкретный множитель к latency, который приложение платит осознанно.
Практический вывод — write concern выбирается на запись, не глобально. Оплата заказа и запись в audit-лог хотят w:majority (терять нельзя). Счётчик просмотров или необязательная телеметрия переживут w:1 (потеря одной записи при редком failover дешевле, чем множитель латентности на каждой). Артикулировать этот выбор — работа приложения, а не БД.
Read concern, причинная согласованность и read preference
Симметрично write concern существует read concern — «насколько согласованные данные я хочу прочитать» (local, majority, linearizable, snapshot). А read preference отвечает на другой вопрос — «с какого узла читать» (primary, secondary, nearest и т.д.). Разгрузить primary, отправив тяжёлые чтения на secondary, — типичный паттерн. Но у него есть цена: secondary применяет oplog асинхронно и может отставать. Чтение с secondary без предосторожностей — это чтение возможно устаревших данных.
Здесь и возникает причинная согласованность (causal consistency) — гарантия read-your-writes даже при чтении с отстающего secondary. Механизм: причинно-согласованная сессия запоминает operationTime последней операции и подкладывает его в следующее чтение как readConcern.afterClusterTime. Secondary, получив такую команду, дожидается, пока его копия oplog догонит указанный cluster-time, и только потом отвечает. Читатель никогда не увидит состояние ДО собственной записи.
Стенд доказал это не гонкой (гонка флаки — то поймала, то нет), а перехватом сырой команды find через *event.CommandMonitor. Проверялось буквально: присутствует ли в отправленной на сервер команде поле readConcern.afterClusterTime.
// сессия с CausalConsistency=true
const session = db.getMongo().startSession({ causalConsistency: true })
const coll = session.getDatabase("cookbook").replication_demo
coll.insertOne({ marker: "causal", seq: 1 }) // запись
coll.find({ marker: "causal" }).readPref("secondary")
// драйвер сам добавит readConcern.afterClusterTime = operationTime записи
// → secondary дождётся применения этой операции из oplog
| Вариант | документ найден на secondary | latency | afterClusterTime в команде |
|---|---|---|---|
| С причинно-согласованной сессией | да (read-your-writes) | 48.4 ms | есть (проверено в сырой команде) |
| БЕЗ сессии (независимый read) | да (в этом прогоне) | 6.2 ms | нет (проверено) |
Разберём обе строки честно.
С сессией — документ реально виден при чтении с secondary сразу после записи, И перехваченная find-команда несла readConcern.afterClusterTime. Это детерминированный, гарантированный контрактом драйвера механизм, а не «повезло с гонкой». Латентность здесь ВЫШЕ (48.4 ms против 6.2 ms) — ровно потому, что secondary при causal-чтении реально ждёт догона реплики до нужного cluster-time вместо того, чтобы ответить из текущего, возможно отстающего, состояния. Более высокая латентность — это видимая цена гарантии, а не накладной расход.
Без сессии — afterClusterTime в команде не появляется никогда: без сессии его взять неоткуда. Это жёсткий ассерт (поля просто нет), а не наблюдение.
Здесь важна честная оговорка. Без причинной согласованности документ на secondary в этом прогоне всё равно нашёлся сразу (6.2 ms) — на одном docker-хосте локальная репликация обычно опережает независимый клиентский read, поэтому «stale read с secondary» тут вживую не воспроизвёлся как промах. Это не значит, что риска нет: на реальной сети и под нагрузкой secondary отстаёт заметно. Именно поэтому доказательство causal consistency построено на детерминированном признаке (наличие/отсутствие afterClusterTime в сырой команде), а не на флаки-наблюдении «нашёлся / не нашёлся». Признак присутствует ровно тогда, когда сессия причинно-согласованная, и отсутствует иначе — независимо от того, повезло ли в конкретном прогоне поймать отставание реплики.
Rollback и живой failover на трёхузловом replica set
Rollback — прямое следствие w:1 и модели выборов. Пусть primary принял запись с w:1 (подтвердил у себя), но упал прежде, чем secondary её реплицировали. Кластер выбирает нового primary из большинства — у которого этой записи нет. Когда упавший узел вернётся, он обнаружит, что его хвост oplog расходится с новым primary (записи в более старом term’е t, которых нет в общей истории), и откатит эти локальные записи в файл rollback, приведя себя в соответствие с кластером. Вот прямая связь: w:1 создаёт окно, в котором подтверждённая приложению запись может быть откачена. w:majority это окно закрывает — такая запись по определению есть у большинства и не может исчезнуть при выборах. Term (t) в oplog — это именно тот признак, по которому вернувшийся узел понимает, что его хвост из «прошлой эпохи» и подлежит откату.
Failover вживую. Стенд не имитировал отказ failpoint’ом — он убил контейнер. Demo-скрипт определил текущий primary (mongo1), сделал docker stop его контейнера и опрашивал db.hello().isWritablePrimary на двух выживших узлах (mongo2/mongo3 — 2 из 3, кворум сохранён) до появления нового primary.
| Метрика | Значение |
|---|---|
| остановленный primary | mongo1 |
| новый primary (избран) | mongo2 |
| приблизительное время перевыборов | ~5 s |
| запись ПОСЛЕ re-election | успешна, обслужена mongo2:27017, latency 25.6 ms |
Про «~5 s» нужна честность. Это не чистое серверное election-time и не константа. Это внешне наблюдаемое полное окно «остановил контейнер → сосед объявил себя primary», измеренное bash-polling’ом с шагом 1 секунда. В него входит таймаут на пропущенные heartbeat’ы, сами выборы, и время самого docker stop. Формально electionTimeoutMillis по умолчанию 10 s, но фактический детект отказа ускоряется: docker stop рвёт TCP явно, а не оставляет узел молчать до таймаута. Поэтому корректная формулировка — «порядка нескольких секунд, фиксируется каждый прогон», а не «MongoDB избирает primary за 5 секунд».
Главное — не число, а сквозная демонстрация: потеря узла-primary → автоматические перевыборы → кластер снова принимает записи, без ручного вмешательства. Запись фазы failover-write реально прошла на новый primary mongo2:27017 (InsertedID вернулся, ошибки нет, latency 25.6 ms). Это и есть высокая доступность в действии — приложение переживает смерть узла ценой короткой паузы на переизбрание.
Внутренности multi-document транзакций: snapshot и concerns
Multi-document транзакции в MongoDB стоят на том же понятийном аппарате, что и всё выше. Транзакция открывает snapshot на определённый cluster-time (тот самый operationTime/afterClusterTime) и видит согласованный срез данных на этот момент, не замечая параллельных изменений. У транзакции есть свои write concern (durability коммита — как у обычной записи, w:majority для гарантии) и read concern (snapshot — чтение из согласованного среза). То есть механика транзакции — это надстройка над oplog, cluster-time и concerns, а не отдельная вселенная.
Здесь проходит граница статьи. Внутренности snapshot-механизма и concerns транзакции — это про то, как транзакция взаимодействует с репликацией, и это здесь. А вот изоляционный угол — уровни изоляции, аномалии (write skew, dirty read), сравнение с другими СУБД — это отдельный большой разговор, и он уже ведётся в серии про транзакции: Транзакции в KV и документных БД. Дублировать его здесь смысла нет; кто пришёл за изоляцией — туда.
PG-нить: oplog против стриминговой и логической репликации PostgreSQL
Сквозная нить серии — контраст с PostgreSQL. По репликации он получается особенно наглядным, потому что фундаментальный механизм у обеих СУБД похож (журнал изменений, который тянут реплики), а вот философия «из коробки» — противоположная.
У PostgreSQL журнал — это WAL (write-ahead log). На нём построены два вида репликации:
- Стриминговая (физическая) репликация — реплика получает поток WAL-записей на уровне байтов страниц и применяет их дословно. Это ближайший аналог oplog по роли, но на более низком уровне (страницы, не логические операции).
- Логическая репликация — WAL декодируется в построчные логические изменения (INSERT/UPDATE/DELETE конкретных строк) и публикуется через publication/subscription. Это ближе к oplog по уровню абстракции (логические операции, а не страницы), и позволяет реплицировать выборочно, между разными версиями, в другую схему.
Стенд зафиксировал один живой факт из postgres:18 «из коробки»:
SHOW wal_level; -- replica (НЕ logical)
SHOW max_wal_senders; -- 10| Параметр | Значение образа postgres:18 «из коробки» |
|---|---|
wal_level |
replica (НЕ logical) |
max_wal_senders |
10 |
Вот в чём контраст. postgres:18 поднимается с wal_level=replica — этого достаточно для стриминговой репликации, но логическая требует явного wal_level=logical плюс перезапуск сервера: она НЕ включена из коробки. То есть в PostgreSQL уровень репликации — это осознанная настройка кластера, за которую платят конфигом и рестартом.
В MongoDB — наоборот: oplog пишется безусловно на любом узле replica set сразу после rs.initiate(), без отдельного флага. Логическая по своей природе (операции, не страницы), включена всегда, как только узел стал частью replica set. Формула контраста: у MongoDB репликация «встроена всегда и логическая по умолчанию», у PostgreSQL — «физическая из коробки, логическая по явному включению». Ни то ни другое не лучше в вакууме: MongoDB-подход проще для типичного кейса (реплики без настройки), PostgreSQL-подход даёт больше контроля над тем, что и как реплицируется. Оговорка честная: wal_level=replica — это факт данной конфигурации postgres:18, а не универсальное правило; полноценный стенд publication/subscription — тема отдельной статьи про PG-репликацию.
Что осталось в руках после этой статьи: replica set — это кворумные выборы поверх oplog, а concerns — это явные рукоятки на осях «durability записи» и «согласованность чтения», за каждую из которых платят конкретной латентностью (7.46× за w:majority, повышенная latency за causal-чтение). Failover — не магия, а перевыборы за секунды с автоматическим возвратом к приёму записей. Дальше в серии — Sharding в продакшене: как эти же replica set становятся шардами, как данные распределяются по ключу шардирования и что при этом происходит с балансировкой и запросами. Живой стенд к этой статье — mongodb/replication.
Комментарии