Модуль: Базы данных · Уровень: Senior

TL;DR#

  • NoSQL — это не «отсутствие схемы», а класс хранилищ с разными моделями данных, оптимизированных под конкретные паттерны доступа: Key-Value (Redis, DynamoDB), Document (MongoDB), Wide-column (Cassandra, HBase), Graph (Neo4j). Выбор движется от паттернов запросов, а не от данных.
  • SQL vs NoSQL — не «или-или». SQL даёт строгую схему, JOIN-ы, ACID, нормализацию; NoSQL — горизонтальное масштабирование, гибкую схему, денормализацию под чтение. По CAP при сетевом разделении (P неизбежно) выбираешь между C и A; многие NoSQL — это AP с BASE-семантикой (eventual consistency), но это конфигурируемо (Cassandra tunable consistency, DynamoDB strongly consistent reads).
  • Redis — однопоточное (для исполнения команд) in-memory хранилище структур данных. Сила не в «кэше», а в богатых структурах: String, List, Hash, Set, ZSET, Bitmap, HyperLogLog, Stream, geo. Атомарность каждой команды — следствие single-threaded модели.
  • Персистентность: RDB (снапшоты, компактно, риск потери данных между снапшотами), AOF (журнал команд, durability до appendfsync everysec/always), гибрид (RDB-преамбула + AOF-хвост) — дефолт в Redis 7.
  • Кэширование: cache-aside (самый частый), write-through/write-back; проблемы — thundering herd / cache stampede (защита: lock на пересчёт, jitter в TTL, early recomputation), stale data / invalidation.
  • Distributed lock: SET key val NX PX ttl + unlock через Lua (проверка владельца). Redlock спорен (критика Martin Kleppmann — fencing token нужен всё равно); для строгих гарантий не полагайся только на TTL-lock.
  • Rate limiting: token bucket / sliding window log / sliding window counter на Redis + Lua для атомарности.
  • Масштабирование: репликация (async, replica может отставать), Redis Cluster (16384 хеш-слота, без multi-key операций между слотами без hash tags). Eviction: allkeys-lru, allkeys-lfu, volatile-*, noeviction.

Теория#

Часть 1. NoSQL#

Что такое NoSQL и зачем он появился#

Реляционные СУБД оптимизированы под нормализованные данные, произвольные запросы (ad-hoc) и строгую консистентность на одном узле. Проблемы возникают при:

  • горизонтальном масштабировании — шардирование SQL с JOIN-ами и распределёнными транзакциями дорого;
  • гибкой/эволюционирующей схеме — миграции ALTER TABLE на больших таблицах болезненны;
  • огромных объёмах записи с предсказуемыми паттернами доступа.

NoSQL — это семейство хранилищ, каждое из которых жертвует какой-то «универсальностью» SQL ради масштабирования и/или производительности на конкретном паттерне. Ключевая идея senior-уровня: в NoSQL ты проектируешь данные под запросы (query-first / access-pattern-driven design), а не под сущности.

Типы NoSQL и их модели данных#

ТипМодель данныхПримерыСильная сторонаКейсы
Key-Valuekey → opaque valueRedis, DynamoDB, RiakO(1) доступ по ключу, простое шардированиекэш, сессии, профили, корзины, feature flags
Documentkey → JSON/BSON документMongoDB, Couchbaseвложенные структуры, частичные запросы по полям, индексыкаталоги, CMS, профили с переменной структурой
Wide-columnrow key → column families (sparse, wide)Cassandra, HBase, ScyllaDBогромная пропускная запись, линейное масштабирование, time-seriesлоги, метрики, IoT, event store, feed
Graphузлы + рёбра + свойстваNeo4j, JanusGraphобход связей (traversal), запросы по отношениямсоцграфы, рекомендации, fraud detection, knowledge graph

Key-Value (Redis/DynamoDB). Простейшая модель: положил по ключу — достал по ключу. DynamoDB добавляет partition key + sort key (composite), GSI/LSI для альтернативных паттернов доступа. Главное ограничение — нет богатых запросов: всё проектируется вокруг ключей. В DynamoDB single-table design — норма.

