core code helper

Кешування з Redis: cache-aside, TTL та інвалідація без болю

Як додати кеш, який справді прискорює сервіс і не віддає застарілі дані: патерни кешування, вибір TTL, захист від «лавини» запитів і типові помилки.

0 імпортували Імпортувати собі

У комп'ютерних науках дві складні речі: інвалідація кешу і придумування назв. Кеш легко додати і дуже легко зробити джерелом багів: користувач змінив профіль, а бачить старі дані ще годину. Розберемо, як робити це правильно, на прикладі Redis і Python.

Cache-aside — патерн за замовчуванням

Застосунок сам вирішує, коли читати з кешу і коли з бази:

  1. Шукаємо в кеші.
  2. Знайшли — повертаємо.
  3. Не знайшли — читаємо з бази, кладемо в кеш з TTL, повертаємо.
import asyncio
import json
import redis.asyncio as redis

r = redis.Redis(host="cache", decode_responses=True)

async def get_product(product_id: int) -> dict:
    key = f"product:v2:{product_id}"
    cached = await r.get(key)
    if cached is not None:
        return json.loads(cached)

    product = await db.fetch_product(product_id)
    if product is None:
        await r.set(key, json.dumps(None), ex=60)   # кешуємо і «немає», але коротко
        return None
    await r.set(key, json.dumps(product), ex=600)
    return product
Порада

Додавайте версію в ключ (product:v2:...). Змінили формат даних у кеші — збільшили версію, і старі записи просто «зникнуть» з ужитку, не ламаючи новий код.

Інвалідація: видаляти, а не оновлювати

При зміні даних видаляйте ключ, а не записуйте нове значення:

async def update_product(product_id: int, data: dict):
    await db.update_product(product_id, data)
    await r.delete(f"product:v2:{product_id}")

Чому не set нового значення? Два паралельні оновлення можуть записати в кеш у «неправильному» порядку, і там надовго залишиться застаріла версія. Видалення безпечніше: наступне читання візьме актуальні дані з бази.

Увага

Видаляйте з кешу після успішного коміту транзакції в базі. Якщо видалити до коміту, паралельний запит може встигнути прочитати старі дані з бази і знову покласти їх у кеш.

TTL — страховка, а не стратегія

Навіть з явною інвалідацією ставте TTL: якщо десь забули видалити ключ, застарілі дані проживуть обмежений час.

Дані Типовий TTL
Довідники, налаштування 10–60 хв
Картки товарів, профілі 5–15 хв + явна інвалідація
Результати важких звітів за розкладом перерахунку
Сесії час життя сесії
Лічильники, rate limit вікно ліміту

Додавайте випадковий розкид до TTL (600 ± 60 секунд), щоб тисячі ключів, створених одночасно, не протухли в одну мить.

Лавина запитів (cache stampede)

Популярний ключ протух — і 500 запитів одночасно йдуть у базу рахувати одне й те саме. Захист — короткий лок: перерахунок робить лише один запит.

async def get_with_lock(key: str, loader, ttl: int = 600):
    cached = await r.get(key)
    if cached is not None:
        return json.loads(cached)

    lock_key = f"lock:{key}"
    if await r.set(lock_key, "1", nx=True, ex=10):     # отримали лок
        try:
            value = await loader()
            await r.set(key, json.dumps(value), ex=ttl)
            return value
        finally:
            await r.delete(lock_key)

    for _ in range(50):                                # інші чекають на результат
        await asyncio.sleep(0.1)
        cached = await r.get(key)
        if cached is not None:
            return json.loads(cached)
    return await loader()                              # запасний варіант

Що не варто кешувати

  • дані, які змінюються частіше, ніж читаються;
  • персональні дані без потреби — кеш теж треба захищати й чистити;
  • результати з помилками (500 від зовнішнього API) — інакше помилка «залипне» на весь TTL.

Налаштування Redis для кешу

maxmemory 2gb
maxmemory-policy allkeys-lfu     # витісняти найрідше використовувані ключі
Важливо

Якщо Redis використовується лише як кеш, застосунок має працювати і без нього — просто повільніше. Обгорніть звернення до кешу в try/except з таймаутом: недоступний кеш не повинен класти сервіс.

Як зрозуміти, що кеш працює

  • hit ratio = hits / (hits + misses) з INFO stats (keyspace_hits, keyspace_misses);
  • латентність ендпоінтів до і після;
  • навантаження на базу.

Hit ratio 30% — кеш, імовірно, кешує не те або з занадто коротким TTL. 95%+ для довідкових даних — нормально.

Чому при оновленні даних краще видалити ключ із кешу, ніж записати туди нове значення?

При паралельних оновленнях записи в кеш можуть прийти в іншому порядку, ніж коміти в базу, і в кеші залишиться застаріла версія до кінця TTL. Видалення змушує наступне читання взяти актуальні дані з бази.

Збережіть статтю собі

Увійдіть, щоб імпортувати її у власні нотатки, відмічати вивчене, ставити лайки й питати спільноту. Реєстрація безкоштовна: вхід через Google займає кілька секунд.

Увійти через Google Інші способи входу