Главная / Статьи / Семь скрытых предположений, из-за которых 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 преобразуются в стабильные доменные модели. Значения, вводимые в формы, преобразуются намеренно, а не путем использования свойств truthiness или операторов принудительного преобразования. Цель не в том, чтобы превратить JavaScript в язык с строгой типизацией; цель — обеспечить, чтобы остальная часть приложения никогда не вынуждена была догадываться о значении какого-либо значения.

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

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

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

Реальные пользователи и реальные сети ведут себя по-разному. Кто-то нажимает снова, потому что не появился индикатор загрузки. Мобильное приложение пытается подключиться заново после прерывания связи. Прокси или балансировщик нагрузки повторяет запрос после временной неудачи. Очередь сообщений пересылает задание, потому что рабочий процесс завершил работу, но сгорел до того, как смог подтвердить это. То, что казалось одним точкой входа, теперь имеет несколько способов выполнения дважды.

Где дубли действительно вредны

Повторное чтение обычно не вредит. Однако повторная регистрация аккаунта, бронирование товаров, оплата, приглашение или генерация экспортного файла могут привести к проблемам: появляются дублирующиеся записи, несколько писем, запасы уменьшаются дважды или с клиента взимается плата дважды.

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

Проектирование операций записи, устойчивых к повторениям

Важные операции записи следует разрабатывать с учетом того, что они будут выполняться несколько раз:

  • Ключ идемпотентности, остающийся стабильным при каждой попытке, позволяет серверу рассматривать их как одну логическую операцию.
  • Уникальное ограничение в базе данных предотвращает дубликаты, даже если два одновременных запроса пройдут проверку на уровне приложения «существует ли это?».
  • Таблица с идентификаторами обработанных сообщений позволяет потребителю очереди пропустить событие, которое он уже обработал.

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

3. Код с использованием Await все еще может сталкиваться с конкуренцией

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

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

Гонка условий проверки и действия на сервере

Представьте конечную точку утверждения, которая загружает элемент с статусом «ожидание», а затем устанавливает его в статус «утверждено». Два администратора открывают один и тот же экран и нажимают кнопку утверждения с интервалом в несколько секунд. Оба запроса считывают значение pending до того, как произойдет любая запись изменений. Итоговый результат может казаться в порядке, но если действие также отправляет уведомление или записывает запись в журнал аудита, оба этих побочных эффекта происходят дважды.

Та же гонка в браузере

В интерфейсе это выглядит как поле поиска. Сначала отправляется запрос к старому вопросу, затем — к новому, причем ответ на новый запрос приходит первым. На экране на мгновение отображаются правильные результаты, после чего приходит ответ на старый запрос и заменяет их. Оба запроса были успешными, и обе обновления состояния использовали корректные данные. Однако отсутствовало решение о том, какой запрос имеет право обновлять экран.

Команда await не берет блокировку, не замораживает значения, прочитанные ранее, и не ставит в очередь вызовы к одной и той же функции. Она просто приостанавливает выполнение, чтобы другие операции могли продолжаться.

Выбор механизма защиты

