Раздел 37 · Redis с нуля

Защита кэша: стампед, лавина, проникновение

junior~20 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

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

Кофейня в восемь утра

Представь кофейню у метро. Бариста заранее варит большой кофейник, и все берут из него. В 8:00 кофейник кончается, а у стойки двадцать человек. Если каждый пойдёт варить себе сам, кухня встанет. Разумнее, чтобы варил один, а остальные подождали минуту.

Вторая беда: бариста сварил все кофейники в 7:00, и все они кончаются одновременно в 8:00. Лучше варить вразнобой.

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

Кофейник это кэш, кухня это база. Разберём каждую беду по очереди.

Стампед: все сразу на кухню

Карточку популярной книги читают сто раз в секунду. Ключ cache:book:1 протухает, и в следующие 200 миллисекунд, пока первый запрос ходит в базу, остальные тоже видят промах и тоже идут в базу. Это стампед: база получает сотню одинаковых запросов вместо одного.

Проверим на Node.js с библиотекой node-redis. База здесь имитирована: функция ждёт 200 миллисекунд и считает свои вызовы. Наивный cache-aside из урока 11:

import { createClient } from 'redis';

const redis = await createClient({ url: 'redis://localhost:6379' }).connect();

async function getBookNaive(id) {
  const key = `cache:book:${id}`;
  const cached = await redis.get(key);
  if (cached !== null) return JSON.parse(cached);
  const book = await loadBookFromDb(id);
  await redis.set(key, JSON.stringify(book), { EX: 300 });
  return book;
}

await Promise.all(Array.from({ length: 100 }, () => getBookNaive(1)));

На пустом кэше сто одновременных вызовов дали naive db queries: 100. Сто походов в базу за одной карточкой.

Лекарство: один готовит, остальные ждут

Пусть в базу идёт только тот, кто первым захватил блокировку. Блокировка в Redis это обычный ключ, который ставят командой SET с двумя флагами:

  • NX: записать, только если ключа ещё нет;
  • EX 10: удалить ключ через 10 секунд, даже если владелец упал и не снял блокировку сам.
127.0.0.1:6379> SET lock:cache:book:1 1 NX EX 10
OK
127.0.0.1:6379> SET lock:cache:book:1 1 NX EX 10
(nil)

Первый получил OK и пошёл в базу. Второй получил (nil): блокировка занята, надо подождать и снова заглянуть в кэш.

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function getBook(id) {
  const key = `cache:book:${id}`;
  const lockKey = `lock:${key}`;
  for (let attempt = 0; attempt < 50; attempt += 1) {
    const cached = await redis.get(key);
    if (cached !== null) return JSON.parse(cached);

    const locked = await redis.set(lockKey, '1', { NX: true, EX: 10 });
    if (locked === 'OK') {
      try {
        const book = await loadBookFromDb(id);
        await redis.set(key, JSON.stringify(book), { EX: 300 });
        return book;
      } finally {
        await redis.del(lockKey);
      }
    }
    await sleep(50);
  }
  return loadBookFromDb(id);
}

Те же сто одновременных вызовов: lock db queries: 1. В базу сходил один запрос, остальные 99 подождали и взяли готовое из кэша. Последняя строка нужна на крайний случай: если за 50 попыток значение так и не появилось, идём в базу сами, а не висим вечно.

У простой блокировки есть тонкости: что, если работа длится дольше 10 секунд и блокировку снимет чужой DEL? Их разберём в уроке про распределённые блокировки. Для защиты кэша такой версии хватает.

Другой способ: обновить заранее

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

урок из курса

Урок из курса «Redis с нуля». Курс покупается один раз и остаётся навсегда: 500 ₽.

Оплата пока не подключена: премиум и курсы открываются промокодом.