DEV Community

Eugene
Eugene

Posted on

Single-flight кэш: как один горячий ключ не роняет базу

На podbor-minuta.ru есть тяжёлые запросы к базе, результат которых мы держим в кэше: списки квартир, сводные цифры по подборкам. Пока значение в кэше есть, всё быстро. Проблема начинается в момент, когда оно протухает.

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

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

Это называют набегом на кэш. Само по себе кэширование тут не помогает, потому что дыра открывается ровно в момент, когда значения нет, а именно в этот момент нагрузка и приходит.

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

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

const inFlight = new Map<string, Promise<unknown>>();

async function getOrSet<T>(key: string, compute: () => Promise<T>): Promise<T> {
  const cached = cache.get(key);
  if (cached !== undefined) return cached as T; // быстрый путь не трогаем

  const running = inFlight.get(key);
  if (running) return running as Promise<T>; // уже считается - ждём тот же результат

  const promise = compute()
    .then((value) => {
      cache.set(key, value);
      return value;
    })
    .finally(() => {
      inFlight.delete(key); // освобождаем ключ в любом случае
    });

  inFlight.set(key, promise);
  return promise;
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

Сайт - podbor-minuta.ru, ежедневный мониторинг цен на новостройки Москвы.

Top comments (0)