Паттерны кэширования — Примеры

Пример 1 — Кэш, читаемый при промахе, с честным сроком жизни

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

async function getProduct(id) {
  return db.query("select * from products where id = $1", [id]);
}

Решение

async function getProduct(id) {
  const key = `product:${id}`;
  const cached = await cache.get(key);
  if (cached) return JSON.parse(cached);

  const product = await db.query("select * from products where id = $1", [id]);
  const ttl = 300;                    // секунды: максимум, на который цена может отстать от записи
  await cache.set(key, JSON.stringify(product), "EX", ttl);
  return product;
}

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

Пример 2 — Истечение, которое приходит под нагрузкой

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

const key = "product:featured";
const ttl = 3600;                      // один фиксированный час
await cache.set(key, body, "EX", ttl);

Решение

const key = "product:featured";
const ttl = 3600 + Math.floor(Math.random() * 300);  // сроки жизни разнесены

// ключ всё ещё слишком горяч, чтобы дать ему истечь под нагрузкой:
// обновляет один вызывающий, а остальные читают копию, когда она появится
const gotLock = await cache.set("lock:product:featured", "1", "NX", "EX", 10);
if (gotLock) {
  const body = await db.query("select * from products where featured = true");
  await cache.set(key, JSON.stringify(body), "EX", ttl);
}

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