26 дек. 2025 г.·7 мин. чтения

Как безопасно заменить общие изменяемые глобальные переменные явным состоянием

Узнайте, как заменить общие изменяемые глобальные данные: найдите скрытые синглтоны и переместите состояние в зависимости с областью запроса, чтобы предотвратить гонки состояния.

Как безопасно заменить общие изменяемые глобальные переменные явным состоянием

Почему общие изменяемые глобальные переменные вызывают странные, случайные баги

«Общее изменяемое» звучит громоздко, но суть простая: значение живёт в одном месте (общее) и код может его менять (изменяемое). Когда много частей приложения читают и записывают одно и то же значение, поведение начинает зависеть от тайминга, а не от намерения.

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

Типичный пример — переменная currentUser в модуле, «глобальный кеш», который хранит данные, специфичные для запроса, или синглтон‑клиент, который тихо держит состояние (заголовки, токены, выбранный тенант). Именно в таких случаях стоит заменить общие изменяемые глобальные переменные на явное состояние.

Типичные симптомы выглядят так:

  • Пользователи видят данные другого человека или оказываются залогинены не под своим аккаунтом
  • «Работает у меня» — ошибки, которые проявляются только под нагрузкой
  • Нестабильные тесты, которые проходят или падают в зависимости от порядка выполнения
  • Случайные 401/403 из‑за повторного использования неправильного токена
  • Фоновые задачи, которые подхватывают неверную конфигурацию

Цель не в том, чтобы полностью отказаться от разделяемых вещей. Базы данных и пулы соединений по‑сути разделяемые. Цель — чтобы разделяемые ресурсы оставались разделяемыми, а состояние, специфичное для запроса, передавалось явно (или создавалось на запрос) так, чтобы ничего не переиспользовалось молча.

Это часто встречается в прототипах, сгенерированных ИИ. FixMyMess часто натыкается на скрытые синглтоны, которые выглядят безобидно до тех пор, пока приложение не получит реального трафика.

Что считается глобальным или синглтоном в реальных проектах

«Глобальным» называется любое состояние, которое живёт вне конкретного запроса, задания или действия пользователя, но при этом читается и изменяется во время работы приложения. «Синглтон» — то же самое с более приятной метрикой: «только один экземпляр», доступный всем.

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

Не всё глобальное — плохо. Константы только для чтения обычно безопасны: строки версий, фиксированные лимиты и настройки по умолчанию. Риск начинается, когда значение может меняться во время выполнения запросов. Изменяемое состояние плюс конкуренция — вот почему баги исчезают, когда вы добавляете логи.

Общее состояние часто проявляется как:

  • Кеши уровня модуля или мемоизированные значения, которые хранят результаты для конкретных пользователей
  • Общий объект конфигурации, который мутирует (например, «текущий тенант»)
  • Общая обёртка DB‑клиента, которая также хранит данные на уровне запроса, вроде «текущего пользователя»
  • Хелперы аутентификации/сессий, которые держат токены в памяти вместо того, чтобы хранить их для конкретного запроса
  • Очереди в памяти или массивы «ожидающих заданий», используемые несколькими пользователями

Опасность растёт вместе со шкалированием. На однопользовательском дев‑сервере всё может выглядеть нормально, но несколько воркеров или инстансов делают поведение непредсказуемым. Некоторые ошибки проявляются только тогда, когда два запроса накладываются друг на друга.

Если вы унаследовали прототип, сгенерированный ИИ, такие «один экземпляр»‑урезки встречаются часто. При аудите FixMyMess обычно находит их вокруг аутентификации, кеширования и фоновой работы.

Быстрые признаки наличия скрытого разделяемого состояния

Скрытое разделяемое состояние обычно проявляется в виде проблем, которые кажутся случайными. Приложение «в основном работает», но затем ломается так, что повторить невозможно.

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

Поведение тестов — ещё один индикатор. Тест проходит, когда вы запускаете его отдельно, но падает при запуске полного набора. Это часто означает, что один тест оставляет после себя состояние (например кешированный currentUser или глобальную обёртку БД с изменяемыми настройками), которое влияет на следующий тест.

Нагрузка усугубляет ситуацию. Когда запросы накладываются в пик, вы получаете случайные 500, которые исчезают при повторном запросе. Такой паттерн часто указывает на мутацию общих объектов во время запроса: глобальный объект конфигурации, синглтон‑клиент с заголовками уровня запроса или модульная переменная «текущий запрос».

