Базы данных Redis: структуры данных, персистентность, кластер, паттерны кэширования
0%

Redis: структуры данных, персистентность, кластер, паттерны кэширования

Redis: структуры данных, персистентность, кластер, паттерны кэширования

Про Redis обычно говорят «это кэш». Это верно примерно так же, как «Python — это скриптовый язык для склейки»: формально не ложь, но описывает лишь самое скучное применение. Redis — это сервер структур данных в оперативной памяти, где сервер берёт на себя ровно то, что тяжело сделать в распределённой системе самостоятельно: атомарность операций над разделяемым состоянием.

Ключевая интуиция такая. У вас есть отсортированное множество, счётчик, очередь или множество уникальных элементов. Пока приложение работает в одном процессе — это структура данных в памяти. Как только процессов становится тридцать, в трёх датацентрах, с автоскейлингом — эта структура должна где-то жить, и все операции над ней должны быть атомарными. Класть её в PostgreSQL можно, но UPDATE counters SET n = n + 1 со ста узлов — это блокировка строки, MVCC-версии, вакуум и латентность в миллисекундах. Redis выполняет INCR за микросекунды и без единой блокировки, потому что у него нет конкуренции: он однопоточный по исполнению команд.

Эта статья — про то, как Redis устроен на самом деле, чем вы платите за его скорость, и где проходит граница между «идеальный инструмент» и «вы только что построили распределённый сингл-поинт-оф-фейлор без долговечности».

Однопоточная модель: источник и скорости, и всех проблем

Redis обрабатывает команды в одном потоке через классический цикл событий (epoll на Linux). Никаких мьютексов на структурах данных, никакого копирования при чтении, никаких сложных решений о когерентности кэшей процессора. Команда — это вызов функции над структурой в памяти.

Из этой картинки следуют почти все продакшн-правила 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 раз.

Внутреннее устройство Sorted Set: dict плюс skiplist

Тип Компактная кодировка Порог переключения Кодировка после порога
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

Два вывода, которые экономят реальные деньги:

  1. Переключение кодировки необратимо. Хеш, разросшийся до 129 полей и ставший hashtable, останется hashtable, даже если вы удалите поля обратно. Только пересоздание ключа вернёт listpack.
  2. Мелкая нарезка данных на много ключей — дорого. Каждый ключ верхнего уровня стоит примерно 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 не является для этих данных системой записи истины.

Репликация: как реплика догоняет мастер

Три параметра, которые решают, будет ли у вас в 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

Sentinel — это внешние наблюдатели поверх обычной пары master/replica. Они не проксируют трафик: клиент спрашивает у Sentinel адрес мастера и подключается напрямую. Плюсы — простота и то, что все команды, включая мультиключевые и Lua над произвольными ключами, работают. Минус — данные не шардируются: весь датасет должен помещаться в память одного узла, и вся запись идёт на одно ядро.

Cluster — шардирование на 16384 слота. Слот ключа — CRC16(key) mod 16384. Каждый мастер владеет диапазоном слотов; клиент, попав не туда, получает редирект.

Разница между 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 в проде, нарушение которых больно:

  1. Все ключи передавайте через KEYS, а не конструируйте внутри скрипта. Иначе Cluster не сможет определить слот, и скрипт сломается при шардировании.
  2. Скрипт должен быть детерминированным и коротким. Он блокирует весь сервер; lua-time-limit (5 с) не убивает скрипт, а лишь начинает отвечать BUSY другим клиентам, и остановить его можно только SCRIPT KILL или, если скрипт уже писал, SHUTDOWN NOSAVE.
  3. Используйте EVALSHA с фолбэком на EVAL — клиентские библиотеки обычно делают это сами (register_script выше именно так и работает).

С версии 7.0 есть Redis Functions (FUNCTION LOAD) — библиотеки, которые живут на сервере, реплицируются и переживают рестарт, в отличие от кэша скриптов. Для стабильного набора серверной логики это правильнее, чем EVALSHA.

Паттерны кэширования

Cache-aside и его отказы

Cache-aside (lazy loading) — дефолт: приложение само читает кэш, при промахе идёт в БД и заполняет кэш. Проблемы начинаются на масштабе:

Cache stampede (dogpile). Популярный ключ истёк; тысяча параллельных запросов одновременно промахнулись и одновременно пошли в БД. БД ложится. Три рабочих средства:

  1. Мьютекс на перезаполнение (схема выше): только один поток идёт в БД, остальные ждут или отдают устаревшее значение.
  2. Вероятностное раннее обновление (XFetch). Каждый читатель с вероятностью, растущей по мере приближения TTL к нулю, обновляет значение заранее. Отличная статья с обоснованием — Vattani, Chierichetti, Lowenstein, «Optimal Probabilistic Cache Stampede Prevention» (VLDB 2015, vldb.org/pvldb/vol8/p886-vattani.pdf).
  3. Джиттер 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 решает задачу «быстро и в памяти», но не решает задачу «много данных, распределённо, с записью на десятках узлов». Следующая статья — про семейство, которое строилось именно под это, с явным отказом от джойнов и ad-hoc запросов в обмен на линейную масштабируемость записи: Cassandra, ScyllaDB и wide-column: модель запросов и компромиссы.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов