В прошлом уроке запросы переехали в код. Пока каждый запрос менял один документ на одном сервере. Настоящему магазину этого мало: заказ и остаток на складе лежат в разных коллекциях, а сервер может сломаться посреди дня. Этот урок обзорный: три идеи, без которых 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 ₽.
Оплата пока не подключена: премиум и курсы открываются промокодом.