Последний признак: «у меня работало локально». Многие дев‑серверы обрабатывают запросы по очереди, поэтому баги со shared‑state скрываются до тех пор, пока в продакшене не начнётся параллельность.

Быстрые красные флажки для сканирования:

  • Модульная переменная, которая меняется во время запроса (token, tenantId, currentUser)
  • Синглтон‑клиент, который хранит данные уровня запроса (заголовки, авторизация, локаль)
  • Кеши в памяти, используемые как источник правды (а не только для ускорения)
  • Логи, где в одном потоке смешиваются ID запросов или ID пользователей
  • Баги, которые исчезают, если добавить print‑сообщения (из‑за изменения тайминга)

Команды иногда приносят прототип, где два человека одновременно заходят в систему и сессии пересекаются. Это почти всегда проблема разделяемого состояния, а не «таинственная авторизация».

Как пошагово искать скрытые синглтоны

Скрытые синглтоны — частая причина «случайного» поведения под нагрузкой: один запрос что‑то меняет, а следующий наследует это.

Пошаговый поиск

Пройдитесь по кодовой базе в таком порядке (это экономит время и ловит самые опасные случаи первыми):

  • Просмотрите модульные переменные, которым присваивают новые значения (не только константы). Ищите имена вроде currentUser, token, client, config, session.
  • Ищите паттерны синглтонов: getInstance(), комментарии «создать только один раз» или ленивую инициализацию вроде «если ещё не создано — создать».
  • Проверьте кеши и мемоизацию. Кеш может быть нормальным, но опасным, когда ключи слишком широки (например, только userId без tenantId) или по умолчанию используется один ключ для всех запросов.
  • Просмотрите обработчики запросов и middleware на предмет объектов, хранимых вне хэндлера. Частая ловушка — создать объект при старте приложения, а затем мутировать его в каждом запросе.
  • Проверьте «locals» фреймворка и глобальные хранилища приложения. Путаница между локалями запроса и глобальными локалями превращает данные уровня запроса в межзапросное хранилище.

Быстрая проверка на реальность

Чтобы убедиться, что вы нашли виновника: откройте два браузерных сеанса (обычный и инкогнито), залогиньтесь за двух разных пользователей и несколько раз вызовите один и тот же эндпоинт. Если идентичности, права или настройки проливаются между сессиями — почти наверняка у вас всё ещё есть разделяемое изменяемое состояние.

Простая модель: состояние запроса vs разделяемые ресурсы

Надёжный способ избавиться от общих изменяемых глобалей — разделить всё на две корзины: то, что принадлежит одному запросу, и то, что безопасно разделять между запросами.

Состояние на уровне запроса — всё, что меняется от пользователя к пользователю или от вызова к вызову: текущий ID пользователя, утверждения аутентификации, correlation ID для логов, локаль и входной payload. Эти данные никогда не должны жить в модульной переменной, потому что два запроса могут пересечься и перезаписать друг друга.

Разделяемые ресурсы — дорогостоящие строительные блоки, которые можно безопасно переиспользовать: пул соединений с БД, HTTP‑клиент с фиксированными настройками, скомпилированный шаблон. Главное — чтобы эти объекты не хранили внутри себя данные уровня запроса.

Правило: если было бы неправильно, чтобы Запрос B это увидел — это не может быть глобальным.

Практическая модель, которая поможет не ошибиться:

  • Кладите состояние запроса в параметры функций или в небольшой объект RequestContext.
  • Явно стройте зависимости через конструкторы или фабрики.
  • Делайте разделяемые объекты неизменяемыми (конфигурация) или внутренне безопасными (например, пул БД).
  • Если что‑то обязательно должно быть разделяемым и изменяемым — защищайте доступ синхронизацией.

Пример: вместо глобальной currentUser создавайте ctx = { user, correlationId } при начале запроса и передавайте ctx в хэндлеры и сервисы. Пул БД остаётся общим, но функции запросов принимают ctx, чтобы логирование и права оставались корректными.

План рефакторинга: как перейти от глобалей к явному состоянию

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

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

Сначала выпишите, откуда код тайно берёт данные: текущий пользователь, tenant ID, feature‑флаги, локаль, request ID и т. п. Затем создайте небольшой объект контекста запроса, в котором будет только то, что действительно нужно.