Document (MongoDB). Документ — самодостаточная единица (агрегат в терминах DDD). Сильно: db.users.find({ "address.city": "Berlin" }), вложенные массивы, частичные обновления. Подводный камень — соблазн денормализации без понимания границ агрегата приводит к большим документам и дублированию. У MongoDB есть multi-document транзакции (с 4.0), но их использование часто сигнал о неверной модели.

Wide-column (Cassandra/HBase). Не «таблица с колонками» в SQL-смысле. Это map of maps: partition key → (clustering columns → values). Запросы эффективны только по первичному ключу и в порядке clustering columns. Cassandra: masterless, tunable consistency (ONE, QUORUM, ALL), оптимизирована под write-heavy (LSM-tree). Моделирование — строго от запросов: одна таблица на паттерн доступа, денормализация ожидаема.

Graph (Neo4j). Когда «связи — это данные первого класса». Запрос «друзья друзей, купившие X» в SQL — каскад JOIN-ов с экспоненциальной деградацией; в графовой БД — index-free adjacency, обход за O(степень узла). Cypher: MATCH (a:User)-[:FRIEND*2]->(b)-[:BOUGHT]->(p:Product {id:$id}) RETURN b.

Когда NoSQL, а когда SQL#

КритерийSQLNoSQL
Схемастрогая, нормализованная, на записьгибкая/schema-on-read, денормализованная под чтение
Запросыпроизвольные ad-hoc, JOIN, агрегациипо заранее известным паттернам доступа
Масштабированиевертикальное; шардинг сложенгоризонтальное «из коробки»
ТранзакцииACID, multi-row, multi-tableограниченные (часто на уровне одного ключа/документа/partition)
Консистентностьstrong по умолчаниючасто eventual (настраиваемо)
ЦелостностьFK, constraints на уровне БДна уровне приложения

Бери SQL по умолчанию, если: нужны произвольные аналитические запросы, строгие транзакции/инварианты между сущностями, объёмы укладываются в один узел или разумный шардинг, схема стабильна. Postgres + JSONB сегодня закрывает значительную долю кейсов «нам нужен документный store».

Бери NoSQL, когда есть конкретная причина: предсказуемый паттерн доступа + потребность в линейном масштабировании (Cassandra/DynamoDB), in-memory скорость и структуры данных (Redis), естественно графовая задача (Neo4j), variable schema документов (MongoDB).

CAP, PACELC, BASE vs ACID#

  • CAP: при сетевом разделении (P) система может гарантировать либо консистентность (C), либо доступность (A), но не обе. P в распределённой системе неизбежно — значит реальный выбор C vs A во время partition. Важно: CAP про поведение при разделении, а не «вообще». «CA-система» — это, по сути, одноузловая.
  • PACELC (расширение Abadi): if P then C/A, Else (в нормальном режиме) — latency (L) vs consistency (C). Например DynamoDB/Cassandra по умолчанию PA/EL (доступность и низкая задержка), Spanner — PC/EC (консистентность, ценой latency).
  • ACID (Atomicity, Consistency, Isolation, Durability) — гарантии классических СУБД.
  • BASE (Basically Available, Soft state, Eventual consistency) — философия многих NoSQL: система всегда отвечает, состояние может быть временно несогласованным, со временем сходится.

Важный нюанс senior-уровня: «NoSQL = eventual consistency» — упрощение. Cassandra даёт tunable consistency (R + W > N → strong), DynamoDB — strongly consistent reads опцией, MongoDB — read/write concern (majority). Консистентность — это спектр и часто per-operation настройка, а не свойство БД целиком.

