Кеширование: что класть, что нет и как решать
Ты уже кешировал, даже если не называл это так: 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.