Раздел 9 · Backend

Кеширование: что класть, что нет и как решать

senior~60 мин

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

Кеширование: что класть, что нет и как решать

Ты уже кешировал, даже если не называл это так: React Query держит ответы в памяти, браузер уважает Cache-Control, а ты сам когда-нибудь оборачивал тяжёлый запрос в Map. Кеш ускоряет ответы и снимает нагрузку с базы, это его работа.

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

Через весь урок проходит один вопрос, и он важнее любого кода:

Что произойдёт, если я отдам пользователю устаревшую версию этих данных?

Если ответ “ничего”, кешируй смело. Если ответ “утечёт чужое, спишутся деньги, покажем то, чего нет”, не кешируй или делай очень осторожно. Держи этот вопрос в голове до самого конца, на нём построен capstone.

Идея: кеш это ставка на то, что данные не изменятся

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

Кешировать стоит то, что попадает в пересечение двух осей: дорого пересчитать и редко меняется.

Меняется редкоМеняется часто
Дорого пересчитатьидеальный кандидат (тяжёлый аналитический запрос, справочник стран)сложный случай (фид: дорого, но протухает быстро, нужен короткий TTL)
Дёшево пересчитатьможно, но выигрыш мал (мелкий справочник)не трогай (счётчик, realtime цена: кеш только добавит рассинхрон)

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

Что нельзя кешировать (или очень осторожно)

Это блок, который ломают чаще всего, и ошибки тут самые дорогие.

  • Персонализированное под общим ключом. Классика: закешировал корзину или профиль по URL без учёта того, чей это запрос, и отдал данные одного пользователя другому. Это не баг производительности, это утечка данных.
  • Данные, где stale стоит денег или безопасности. Баланс, права доступа, остатки на складе. Показать stale-баланс означает разрешить операцию, которую нельзя. Показать stale-права означает пустить туда, куда нельзя.
  • Быстро меняющийся realtime. Котировки, позиция курьера на карте, счёт матча. Кеш тут отдаёт прошлое и злит пользователя.
  • Кеш с низким hit ratio. Если ключ почти всегда уникален (кешируешь то, что каждый раз разное), кеш не ускоряет, а только тратит память и добавляет латентность на запись.

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

// ОПАСНО: ключ только по URL, без учёта того, чей это запрос
const cache = new Map<string, unknown>();

app.get('/api/cart', async (req, res) => {
  const key = req.originalUrl; // всегда "/api/cart" для всех пользователей
  if (cache.has(key)) return res.json(cache.get(key));

  const cart = await loadCart(req.user.id);
  cache.set(key, cart); // первый пришедший записал СВОЮ корзину под общий ключ
  res.json(cart);
});

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

app.get('/api/cart', async (req, res) => {
  const key = `cart:v1:${req.user.id}`; // ключ привязан к личности
  const cached = cache.get(key);
  if (cached) return res.json(cached);

  const cart = await loadCart(req.user.id);
  cache.set(key, cart);
  res.set('Cache-Control', 'private, max-age=30'); // запрет общим прокси и CDN
  res.json(cart);
});

Дизайн ключа: половина успеха

Ключ это контракт кеша. Хороший ключ это namespace:сущность:id:версия, например user:v2:123:profile. Зачем такая структура:

  • namespace и версия позволяют выкинуть целый пласт одним движением. Поменял форму данных, поднял версию с v2 на v3, и все старые ключи мгновенно стали невидимы, без перебора и без FLUSHDB.
  • identity, locale, tenant обязаны попадать в ключ, если ответ от них зависит. Забыл identity, получил утечку из прошлого блока. Забыл locale, отдал русский текст англоязычному.

Главная ловушка ключей это кардинальность. Если ты пихаешь в ключ весь query string как есть, ты взрываешь число ключей и обнуляешь hit ratio: ?sort=price&page=1 и ?page=1&sort=price это семантически одно и то же, но как ключи они разные, и кеш считает их двумя разными ответами.

Лечение это нормализация: отсортировать параметры, схлопнуть значения по умолчанию, выкинуть мусорные.

// нормализуем query в стабильный ключ: меньше уникальных ключей, выше hit ratio
function buildListKey(query: Record<string, string | undefined>) {
  const defaults = { sort: 'created', order: 'desc', page: '1' };
  const merged = { ...defaults, ...stripEmpty(query) };

  const canonical = Object.keys(merged)
    .sort() // page=1&sort=price и sort=price&page=1 дают один ключ
    .map((k) => `${k}=${merged[k]}`)
    .join('&');

  return `articles:v1:list:${canonical}`;
}

После нормализации две формы одного запроса попадают в один ключ, и второй запрос читает то, что прогрел первый.

Стратегии: как кеш и источник договариваются

В теории кеширования это называют стратегиями взаимодействия. На бэке их пять, но в Node 90 процентов кода это первая.

  • Cache-aside (она же lazy): приложение само управляет кешем. Промах, идём в источник, кладём в кеш, отдаём. Это вариант по умолчанию, с него начинай.
  • Read-through и write-through: за кеш и базу отвечает один слой (библиотека или прокси). Read-through сам подгружает на промахе, write-through пишет одновременно в кеш и в базу, поэтому повторное чтение сразу горячее.
  • Write-behind: пишем в кеш, а в базу асинхронно пачкой. Быстро, но рискуешь потерять запись при падении. На продуктовом бэке редко.
  • Write-around: пишем мимо кеша, прямо в базу, и инвалидируем ключ. Хорошо, когда записанное читают не сразу.
  • Refresh-ahead: обновляем горячий ключ заранее, до того как он протухнет. Сглаживает пики, к нему вернёмся в блоке про stampede.

Канонический cache-aside на ioredis:

import Redis from 'ioredis';
const redis = new Redis();

async function getUser(id: string) {
  const key = `user:v1:${id}:profile`;

  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached); // попадание

  const user = await db.users.findById(id); // промах, идём в источник
  await redis.set(key, JSON.stringify(user), 'EX', 300); // прогреваем кеш
  return user;
}

Отдельный приём это negative caching: если источник вернул “нет такого”, полезно закешировать и это, иначе поток запросов по несуществующему id будет каждый раз бить в базу.

const NONE = '\u0000none'; // маркер отсутствия, заведомо не валидный JSON

async function getUserNeg(id: string) {
  const key = `user:v1:${id}:profile`;
  const cached = await redis.get(key);
  if (cached === NONE) return null; // знаем, что нет, в базу не идём
  if (cached) return JSON.parse(cached);

  const user = await db.users.findById(id);
  if (!user) {
    await redis.set(key, NONE, 'EX', 30); // негатив живёт меньше позитива
    return null;
  }
  await redis.set(key, JSON.stringify(user), 'EX', 300);
  return user;
}

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.