Типичные ошибки выбора NoSQL#

  • «Schemaless = без проектирования» — на деле схема перемещается в код и становится неявной; разные версии документов сосуществуют и порождают баги.
  • Выбор под хайп, а не под паттерн доступа — MongoDB «потому что JSON», хотя нужны транзакции и JOIN.
  • Игнор паттернов запросов в Cassandra/DynamoDB — спроектировали по сущностям, потом нужен запрос «не по ключу» → full scan / дорогие GSI.
  • Ожидание strong consistency от AP-системы по умолчанию — read-after-write баги.
  • Графовая задача в реляционке (рекурсивные CTE на глубоких обходах деградируют) или наоборот, таблично-аналитическая задача в графе.
  • Redis как primary database без понимания durability — потеря данных при сбое между fsync.

Часть 2. Redis#

Модель исполнения: single-threaded#

Redis исполняет команды в одном потоке (event loop на epoll/kqueue). Следствия:

  • каждая команда атомарна — нет гонок между командами, не нужны блокировки для одиночной операции;
  • нет накладных расходов на синхронизацию между потоками; узкое место — CPU одного ядра и сеть;
  • долгие команды блокируют всёKEYS *, SMEMBERS на огромном сете, неоптимальный Lua-скрипт «вешают» весь инстанс. Используй SCAN/HSCAN/SSCAN (курсорные, неблокирующие).

Современный Redis (6+) многопоточен для I/O (чтение/парсинг/запись в сокеты) и для фоновых задач (удаление больших ключей UNLINK, сохранение RDB через fork), но логика команд по-прежнему сериализуется. Это и обеспечивает простую модель консистентности.

Структуры данных, команды и кейсы#

СтруктураКлючевые командыСложностьТипичные кейсы
StringSET/GET, INCR, SETEX, SET NX PX, GETSETO(1)кэш, счётчики, флаги, distributed lock
ListLPUSH/RPUSH, LPOP/RPOP, BLPOP, LRANGEO(1) краяочереди, стек, recent items
HashHSET/HGET, HGETALL, HINCRBYO(1) полеобъекты/записи (профиль = hash)
SetSADD, SISMEMBER, SINTER, SUNIONO(1)/O(N)теги, уникальные посетители, отношения
Sorted Set (ZSET)ZADD, ZRANGE, ZRANGEBYSCORE, ZRANKO(log N)leaderboard, приоритетные очереди, sliding window
BitmapSETBIT, GETBIT, BITCOUNT, BITOPO(1)/O(N)daily active users, флаги по id
HyperLogLogPFADD, PFCOUNT, PFMERGEO(1), ~12KBприблизительный count-distinct (unique visitors)
StreamXADD, XREAD, XREADGROUP, XACKO(1) добавлениеevent log, очереди с consumer groups
GeospatialGEOADD, GEOSEARCH, GEODISTO(log N)«рядом со мной», доставка

String. Не только текст — это байты до 512 МБ. INCR/INCRBY атомарны → счётчики просмотров, генерация ID. SET key val EX 60 NX — атомарная установка с TTL только если ключа нет (основа lock).

List. Двусвязный список (quicklist). LPUSH + BRPOP → надёжная-ish очередь (producer/consumer). BLPOP блокирует клиента до появления элемента — без busy-polling.

Hash. Хранение объекта одним ключом без сериализации всего JSON: HSET user:42 name Ann age 30, обновление одного поля HINCRBY user:42 age 1. Память эффективнее, чем String-на-поле, при малых hash (ziplist/listpack-кодировка).

Set / Sorted Set. Set — членство и операции множеств (общие друзья = SINTER). ZSET — каждый элемент со score, отсортирован. Leaderboard: ZADD lb 1500 player1, топ-10: ZREVRANGE lb 0 9 WITHSCORES, ранг: ZREVRANK lb player1. ZSET — основа sliding-window rate limit и приоритетных очередей.

Bitmap. Один бит на сущность. DAU: SETBIT dau:2026-06-14 <userId> 1, число активных — BITCOUNT. Для 1М пользователей — 125 КБ. Retention-анализ через BITOP AND.

HyperLogLog. Вероятностная структура для cardinality с ~0.81% ошибкой и фиксированными ~12 КБ независимо от объёма. PFADD visitors:2026-06-14 user1 user2 / PFCOUNT. Когда точность не критична, а Set был бы гигантским.

