Головна / Статті / Сім прихованих припущень, через які JavaScript зазнає невдач під реальним трафіком.

Сім прихованих припущень, через які JavaScript зазнає невдач під реальним трафіком.

Дізнайтеся, які припущення щодо типів даних, повторних спроб, конкурентності, порядку відповідей, обсягу даних, відсутніх даних та стану в пам’яті порушують код JavaScript після його впровадження у продакшн.

3164 слів

Більшість випадків, коли JavaScript не працює у продакшені, не пов’язані з синтаксичними помилками. Він був коректним за певних умов, які існували лише на ноутбуці розробника: маленькі набори даних, швидка відповідь, один обережний користувач, один процес. Нижче наведено сім таких прихованих умов, способи, якими кожна з них порушується під впливом реального трафіку, а також конкретні кроки у проектуванні, які дозволяють перетворити їх на чітке, протестоване рішення замість несподіванок під час інциденту.

Чому локальна розробка є поганим індикатором

Середовище розробки є надзвичайно толерантним. База даних містить кілька рядків, запити повертаються протягом кількох мілісекунд, ніхто не клікає на щось двічі, а кожен сервіс працює на тій самій машині або у надійній локальній мережі. Код, написаний за таких умов, може здаватися надійним протягом тижнів: функції виконуються чисто, кожна обіцянка очікується, набір тестів показує позитивні результати, а консоль залишається тихою.

Система обробки змінює вхідні дані, а не мову. Дані були створені п’ятьма різними версіями додатку, тож не всі вони мають однакову структуру. Користувачі клацають двічі, оскільки екран здається застряглим. Відповіді надходять у іншому порядку, ніж були надіслані запити. API сторонніх постачальників працюють повільно, повертають часткові дані або завершують роботу через таймаут. Кілька екземплярів однієї служби оновлюють одні й ті самі рядки водночас. Поле, яке завжди заповнювалося локально, відображається як "", null, значення з переліку, яке було видалено двома версіями раніше, або як рядок, у якому мало б бути число.

Жоден з цих факторів не погіршує код після розгортання. Вони просто усувають умови, які робили слабкі межі здаючимися безпечними. Корисною звичкою є припинити ставити лише запитання „Чи працює це з очікуваним вхідним даними?“, а почати розглядати те, що код мовчки припускає щодо таймінгу, обсягу, власності, форми даних та можливих збоїв. Багато з цих припущень є цілком обґрунтованими. Ризик полягає у тому, що їх залишають незазначеними, доки справжні користувачі не спростують їх.

1. Значення не завжди мають той тип, який здається

У JavaScript значення може перетинати кілька меж та втрачати свій початковий тип на кожній з них. Числовий ідентифікатор бази даних перетворюється на рядок, як тільки потрапляє у URL. Поле для позначки галочкою надсилається на сервер у вигляді "true" або "false". Порожній вхідний даний стає "", тоді як на боці сервера очікувався null. Часовий позначник ISO передається у вигляді звичайного тексту та обробляється так, ніби це Date, просто тому що він схожий на нього.

Локально це рідко трапляється, оскільки один і той самий розробник пише код як з боку клієнта, так і з боку сервера, а тестові дані вже підготовлені для перевірки функції. У продакшені дані надходять від старіших мобільних клієнтів, через поведінку браузера, інтеграції з партнерами, застарілі дані з кешу та записи, які хтось вніс вручну в інструменти адміністрування.

Примусова трансформація дає результати, які здаються майже правильними

Серйозні помилки — це ті, коли JavaScript виконує правдоподібну конвертацію замість того, щоб кинути помилку. "10" добре порівнюється з числами за допомогою < та >, проте "10" + 1 дає результат "101". Рядок "false" вважається істинним, тож флаг, призначений для вимкнення певної функції, може її увімкнути. Порожній рядок в одному функціоналі вважається „відсутнім“, а в іншому — законним значенням.

Ніщо не збивається. Сторінкування пропускає неправильну сторінку, вимкнений параметр стає доступним для вибору, або userId === record.ownerId тихо повертає помилку через те, що одна зі сторін — це число, а інша — рядок. Команди потім виправляють кожен окремий прояв проблеми, і кодова база поступово накопичує кілька суттєво різних правил нормалізації.

Нормалізуйте один раз, на межі

