
Кеш — это быстрый слой хранения недавно вычисленных или часто запрашиваемых данных. Он сокращает время отклика, разгружает базу данных и внешние сервисы, а значит — экономит деньги на инфраструктуре и повышает стабильность под нагрузкой.
Плюсы, которые чувствует бизнес:
Кешировать можно на нескольких уровнях:
Что кешировать:
Чего не стоит:
Для большинства продуктовых API подходит cache-aside с коротким TTL и контролируемой инвалидацией.
Главная боль кеша — инвалидация. Надёжные приёмы:
Если ценность протухла у многих одновременно, клиенты кинулись в источник — это «шторм» (cache stampede). Спасают:
Минимальный набор метрик:
Пример оценки окупаемости: было 1200 RPS к БД, стало 300 RPS при 75% hit ratio; кластер БД можно уменьшить на один узел или не апгрейдить в след. квартале. Если минус один узел — это −800$ в месяц, Redis обошёлся в +200$, выгода 600$ ежемесячно, а отклик API ускорился с 180 до 40 мс.
Ниже — рабочий пример, который можно вставить в ваш сервис. Он:
npm i redis pino
export REDIS_URL="redis://localhost:6379"
export APP_ENV="prod"
export APP_NAME="myapp"
// cache.ts
import { createClient, RedisClientType } from 'redis';
import pino from 'pino';
const log = pino({ level: process.env.LOG_LEVEL || 'info' });
let client: RedisClientType | null = null;
export async function getRedis(): Promise<RedisClientType> {
if (client) return client;
client = createClient({ url: process.env.REDIS_URL });
client.on('error', (err) => log.error({ err }, 'Redis error'));
await client.connect();
return client;
}
export type CacheEntry<T> = {
payload: T;
exp: number; // свежесть до этого времени (ms)
swr: number; // допускаем отдавать "протухшее" до этого времени (ms)
};
export type CacheOptions = {
ttlSec: number; // сколько секунд ответ считается свежим
staleSec?: number; // сколько секунд можно отдавать "устаревшее", пока идёт фоновое обновление
jitterPct?: number; // случайный джиттер TTL, в долях (например, 0.1)
prefix?: string; // префикс приложения
version?: number; // общая версия пространства ключей
};
const inflight = new Map<string, Promise<any>>();
function nowMs() { return Date.now(); }
function withJitter(ttlSec: number, jitterPct: number | undefined) {
if (!jitterPct) return ttlSec;
const delta = ttlSec * jitterPct;
const jitter = (Math.random() * 2 - 1) * delta; // [-delta, +delta]
return Math.max(1, Math.round(ttlSec + jitter));
}
export function buildKey(parts: Array<string | number | undefined | null>): string {
const safe = parts
.filter((p) => p !== undefined && p !== null)
.map(String)
.map((p) => p.replace(/\s+/g, '_')) // пробелы в подчёрки
.map((p) => p.replace(/[^a-zA-Z0-9:_\-\.]/g, '')); // упростим ключ
return safe.join(':');
}
export async function getTagVersion(tag: string): Promise<number> {
const r = await getRedis();
const key = buildKey(['ver', tag]);
const v = await r.get(key);
if (v) return Number(v);
await r.set(key, '1');
return 1;
}
export async function bumpTagVersion(tag: string): Promise<number> {
const r = await getRedis();
const key = buildKey(['ver', tag]);
const v = await r.incr(key);
return v;
}
export async function withCache<T>(
key: string,
loader: () => Promise<T>,
opts: CacheOptions
): Promise<T> {
const r = await getRedis();
const ttlSec = withJitter(opts.ttlSec, opts.jitterPct);
const staleSec = withJitter(opts.staleSec || 0, opts.jitterPct);
const maxAgeSec = ttlSec + staleSec;
const raw = await r.get(key);
const t = nowMs();
if (raw) {
const entry = JSON.parse(raw) as CacheEntry<T>;
if (t < entry.exp) {
return entry.payload; // свежий ответ
}
if (t < entry.swr) {
// отдаём устаревшее и пытаемся обновить в фоне
void refreshInBackground(key, loader, ttlSec, staleSec).catch((e) => log.warn({ e, key }, 'bg refresh failed'));
return entry.payload;
}
// полностью протухло — упадём в единый запрос
}
return await singleflightRefresh(key, loader, ttlSec, staleSec);
}
async function refreshInBackground<T>(
key: string,
loader: () => Promise<T>,
ttlSec: number,
staleSec: number
): Promise<void> {
// не запускаем параллельных обновлений
if (inflight.has(key)) return;
await singleflightRefresh(key, loader, ttlSec, staleSec);
}
async function singleflightRefresh<T>(
key: string,
loader: () => Promise<T>,
ttlSec: number,
staleSec: number
): Promise<T> {
const exist = inflight.get(key);
if (exist) return exist as Promise<T>;
const p = (async () => {
const r = await getRedis();
const payload = await loader();
const t = nowMs();
const entry: CacheEntry<T> = {
payload,
exp: t + ttlSec * 1000,
swr: t + (ttlSec + staleSec) * 1000,
};
await r.set(key, JSON.stringify(entry), { EX: ttlSec + staleSec });
return payload;
})()
.catch((e) => {
throw e;
})
.finally(() => inflight.delete(key));
inflight.set(key, p);
return p;
}
// app.ts
import http from 'http';
import url from 'url';
import pino from 'pino';
import { withCache, buildKey, getTagVersion, bumpTagVersion } from './cache';
const log = pino({ level: process.env.LOG_LEVEL || 'info' });
// Заглушка источника данных: дорогой запрос в БД/внешний API
async function loadUserFromDb(tenantId: string, userId: string) {
await new Promise((r) => setTimeout(r, 120)); // симуляция 120 мс
return { id: userId, tenantId, name: 'Alice', updatedAt: new Date().toISOString() };
}
function appName() { return process.env.APP_NAME || 'app'; }
function env() { return process.env.APP_ENV || 'dev'; }
async function getUser(tenantId: string, userId: string) {
const tag = `users:${tenantId}`; // тег на уровень пользователей арендатора
const ver = await getTagVersion(tag);
const key = buildKey([appName(), env(), 'users', `v${ver}`, `t${tenantId}`, `id${userId}`]);
const user = await withCache(
key,
() => loadUserFromDb(tenantId, userId),
{ ttlSec: 60, staleSec: 120, jitterPct: 0.1 }
);
return user;
}
async function invalidateUsersOfTenant(tenantId: string) {
const tag = `users:${tenantId}`;
const v = await bumpTagVersion(tag);
log.info({ tenantId, v }, 'users cache invalidated by version bump');
}
const server = http.createServer(async (req, res) => {
const { pathname, query } = url.parse(req.url || '', true) as any;
try {
if (pathname === '/api/users' && req.method === 'GET') {
const { tenantId, id } = query;
if (!tenantId || !id) {
res.statusCode = 400;
res.end('tenantId and id are required');
return;
}
const user = await getUser(String(tenantId), String(id));
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(user));
return;
}
if (pathname === '/api/users/invalidate' && req.method === 'POST') {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', async () => {
const { tenantId } = JSON.parse(body || '{}');
if (!tenantId) {
res.statusCode = 400;
res.end('tenantId is required');
return;
}
await invalidateUsersOfTenant(String(tenantId));
res.end('ok');
});
return;
}
res.statusCode = 404;
res.end('not found');
} catch (e: any) {
log.error({ err: e }, 'request failed');
res.statusCode = 500;
res.end('internal error');
}
});
server.listen(3000, () => {
log.info('Server listening on :3000');
});
Как это работает:
# простой прогрев 1000 пользователей арендатора t1
for i in $(seq 1 1000); do curl -s "http://localhost:3000/api/users?tenantId=t1&id=$i" > /dev/null & done
wait
Кеширование в приложении с Redis — быстрый и экономичный способ ускорить продукт и стабилизировать нагрузку. Схема cache-aside с грамотными ключами, версионированием и защитой от шторма даёт:
Дайте этому неделею инженерного времени — и у вас появится контролируемый инструмент экономии и роста SLA. Пример кода выше можно брать как основу и дорабатывать под свой домен.