Атомарность на уровне документа

Главная гарантия MongoDB, на которой держится почти всё: одна операция над одним документом — это всё или ничего.

Атомарность документа — свойство MongoDB, при котором любое изменение единичного документа (даже если оно затрагивает десятки вложенных полей и массивов) применяется целиком и неделимо: другой клиент никогда не увидит документ «на полпути».

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

В реляционных базах, чтобы согласованно изменить заказ и его позиции, вы открываете транзакцию, потому что строки лежат в разных таблицах. В MongoDB заказ вместе с позициями часто хранится в одном документе — и тогда обновление всего заказа целиком уже атомарно, без всякой транзакции. Это не мелкая деталь, а основа модели данных: правильно спроектировав документ, вы получаете целостность «бесплатно».

Разберём типичную ситуацию — счётчик лайков. Если читать значение в приложение, прибавлять единицу и писать обратно, два параллельных запроса затрут друг друга, и один лайк потеряется. MongoDB решает это операторами-модификаторами, которые выполняются на сервере атомарно.

// два клиента почти одновременно жмут «лайк»
db.posts.updateOne({ _id: 42 }, { $inc: { likes: 1 } })
db.posts.updateOne({ _id: 42 }, { $inc: { likes: 1 } })
// результат всегда +2, потому что $inc считается на сервере, а не в приложении

Вывод:

likes увеличится ровно на 2 — ни один инкремент не потеряется

Встраивание вместо транзакций

Самый частый совет в моделировании MongoDB: данные, которые меняются вместе и читаются вместе, держите в одном документе. Тогда «согласованная» правка нескольких сущностей превращается в одну атомарную операцию. Сравните два подхода к корзине покупок.

{
  "_id": "cart_9",
  "userId": "u_1",
  "items": [
    { "sku": "A-100", "qty": 2, "price": 590 },
    { "sku": "B-200", "qty": 1, "price": 1490 }
  ],
  "total": 2670
}

Добавить товар и пересчитать итог можно одним обновлением — корзина никогда не окажется в состоянии «товар добавлен, а сумма ещё старая», потому что обе правки попадают в один документ за одну операцию.

db.carts.updateOne(
  { _id: "cart_9" },
  {
    $push: { items: { sku: "C-300", qty: 1, price: 300 } },
    $inc:  { total: 300 }
  }
)

Какие операторы атомарны

Атомарны все стандартные модификаторы внутри одной операции updateOne / findOneAndUpdate над одним документом.

ОператорЧто делает атомарно
$incприбавляет число (счётчики, остатки, баланс)
$push / $pullдобавляет/удаляет элемент массива
$set / $unsetзадаёт/удаляет поле, в т.ч. вложенное
$addToSetдобавляет в массив без дублей
$min / $maxобновляет, только если значение меньше/больше

Условные обновления и оптимистичная блокировка

Атомарность можно усилить условием прямо в фильтре. Классика — списать товар со склада, только если его хватает. Условие qty: { $gte: 1 } и сам декремент проверяются и применяются неделимо: если двое одновременно покупают последнюю единицу, выиграет ровно один.

const res = db.stock.updateOne(
  { sku: "A-100", qty: { $gte: 1 } },
  { $inc: { qty: -1 } }
)
// res.modifiedCount === 1 — успели; === 0 — товара уже нет

Такой приём называют оптимистичной блокировкой: вы не держите замок, а полагаетесь на то, что условие отсечёт «опоздавших». Поле-версию (version) добавляют, когда нужно гарантировать, что документ не изменился между чтением и записью.

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

В движке WiredTiger каждая запись документа проходит через его внутренний контроль конкуренции (MVCC): сервер сериализует конкурирующие изменения одного документа, поэтому со стороны клиента они выглядят как выполненные по очереди, а не вперемешку. Промежуточное состояние документа другим читателям не видно. Именно поэтому «прочитал — изменил — записал» в приложении опасно (между шагами вклинится чужая запись), а серверный модификатор вроде $inc — безопасен: чтение и запись происходят на сервере в одной атомарной единице.

Важная граница: атомарность распространяется на ОДИН документ. Как только операция трогает два документа (или две коллекции), без транзакции между этими правками может вклиниться чужое чтение — об этом следующие уроки.

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

  • Read-modify-write в коде. Прочитать likes, прибавить в приложении и записать — гонка. Используйте $inc.
  • Ожидать атомарности между документами. Два отдельных updateOne по разным документам атомарны каждый сам по себе, но не вместе.
  • Разносить тесно связанные данные по коллекциям без причины. Тогда теряется бесплатная целостность встраивания и приходится тянуть транзакции.
  • Безоглядно раздувать документ. Встраивание хорошо до лимита 16 МБ и пока массив не растёт безгранично; иначе — отдельная коллекция и ссылки.

Итоги

  • Любая операция над одним документом атомарна — целиком применяется или не применяется вовсе.
  • Встраивание связанных данных превращает «согласованную правку» в одну атомарную операцию без транзакций.
  • $inc, $push, $set и другие модификаторы считаются на сервере и безопасны при конкуренции.
  • Условие в фильтре ($gte, версия) даёт оптимистичную блокировку без явных замков.
  • Граница атомарности — один документ; за её пределами нужны транзакции.
Проверьте себя
1. Почему обновление счётчика через $inc безопаснее, чем «прочитать значение, прибавить 1 в приложении и записать обратно»?
A$inc выполняется на сервере атомарно, поэтому параллельные инкременты не затирают друг друга
B$inc автоматически создаёт транзакцию на несколько документов
CЧтение в приложение работает медленнее, чем $inc
D$inc блокирует всю коллекцию до конца операции
2. На какой объём данных распространяется гарантия атомарности в MongoDB по умолчанию?
AНа всю коллекцию
BНа один документ, даже если правка затрагивает много вложенных полей и массивов
CНа все документы с одинаковым значением поля
DНа одну базу данных целиком