Надійні системи перетворюють значення, коли вони досягають певної межі, і більше цього не роблять. Параметри запиту та тіла запитів аналізуються на предмет валідації та перетворюються у внутрішні типи ще до того, як до них дістанеться бізнес-логіка. Відповіді від зовнішніх API отримують відповідне відображення у стабільних моделях домену. Дані, введені у формах, перетворюються навмисно, а не за принципом автоматичної оцінки істинності чи примусового застосування операторів. Мета не в тому, щоб перетворити JavaScript на мову з суворою типізацією; мета — забезпечити, щоб решта програми ніколи не мусила здогадуватися, що означає певне значення.

TypeScript документує бажану структуру, але типи видаляються під час виконання та не можуть перевірити, що насправді надходить через мережу. Обробник, параметр якого ідеально типізований, все одно може отримати будь-що. Більш безпечним підходом є використання схеми під час виконання та статичного типу, які описують один і той самий контракт, бажано так, щоб тип походив від схеми. Бібліотеки схем, такі як Zod, роблять це практичним, оскільки одне визначення може використовуватися як для перевірки під час виконання, так і для створення типу TypeScript.

2. Один клік не означає одну обробку

На екрані видно лише одну кнопку, тому здається логічним уявляти один запит: користувач натискає, сервер виконує роботу, а відповідь підтверджує це. Ручні тести підкріплюють цю картину, оскільки розробники натискають один раз та терпляче чекають.

Реальні користувачі та реальні мережі поводяться по-різному. Хтось натискає ще раз через відсутність індикатора завантаження. Мобільний додаток намагається знову — після переривання з’єднання. Проксі чи балансувальник навантаження повторює запит після тимчасової несправності. Очередь повідомлень доставляє завдання знову, тому що працівник виконав його, але зупинився перед підтвердженням. Те, що здавалося одним точком входу, тепер має кілька способів для подвійного виконання.

Де дублікати справді завдають шкоди

Повторне читання зазвичай нешкідливе. Однак повторна створення облікового запису, бронювання товару, оплата, запрошення чи генерований експорт — це не так: у результаті з’являються дублікати записів, кілька електронних листів, запаси зменшуються двічі чи клієнта обкладають подвійною платою.

Вимкнення кнопки після першого натискання покращує користувацький досвід, але це не є гарантією. Клієнти можна обійти, запити можна спробувати знову поза інтерфейсом користувача, і кожен з двох екземплярів сервісу може отримати однакову логічну дію. Захист на фронтенді — це лише ввічливість; бекенд та база даних мають забезпечувати дотримання цього принципу.

Проектування операцій запису, стійких до повторень

Важливі операції запису слід розробляти з припущенням, що до них буде зроблено кілька спроб:

  • Ключ ідемпотентності, який залишається стабільним під час кожної спроби, дозволяє серверу розглядати їх як одну логічну операцію.
  • Унікальний обмежувальний правило в базі даних запобігає дублікаціям навіть тоді, коли два конкуруючі запити пройшли перевірку на рівні додатку „Чи існує це?“.
  • Таблиця з ідентифікаторами оброблених повідомлень дозволяє споживачеві черги пропустити подію, яку він вже обробив.

Ключове розуміння полягає у тому, що тайм-аут чи помилкова відповідь не доводять, що операція зазнала невдачі. Сервер міг завершити роботу після того, як клієнт припинив очікування. Повторна спроба без стабільного ідентифікатора операції перетворює цю невизначеність на дублювання. Надійна система — це не та, яка запобігає кожному повтору; це та, де повтор не змінює кінцевого значення операції. Механізми з боку сервера детальніше розглядаються у розділі про розуміння ключів ідемпотентності у Node.js POST-кінцевих точках.

3. Код з використанням Await все ще може мати проблеми з синхронізацією

async/await робить функцію схожою на захищену послідовність дій: завантажити запис, перевірити його статус, оновити та повернутися. Кожен крок очікується, тому потік виконання здається контрольованим. Однак цей контроль існує лише всередині одного виклику.

Поки один виклик знаходиться у стані очікування через await, движок може виконувати інший обробник запитів, функцію-відповідь на подію чи завдання для тієї самої функції. Дві операції можуть читати один і той самий стан, обидві визначати, що операція дозволена, та обидві записувати дані. Кожна з цих операцій є правильною окремо, проте разом вони порушують бізнес-правило.

Конкуренція типу «спочатку перевірка, потім дія» на сервері