Stream. Append-only лог с ID <ms>-<seq>, consumer groups (как Kafka-lite): несколько consumer’ов делят нагрузку, XACK подтверждает обработку, XPENDING/XCLAIM — переобработка «зависших». Замена List-очередям, где нужны acknowledgement, replay и несколько групп потребителей.

Geospatial. Поверх ZSET (geohash как score). GEOADD, поиск в радиусе GEOSEARCH ... BYRADIUS 5 km.

// go-redis (github.com/redis/go-redis/v9): базовые операции
import (
    "context"
    "time"
    "github.com/redis/go-redis/v9"
)

rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
ctx := context.Background()

// String + TTL
rdb.Set(ctx, "user:42:token", "abc", 30*time.Minute)
// Атомарный счётчик
views, _ := rdb.Incr(ctx, "post:1:views").Result()
// Hash как объект
rdb.HSet(ctx, "user:42", "name", "Ann", "age", 30)
// Sorted set — leaderboard
rdb.ZAdd(ctx, "lb", redis.Z{Score: 1500, Member: "player1"})
top, _ := rdb.ZRevRangeWithScores(ctx, "lb", 0, 9).Result()
_ = views
_ = top

Персистентность#

МеханизмЧто делаетПлюсыМинусы / durability
RDBбинарный снапшот всего датасета по расписанию (save 900 1) через fork()компактен, быстрый рестарт, мало влияет на runtimeтеряются данные между снапшотами (минуты); fork дорог при больших датасетах (copy-on-write память)
AOFжурнал всех модифицирующих командминимальная потеря (everysec ≤1с, always — почти 0)файл больше, рестарт медленнее (реплей лога)
Гибрид (Redis 7)AOF с RDB-преамбулой: компактный снапшот + журнал хвостабыстрый рестарт + хорошая durabilityкомпромисс по умолчанию

appendfsync:

  • always — fsync на каждую запись: максимальная durability, минимальный throughput.
  • everysec (дефолт) — fsync раз в секунду: потеря ≤1 секунды при сбое, хороший баланс.
  • no — fsync отдаёт ОС: быстро, но окно потери — неопределённое.

Главный тезис для senior: даже с AOF always Redis не даёт гарантий уровня традиционной СУБД при репликации, потому что репликация асинхронна — подтверждённая клиенту запись может потеряться при failover, если не реплицировалась (см. WAIT, но это не панацея). Поэтому Redis как single source of truth для критичных данных — осознанный риск.

Pipelining#

Несколько команд отправляются одним батчем без ожидания ответа на каждую → экономия RTT. Это не транзакция (нет атомарности/изоляции), просто батчинг сети.

pipe := rdb.Pipeline()
incr := pipe.Incr(ctx, "counter")
pipe.Expire(ctx, "counter", time.Hour)
_, _ = pipe.Exec(ctx)        // один round-trip
_ = incr.Val()

Transactions: MULTI / EXEC / WATCH#

MULTI начинает транзакцию, команды ставятся в очередь, EXEC исполняет их атомарно и последовательно (никто не вклинится — single-threaded). НО: это не ACID-транзакция SQL — нет rollback при логической ошибке (если команда выполнилась с ошибкой типа, остальные всё равно выполняются). WATCH даёт оптимистичную блокировку: если watched-ключ изменился между WATCH и EXEC, транзакция отменяется (EXEC возвращает nil) — это CAS-паттерн.

// Оптимистичный декремент остатка через WATCH (CAS)
key := "stock:item1"
err := rdb.Watch(ctx, func(tx *redis.Tx) error {
    n, err := tx.Get(ctx, key).Int()
    if err != nil { return err }
    if n <= 0 { return errors.New("out of stock") }
    _, err = tx.TxPipelined(ctx, func(p redis.Pipeliner) error {
        p.Decr(ctx, key)
        return nil
    })
    return err            // redis.TxFailedErr → ретрай при конфликте
}, key)
_ = err

