Пошук уроків, статей та іншого контенту
Розберете ефект одночасного завершення TTL та застосуєте блокування, jitter і прогрівання кешу.
Cache stampede — це ситуація, коли велика кількість запитів одночасно виявляє, що потрібного значення немає в кеші, і всі звертаються до повільного джерела даних: бази, зовнішнього API або сервісу.
Типовий сценарій:
Значення записане в кеш із TTL 60 секунд.
Через 60 секунд запис стає протермінованим.
Одночасно приходять сотні запитів.
Кожен запит отримує cache miss.
Кожен запит запускає власний запит до бази.
База або downstream-сервіс отримує різкий сплеск навантаження.
Зростають latency та кількість помилок.
Частина запитів може завершитися тайм-аутом, що ще більше погіршує ситуацію.
Проблема не лише в тому, що кеш не містить значення. Проблема в тому, що багато клієнтів одночасно намагаються відновити одне й те саме значення.
Якщо багато ключів записуються приблизно в один момент із однаковим TTL, вони можуть завершити життєвий цикл майже одночасно.
Наприклад, після деплою застосунок прогріває кеш:
key:product:1 TTL: 3600 секунд
key:product:2 TTL: 3600 секунд
key:product:3 TTL: 3600 секундЧерез годину значна частина цих ключів стане недійсною в одному часовому вікні. Якщо в цей момент буде високий трафік, система отримає масовий промах.
Окремий ключ також може створити проблему. Якщо популярний ключ протермінувався, сотні одночасних запитів можуть спробувати побудувати його значення.
Найпоширеніший підхід — дозволити лише одному запиту завантажувати значення для конкретного ключа.
Інші запити мають:
дочекатися результату першого запиту;
отримати значення після його запису в кеш;
не звертатися до джерела даних повторно.
Цей підхід часто називають:
request coalescing;
single-flight;
пер-ключовим блокуванням;
захистом від dogpile effect.
Важливо блокувати саме конкретний ключ, а не весь кеш. Якщо заблокувати глобальним mutex усі ключі, повільне завантаження одного об'єкта зупинить роботу з усіма іншими.
Надійний алгоритм має такі кроки:
Перевірити кеш.
Якщо значення актуальне — повернути його.
Перевірити, чи вже існує завантаження цього ключа.
Якщо існує — дочекатися його.
Якщо не існує — створити блокування.
Повторно перевірити кеш після отримання блокування.
Завантажити значення з джерела.
Записати його в кеш.
Звільнити блокування.
Повернути результат.
Повторна перевірка після отримання блокування необхідна через race condition:
запит A побачив промах;
запит B також побачив промах;
A першим створив блокування;
A завантажив і записав значення;
B дочекався блокування;
без повторної перевірки B знову звернувся б до бази.
Нижче наведено повністю runnable-приклад для Node.js. Він демонструє:
кеш із TTL;
випадковий jitter;
блокування завантаження на рівні ключа;
очікування паралельних запитів;
прогрівання кешу.
const cache = new Map();
const inFlight = new Map();
const BASE_TTL_MS = 100;
const JITTER_MS = 50;
let originCalls = 0;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function ttlWithJitter() {
// Додаємо випадковий інтервал до базового TTL.
return BASE_TTL_MS + Math.floor(Math.random() * (JITTER_MS + 1));
}
async function loadFromOrigin(key) {
originCalls += 1;
// Імітуємо повільний запит до бази або зовнішнього сервісу.
await sleep(30);
return {
key,
value: `value-for-${key}`,
loadedAt: Date.now(),
};
}
async function getOrLoad(key) {
const now = Date.now();
const cached = cache.get(key);
if (cached && cached.expiresAt > now) {
return cached.value;
}
const existingLoad = inFlight.get(key);
if (existingLoad) {
// Інші запити очікують той самий Promise.
return existingLoad;
}
const loadPromise = (async () => {
// Подвійна перевірка після отримання "блокування".
const freshCached = cache.get(key);
if (freshCached && freshCached.expiresAt > Date.now()) {
return freshCached.value;
}
const value = await loadFromOrigin(key);
cache.set(key, {
value,
expiresAt: Date.now() + ttlWithJitter(),
});
return value;
})();
inFlight.set(key, loadPromise);
try {
return await loadPromise;
} finally {
// Блокування треба звільнити і в разі успіху, і в разі помилки.
inFlight.delete(key);
}
}
async function warmUp(keys) {
// Прогріваємо популярні ключі до приходу користувацького трафіку.
await Promise.all(keys.map((key) => getOrLoad(key)));
}
async function main() {
const key = "product:42";
await warmUp([key]);
// Чекаємо, доки прогрітий запис протермінується.
await sleep(BASE_TTL_MS + JITTER_MS + 10);
// Імітуємо 100 одночасних запитів після завершення TTL.
const results = await Promise.all(
Array.from({ length: 100 }, () => getOrLoad(key))
);
const allResultsAreEqual = results.every(
(result) => result.value === results[0].value
);
console.log("Запитів:", results.length);
console.log("Звернень до джерела:", originCalls);
console.log("Усі результати однакові:", allResultsAreEqual);
console.log("Записів у кеші:", cache.size);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});Після прогрівання і завершення TTL сто запитів не створюють сто звернень до джерела. Для цього ключа відбувається лише одне повторне завантаження.
Це блокування працює тільки в межах одного процесу. Якщо застосунок запущено на десяти інстансах, кожен інстанс може створити власне завантаження. Для розподіленої системи потрібне спільне блокування.
У кластері блокування має бути доступним усім інстансам. Зазвичай для цього використовують окреме швидке сховище, наприклад Redis.
Концептуально операція виглядає так:
lockKey = "lock:product:42"
token = випадковий унікальний токен
SET lockKey token NX PX 5000Сенс параметрів:
NX — створити ключ лише якщо його ще немає;
PX 5000 — автоматично протермінувати блокування через 5 секунд;
token — значення, яке ідентифікує власника блокування.
Якщо команда успішна, інстанс став власником блокування і може завантажувати дані.
Якщо команда неуспішна, інстанс має:
зачекати короткий інтервал;
повторно перевірити кеш;
за потреби повторити очікування;
не завантажувати дані паралельно без крайньої необхідності.
Блокування може залишитися назавжди, якщо власник:
аварійно завершився;
втратив мережеве з'єднання;
завис під час запиту до джерела;
був перезапущений під час завантаження.
TTL гарантує, що інші інстанси згодом зможуть продовжити роботу.
TTL блокування має бути більшим за очікуваний час завантаження, але не настільки великим, щоб аварія надовго заблокувала ключ.
Власник не повинен безумовно видаляти lockKey. Можлива ситуація:
Інстанс A отримав блокування.
TTL блокування завершився.
Інстанс B отримав нове блокування.
Інстанс A завершив роботу і видалив блокування B.
Тому під час звільнення треба видаляти ключ лише тоді, коли його значення все ще дорівнює власному token. Перевірка і видалення повинні бути атомарними.
Також варто враховувати, що TTL блокування може завершитися ще до завершення завантаження. Збільшення TTL, продовження lease або механізми fencing потрібні в системах із довгими та критичними операціями.
Jitter — це випадкова зміна TTL навколо базового значення.
Замість:
TTL = 3600 секундможна використовувати:
TTL = 3600 + випадкове число від 0 до 300 секундТоді різні екземпляри одного типу даних завершуватимуться в різний час.
Абсолютний jitter:
TTL = baseTTL + random(0, jitter)Наприклад:
TTL = 3600 + random(0, 300)Відносний jitter:
TTL = baseTTL × random(0.9, 1.1)Наприклад, для TTL 3600 секунд фактичне значення буде в діапазоні приблизно від 3240 до 3960 секунд.
Додатний jitter часто простіше контролювати: запис не протермінується раніше за гарантований мінімум. Відносний jitter краще підходить, коли TTL суттєво відрізняється для різних класів даних.
Jitter розподіляє промахи в часі, але не вирішує всі проблеми:
він не захищає популярний ключ, який протермінувався прямо зараз;
він не замінює блокування;
він не допомагає, якщо кеш масово очищено;
він не захищає від повільного джерела даних під час першого завантаження.
Практична комбінація:
jitter зменшує ймовірність одночасного завершення TTL, а блокування не дозволяє паралельно перебудовувати один ключ.
Прогрівання кешу — це завчасне завантаження даних до того, як на них прийде основний трафік.
Це корисно в таких ситуаціях:
після деплою;
після очищення кешу;
перед початком прогнозованого пікового навантаження;
після перемикання на новий кластер;
для набору найпопулярніших ключів.
Простий алгоритм прогрівання:
Визначити список популярних ключів.
Завантажувати їх із контрольованою паралельністю.
Записати результати в кеш із jitter.
Перевірити помилки й повторити невдалі операції.
Не створювати надмірне навантаження на джерело.
Не варто безконтрольно запускати тисячі запитів для прогрівання. Саме прогрівання може створити stampede, якщо всі ключі одночасно завантажуються з бази.
Прогрівання не гарантує, що дані залишаться в кеші:
ключ може бути видалений через memory eviction;
TTL може завершитися раніше через помилку в конфігурації;
з'являється новий популярний ключ;
кілька інстансів одночасно запускають однакове прогрівання;
джерело повертає помилку під час прогрівання.
Тому прогрівання потрібно поєднувати з блокуванням і jitter.
Якщо запит до джерела завершився помилкою, важливо правильно поводитися з блокуванням:
завжди звільняти блокування в finally;
не записувати помилковий результат як звичайне значення;
не запускати нескінченний цикл миттєвих повторів;
застосовувати backoff для повторних спроб;
контролювати тайм-аут завантаження.
Інакше можливі дві протилежні проблеми:
блокування залишиться назавжди;
після помилки всі очікувачі одразу повторять запит і створять новий сплеск.
Для деяких типів даних можна тимчасово кешувати негативний результат або помилку з коротким TTL. Це називають negative caching. Такий підхід треба застосовувати обережно, щоб короткочасна помилка не стала довготривалою для всіх клієнтів.
Зазвичай підходить:
довгий TTL;
jitter;
прогрівання;
блокування під час оновлення.
Потрібно особливо уважно налаштувати:
блокування на рівні конкретного ключа;
тайм-аут завантаження;
обмеження кількості очікувачів;
захист джерела даних;
моніторинг часу оновлення.
Надто довгий TTL може бути неприйнятним. У такому випадку кеш треба оновлювати контрольовано:
централізованим оновленням;
подіями про зміну даних;
плановим прогріванням;
блокуванням під час перебудови.
Головний принцип залишається тим самим: одночасний доступ не повинен перетворюватися на одночасне виконання важкої операції.
Щоб виявити cache stampede, корисно вимірювати:
кількість cache hit;
кількість cache miss;
кількість операцій завантаження з джерела;
кількість запитів, які чекали на блокування;
середній і максимальний час очікування блокування;
кількість помилок під час оновлення;
кількість прострочених блокувань;
час перебудови конкретного ключа.
Особливо важливе співвідношення:
кількість cache miss
до
кількості звернень до джерелаЯкщо сто одночасних промахів для одного ключа породжують сто запитів до бази, захист не працює. У нормальній single-flight реалізації вони мають породити одну операцію завантаження або невелику кількість операцій у разі помилки й повторних спроб.
Глобальний mutex серіалізує завантаження всіх ключів. У результаті один повільний ключ блокує незалежні запити до інших ключів.
Потрібне блокування на рівні cache key.
Перевірка кешу лише до отримання блокування створює race condition. Кожен очікувач після завершення блокування може знову звернутися до джерела.
Після падіння власника ключ може залишитися заблокованим назавжди. Розподілене блокування повинно мати термін дії.
Старий власник може видалити нове блокування. Для безпечного release потрібна перевірка токена власника.
Jitter зменшує синхронність завершення TTL, але не захищає від одночасного промаху одного дуже популярного ключа.
Масове прогрівання може саме перевантажити базу. Потрібно контролювати кількість одночасних операцій.
Якщо блокування не звільняється в finally, наступні запити можуть зависати або чекати завершення TTL.
Велика кількість запитів може одночасно чекати один повільний origin-запит. Потрібно мати тайм-аути та визначену поведінку для очікувачів.
Cache stampede виникає, коли багато запитів одночасно бачать промах кешу й паралельно звертаються до джерела даних.
Основні засоби захисту:
пер-ключове блокування — лише один запит перебудовує конкретне значення;
подвійна перевірка кешу — після отримання блокування;
jitter для TTL — розподіляє завершення записів у часі;
прогрівання — завантажує популярні дані до приходу пікового трафіку;
TTL блокування — захищає від завислих власників;
метрики — показують, чи дійсно промахи об'єднуються в одну операцію.
Найнадійніша схема зазвичай поєднує всі три основні механізми: jitter зменшує синхронність, блокування обмежує паралельне завантаження, а прогрівання зменшує кількість промахів у критичні моменти.