Как делать11 октября 2026 г.11 мин чтения
Архитектура ПОШаг 8 из 10

Кэширование и инвалидация

Коротко

Кэш — это копия данных ближе к потребителю: в браузере, на 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 г.

Содержание
  1. 01Где можно кэшировать: от браузера до базы
  2. 02HTTP-кэш: Cache-Control, ETag и CDN
  3. 03Стратегии кэша приложения
  4. 04Инвалидация: TTL, событие и версия ключа
  5. 05Cache stampede: блокировка и stale-while-revalidate
  6. 06Согласованность: что и насколько можно устаревать
  7. 07Как измерять эффект
  8. 08Упражнения

Где можно кэшировать: от браузера до базы

Запрос пользователя проходит несколько слоёв, и на каждом можно сохранить готовый ответ. Чем ближе копия к пользователю, тем она дешевле в чтении и тем сложнее её отозвать: ответ в браузере сервер удалить не может, ответ на CDN — только через API провайдера, ключ в Redis — одной командой.

  1. 01БраузерЧастный кэш пользователя: Cache-Control, ETag
  2. 02CDNОбщий кэш у края сети: s-maxage, purge через API
  3. 03ПриложениеL1 в памяти процесса и L2 в Redis
  4. 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.

src/http-server.ts
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'));
API каталога на node:http: Cache-Control для браузера и CDN, сильный ETag из хеша тела и ответ 304
terminal
$ 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"
Реальный вывод curl (служебные заголовки Date, Connection, Keep-Alive и Transfer-Encoding убраны): повторный запрос с ETag получает 304 без тела
  • Статика с хешем в имени (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.

  1. TTL

    Задаёт, сколько устарелости допустимо. Для цены в каталоге — минуты, для курса валют — секунды, для справочника городов — часы.

  2. Событие

    Изменение в базе порождает сообщение «товар изменён», и обработчик удаляет ключ. Надёжный источник таких событий — transactional outbox из урока о событиях и очередях.

  3. Версия ключа

    Массовое изменение (переоценка каталога, новый формат данных) — одна команда INCR catalog:version вместо удаления тысяч ключей.

Когда экземпляров приложения несколько и у каждого есть кэш в памяти (L1) поверх общего Redis (L2), удалить ключ в Redis мало: копии остались в памяти остальных процессов. Руководство Microsoft отмечает именно это — локальный кэш у каждого экземпляра свой и быстро расходится с остальными. Ниже каждый экземпляр подписан на канал Redis Pub/Sub и удаляет ключ из L1 при сообщении. Подготовка проекта и модуль кэша — в разделе о stampede; здесь — сервис товаров поверх него.

src/products.ts
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 };
}
Экземпляр приложения: L1 в памяти, L2 в Redis, инвалидация по сообщению и версия каталога в ключе
src/demo-invalidation.ts
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();
Два экземпляра, изменение цены и массовое обновление через версию
terminal
$ 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 db
Реальный вывод: после изменения цены оба экземпляра сбросили L1, после INCR все товары читаются по новым ключам

У инвалидации есть и гонки. Классическая для 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: ключ живёт дольше, чем свеж. Срок свежести хранится внутри значения; когда он истёк, запрос сразу получает старое значение, а перестроение идёт в фоне — тоже под блокировкой. Пользователь не ждёт базу вообще.
bash
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
Redis 8.10 в контейнере (только 127.0.0.1, --rm удалит его после остановки) и проект
tsconfig.json
{
  "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 напрямую
src/redis.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>>;
Подключение node-redis 6.3; ошибки соединения логируются, а не роняют процесс
src/db.ts
// 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;
}
Имитация медленной базы: 200 мс на запрос и счётчик обращений
src/cache.ts
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;
}
Кэш с защитой от stampede: блокировка на перестроение, ожидание результата и stale-while-revalidate
  • Токен блокировки случайный, а снимается она командой DELEX … IFEQ token (Redis 8.4+): если загрузка длилась дольше LOCK_MS и блокировку уже взял другой запрос, мы не удалим чужую. Для Redis 7 то же делает Lua-скрипт из документации команды SET — см. упражнение.
  • LOCK_MS должен быть больше самой долгой загрузки, а WAIT_MS ограничивает ожидание: если перестроение зависло, запрос идёт в базу сам (fallback), а не висит.
  • Документация Redis считает простую блокировку на SET NX недостаточной там, где важна строгая взаимоисключаемость, и предлагает алгоритм Redlock. Для кэша это не критично: худший исход — две загрузки вместо одной, данные не портятся.
  • Отсутствующий товар тоже кэшируется (null): так повторные запросы несуществующих id не долбят базу.
src/demo-stampede.ts
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();
100 одновременных запросов к одному ключу: без защиты, на холодный, свежий и устаревший ключ
terminal
$ 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
Реальный вывод: без защиты 100 обращений к базе, с блокировкой — одно; устаревший ключ отдан за миллисекунды, перестроение в фоне — ещё одно обращение

Что видно в выводе. Без защиты сто запросов дали сто обращений к базе. С блокировкой один запрос перестроил ключ (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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.