Redis: структуры данных, персистентность, кластер, паттерны кэширования
Про Redis обычно говорят «это кэш». Это верно примерно так же, как «Python — это скриптовый язык для склейки»: формально не ложь, но описывает лишь самое скучное применение. Redis — это сервер структур данных в оперативной памяти, где сервер берёт на себя ровно то, что тяжело сделать в распределённой системе самостоятельно: атомарность операций над разделяемым состоянием.
Ключевая интуиция такая. У вас есть отсортированное множество, счётчик, очередь или множество уникальных элементов. Пока приложение работает в одном процессе — это структура данных в памяти. Как только процессов становится тридцать, в трёх датацентрах, с автоскейлингом — эта структура должна где-то жить, и все операции над ней должны быть атомарными. Класть её в PostgreSQL можно, но UPDATE counters SET n = n + 1 со ста узлов — это блокировка строки, MVCC-версии, вакуум и латентность в миллисекундах. Redis выполняет INCR за микросекунды и без единой блокировки, потому что у него нет конкуренции: он однопоточный по исполнению команд.
Эта статья — про то, как Redis устроен на самом деле, чем вы платите за его скорость, и где проходит граница между «идеальный инструмент» и «вы только что построили распределённый сингл-поинт-оф-фейлор без долговечности».
Однопоточная модель: источник и скорости, и всех проблем
Redis обрабатывает команды в одном потоке через классический цикл событий (epoll на Linux). Никаких мьютексов на структурах данных, никакого копирования при чтении, никаких сложных решений о когерентности кэшей процессора. Команда — это вызов функции над структурой в памяти.
парсинг RESP"] R --> IO1{"io-threads > 1?"} IO1 -->|"да"| P1["Параллельный парсинг
в потоках ввода-вывода"] IO1 -->|"нет"| X P1 --> X["ГЛАВНЫЙ ПОТОК:
исполнение команд строго по одной"] X --> PROP["Пропагация: AOF-буфер + поток репликации"] PROP --> W["Запись ответов клиентам"] W --> CRON["serverCron: вытеснение по TTL,
рехеширование, проверка maxmemory"] CRON --> E X -.->|"одна команда O_N
блокирует ВСЁ"| STALL["p99 всех клиентов
= длительность этой команды"]
Из этой картинки следуют почти все продакшн-правила Redis:
- Сложность команды — это не абстракция, а прямая задержка для всех остальных.
KEYS *на базе с 10 миллионами ключей выполняется секунды, и всё это время сервер не отвечает никому. Не «медленнее отвечает» — не отвечает вообще. - Redis не масштабируется по ядрам вертикально. 64-ядерный сервер даёт ровно ту же пропускную способность по командам, что и 4-ядерный, — упор в частоту одного ядра. Единственный путь горизонтального роста — шардирование (Cluster), а
io-threads(с версии 6.0) распараллеливает только чтение/запись сокетов и парсинг, но не исполнение. - Пропускная способность определяется числом сетевых обходов, а не работой. Простой
GETстоит серверу ~1 мкс CPU, но круговой обход по сети внутри датацентра — 100–200 мкс. Отсюда: пайплайнинг и мультиключевые команды дают выигрыш в 10–50 раз не потому, что Redis становится быстрее, а потому, что вы перестаёте платить за сеть.
Практические ориентиры на современном железе (одно ядро, in-DC клиент): 80–150 тыс. простых команд в секунду без пайплайнинга; 700 тыс. – 1.5 млн с пайплайном по 16–64 команды; p50 около 0.1–0.2 мс, p99 — 0.3–0.8 мс при здоровой нагрузке. Если ваш p99 — 50 мс, проблема почти наверняка не в Redis, а в одной вашей команде на большом ключе или в фоновом fork.
Структуры данных: чем Redis отличается от Memcached
Memcached — это словарь «строка → байты». Redis — набор типизированных структур, каждая со своим набором атомарных операций. Именно это, а не скорость, делает его особенным: атомарность операции нельзя воспроизвести на стороне клиента без блокировок.
Сжатая таблица с честной сложностью — это то, что стоит держать в голове постоянно:
| Тип | Ключевые операции | Сложность | Типичное применение | Ловушка |
|---|---|---|---|---|
| String | GET/SET/INCR |
O(1) | кэш, счётчики, флаги | APPEND в цикле → ключ на гигабайты |
| List | LPUSH/RPOP/BLPOP |
O(1) с концов | очередь, буфер | LRANGE 0 -1 — O(N), LINSERT — O(N) |
| Hash | HSET/HGET/HDEL |
O(1) на поле | объект с частичным обновлением | HGETALL на 100k полей — O(N) и мегабайты в ответ |
| Set | SADD/SISMEMBER |
O(1) | уникальность, теги | SINTER — O(N·M), SMEMBERS — O(N) |
| Sorted Set | ZADD/ZSCORE |
O(log N) | рейтинги, отложенные задачи | ZRANGEBYSCORE — O(log N + M), где M — результат |
| Bitmap | SETBIT/BITCOUNT |
O(1) / O(N) | DAU по битам | SETBIT key 2^32 мгновенно выделит 512 МБ |
| HyperLogLog | PFADD/PFCOUNT |
O(1) | approx. уникальные | ошибка ~0.81%, PFCOUNT на нескольких ключах — дорого |
| Stream | XADD/XREADGROUP |
O(1) / O(log N) | журнал событий, шина | без MAXLEN растёт неограниченно |
Ключевой приём проектирования: выбирайте тип по требуемой операции, а не по форме данных. Нужен «топ-100 за последний час» — это Sorted Set со счётом, равным timestamp, и периодическим ZREMRANGEBYSCORE. Нужна дедупликация событий за сутки — это Set с TTL или, если точность не критична, HyperLogLog за 12 КБ вместо гигабайта. Нужна очередь с подтверждением обработки — это Stream с consumer group, а не List (List теряет сообщение, если воркер умер после RPOP).
Внутренние кодировки: где живёт память
Один и тот же логический тип Redis хранит по-разному в зависимости от размера. Это не деталь реализации — это разница в памяти в 5–10 раз.
| Тип | Компактная кодировка | Порог переключения | Кодировка после порога |
|---|---|---|---|
| String | int / embstr |
≤ 44 байт и не число → embstr |
raw (отдельный SDS-буфер) |
| Hash | listpack |
hash-max-listpack-entries 128 / -value 64 |
hashtable |
| List | listpack внутри quicklist |
list-max-listpack-size 128 |
quicklist из нескольких узлов |
| Set | intset / listpack |
set-max-intset-entries 512 |
hashtable |
| Sorted Set | listpack |
zset-max-listpack-entries 128 / -value 64 |
skiplist + dict |
# Проверяем реальную кодировку — это первое, что нужно сделать при разборе перерасхода памяти
redis-cli> RPUSH small a b c
redis-cli> OBJECT ENCODING small
"listpack"
redis-cli> HSET user:1 name Ivan age 33
redis-cli> OBJECT ENCODING user:1
"listpack" # ~90 байт на весь объект
redis-cli> MEMORY USAGE user:1
(integer) 96
Два вывода, которые экономят реальные деньги:
- Переключение кодировки необратимо. Хеш, разросшийся до 129 полей и ставший
hashtable, останетсяhashtable, даже если вы удалите поля обратно. Только пересоздание ключа вернётlistpack. - Мелкая нарезка данных на много ключей — дорого. Каждый ключ верхнего уровня стоит примерно 50–100 байт накладных расходов (запись в глобальном dict, объект
robj, SDS-заголовок ключа, возможная запись в словаре TTL). Миллион ключейuser:{id}:name— это ~80 МБ чистого оверхеда. Классический приём Instagram: сгруппировать по 1000 идентификаторов в один хешbucket:{id/1000}в кодировкеlistpack— расход памяти падает в 5–10 раз (instagram-engineering: Storing hundreds of millions of simple key-value pairs).
# Антипаттерн: миллион ключей по одному полю
# r.set(f"u:{uid}:email", email) -> ~85 байт оверхеда на каждое значение
# Паттерн: бакетирование в listpack-хеши
BUCKET = 1000
def set_email(r, uid: int, email: str) -> None:
"""Кладём в хеш-бакет: 1000 значений в одном ключе остаются listpack'ом,
пока полей <= hash-max-listpack-entries (поднимите его до 1024)."""
r.hset(f"u:bucket:{uid // BUCKET}", str(uid % BUCKET), email)
def get_email(r, uid: int) -> str | None:
return r.hget(f"u:bucket:{uid // BUCKET}", str(uid % BUCKET))
Плата за приём: listpack — линейный массив, поиск поля внутри него O(N), а не O(1). При 1000 полей это несколько сотен наносекунд — незаметно. При 100 000 — уже катастрофа. Держите бакеты в пределах 512–1024 элементов.
Вытеснение и политика памяти
Redis без maxmemory — это мина замедленного действия: он будет расти, пока OOM killer ядра не убьёт процесс (и вместе с ним всё, что не успело попасть в RDB). Первое правило продакшена: maxmemory выставлен всегда, и он не больше 50–60% физической памяти хоста, если включена персистентность, — потому что fork для сохранения снапшота требует запаса на copy-on-write.
| Политика | Что делает | Когда брать |
|---|---|---|
noeviction |
ошибка на запись при переполнении | Redis как БД/очередь: терять данные нельзя |
allkeys-lru |
вытесняет любой ключ по приближённому LRU | чистый кэш, TTL не у всех ключей |
allkeys-lfu |
вытесняет наименее частые (LFU) | кэш с «горячим хвостом»: единичный скан не выбьет горячие данные |
volatile-lru / -lfu / -ttl |
вытесняет только ключи с TTL | смешанное хранилище: часть данных обязана жить |
allkeys-random |
случайный ключ | равномерное распределение обращений, экономия CPU |
Важная честность: LRU и LFU в Redis приближённые. Точный LRU потребовал бы двусвязного списка на все ключи и обновления его на каждом обращении. Redis вместо этого сэмплирует maxmemory-samples (по умолчанию 5) случайных ключей и выбрасывает худший из них, поддерживая пул кандидатов. При 10 сэмплах результат практически неотличим от точного LRU, при 5 — близок (redis.io: Key eviction). LFU использует 8-битный счётчик с логарифмическим приращением и затуханием по времени (lfu-decay-time) — за счёт этого он устойчив к «скану всей базы ночным джобом», который в LRU выбивает весь горячий набор.
maxmemory 12gb
maxmemory-policy allkeys-lfu
maxmemory-samples 10
lfu-log-factor 10
lfu-decay-time 1
Отдельная боль — фрагментация. mem_fragmentation_ratio из INFO memory — это RSS / used_memory. Значение 1.5 означает, что jemalloc держит в 1.5 раза больше памяти, чем полезных данных: типично после массового удаления ключей разного размера. Лечится активной дефрагментацией (activedefrag yes, доступна с jemalloc) или рестартом реплики с переключением. Значение меньше 1 — это не «хорошо», это признак того, что часть памяти ушла в своп; такой инстанс уже даёт задержки в десятки миллисекунд.
Персистентность: что именно вы теряете
Redis предлагает два механизма, и оба заслуживают понимания на уровне «что произойдёт при выдёргивании кабеля».
RDB — бинарный снапшот всего датасета. Redis делает fork(), дочерний процесс обходит память и пишет компактный файл. Родитель продолжает обслуживать клиентов; страницы, которые он модифицирует, копируются ядром (copy-on-write).
AOF — журнал команд. Каждая изменяющая команда дописывается в файл в формате RESP. Периодически журнал переписывается (BGREWRITEAOF), чтобы не расти бесконечно. С версии 7.0 AOF — это не один файл, а каталог с манифестом: базовый снапшот плюс инкрементальные файлы.
| Свойство | RDB | AOF (everysec) |
AOF (always) |
|---|---|---|---|
| Окно потери данных | до save-интервала (минуты) |
до ~2 секунд | ~0 (одна команда) |
| Скорость рестарта | быстрая (десятки секунд на 10 ГБ) | медленная — проигрывание журнала | медленная |
| Размер на диске | компактный | в 2–5 раз больше | в 2–5 раз больше |
| Влияние на p99 | пик при fork |
почти нет | падение throughput в 10–100 раз |
| Пригодность для бэкапа | отличная (один файл) | плохая | плохая |
Гибридный режим — правильный дефолт с версии 4.0: aof-use-rdb-preamble yes. При перезаписи AOF получает RDB-снапшот в начале файла и текстовые команды после него. Итог — быстрая загрузка RDB плюс малое окно потерь AOF.
# Продакшн-профиль «данные важны, но это не банк»
appendonly yes
appendfsync everysec
aof-use-rdb-preamble yes
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 256mb
no-appendfsync-on-rewrite no # yes даёт всплеск потерь во время rewrite
save 900 1
save 300 100
stop-writes-on-bgsave-error yes # НЕ выключать: маскирует полный диск
rdbcompression yes
rdbchecksum yes
Самая частая продакшн-катастрофа: fork и copy-on-write
fork() в Linux не копирует память сразу — он копирует таблицы страниц. Для инстанса на 32 ГБ это ~64 МБ таблиц, и сам fork занимает 50–500 мс, в течение которых Redis полностью заморожен. На виртуалках с медленной подсистемой памяти (особенно на EC2 с гипервизором старых поколений) — до нескольких секунд. Дальше каждая страница, модифицированная родителем, дублируется: при интенсивной записи RSS может вырасти почти вдвое.
Практические меры:
latency-monitor-threshold 100иredis-cli --latency-history; событиеforkвидно вINFO statsкакlatest_fork_usec.- Отключить transparent huge pages: с THP страница копируется по 2 МБ вместо 4 КБ, и всплеск задержек растёт на порядок.
echo never > /sys/kernel/mm/transparent_hugepage/enabled— это прямая рекомендация документации Redis. vm.overcommit_memory = 1вsysctl— иначеforkможет провалиться при нехватке «обещанной» памяти, и снапшот тихо не сделается.- Переносить снапшоты на реплику: мастер работает с
save ""и без AOF, реплика делает и RDB, и AOF. Мастер не платит заfork, бэкапы есть. Плата — при одновременной потере мастера и реплики теряется всё.
Отдельно стоит проговорить: асинхронная репликация не даёт долговечности. WAIT 1 100 заставит клиента дождаться подтверждения от одной реплики, но это не консенсус: реплика подтверждает получение данных в память, а не запись на диск, и при разделении сети возможен откат. Redis сознательно выбрал производительность вместо линеаризуемости — анализ Jepsen прямо показывает потерю подтверждённых записей при failover (aphyr.com: Redis). Если ваш ответ на вопрос «что если потеряем 3 секунды записей?» — «катастрофа», Redis не является для этих данных системой записи истины.
Репликация: как реплика догоняет мастер
копятся в client-output-buffer реплики M-->>R: накопленный поток команд Note over R,M: полная синхронизация — минуты, всплеск CPU/сети/RSS end loop постоянно M-->>R: поток команд (асинхронно, без подтверждения) R->>M: REPLCONF ACK
Три параметра, которые решают, будет ли у вас в 3 часа ночи каскад полных ресинков:
repl-backlog-size(по умолчанию 1 МБ — абсурдно мало для нагруженного мастера). Если реплика отвалилась на 30 секунд при трафике записи 20 МБ/с, ей нужно 600 МБ бэклога, чтобы догнать частично. Иначе — полный ресинк сfork, который может уронить мастер по памяти. Ставьте 256 МБ – 1 ГБ.repl-backlog-ttl— сколько держать бэклог после отключения последней реплики.client-output-buffer-limit replica 512mb 128mb 60— если реплика не успевает потреблять, мастер разрывает соединение по достижении лимита, и начинается новый полный ресинк. Классическая петля смерти: медленная реплика → разрыв → полный ресинк → ещё больше нагрузки → разрыв.
repl-diskless-sync yes передаёт RDB прямо в сокет, минуя диск, — обязателен, если диск медленный или его мало.
Реплики по умолчанию доступны для чтения (replica-read-only yes), но читать с них можно только то, что переживёт устаревание: лаг измеряется в INFO replication через master_repl_offset минус offset реплики. При этом истёкшие по TTL ключи на реплике логически невидимы, но физически удаляются только по команде DEL от мастера — поэтому память реплики может быть больше, чем ожидается.
Высокая доступность: Sentinel против Cluster
дольше down-after-milliseconds SDOWN --> Здоров: PONG вернулся SDOWN --> ODOWN: кворум Sentinel'ов
подтвердил недоступность ODOWN --> ВыборЛидера: Sentinel'ы выбирают,
кто проводит failover (Raft-подобно) ВыборЛидера --> Промоушен: лучшая реплика по
priority / offset / runid Промоушен --> Переконфигурация: остальные реплики
переключаются на нового мастера Переконфигурация --> Здоров: клиенты узнают адрес
через Sentinel pub/sub Промоушен --> SplitBrain: старый мастер жив
в изолированном сегменте SplitBrain --> Здоров: старый мастер видит новую
конфигурацию и становится репликой —
его записи ТЕРЯЮТСЯ
Sentinel — это внешние наблюдатели поверх обычной пары master/replica. Они не проксируют трафик: клиент спрашивает у Sentinel адрес мастера и подключается напрямую. Плюсы — простота и то, что все команды, включая мультиключевые и Lua над произвольными ключами, работают. Минус — данные не шардируются: весь датасет должен помещаться в память одного узла, и вся запись идёт на одно ядро.
Cluster — шардирование на 16384 слота. Слот ключа — CRC16(key) mod 16384. Каждый мастер владеет диапазоном слотов; клиент, попав не туда, получает редирект.
(CLUSTER SHARDS) — редирект случается один раз C->>B: GET user:42 B-->>C: "Ivan" Note over A,B: во время ресharding'а слота C->>A: GET user:99 A-->>C: -ASK 1234 10.0.0.2:6379 C->>B: ASKING + GET user:99 B-->>C: значение Note over C: ASK — временный редирект на один запрос,
карту слотов обновлять НЕЛЬЗЯ
Разница между MOVED и ASK — то, на чём чаще всего ломаются самописные клиенты: MOVED означает «слот теперь навсегда там, обнови карту», ASK — «этот конкретный ключ уже переехал, но слот ещё мигрирует; сходи туда один раз с префиксом ASKING».
Главное ограничение Cluster: мультиключевые операции работают только внутри одного слота. MGET a b c, SINTER, Lua-скрипт с несколькими ключами, транзакция MULTI — всё это упадёт с CROSSSLOT, если ключи в разных слотах. Решение — hash tags: часть ключа в фигурных скобках участвует в хешировании.
# Разные слоты — CROSSSLOT при любой мультиключевой операции
SET user:42:profile ...
SET user:42:sessions ...
# Один слот: хешируется только "user:42"
SET {user:42}:profile ...
SET {user:42}:sessions ...
MGET {user:42}:profile {user:42}:sessions # работает
Плата за hash tags — риск перекоса: если положить под один тег гигантского клиента, его шард станет горячим, и переливать его придётся вручную.
| Критерий | Standalone + Sentinel | Cluster | Внешний прокси (Envoy/twemproxy) |
|---|---|---|---|
| Объём данных | до памяти одного узла | линейно по числу шардов | линейно |
| Мультиключевые команды | все | только внутри слота | ограниченно |
| Сложность клиента | низкая (нужна поддержка Sentinel) | нужен cluster-aware клиент | нулевая |
| Failover | автоматический, секунды | автоматический, cluster-node-timeout |
зависит от прокси |
| SELECT / несколько БД | 16 баз | только БД 0 | — |
| Эксплуатация | простая | ресhardинг, перекос слотов, gossip-трафик | ещё один компонент в проде |
Практический совет: не берите Cluster, пока данные помещаются в один узел. 200–300 ГБ на инстанс сегодня реальны. Cluster решает задачу масштаба и добавляет целый класс проблем (CROSSSLOT, ресhardинг, сложность отладки); брать его «на вырост» — типичная преждевременная сложность.
Атомарность: транзакции, Lua и Functions
MULTI/EXEC — это не транзакции в смысле ACID. Это батч: команды копятся и выполняются последовательно без вклинивания других клиентов. Отката нет: если пятая команда упадёт (например, INCR на строке), первые четыре останутся применёнными. DISCARD работает только до EXEC.
Оптимистичная блокировка делается через WATCH: если наблюдаемый ключ изменился до EXEC, транзакция возвращает nil, и клиент повторяет цикл.
Практичнее — Lua. Скрипт выполняется атомарно целиком, потому что Redis однопоточный; внутри доступны все команды и вся логика.
-- rate_limit.lua: скользящее окно на sorted set.
-- KEYS[1] — ключ окна, ARGV[1] — now (мс), ARGV[2] — размер окна (мс), ARGV[3] — лимит
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
-- Выбрасываем всё, что вышло за окно
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window)
local count = redis.call('ZCARD', KEYS[1])
if count >= limit then
-- Возвращаем, через сколько мс освободится слот
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
return {0, math.ceil((tonumber(oldest[2]) + window) - now)}
end
redis.call('ZADD', KEYS[1], now, now .. ':' .. math.random())
redis.call('PEXPIRE', KEYS[1], window)
return {1, 0}
import time, redis
r = redis.Redis()
limiter = r.register_script(open("rate_limit.lua").read())
def allow(user_id: str, limit: int = 100, window_ms: int = 60_000) -> tuple[bool, int]:
"""Возвращает (разрешено, сколько_ждать_мс). Один сетевой обход, атомарно."""
ok, retry_ms = limiter(keys=[f"rl:{user_id}"], args=[int(time.time() * 1000), window_ms, limit])
return bool(ok), int(retry_ms)
Три правила Lua в проде, нарушение которых больно:
- Все ключи передавайте через
KEYS, а не конструируйте внутри скрипта. Иначе Cluster не сможет определить слот, и скрипт сломается при шардировании. - Скрипт должен быть детерминированным и коротким. Он блокирует весь сервер;
lua-time-limit(5 с) не убивает скрипт, а лишь начинает отвечатьBUSYдругим клиентам, и остановить его можно толькоSCRIPT KILLили, если скрипт уже писал,SHUTDOWN NOSAVE. - Используйте
EVALSHAс фолбэком наEVAL— клиентские библиотеки обычно делают это сами (register_scriptвыше именно так и работает).
С версии 7.0 есть Redis Functions (FUNCTION LOAD) — библиотеки, которые живут на сервере, реплицируются и переживают рестарт, в отличие от кэша скриптов. Для стабильного набора серверной логики это правильнее, чем EVALSHA.
Паттерны кэширования
Cache-aside и его отказы
~0.2 мс"] HIT -->|"нет: miss"| LOCK{"Взять лок
SET lock NX PX 5000"} LOCK -->|"получен"| DB["Запрос в PostgreSQL
~5-50 мс"] LOCK -->|"занят другим"| WAIT["Подождать 20-50 мс
и перечитать кэш"] WAIT --> HIT DB --> FILL["SET key value EX ttl
с джиттером ±10%"] FILL --> UNLOCK["DEL lock"] --> SERVE DB -.->|"пусто в БД"| NEG["Закэшировать 'нет данных'
с коротким TTL 30-60 с"] NEG --> SERVE
Cache-aside (lazy loading) — дефолт: приложение само читает кэш, при промахе идёт в БД и заполняет кэш. Проблемы начинаются на масштабе:
Cache stampede (dogpile). Популярный ключ истёк; тысяча параллельных запросов одновременно промахнулись и одновременно пошли в БД. БД ложится. Три рабочих средства:
- Мьютекс на перезаполнение (схема выше): только один поток идёт в БД, остальные ждут или отдают устаревшее значение.
- Вероятностное раннее обновление (XFetch). Каждый читатель с вероятностью, растущей по мере приближения TTL к нулю, обновляет значение заранее. Отличная статья с обоснованием — Vattani, Chierichetti, Lowenstein, «Optimal Probabilistic Cache Stampede Prevention» (VLDB 2015, vldb.org/pvldb/vol8/p886-vattani.pdf).
- Джиттер TTL. Никогда не ставьте всем ключам ровный
EX 3600: после массового прогрева они истекут в одну секунду.EX = base ± random(10%)размазывает пик.
import random, time, math, json
def get_with_xfetch(r, db, key: str, ttl: int = 300, beta: float = 1.0):
"""Вероятностное раннее обновление: чем ближе истечение и чем дороже
пересчёт, тем выше шанс обновить значение до промаха."""
packed = r.get(key)
if packed is not None:
obj = json.loads(packed)
delta = obj["delta"] # сколько миллисекунд занял пересчёт в прошлый раз
expiry = obj["expiry"] # unix-время истечения, мс
now = time.time() * 1000
# Порог Vattani et al.: обновляем, если now - delta*beta*ln(rand) >= expiry
if now - delta * beta * math.log(random.random()) < expiry:
return obj["value"]
t0 = time.time()
value = db.fetch(key)
delta = (time.time() - t0) * 1000
jittered = int(ttl * random.uniform(0.9, 1.1))
r.set(key, json.dumps({
"value": value, "delta": delta,
"expiry": time.time() * 1000 + jittered * 1000,
}), ex=jittered + 60) # физический TTL с запасом относительно логического
return value
Cache penetration. Запросы на несуществующие идентификаторы никогда не кэшируются и всегда бьют в БД — это готовый вектор DoS. Лечится негативным кэшированием (короткий TTL на маркер «нет данных») плюс Bloom-фильтром существующих ключей на входе.
Инвалидация. Самый недооценённый выбор архитектуры:
| Стратегия | Как работает | Плюсы | Минусы |
|---|---|---|---|
| TTL-only | ждём истечения | предельно просто, самовосстанавливается | окно устаревших данных |
Явный DEL при записи |
приложение чистит кэш | быстрое схождение | гонка «прочитал старое → записал в кэш после DEL» |
| Версионированный ключ | user:42:v7 из счётчика версий |
нет гонок, атомарная «инвалидация» пачки | мусор остаётся до вытеснения |
| Write-through | пишем в кэш и в БД синхронно | кэш всегда свеж | латентность записи, две точки отказа |
| Write-behind | пишем в кэш, в БД асинхронно | быстрая запись | потеря данных при падении, сложность |
Гонка при явном DEL реальна и коварна: поток A читает БД (старое значение), поток B пишет в БД и делает DEL, поток A кладёт в кэш прочитанное старое значение — и оно живёт до истечения TTL. Промышленное решение — паттерн delayed double delete (удалить, обновить БД, через ~500 мс удалить ещё раз) либо инвалидация через журнал изменений БД (Debezium/логическая репликация), что мы разбирали в контексте репликации и шардирования.
Распределённая блокировка: честный разговор
Каноническое SET lock:resource <random-token> NX PX 30000 плюс освобождение через Lua, сравнивающий токен, — это правильно и достаточно для 95% случаев:
-- unlock.lua: снимаем лок, только если он всё ещё наш
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
Чего этот механизм не даёт: гарантии взаимного исключения. Процесс может взять лок, попасть в stop-the-world паузу GC на 40 секунд, лок истечёт, его возьмёт другой процесс, а первый проснётся и продолжит считать себя владельцем. Алгоритм Redlock на пяти независимых узлах эту проблему не решает — он завязан на синхронность часов и таймингов; развёрнутая критика — Martin Kleppmann, «How to do distributed locking» (martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html), с ответом Salvatore Sanfilippo (antirez.com/news/101).
Практический вывод: используйте Redis-лок как оптимизацию эффективности (не хотим, чтобы два воркера делали одну и ту же дорогую работу), а не как гарантию корректности. Если корректность критична — нужен fencing token: монотонный INCR, который передаётся в защищаемый ресурс, и ресурс отвергает операции с устаревшим номером. Если и это невозможно — берите систему с настоящим консенсусом (etcd, ZooKeeper), о которых речь в статье про графовые и key-value БД.
Эксплуатация: чек-лист и типичные аварии
Команды, которые нельзя запускать на проде:
| Опасно | Почему | Замена |
|---|---|---|
KEYS pattern |
O(N) с блокировкой всего сервера | SCAN 0 MATCH p COUNT 100 |
DEL bigkey |
O(N) освобождения памяти в главном потоке | UNLINK (освобождение в фоновом потоке) |
FLUSHALL / FLUSHDB |
блокировка на всю базу | FLUSHALL ASYNC |
SMEMBERS / HGETALL / LRANGE 0 -1 |
O(N) плюс гигантский ответ | SSCAN/HSCAN, постраничный LRANGE |
SORT на большом списке |
O(N log N) в главном потоке | сортировать в приложении или ZSET |
SAVE |
синхронный снапшот, полная заморозка | BGSAVE |
Big keys и hot keys. Ключ на 500 МБ ломает всё: его нельзя атомарно удалить без паузы, он делает миграцию слота в Cluster неподъёмной, а client-output-buffer реплики переполненным. Ищите регулярно:
# Оценка распределения размеров без блокировки (сэмплирует через SCAN)
redis-cli --bigkeys
redis-cli --memkeys # по фактическому MEMORY USAGE
redis-cli --hotkeys # требует maxmemory-policy allkeys-lfu
# Профиль латентности и источники всплесков
redis-cli --latency-history -i 5
redis-cli latency reset && redis-cli latency latest
redis-cli slowlog get 20
# Оффлайн-анализ RDB без нагрузки на прод
rdb --command memory dump.rdb --bytes 1024 -f report.csv # redis-rdb-tools
Метрики, за которыми надо следить, и что они означают:
Метрика (INFO) |
Порог тревоги | Что значит |
|---|---|---|
used_memory / maxmemory |
> 85% | скоро начнётся вытеснение или отказы записи |
mem_fragmentation_ratio |
> 1.5 или < 1.0 | фрагментация jemalloc / уход в своп |
evicted_keys |
растёт постоянно | кэш мал либо есть ключи без TTL |
keyspace_hits / (hits+misses) |
< 0.8 для кэша | кэш не окупается: проверьте TTL и ключевание |
blocked_clients |
> 0 неожиданно | висящие BLPOP/XREAD BLOCK |
rdb_last_bgsave_status |
не ok |
снапшоты не делаются — бэкапов нет |
latest_fork_usec |
> 200 000 | fork замораживает сервер на 200+ мс |
sync_full |
растёт | реплики впадают в полные ресинки: мал backlog |
rejected_connections |
> 0 | упёрлись в maxclients или лимит файловых дескрипторов |
Настройки хоста, которые забывают: vm.overcommit_memory=1, THP выключены, net.core.somaxconn=1024 и соответствующий tcp-backlog, ulimit -n минимум maxclients + 32, отключённый swap для процесса Redis, tcp-keepalive 300. TLS даёт 30–50% просадки пропускной способности — закладывайте это в capacity planning, а не обнаруживайте на релизе.
Redis, Valkey и альтернативы: честное сравнение
В марте 2024 Redis Ltd. сменила лицензию с BSD на дуальную RSALv2/SSPLv1 — это не OSI-одобренные открытые лицензии, и они запрещают предоставлять Redis как управляемый сервис. В ответ сообщество и крупные вендоры (AWS, Google, Oracle, Ericsson) форкнули последнюю BSD-версию 7.2.4 в проект Valkey под эгидой Linux Foundation. В мае 2025 Redis 8.0 добавил AGPLv3 как третью опцию, но раскол уже произошёл: Valkey — это де-факто продолжение открытой линии, с собственными улучшениями (многопоточная обработка ядра, экономия памяти на встроенных словарях).
| Система | Модель | Сильная сторона | Когда НЕ брать |
|---|---|---|---|
| Redis (8.x) | однопоточный, типизированные структуры | богатейший набор структур, модули (Search, JSON, Bloom, TimeSeries), зрелая экосистема | лицензия критична для вашего юрлица; нужна долговечность уровня БД |
| Valkey | форк 7.2.4, BSD | лицензионная чистота, многопоточность на чтении, поддержка гипероблаков | нужны фирменные модули Redis Stack |
| Memcached | многопоточный, только строки | предельно простой, отлично масштабируется по ядрам, меньше памяти на простой кэш | нужны структуры, персистентность, репликация |
| Dragonfly | многопоточный, shared-nothing внутри процесса | вертикальное масштабирование на 64 ядрах, совместим по протоколу | нужна экосистема модулей; молодость проекта |
| KeyDB | многопоточный форк Redis | drop-in для legacy-нагрузок | проект замедлился после покупки Snap |
| Hazelcast / Ignite | JVM, распределённый грид | вычисления рядом с данными, строгие транзакции | JVM-стек, тяжёлая эксплуатация |
Про стоимость. Управляемый Redis у облачных провайдеров стоит примерно в 2–4 раза дороже эквивалентной виртуальной машины: инстанс с 26 ГБ памяти в режиме multi-AZ обходится в районе 400–600 долларов в месяц против 120–200 за сравнимую VM. За эту разницу вы получаете failover, патчи, снапшоты и отсутствие ночных дежурств. Разумная эвристика: до трёх инстансов берите управляемый сервис (стоимость инженерного времени выше разницы в счёте), после десяти считайте своё — экономия начинает окупать выделенного человека. И отдельно: для чистого кэша считайте не только Redis, но и «а нужен ли кэш вообще» — правильный индекс в PostgreSQL часто убирает необходимость в кэше целиком, вместе со всем классом проблем инвалидации.
Когда Redis — неправильный ответ
Это самая полезная часть статьи, потому что Redis очень легко применить не туда.
- Как основную БД для данных, потерю которых нельзя пережить. Даже с AOF
alwaysу вас нет ни консенсуса, ни атомарности между шардами, ни точки восстановления на момент времени. Для этого есть PostgreSQL и распределённые SQL-системы. - Когда датасет заметно больше RAM. Redis не рассчитан на работу с диском как на уровень хранения. Нужен key-value поверх диска — смотрите RocksDB и LMDB в статье про key-value и графовые БД.
- Для сложных запросов и аналитики. Нет джойнов, нет ad-hoc фильтрации по полям (RediSearch частично решает, но это уже другой продукт с другой ценой эксплуатации). Аналитика — это ClickHouse.
- Как надёжная шина сообщений. Pub/Sub — это fire-and-forget: отсутствующий подписчик просто не получит сообщение, доставка не гарантируется. Streams заметно лучше (consumer groups, подтверждения, повторная доставка), но у Kafka принципиально другие гарантии долговечности и хранения; см. трек data engineering.
- Когда данных мало, а нагрузка невелика. Один Redis — это ещё один процесс, ещё один failover, ещё один источник инцидентов. Кэш в памяти приложения (с TTL) часто и быстрее, и надёжнее.
Мини-итог
- Redis однопоточный по исполнению: это даёт бесплатную атомарность и предсказуемость, но означает, что любая O(N)-команда — это простой всего сервера. Масштабирование — только шардированием.
- Типы Redis выбираются по нужной атомарной операции. Внутренние кодировки (
listpack,intset,skiplist) определяют расход памяти; бакетирование мелких ключей в хеши экономит в разы, но необратимое переключение кодировки — реальная ловушка. - Персистентность не делает Redis долговечным хранилищем: RDB теряет минуты, AOF
everysec— до двух секунд, репликация асинхронная и может откатить подтверждённые записи при failover. Считайте эти окна явно, а не надейтесь. - Sentinel — когда данные помещаются в один узел (это до сотен гигабайт, чаще чем кажется). Cluster — когда нет; вместе с ним приходят
CROSSSLOT, hash tags и ресhardинг. - Кэширование — это в первую очередь инвалидация и защита от лавины: джиттер TTL, мьютекс или XFetch против stampede, негативное кэширование против penetration, версионированные ключи против гонок с
DEL. - Redis-лок — оптимизация, а не гарантия взаимного исключения. Нужна корректность — нужен fencing token или настоящий консенсус.
Источники
- Redis Documentation — команды со сложностями, конфигурация, персистентность; особенно разделы Persistence и Key eviction.
- Redis Cluster Specification — слоты,
MOVED/ASK, failover, гарантии и их отсутствие. - Valkey documentation и объявление Linux Foundation о форке.
- Kyle Kingsbury, «Jepsen: Redis» и «Jepsen: Redis-Raft» — экспериментальные границы гарантий.
- Martin Kleppmann, «How to do distributed locking»; ответ antirez — «Is Redlock safe?».
- Vattani, Chierichetti, Lowenstein, «Optimal Probabilistic Cache Stampede Prevention», VLDB 2015.
- Instagram Engineering: Storing hundreds of millions of simple key-value pairs in Redis — приём с бакетированием хешей.
- Salvatore Sanfilippo, antirez.com — архив заметок автора Redis о внутреннем устройстве и решениях по дизайну.
- Martin Kleppmann, «Designing Data-Intensive Applications», главы 5 и 9 — репликация и границы согласованности.
Что дальше
Redis решает задачу «быстро и в памяти», но не решает задачу «много данных, распределённо, с записью на десятках узлов». Следующая статья — про семейство, которое строилось именно под это, с явным отказом от джойнов и ad-hoc запросов в обмен на линейную масштабируемость записи: Cassandra, ScyllaDB и wide-column: модель запросов и компромиссы.