Пошук уроків, статей та іншого контенту
Проєктуйте кеш із TTL, інвалідацією та захистом від cache stampede для швидших відповідей.
Кеш зберігає результат дорогої операції, щоб повторні запити не виконували її знову. У Node.js кеш часто використовують для:
результатів запитів до бази даних;
відповідей зовнішніх API;
обчислень, які рідко змінюються;
конфігурації або довідкових даних.
Без кешу кожен запит може виконувати однакову повільну операцію:
HTTP-запит → база даних → формування відповідіЗ кешем повторні запити отримують дані швидше:
HTTP-запит → кеш → відповідьОднак кешовані дані можуть застарівати. Тому кеш потрібно проєктувати з урахуванням:
TTL — часу життя запису;
інвалідації — видалення або оновлення запису після зміни даних;
захисту від cache stampede — одночасного повторного виконання дорогої операції для багатьох запитів.
TTL (time to live) визначає, скільки часу запис вважається дійсним.
Наприклад, якщо TTL дорівнює 60 секундам:
перший запит отримує дані з бази;
результат зберігається в кеші;
наступні запити протягом 60 секунд використовують кеш;
після завершення TTL запис вважається протермінованим;
наступний запит завантажує актуальні дані.
TTL не повинен бути однаковим для всіх даних:
список країн можна кешувати на години;
каталог товарів — на хвилини;
залишок товару — на секунди або не кешувати взагалі.
Найпростіший підхід — не запускати окремий таймер для кожного запису. Замість цього перевіряти TTL під час читання:
const entry = cache.get(key);
if (!entry || entry.expiresAt <= Date.now()) {
cache.delete(key);
// Завантажити актуальні дані
}Такий підхід називають лінивим видаленням. Він простий, але запис, до якого більше ніхто не звертається, може залишатися в Map після завершення TTL. Для обмеження пам’яті можна додати періодичне очищення.
Інвалідація — це видалення або оновлення кешованого значення, коли воно більше не відповідає джерелу даних.
Наприклад, після оновлення користувача потрібно видалити кеш:
PUT /users/42
├─ оновити користувача в базі
└─ видалити ключ user:42Наступний запит завантажить свіжі дані та знову збереже їх у кеші.
Поширений підхід називається cache-aside:
спробувати прочитати дані з кешу;
якщо даних немає — прочитати їх із бази;
записати результат у кеш;
повернути результат;
після зміни даних інвалідовувати відповідний ключ.
Псевдокод читання:
value = cache.get(key)
якщо value існує:
повернути value
value = database.get(...)
cache.set(key, value, ttl)
повернути valueПсевдокод оновлення:
database.update(...)
cache.delete(key)Інвалідувати кеш краще після успішного оновлення джерела даних. Якщо видалити кеш до запису в базу, а операція бази завершиться помилкою, наступний запит може зайво завантажити старі дані.
Cache stampede виникає, коли запис одночасно протермінувався, а багато запитів намагаються його отримати.
Без захисту відбувається так:
Запит 1 ─┐
Запит 2 ─┼─ кеш порожній → кожен звертається до бази
Запит 3 ─┘Якщо одночасно прийшло 100 запитів, база може отримати 100 однакових дорогих операцій.
Для кожного ключа можна зберігати поточне завантаження в окремій Map.
Перший запит:
не знаходить значення в кеші;
створює Promise завантаження;
зберігає його в pending;
очікує результат.
Наступні запити:
не знаходять значення в кеші;
знаходять Promise у pending;
очікують той самий Promise;
не запускають додаткових запитів до бази.
Перший запит → створює завантаження ─┐
Другий запит → очікує те саме ├─ одна операція завантаження
Третій запит → очікує те саме ┘Після завершення завантаження Promise потрібно видалити з pending. Якщо завантаження завершилося помилкою, його також потрібно видалити, щоб наступний запит міг повторити операцію.
Нижче наведено самодостатній приклад кешу в пам’яті процесу Node.js. Він підтримує:
TTL;
читання та запис;
інвалідацію одного ключа;
інвалідацію ключів за префіксом;
захист від cache stampede через getOrSet.
class MemoryCache {
constructor() {
this.entries = new Map();
this.pending = new Map();
}
set(key, value, ttlMs) {
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
throw new Error("TTL має бути додатним числом");
}
this.entries.set(key, {
value,
expiresAt: Date.now() + ttlMs,
});
}
get(key) {
const entry = this.entries.get(key);
if (!entry) {
return undefined;
}
if (entry.expiresAt <= Date.now()) {
this.entries.delete(key);
return undefined;
}
return entry.value;
}
delete(key) {
return this.entries.delete(key);
}
deleteByPrefix(prefix) {
let deletedCount = 0;
for (const key of this.entries.keys()) {
if (key.startsWith(prefix)) {
this.entries.delete(key);
deletedCount += 1;
}
}
return deletedCount;
}
async getOrSet(key, loader, ttlMs) {
const cachedValue = this.get(key);
if (cachedValue !== undefined) {
return cachedValue;
}
const existingRequest = this.pending.get(key);
if (existingRequest) {
return existingRequest;
}
const request = (async () => {
try {
const value = await loader();
this.set(key, value, ttlMs);
return value;
} finally {
// Видаляємо Promise і після успіху, і після помилки
this.pending.delete(key);
}
})();
this.pending.set(key, request);
return request;
}
}
const cache = new MemoryCache();
let databaseCalls = 0;
async function loadProductFromDatabase(productId) {
databaseCalls += 1;
// Імітація повільного запиту до бази даних
await new Promise((resolve) => setTimeout(resolve, 300));
return {
id: productId,
name: "Навушники",
price: 2500,
loadedAt: new Date().toISOString(),
};
}
async function getProduct(productId) {
const key = `product:${productId}`;
return cache.getOrSet(
key,
() => loadProductFromDatabase(productId),
1000
);
}
async function updateProduct(productId, changes) {
// Тут у реальному застосунку відбувається оновлення в базі даних.
console.log("Оновлення товару:", productId, changes);
// Інвалідація виконується після успішного оновлення джерела даних
cache.delete(`product:${productId}`);
}
async function main() {
const results = await Promise.all([
getProduct(42),
getProduct(42),
getProduct(42),
]);
console.log("Результати паралельних запитів:", results);
console.log("Кількість звернень до бази:", databaseCalls);
const cachedProduct = await getProduct(42);
console.log("Результат із кешу:", cachedProduct);
console.log("Кількість звернень до бази:", databaseCalls);
await new Promise((resolve) => setTimeout(resolve, 1100));
await getProduct(42);
console.log("Після завершення TTL звернень до бази:", databaseCalls);
await updateProduct(42, { price: 2300 });
await getProduct(42);
console.log("Після інвалідації звернень до бази:", databaseCalls);
}
main().catch((error) => {
console.error("Помилка:", error);
process.exitCode = 1;
});У цьому прикладі три паралельні виклики getProduct(42) використовують один Promise завантаження. Тому до бази виконується лише один запит.
Після завершення TTL наступний виклик видаляє протермінований запис і завантажує товар повторно.
Після updateProduct ключ видаляється явно. Це означає, що наступний виклик отримає актуальне значення з джерела даних.
Ключ має однозначно описувати всі параметри, від яких залежить результат.
Невдалий ключ:
const key = "products";Якщо результат залежить від сторінки, розміру сторінки та фільтра, такий ключ може повернути неправильні дані.
Кращий варіант:
const key = `products:page=${page}:limit=${limit}:category=${category}`;Для об’єктів із багатьма параметрами важливо використовувати стабільне формування ключа. Наприклад, однакові параметри не повинні давати різні ключі лише через різний порядок властивостей.
Одна зміна може впливати на кілька ключів.
Наприклад, зміна товару може впливати на:
product:42;
список товарів певної категорії;
пошукові результати;
кешовані рекомендації.
У такій ситуації можна інвалідовувати конкретний ключ:
cache.delete("product:42");Або групу ключів за префіксом:
cache.deleteByPrefix("products:");Інвалідація за префіксом проста, але може видалити більше записів, ніж потрібно. Надто широка інвалідація зменшує ефективність кешу, а надто вузька може залишити застарілі дані.
Map зберігає дані лише в пам’яті поточного Node.js-процесу. Це має важливі наслідки:
після перезапуску процесу кеш очищається;
кілька процесів мають різні кеші;
кілька екземплярів застосунку не бачать зміни один одного;
обсяг кешу обмежений пам’яттю процесу.
Такий кеш підходить для:
одного процесу;
невеликих значень;
локальних тимчасових даних;
простих сценаріїв, де допустиме очищення кешу після перезапуску.
Якщо кеш має бути спільним для кількох екземплярів застосунку, потрібне зовнішнє сховище кешу. У такому випадку TTL та інвалідація мають виконуватися в цьому спільному сховищі, а захист від одночасних завантажень може вимагати розподіленого блокування.
TTL — це компроміс між актуальністю та продуктивністю.
Довгий TTL:
зменшує кількість звернень до бази;
підвищує частку кешованих відповідей;
довше може повертати застарілі дані.
Короткий TTL:
швидше відображає зміни;
збільшує навантаження на джерело даних;
зменшує користь від кешу.
Перед вибором TTL потрібно визначити, наскільки застарілими можуть бути дані. Для критичних даних не варто покладатися лише на TTL — після зміни потрібно виконувати явну інвалідацію.
Записи без TTL можуть накопичуватися в пам’яті необмежено.
cache.set(key, value);Краще встановлювати TTL для кожного запису або мати чітку політику очищення.
Не слід зберігати помилку зовнішнього сервісу як успішне значення кешу. Тимчасова помилка може залишатися доступною протягом усього TTL.
У прикладі getOrSet у кеш записується лише результат, який успішно повернув loader.
pendingЯкщо не видалити Promise після помилки, наступні виклики можуть постійно отримувати вже відхилений Promise.
Саме тому очищення має бути у finally:
try {
return await loader();
} finally {
pending.delete(key);
}Якщо спочатку видалити кеш, а потім оновити базу, і оновлення завершиться помилкою, наступний запит може знову записати старі дані в кеш.
Надійніший порядок:
оновити джерело даних;
дочекатися успішного результату;
інвалідовувати кеш.
Якщо ключ не враховує параметри запиту, один користувач може отримати результат, сформований для іншого набору параметрів.
Ключ повинен включати всі значення, що впливають на результат.
Кеш може значно прискорити відповідь, але неправильний TTL перетворить його на джерело застарілої інформації. TTL потрібно визначати відповідно до вимог конкретних даних.
TTL визначає, коли кешований запис перестає бути дійсним.
Ліниве видалення перевіряє протермінування під час читання.
Cache-aside спочатку читає кеш, а за відсутності значення звертається до джерела даних.
Після успішної зміни даних пов’язані ключі потрібно інвалідовувати.
Cache stampede виникає, коли багато запитів одночасно завантажують один протермінований запис.
Спільний Promise для кожного ключа дозволяє виконати лише одне завантаження.
Promise потрібно видаляти з pending і після успішного завершення, і після помилки.
Кеш у Map працює лише в межах одного процесу Node.js.
Надійна стратегія кешування починається з правильного вибору ключів, TTL та правил інвалідації.