Паттерн cache-aside

Cache-aside — самый распространённый паттерн кэширования. Если вы один раз поймёте его схему, вы поймёте 80% кэширования вообще.

Идея проста: сначала спроси у кэша. Нет данных — сходи в БД и положи их в кэш. В следующий раз кэш ответит сам.

Cache-aside (его ещё называют «lazy loading», ленивая загрузка) — стратегия, при которой приложение само управляет кэшем. Кэш заполняется «по требованию»: только тем, что реально запрашивают. Это рабочая лошадка для большинства read-heavy сценариев.

Как это работает

   Запрос данных при cache-aside

   Приложение
       |
       | 1. GET cache:user:42
       v
   +--------+   попадание (hit)
   | Redis  | -----------------------> вернуть данные
   +--------+
       | промах (miss)
       | 2. SELECT ... FROM users
       v
   +--------+
   |   БД   | -----------------------> данные
   +--------+
       |
       | 3. SET cache:user:42 ... EX 300
       v
   положить в кэш и вернуть приложению

Шаги: (1) приложение спрашивает кэш; (2) при промахе идёт в БД; (3) кладёт результат в кэш с TTL и возвращает. При следующем запросе данные уже в кэше — БД не тревожат.

Команды в основе

# Попытка достать из кэша
GET cache:user:42
# (nil) -- промах, идём в БД, затем:
SET cache:user:42 "{...данные...}" EX 300
# Следующий GET cache:user:42 вернёт данные сразу

Демонстрация: cache-aside на Python

Смоделируем кэш поверх «медленной БД» и увидим выигрыш на повторных запросах:

import time

# "Медленная БД" — имитируем задержку
def slow_db_query(user_id):
    time.sleep(0.05)  # будто запрос к БД занял время
    return {"id": user_id, "name": f"User{user_id}"}

cache = {}   # это наш Redis
db_calls = 0

def get_user(user_id):
    global db_calls
    key = f"cache:user:{user_id}"
    if key in cache:                 # 1. спрашиваем кэш
        return cache[key], "HIT"
    db_calls += 1                    # 2. промах -> идём в БД
    data = slow_db_query(user_id)
    cache[key] = data                # 3. кладём в кэш
    return data, "MISS"

# Три запроса к одному пользователю
for i in range(3):
    data, status = get_user(42)
    print(f"Запрос {i+1}: {status} -> {data}")

print(f"\nОбращений к БД: {db_calls} (а запросов было 3)")
print("Cache-aside: БД потревожена один раз, дальше отвечает кэш.")

Первый запрос — промах и поход в БД. Два следующих — попадания, мгновенные и без нагрузки на БД. В этом вся суть.

Как работает под капотом

В cache-aside кэш и БД не связаны напрямую — синхронизацию полностью контролирует приложение. Кэш не знает о существовании БД. Это даёт гибкость (кэшируем что угодно и как угодно), но накладывает ответственность: приложение должно правильно инвалидировать устаревшие данные. TTL — главный инструмент: даже если вы забыли что-то обновить, через срок жизни ключ исчезнет и данные перечитаются.

Частые ошибки

  • Кэш без TTL. Данные устареют и «застрянут» в кэше навсегда.
  • Кэширование промахов не предусмотрено. Если данных нет и в БД, каждый запрос будет ходить в БД (см. урок про защиту кэша).
  • Несогласованность при записи. Обновили БД, но забыли инвалидировать кэш — пользователь видит старое.

Best practices

  • Всегда задавайте TTL — это «страховка» от застаревших данных.
  • При обновлении данных инвалидируйте (удаляйте) соответствующий кэш-ключ.
  • Кэшируйте то, что часто читают и редко меняют, — выигрыш максимален.

Итог: Cache-aside — приложение спрашивает кэш, при промахе идёт в БД и заполняет кэш. Простой, гибкий, самый частый паттерн. Его слабые места — инвалидация и застаревание — лечатся грамотным TTL.

Инвалидация: ахиллесова пята кэша

Есть известная шутка, что в программировании всего две сложные вещи: инвалидация кэша и придумывание имён. Cache-aside перекладывает инвалидацию на ваше приложение, поэтому стоит сразу выбрать стратегию, как держать кэш свежим.

  • По TTL. Самый простой путь: данные живут заданное время и сами протухают. Подходит, когда небольшая задержка свежести допустима (каталог, статистика).
  • Явная инвалидация. При изменении данных приложение удаляет соответствующий кэш-ключ через DEL. Свежесть мгновенная, но нужно не забыть удалить ключ во всех местах, где данные меняются.
  • Версионирование ключа. В имя ключа включают версию или метку времени изменения; при обновлении версия растёт, и старый ключ просто перестаёт запрашиваться (а потом вытесняется).

На практике эти подходы комбинируют: явная инвалидация при записи плюс TTL как страховка на случай, если какой-то путь обновления данных её пропустил. Такой «ремень и подтяжки» — разумный дефолт для большинства веб-приложений, где данные читают намного чаще, чем меняют.

Проверьте себя
1. Какова последовательность действий в паттерне cache-aside при промахе кэша?
AЗаписать в БД, затем в кэш, затем вернуть
BСпросить кэш -> промах -> запросить БД -> положить результат в кэш -> вернуть
CСначала всегда идти в БД, кэш использовать только для записи
DУдалить ключ из кэша и завершить запрос
2. Почему в cache-aside кэш-ключам важно задавать TTL?
ATTL ускоряет чтение из кэша
BБез TTL Redis откажется хранить ключ
CTTL служит страховкой: устаревшие данные сами исчезнут и будут перечитаны из БД
DTTL шифрует данные в кэше