Кэширование
Предпосылки: модели консистентности (read-your-writes), базовое понимание PostgreSQL (shared buffers, соединения), Redis (GET/SET/DEL), pub/sub для инвалидации, CDN (RTT, origin/edge).
← Паттерны надёжности | Гарантии доставки в распределённых системах →
В read-heavy системах кэширование — основной способ масштабирования чтения.
Зачем кэш: иерархия задержек
Доступ к данным занимает разное время в зависимости от того, где они находятся. Порядки величин для random access:
| Хранилище | Latency | Относительно RAM |
|---|---|---|
| RAM | ~10 ns | 1× |
| Optane (persistent memory) | ~10 µs | 1,000× |
| SSD | ~100 µs | 10,000× |
| HDD | ~10 ms | 1,000,000× |
RAM быстрее SSD примерно в 10,000 раз. Это не «немного быстрее» — это качественно другой уровень. Кэширование существует потому, что перенос данных ближе к процессору (из медленного хранилища в быстрое) даёт выигрыш на порядки — тот же принцип лежит в основе иерархии памяти от регистров до диска.
Почему кэш работает: локальность обращений
Кэш помогает только если одни и те же данные запрашиваются повторно. Это свойство называется locality of reference — локальность обращений.
Temporal locality (временна́я локальность): данные, к которым обращались недавно, скорее всего понадобятся снова. Пользователь открыл профиль — вероятно, откроет его снова в ближайшие минуты. Товар появился в топе продаж — его будут запрашивать чаще обычного.
Spatial locality (пространственная локальность): данные, расположенные рядом в памяти, часто используются вместе. Это важнее для CPU-кэшей и структур данных, чем для application-level кэширования.
Temporal locality — причина, почему кэш вообще работает на уровне приложений. Если бы доступ к данным был равномерно случайным, каждый запрос был бы к новым данным, и кэш не помогал бы.
Три проблемы — три уровня кэширования
Локальность объясняет, почему кэш помогает. Но где его разместить — отдельный вопрос: ответ зависит от того, какую проблему решаем.
Проблема 1: нагрузка на origin
Rails-приложение. Страница профиля требует 5 запросов к PostgreSQL, каждый по 10ms. Страницу просматривают 100 раз в секунду. База выполняет 500 одинаковых запросов в секунду, возвращая одни и те же данные.
PostgreSQL кэширует страницы данных в shared buffers — disk I/O не происходит, если данные «горячие». Но при каждом запросе PostgreSQL парсит SQL, выбирает план выполнения, проходит по индексам, сериализует результат, занимает соединение из пула. При 10,000 запросах в секунду (RPS) PostgreSQL может стать узким местом не по I/O, а по CPU и connections.
Решение: внешний кэш (Redis). Снимает нагрузку с PostgreSQL — запросы обслуживаются без работы базы.
Проблема 2: сетевой RTT
Redis и PostgreSQL оба по сети. RTT в датацентре ~0.5ms. Если цель — минимальная latency для конкретного запроса, сетевой вызов всё равно добавляет миллисекунды.
Решение: локальный кэш в памяти процесса. Доступ за микросекунды, RTT отсутствует.
Проблема 3: bandwidth и географическое расстояние
Пользователь в Токио запрашивает данные с сервера в Амстердаме. Физическое расстояние создаёт latency, которую не убрать никаким кэшем на стороне сервера.
Решение: CDN — кэш на edge-серверах, географически близких к пользователю.
Сводка уровней
| Уровень | Где находится | Какую проблему решает | Цена |
|---|---|---|---|
| Local (in-process) | Память Rails-процесса | Latency (нет RTT) | Когерентность между процессами |
| External (Redis) | Отдельный сервер | Нагрузка на origin | RTT остаётся |
| CDN | Edge-серверы | Географическая latency | Ещё сложнее инвалидация |
Когерентность: локальный vs внешний кэш
Локальный кэш создаёт проблему cache coherence — когерентности кэша.
Несколько процессов на одном сервере
20 Puma workers на одном сервере, каждый с локальным кэшем. Профиль пользователя закэширован в каждом worker. Пользователь обновил имя через worker 3. Остальные 19 workers указывают на устаревший профиль.
Инвалидировать кэш в worker 3 легко — там, где изменили, там и удалили. Но нет способа передать событие другим workers. Они покажут устаревшие данные, пока не истечёт TTL (Time-To-Live — время жизни записи в кэше).
Масштаб растёт с серверами
Сервер 1: 20 workers ── локальный кэш
Сервер 2: 20 workers ── локальный кэш
Сервер 3: 20 workers ── локальный кэш
60 копий данных профиля. Изменение через один worker — 59 stale (устаревших) копий.
Можно решить через pub/sub: broadcast инвалидации всем workers. Но это сложно: нужен канал между процессами, обработка случаев когда worker был недоступен, гонки между инвалидацией и новыми запросами.
Внешний кэш решает coherence
Redis как единственный источник кэшированных данных:
Сервер 1: 20 workers ─┐
Сервер 2: 20 workers ─┼──> Redis (одна копия данных)
Сервер 3: 20 workers ─┘
DEL profile:42 — и все workers при следующем запросе получат cache miss, пойдут в PostgreSQL, получат свежие данные.
Фундаментальный trade-off
| Локальный кэш | Внешний кэш (Redis) |
|---|---|
| Минимальная latency (нет RTT) | RTT на каждый запрос |
| Проблема coherence (N копий) | Одна копия, простая инвалидация |
| Память каждого процесса ограничена | Выделенный сервер с большой памятью |
| Не переживает рестарт процесса | Переживает рестарты приложения |
На практике комбинируют: локальный кэш с коротким TTL (секунды) снимает повторные запросы в рамках одного процесса, Redis как основной кэш (минуты) снимает нагрузку с PostgreSQL.
Инвалидация
Внешний кэш решает проблему когерентности — одна копия вместо многих. Остаётся вторая проблема: данные в кэше устаревают. Когда и как их обновлять — центральная проблема кэширования.
TTL-based инвалидация
Данные живут фиксированное время, после чего автоматически удаляются.
Rails.cache.fetch("profile:#{user.id}", expires_in: 5.minutes) do
User.find(user.id).profile_data
endTTL — простейшая стратегия. Не нужно отслеживать, где данные изменились. Через 5 минут кэш перечитается в любом случае.
Цена: данные могут быть stale всё время TTL, даже если изменились секунду назад.
Event-based инвалидация
Кэш удаляется в момент изменения данных.
def update_profile(user, new_name)
user.update!(name: new_name)
Rails.cache.delete("profile:#{user.id}")
endПроблема: нужно знать все места, где данные могут измениться. Профиль пользователя может измениться через веб-форму, мобильное приложение, админку, background job, API, миграцию данных, прямой SQL-запрос. Каждое место должно инвалидировать кэш. Забыл одно — получил stale data.
Производные данные усложняют ещё больше. Кэшируется страница со списком постов пользователя. Что должно инвалидировать этот кэш? Создание поста, удаление, редактирование, скрытие модератором, изменение настроек приватности.
Практический компромисс: TTL + event-based
Event-based ловит известные пути изменения. TTL — страховка на случай, если что-то пропустили (прямой SQL, баг, новый код без инвалидации). Данные будут stale максимум N минут, а не навсегда.
def update_profile(user, new_name)
user.update!(name: new_name)
Rails.cache.delete("profile:#{user.id}") # event-based
end
Rails.cache.fetch("profile:#{user.id}", expires_in: 5.minutes) do # TTL как страховка
User.find(user.id).profile_data
endКонсистентность и кэш
TTL без event-based инвалидации нарушает read-your-writes: пользователь записал данные и сразу читает, но получает старое значение из кэша.
Три подхода к восстановлению read-your-writes
1. Инвалидация при записи (описана выше). Следующее чтение — cache miss, свежие данные из PostgreSQL.
2. Write-through. Кэш обновляется сразу при записи, без промежуточного miss.
def update_profile(user, new_name)
user.update!(name: new_name)
Rails.cache.write("profile:#{user.id}", user.profile_data, expires_in: 5.minutes)
end3. Обход кэша для автора. После записи устанавливается короткоживущий маркер «этот пользователь только что писал». Для него читаем напрямую из PostgreSQL, минуя кэш. Сложнее в реализации, используется редко.
Cache-aside pattern
Мы обсудили когда и почему инвалидировать. Теперь — как организовать взаимодействие кэша с origin в коде.
Стандартный паттерн управления кэшем — cache-aside (или lazy loading). Приложение само решает, когда читать из кэша, когда из origin, когда инвалидировать.
Чтение:
- Проверить кэш
- Если hit — вернуть
- Если miss — прочитать из origin, записать в кэш, вернуть
Запись:
- Записать в origin
- Инвалидировать кэш (или обновить)
def get_profile(user_id)
Rails.cache.fetch("profile:#{user_id}", expires_in: 5.minutes) do
User.find(user_id).profile_data # выполнится только при miss
end
end
def update_profile(user, new_name)
user.update!(name: new_name)
Rails.cache.delete("profile:#{user.id}")
endПриложение явно контролирует все операции с кэшем. Альтернативы перекладывают часть логики на кэширующий слой.
Write-through и write-behind
Cache-aside требует явной инвалидации или обновления кэша при записи. Альтернативные паттерны встраивают запись в кэш в сам процесс записи данных.
Write-through
Данные записываются синхронно и в кэш, и в origin. Запись считается завершённой только когда оба хранилища обновлены.
def update_profile(user, new_name)
# Оба вызова синхронны — клиент ждёт завершения обоих
user.update!(name: new_name) # → PostgreSQL
Rails.cache.write("profile:#{user.id}", user.profile_data) # → Redis
endГарантия: кэш всегда содержит актуальные данные. Read-your-writes выполняется автоматически.
Цена: latency записи увеличивается. Клиент ждёт и PostgreSQL, и Redis. Если Redis недоступен — запись либо падает, либо нужна обработка ошибки.
Write-behind (write-back)
Данные записываются сначала в кэш, а в origin — асинхронно, позже. Запись считается завершённой сразу после обновления кэша.
def increment_view_count(video_id)
# Быстро: только Redis
Rails.cache.increment("views:#{video_id}")
# PostgreSQL обновится позже, в фоне
end
# Background job (каждые 10 секунд):
def flush_view_counts
keys = Rails.cache.read_multi(*pending_view_keys)
keys.each do |key, count|
video_id = key.split(":").last
Video.update_counters(video_id, views: count)
Rails.cache.delete(key)
end
endВыигрыш: минимальная latency записи. Клиент не ждёт PostgreSQL.
Риск: при crash Redis данные, не записанные в PostgreSQL, теряются. Окно потери = интервал между flush’ами.
Когда write-behind допустим
Write-behind подходит для данных, где потеря небольшого хвоста не критична:
- Счётчики просмотров — потеря 100 просмотров из миллиона незаметна
- Лайки — можно пережить потерю нескольких
- Позиция воспроизведения видео — «продолжить с 5:30» vs «продолжить с 5:00» приемлемо
- Аналитические события — статистика допускает погрешность
- Онлайн-статус — «был онлайн 5 минут назад» не требует точности
Общий принцип: write-behind для данных, где допустима потеря небольшого хвоста при сбое. Антипример: платежи, заказы, баланс счёта — здесь потеря данных недопустима.
Сравнение паттернов записи
| Паттерн | Latency записи | Консистентность кэша | Риск потери данных |
|---|---|---|---|
| Cache-aside + invalidate | Низкая (только origin) | Eventual (до TTL) | Нет |
| Cache-aside + update | Средняя (origin + кэш) | Сильная | Нет |
| Write-through | Высокая (origin + кэш синхронно) | Сильная | Нет |
| Write-behind | Минимальная (только кэш) | Сильная | Да (окно = flush interval) |
Cache stampede
Cache stampede — пример cascading failure: внезапная нагрузка на origin может перегрузить всю систему.
Популярный профиль с TTL 5 минут. В момент истечения TTL к этому профилю приходит 1000 одновременных запросов. Все получают cache miss, все идут в PostgreSQL.
Что происходит с системой
Puma с 20 workers, пул из 5 соединений к PostgreSQL на каждый. 100 соединений максимум. 1000 запросов — 100 получают соединение к БД, 900 ждут в очереди Puma.
PostgreSQL выполняет 100 одинаковых запросов. Если каждый занимает 500ms, обработка всех 1000 запросов займёт ~5 секунд. Не crash, но 5 секунд latency для всех пользователей.
Три решения
1. Блокировка на перестроение (mutex). Только один запрос идёт в PostgreSQL, остальные ждут результата.
def get_profile_with_lock(user_id)
cache_key = "profile:#{user_id}"
lock_key = "lock:#{cache_key}"
cached = Rails.cache.read(cache_key)
return cached if cached
if Rails.cache.write(lock_key, "1", nx: true, expires_in: 10.seconds)
# Захватили блокировку — перестраиваем кэш
data = User.find(user_id).profile_data
Rails.cache.write(cache_key, data, expires_in: 5.minutes)
Rails.cache.delete(lock_key)
data
else
# Кто-то уже перестраивает — ждём
sleep(0.1)
Rails.cache.read(cache_key) || get_profile_with_lock(user_id)
end
end1000 запросов → 1 идёт в PostgreSQL, 999 ждут. Trade-off: добавили latency для ожидающих (минимум время перестроения кэша).
2. Stale-while-revalidate. Отдаём устаревшие данные, пока один запрос обновляет кэш в фоне.
Храним в кэше не только данные, но и время «мягкого» истечения. Если данные устарели, но ещё есть — отдаём stale, запускаем фоновое обновление.
Trade-off: пользователи могут видеть stale данные чуть дольше, но нет ожидания.
3. Раннее обновление (early expiration). Не ждём истечения TTL. Когда до истечения осталось мало времени, запускаем фоновое обновление. К моменту истечения новые данные уже в кэше. Stampede не происходит.
Когда можно игнорировать
Защита от stampede добавляет сложность. Три фактора определяют, нужна ли она:
-
Время запроса к origin. Если запрос занимает 5ms, 100 одновременных запросов завершатся за ~5ms параллельно. PostgreSQL не заметит.
-
Общий throughput. Если запросов мало, в момент истечения TTL придёт 2–3 запроса, не 1000.
-
Популярность ключа. Stampede — проблема только для «горячих» ключей. Профиль обычного пользователя запрашивают раз в минуту — никакого stampede при истечении TTL. Проблема только для top-0.1%: главная страница, популярный товар, профиль знаменитости.
Практическое правило: защита нужна когда (RPS к ключу) × (время запроса) > порог. 1000 RPS × 500ms — проблема. 10 RPS × 5ms — можно игнорировать.
Eviction policies
Stampede — проблема при истечении TTL. Но TTL — не единственная причина удаления данных из кэша. Когда память заполнена (Redis настроен на 10GB, данных больше), кэш вынужден удалять записи сам. Цель: удалить ключ, который с наименьшей вероятностью понадобится в будущем. Будущее неизвестно, поэтому используются эвристики на основе прошлого.
LRU — Least Recently Used
Удаляется ключ, к которому дольше всего не обращались (реализация: LRU-кэш). Логика основана на temporal locality: если данные не запрашивались давно, вероятно, не понадобятся и дальше.
Ключи по времени последнего доступа:
profile:1 — 5 секунд назад
profile:2 — 2 минуты назад
profile:3 — 1 час назад ← кандидат на удаление
profile:4 — 30 секунд назад
Когда работает: доступ к данным следует temporal locality — недавно запрошенное «горячее».
Когда ломается: scan-паттерн. Построение отчёта прошлось по миллиону записей один раз — все они теперь «недавние» и вытеснили реально горячие данные.
LFU — Least Frequently Used
Удаляется ключ, к которому обращались реже всего.
Ключи по частоте доступа:
profile:1 — 1000 обращений
profile:2 — 5 обращений ← кандидат на удаление
profile:3 — 500 обращений
profile:4 — 3 обращения ← или этот
Когда работает: есть стабильно популярные данные (главная страница, топ товаров).
Когда ломается: новые данные. Только что добавленный популярный товар имеет счётчик 0 — сразу вытесняется, не успев набрать частоту. Redis решает это через LFU с затуханием (decay) — счётчики постепенно уменьшаются со временем, давая новым ключам шанс.
Выбор политики по типу данных
Сессии → LRU. Новая сессия = пользователь только что залогинился = скорее всего активен прямо сейчас. С LFU новая сессия имеет счётчик 1 и мгновенно становится кандидатом на вытеснение. Плюс сессии временные — старая сессия неактивного пользователя должна уйти, даже если за неделю накопила много обращений.
Каталог товаров → LFU. Популярный товар запрашивают тысячи раз в день — он должен остаться. Редкий товар запрашивают раз в неделю — можно вытеснить. Добавление нового товара не означает, что он когда-либо будет востребован.
| Данные | Рекомендуемая политика | Причина |
|---|---|---|
| Сессии | LRU | Новые сессии активны, старые неактивны |
| Каталог товаров | LFU | Популярность стабильна, новое не всегда важно |
| Результаты поиска | LRU | Запросы редко повторяются точь-в-точь |
| Статические справочники | LFU | Частые справочники должны остаться |
Детали реализации в Redis: eviction policies.
Multi-tier caching
До сих пор мы рассматривали кэш как один слой. На практике уровни кэширования (CDN, локальный, внешний) часто используются одновременно. Это создаёт архитектуру, похожую на иерархию CPU-кэшей (L1/L2/L3), но с важным отличием: в CPU когерентность реализована аппаратно (MESI protocol), в web-архитектуре — вручную.
Клиент (браузер)
│
v
┌─────────┐
│ CDN │ ← L1: географически близко к пользователю
└────┬────┘
│ miss
v
┌──────────────┐
│ Rails App │
│ ┌──────────┐ │
│ │ Local │ │ ← L2: в памяти процесса, микросекунды
│ │ Cache │ │
│ └────┬─────┘ │
│ │ miss │
│ v │
│ ┌──────────┐ │
│ │ Redis │ │ ← L3: внешний кэш, ~1ms RTT
│ └────┬─────┘ │
│ │ miss │
│ v │
│ ┌──────────┐ │
│ │PostgreSQL│ │ ← Origin
│ └──────────┘ │
└──────────────┘
Каждый уровень «прогревается» от нижнего при miss: данные из PostgreSQL сохраняются в Redis, из Redis — в Local, ответ может закэшироваться на CDN.
Инвалидация multi-tier
С одним кэшом инвалидация сложная. С тремя уровнями — ещё сложнее. При обновлении данных инвалидировать нужно снизу вверх: сначала Redis, потом Local, потом CDN. Логика: если сначала инвалидировать Local, он при miss пойдёт в Redis, который ещё содержит устаревшие данные.
Подводный камень 1: Local cache — это N кэшей. 3 сервера по 20 workers = 60 независимых локальных кэшей. DEL в Redis — одна команда. Инвалидация 60 локальных кэшей требует механизма broadcast: pub/sub, где каждый процесс подписан на канал инвалидации, или просто короткий TTL (5-10 секунд) без явной инвалидации.
Подводный камень 2: CDN propagation delay. CDN — сотни edge-серверов по миру. Инвалидация не мгновенная: вызов API запускает распространение, которое занимает секунды. Пользователь в одном регионе увидит обновление раньше, чем в другом.
Подводный камень 3: Partial failure. Что если Redis инвалидирован, Local инвалидирован, а CDN API вернул таймаут? Откатывать нельзя — данные уже в PostgreSQL. Retry в background job увеличивает окно inconsistency.
Практический подход
Не пытаться обеспечить строгую инвалидацию всех уровней. Вместо этого — принять eventual consistency и использовать TTL как страховку.
CDN: TTL 1-5 минут (или не кэшировать динамику)
Local: TTL 5-30 секунд
Redis: TTL 5-15 минут + event-based инвалидация
Event-based инвалидация для Redis (один источник, просто). TTL для Local и CDN (много источников, сложно инвалидировать явно).
| Данные | CDN | Local | Redis |
|---|---|---|---|
| Статика (JS, CSS, картинки) | ✓ долгий TTL | — | — |
| Справочники (страны, категории) | ✓ | ✓ | ✓ |
| Профиль пользователя | ✗ | короткий TTL | ✓ + invalidation |
| Сессия | ✗ | ✗ | ✓ |
| Баланс счёта | ✗ | ✗ | ✗ |
Чем выше требования к консистентности — тем меньше уровней кэширования.
Распределённый кэш
Multi-tier решает проблему latency через иерархию уровней. Но остаётся вопрос объёма: что если данных больше, чем помещается в память одного Redis?
Проблема масштабирования
Redis настроен на 64GB, данных для кэширования — 500GB. Решение: распределить данные между несколькими серверами (партиционирование кэша).
Наивный подход: hash % N
def get_redis_server(key)
hash = crc32(key) # любая хеш-функция
server_index = hash % num_servers
servers[server_index]
endПри 3 серверах ключи распределяются равномерно. Проблема возникает при изменении числа серверов.
Добавление сервера: num_servers меняется с 3 на 4. Ключ с hash=7: 7 % 3 = 1 → 7 % 4 = 3 — переехал на другой сервер. При изменении с N на N+1 примерно N/(N+1) ключей меняют сервер. С 3 на 4 — около 75% ключей «переезжают».
Для кэша это означает: 75% запросов получат cache miss одновременно. Все пойдут в PostgreSQL. Это хуже, чем stampede одного ключа — это stampede всего кэша.
Consistent hashing
Алгоритм, при котором добавление сервера перемещает только 1/N ключей, а не (N-1)/N.
Пространство хешей представляется как кольцо. Серверы размещаются на кольце по хешу своего имени. Ключ хешируется и «идёт по кольцу по часовой стрелке» до первого сервера.
Кольцо 0..360:
0────90────180────270────360(=0)
S2 S3 S1
Ключ hash=100: идём вправо → первый сервер S3 (180)
Ключ hash=200: идём вправо → первый сервер S1 (270)
Ключ hash=300: идём вправо, через 0 → первый сервер S2 (90)
Добавление S4 на позицию 135:
0────90────135────180────270────360
S2 S4 S3 S1
Ключи 91-135 переехали с S3 на S4. Все остальные ключи остались на своих серверах. Переместилась только часть ключей между S2 и S3 — примерно 1/4 от ключей S3, а не 75% всего кэша.
Virtual nodes
Consistent hashing решает проблему массовой миграции, но создаёт проблему неравномерности при малом числе серверов. Если позиции серверов на кольце случайны, один может получить половину пространства, другие — по четверти.
Решение: каждый физический сервер размещается на кольце много раз — в виде виртуальных узлов (vnodes).
Без vnodes (3 сервера, случайные позиции):
S1 может отвечать за 50% ключей, S2 за 30%, S3 за 20%
С vnodes (3 сервера × 100 vnodes = 300 точек на кольце):
Каждый сервер «разбросан» равномерно, ~33% ключей каждому
На практике используют 100-200 vnodes на сервер. При добавлении сервера его vnodes «откусывают» маленькие кусочки от всех существующих серверов равномерно.
Redis Cluster использует похожий принцип: 16384 слота распределены между узлами. Детали: Redis Cluster.
Применённые концепции
| Концепция | Где применена |
|---|---|
| Temporal locality | Причина, почему кэш работает |
| Cache coherence | Проблема множества копий при локальном кэше |
| Read-your-writes | Нарушается TTL-only кэшем, восстанавливается event-based инвалидацией |
| Cache-aside | Стандартный паттерн: приложение управляет кэшем явно |
| Write-through / write-behind | Альтернативы cache-aside с разными trade-offs durability vs latency |
| Mutex / distributed lock | Защита от cache stampede |
| LRU / LFU | Политики вытеснения при заполнении кэша |
| Consistent hashing | Минимизация миграции при масштабировании кэша |
| Virtual nodes | Равномерность распределения в consistent hashing |
Sources
- Kleppmann, 2017, Designing Data-Intensive Applications, Chapter 1 — latency numbers, caching motivation
- Carlson, 2013, Redis in Action, Chapter 2 — cache-aside pattern, stampede solutions
- Karger et al., 1997, Consistent Hashing and Random Trees — оригинальная статья о consistent hashing
- DeCandia et al., 2007, Dynamo: Amazon’s Highly Available Key-value Store — virtual nodes в production
← Паттерны надёжности | Гарантии доставки в распределённых системах →