Решение зависит от того, где происходит конкуренция:

  • Условное обновление, например «установить статус в approved, если текущий статус — pending», с проверкой количества затронутых строк.
  • Оптимистичная конкурентность с колонкой версии, которая должна совпадать.
  • Транзакция базы данных с соответствующим уровнем изоляции.
  • Ограничение на уникальность, приводящее к явному сбою при втором записи.
  • Отмена запроса или идентификатор операции, позволяющий только самому последнему результату изменять общее состояние.
  • Опасное заблуждение заключается в том, что код, выполняемый последовательно, подразумевает систему, ведущую себя так же последовательно. JavaScript позволяет сделать отдельную функцию очень понятной, даже когда одновременно выполняются множество её копий.

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

    Поскольку код пишется сверху вниз, возникает соблазн рассматривать асинхронные операции в порядке их создания: запрос A был отправлен раньше запроса B, поэтому он должен вернуться первым. Сети, кэши, базы данных и внешние поставщики не дают никаких таких гарантий.

    Первый запрос может столкнуться с медленным выполнением запроса, в то время как второй будет обслужен из кэша. Один регион провайдера отвечает немедленно, в то время как другой повторяет попытку обработки внутри системы. Обработка большого объема данных занимает больше времени, даже если сервер ответил быстро.

    Устаревшие ответы — это корректные данные в неподходящее время

    Это превращается в баг в тот момент, когда порядок выполнения операций определяет текущее состояние. Обычными жертвами являются результаты поиска, проверка форм, загрузчики маршрутов, панели управления и функция автодополнения: пользователь продолжает работу, но запоздалый ответ замедляет работу интерфейса. Данные в таком ответе не ошибочны, они просто больше не актуальны для того, что смотрит пользователь.

    Техника дебаунсинга помогает, сокращая количество запускаемых запросов, но не устраняет их одновременное выполнение. Пользователь может сделать паузу достаточно долгую, чтобы запустить запрос, а затем продолжать вводить данные, пока запрос еще находится в процессе обработки; при этом старый запрос все равно может завершиться последним.

    Определите, кто владеет результатом

    Надежное решение начинается с правила принадлежности. Во многих интерфейсах должен победить самый свежий запрос, поэтому либо отменяются старые запросы, либо каждый из них маркируется порядковым номером, а результаты, устаревшие, удаляются. Другие сценарии работы требуют обработки по принципу «первый вошел — первым вышел» или независимого состояния для каждой операции. То же правило принадлежности должно распространяться и на состояния загрузки и ошибок: отмененный запрос не должен останавливать индикатор загрузки для активного запроса или отображать ошибку для запроса, который пользователь уже заменил. Информацию о специфическом подходе в React смотрите по ссылке React search with clear state ownership.

    В производственной среде не учитывается порядок создания обещаний. Ваш код должен определить в момент завершения обработки, имеет ли этот результат еще значение.

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

    Некоторые преобразования кажутся безобидными, когда речь идет о нескольких десятках элементов: отображение массива и вызов функции find для каждого элемента в другом массиве, фильтрация одного списка с помощью функции includes по второму списку или создание отчета путем фильтрации всего набора данных один раз для каждой группы.

    С использованием тестовых фикстур все это выполняется мгновенно, а код остается достаточно коротким, поэтому его алгоритмические затраты остаются незаметными. При росте объемов данных в производственной среде структура кода не меняется. Вызов функции find внутри операции map для двух коллекций по десять тысяч записей означает до примерно ста миллионов сравнений. Использование функции includes в цикле добавляет еще один линейный поиск для каждого элемента. Отчет, созданный для одной команды, вдруг начинают использовать во всей организации.

    Соответствие структуры шаблону доступа

    Проблема не в методах массивов, а в использовании последовательной структуры для многократных поисков по ключу или проверки принадлежности. Создайте объект Map, ориентированный по ключу id, и тогда каждый поиск в среднем займет константное время. Используйте объект Set, когда вопрос заключается в том, «есть ли это в коллекции?». Часто лучшим решением является объединение с базой данных, так что не нужно загружать обе коллекции в память и объединять их в коде приложения.

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

    Память и конкурентность также влияют на производительность

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

    Большинство проблем с производительностью возникают из-за того, что обычный код сталкивается с чрезмерным объемом данных. Необходимо знать ожидаемый масштаб нагрузки, избегать ненужных операций и проводить анализ реального поведения системы перед тем, как применять сложные оптимизации. Часто самой дорогостоящей является та строка кода, которая кажется слишком знакомой, чтобы ее ставить под сомнение.

    6. Отсутствие данных — это не доказательство того, что ничего не пошло не так

    JavaScript позволяет легко реализовывать надежные альтернативные решения. Опциональное цепочечное обращение предотвращает ошибки доступа к свойствам, операция объединения значений с учетом null предоставляет значения по умолчанию, а блок catch может возвращать пустой массив. Все это полезно, когда отсутствие данных ожидается и понимается. Однако это становится вредным, когда теряется разница между ситуацией «ничего нет» и ситуацией «мы не смогли это выяснить».

    Когда альтернативные решения скрывают проблемы

    Запрос к панели управления терпит неудачу из-за отказа базы данных, сервис возвращает [], а интерфейс с радостью сообщает «Записи не найдены». Поиск разрешений проваливается, опциональное цепочковое обращение возвращает undefined, и код рассматривает это как обычное значение false. Некорректный ответ генерирует undefined, который в трех уровнях глубже преобразуется в объект по умолчанию. Приложение кажется стабильным, поскольку никогда не падает, но оно сообщает пользователям то, чего на самом деле не знает.

    Эти ситуации нельзя взаимозаменять:

    • Запрос, который был выполнен и вернул ноль строк, в отличие от запроса, который так и не был выполнен.
    • Отсутствующий опциональный аватар в отличие от отсутствующего объекта пользователя.
    • Умышленное отклонение запроса со стороны бизнес-системы в отличие от сбоя сети.

    Каждый случай может требовать собственного сообщения для пользователя, политики повторных попыток, правил оповещения и инструкций по поддержке. В реальных условиях такие сбои являются обычным явлением, а не теоретическим: отказывают зависимости, меняются права доступа, при поэтапной развертке временно работают смешанные версии, а старые данные нарушают новые правила. Если каждый нестандартный результат просто относится к категории «пустой», инциденты остаются незамеченными, пока какой-то другой сигнал не станет достаточно явным, чтобы его заметить.

    Сначала определите контракт, затем добавьте защитные механизмы

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

    7. Состояние в памяти не делится между компонентами приложения

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

    В производственной среде один и тот же код может выполняться в нескольких процессах, контейнерах, серверных инстансах или регионах, причем у каждого из них своя память:

    • Кэш, обновленный на одной инстансе, остается устаревшим на других.
    • Флаг «уже инициализировано» защищает только тот процесс, который его установил.
    • Лимитатор скорости в памяти позволяет клиенту превысить лимит, просто обратившись к другим инстансам.
    • Таймер, запланированный в памяти, исчезает при перезагрузке контейнера.

    Даже один процесс менее постоянен, чем кажется. При развертывании он перезагружается, платформы без серверов замораживают и перерабатывают инстансы, сбои удаляют всё, что не сохранено, а при недостатке памяти платформа может завершить процесс, не дав ему возможности сохранить незавершённую работу.

    Предоставьте состоянию тот объём памяти, который ему действительно нужен

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

    Вопросы, которые следует задать, касаются области применения и срока жизни. Существует ли это состояние для каждого вызова функции, для каждой сессии пользователя, для каждого процесса, при каждой развертке или в рамках всей системы? И что происходит с ним, когда процесс исчезает? В большинстве современных архитектур один движок JavaScript не является самим приложением; он представляет собой временного участника в более крупной системе.

    Продакшн более честен, а не более случайный

    Хочется назвать процесс разработки непредсказуемым. Более точно будет сказать, что он в конечном итоге обеспечивает необходимые параметры — временные рамки, масштаб, исторические данные, количество одновременных пользователей и ограничения инфраструктуры, с которыми всегда должно справляться приложение. JavaScript продолжает следовать точно тем же правилам: непустые строки считаются истинными, асинхронные функции работают одновременно при нескольких вызовах, массивы просматриваются линейно, а переменные модулей принадлежат одному времени выполнения. Удивления возникают из-за предположений, которые местные условия никогда не заставляли проверять.

    Надежный код не пытается защититься от каждого возможного сценария. Он выявляет те предположения, которые окажутся дорогостоящими в случае их неверности, и делает их явными. Дополнительный код обычно небольшой; настоящая польза заключается в устранении неоднозначности. Следующий разработчик может увидеть, какие значения считаются допустимыми, какая операция возвращает результат, что означает успех и безопасно ли попытаться выполнить операцию снова.

    Основные выводы

    • Анализируйте и проверяйте внешние значения один раз на границе, поддерживая синхронизацию между схемами во время выполнения и статическими типами.
    • Рассматривайте каждую важную операцию записи как таковую, которая может быть выполнена несколько раз, и обеспечивайте уникальность данных в месте их хранения.
    • Помните, что оператор await приостанавливает один вызов; он не сериализует всю систему и не блокирует общее состояние.
    • Определите, какой асинхронный результат отвечает за текущее состояние, включая индикаторы загрузки и ошибок.
    • Выбирайте структуры данных и запросы в зависимости от объема данных, с которыми вы будете работать, а не в зависимости от тестового примера.
    • Сохраняйте статусы «пусто» и «ошибка» как отдельные результаты на всех уровнях, вплоть до пользователя и систем мониторинга.
    • Храните состояние в том объеме и с такой степенью надежности, которые действительно необходимы, и предполагайте, что любой отдельный процесс может прекратить свою работу.

    Связанная литература