Раздел 36 · MongoDB с нуля

Транзакции, репликация и шардирование

junior~20 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В прошлом уроке запросы переехали в код. Пока каждый запрос менял один документ на одном сервере. Настоящему магазину этого мало: заказ и остаток на складе лежат в разных коллекциях, а сервер может сломаться посреди дня. Этот урок обзорный: три идеи, без которых MongoDB в продакшене не живёт. Подробно каждую разберём во второй половине раздела.

Одна посылка и переезд

Посылка на почте либо дошла целиком, либо не дошла вовсе: половину коробки тебе не вручат. Переезд устроен иначе: десять коробок едут отдельно, и легко оказаться в новой квартире без коробки с посудой.

MongoDB гарантирует посылку: изменение одного документа всегда целое. Переезд, то есть согласованное изменение нескольких документов, нужно организовать отдельно, через транзакцию.

Один документ: всё или ничего

Атомарность одного документа в MongoDB есть всегда. В уроке про денормализацию мы этим уже пользовались: $push отзыва и $inc счётчика в одной команде меняются вместе.

Вот задача, где это спасает. На складе последний экземпляр «Пикника на обочине», и два покупателя нажимают «купить» одновременно. Наивный код сначала читает остаток, потом записывает новый:

покупатель А                       покупатель Б
───────────────────────────────    ───────────────────────────────
читает qty: 1
                                   читает qty: 1
записывает qty: 0, продано
                                   записывает qty: 0, продано

Одна книга продана дважды. Лечится это одной командой, где проверка и списание живут в одном фильтре:

db.stock.updateOne({ _id: 3, qty: { $gte: 1 } }, { $inc: { qty: -1 } })

Сервер проверяет условие и меняет документ атомарно. Первый покупатель получает книгу:

{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}

Второй получает matchedCount: 0: условие qty >= 1 больше не выполняется. Приложение видит ноль и отвечает «закончилось»:

{
  acknowledged: true,
  insertedId: null,
  matchedCount: 0,
  modifiedCount: 0,
  upsertedCount: 0
}

Большую часть задач, где в SQL нужна транзакция, в MongoDB решает модель данных: что меняется вместе, лежит в одном документе.

Несколько документов: транзакция

Иногда данные всё же живут в разных документах. Оформление заказа в нашем магазине трогает две коллекции: новый документ в orders и списание в stock. Если сервер упадёт между ними, будет заказ без списания.

Для этого есть многодокументная транзакция, тот же принцип, что в транзакциях PostgreSQL. В mongosh она выглядит так:

const session = db.getMongo().startSession()
const shop = session.getDatabase('test')

session.withTransaction(() => {
  shop.orders.insertOne({ _id: 6, customer_id: 3, status: 'new', items: [{ book_id: 7, qty: 1, price: 1290 }] })
  shop.stock.updateOne({ _id: 7 }, { $inc: { qty: -1 } })
})

session.endSession()

Транзакция живёт внутри сессии, и операции идут через базу, полученную из сессии. Если внутри функции случится ошибка, MongoDB отменит всё, что успело выполниться:

session.withTransaction(() => {
  shop.orders.insertOne({ _id: 7, customer_id: 1, status: 'new', items: [] })
  throw new Error('книги нет на складе')
})
Uncaught Error: книги нет на складе

После этого db.orders.findOne({ _id: 7 }) вернёт null: вставка откатилась вместе с транзакцией.

Две оговорки:

  • Нужен replica set. На одиночном сервере, как в нашем Docker из урока 3, транзакцию не начать. Запись в ней падает с ошибкой This MongoDB deployment does not support retryable writes. Что такое replica set, ниже.
  • Транзакции дороже обычных операций. Пока транзакция открыта, сервер держит блокировки и копит изменения. Документация MongoDB прямо советует: если можно обойтись одним документом, обходись.

урок из курса

Урок из курса «MongoDB с нуля». Курс покупается один раз и остаётся навсегда: 500 ₽.

Оплата пока не подключена: премиум и курсы открываются промокодом.