На пръв поглед caching изглежда като един от най-простите начини да ускорим приложение.
Имаме скъпа заявка към базата:
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. Обичайният код изглежда приблизително така:
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 seconds1000 заявки:
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-а. Тези две операции:
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 можем да направим нещо подобно:
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:
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 можем да съхраняваме:
{
"data": [...],
"expires_at": 1760000000
}Redis TTL може да бъде по-дълъг от логическия TTL.
Например:
logical TTL = 300 sec
Redis TTL = 3600 secПри четене:
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:
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 |
| Няколко сървъра трябва да координират rebuild | Distributed lock |
| Много retries удрят едновременно | Exponential backoff + jitter |
| Нуждаем се от защита при DB failure | Circuit 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
if not cache:
query_database()Това е най-простият начин да създадете Cache Stampede.
Лош вариант №2
if not cache:
sleep(1)
query_database()Това само измества проблема.
Лош вариант №3
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-ът изчезне.
Именно този момент често определя дали системата просто ще стане малко по-бавна или ще повлече със себе си базата данни и останалата инфраструктура.