Lua-скрипты#

Скрипт исполняется атомарно (как единая команда, блокирует loop на время выполнения) и серверно — идеален для read-modify-write без race и без сетевых RTT. EVAL/EVALSHA (по SHA1 закэшированного скрипта). Используется для надёжного unlock, rate limiter, conditional updates.

// Атомарный unlock: удаляем ключ только если значение наше (наш токен)
var unlock = redis.NewScript(`
  if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
  else
    return 0
  end`)
res, _ := unlock.Run(ctx, rdb, []string{"lock:order:1"}, myToken).Int()
_ = res

Кэширование#

Cache-aside (lazy loading) — самый частый. Приложение само управляет кэшем:

func GetUser(ctx context.Context, rdb *redis.Client, db DB, id string) (User, error) {
    key := "user:" + id
    if s, err := rdb.Get(ctx, key).Result(); err == nil {
        return decode(s), nil                  // hit
    } else if err != redis.Nil {
        return User{}, err                     // ошибка Redis — решай: fail или fallback в БД
    }
    u, err := db.LoadUser(ctx, id)             // miss → из БД
    if err != nil { return User{}, err }
    // TTL + небольшой jitter против синхронного истечения (stampede)
    ttl := 10*time.Minute + time.Duration(rand.Intn(120))*time.Second
    rdb.Set(ctx, key, encode(u), ttl)
    return u, nil
}

Стратегии записи:

  • Write-through — пишем в кэш и в БД синхронно: кэш всегда свежий, но запись дороже.
  • Write-back / write-behind — пишем в кэш, в БД асинхронно: быстро, но риск потери при сбое и сложность.
  • Cache-aside — на запись инвалидируем (DEL) ключ, пусть следующее чтение перечитает.

Инвалидация — «одна из двух трудных задач в CS». Подходы: TTL (просто, но stale до истечения), явный DEL на запись, event-driven (CDC/outbox → инвалидация), versioned keys (user:42:v7). Предпочитай DEL после коммита БД, а не «обновление кэша» (избегаешь записи устаревшего значения при гонке).

Thundering herd / cache stampede — при истечении популярного ключа множество запросов одновременно идут в БД и пересчитывают одно и то же. Защиты:

  • Mutex/lock на пересчёт: первый берёт SET NX lock и считает, остальные ждут/возвращают stale.
  • TTL jitter — рандомизация времени жизни, чтобы ключи не истекали синхронно.
  • Early recomputation (probabilistic early expiration) — обновлять ключ до истечения с вероятностью, растущей у конца TTL.
  • Stale-while-revalidate — отдавать устаревшее значение, асинхронно обновляя.

Negative caching — кэшировать «не найдено» (с коротким TTL), чтобы запросы несуществующих ключей не били в БД (cache penetration).

Distributed locks#

Базовый паттерн:

SET lock:resource <unique-token> NX PX 30000   // взять, только если свободен, с TTL
... критическая секция ...
// unlock — ТОЛЬКО Lua с проверкой токена (см. выше), нельзя DEL вслепую

Почему токен и Lua: если просто DEL — можно удалить чужой lock, взятый после истечения нашего TTL. Почему TTL обязателен: чтобы lock не остался навсегда при падении владельца.

Проблемы single-instance lock: мастер с lock падает до репликации на реплику → после failover lock «потерян», двое держат его одновременно. Отсюда Redlock (lock на N независимых мастерах, нужно большинство).

Критика Redlock (Martin Kleppmann): TTL-lock небезопасен при GC-паузах / stop-the-world / сетевых задержках — владелец может «проснуться» после истечения TTL и продолжить работу, думая, что держит lock. Решение для корректности — fencing token (монотонно растущий номер, проверяемый ресурсом-получателем). Antirez отвечал, что Redlock рассчитан на другую модель отказов. Вывод для senior: для взаимного исключения «по производительности» (избежать дублей работы) — ок; для корректности (нельзя допустить двойного выполнения никогда) — Redis-lock недостаточен, нужен fencing token или линеаризуемый координатор (etcd/ZooKeeper/Consul).