pending ще до того, як буде виконано будь-яке оновлення. Остаточний стан може здаватися правильним, але якщо дія також передає сповіщення чи записує запис аудиту, обидва ці побічні ефекти відбуваються двічі.

Та сама конкуренція у браузері

У користувацькому інтерфейсі ця структура виглядає як поле пошуку. Спочатку надсилається запит на старішу інформацію, після цього — запит на новішу, і відповідь на новий запит надходить першою. На екрані на мить відображаються правильні результати, а потім надходить відповідь на старий запит та замінює їх. Обидва запити були успішними, і обидві зміни статусу використовували коректні дані. Бракувало лише рішення щодо того, який запит все ще має право оновлювати екран.

Оператор await не бере блокування, не заморожує значення, які були прочитані раніше, та не створює чергу для викликів однієї й тієї самої функції. Він призупиняє виконання саме для того, щоб інші операції могли продовжуватися.

Вибір механізму захисту

Рішення залежить від того, де відбувається конкуренція:

  • Оптимістична конкурентність із стовпцем версії, який має збігатися.
  • Транзакція бази даних із відповідним рівнем ізоляції.
  • Обмеження щодо унікальності, яке змушує друге записування помилково завершитися.
  • Скасування запиту або ідентифікатор операції, який дозволяє лише останньому результату змінювати спільний стан.
  • Небезпечна думка полягає у тому, що код, який працює послідовно, означає систему, яка також поводиться послідовно. JavaScript може зробити окрему функцію дуже зрозумілою, навіть коли одночасно виконується багато її копій.

    4. Запити не завершуються у тому порядку, в якому були початі

    Оскільки код пишеться зверху вниз, легко думати про асинхронні операції в порядку їх створення: запит A було надіслано раніше за запит B, тож A має повернутися першим. Мережі, кеші, бази даних та зовнішні постачальники не дають таких гарантій.

    Перше запитання може зіткнутися із повільним обробленням, тоді як друге надсилається з кешу. Одна з регіонів постачальника відповідає миттєво, тоді як інша робить внутрішню спробу знову. Великий об’єм даних потребує більше часу на обробку, навіть якщо сервер відповідає швидко.

    Застарілі відповіді — це правильні дані у неправильний час

    Це стає помилкою у ту мить, коли порядок виконання запитів впливає на поточний стан. Зазвичай постраждають результати пошуку, перевірка форм, завантажувачі маршрутів, панелі керування та функція автодоповнення: користувач рухається далі, а запізнена відповідь затримує роботу інтерфейсу. Дані в цій відповіді не є неправильними. Вони просто більше не є актуальними для того, що переглядає користувач.

    Метод дебаунсингу допомагає, зменшуючи кількість запущених запитів, але не усуває їхнє одночасне виконання. Користувач може зробити паузу на достатній час, щоб ініціювати запит, а потім продовжувати вводити дані, поки запит ще у процесі обробки, і старіший запит все одно може завершитися останнім.

    Вирішуйте, хто володіє результатом

    Надійне вирішення починається з правила власності. У багатьох інтерфейсах має перемагати найновіше запитання, тому ви або скасовуєте старіші запити, або позначаєте кожен з них номером послідовності та видаляєте результати, які більше не є актуальними. Інші сценарії роботи вимагають обробки за принципом «перший прийшов — перший оброблений» або окремого стану для кожної операції. Те саме правило власності має поширюватися також на стан завантаження та помилок: покинутий запит не повинен скасовувати індикатор завантаження для активного запиту чи відображати помилку для запиту, який користувач вже замінив. Щодо специфічного підходу для React, дивіться React search with clear state ownership.

    У продакшені не дотримується порядок створення обіцянок. Ваш код має вирішити у момент обробки, чи все ще має значення цей результат.

    5. Малі колекції не залишаються малими

    Деякі трансформації здаються безневинними, коли йдеться про кілька десятків елементів: мапування масиву та виклик функції find для кожного елемента в іншому масиві, фільтрація одного списку за допомогою includes відносно іншого або створення звіту шляхом фільтрації всього набору даних один раз на кожну групу.

    За допомогою тестових конфігурацій ці операції виконуються миттєво, а код залишається достатньо коротким, тож його алгоритмічні витрати залишаються непомітними. У умовах реального використання обсяг даних зростає, але форма коду не змінюється. Використання find всередині map для двох масивів по десять тисяч записів означає до приблизно ста мільйонів порівнянь. Використання includes у циклі додає ще один лінійний обхід для кожного елемента. Звіт, створений для одної команди, раптово починає оброблятися в усій організації.

    Підлаштовуйте структуру під шаблон доступу

    Методи масивів не є проблемою; проблема полягає у використанні послідовної структури для багаторазових пошуків за ключем чи перевірок на наявність елемента. Створіть один раз Map, ключем якого є id, і тоді кожен пошук у середньому займатиме константний час. Використовуйте Set, коли потрібно дізнатися «Чи є це в колекції?». Часто кращим рішенням є з’єднання з базою даних, тож ви ніколи не завантажуєте обидві колекції в пам’ять та не поєднуєте їх у коді програми.

    Це не означає, що потрібно замінювати кожен масив. Створення індексу також має свою вартість, а для дійсно невеликого списку find може бути найкращим варіантом. Розрахунки змінюються, коли операція виконується часто або коли обсяг даних може суттєво зрости.

    Пам’ять та конкурентність також змінюються

    Обсяг даних також впливає на пам’ять та використання ресурсів. Завантаження кожного рядка перед фільтрацією, послідовне використання кількох функцій map та filter, які кожна з них виділяє новий масив, або використання Promise.all для обробки по одній обіцянці на кожен елемент може бути прийнятним у локальних умовах, але завдати шкоди в продакшн-середовищі. Процес може закінчитися нестачею пам’яті, вичерпати запаси з’єднань до бази даних чи зайняти весь цикл подій, через що всі інші запити будуть виконуватися повільніше. Пагінація, стрімінг та обмеження конкурентності є звичайними способами вирішення цих проблем.

    Більшість проблем з продуктивністю виникає через те, що звичайний код стикається з надзвичайним обсягом даних. Дізнайтеся про очікувану масштабованість, уникайте роботи, яка вам не потрібна, та проаналізуйте реальний шлях виконання перед тим, як вдаватися до складних оптимізацій. Найбільш дорогою частиною коду часто є та, яка здається занадто знайомою, щоб її ставити під сумнів.

    6. Відсутність даних — це не доказ того, що нічого не пішло не так

    JavaScript дозволяє легко використовувати елегантні альтернативні рішення. Опційне ланкування уникає помилок доступу до властивостей, операція об’єднання значень nullish coalescing забезпечує значення за замовчуванням, а блок catch може повернути порожній масив. Це корисно, коли відсутність чогось очікується та зрозуміла. Проте це стає шкідливим, коли це стирає різницю між „тут нічого немає“ та „ми не змогли це з’ясувати“.

    Коли альтернативні рішення приховують проблеми

    Запит до панелі керування зазнає невдачі через те, що база даних не працює; сервіс повертає [], а інтерфейс весело повідомляє „Жодних записів не знайдено“. Пошук дозволів також провалюється, опційне ланкування дає undefined, і код сприймає це як звичайне false. Неправильна відповідь генерує undefined, який через три рівні стає значенням за замовчуванням. Додаток виглядає стабільним, оскільки ніколи не падає, але він повідомляє користувачам те, чого насправді не знає.

    Ці ситуації не є взаємозамінними:

    • Запит, який виконався та повернув нуль рядків, на відміну від запиту, який так і не було виконано.
    • Відсутній опційний аватар на відміну від відсутнього об’єкта користувача.
    • Умисний відхилення з боку бізнес-логіки на відміну від таймауту мережі.

    Кожен випадок може потребувати власного повідомлення для користувача, політики повторних спроб, сигналів попередження та інструкцій щодо підтримки. У реальних умовах такі збої є звичайними, а не теоретичними: зникають залежності, змінюються права доступу, під час поступового розгортання на короткий час працюють змішані версії, а старі дані порушують нові правила. Якщо кожен неплановий результат приймається як „порожній“, інциденти залишаються непомітними, доки якийсь інший сигнал не стане достатньо яскравим, щоб його помітити.

    Спочатку визначте контракт, а потім додайте захисну синтаксис

    Використовуйте опційне ланкування там, де значення справді є необов’язковим. Використовуйте запасний варіант, коли система має достовірну альтернативу. При ловлі помилок перетворюйте їх на стабільні категорії, такі як «не знайдено», «заборонено», «недоступно» чи «некоректно», але зберігайте первинну причину та контекст для журналів та моніторингу. Граційне погіршення якості слугує для того, щоб додаток залишався корисним, зберігаючи при цьому точність; він ніколи не повинен створювати враження успіху шляхом повернення правдоподібного значення.

    7. Стан у пам’яті не є спільним для всього додатку

    Область видимості модуля робить стан у пам’яті зручним для використання. Змінна верхнього рівня може зберігати кеш, відстежувати виконувані завдання, підраховувати кількість запитів для обмеження швидкості чи пам’ятати, чи вже відбулася ініціалізація. Локально один процес обробляє кожен запит, тому така змінна поводиться як глобальний стан додатку.

    У продакшені один і той самий код може виконуватися у кількох процесах, контейнерах, безсерверних інстанціях чи регіонах, причому кожен з них має власну пам’ять:

    • Кеш, оновлений на одній інстанції, залишається застарілим на інших.
    • Флаг „вже ініціалізовано“ захищає лише той процес, який його встановив.
    • Лімітер швидкості, реалізований у пам’яті, дозволяє клієнту перевищити ліміт, просто звертаючись до різних інстанцій.
    • Таймер, запланований у пам’яті, зникає під час перезапуску контейнера.

    Навіть один процес є менш постійним, ніж здається. Під час розгортання він перезапускається, безсерверні платформи заморожують та переробляють інстанції, збої стирають все, що не було збережено, а під тиском пам’яті платформа може завершити роботу процесу, не даючи йому можливості завершити поточні операції.

    Надайте стану той обсяг, який йому дійсно потрібен

    Стан у пам’яті все ще є відмінним для кешів на рівні процесу, локальних оптимізацій, короткочасної координації в межах однієї заявки та будь-яких значень, втрата яких є прийнятною. Проблеми починаються тоді, коли йому надають обов’язки, що вимагають системних повноважень чи довготривалості зберігання даних. Спільні ліміти швидкості зазвичай мають зберігатися у централізованому сховищі, такому як Redis. Завдання, які мають залишатися активними після перезапуску, потрібно зберігати у черзі чи базі даних. Для розподілених блокувань потрібен механізм, який буде видимий усім конкуруючим процесам. Критична конфігурація має походити з надійного джерела, а не від змінної, яка може бути оновлена в одному екземплярі.

    Питання, які потрібно поставити, стосуються обсягу та тривалості існування. Чи існує цей стан за кожен виклик функції, за кожну сесію користувача, за кожен процес, під час кожної розгортки чи протягом усієї системи? І що з ним відбувається, коли процес зникає? У більшості сучасних архітектур один середовище виконання JavaScript — це не сама програма; це лише тимчасовий учасник у більшій системі.

    Продакшн є більш чесним, а не більш випадковим

    Хочеться назвати процес виробництва непередбачуваним. Точніше буде сказати, що він нарешті забезпечує необхідний таймінг, масштаб, історичні дані, кількість одночасних користувачів та межі інфраструктури, з якими завжди мала працювати програма. JavaScript дотримується абсолютно тих самих правил: непорожні рядки вважаються істинними, асинхронні функції діють одночасно під час кількох викликів, масиви обробляються лінійно, а змінні модулів належать до одного моменту виконання. Несподіванки виникають через припущення, які місцеві умови ніколи не змушували перевіряти.

    Надійний код не намагається захиститися від кожної можливої ситуації. Він визначає ті припущення, які матимуть великі наслідки у разі їхньої хибності, та робить їх очевидними. Додатковий код зазвичай є невеликим; справжня перевага — усунення неоднозначностей. Наступний розробник може бачити, які значення є дійсними, яка операція забезпечує результат, що означає успіх та чи безпечно повторити спробу.

    Основні висновки

    • Аналізуйте та перевіряйте зовнішні значення один раз на межі, підтримуючи синхронність між схемами під час виконання та статичними типами.
    • Розглядайте кожен важливий запис як той, що може бути виконаний кілька разів, та забезпечуйте унікальність даних у місці їх зберігання.
    • Пам’ятайте, що await призупиняє один виклик; він не серіалізує систему та не блокує спільний стан.
    • Вирішуйте, який асинхронний результат визначає поточний стан, включаючи індикатори завантаження та помилок.
    • Вибирайте структури даних та запити відповідно до обсягу даних, які у вас будуть, а не відповідно до тестових даних.
    • Зберігайте статуси „порожній“ та „не вдалося“ як окремі результати аж до користувача та систем моніторингу.
    • Зберігайте стан у межах та з рівнем тривалості, які йому дійсно потрібні, припускаючи, що будь-який окремий процес може зникнути.

    Пов’язана література