Cache Stampede: как 10 000 заявки могат да ударят базата данни едновременно

На пръв поглед caching изглежда като един от най-простите начини да ускорим приложение.

Имаме скъпа заявка към базата:

SQL
SELECT *
FROM products
WHERE category_id = 42;

Поставяме резултата в Redis за 5 минути: products:category:42 → TTL 300 seconds

Докато ключът съществува, приложението не докосва базата. Просто.

Но какво се случва в секундата, в която тези 5 минути изтекат? Ако приложението има 10 000 едновременни заявки, всички те могат да видят едно и също: CACHE MISS

И вместо Redis да предпази базата от 10 000 заявки, всички 10 000 заявки отиват към нея едновременно.

Това явление се нарича Cache Stampede, известно още като Thundering Herd или Cache Miss Storm.

И най-интересното е, че проблемът може да възникне дори когато cache-ът работи напълно коректно.

1. Как изглежда проблемът

Да приемем, че имаме API: GET /api/products

Заявката извлича данни от MySQL и резултатът се кешира в Redis. Обичайният код изглежда приблизително така:

Python
data = redis.get("products")

if data:
    return data

data = database.query("""
    SELECT *
    FROM products
    WHERE active = 1
""")

redis.setex(
    "products",
    300,
    serialize(data)
)

return data

При нормална работа имаме:

Client -> Application -> Redis:
if HIT -> return
if MISS -> Database

Това е прекрасно, докато cache-ът е наличен. Но нека разгледаме момента, в който TTL изтече.

Преди изтичането

Redis:
products → DATA
TTL: 3 seconds

1000 заявки:

Request → Redis → HIT
Request → Redis → HIT
Request → Redis → HIT
...

Database:

0 queries

След изтичането

Ключът вече не съществува:

Redis:
products → MISS

Идват 10 000 заявки:

Request 1  → Redis MISS → DB
Request 2  → Redis MISS → DB
Request 3  → Redis MISS → DB
Request 4  → Redis MISS → DB
...
Request 10000 → Redis MISS → DB

Получаваме: 10 000 HTTP requests -> 10 000 Redis MISS -> 10 000 database queries. И тук се появява истинският проблем.

2. Защо cache-ът не ни защитава?

Това е важният момент. Cache-ът предпазва базата само когато данните вече са кеширани. Той не координира кой има право да попълни cache-а. Тези две операции:

Python
value = redis.get(key)
if value is None:
  value = database.query(...)

Представете си:

Request A → GET cache → MISS
Request B → GET cache → MISS
Request C → GET cache → MISS
Request D → GET cache → MISS

Нито една от заявките още не е записала резултата. Следователно всички смятат: „Аз трябва да заредя данните.“. Това е race condition.

3. Cache Stampede не изисква 10 000 потребители

Това е една от най-важните особености. Не е необходимо да имаме 10 000 различни потребители. Може да имаме 1000 users, но всеки да генерира 10 concurrent requests или 100 users × 100 requests

Важното е колко заявки едновременно достигат cache miss-а, а не просто броят потребители.

4. Най-опасният вариант: скъпата заявка

Да приемем, че DB query-то отнема: 500 ms и database server може да обработва приблизително: 200 такива заявки/секунда. При cache miss получаваме: 10 000 requests, дори ако само част от тях успеят да се изпълнят паралелно, базата внезапно получава огромен burst.

Това може да доведе до:

  • изчерпване на DB connections
  • CPU spike
  • lock contention
  • увеличаване на latency
  • timeout-и
  • retry-и
  • още заявки
  • cascading failure

И тук се появява особено опасният сценарий: Cache miss -> Database overload -> Slow database -> Application timeout -> Client retry -> More requests -> More database load

Cache Stampede вече се е превърнал в cascading failure.

5. Решение №1: Distributed Lock

Най-интуитивното решение е: Само една заявка има право да зареди cache-а.

Останалите трябва да чакат. Например Redis lock: products:lock

Първата заявка се опитва да го създаде: SET products:lock 1 NX EX 10

NX означава: Създай ключа само ако не съществува. Следователно:

Request A → acquire lock → SUCCESS
Request B → acquire lock → FAIL
Request C → acquire lock → FAIL
Request D → acquire lock → FAIL

Само A отива към базата:

A → DB
A → Redis SET
A → response

Останалите могат да изчакат cache-а.

6. Python + Redis пример

С redis-py можем да направим нещо подобно:

Python
import time
import json

def get_products(redis, db):

    cache_key = "products"
    lock_key = "lock:products"

    cached = redis.get(cache_key)

    if cached:
        return json.loads(cached)

    lock_acquired = redis.set(
        lock_key,
        "1",
        nx=True,
        ex=10
    )

    if lock_acquired:

        try:
            # Double check
            cached = redis.get(cache_key)

            if cached:
                return json.loads(cached)

            data = db.query("""
                SELECT *
                FROM products
                WHERE active = 1
            """)

            redis.setex(
                cache_key,
                300,
                json.dumps(data)
            )

            return data

        finally:
            redis.delete(lock_key)

    # Someone else is loading the cache
    for _ in range(50):

        time.sleep(0.1)

        cached = redis.get(cache_key)

        if cached:
            return json.loads(cached)

    raise TimeoutError("Cache rebuild timeout")

Тук има една много важна подробност. Double check- след като получим lock-а, отново проверяваме cache-а.

Защото между първата проверка и придобиването на lock-а друга заявка може вече да е попълнила cache-а. Без double check можем ненужно да изпълним DB query.

7. Но distributed lock има проблеми

Lock-ът не е магическо решение. Представете си Request A -> получава lock -> започва DB query -> DB query зависва. Ако lock-ът няма TTL: products:lock, може да остане завинаги.

Тогава никой повече няма да може да обнови cache-а. Затова lock-ът трябва да има expiration: SET lock:products 1 NX EX 10

Но и това създава нов проблем. Ако DB операцията отнеме повече от 10 секунди:

T0       lock acquired
T10      lock expires
T11      Request B acquires lock

Сега имаме два worker-а, които мислят, че притежават lock-а. Затова при по-сложни distributed locking системи се използват ownership tokens, leases и fencing tokens. За обикновен cache rebuild обаче кратък lock с подходящ TTL често е достатъчен.

8. Решение №2: Jitter

Друг подход е да не позволяваме на огромен брой cache entries да изтекат едновременно. Представете си: TTL = 300 seconds

Ако 100 000 ключа са създадени приблизително по едно и също време:

300s → expire
300s → expire
300s → expire
300s → expire

получаваме огромен burst, вместо: redis.setex(key, 300, value), можем да използваме произволен TTL:

Python
import random
ttl = 300 + random.randint(-30, 30)
redis.setex(
  key,
  ttl,
  value
)

Получаваме:

key A → 281 sec
key B → 316 sec
key C → 294 sec
key D → 327 sec
key E → 302 sec

Вместо всички ключове да изчезнат едновременно, expiration-ите се разпределят във времето. Това се нарича TTL jitter.

9. Jitter не решава всичко

Това е важно. Jitter е много полезен при много различни cache keysно, но не решава напълно проблема при един изключително популярен key. Например homepage, ако имаме един key homepage → TTL 300 и той изтече, няма значение дали TTL-ът е бил 290 или 310.

В момента на изтичането му всички заявки към този key могат да получат MISS.

За този случай са по-подходящи:

  • locking
  • single-flight
  • stale-while-revalidate

10. Решение №3: Stale-While-Revalidate

Това е едно от най-практичните решения. Идеята е да не е задължително cache-ът да стане неизползваем точно в момента, в който TTL изтече.

Вместо: fresh → expired → delete използваме две понятия fresh, stale, например:

Fresh TTL: 300 sec
Stale TTL: 3600 sec

След 5 минути данните са технически остарели, но все още ги пазим.

Получаваме: 0–300 sec -> FRESH -> 300–3600 sec -> STALE -> 3600 sec ->DELETE

11. Как работи Stale-While-Revalidate

При fresh cache: Request -> Redis -> FRESH -> Response

При stale cache: Request -> Redis -> STALE -> Return old data immediately -> Start refresh

Това означава, че потребителят не чака database query. Един worker обновява данните във фонов режим.

12. Практически модел с Redis

Вместо да съхраняваме само products → JSON можем да съхраняваме:

JSON
{
  "data": [...],
  "expires_at": 1760000000
}

Redis TTL може да бъде по-дълъг от логическия TTL.

Например:

logical TTL = 300 sec
Redis TTL = 3600 sec

При четене:

Python
if now < expires_at:
    return data

Ако now >= expires_at връщаме stale data и задействаме refresh.

13. Най-важната идея

Тук разделяме Cache retention от Data freshness. Това е изключително полезен архитектурен принцип.

Например: Данните са приемливи за 5 минути. Но ако са остарели, предпочитаме да ги покажем още 10 минути, вместо да блокираме всички потребители, докато ги обновим.

Това е особено полезно за:

  • каталози
  • статистики
  • rankings
  • dashboards
  • exchange rates
  • weather data
  • API responses
  • AI-generated results

Разбира се, не е подходящо за всякакви данни. За банкова сметка, наличност на конкретен билет, payment status. Stale data може да бъде неприемлива.

14. Решение №4: Single-Flight

Single-flight решава проблема на малко по-различно ниво.

Идеята е: Ако 100 заявки едновременно поискат един и същ скъп резултат, само една реално изпълнява операцията. Останалите използват нейния резултат.

Например:

Request A ─┐
Request B ─┤
Request C ─┤
Request D ─┼──→ SINGLE FLIGHT ──→ DB
Request E ─┤                         ↓
Request F ─┘                       result
                                    ↓
                       A B C D E F receive result

Това е особено полезно не само за databases. Може да се използва за: DB query, HTTP API, LLM request, file generation, expensive computation

15. Single-Flight срещу Distributed Lock

Двете идеи си приличат, но не са идентични.

Distributed Lock

Казва: Само един процес може да изпълнява критичната секция.

Single-Flight

Казва: Всички заявки, които чакат един и същ резултат, трябва да споделят едно изпълнение.

Това е по-скоро request coalescing. Например: 100 requests -> same key -> one expensive operation -> 100 responses. Докато lock-ът обикновено координира достъпа до ресурс.

16. Single-Flight при AI приложения

Тук моделът става особено интересен. Представете си приложение с LLM.

100 потребители изпращат: „Give me today’s top 10 songs.„. Ако приложението няма deduplication тогава 100 requests = 100 LLM calls.

При скъп модел това означава:

  • 100× token consumption
  • 100× latency/load
  • 100× потенциална цена

Но можем да създадем fingerprint:

Python
import hashlib

def request_key(prompt):
    return hashlib.sha256(
        prompt.strip().lower().encode()
    ).hexdigest()

Получаваме: llm:request:8e73…

И първата заявка става owner на операцията. Останалите чакат същия резултат. Това е комбинация от cache + single-flight + deduplication.

17. Какво става при грешка?

Това е мястото, където много имплементации се провалят. Да приемем:

Request A → lock
Request A → DB
Request A → ERROR

Ако просто върнем грешка и оставим: cache = empty следващите заявки отново ще видят: MISS и ще опитат. При тежка повреда на базата това може да създаде MISS -> DB error -> MISS -> DB error -> MISS -> DB error.

Затова при някои системи е полезно да се използва negative caching или кратък backoff след failure.

Например: products:error → 5 sec. Така системата не атакува база, която вече е недостъпна, хиляди пъти в секунда.

18. Retry може да направи проблема по-лош

Да приемем: 10 000 requests. Cache-ът изтича. Получаваме: 10 000 DB requests. Database започва да timeout-ва. Клиентите имат retry = 3

Сега: 10 000 original + 30 000 retries = 40 000 requests

Ако retry механизмът няма exponential backoff и jitter, всички могат да се върнат приблизително едновременно.

Получаваме Cache Stampede -> DB overload -> Timeout -> Retry Storm -> DB collapse.

Следователно cache стратегията не трябва да се разглежда изолирано от retry стратегията.

19. Exponential Backoff + Jitter

При retry вместо:

retry after 1 sec
retry after 1 sec
retry after 1 sec

използваме например:

1 sec
2 sec
4 sec
8 sec

