Главная / Статьи

Кэширование Stampedes в Node.js: почему кэш Redis может перегрузить вашу базу данных

Узнайте, почему примитивная конфигурация Redis с использованием механизма cache-aside приводит к синхронизации нагрузки на вашу базу данных при истечении срока действия «горячего» ключа, и как блокировки и фоновое обновление предотвращают это.

Издатель: REACTAPP.TOP7 мин чтения1648 слов

Источники: “I Thought Redis Would Make My API Faster.” It Didn’t.

Добавление Redis перед медленным запросом — это стандартное решение для базы данных, находящейся под высокой нагрузкой, и в спокойные дни оно кажется явным успехом. Однако в периоды пиковой нагрузки простая конфигурация с кэшем может значительно усугубить ситуацию: как только истекает срок действия популярного ключа, все одновременные запросы терпят неудачу, что приводит к сильной нагрузке на базу данных. В этой статье объясняется эта проблема, известная как «стампид в кэше», описывается, как она распространяется через API Node.js, и показаны два способа её предотвращения: распределенный замок для регенерации данных и предварительные вычисления в фоновом режиме.

Решение, работающее до пиковой нагрузки

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

Затем наступает утренний пик в понедельник, и API перестаёт отвечать. Процесс Node.js показывает низкую загрузку CPU, поэтому проблема не в цикле событий. Однако основная база данных работает с загрузкой CPU 100%.

Redis не является узким местом

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

Типичная модель работы заключается в сравнении с тихой библиотекой: вы обращаетесь к стойке регистрации (Redis), и если книги там нет, библиотекарь (API) достаёт её из полок (PostgreSQL) и оставляет копию на стойке. В масштабах это больше похоже на биржевую площадку. Когда тысячи клиентов запрашивают один и тот же популярный товар сразу после истечения срока хранения его в кэше, все они одновременно обнаруживают пустую стойку и направляются к полкам.

Анатомия толпы

Возьмём такой конечный пункт, как GET /products/trending. Подготовка ответа на него включает несколько объединений данных, подзапросов и операций агрегации в Node.js, и на это уходит примерно 1,2 секунды. Результат хранится в кэше в течение десяти минут.

В устойчивом режиме это очень эффективно: из десяти тысяч запросов только один поступает в базу данных, а остальные обслуживаются из Redis примерно за 5 мс.

Предположим, что срок действия TTL в 10 минут истекает в 09:00:00. В 09:00:01 одновременно поступают пятьдесят запросов. Все они терпят неудачу, но каждый из них запускает одинаковый запрос продолжительностью 1,2 секунды. База данных внезапно выполняет пятьдесят тяжелых запросов параллельно. Пул соединений практически мгновенно исчерпывается, и все остальные запросы к API, требующие доступа к базе данных, даже для не связанных с ней данных, оказываются в очереди после этих пятидесяти.

Стоит быть точным: ожидание свободного соединения из пула не блокирует цикл событий Node.js, поскольку такое ожидание является асинхронным. В результате запросы накапливаются в виде необработанных обещаний и открытых сокетов, из-за чего процесс кажется неактивным, в то время как у пользователей возникают проблемы с таймаутами.

Это явление называется «стампидом кэша», или эффектом скопления запросов. Кэш не снизил нагрузку; он лишь синхронизировал всплеск нагрузки в один конкретный момент времени.

Почему происходит каскадная реакция вместо восстановления

Паническая нагрузка — это не просто период замедления работы. Она сама себя усугубляет.

  • Время обработки запросов резко возрастает. Запрос, ранее выполнявшийся за 1,2 секунды, может занять до 25 секунд, когда пятьдесят экземпляров конкурируют за процессорные ресурсы, блокировки и операции ввода-вывода.
  • Инстансы API удерживают тысячи открытых HTTP-соединений в ожидании, что приводит к расходу памяти и файловых дескрипторов.
  • Нагрузка на процессор на уровне API остается низкой, поэтому автоматический масштабировщик, основанный на нагрузке процессора, может так и не увеличить мощность, а добавление новых инстансов API лишь приведет к отправке еще большего количества запросов в базу данных.
  • Новые запросы продолжают поступать, но не обрабатываются, поскольку медленные запросы еще не вернули результаты. В конечном итоге база данных перестает работать из-за исчерпания памяти или истечения времени ожидания соединений.