Rate limiting#

Fixed windowINCR key + EXPIRE; просто, но всплеск на границе окон (до 2x лимита).

Sliding window log — храним timestamps в ZSET, чистим старые, считаем оставшиеся:

var slidingWindow = redis.NewScript(`
  local key, now, window, limit = KEYS[1], tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
  redis.call("ZREMRANGEBYSCORE", key, 0, now - window)        -- удалить устаревшие
  local count = redis.call("ZCARD", key)
  if count < limit then
    redis.call("ZADD", key, now, now .. ":" .. math.random())  -- уникальный member
    redis.call("PEXPIRE", key, window)
    return 1                                                    -- allowed
  end
  return 0                                                      -- rejected
`)
// allowed == 1?
allowed, _ := slidingWindow.Run(ctx, rdb,
    []string{"rl:user:42"},
    time.Now().UnixMilli(), int64(60000), 100).Int()
_ = allowed

Точно, но память O(числа запросов в окне).

Token bucket — хранить токены и время последнего пополнения (в Hash), пополнять лениво при запросе. Допускает burst до размера ведра, экономнее по памяти. Реализуется одним Lua-скриптом для атомарности.

Sliding window counter — аппроксимация: взвешенная сумма текущего и предыдущего fixed-окна. Компромисс точности и памяти.

Главное: любой rate limiter под конкуренцией должен быть атомарным → Lua-скрипт (read-modify-write в одной операции), иначе гонки превышают лимит.

Очереди и Streams#

  • List-очередь: LPUSH + BRPOP. Просто, но нет ack — если consumer упал после BRPOP, сообщение потеряно (паттерн reliable queue: BLMOVE/LMOVE в processing-список, удалять после обработки).
  • Streams: XADD + consumer group (XGROUP CREATE, XREADGROUP), XACK после успеха, XPENDING/XCLAIM для зависших. Поддержка нескольких групп (fan-out), replay по ID, ограничение длины XADD ... MAXLEN ~ 10000. Если нужны гарантии at-least-once и реальная очередь — Streams, не List.

Репликация и Cluster#

  • Репликация: один master, N replica, асинхронная по умолчанию (реплика может отставать → читаешь с реплики stale данные). WAIT numreplicas timeout блокирует, пока запись не дойдёт до N реплик, но это best-effort, не строгая durability. Failover — через Redis Sentinel (мониторинг + автопродвижение реплики).
  • Redis Cluster: данные шардируются по 16384 хеш-слотам (slot = CRC16(key) mod 16384), каждый master владеет диапазоном слотов. Горизонтальное масштабирование и отказоустойчивость (replica на каждый master). Ограничение: multi-key команды работают только если ключи в одном слотеhash tags {user:42}:profile и {user:42}:cart форсируют один слот. Транзакции/Lua с несколькими ключами — тоже в пределах слота.

Eviction policies#

Когда maxmemory достигнут, Redis удаляет ключи по политике:

ПолитикаПоведение
noevictionне удаляет, новые записи → ошибка (для primary-store)
allkeys-lruвытесняет наименее недавно использованные среди всех ключей
allkeys-lfuнаименее часто используемые (Redis 4+, точнее для горячих данных)
volatile-lru / volatile-lfuто же, но только среди ключей с TTL
allkeys-random / volatile-randomслучайно
volatile-ttlсначала ключи с наименьшим оставшимся TTL

Redis LRU/LFU — аппроксимация (сэмплирование maxmemory-samples, не точный LRU), ради скорости. Для чистого кэша allkeys-lru/allkeys-lfu; если часть ключей — persistent, а часть — кэш, используй volatile-* + ставь TTL только на кэш-ключи.