Последовательность, которая работает в большинстве кодовых баз:

  • Выберите одну точку входа (API‑маршрут, хэндлер или джоб) и создайте там контекст запроса.
  • Меняйте по одной функции: пусть она принимает контекст в аргументах вместо чтения глобалей.
  • Если функция нуждается в сервисе (db, кеш, auth‑клиент), передавайте его в аргументах или создавайте через фабрику.
  • Сохраняйте разделяемые ресурсы разделяемыми (пул соединений), но держите данные уровня запроса в контексте.
  • После того как всё заработало, удалите старый глобал или заставьте его падать с ощутимой ошибкой при обращении.

Фабрика может стать мостом, который предотвратит большую переработку. Например, createServices(ctx) может возвращать authService, tenantService и auditLogger, которые читают из переданного контекста, а не из модульных переменных. Это делает зависимости видимыми, а не неявными.

Наконец, удалите или «застыньте» старый глобал. Не оставляйте его «на всякий случай» — кто‑то обязательно воспользуется им при быстром фиксe.

Зависимости с областью запроса без переусложнения

Цель проста: создавать то, что нужно для одного запроса, один раз, передавать это явно и держать по‑настоящему разделяемые части (например, пулы соединений) неизменяемыми с точки зрения хэндлера. Обычно этого достаточно, чтобы остановить баги конкуренции, не выстраивая огромную систему DI.

Держите «контейнер запроса» маленьким. Рассматривайте его как простой объект с тем, что меняется на запрос: текущий пользователь, request ID, локаль, feature‑флаги и часы. Всё остальное — разделяемые ресурсы, безопасные для повторного использования.

Практичный паттерн в веб‑приложении:

  • При старте приложения: создайте разделяемые ресурсы (пул БД, HTTP‑клиент, конфигурация логгера)
  • На каждый запрос: создавайте состояние запроса (пользователь, request ID) и небольшие хелперы, которым это нужно
  • В хэндлере: принимайте эти зависимости как параметры, а не через импорт

Например, обработчик логина может один раз собрать RequestContext, а затем передать его сервисам вроде AuthService(ctx, db_pool, logger). Пул БД — общий, но контекст — нет. Это предотвращает утечку данных одного пользователя в запрос другого.

К разделяемым зависимостям, которые обычно безопасны, относятся пул соединений БД (не одно единичное соединение в глобале), HTTP‑клиент без встроенных заголовков для пользователя и конфигурация логгера (но не изменяемый глобал currentUser).

Фоновые задания склонны возвращать к глобалям, потому что «запроса нет». Обращайтесь с джобами так же: создавайте JobContext с job ID и любыми ID пользователя/рабочего пространства, и передавайте его в функцию джоба.

Распространённые ошибки, которые усугубляют проблему

Защитите ваш бэкенд, сгенерированный ИИ
Укрепляем безопасность типичных проблем в коде, сгенерированном ИИ, включая открытые секреты.

Самый быстрый способ откатить собственный рефакторинг — оставить глобал и пытаться «сбрасывать» его на каждом запросе. Это выглядит безопасно при локальном тестировании, но ломается, когда запросы перекрываются: один запрос сбрасывает значение, а другой всё ещё его использует — и вы получаете сложный для воспроизведения баг.

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

Глобальные кеши тоже рискованные, если в ключах отсутствует tenant или user. Кеш, использующий только product ID (или ещё хуже — единственное «последнее» значение), может протекать между аккаунтами. Такой баг выглядит не как проблема кеша, а как «иногда приложение показывает чужие данные».

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

Смешивание изменяемой конфигурации и runtime‑состояния — ещё один тихий убийца. Если вы меняете объект конфигурации после старта и он общий, каждый запрос может увидеть разную конфигурацию в зависимости от тайминга.

Быстрые красные флажки, которые часто идут вместе:

  • Синглтон имеет поля вроде currentUser, token, requestId или lastResult
  • Ключ кеша не содержит tenantId или userId
  • Функции сброса запускаются в middleware или перед хэндлерами
  • В хэндлере открываются и закрываются соединения к БД
  • Объекты конфигурации изменяются после старта

Если у вас AI‑сгенерированный код, эти паттерны встречаются часто в прототипах и проявляются только при реальных параллельных пользователях.

Как подтвердить исправление простыми тестами

После замены общих изменяемых глобалей код обычно сразу становится понятнее. Настоящее доказательство — он остаётся консистентным, когда происходят два события одновременно.

1) Добавьте один небольшой тест на конкурентность

