Кэш — это копия данных ближе к потребителю: в браузере, на CDN, в памяти процесса, в Redis или в самой базе. Архитектурные решения сводятся к трём вопросам: на каком уровне хранить копию, как её заполнять (cache-aside, read-through, write-through) и как узнавать, что она устарела (TTL, событие об изменении, новая версия ключа). Отдельная опасность — cache stampede, когда истёкший популярный ключ одновременно отправляет в базу сотни запросов; от неё защищают блокировка на перестроение и отдача устаревшего значения на время обновления.
После материала вы сможете
- выбрать уровень кэша — браузер, CDN, память процесса, Redis, база — под тип данных и допустимую устарелость
- настроить HTTP-кэш ответа API:
Cache-Control,ETagи ответ304 Not Modified - выбрать стратегию — cache-aside, read-through, write-through, write-behind — и способ инвалидации: TTL, событие, версия ключа
- защитить популярный ключ от cache stampede блокировкой на перестроение и stale-while-revalidate и проверить эффект замером
Проверено на
Node.js 24 LTS (24.18)Redis 8.10.2 (redis:8.10-alpine)node-redis 6.3.0TypeScript 7.0HTTP caching (RFC 9111, RFC 5861)
Сверено с документацией: 11 октября 2026 г.
Содержание
Где можно кэшировать: от браузера до базы
Запрос пользователя проходит несколько слоёв, и на каждом можно сохранить готовый ответ. Чем ближе копия к пользователю, тем она дешевле в чтении и тем сложнее её отозвать: ответ в браузере сервер удалить не может, ответ на CDN — только через API провайдера, ключ в Redis — одной командой.
- 01БраузерЧастный кэш пользователя: Cache-Control, ETag
- 02CDNОбщий кэш у края сети: s-maxage, purge через API
- 03ПриложениеL1 в памяти процесса и L2 в Redis
- 04База данныхБуферный кэш и материализованные представления
Браузер (private cache)
- Что хранить
- Статику с хешем в имени, публичные ответы API
- Чем инвалидировать
- Только сменой URL или истечением срока
- Главный риск
- Старую версию нельзя отозвать
CDN, обратный прокси (shared cache)
- Что хранить
- Публичные страницы, статику, ответы API для всех
- Чем инвалидировать
- Срок жизни, purge через API провайдера
- Главный риск
- Отдать одному пользователю данные другого
Память процесса (L1)
- Что хранить
- Горячие справочники, конфигурацию
- Чем инвалидировать
- TTL, сообщение об изменении
- Главный риск
- У каждого экземпляра своя копия
Общий кэш — Redis (L2)
- Что хранить
- Результаты тяжёлых запросов, агрегаты
- Чем инвалидировать
- TTL, удаление ключа, версия ключа
- Главный риск
- Stampede, рассинхронизация с базой
База данных
- Что хранить
- Буферный кэш страниц, материализованные представления
- Чем инвалидировать
- СУБД сама; представления — явным обновлением
- Главный риск
- Устаревшая витрина между обновлениями
Основы работы с Redis — типы данных, TTL, вытеснение при нехватке памяти и базовый cache-aside — разобраны в уроке о кэше на Redis. Здесь — архитектурные решения поверх этих механизмов: какие уровни сочетать, как они узнают об изменениях и что происходит под нагрузкой.
HTTP-кэш: Cache-Control, ETag и CDN
HTTP-кэш описан стандартом RFC 9111 и работает без вашего кода — его поведение задают заголовки ответа. MDN разделяет кэши на частные (браузер конкретного пользователя) и общие (прокси, CDN, обратный прокси). Если заголовка Cache-Control нет, кэш вправе применить эвристику — например, по Last-Modified, — поэтому MDN советует явно указывать Cache-Control практически в каждом ответе.
max-age=N
- Что означает
- Ответ свежий N секунд с момента создания
s-maxage=N
- Что означает
- То же для общих кэшей (CDN); частные её игнорируют
no-cache
- Что означает
- Хранить можно, но перед каждым использованием — проверить у сервера. Это не «не кэшировать»
no-store
- Что означает
- Не хранить нигде. Уже сохранённые копии не удаляет
private
- Что означает
- Только частный кэш браузера — для персональных ответов
public
- Что означает
- Можно в общий кэш, даже если запрос был с
Authorization
immutable
- Что означает
- Пока ответ свежий, он не изменится — не проверять при перезагрузке
stale-while-revalidate=N
- Что означает
- После истечения свежести ещё N секунд отдавать старую копию, обновляя её в фоне (RFC 5861)
Когда свежесть истекла, кэш не обязательно скачивает ответ заново: он отправляет условный запрос с If-None-Match и значением ETag — идентификатором версии ответа, который придумывает сервер. Если версия не изменилась, сервер отвечает 304 Not Modified без тела. Ниже — эндпоинт каталога: он берёт товар через кэш приложения из следующих разделов и ставит заголовки для браузера и CDN.
import { createHash } from 'node:crypto';
import { createServer } from 'node:http';
import { createProductService } from './products.ts';
const products = await createProductService('http');
const server = createServer(async (req, res) => {
const match = req.url?.match(/^\/api\/products\/(\d+)$/);
if (req.method !== 'GET' || !match) {
res.writeHead(404).end();
return;
}
let value;
try {
({ value } = await products.getProduct(Number(match[1])));
} catch (err) {
console.error(err);
res.writeHead(500, { 'Cache-Control': 'no-store' }).end(); // never cache an error
return;
}
if (!value) {
res.writeHead(404, { 'Cache-Control': 'public, max-age=30' }).end();
return;
}
const body = JSON.stringify(value);
const etag = `"${createHash('sha256').update(body).digest('base64url').slice(0, 22)}"`;
const headers = {
// browser: 60 s; CDN: 300 s; after that a stale copy may be served for 10 min while revalidating
'Cache-Control': 'public, max-age=60, s-maxage=300, stale-while-revalidate=600',
ETag: etag,
};
// Conditional request: the client already has this version
const known = req.headers['if-none-match']?.split(',').map((t) => t.trim()) ?? [];
if (known.includes(etag)) {
res.writeHead(304, headers).end();
return;
}
res.writeHead(200, { ...headers, 'Content-Type': 'application/json' }).end(body);
});
server.listen(3080, '127.0.0.1', () => console.log('http://127.0.0.1:3080'));node:http: Cache-Control для браузера и CDN, сильный ETag из хеша тела и ответ 304$ curl -si localhost:3080/api/products/1
HTTP/1.1 200 OK
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600
ETag: "QiAF3iaw-m9HY2CB31jgsu"
Content-Type: application/json
{"id":1,"title":"Latte","priceKzt":1800}
$ curl -si localhost:3080/api/products/1 -H 'If-None-Match: "QiAF3iaw-m9HY2CB31jgsu"'
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600
ETag: "QiAF3iaw-m9HY2CB31jgsu"- Статика с хешем в имени (
app.3f9a1c.js) —public, max-age=31536000, immutable: новая версия получит новый URL, старую отзывать не нужно. - HTML-страницы нельзя переименовать при каждом изменении, поэтому MDN советует для них
no-cacheвместе сETagилиLast-Modifiedиprivate, если страница персональная. - Персональные данные —
privateилиno-store. Наличие cookie само по себе ответ частным не делает: безprivateобщий кэш может сохранить чужой профиль. - Ошибки —
no-store, как в обработчике выше: закэшированный на CDN ответ 500 будет «лежать» дольше, чем сама авария. Varyдобавляет заголовок запроса в ключ кэша (Vary: Accept-Language).Vary: User-Agentпочти обнуляет попадания — у этого заголовка слишком много вариантов.
Стратегии кэша приложения
Внутри приложения кэш заполняют и обновляют по одной из нескольких схем. Различаются они тем, кто ходит в базу — код приложения или сам кэш — и в какой момент копия обновляется при записи.
Cache-aside
- Чтение
- Приложение смотрит в кэш, при промахе читает базу и кладёт результат в кэш
- Запись
- Пишет в базу, затем удаляет ключ
- Компромисс
- Просто и устойчиво к падению кэша; после записи возможен короткий период со старыми данными
Read-through
- Чтение
- Приложение читает только кэш; при промахе кэш сам загружает данные
- Запись
- Как в cache-aside
- Компромисс
- Логика загрузки в одном месте; нужен кэш или библиотека с загрузчиком
Write-through
- Чтение
- Как в read-through
- Запись
- Запись идёт через кэш: он обновляет и базу, и копию в одной операции
- Компромисс
- Свежие данные сразу после записи; запись медленнее, в кэш попадает то, что могут не читать
Write-behind (write-back)
- Чтение
- Как в read-through
- Запись
- Запись в кэш, в базу — позже, пачками
- Компромисс
- Быстрая запись и разгрузка базы; при падении кэша несохранённые изменения теряются
Руководство Microsoft по паттерну cache-aside формулирует ключевые оговорки. Согласованности кэша и хранилища он не гарантирует: данные может изменить другой процесс, и кэш узнает об этом только после истечения срока. Срок жизни подбирают под характер доступа: слишком короткий — кэш постоянно перезагружается, слишком длинный — данные устаревают. Порядок при записи важен: сначала обновить хранилище, потом удалить ключ, иначе параллельный запрос успеет положить в кэш старое значение.
Инвалидация: TTL, событие и версия ключа
Узнать, что копия устарела, можно тремя способами, и в рабочей системе их сочетают. TTL — верхняя граница устарелости, страховка на случай, если остальные механизмы сработали не так. Инвалидация по событию — удаление копии в момент изменения данных: быстро, но требует, чтобы каждое изменение было видно. Версионирование ключей — вместо удаления множества ключей меняется номер версии в имени, и все старые копии разом становятся недостижимыми и истекают по TTL.
TTL
Задаёт, сколько устарелости допустимо. Для цены в каталоге — минуты, для курса валют — секунды, для справочника городов — часы.
Событие
Изменение в базе порождает сообщение «товар изменён», и обработчик удаляет ключ. Надёжный источник таких событий — transactional outbox из урока о событиях и очередях.
Версия ключа
Массовое изменение (переоценка каталога, новый формат данных) — одна команда
INCR catalog:versionвместо удаления тысяч ключей.
Когда экземпляров приложения несколько и у каждого есть кэш в памяти (L1) поверх общего Redis (L2), удалить ключ в Redis мало: копии остались в памяти остальных процессов. Руководство Microsoft отмечает именно это — локальный кэш у каждого экземпляра свой и быстро расходится с остальными. Ниже каждый экземпляр подписан на канал Redis Pub/Sub и удаляет ключ из L1 при сообщении. Подготовка проекта и модуль кэша — в разделе о stampede; здесь — сервис товаров поверх него.
import { createCache } from './cache.ts';
import { loadProduct, savePrice, type Product } from './db.ts';
import { connectRedis } from './redis.ts';
const CHANNEL = 'cache:invalidate';
const L1_TTL_MS = 30_000; // safety net: Pub/Sub may lose a message
const L1_MAX = 10_000;
const policy = { freshMs: 60_000, staleMs: 300_000 };
// One instance of the application: L1 in process memory, L2 in shared Redis
export async function createProductService(name: string) {
const redis = await connectRedis();
const cache = createCache(redis);
const local = new Map<string, { value: Product | null; expires: number }>();
// A subscribed connection cannot run other commands, so it is a separate one
const sub = redis.duplicate();
sub.on('error', (err) => console.error(`[${name}] sub error:`, err.message));
sub.on('ready', () => local.clear()); // after a reconnect, missed messages are gone: drop L1
await sub.connect();
await sub.subscribe(CHANNEL, (key) => {
local.delete(key);
console.log(`[${name}] invalidated ${key}`);
});
// Bumping catalog:version switches every product to new keys at once
async function keyFor(id: number) {
const version = (await redis.get('catalog:version')) ?? '0';
return `catalog:v${version}:product:${id}`;
}
async function getProduct(id: number) {
const key = await keyFor(id);
const l1 = local.get(key);
if (l1 && l1.expires > Date.now()) return { key, value: l1.value, from: 'L1' };
const { value, outcome } = await cache.get(key, () => loadProduct(id), policy);
if (local.size >= L1_MAX) local.delete(local.keys().next().value!); // evict the oldest
local.set(key, { value, expires: Date.now() + L1_TTL_MS });
return { key, value, from: outcome === 'miss' ? 'db' : `L2 (${outcome})` };
}
async function changePrice(id: number, priceKzt: number) {
await savePrice(id, priceKzt); // 1. the source of truth first
const key = await keyFor(id);
await redis.del(key); // 2. the shared cache
await redis.publish(CHANNEL, key); // 3. local caches of every instance
}
async function close() {
await sub.close();
await redis.close();
}
return { getProduct, changePrice, redis, close };
}import { setTimeout as sleep } from 'node:timers/promises';
import { createProductService } from './products.ts';
// Two application instances; in production they are separate processes
const a = await createProductService('A');
const b = await createProductService('B');
async function show(name: string, svc: typeof a, id: number) {
const { key, value, from } = await svc.getProduct(id);
console.log(`${name} get ${key}: ${value?.priceKzt} KZT from ${from}`);
}
await show('A', a, 1);
await show('B', b, 1);
await show('A', a, 1);
await show('B', b, 1);
console.log('--- A changes the price of product 1');
await a.changePrice(1, 2000);
await sleep(50); // let the Pub/Sub message arrive
await show('B', b, 1);
await show('A', a, 1);
console.log('--- bulk price update: bump catalog:version');
await show('A', a, 2);
console.log('catalog:version =', await a.redis.incr('catalog:version'));
await show('A', a, 2);
await show('B', b, 1);
await a.close();
await b.close();$ node src/demo-invalidation.ts
A get catalog:v0:product:1: 1800 KZT from db
B get catalog:v0:product:1: 1800 KZT from L2 (hit)
A get catalog:v0:product:1: 1800 KZT from L1
B get catalog:v0:product:1: 1800 KZT from L1
--- A changes the price of product 1
[B] invalidated catalog:v0:product:1
[A] invalidated catalog:v0:product:1
B get catalog:v0:product:1: 2000 KZT from db
A get catalog:v0:product:1: 2000 KZT from L2 (hit)
--- bulk price update: bump catalog:version
A get catalog:v0:product:2: 2400 KZT from db
catalog:version = 1
A get catalog:v1:product:2: 2400 KZT from db
B get catalog:v1:product:1: 2000 KZT from dbINCR все товары читаются по новым ключамУ инвалидации есть и гонки. Классическая для cache-aside: запрос A промахнулся и читает старую цену из базы; в этот момент запрос B записывает новую цену и удаляет ключ; затем A кладёт в кэш старое значение — и оно проживёт до конца TTL. Справочник Redis по client-side caching описывает похожую гонку между ответом и сообщением об инвалидации. Полностью их не устранить без усложнений (номер версии записи в значении, отказ от записи, если версия в базе новее), поэтому TTL остаётся обязательным даже при инвалидации по событию.
Redis умеет и сам рассылать инвалидации: в режиме tracking сервер запоминает, какие ключи читал клиент, и присылает сообщение при их изменении; node-redis поддерживает это опцией clientSideCache поверх протокола RESP3. В нашей проверке этот режим не запускался — схема с Pub/Sub выше нагляднее показывает механизм и не зависит от клиента.
Cache stampede: блокировка и stale-while-revalidate
Cache stampede («паническое бегство», он же dog-pile) — ситуация, когда популярный ключ истекает и все запросы, пришедшие за время его перестроения, одновременно идут в базу. Сто одинаковых тяжёлых запросов в одну секунду способны положить базу, после чего кэш не заполнится вовсе. Защит две, и они дополняют друг друга.
- Блокировка на перестроение: в базу идёт только запрос, который захватил ключ-блокировку
SET lock:<key> <token> NX PX 5000; остальные ждут, пока в кэше появится значение. - Stale-while-revalidate: ключ живёт дольше, чем свеж. Срок свежести хранится внутри значения; когда он истёк, запрос сразу получает старое значение, а перестроение идёт в фоне — тоже под блокировкой. Пользователь не ждёт базу вообще.
docker run -d --rm --name kb-redis-cache -p 127.0.0.1:56390:6379 redis:8.10-alpine
mkdir cache-demo && cd cache-demo
npm init -y && npm pkg set type=module
npm install redis@6.3.0
npm install -D typescript@7.0.2 @types/node@24{
"compilerOptions": {
"target": "es2024",
"module": "nodenext",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": ["src"]
}npx tsc -p .): Node.js 24 исполняет .ts напрямуюimport { createClient } from 'redis';
export async function connectRedis() {
const client = createClient({ url: process.env.REDIS_URL ?? 'redis://localhost:56390' });
client.on('error', (err) => console.error('redis error:', err.message));
await client.connect();
return client;
}
export type Redis = Awaited<ReturnType<typeof connectRedis>>;// Stand-in for a slow database: in a real project this is SQL or an external API
export type Product = { id: number; title: string; priceKzt: number };
const products = new Map<number, Product>([
[1, { id: 1, title: 'Latte', priceKzt: 1800 }],
[2, { id: 2, title: 'Cheesecake', priceKzt: 2400 }],
]);
export const db = { calls: 0 };
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function loadProduct(id: number): Promise<Product | null> {
db.calls++;
await sleep(200); // a heavy query
const product = products.get(id);
return product ? structuredClone(product) : null;
}
export async function savePrice(id: number, priceKzt: number): Promise<void> {
await sleep(10);
const product = products.get(id);
if (!product) throw new Error(`product ${id} not found`);
product.priceKzt = priceKzt;
}import { randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
import type { Redis } from './redis.ts';
export type Outcome = 'hit' | 'stale' | 'miss' | 'waited' | 'fallback';
type Entry<T> = { value: T; freshUntil: number }; // freshness lives inside the value
type Policy = { freshMs: number; staleMs: number };
const LOCK_MS = 5000; // longer than the slowest load, otherwise two loads may overlap
const POLL_MS = 25;
const WAIT_MS = 2000;
export function createCache(redis: Redis) {
async function write<T>(key: string, value: T, { freshMs, staleMs }: Policy) {
const entry: Entry<T> = { value, freshUntil: Date.now() + freshMs };
// the key lives longer than it is fresh: the stale tail is served while rebuilding
await redis.set(key, JSON.stringify(entry), {
expiration: { type: 'PX', value: freshMs + staleMs },
});
}
// Only the request that takes the lock goes to the source
async function rebuild<T>(key: string, load: () => Promise<T>, policy: Policy) {
const lockKey = `lock:${key}`;
const token = randomUUID();
const locked = await redis.set(lockKey, token, {
condition: 'NX',
expiration: { type: 'PX', value: LOCK_MS },
});
if (locked !== 'OK') return null; // somebody else is already rebuilding
try {
const value = await load();
await write(key, value, policy);
return { value };
} finally {
// Redis 8.4+: delete the lock only if it is still ours
await redis.delEx(lockKey, { condition: 'IFEQ', matchValue: token });
}
}
async function get<T>(key: string, load: () => Promise<T>, policy: Policy): Promise<{ value: T; outcome: Outcome }> {
const raw = await redis.get(key);
if (raw !== null) {
const entry = JSON.parse(raw) as Entry<T>;
if (Date.now() < entry.freshUntil) return { value: entry.value, outcome: 'hit' };
// stale-while-revalidate: answer with the old value, rebuild in the background
rebuild(key, load, policy).catch((err) => console.warn('background rebuild failed:', err.message));
return { value: entry.value, outcome: 'stale' };
}
const built = await rebuild(key, load, policy);
if (built) return { value: built.value, outcome: 'miss' };
// Someone holds the lock: wait for their result instead of hitting the database
for (let waited = 0; waited < WAIT_MS; waited += POLL_MS) {
await sleep(POLL_MS);
const ready = await redis.get(key);
if (ready !== null) return { value: (JSON.parse(ready) as Entry<T>).value, outcome: 'waited' };
}
return { value: await load(), outcome: 'fallback' }; // the rebuild is too slow: go to the source
}
return { get };
}
// For comparison: plain cache-aside without any protection
export async function naiveGet<T>(redis: Redis, key: string, load: () => Promise<T>, ttlMs: number) {
const raw = await redis.get(key);
if (raw !== null) return JSON.parse(raw) as T;
const value = await load();
await redis.set(key, JSON.stringify(value), { expiration: { type: 'PX', value: ttlMs } });
return value;
}- Токен блокировки случайный, а снимается она командой
DELEX … IFEQ token(Redis 8.4+): если загрузка длилась дольшеLOCK_MSи блокировку уже взял другой запрос, мы не удалим чужую. Для Redis 7 то же делает Lua-скрипт из документации командыSET— см. упражнение. LOCK_MSдолжен быть больше самой долгой загрузки, аWAIT_MSограничивает ожидание: если перестроение зависло, запрос идёт в базу сам (fallback), а не висит.- Документация Redis считает простую блокировку на
SET NXнедостаточной там, где важна строгая взаимоисключаемость, и предлагает алгоритм Redlock. Для кэша это не критично: худший исход — две загрузки вместо одной, данные не портятся. - Отсутствующий товар тоже кэшируется (
null): так повторные запросы несуществующих id не долбят базу.
import { setTimeout as sleep } from 'node:timers/promises';
import { createCache, naiveGet, type Outcome } from './cache.ts';
import { db, loadProduct } from './db.ts';
import { connectRedis } from './redis.ts';
const redis = await connectRedis();
const cache = createCache(redis);
const policy = { freshMs: 1000, staleMs: 60_000 };
const N = 100; // simultaneous requests for the same popular key
async function burst(label: string, request: () => Promise<Outcome | void>) {
const before = db.calls;
const t0 = performance.now();
const outcomes = await Promise.all(Array.from({ length: N }, request));
const ms = Math.round(performance.now() - t0);
const counts: Record<string, number> = {};
for (const o of outcomes) if (o) counts[o] = (counts[o] ?? 0) + 1;
console.log(`${label}: db +${db.calls - before} (total ${db.calls}), ${ms} ms`, Object.keys(counts).length ? counts : '');
}
await redis.del(['naive:product:1', 'product:1']);
await burst('naive, cold key ', async () => {
await naiveGet(redis, 'naive:product:1', () => loadProduct(1), 60_000);
});
await burst('locked, cold key ', async () => (await cache.get('product:1', () => loadProduct(1), policy)).outcome);
await burst('locked, fresh key ', async () => (await cache.get('product:1', () => loadProduct(1), policy)).outcome);
await sleep(1100); // the entry is now stale but still in Redis
await burst('locked, stale key ', async () => (await cache.get('product:1', () => loadProduct(1), policy)).outcome);
await sleep(300); // the background rebuild has finished
await burst('after rebuild ', async () => (await cache.get('product:1', () => loadProduct(1), policy)).outcome);
await redis.close();$ node src/demo-stampede.ts
naive, cold key : db +100 (total 100), 212 ms
locked, cold key : db +1 (total 101), 210 ms { miss: 1, waited: 99 }
locked, fresh key : db +0 (total 101), 1 ms { hit: 100 }
locked, stale key : db +0 (total 101), 7 ms { stale: 100 }
after rebuild : db +0 (total 102), 2 ms { hit: 100 }
$ docker exec kb-redis-cache redis-cli INFO stats | grep keyspace_
keyspace_hits:401
keyspace_misses:695Что видно в выводе. Без защиты сто запросов дали сто обращений к базе. С блокировкой один запрос перестроил ключ (miss), 99 дождались его результата (waited) — время ответа почти то же, но база получила один запрос вместо ста. Устаревший ключ все сто запросов получили сразу (stale), а единственное фоновое перестроение видно по итоговому счётчику: 102 обращения вместо 101.
Согласованность: что и насколько можно устаревать
Любой кэш — это решение «здесь можно показать немного устаревшие данные». Сформулируйте его явно для каждого типа данных: сколько секунд устарелости допустимо и кто пострадает, если она больше.
Хорошо кэшируется
- Каталог, цены, расписание — читают часто, меняют редко
- Справочники и настройки
- Агрегаты для дашбордов, где минута задержки не важна
- Статика с версией в имени файла
Кэшировать осторожно или нельзя
- Остатки и слоты записи — устаревшее значение ведёт к двойной продаже
- Баланс, статус оплаты — источник истины только база
- Персональные данные в общем кэше — риск утечки
- Данные, которые меняются чаще, чем читаются
Частая жалоба пользователей — «сохранил, а вижу старое». Это нарушение согласованности «читаю свои записи» (read-your-writes). Решения: после записи отдавать автору данные из базы в обход кэша, обновлять кэш в том же запросе (write-through) или вернуть в ответе на запись уже новое значение, чтобы интерфейс не перечитывал его. Остатки, бронирования и деньги проверяйте в базе в момент операции, даже если на витрине показывается кэшированное значение, — защита от двойной записи разобрана в уроке о транзакциях.
В Next.js кэш данных встроен во фреймворк: директива use cache, профили срока жизни cacheLife и теги для инвалидации по событию cacheTag с updateTag и revalidateTag — тот же набор идей (TTL, stale-while-revalidate, инвалидация по событию), только на уровне фреймворка. Подробно — в уроке о серверных компонентах и загрузке данных.
Как измерять эффект
Кэш без метрик — источник догадок: непонятно, помогает ли он, и где именно появляются устаревшие данные. Метрики снимайте на уровне приложения, по исходам, которые возвращает кэш.
- Доля попаданий по каждому типу ключа:
hitиstaleпротивmiss,waitedиfallback - Время ответа p50 и p95 с кэшем и без — среднее скрывает медленные промахи
- Нагрузка на базу: число запросов и время самых тяжёлых — ради неё кэш и ставят
- Число
fallback: если растёт, перестроение не укладывается вWAIT_MS - Устарелость: сколько времени прошло между изменением и моментом, когда новое значение увидели
- Память Redis и число вытесненных ключей (
evicted_keysвINFO stats)
Счётчики самого Redis полезны, но читать их нужно осторожно. После демо выше INFO stats показал 401 попадание и 695 промахов, хотя по логике приложения из 400 защищённых запросов в базу пошёл один. Промахами Redis считает любой GET отсутствующего ключа: 100 запросов без защиты и повторные проверки 99 ожидающих запросов, которые опрашивают ключ каждые 25 мс. Поэтому keyspace_hits и keyspace_misses годятся для общего наблюдения за сервером, а решения о кэше принимайте по метрикам приложения.
Кэш начинают с вопроса «насколько данные могут устареть», а не «где взять Redis». Ответ на него определяет и уровень, и TTL, и способ инвалидации.
Упражнения
Дальше — разбор проектирования системы целиком, где кэш становится одним из решений наравне с очередями и репликами базы. Источник надёжных событий для инвалидации — transactional outbox из урока о событиях и очередях.
Как проверено3 · пройденосверено 11 октября 2026 г.
- запущено, работает
src/redis.ts, src/db.ts, src/cache.ts, src/products.ts, src/demo-stampede.ts, src/demo-invalidation.ts, src/http-server.ts, tsconfig.json
временный проект в scratchpad/kb/verify/keshirovanie-i-invalidaciya: redis@6.3.0 (node-redis), typescript@7.0.2, @types/node@24; `tsc -p .` без ошибок; Node 24.18 исполняет .ts напрямую; Redis 8.10.2 в docker redis:8.10-alpine (kb-redis-cache, --rm, 127.0.0.1:56390)
- запущено, работает
выводы demo-stampede, INFO stats, demo-invalidation, curl к http-server (в статье)
demo-stampede прогнан 5 раз: всегда naive 100 обращений, с блокировкой 1 (miss 1 / waited 99), stale 100 + 1 фоновое перестроение; INFO после CONFIG RESETSTAT: keyspace_hits 401, keyspace_misses 695 (в другом прогоне 794 — зависит от числа опросов). demo-invalidation: оба экземпляра получили инвалидацию, INCR catalog:version переключил ключи. curl: 200 с Cache-Control и ETag, повтор с If-None-Match → 304; несуществующий id → 404 с max-age=30. В выводе curl убраны заголовки Date/Connection/Keep-Alive/Transfer-Encoding (отмечено в подписи)
- запущено, работает
упражнения: N=500, снятие блокировки Lua-скриптом на Redis 7, jitter свежести
N=500: naive 500, locked 1 (waited 499). Вариант с redis.eval на redis:7-alpine (Redis 7.4.10, kb-redis7-cache): тот же результат, ключей lock:* не осталось. Jitter: 5 ключей с freshMs 60000 получили 55/59/63/65/66 с. DELEX IFEQ проверен в redis-cli: чужой токен → 0, свой → 1
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.