Подводные камни / gotchas#

  • KEYS * в проде блокирует весь инстанс — только SCAN. Аналогично большие HGETALL/SMEMBERS/ZRANGE 0 -1.
  • MULTI/EXEC ≠ ACID: нет rollback, нет изоляции в смысле SQL; ошибка в одной команде не откатывает остальные. Для условной логики — WATCH (CAS) или Lua.
  • Pipelining путают с транзакцией — pipeline лишь экономит RTT, атомарности не даёт.
  • Async-репликация → потеря подтверждённых записей при failover; WAIT смягчает, но не устраняет. Не считай Redis линеаризуемым по умолчанию.
  • Distributed lock без токена/Lua удаляет чужой lock; без TTL — вечный lock; с TTL — риск двойного владения при паузах (нужен fencing token для корректности).
  • Stampede при истечении горячего ключа — без jitter/lock/early-expiry получишь шквал на БД.
  • Big keys / hot keys: огромный ключ (миллионы элементов) → блокировки и неравномерная нагрузка на шард; hot key в Cluster перегружает один узел.
  • DEL большого ключа блокирует — используй UNLINK (асинхронное освобождение памяти).
  • Cluster и multi-key: команды между слотами падают с CROSSSLOT — нужны hash tags.
  • EXPIRE сбрасывается некоторыми командами? Нет — но SET без KEEPTTL затирает TTL. В Redis 6+ есть SET ... KEEPTTL.
  • INCR на нечисловой строке → ошибка; счётчики держи отдельными ключами.
  • fork для RDB/AOF-rewrite при больших датасетах вызывает latency-спайки и риск OOM (copy-on-write + интенсивная запись удваивает память).
  • Кэширование результата ошибки/частичных данных — кэшируй только валидные ответы (или negative-cache «не найдено» с коротким TTL осознанно).
  • Сериализация: GETSET устарел (используй SET ... GET); следи за версией Redis при использовании новых флагов.

Вопросы на собеседовании#

В: Redis однопоточный — как тогда он быстрый и как это влияет на консистентность? О: Команды исполняются в одном event-loop, поэтому каждая команда атомарна без локов и нет издержек синхронизации; всё in-memory + эффективные структуры данных. Узкое место — одно ядро и сеть, поэтому масштабируют шардированием (Cluster). I/O и фоновые задачи (RDB-fork, UNLINK) в новых версиях многопоточны, но логика команд сериализована. Минус — долгая команда (KEYS *, тяжёлый Lua) блокирует всех.

В: В чём разница RDB и AOF, что выбрать для durability? О: RDB — периодические бинарные снапшоты: компактно, быстрый рестарт, но теряются данные между снапшотами. AOF — журнал команд: с appendfsync everysec потеря ≤1с, с always — почти ноль, ценой throughput; рестарт медленнее. Дефолт Redis 7 — гибрид (RDB-преамбула + AOF-хвост). Но даже AOF always не гарантирует durability при failover из-за асинхронной репликации.

В: Объясни CAP и где Redis на этой карте. О: При сетевом разделении выбираешь C или A. Одиночный Redis — CP-ish на узле (консистентен, но недоступен при падении). Redis с async-репликацией ближе к AP при failover: жертвует консистентностью (можно потерять последние записи) ради доступности. По PACELC в нормальном режиме Redis выбирает latency. Строгой линеаризуемости из коробки нет.

В: Как реализовать распределённый lock и какие у него проблемы? О: SET key uniqueToken NX PX ttl, unlock — Lua с проверкой токена. Проблемы: при падении master до репликации lock дублируется после failover; при GC-паузе владелец может пережить TTL и нарушить взаимное исключение. Redlock (N мастеров, большинство) повышает надёжность, но раскритикован Kleppmann: для корректности нужен fencing token, проверяемый самим ресурсом, либо линеаризуемый координатор (etcd/ZooKeeper). Для оптимизации «не делать работу дважды» Redis-lock достаточно.

В: Что такое cache stampede / thundering herd и как бороться? О: При истечении популярного ключа лавина запросов одновременно пересчитывает его, перегружая БД. Защиты: lock на пересчёт (SET NX, остальные ждут/получают stale), TTL jitter (рассинхронизировать истечение), probabilistic early recomputation, stale-while-revalidate. Дополнительно negative caching против penetration.