Не нужен огромный тестовый набор. Начните с теста, который отсылает два запроса параллельно от разных пользователей (или с разными API‑ключами) и проверяет, что их ответы не перемешиваются.

# Pseudocode example
# Send two parallel requests:
# - user A logs in and fetches /me
# - user B logs in and fetches /me
# Assert A never sees B's data, and B never sees A's data.

Если этот тест хоть раз падает — где‑то ещё есть разделяемое состояние.

2) Сделайте межсвязь видимой через логи

Добавьте простые структурированные логи с request ID и user ID в каждом хэндлере и в сервисах, которые вы рефакторили. Затем ищите невозможные последовательности, например request ID пользователя A, вдруг логирующий ID пользователя B.

Несколько быстрых проверок, которые выявляют скрытую связанность:

  • Запустите тесты с параллельным выполнением
  • Прогоните тест конкурентности в цикле 50–200 раз, чтобы поймать недетерминированные ошибки
  • Добавьте проверку, что объекты с областью запроса создаются на каждый запрос (не переиспользуются)
  • Отслеживайте память во время цикла, чтобы убедиться, что после удаления непреднамеренных кешей потребление остаётся стабильным
  • Временно уменьшите таймауты, чтобы гонки проявлялись быстрее

Если случайные ошибки остаются — обычно это означает оставшийся синглтон (например, модульный клиент, который хранит "текущего пользователя") или кеш с слишком широким ключом.

Пример: прототип, который ломается, когда два пользователя входят одновременно

Распространённая ошибка в AI‑прототипах выглядит безобидно при однопользовательском тестировании: состояние аутентификации хранится в глобальной переменной. Например, приложение держит currentUser (или accessToken) на уровне модуля, и все API‑маршруты читают его.

В проде происходит следующее. Пользователь A входит, затем пользователь B входит чуть позже. Глобальный currentUser перезаписывается. Теперь, когда пользователь A нажимает «Мой аккаунт», сервер иногда отвечает как пользователь B. Это кажется случайным, потому что всё зависит от тайминга, а не от логики кода.

Типичные признаки в логах и тикетах поддержки:

  • «Я на секунду увидел данные другого человека»
  • В запросах показывается неправильный user ID, хотя куки выглядят корректно
  • Проблема проявляется только под нагрузкой или когда два человека тестируют одновременно
  • Обновление страницы иногда «исправляет» проблему

Исправление: перестать спрашивать у глобального синглтона, кто сейчас пользователь. Вместо этого создавайте сервис аутентификации на каждый запрос, используя явный контекст (заголовки, куки, session ID). Каждый запрос получает свой auth‑объект, а хэндлеры принимают его в аргументах.

Конкретно: парсите токен из входящего запроса, верифицируйте его и передавайте подтверждённого пользователя в функции, которым это нужно. Разделяемые ресурсы (пул БД) остаются общими, но идентичность пользователя должна быть в области запроса.

После такого рефактора параллельные логины и вызовы API становятся предсказуемыми: пользователь A всегда видит данные A, даже если пользователь B активен одновременно.

Бычеклист перед релизом

Найдём ваше скрытое разделяемое состояние
Мы найдём скрытые глобальные переменные и утечки состояния, вызывающие случайное поведение под нагрузкой.

Перед релизом сделайте финальную проверку, чтобы убедиться, что вы не оставили разделяемое состояние на виду.

Проверки перед отправкой в прод

  • Просканируйте код обработки запросов на модульные переменные, которые меняются (всё, что записывается во время запроса).
  • Перепроверьте кеши и мемоизацию. Убедитесь, что ключи включают то, что разделяет пользователей и тенанты (и локаль, если она меняет вывод).
  • Подтвердите, что контекст запроса передаётся явно, а не читается из скрытых синглтонов. Если функция нуждается в текущем пользователе, токене, тенанте или временной зоне — она должна получать это в аргументах или через зависимость с областью запроса.
  • Проверьте, что вы действительно делите: пулы соединений, неизменяемая конфигурация и клиенты только для чтения обычно безопасны. Всё, что имеет изменяемые поля (currentUser, lastQuery, headers), — нет.
  • Запустите параллельный тест: два пользователя входят и делают разные действия одновременно. Ищите кросс‑пользовательские данные, смешанные сессии или «случайные» ошибки прав доступа.

Если вы унаследовали AI‑сгенерированный прототип, этот чек‑лист стоит пройти дважды. Такие приложения часто прячут глобальное состояние «текущего пользователя» или общие клиенты с мутируемыми заголовками.

