Модуль: Базы данных · Уровень: 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-Value | key → opaque value | Redis, DynamoDB, Riak | O(1) доступ по ключу, простое шардирование | кэш, сессии, профили, корзины, feature flags |
| Document | key → JSON/BSON документ | MongoDB, Couchbase | вложенные структуры, частичные запросы по полям, индексы | каталоги, CMS, профили с переменной структурой |
| Wide-column | row 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#
| Критерий | SQL | NoSQL |
|---|---|---|
| Схема | строгая, нормализованная, на запись | гибкая/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), но логика команд по-прежнему сериализуется. Это и обеспечивает простую модель консистентности.
Структуры данных, команды и кейсы#
| Структура | Ключевые команды | Сложность | Типичные кейсы |
|---|---|---|---|
| String | SET/GET, INCR, SETEX, SET NX PX, GETSET | O(1) | кэш, счётчики, флаги, distributed lock |
| List | LPUSH/RPUSH, LPOP/RPOP, BLPOP, LRANGE | O(1) края | очереди, стек, recent items |
| Hash | HSET/HGET, HGETALL, HINCRBY | O(1) поле | объекты/записи (профиль = hash) |
| Set | SADD, SISMEMBER, SINTER, SUNION | O(1)/O(N) | теги, уникальные посетители, отношения |
| Sorted Set (ZSET) | ZADD, ZRANGE, ZRANGEBYSCORE, ZRANK | O(log N) | leaderboard, приоритетные очереди, sliding window |
| Bitmap | SETBIT, GETBIT, BITCOUNT, BITOP | O(1)/O(N) | daily active users, флаги по id |
| HyperLogLog | PFADD, PFCOUNT, PFMERGE | O(1), ~12KB | приблизительный count-distinct (unique visitors) |
| Stream | XADD, XREAD, XREADGROUP, XACK | O(1) добавление | event log, очереди с consumer groups |
| Geospatial | GEOADD, GEOSEARCH, GEODIST | O(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)
_ = errLua-скрипты#
Скрипт исполняется атомарно (как единая команда, блокирует 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 NXlock и считает, остальные ждут/возвращают 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 window — INCR 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.