В: Cache-aside vs write-through vs write-back и инвалидация? О: Cache-aside: приложение читает из кэша, при miss грузит из БД и кладёт; на запись — инвалидирует (DEL). Write-through: синхронно в кэш и БД (свежесть ценой latency). Write-back: в кэш сразу, в БД асинхронно (быстро, риск потери). Инвалидацию предпочтительно делать DEL после коммита БД (не «обновлением», чтобы не записать stale при гонке); альтернативы — TTL, versioned keys, event/CDC-driven.

В: Когда ZSET, когда HyperLogLog, когда Bitmap? О: ZSET — упорядоченные данные со score (leaderboard, sliding-window rate limit, приоритетная очередь), O(log N). Bitmap — булев признак на сущность по числовому id (DAU, флаги), считаешь BITCOUNT, очень компактно. HyperLogLog — приблизительный count-distinct (~0.81% ошибка, ~12 КБ независимо от объёма), когда точный Set был бы слишком большим.

В: Как работает Redis Cluster и какие ограничения на операции? О: Данные распределены по 16384 хеш-слотам (CRC16(key) mod 16384), каждый master владеет диапазоном, реплики дают отказоустойчивость. Multi-key команды (включая транзакции и Lua с несколькими ключами) работают только если ключи в одном слоте — иначе CROSSSLOT. Чтобы связанные ключи попали в один слот, используют hash tags {user:42}:....

В: Как сделать корректный rate limiter на Redis? О: Варианты: fixed window (INCR+EXPIRE, но всплеск на границе), sliding window log (ZSET с timestamps — точно, но память O(N запросов)), token bucket (Hash с токенами и lazy refill — допускает burst, экономен), sliding window counter (аппроксимация). Ключевое — атомарность read-modify-write через Lua-скрипт, иначе под конкуренцией лимит превышается.

В: MULTI/EXEC — это настоящая транзакция? О: Нет в ACID-смысле. Команды очередь, EXEC исполняет их атомарно и без вклинивания (single-threaded), но нет rollback при логической ошибке и нет уровней изоляции SQL. Для условной модификации — WATCH (оптимистичная блокировка/CAS) или Lua-скрипт (атомарный read-modify-write).


На что копают на senior+#

  • Durability vs availability trade-offs: понимаешь ли, что async-репликация + AOF не равно «данные не потеряются», и когда Redis нельзя делать source of truth. Знание WAIT, его ограничений, и почему он не даёт строгой гарантии.
  • Корректность distributed lock: умеешь объяснить fencing token, разницу между «lock для производительности» и «lock для корректности», критику Redlock и когда нужен etcd/ZooKeeper вместо Redis.
  • Консистентность как спектр: read-after-write, monotonic reads, чтение с реплики и stale data; tunable consistency в Cassandra (R+W>N), read/write concern в Mongo, strongly consistent reads в DynamoDB — не сводишь NoSQL к «eventual».
  • Access-pattern-driven моделирование для Cassandra/DynamoDB (single-table design, денормализация, hot partition) — проектируешь от запросов, оцениваешь стоимость GSI и full scan.
  • Операционные риски Redis: big keys, hot keys, fork-latency и copy-on-write при RDB/rewrite, eviction под давлением памяти (LRU vs LFU, аппроксимация), блокирующие команды.
  • Cluster-aware дизайн: hash tags, CROSSSLOT, перешардирование (slot migration), почему multi-key атомарность ограничена.
  • Стоимость и границы: когда Postgres + JSONB/UNLOGGED-таблицы/pg_partman решают задачу без введения нового хранилища; чем платишь за каждый дополнительный datastore (операционная сложность, консистентность между ними, dual-write проблема → outbox/CDC).
  • Защита кэша: stampede, penetration (negative cache, bloom filter), avalanche (массовое одновременное истечение), и как их различать.
  • Идемпотентность и at-least-once в Streams: дедупликация на стороне consumer, обработка XPENDING/XCLAIM, сравнение с Kafka.