Модели согласованности и retryable writes

Что именно увидит клиент в распределённом кластере: свои же записи, согласованную причинно-следственную картину — и где данные могут оказаться слегка устаревшими.

Причинная согласованность (causal consistency) — гарантия, что связанные операции наблюдаются в порядке их причинно-следственной связи: если запись B логически следует за чтением/записью A, ни один клиент в рамках сессии не увидит B раньше A.

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

В репликасете чтения можно разгрузить на вторичные узлы — это масштабирует нагрузку. Но вторичные отстают от primary на величину репликационного лага, и наивное чтение оттуда легко приводит к парадоксам: пользователь сохранил профиль, обновил страницу и не увидел своих изменений. Модели согласованности и retryable-механизмы дают предсказуемость без отказа от масштабирования.

Read your writes и причинная согласованность

Гарантия «read your writes» означает: клиент всегда видит результат собственной завершённой записи при последующем чтении. MongoDB обеспечивает это через каузально-согласованные сессии. Сессия переносит между операциями «отметку времени кластера»: следующее чтение ждёт, пока выбранный узел догонит как минимум до момента вашей записи.

const s = db.getMongo().startSession({ causalConsistency: true })
const users = s.getDatabase("app").users
// записали профиль с majority
users.updateOne({ _id: "u_1" }, { $set: { name: "Аня" } },
  { writeConcern: { w: "majority" } })
// в той же сессии гарантированно увидим своё изменение,
// даже если чтение уйдёт на вторичный узел
users.find({ _id: "u_1" }).readPref("secondaryPreferred")

Без каузальной сессии то же чтение с вторичного узла могло бы вернуть старое имя, попав на отставшую реплику. Каузальная согласованность гарантирует ещё и порядок: если вы прочитали комментарий, то ответ на него, написанный позже, не «опередит» сам комментарий в вашей картине мира.

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

Read preference выбирает, с каких узлов читать. Это рычаг масштабирования, но у него есть цена — устаревшие данные (stale reads).

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

Параметр maxStalenessSeconds ограничивает, насколько сильно вторичный узел может отставать, чтобы вообще участвовать в чтении. Это защита от выбора совсем «протухшей» реплики.

// аналитика терпит лёгкое отставание, но не больше 90 секунд
db.events.find({ type: "click" })
  .readPref("secondary", [], { maxStalenessSeconds: 90 })

Правило простое: данные, для которых критична свежесть (баланс, статус заказа), читайте с primary или в каузальной сессии; аналитику и фоновые отчёты можно отдать вторичным.

Retryable writes и reads

Сеть в распределённой системе ненадёжна: запрос может не дойти или ответ — потеряться. Retryable writes позволяют драйверу один раз безопасно повторить запись, не создав дубликат.

Retryable write — запись, которую драйвер при сетевом сбое или смене primary автоматически повторяет ровно один раз; сервер по идентификатору операции распознаёт повтор и не применяет её дважды.

Идемпотентность обеспечивает сервер: каждой повторяемой записи драйвер присваивает уникальный идентификатор транзакции, и при повторе сервер видит, что операция уже применялась, и возвращает прежний результат вместо второго выполнения. Поэтому повтор updateOne или insertOne не задвоит данные. По умолчанию retryable writes включены в современных драйверах (retryWrites=true в строке подключения).

// строка подключения с включёнными повторами записей
mongodb://host1,host2,host3/app?replicaSet=rs0&retryWrites=true

Аналогично работают retryable reads: упавшее из-за сети чтение драйвер повторит автоматически (тоже включено по умолчанию). Замечание: повторяются единичные операции; для многошаговой логики через несколько документов нужен механизм транзакций с ретраями из урока про многодокументные транзакции.

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

Каузальная сессия хранит два «маяка»: последний наблюдённый operationTime (для чтений) и cluster time. Перед чтением драйвер сообщает узлу «не отвечай, пока не догонишь до этой отметки» — узел дожидается репликации до нужной позиции oplog. Для retryable writes сервер ведёт учёт идентификаторов сессии и номера транзакции: получив повтор с тем же номером, он находит сохранённый результат предыдущего применения и отдаёт его, гарантируя «ровно один раз» вместо «как минимум один раз».

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

  • Чтение профиля сразу после записи с secondary без каузальной сессии. Классический «пропавший апдейт» из-за лага реплики.
  • secondary для критичных к свежести данных. Баланс и статус заказа читайте с primary или в каузальной сессии.
  • Считать, что retryable writes повторяют всё. Они повторяют одну операцию, а не вашу многошаговую бизнес-логику.
  • Игнорировать maxStalenessSeconds. Без него чтение может попасть на сильно отставший узел.
  • Путать причинную и сильную согласованность. Causal гарантирует порядок связанных операций в сессии, но не «всегда самое последнее значение глобально».

Итоги

  • Каузально-согласованные сессии дают read-your-writes и сохраняют порядок связанных операций даже при чтении с реплик.
  • Read preference масштабирует чтения через вторичные узлы ценой возможного устаревания данных.
  • maxStalenessSeconds отсекает слишком отставшие реплики; свежесть-критичное читайте с primary.
  • Retryable writes/reads безопасно повторяют единичную операцию при сетевом сбое и не создают дублей.
  • Причинная согласованность — это про порядок связанных событий в сессии, а не про глобально «самое последнее» значение.
Проверьте себя
1. Зачем нужна каузально-согласованная сессия при чтении с вторичных узлов сразу после собственной записи?
AОна ускоряет репликацию между узлами
BОна гарантирует read-your-writes: чтение дождётся, пока узел догонит до момента вашей записи, и вы увидите своё изменение
CОна отключает вторичные узлы и читает только с primary
DОна превращает запись в многодокументную транзакцию
2. Почему повтор retryable write не приводит к дублированию данных?
AДрайвер вообще не повторяет записи, а только логирует ошибку
BСервер по уникальному идентификатору операции распознаёт повтор и возвращает прежний результат вместо второго применения
CMongoDB удаляет дубликаты фоновым процессом раз в минуту
DПовтор выполняется только при w: 0