Redis — хранилище «ключ — значение» в оперативной памяти: команды выполняются за доли миллисекунды, а у каждого ключа может быть срок жизни (TTL). Поэтому его используют как кэш перед медленной базой или API, для счётчиков, лимитов запросов, коротких кодов и блокировок. Основная схема кэша — cache-aside: приложение сначала ищет данные в Redis, при промахе берёт их из источника и кладёт в кэш с TTL, а при изменении данных удаляет устаревший ключ.
После материала вы сможете
- поднять Redis 8.10 в Docker и работать со строками, хешами, множествами и списками через
redis-cli - объяснить, как работают
TTL, какие команды сбрасывают срок жизни и что происходит при нехватке памяти - реализовать кэш по схеме cache-aside на node-redis с инвалидацией и запасным путём при недоступном Redis
- сделать счётчик на
INCRи лимит запросов формы черезMULTIсINCRиEXPIRE NX
Что знать заранее
Проверено на
Redis 8.10.2 (redis:8.10-alpine)node-redis 6.3.0Node.js 24 LTS (24.18)Express 5.3.0
Сверено с документацией: 11 октября 2026 г.
Содержание
Что такое Redis и когда он нужен
Redis — сервер структур данных в оперативной памяти. Каждое значение лежит под строковым ключом, а значение может быть строкой, хешем, списком, множеством и другими типами. Данные в памяти, поэтому чтение и запись очень быстрые; на диск Redis может сохранять снимки (RDB) или журнал команд (AOF), но это настройка, а не гарантия по умолчанию.
Медленный запрос к базе или внешнему API
- Как решает Redis
- кэш с TTL: повторные чтения берутся из памяти
Счётчики просмотров, лайков, попыток входа
- Как решает Redis
- атомарный
INCRбез гонок между серверами
Ограничение частоты запросов (rate limit)
- Как решает Redis
- счётчик на окно времени, который сам исчезает по TTL
Короткоживущие данные: коды подтверждения, сессии
- Как решает Redis
SETсEX— ключ удалится сам
Очереди фоновых задач
- Как решает Redis
- списки и потоки; на них построены библиотеки вроде BullMQ
Redis не заменяет основную базу: это быстрый слой рядом с ней. Источник истины — PostgreSQL или другая СУБД, а в Redis лежат копии и временные данные, потеря которых не ломает бизнес. Как запустить PostgreSQL, разобрано в уроке направления; здесь его роль играет имитация медленной базы, чтобы урок не зависел от схемы.
Запуск в Docker и первые команды
Контейнер публикует порт только на 127.0.0.1: документация Redis по безопасности прямо говорит, что Redis рассчитан на доверенных клиентов в доверенной сети и его порт не должен быть доступен из интернета. Порт 56391 выбран, чтобы не конфликтовать с другим Redis на стандартном 6379.
docker run -d --rm --name redis-lab -p 127.0.0.1:56391:6379 redis:8.10-alpine
docker exec redis-lab redis-cli INFO server | grep redis_version
docker exec -it redis-lab redis-cli$ docker exec redis-lab redis-cli INFO server | grep redis_version
redis_version:8.10.2
$ docker exec redis-lab redis-cli PING
PONGСтрока — базовый тип Redis: последовательность байтов, в которой может лежать текст, JSON или число. Число в строке можно атомарно увеличивать: INCR прибавляет единицу, INCRBY — заданное значение. Атомарно значит, что два сервера приложения, увеличивающие счётчик одновременно, не потеряют ни одного увеличения.
> SET site:title "Demo Booking"
OK
> GET site:title
"Demo Booking"
> SET page:views 10
OK
> INCR page:views
(integer) 11
> INCRBY page:views 5
(integer) 16
> INCR site:title
(error) ERR value is not an integer or out of range
> GET missing:key
(nil)Хеши, множества и списки — коротко
Кроме строк, в повседневной работе нужны ещё три типа. Хеш — набор пар «поле — значение» под одним ключом, похож на объект или словарь. Множество — неупорядоченный набор уникальных строк, проверка принадлежности занимает O(1) — то есть не зависит от размера множества. Список — строки в порядке добавления.
> HSET service:1 title "Haircut" duration_min 60 price_kzt 8000
(integer) 3
> HGET service:1 price_kzt
"8000"
> HGETALL service:1
1) "title"
2) "Haircut"
3) "duration_min"
4) "60"
5) "price_kzt"
6) "8000"
> HINCRBY service:1 price_kzt 500
(integer) 8500
> SADD service:1:masters "aigerim" "dana" "aigerim"
(integer) 2
> SCARD service:1:masters
(integer) 2
> SISMEMBER service:1:masters "dana"
(integer) 1
> SMEMBERS service:1:masters
1) "aigerim"
2) "dana"
> RPUSH leads:recent "lead-101" "lead-102" "lead-103"
(integer) 3
> LRANGE leads:recent 0 -1
1) "lead-101"
2) "lead-102"
3) "lead-103"
> TYPE service:1
hash
> GET service:1
(error) WRONGTYPE Operation against a key holding the wrong kind of valueСтрока
- Команды
SET,GET,INCR,SET … EX- Когда использовать
- кэш ответа в JSON, счётчики, коды, флаги
Хеш
- Команды
HSET,HGET,HGETALL,HINCRBY- Когда использовать
- объект с полями, которые читают и меняют по отдельности; счётчики по категориям
Множество
- Команды
SADD,SISMEMBER,SCARD,SMEMBERS- Когда использовать
- уникальные значения: теги, участники, «уже обработан»
Список
- Команды
RPUSH,LPUSH,LRANGE,LTRIM- Когда использовать
- лента последних событий, простые очереди
Тип закрепляется за ключом при создании: строковую команду GET к хешу Redis выполнит с ошибкой WRONGTYPE. Порядок элементов в SMEMBERS не гарантирован — множество неупорядочено. Для договорённости об именах ключей используйте префиксы через двоеточие: service:1, cache:services:v1, rl:lead-form:<ip> — так ключи легко искать и удалять группами.
TTL и вытеснение
TTL (time to live) — срок жизни ключа в секундах. Задать его можно сразу при записи (SET key value EX 60) или позже командой EXPIRE. TTL возвращает оставшиеся секунды, -1 — если срока нет, -2 — если ключа нет. Сессия ниже показывает поведение, которое чаще всего удивляет:
> SET otp:demo-user "4821" EX 60
OK
> TTL otp:demo-user
(integer) 60
> TTL site:title
(integer) -1
> TTL no:such:key
(integer) -2
> EXPIRE site:title 2
(integer) 1
# ...3 seconds later
> GET site:title
(nil)
> TTL site:title
(integer) -2
> SET visits:today 1 EX 100
OK
> INCR visits:today
(integer) 2
> TTL visits:today
(integer) 99
> SET visits:today 5
OK
> TTL visits:today
(integer) -1
> SET lock:report 1 NX EX 30
OK
> SET lock:report 1 NX EX 30
(nil)
> CONFIG GET maxmemory
1) "maxmemory"
2) "0"
> CONFIG GET maxmemory-policy
1) "maxmemory-policy"
2) "noeviction"INCR,HSET,LPUSHменяют значение, но не трогают TTL;SET,GETSETиDELперезаписывают ключ и сбрасывают срок — так описано в документацииEXPIRE.EXPIREсNXставит срок, только если его ещё нет;XX— только если он уже есть;GTиLT— только если новый срок больше или меньше текущего.SET … NX EX 30записывает ключ, только если его нет: второй вызов вернул(nil). Это основа простой блокировки «отчёт уже строится».- Истёкшие ключи Redis удаляет двумя путями: при обращении к ключу и фоновой проверкой случайной выборки ключей со сроком жизни.
Отдельный вопрос — что будет, когда закончится память. По умолчанию maxmemory равен 0 (без ограничения на 64-битных системах), а политика maxmemory-policy — noeviction: при достижении лимита Redis не удаляет ключи, а возвращает ошибку на команды записи. Для сервера, который работает только как кэш, документация советует allkeys-lru как хороший выбор по умолчанию.
noeviction
- Что вытесняется
- ничего: записи получают ошибку (значение по умолчанию)
allkeys-lru
- Что вытесняется
- давно не использованные ключи — частый выбор для кэша
allkeys-lfu
- Что вытесняется
- редко используемые ключи
volatile-lru, volatile-ttl
- Что вытесняется
- только ключи с TTL: давно не использованные или с самым коротким остатком
allkeys-lrm, volatile-lrm
- Что вытесняется
- давно не изменявшиеся ключи (появились в Redis 8.6)
Cache-aside из Node.js
Cache-aside (кэш «сбоку», ленивое заполнение) — самая распространённая схема: приложение само обращается и к кэшу, и к базе. Чтение: ищем ключ в Redis; нашли — отдаём; не нашли — читаем из источника, кладём в Redis с TTL и отдаём. Запись: меняем данные в источнике и удаляем ключ кэша, чтобы следующее чтение взяло свежие данные.
- 01Приложение
- 02Redis
- 03База данных
Клиент — node-redis 6.3, его redis.io рекомендует для Node.js (ioredis поддерживается и документируется как более старый клиент; на нём, например, работает BullMQ из урока о фоновых задачах). В шестой версии, по руководству по переходу с v5, протокол по умолчанию — RESP3, минимальная версия Node.js — 20, а у команд появился таймаут по умолчанию 5 секунд.
mkdir redis-cache && cd redis-cache
npm init -y
npm pkg set type=module
npm install redis@6.3.0import { createClient } from "redis";
export const redis = createClient({
url: process.env.REDIS_URL ?? "redis://localhost:56391",
// while reconnecting, fail a waiting command after 500 ms instead of the default 5 s
commandOptions: { timeout: 500 },
});
redis.on("error", (err) => console.error("redis error:", err.code ?? err.name));
await redis.connect();// Stand-in for a slow database: in a real project this is SQL or an external API
const services = [
{ id: 1, title: "Haircut", priceKzt: 8000 },
{ id: 2, title: "Manicure", priceKzt: 10000 },
{ id: 3, title: "Massage", priceKzt: 15000 },
];
export const stats = { dbCalls: 0 };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function loadServicesFromDb() {
stats.dbCalls++;
await sleep(300); // a heavy query
return structuredClone(services);
}
export async function updatePriceInDb(id, priceKzt) {
await sleep(20);
const service = services.find((s) => s.id === id);
if (!service) throw new Error(`Service ${id} not found`);
service.priceKzt = priceKzt;
}import { redis } from "./redis.js";
// Cache-aside: try the cache; on a miss load from the source and store with a TTL
export async function cached(key, ttlSeconds, load) {
let redisOk = true;
try {
const hit = await redis.get(key);
if (hit !== null) return { data: JSON.parse(hit), source: "cache" };
} catch (err) {
redisOk = false; // Redis is unavailable: serve from the source, don't fail the request
console.warn(`cache read skipped: ${err.constructor.name}`);
}
const data = await load();
if (redisOk) {
await redis
.set(key, JSON.stringify(data), { expiration: { type: "EX", value: ttlSeconds } })
.catch((err) => console.warn(`cache write skipped: ${err.constructor.name}`));
}
return { data, source: "db" };
}
export async function invalidate(...keys) {
await redis.del(keys);
}import { cached, invalidate } from "./cache.js";
import { loadServicesFromDb, updatePriceInDb } from "./db.js";
const SERVICES_KEY = "cache:services:v1";
const SERVICES_TTL = 300; // 5 minutes: the upper bound of staleness if invalidation is missed
export function getServices() {
return cached(SERVICES_KEY, SERVICES_TTL, loadServicesFromDb);
}
export async function changePrice(id, priceKzt) {
await updatePriceInDb(id, priceKzt); // 1. write to the source of truth
await invalidate(SERVICES_KEY); // 2. only then drop the stale copy
}import { redis } from "./redis.js";
import { getServices, changePrice } from "./catalog.js";
import { stats } from "./db.js";
async function timed(label, fn) {
const t0 = performance.now();
const { data, source } = await fn();
const ms = (performance.now() - t0).toFixed(1);
console.log(`${label}: ${source} in ${ms} ms, Haircut = ${data[0].priceKzt}`);
}
await redis.del("cache:services:v1");
await timed("1st read", getServices);
await timed("2nd read", getServices);
console.log("TTL:", await redis.ttl("cache:services:v1"));
await changePrice(1, 9000);
console.log("after invalidation exists:", await redis.exists("cache:services:v1"));
await timed("3rd read", getServices);
await timed("4th read", getServices);
console.log("db calls:", stats.dbCalls);
await redis.close();$ node src/demo-cache.js
1st read: db in 302.5 ms, Haircut = 8000
2nd read: cache in 0.7 ms, Haircut = 8000
TTL: 300
after invalidation exists: 0
3rd read: db in 308.2 ms, Haircut = 9000
4th read: cache in 0.7 ms, Haircut = 9000
db calls: 2Обратите внимание на запись с TTL: { expiration: { type: "EX", value: ttlSeconds } }. Вариант { EX: 300 } из многих примеров в node-redis 6.3 помечен в типах как устаревший. То же с закрытием: в определениях типов quit() помечен как устаревший в пользу close(), который дожидается выполнения отправленных команд, а destroy() обрывает их сразу.
- Сначала запись в базу, потом удаление ключа. Если удалить ключ до записи, параллельный запрос успеет положить в кэш старые данные.
- Удалять надёжнее, чем перезаписывать: два параллельных изменения могут записать в кэш значения в неправильном порядке.
- TTL — страховка: даже если инвалидацию где-то забыли, устаревшие данные проживут не дольше 5 минут.
- Версия в ключе (
cache:services:v1) позволяет сменить формат данных: новая версия кода просто читает другой ключ.
Проверка запасного пути: прогрели кэш, остановили контейнер командой docker stop и повторили чтение. Клиент пытается переподключиться, команда GET ждёт отправки и через 500 мс отклоняется с TimeoutError, а данные приходят из базы:
warm: db in 305 ms
redis error: Error
redis error: ECONNREFUSED
redis error: ECONNREFUSED
redis error: ECONNREFUSED
cache read skipped: TimeoutError
redis error: ECONNREFUSED
redis stopped: db in 824 msЕщё одна проблема cache-aside — одновременный промах (cache stampede): когда популярный ключ истёк, сотни запросов одновременно идут в базу. Простые меры — разброс TTL (упражнение ниже) и блокировка на перестроение через SET … NX EX; подробный разбор стратегий инвалидации — в материале о кэшировании в направлении архитектуры.
Счётчики и rate limit
Счётчик — самый частый после кэша сценарий: INCR атомарен, поэтому десять копий приложения могут увеличивать один ключ без потерь. Для статистики по дням удобно держать один хеш на день и увеличивать поле услуги через HINCRBY, а срок жизни хеша задать один раз.
Rate limit (ограничение частоты) защищает форму заявки от спама: не больше 5 отправок в минуту с одного IP. Схема фиксированного окна: ключ rl:lead-form:<ip>, INCR на каждый запрос и TTL 60 секунд на первый запрос окна. Документация INCR предупреждает о гонке: если между INCR и EXPIRE процесс упадёт, ключ останется без срока и клиент будет заблокирован навсегда. Поэтому команды идут одним блоком MULTI/EXEC — Redis выполняет их подряд, не вклиниваясь командами других клиентов, — а EXPIRE с опцией NX не продлевает окно при каждом запросе.
import { redis } from "./redis.js";
// Views per service per day: one hash per day, kept for 8 days
export async function countView(serviceId, day = new Date().toISOString().slice(0, 10)) {
const key = `stats:views:${day}`;
const [views] = await redis
.multi()
.hIncrBy(key, String(serviceId), 1)
.expire(key, 8 * 24 * 3600, "NX")
.exec();
return views;
}
// Fixed window: at most `limit` requests per `windowSec` seconds for one client
export async function rateLimit(clientId, limit, windowSec) {
const key = `rl:lead-form:${clientId}`;
// MULTI/EXEC: INCR and EXPIRE are sent together, the counter can't be left without a TTL
const [count, , ttl] = await redis
.multi()
.incr(key)
.expire(key, windowSec, "NX") // NX: set the TTL only on the first request of the window
.ttl(key)
.exec();
return { allowed: count <= limit, count, retryAfter: count <= limit ? 0 : ttl };
}import { redis } from "./redis.js";
import { countView, rateLimit } from "./counters.js";
await redis.del(["rl:lead-form:203.0.113.7", "rl:lead-form:198.51.100.20", "stats:views:2026-10-12"]);
for (const id of [1, 1, 2, 1, 3]) await countView(id, "2026-10-12");
console.log("views:", await redis.hGetAll("stats:views:2026-10-12"));
console.log("views TTL:", await redis.ttl("stats:views:2026-10-12"));
// The lead form: 5 submissions per 60 seconds per IP (addresses from documentation ranges)
for (let i = 1; i <= 7; i++) {
const r = await rateLimit("203.0.113.7", 5, 60);
console.log(`request ${i}: ${r.allowed ? "200 OK" : `429, retry after ${r.retryAfter}s`} (count ${r.count})`);
}
const other = await rateLimit("198.51.100.20", 5, 60);
console.log(`another IP: ${other.allowed ? "200 OK" : "429"} (count ${other.count})`);
await redis.close();$ node src/demo-limits.js
views: { '1': '3', '2': '1', '3': '1' }
views TTL: 691200
request 1: 200 OK (count 1)
request 2: 200 OK (count 2)
request 3: 200 OK (count 3)
request 4: 200 OK (count 4)
request 5: 200 OK (count 5)
request 6: 429, retry after 60s (count 6)
request 7: 429, retry after 60s (count 7)
another IP: 200 OK (count 1)> TTL rl:lead-form:203.0.113.7
(integer) 60
> HGETALL stats:views:2026-10-12
1) "1"
2) "3"
3) "2"
4) "1"
5) "3"
6) "1"
$ docker exec redis-lab redis-cli --scan --pattern 'rl:*'
rl:lead-form:198.51.100.20
rl:lead-form:203.0.113.7Фиксированное окно простое, но неточное на границе: клиент может отправить 5 запросов в конце одной минуты и 5 в начале следующей. Для формы заявки этого достаточно; для платного API обычно берут скользящее окно или token bucket. Мы отдельно проверили сброс: с окном 2 секунды третий запрос был отклонён, а после паузы 2,1 секунды счёт начался заново.
Чего не хранить в Redis и частые ошибки
- Единственную копию важных данных — заказы, платежи, заявки: со снимками RDB можно потерять последние минуты записей, при AOF с fsync раз в секунду — до секунды
- Большие объекты в одном ключе: команды выполняются по очереди, и огромный
GETзадерживает остальные запросы - Данные без TTL «на всякий случай» — память конечна, а политика по умолчанию
noevictionначнёт отвечать ошибками на запись - Персональные данные без необходимости и без срока жизни
- Секреты и пароли в открытом виде; доступ к Redis — только из приложения, по паролю или ACL
Счётчик лимита никогда не сбрасывается
- Причина
INCRбезEXPIREилиEXPIREвнеMULTI- Что делать
MULTIсINCRиEXPIRE … NX
TTL пропал после обновления ключа
- Причина
SETбезEXперезаписывает ключ вместе со сроком- Что делать
- передавать срок при каждой записи
Пользователи видят старую цену
- Причина
- кэш не инвалидирован или удалён до записи в базу
- Что делать
- запись в базу, затем
DEL; TTL как страховка
WRONGTYPE Operation against a key…
- Причина
- команда не того типа или конфликт имён ключей
- Что делать
- префиксы в именах:
cache:,rl:,stats:
Ошибки записи при заполненной памяти
- Причина
- политика
noevictionи достигнутmaxmemory - Что делать
- задать
maxmemoryиallkeys-lruдля кэша
Сайт падает, когда Redis недоступен
- Причина
- ошибка кэша не обработана
- Что делать
- запасной путь в источник и короткий таймаут
Про сравнение Redis с документными и другими NoSQL-базами — в отдельном материале направления. Главное правило урока: Redis ускоряет и разгружает основную базу, но не отвечает за сохранность данных, если вы явно не настроили для этого persistence, резервные копии и репликацию.
Упражнения
Как проверено4 · пройденосверено 11 октября 2026 г.
- запущено, работает
redis-cli: строки, хеши, множества, списки, TTL, SET NX, CONFIG GET
контейнер kb-redis-keshirovanie (в статье — redis-lab), redis:8.10-alpine = Redis 8.10.2; команды выполнены через docker exec redis-cli --no-raw, вывод в статье — реальный (строка '# ...3 seconds later' вместо sleep 3)
- запущено, работает
src/redis.js, db.js, cache.js, catalog.js, demo-cache.js
Node.js 24.18, node-redis 6.3.0, node --check + запуск; промах ~300 мс, попадание <1 мс, инвалидация; запасной путь проверен остановкой контейнера (TimeoutError через 500 мс, данные из источника). Отдельно: при docker pause команда не завершилась за 20 с — таймаут node-redis действует только до отправки команды (по исходникам commands-queue.js 6.3.0), это отражено в статье
- запущено, работает
src/counters.js, demo-limits.js (HINCRBY + EXPIRE NX, rate limit MULTI INCR/EXPIRE NX/TTL)
запуск на Redis 8.10.2: 5 разрешено, 6-й и 7-й — 429 с TTL 60; сброс окна проверен отдельным прогоном с окном 2 с (true true false, затем true после 2,1 с)
- запущено, работает
упражнения: getService + инвалидация двух ключей, ttlWithJitter, Express-middleware 429
ex1: db → cache, TTL 120, после changePrice — db и 11000; ex2: TTL 329 312 306 302 306; ex3: Express 5.3.0, 7 запросов fetch — 201×5, 429 с Retry-After: 60 ×2
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.