Забэрэжанне падтрымкі пры выкарыстоўванні 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, 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 мнагадокументныя транзакцыі выконваюцца за дапамогою набора реплікаў чы шардаванага кластэра через сесію кліента.
Адзінкія не ўсепанае рашэнне для рэшэння проблем з адрабоўкамі дадзеных. Яны коштаюць больш, могу быць перарваныя з-за канфліктав у запісах і вымагаюць логіки практыкавання. Ожывайце іх там, дзе бізнес-операцыя практычна вымагае або цалковай, або ніякой консыстэнтнасі, і валічыце адзін умовны запіс, калі гэтага дастаткова.
Выбор правога інструмента
Уместо таго, каб пачынаць з пытання «Чы гэта трэба робіць за дапамою оптымістычнага блакання?», пачніце з пытання «Каторую проблему мы хочамі запобiec?»:
- Адзінны запіс або умовная змяна, напрыклад, зменшэнне колькасці запасоў толькі тады, калі яны ёсць у наявнасці: вжывайце атамічную адрабоўку.
- Застарэлыя змяны ад корыстнікаў, якіе працуюць з старымі дадзеннямі, напрыклад, два адміністратары, якія зменяюць адзін тавар: вжывайце оптымістычны контроль канкуранцыі.
- Калькі запісаў, якія должны або успець, або не успець разам, напрыклад, змяны замовленняў, запасоў і рахункоў: вжывайце адзінкію.
Няма аднаго шаблона, які падходзіць для кожной системы, і багатыя рэальныя рашэнні суманаваюць два з іх.
Відтворэнне проблемы супернагружэння ў тэстах
Тэст, у яком пользователя А апдэйтуе тавар і отрымае адпаведзь пра успех, нічога не дазволяе сказаць пра можлівасць адночаснага выканання задач. Звычныя тэсты выконваюць операцыі адна за іншай, і самэ гэта прычына таго, што такія багі застаюцца непазначанымі. Ёсць неабходнасць стварыць ситуацію супернагружэння, каб можна было яе перапрацаваць.
У разе кантролю за запасамі пачніце з значэння запаса 1 і адночасна запусціце 100 спроб купавання, напрыклад, за дапамою Promise.all. Адначак выкарыстоўваецца толькі адна резервацыя, якая будзе успешной, а ўсі іншыя 99 будуць адхілены, і запасы застаюцца роўным нулю, а не мінусу.
Для оптымістичнага блакання задайце версію на 10 і адправьце калькольныя актуалізацыі, якія всі заявляюць версію 10. Вы должны паблікаваць, што адна з іх будзе успешной і паднесе версію, тады калі рэшта будуць прымець конфлікты, а не заменяць новейшыя данні.
Што старацца фіксаваць у прыменні
Пасля развяртання зрабіце гэтыя сигналы виднымі ў вашых метрыках і логах:
- адказы
409 Conflict; - условныя актуалізацыі, якія не падпадаюць ні на шта;
- перапрыбуткі та абраныя транзакцыі;
- застойны стан та борба за блакання;
- неспадзеваныя змены у запасах;
- дуплікатныя операцыі;
- іншыя бягучасносцю-зв’язаныя адказы.
Разыгранне суперчынства частацай паказвае на штос глыбэйшае: высокую температуру, нестандартную модель трафіку, кліентав, якія занадта агрэсыўна прабуюць зноў, або новую функцыю, якая стварае больш суперчынства, чым хто-лібо спакоўваўся. Дуплікатныя операцыі, у частым разе, лепей кераваць за дапамогою ключоў ідэмпатантнасці, пра якія пішаўся ў нашым адгуку пра ідэмпатантныя POST-канцэнты.
Канкурантнасць — гэта нормальная ситуацыя
Суперчынства на самай справе не стосуюцца двух чалавек, якія клацаюць адночасова. Яны выступаюць тады, калі калькі актараў можу зменяць спакойны стан: корыстнікі, экземпляры API, фонавыя працоўнікі, спожывачы канэ, запланаваныя заданні, webhooks і іншыя службы. На будзь-якай значным масштабе канкурантны доступ ёсць стандартам, а не выключэнням.
Таму не запытваюцеся, чы можа два запиты прайсці да таго ж коду адразу; прыймайце, што так і будзе. Корыстныя практыкі перагляду — це запытанне па кожным апдэйту, які будзе результат, якщо два його экземпляры будуць выкананы адночасова. Якшта дизайн чыста і ясна адпавядае на гэта запытанне, значы все ў порядку. Якшта часовая адпаведзь — «надзеюся, другі запыт не будзе шкодным», тады код патрэбуе ўважнейшага аналізу.
Ключовыя выводы
- Працоўны апдэйт выходзіць за межы, калі адна правямоўная змена парадзейваецца іншай, напісанай на старых данных, зазвычай праз процес чытання-змены-запісу.
- Валідзіруйце атомарныя, умовныя апдэйты, якія закодаваюць бізнес-правіла ў фільтры.
- Для выяўлення старых змэн вядзіце поле версіі і выдавайце адпаведзь
409 Conflict, замест таго каб парадзейваць іх. - Вяртайцеся да транзакцый толькі тады, калі калькі запісаў павінны быть абмовлены чы выкананы разам.
Спадні матэрыялы
- Шасць шаблонаў інтэграцыі для надзеямагчымайшага з’ўязку служб Node.js — Дазнаецеся пра основныя шаблоны, якія лежачы ў падазе надзеямагчымайшых інтэграцый у Node.js: модель запыт-адказ, поллінг, вебхукі, ключі API, аутэнтыкацыя JWT і OAuth, павторныя запыты з адкладаннем, а таксама картаванне дадзэнняў.
- Контроль адночаснасці ў Node.js: як ухіліцца ад збоев API за дапамой п-мапа і Bottleneck — Дазнаецеся, як сумешчанне п-мапа і Bottleneck у Node.js запобегае бягам з меры швайнасці і перавантажэнню системы за дапамой карання адночаснасці і таймінгу запытоў.
- Стварэнне каналаваў аграгацыі MongoDB з $match, $group і $lookup — Дазвольце вам дазнацца, як стадіі аграгацыі MongoDB фільтруюць, групаваюць, перакаштовваюць, спаўнююць і сортаваюць дакументы, а таксама як іх можна склець у каналавай, який адпавядае на рэальныя запитанні звярнучыся да звітавання.