Запобігання втраті оновлень у Node.js та MongoDB під час одночасних записів
Дізнайтеся, як атомарні умовні оновлення, оптимістичне блокування на основі версій, відповіді 409 та транзакції запобігають тому, щоб одночасні записи в MongoDB мимохідь видаляли дані.
Двоє людей натискають кнопку «Зберегти» для одного й того ж запису, обидві заявки повертають код 200 OK, а зміни однієї людини мовчки зникають. Ніщо не падає, нічого не фіксується в журналі, а код, відповідальний за це, здається абсолютно логічним при розгляді кожної заявки окремо. У цій статті пояснюється, чому відбуваються такі втрачені оновлення у типовому серверному середовищі Node.js та MongoDB, а також наводиться набір інструментів для їх запобігання: атомарні умовні оновлення, оптимістичне блокування на основі версій, відповіді 409 Conflict, транзакції та тести, які дійсно відтворюють цей сценарій конкуренції.
Реалістичний сценарій
Візьмемо панель керування адміністратора онлайн-магазину. Один з товарів наразі має ціну 100 доларів та 10 одиниць у наявності.
Співробітник у США відкриває продукт та знижує ціну до 90 доларів. Майже одночасно колега в Європі відкриває той самий продукт та встановлює кількість наявності на рівень 8. Обидва завантажили продукт до того, як хтось із них зберіг зміни, тому обидва редагують одну й ту саму застарілу копію даних. Саме ця спільна вихідна точка є причиною проблем.
Розрив між читанням, зміною та записом
Поширеним способом здійснення кожної зміни є завантаження документа, зміна певної властивості в пам’яті та її збереження. Перше запитання змінює ціну. (У наведеному уривку є додатковий, дубльований рядок product.price = 90; після виклику save(); його можна проігнорувати, адже він лише ілюструє цю схему.)
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. Користувач Б все ще має версію 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, фонові процеси, споживачі черг, заплановані завдання, webhooks та інші сервіси. На будь-якому значущому рівні конкурентний доступ є звичайною практикою, а не винятком.
Тож не запитуйте, чи можуть два запити одночасно потрапити до того самого коду; припускайте, що так і буде. Корисною практикою під час перевірки є запитання до кожної зміни: яким буде результат, якщо дві її копії виконуватимуться одночасно. Якщо архітектура чітко відповідає на це питання, у вас все гаразд. Якщо чесна відповідь — «сподіваємося, другий запит не завдасть шкоди», код потребує додаткового аналізу.
Ключові висновки
- Втрата оновлення відбувається тоді, коли одна дійсна зміна перезаписується іншою, створеною на основі застарілих даних, зазвичай через моделювання «читати-змінити-записати».
- Віддавайте перевагу атомарним, умовним оновленням, які кодують бізнес-правила в фільтрах.
- Використовуйте поле версії та відповідь
409 Conflict, щоб виявляти застарілі зміни, а не перезаписувати їх. - Звертайтеся до транзакцій лише тоді, коли кілька записів мають бути або підтверджені, або скасовані разом.
Пов’язана література
- Шість шаблонів інтеграції для надійного з’єднання сервісів Node.js — Дізнайтеся про основні шаблони міцних інтеграцій у Node.js: модель запит-відповідь, опитування, webhooks, API key, автентифікація JWT та OAuth, повторні спроби з затримкою та мапування даних.
- Контроль конкурентності в Node.js: як уникнути збоїв API за допомогою p-map та Bottleneck — Дізнайтеся, як поєднання p-map та Bottleneck у Node.js запобігає помилкам обмеження частоти та перевантаженню системи шляхом контролю конкурентності та часу виконання запитів.
- Створення конвеєрів агрегації MongoDB за допомогою $match, $group та $lookup — Дізнайтеся, як етапи агрегації MongoDB фільтрують, групують, перетворюють, об’єднують та сортують документи, а також як поєднувати їх у конвеєр, який дозволяє відповідати на реальні запитання звітності.