Предотвращение потери обновлений в Node.js и MongoDB при одновременных записях
Узнайте, как атомарные условные обновления, оптимистичное блокирование на основе версий, ответы 409 и транзакции предотвращают случайное удаление данных при одновременных операциях записи в MongoDB.
Двое пользователей нажимают кнопку «Сохранить» для одной и той же записи, оба запроса возвращают код 200 OK, но изменения одного из пользователей просто исчезают. Ничего не ломается, ничего не фиксируется в журнале, а код, отвечающий за обработку запросов, кажется совершенно разумным при рассмотрении каждого запроса по отдельности. В этой статье объясняется, почему происходят такие потери обновлений в типичном фоновом сервисе на Node.js и MongoDB, а также приводится набор инструментов для их предотвращения: атомарные условные обновления, оптимистическое блокирование на основе версий, ответы с кодом 409 Conflict, транзакции и тесты, которые действительно воспроизводят ситуацию конкуренции.
Реалистичный сценарий
Возьмем панель управления администратора онлайн-магазина. У одного из товаров в настоящее время цена составляет 100 долларов, а количество на складе — 10 единиц.
Сотрудник в США открывает товар и снижает его цену до 90 долларов. Почти одновременно коллега в Европе открывает тот же товар и устанавливает количество на складе равным 8. Оба загрузили товар до того, как сделали сохранение, поэтому оба редактируют одну и ту же устаревшую копию. Именно из-за этой общей отправной точки и начинаются проблемы.
Разрыв между чтением, изменением и записью
Распространенный способ выполнения каждой правки — загрузка документа, изменение свойства в памяти и его сохранение. Первый запрос меняет цену. (В приведенном фрагменте после вызова save() имеется лишний дублирующийся код product.price = 90;; его можно игнорировать, так как он лишь иллюстрирует схему.)
const product = await Product.findById(productId);
product.price = 90;
await product.save();product.price = 90;
Второй запрос выполняет то же самое с полем количества на складе:
const product = await Product.findById(productId);
product.stock = 8;
await product.save();
Каждый запрос считывает старое состояние, изменяет его локальную копию и записывает обратно. Вина не в функции findById(). Опасность кроется в промежутке времени между считыванием данных и их записью обратно, поскольку другой запрос может изменить ту же запись в течение этого промежутка. Побеждает то, что будет записано последним, а любые изменения, сделанные между ними, могут быть перезаписаны: это и есть потерянное обновление.
Насколько это уже решается Mongoose
Важно точно понимать, когда возникает эта проблема. Когда вы вызываете метод save() для существующего документа Mongoose, библиотека отправляет только те поля, которые вы изменили, в формате $set. Таким образом, в приведённом выше примере, где два запроса касаются разных полей, изменения цены и запасов обычно сохраняются оба. Потерянное обновление возникает в следующих случаях:
- оба запроса изменяют одно и то же поле (два администратора редактируют цену);
- новое значение рассчитывается на основе старого (
product.stock = product.stock - 1), поэтому второй пользователь работает с устаревшим числом; - ваш API принимает от клиента целый документ, как это обычно бывает в обработчике
PUT, и записывает обратно все поля, включая те, которые только что изменил другой пользователь.
Другие ODM, чистые драйверы и SQL ORM ведут себя по-разному, поэтому не стоит полагаться на отслеживание изменений как на стратегию обеспечения конкурентности. Считайте модель чтение-изменение-запись по умолчанию небезопасной и осознанно выбирайте один из приведенных ниже инструментов.
Начните с неизменяемой величины
Прежде чем выбрать технику, определите, что ни в коем случае не должно стать неверным. Ответ зависит от области применения:
- Для запасов: количество единиц никогда не должно становиться меньше нуля.
Именно это бизнес-правило, а не какой-то предпочтительный шаблон, должно определять техническое решение.
Атомарные обновления: пусть база данных выполняет изменения
Если вы меняете одно поле, часто вовсе не нужно читать документ. Отправьте базе данных именно ту изменение, которую вы планируете. Установка цены становится одним вызовом updateOne с параметром $set:
await Product.updateOne(
{ _id: productId },
{
$set: {
price: 90
}
}
);
Редактирование запасов также происходит независимо:
await Product.updateOne(
{ _id: productId },
{
$set: {
stock: 8
}
}
);
Теперь каждая операция описывает свою истинную цель вместо того, чтобы отправлять на сервер старую копию документа. MongoDB выполняет каждое обновление отдельного документа атомарно, поэтому две такие операции над разными полями не могут взаимно уничтожить друг друга.
Разместите правило бизнес-логики внутри операции обновления
Этот подход становится ещё более эффективным, когда условие включается непосредственно в запрос. Предположим, остался всего один экземпляр товара. В примитивном варианте сначала проверяется наличие товара в коде приложения, затем его количество уменьшается, что создаёт возможность для другого покупателя его приобрести. Вместо этого пусть фильтр будет выражать правило, а функция $inc выполнит изменение в ходе той же операции:
const result = await Product.updateOne(
{
_id: productId,
stock: { $gt: 0 }
},
{
$inc: {
stock: -1
}
}
);
Если операция обновления изменяет документ, значит, в момент её выполнения товар был доступен. Если же изменений нет, значит, кто-то другой уже забрал последний экземпляр, и можно сообщить пользователю, что товар распродан. Между проверкой и записью нет разницы, поскольку это один и тот же шаг. Это один из самых простых и эффективных подходов к обработке конкурентности; он работает с счётчиками, квотами, бронированием мест и любыми правилами, которые можно выразить в виде фильтра запроса.
Оптимистическое блокирование для долговременных изменений
Атомарные обновления не могут охватить все случаи. Представьте, что сотрудник открывает сложную конфигурацию продукта, тратит пять минут на настройку нескольких полей и затем сохраняет изменения. Тем временем его коллега уже сохранила свои изменения к тому же продукту. Первый сотрудник не должен перезаписывать более новую версию, не зная о её существовании.
Стандартное решение — хранение номера версии в документе:
Product
Price: $100
Stock: 10
Version: 7
Оба пользователя загружают версию 7. Пользователь А сохраняет изменения первым, и номер версии становится 8. Пользователь B по-прежнему имеет версию 7, поэтому его сохранение должно означать «применить эти изменения только если продукт всё ещё находится на версии 7». В MongoDB это реализуется путём указания ожидаемой версии в фильтре и её увеличения при одновременном обновлении:
const result = await Product.updateOne(
{
_id: productId,
version: currentVersion
},
{
$set: {
price: newPrice
},
$inc: {
version: 1
}
}
);
if (result.modifiedCount === 0) {
return res.status(409).json({
message: "This product was updated by another user."
});
}
Если фильтр больше не соответствует, ничего не записывается, и обработчик возвращает сигнал конфликта вместо того, чтобы молча проигнорировать действия A. Это оптимистичный контроль конкуренции: вы предполагаете, что конфликты встречаются редко, не блокируете ресурсы во время редактирования пользователя и обнаруживаете коллизию в момент записи.
Два усовершенствования делают этот подход более надежным на практике. Во-первых, значение modifiedCount === 0 также получается в случае, когда продукт вообще отсутствует, поэтому проверка значения matchedCount или последующий поиск позволяют возвращать код 404 для отсутствующего продукта и код 409 только в случае реального конфликта версий. Во-вторых, если вы используете документы Mongoose вместо метода updateOne, обратите внимание на опцию optimisticConcurrency на уровне схемы; встроенный ключ __v в Mongoose иначе используется только для защиты определенных операций с массивами, а не для каждой операции сохранения.
Почему правильным статусом является 409 Conflict
Различие версий — это не сбой сервера. API работает нормально, запрос сформирован корректно; он просто вступает в конфликт с текущим состоянием ресурса. 409 Conflict точно указывает на это и позволяет клиенту правильно отреагировать:
- перезагрузить последнюю версию;
- показать пользователю, что изменилось с момента его последнего действия;
- позволить ему объединить свои изменения или попробовать снова;
- использовать механизмы обработки конфликтов, специфичные для данного продукта.
Как бы ни действовал интерфейс, принцип остается тем же: никогда не уничтожайте чужую работу, не сообщив об этом никому.
Транзакции: когда несколько операций записи должны выполниться одновременно
Теперь рассмотрим процесс оформления заказа. Он может включать создание документа заказа, бронирование запасов и ведение соответствующих записей, таких как информация о оплате или аудиторских записях. Если первые два шага пройдут успешно, а третий — нет, система останется в состоянии незавершенной операции.
Когда несколько операций должны либо все выполниться, либо все провалиться, транзакция обеспечивает атомарность. Концептуально вы начинаете транзакцию, выполняете соответствующие записи и подтверждаете их; если какой-либо необходимый шаг проваливается, происходит откат, и ни одна из записей не вступает в силу. В MongoDB многодокументные транзакции требуют наличия набора реплик или шардированного кластера и выполняются через сессию клиента.
Однако транзакции не являются универсальным решением для всех ситуаций. Они стоят дороже, могут сброситься при конфликтах при записи и требуют логики повторных попыток. Используйте их там, где бизнес-операция действительно требует целостности «всё или ничего», и предпочитайте одну условную обновление, когда этого достаточно.
Выбор подходящего инструмента
Вместо того чтобы сразу спрашивать «стоит ли использовать оптимистическое блокирование?», начните с вопроса «какую ошибку мы пытаемся предотвратить?»:
- Однофильтровое или условное изменение, например, уменьшение запасов только тогда, когда они доступны: используйте атомарное обновление.
- Устаревшие изменения от пользователей, работающих с старыми данными, например, когда два администратора редактируют один продукт: используйте оптимистический контроль конкуренции.
- Несколько операций записи, которые должны либо успешно завершиться вместе, либо совсем не выполниться, например, изменения в заказах, запасах и счетах: используйте транзакцию.
Ни один шаблон не подходит ко всем системам, и многие реальные решения сочетают в себе два из них.
Воспроизведение ситуации конкурентного доступа в тестах
Тест, в котором пользователь A обновляет товар и получает ответ о успехе, ничего не говорит о конкурентности. Обычные тесты выполняют операции одну за другой, и именно поэтому такие ошибки остаются незамеченными. Чтобы их обнаружить, необходимо создать ситуацию конкурентного доступа.
Для примера с запасами начните с установки количества на уровне 1 и одновременно запустите 100 попыток покупки, например с помощью Promise.all. Ожидаемый результат — когда ровно одна попытка бронирования увенчается успехом, а остальные 99 будут отклонены, при этом количество запасов снизится до нуля, а не станет отрицательным.
Для оптимистического блокирования установите версию в 10 и отправьте несколько обновлений, все из которых указывают на версию 10. Одно из обновлений должно успешно примениться и повысить версию, в то время как остальные получат сообщения об конфликтах вместо того, чтобы перезаписать более свежие данные.
На что обратить внимание в производственной среде
После развертывания сделайте эти показатели видимыми в ваших метриках и логах:
- ответы типа
409 Conflict; - условные обновления, которые не нашли совпадений;
- попытки повторного выполнения транзакций и их отмена;
- зацикливания и борьба за блокировки;
- непредвиденные изменения в запасах;
- дублирующиеся операции;
- другие ошибки, связанные с конкурентностью.
Внезапный рост количества конфликтов часто указывает на более глубокую причину: экстремально высокие температуры, необычные паттерны трафика, слишком агрессивные попытки клиентов повторной отправки запросов или новая функция, вызывающая больше конкуренции, чем ожидалось. Особенно эффективным решением для дублирующихся операций являются ключи идемпотентности, о которых рассказано в нашем руководстве по идемпотентным POST-конечным точкам.
Конкурентный доступ — это норма
Проблемы, связанные с одновременным доступом, на самом деле не возникают из-за того, что два человека нажимают на кнопки в один и тот же момент. Они возникают каждый раз, когда несколько компонентов могут изменять общее состояние: пользователи, экземпляры API, фоновые процессы, потребители очередей, запланированные задачи, вебхуки и другие сервисы. На любом значимом уровне конкурентный доступ является обычным явлением, а не исключением.
Поэтому не стоит спрашивать, могут ли два запроса одновременно попасть в тот же код; предполагайте, что так и будет. Полезной привычкой при анализе является задавание вопроса о том, каким будет результат, если две копии обновления будут выполнены одновременно. Если дизайн даёт на этот вопрос чёткий ответ, всё в порядке. Если честный ответ звучит как «надеюсь, второй запрос не навредит», код требует дополнительного рассмотрения.
Основные выводы
- Потерянное обновление возникает, когда одно действительное изменение перезаписывается другим, сгенерированным на основе устаревших данных, обычно в процессе операций чтения-изменения-записи.
- Преферируйте атомарные, условные обновления, в которых бизнес-правило закодировано в фильтре.
- Используйте поле версии и ответ кода
409 Conflict, чтобы обнаруживать устаревшие изменения, а не перезаписывать их. - Обращайтесь к транзакциям только тогда, когда несколько операций записи должны быть либо подтверждены, либо откатены вместе.
Связанные статьи
- Шесть шаблонов интеграции для надёжного соединения сервисов Node.js — Узнайте о основных шаблонах, лежащих в основе надёжной интеграции Node.js: модель запрос-ответ, опросы, webhooks, API-ключи, аутентификация JWT и OAuth, повторные попытки с задержками и маппинг данных.
- Как контролировать конкурентность в Node.js: избегание сбоев API с помощью p-map и Bottleneck — Узнайте, как сочетание плагинов p-map и Bottleneck в Node.js предотвращает ошибки ограничения скорости и перегрузку системы путём контроля конкурентности и времени обработки запросов.
- Создание пайплайнов агрегации в MongoDB с использованием $match, $group и $lookup — Узнайте, как этапы агрегации MongoDB фильтруют, группируют, преобразуют, объединяют и сортируют документы, а также как связать их в пайплайн для получения ответов на реальные отчетные вопросы.