Предотвращение панической нагрузки

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

Шаблон 1: распределенный замок для генерации

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

  • Ожидание и повторная попытка: запрос на короткое время, например 50 мс, входит в спящий режим и снова проверяет Redis до появления новых данных.
  • Предоставление устаревших данных: возвращается предыдущее значение, если система может справиться с незначительно устаревшими результатами.

В приведённом ниже примере используется ioredis. Сначала считывается кэш. При его отсутствии применяется команда SET с параметрами NX и PX 5000, что позволяет создать блокировку только в том случае, если её ещё никто не держит, и она автоматически истекает через пять секунд. Победитель выполняет запрос, сохраняет результат на 600 секунд и освобождает блокировку в блоке finally; все остальные продолжают проверять кэш до тех пор, пока значение не будет загружено.

// Example: Using Redis for a locking/syncing pattern (Conceptual)
import { Redis } from 'ioredis';
const redis = new Redis();


async function getCachedTrendingProducts() {
  const cacheKey = 'products:trending';
  const lockKey = 'lock:products:trending';
  // Attempt 1: Get data
  let data = await redis.get(cacheKey);
  if (data) return JSON.parse(data);
  // Attempt 2: Cache miss. Try to acquire the regeneration lock.
  const acquiredLock = await redis.set(lockKey, 'locked', 'NX', 'PX', 5000); // 5s lock
  if (acquiredLock) {
    // We hold the lock. We are the ONLY worker hitting the DB.
    try {
      // 1.2s Database Operation
      const dbData = await fetchTrendingProductsFromDB();
      // Store in Redis (10 min TTL)
      await redis.set(cacheKey, JSON.stringify(dbData), 'EX', 600);
      return dbData;
    } finally {
      // Release lock
      await redis.del(lockKey);
    }
  } else {
    // We did NOT get the lock. Someone else is already regenerating.
    // Wait and Retry (e.g., exponential backoff or simple polling)
    return pollRedisUntilPopulated(cacheKey);
  }
}

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

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

  • Срок действия блокировки должен превышать максимально возможное время выполнения запроса. Если процесс обновления занимает больше пяти секунд, блокировка истекает, и другой процесс начинает повторный запрос.
  • Безусловное удаление блокировки может привести к удалению блокировки, полученной другим процессом после истечения срока действия вашей. Чтобы избежать этого, можно хранить уникальный токен в качестве значения блокировки и удалять её только в том случае, если он по-прежнему совпадает, обычно с помощью небольшого скрипта на Lua.
  • pollRedisUntilPopulated требует параметров таймаута и запасного решения, иначе запросы на ожидание могут застрять в случае сбоя процесса, удерживающего блокировку.
  • Внутри одного процесса Node.js также можно объединять одновременные неудачные попытки, используя одну общую задачу на выполнение для каждого ключа, что сокращает количество попыток блокировки ещё до того, как в дело вступит Redis.
  • Шаблон 2: предварительный расчёт на фоне

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

    Воссоздание данных происходит в фоновом процессе — например, через задачу cron, рабочего процесса BullMQ или потребителя для захвата изменений данных — который обновляет ключ до истечения срока его действия. В приведенном ниже примере рабочий процесс, запускаемый каждые девять минут, воссоздает список популярных элементов и записывает его обратно с тем же сроком действия в 10 минут, так что ключ никогда не становится неактивным в условиях нормальной работы:

    // Separate background worker file
    // For example, running every 9 minutes via Cron or BullMQ
    async function regenerateTrendingProductsCache() {
      const cacheKey = 'products:trending';
      try {
        // Proactive DB fetch (1.2s)
        const dbData = await fetchTrendingProductsFromDB();
        // Refresh the 10-minute cache
        await redis.set(cacheKey, JSON.stringify(dbData), 'EX', 600);
        console.log('Cache proactively refreshed.');
      } catch (err) {
        console.error('Proactive refresh failed:', err);
      }
    }
    

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

    Выбор между ними

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

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

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