ключ — значение — паттерны и структураkey-value — patterns and structure
Три вопроса, которые определяют качество любого key-value дизайна: как кэш синхронизируется с источником истины (паттерн), что происходит, когда память заканчивается (eviction), и как назвать ключ так, чтобы по нему можно было выборочно инвалидировать и обходить данные.Three questions decide the quality of any key-value design: how the cache stays in sync with the source of truth (the pattern), what happens when memory runs out (eviction), and how to name a key so it supports selective invalidation and traversal.
Cache-Aside (он же Lazy Loading) — приложение само проверяет кэш, при промахе читает из БД и кладёт результат в кэш; самый распространённый паттерн, потому что кэш заполняется только тем, что реально запрашивалось, и БД не привязана к кэшу напрямую.Cache-Aside (a.k.a. Lazy Loading) — the application itself checks the cache, and on a miss reads from the DB and stores the result in the cache; the most common pattern, since the cache only fills with what was actually requested, and the DB isn't directly coupled to the cache.
async function getUser(id) {
let user = await cache.get(`user:${id}`);
if (!user) { // промах кэша// cache miss
user = await db.query('SELECT * FROM users WHERE id = ?', [id]);
await cache.set(`user:${id}`, user, { ttl: 300 });
}
return user;
}
Write-Through — запись сразу идёт и в кэш, и в БД синхронно как одна операция; чтение после записи гарантированно видит свежие данные, но каждая запись становится дороже (два хранилища вместо одного) и медленнее на пути записи.Write-Through — a write goes to both the cache and the DB synchronously as one operation; a read right after a write is guaranteed to see fresh data, but every write becomes more expensive (two stores instead of one) and slower on the write path.
Refresh-Ahead — кэш проактивно обновляет запись до истечения её TTL, если видит, что она часто запрашивается, — устраняет задержку "холодного" промаха для горячих ключей ценой дополнительных фоновых запросов к БД, часть которых окажется впустую, если данные больше не понадобятся.Refresh-Ahead — the cache proactively refreshes an entry before its TTL expires if it sees the entry is frequently requested — eliminates the "cold miss" delay for hot keys, at the cost of extra background DB queries, some of which are wasted if the data turns out not to be needed again.
Когда память заканчивается, а новые ключи продолжают поступать, движок должен решить, какие существующие ключи выселить. LRU (Least Recently Used) выселяет ключ, к которому дольше всего не обращались — хорошо работает, когда "недавно использованное снова понадобится скоро" (типичный веб-трафик). LFU (Least Frequently Used) выселяет ключ с наименьшим числом обращений за всё время — лучше для устойчиво "горячих" и устойчиво "холодных" ключей, но хуже реагирует на резкую смену паттерна доступа. ARC (Adaptive Replacement Cache) динамически балансирует между LRU и LFU в реальном времени, адаптируясь к текущему паттерну нагрузки — сложнее в реализации, но устойчивее к разным профилям трафика.When memory runs out and new keys keep arriving, the engine must decide which existing keys to evict. LRU (Least Recently Used) evicts the key that hasn't been touched the longest — works well when "recently used will likely be needed again soon" (typical web traffic). LFU (Least Frequently Used) evicts the key with the fewest accesses over its whole lifetime — better for keys that are consistently hot or consistently cold, but reacts worse to a sudden shift in access pattern. ARC (Adaptive Replacement Cache) dynamically balances between LRU and LFU in real time, adapting to the current load pattern — more complex to implement, but more resilient across different traffic profiles.
Как не уронить сервер при нехватке RAM: ключевое решение — задать `maxmemory` заранее (не полагаться на дефолт или отсутствие лимита) и выбрать `maxmemory-policy`, соответствующую реальному профилю доступа (`allkeys-lru` для чистого кэша, `volatile-lru` — если часть ключей вообще не должна выселяться, только с явным TTL). Без заданного лимита память растёт неограниченно до OOM-килла процесса ОС.How not to crash the server on RAM exhaustion: the key decision is setting `maxmemory` in advance (not relying on the default or no limit at all) and picking a `maxmemory-policy` matching the real access profile (`allkeys-lru` for a pure cache, `volatile-lru` if some keys must never be evicted, only ones with an explicit TTL). Without a set limit, memory grows unbounded until the OS OOM-kills the process.
Key-value хранилища не имеют вложенности или схемы — единственный способ выразить иерархию и связь между сущностями — соглашение об именовании ключей через разделитель (обычно `:`). Хорошо спроектированная структура ключей делает возможными операции, которые иначе потребовали бы отдельного индекса: массовую инвалидацию по префиксу, группировку в мониторинге, понятную отладку через CLI.Key-value stores have no nesting or schema — the only way to express hierarchy and relationships between entities is a key-naming convention via a delimiter (typically `:`). A well-designed key structure makes possible operations that would otherwise need a separate index: bulk prefix-based invalidation, grouping in monitoring, straightforward CLI debugging.
user:1042:profile // профиль пользователя 1042// user 1042's profile
user:1042:sessions:abc123 // конкретная сессия этого пользователя// a specific session for that user
product:catalog:electronics:42 // товар внутри категории// a product within a category
rate-limit:api:203.0.113.4 // счётчик rate limit по IP// a rate-limit counter by IP
Такая структура позволяет, например, найти и удалить все сессии конкретного пользователя по паттерну `user:1042:sessions:*` (с оговоркой, что `KEYS`/`SCAN` с wildcard — операция, которую нужно использовать осторожно в проде, см. грабли ниже) при логауте со всех устройств, не храня отдельный список сессий где-то ещё.This structure lets you, say, find and delete all of a specific user's sessions via the pattern `user:1042:sessions:*` (with the caveat that `KEYS`/`SCAN` with a wildcard is an operation to use carefully in production, see the gotchas below) on a logout-from-all-devices, without keeping a separate session list somewhere else.
Redis (и большинство key-value движков схожей архитектуры) однопоточен для обработки команд — `KEYS pattern*` сканирует ВСЁ пространство ключей синхронно, блокируя обработку всех остальных запросов на время сканирования. На проде миллионов ключей это может занять секунды и вызвать каскадные таймауты у клиентов. `SCAN` с курсором — неблокирующая альтернатива, проходящая пространство ключей итеративно небольшими порциями.Redis (and most key-value engines of similar architecture) is single-threaded for command processing — `KEYS pattern*` scans the ENTIRE keyspace synchronously, blocking all other request handling for the scan's duration. In production with millions of keys, this can take seconds and trigger cascading client timeouts. `SCAN` with a cursor is the non-blocking alternative, walking the keyspace iteratively in small chunks.
Если TTL истекает у ОЧЕНЬ горячего ключа, и в тот же момент приходит тысяча одновременных запросов, все они видят промах кэша и одновременно бьют по БД одним и тем же тяжёлым запросом — БД получает всплеск нагрузки, которого кэш как раз должен был не допустить. Решения: блокировка на пересчёт значения (первый запрос считает, остальные ждут), либо вероятностное раннее обновление (probabilistic early expiration).If TTL expires on a VERY hot key, and at that same moment a thousand concurrent requests arrive, they all see a cache miss and simultaneously hit the DB with the same heavy query — the DB takes a load spike that the cache was precisely supposed to prevent. Fixes: a lock around recomputing the value (first request computes, the rest wait), or probabilistic early expiration.
Один Redis-инстанс, используемый одновременно и как чистый кэш (можно терять данные), и как хранилище сессий (терять нельзя без явного логаута), под общим `allkeys-lru` рискует выселить активную сессию наравне с закэшированным значением. Разделение на разные логические базы данных/инстансы с разными policy или явная защита сессионных ключей через `volatile-lru` + TTL на всём остальном — правильное разделение ответственности.A single Redis instance used simultaneously as both a pure cache (losing data is fine) and a session store (losing data isn't, absent an explicit logout), under a blanket `allkeys-lru`, risks evicting an active session right alongside a cached value. Splitting into separate logical databases/instances with different policies, or explicitly protecting session keys via `volatile-lru` + TTL on everything else, is the correct separation of concerns.