Следующие шаги, если в вашем AI‑прототипе есть баги конкуренции

Если приложение работает с одним пользователем, но падает под реальным трафиком — рассматривайте это как проблему разделяемого состояния до тех пор, пока не докажете обратного. Во многих AI‑проектах данные держатся в памяти там, где они должны жить в запросе, сессии или в базе.

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

Простой путь вперёд — исправить один пользовательский сценарий полностью. Выберите путь, который ломается чаще всего (логин, оформление заказа, загрузка файла). Рефакторьте только этот путь так, чтобы состояние прошло через аргументы функций или зависимости с областью запроса. Когда один путь чист, паттерны легче и безопаснее повторять.

Если вы не уверены, где скрытый синглтон, сузьте поиск:

  • Добавьте логи с ID объектов и ID пользователей в ключевых местах (аутентификация, доступ к БД, кеширование)
  • Поиск по коду: grep по «global», «singleton», «cache», «memo», «static» и присвоениям на уровне модуля
  • Временно отключите кеширование в памяти, чтобы посмотреть, уйдёт ли баг

Иногда быстрее привлечь эксперта, особенно когда код смешивает фреймворки, фоновые задачи и кастомную аутентификацию. FixMyMess (fixmymess.ai) диагностирует и исправляет AI‑сгенерированный код, начиная с бесплатного аудита. Большинство проектов завершаются в течение 48–72 часов с помощью инструментов AI и проверки человеком‑экспертом, поэтому исправления держат при нагрузке.

Часто задаваемые вопросы

Что именно такое «общая изменяемая глобальная переменная»?

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

Почему общие глобальные переменные вызывают ошибки, которые кажутся случайными?

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

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

Обращайте внимание на такие вещи, как модульная переменная currentUser, синглтон‑клиент, который хранит заголовки или токены, «глобальный кеш», где хранятся результаты для конкретного пользователя, или общий объект конфигурации, который мутирует (например, «текущий тенант»). Эти паттерны обычно работают при однопользовательском тестировании и ломаются при пересечении запросов.

Какие наиболее явные признаки утечки состояния между запросами?

Если пользователи видят чужой аккаунт, неверное название тенанта или данные другого пользователя — считайте это проблемой общей памяти до доказательства обратного. Нестабильные тесты, зависящие от порядка, и случайные ошибки аутентификации (401/403) тоже — сильные признаки.

Как быстро воспроизвести или подтвердить проблему?

Откройте два сеанса (например, обычное окно и приватное), залогиньтесь за двух разных пользователей и многократно вызывайте один и тот же эндпоинт. Если хоть раз произойдёт пересечение идентичностей, прав или настроек — где‑то в памяти всё ещё хранится состояние, специфичное для запроса.

Что можно делить безопасно, а что никогда не стоит делать глобальным?

Можно разделять ресурсы, если они не содержат данных, специфичных для запроса. Подключение к базе через пул, HTTP‑клиент с фиксированной конфигурацией и неизменяемая конфигурация обычно безопасны; опасность возникает, когда в общем объекте есть изменяемые поля, такие как currentUser, token, tenantId или заголовки для конкретного запроса.

Как убрать глобальную `currentUser`, не переписывая всё приложение?

Создайте небольшой контекст запроса в точке входа (хэндлер или middleware), содержащий только то, что меняется на запрос — идентификатор пользователя и request ID. Передавайте этот контекст (или сервисы, созданные из него) во функции вместо импорта глобальной переменной, которая молча хранит состояние.

Как сохранить кеширование, но не допустить утечку данных между пользователями?

Кеширование допустимо как уровень производительности, но не как скрытый источник истины. Важно правильно масштабировать область действия и ключи: включайте tenant и user в ключи, когда результаты различаются по ним, и избегайте единой «latest»‑переменной, которую любой запрос может перезаписать.

Как работать с фоновыми задачами, если запроса нет?

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

Что делать, если унаследовали прототип, сгенерированный ИИ, который ломается под нагрузкой?

Начните с целевой проверки аутентификации, кеширования и синглтонов — это частые места ошибок в прототипах, сгенерированных ИИ. Если нужно быстрее — FixMyMess выполняет бесплатный аудит кода, чтобы найти скрытое разделяемое состояние и затем исправить и усилить код; большинство проектов завершаются за 48–72 часа.