Кэширование

Предпосылки: модели консистентности (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
Optane (persistent memory)~10 µs1,000×
SSD~100 µs10,000×
HDD~10 ms1,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)Отдельный серверНагрузка на originRTT остаётся
CDNEdge-серверыГеографическая 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
end

TTL — простейшая стратегия. Не нужно отслеживать, где данные изменились. Через 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)
end

3. Обход кэша для автора. После записи устанавливается короткоживущий маркер «этот пользователь только что писал». Для него читаем напрямую из PostgreSQL, минуя кэш. Сложнее в реализации, используется редко.

Cache-aside pattern

Мы обсудили когда и почему инвалидировать. Теперь — как организовать взаимодействие кэша с origin в коде.

Стандартный паттерн управления кэшем — cache-aside (или lazy loading). Приложение само решает, когда читать из кэша, когда из origin, когда инвалидировать.

Чтение:

  1. Проверить кэш
  2. Если hit — вернуть
  3. Если miss — прочитать из origin, записать в кэш, вернуть

Запись:

  1. Записать в origin
  2. Инвалидировать кэш (или обновить)
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
end

1000 запросов → 1 идёт в PostgreSQL, 999 ждут. Trade-off: добавили latency для ожидающих (минимум время перестроения кэша).

2. Stale-while-revalidate. Отдаём устаревшие данные, пока один запрос обновляет кэш в фоне.

Храним в кэше не только данные, но и время «мягкого» истечения. Если данные устарели, но ещё есть — отдаём stale, запускаем фоновое обновление.

Trade-off: пользователи могут видеть stale данные чуть дольше, но нет ожидания.

3. Раннее обновление (early expiration). Не ждём истечения TTL. Когда до истечения осталось мало времени, запускаем фоновое обновление. К моменту истечения новые данные уже в кэше. Stampede не происходит.

Когда можно игнорировать

Защита от stampede добавляет сложность. Три фактора определяют, нужна ли она:

  1. Время запроса к origin. Если запрос занимает 5ms, 100 одновременных запросов завершатся за ~5ms параллельно. PostgreSQL не заметит.

  2. Общий throughput. Если запросов мало, в момент истечения TTL придёт 2–3 запроса, не 1000.

  3. Популярность ключа. 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 (много источников, сложно инвалидировать явно).

ДанныеCDNLocalRedis
Статика (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 = 17 % 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

Паттерны надёжности | Гарантии доставки в распределённых системах