и добавяме случайност:

1.2 sec
2.7 sec
4.1 sec
7.4 sec

Така клиентите не се връщат едновременно.

20. Най-добрата стратегия обикновено е комбинация

В реална production система рядко разчитаме на само един механизъм.

TTL jitter
+
stale-while-revalidate
+
distributed locking
+
single-flight
+
retry backoff

Това вече е цялостна защита срещу burst натоварване.

21. Коя техника кога да използваме?

ПроблемПодход
Много ключове изтичат едновременноTTL jitter
Един key е изключително популяренSingle-flight / lock
Не можем да позволим потребителят да чакаStale-while-revalidate
Скъпа операция трябва да се изпълни само веднъжSingle-flight
Няколко сървъра трябва да координират rebuildDistributed lock
Много retries удрят едновременноExponential backoff + jitter
Нуждаем се от защита при DB failureCircuit breaker / backoff / negative caching
Данните могат да бъдат леко остарелиStale-while-revalidate
Данните трябва да са винаги актуалниLock + synchronous refresh

22. Един практически production pattern

За много API приложения бих използвал следната логика:

1. GET Redis
2. Fresh?- YES
3. Return- NO
4. Stale?- YES
5. Return stale
6. Trigger background refresh- NO
7. Acquire lock
8. Double-check Redis
9. Query database
10. Write cache
11. Release lock
12. Return

Това има една много важна характеристика, че нормалният потребителски request не трябва да чака database refresh, когато това не е необходимо.

23. Какво да НЕ правим

Лош вариант №1

Python
if not cache:
    query_database()

Това е най-простият начин да създадете Cache Stampede.

Лош вариант №2

Python
if not cache:
    sleep(1)
        query_database()

Това само измества проблема.

Лош вариант №3

Python
if not cache:
    query_database()
        cache.set(...)

Без locking отново имаме race condition.

Лош вариант №4

TTL = 5 minutes за абсолютно всички ключове. Ако системата има много едновременно създадени записи, това може да създаде синхронизиран expiration burst.

Лош вариант №5

Lock без expiration SET lock, ако worker-ът умре, lock-ът може да остане завинаги.

24. Най-важният принцип

Cache-ът не е просто fast storage. При мащабни системи той често е механизъм за контролиране на натоварването. И това е съществената разлика.

Лошо проектираният cache може да изглежда прекрасно при 100 requests/sec и да се провали точно когато достигнете 10 000 requests/sec. Защото проблемът не е средната скорост. Проблемът е burst-а.

25. Cache Stampede всъщност е проблем на координацията

На повърхността изглежда като caching проблем. Но в основата си е проблем на: concurrency + coordination.

Имаме много независими workers, които едновременно вземат едно и също решение, че cache-ът липсва, тогава аз ще го заредя.

Правилното поведение е N requests -> 1 expensive operation -> N responses.

Точно тази трансформация е същността на:

  • distributed locks
  • single-flight
  • request coalescing
  • stale-while-revalidate
  • jitter

Заключение

Cache Stampede е пример за проблем, който обикновено не се вижда по време на разработката.

При 10 заявки всичко работи, но при 1000 заявки вероятно пак работи.

Но когато TTL изтече в неподходящия момент, системата може да се превърне от 10 000 requests, 10 000 Redis reads, 0 DB queries в 10 000 requests, 10 000 Redis misses, 10 000 DB queries.

Самият cache не предотвратява това. Трябва да контролираме какво се случва при cache miss. Най-практичните инструменти са:

  • TTL Jitter
  • разпределя expiration-ите
  • Distributed Lock
  • позволява само един rebuild
  • Single-Flight
  • обединява еднаквите concurrent операции
  • Stale-While-Revalidate
  • избягва чакането при refres
  • Exponential Backoff + Jitter
  • предотвратява retry storm

И най-важното е, че не е достатъчно да проектираме системата за нормалния случай. Трябва да проектираме и момента, в който cache-ът изчезне.

Именно този момент често определя дали системата просто ще стане малко по-бавна или ще повлече със себе си базата данни и останалата инфраструктура.

Вашият коментар

Time limit is exhausted. Please reload the CAPTCHA.