Пошук уроків, статей та іншого контенту
Порівняєте автоматичне читання та синхронний запис через кеш і визначите, коли застосовувати кожен підхід.
Read-Through — патерн читання, у якому застосунок звертається до кешу, а кеш самостійно завантажує дані з основного сховища, якщо їх немає.
Потік читання має такий вигляд:
Застосунок запитує об’єкт у кешу.
Якщо об’єкт є в кеші, кеш одразу його повертає.
Якщо об’єкта немає:
кеш звертається до бази даних;
зберігає отриманий результат у себе;
повертає результат застосунку.
Наступне читання отримує дані вже з кешу.
Застосунок → Кеш → дані знайдено → відповідь
↓
даних немає
↓
База даних
↓
Кешування
↓
відповідьГоловна особливість Read-Through — логіка заповнення кешу прихована всередині кешового шару. Код застосунку не повинен окремо перевіряти кеш, а потім самостійно звертатися до бази даних.
Write-Through — патерн запису, у якому кожна зміна спочатку або одночасно проходить через кеш і синхронно записується в основне сховище.
Типовий потік:
Застосунок передає нове значення кешу.
Кеш записує значення в базу даних.
Після успішного запису в базу даних кеш оновлюється.
Застосунок отримує успішну відповідь.
Застосунок → Кеш → База даних
↓
оновлення кешу
↓
відповідьУ цьому підході кеш не є єдиним постійним сховищем. База даних залишається джерелом істини, а кеш містить актуальну копію даних.
Read-Through і Write-Through часто використовують разом:
Read-Through автоматично завантажує відсутні дані;
Write-Through підтримує кеш актуальним після змін.
Тоді типовий сценарій має такий вигляд:
Читання:
застосунок → кеш → за відсутності даних база даних → кеш → застосунок
Запис:
застосунок → кеш → база даних → оновлення кешу → застосунокЦе зменшує кількість логіки в сервісному коді: сервіс працює з абстракцією кешу, а не окремо координує кеш і базу даних.
Нижче наведено спрощену реалізацію обох патернів. У прикладі база даних і кеш представлені структурами Map, але порядок операцій відповідає реальному підходу.
class InMemoryDatabase {
constructor(initialData = {}) {
this.data = new Map(Object.entries(initialData));
}
async findById(id) {
console.log(`DB: читання користувача ${id}`);
// Імітуємо мережевий або дисковий запит
await new Promise((resolve) => setTimeout(resolve, 100));
return this.data.get(id) ?? null;
}
async save(user) {
console.log(`DB: збереження користувача ${user.id}`);
// Імітуємо затримку запису
await new Promise((resolve) => setTimeout(resolve, 100));
this.data.set(user.id, user);
return user;
}
}
class ReadThroughWriteThroughCache {
constructor(database, ttlMilliseconds = 5000) {
this.database = database;
this.ttlMilliseconds = ttlMilliseconds;
this.entries = new Map();
}
async get(id) {
const entry = this.entries.get(id);
if (entry && entry.expiresAt > Date.now()) {
console.log(`Cache: попадання для ${id}`);
return entry.value;
}
if (entry) {
this.entries.delete(id);
}
console.log(`Cache: промах для ${id}`);
// Read-Through: кеш сам завантажує відсутні дані
const value = await this.database.findById(id);
if (value !== null) {
this.setInCache(id, value);
}
return value;
}
async save(user) {
// Write-Through: спочатку підтверджуємо запис у базі даних
const savedUser = await this.database.save(user);
// Після успішного запису оновлюємо кеш
this.setInCache(savedUser.id, savedUser);
return savedUser;
}
setInCache(id, value) {
this.entries.set(id, {
value,
expiresAt: Date.now() + this.ttlMilliseconds
});
}
}
async function main() {
const database = new InMemoryDatabase({
"1": { id: "1", name: "Олена", role: "developer" }
});
const users = new ReadThroughWriteThroughCache(database);
console.log("Перше читання:");
console.log(await users.get("1"));
console.log("\nДруге читання:");
console.log(await users.get("1"));
console.log("\nЗапис:");
await users.save({
id: "1",
name: "Олена",
role: "senior developer"
});
console.log("\nЧитання після запису:");
console.log(await users.get("1"));
}
main().catch((error) => {
console.error("Помилка:", error);
});Під час першого читання кеш порожній, тому дані завантажуються з бази даних. Під час другого читання база даних уже не викликається.
Після save база даних оновлюється першою. Лише після успішного завершення цього запису кеш отримує нове значення.
Сервісний код не містить повторюваної логіки get cache → якщо немає, get database.
Усі споживачі кешу однаково обробляють промахи.
Дані автоматично кешуються під час першого читання.
Зручно централізовано додавати TTL, серіалізацію та обробку помилок.
Кешовий шар повинен знати, як звертатися до бази даних.
Перший запит до кожного ключа все одно має затримку бази даних.
Під час масового очищення або завершення TTL багато запитів можуть одночасно звернутися до бази даних.
Для рідко запитуваних даних кеш може витрачати пам’ять без помітної користі.
Read-Through добре підходить для даних, які:
часто читаються;
змінюються рідше, ніж читаються;
можна безпечно відновити з основного сховища;
мають зрозумілий час актуальності.
Після успішного запису кеш містить нове значення.
Наступне читання не отримує стару версію через невчасне оновлення кешу.
Запис і оновлення кешу централізовані в одному шарі.
Менша ймовірність забути інвалідувати кеш після зміни даних.
Кожен запис має чекати на основне сховище.
Запис не завершується, якщо база даних недоступна.
Кеш може містити дані, які незабаром більше ніхто не прочитає.
Потрібно визначити порядок дій при помилках.
У наведеному прикладі використано порядок:
записати дані в базу;
оновити кеш;
повернути успішну відповідь.
Це безпечніше для систем, де база даних є джерелом істини. Якщо оновлення кешу не вдалося після успішного запису в базу, кеш може тимчасово містити старі дані. Такий запис не можна повторити бездумно: потрібно або видалити застарілий ключ, або повторити оновлення кешу окремим механізмом.
Обирайте Read-Through, коли:
головна проблема — повільні або дорогі операції читання;
однакові об’єкти часто запитують багато клієнтів;
прийнятна затримка першого запиту після промаху кешу;
кеш може самостійно отримати об’єкт за ключем;
потрібно приховати деталі кешування від бізнес-логіки.
Наприклад, це можуть бути:
профілі користувачів;
налаштування застосунку;
картки товарів;
результати обчислень, які можна повторити;
довідкові дані.
Обирайте Write-Through, коли:
після запису наступні читання повинні бачити нові дані;
записів менше, ніж читань;
важливі узгодженість кешу та бази даних;
кеш є постійним компонентом доступу до даних;
додаткові витрати на синхронний запис прийнятні.
Наприклад, це може бути корисно для:
змін профілю користувача;
оновлення конфігурації;
даних, які часто читають одразу після редагування;
об’єктів, для яких застаріле значення створює помилки в інтерфейсі.
Під час вибору оцініть характер навантаження:
Якщо переважають читання — Read-Through може значно зменшити навантаження на базу даних.
Якщо після запису дані часто читаються — поєднання Read-Through і Write-Through не допускає тривалого використання старої копії.
Якщо записи дуже часті — Write-Through може збільшити затримку та навантаження на кеш.
Якщо дані не можна відновити з бази або промах кешу критичний — автоматичне завантаження через Read-Through потребує додаткової обробки помилок.
Якщо допустима тимчасова неузгодженість — синхронний Write-Through може бути надмірним.
Read-Through відповідає на питання: як завантажити дані під час промаху кешу?
Write-Through відповідає на питання: як синхронно оновити кеш разом з основним сховищем?
Кеш не повинен приховувати помилки основного сховища.
Для Read-Through:
якщо база даних недоступна під час промаху, запит має отримати помилку або контрольовану відповідь;
помилкову відповідь зазвичай не слід кешувати як звичайне значення;
успішно отримане значення можна кешувати лише після завершення читання.
Для Write-Through:
якщо запис у базу не вдався, кеш не слід оновлювати новим значенням;
якщо база оновилася, а кеш — ні, потрібно видалити або повторно оновити кешовий ключ;
TTL не замінює правильну обробку записів, оскільки до завершення TTL клієнти можуть бачити старі дані.
У розподіленій системі додатково важливо, щоб усі екземпляри застосунку використовували спільний кеш або однакові правила його оновлення. Локальний кеш кожного екземпляра може мати різні версії одного об’єкта.
Якщо спочатку оновити кеш, а запис у базу завершиться помилкою, кеш міститиме дані, яких фактично немає в основному сховищі.
Для Write-Through зазвичай безпечніше спочатку успішно записати дані в базу, а потім оновити кеш.
Успішний запис у базу не означає, що кеш гарантовано оновився. Якщо цю помилку проігнорувати, користувачі можуть отримувати старе значення.
Можливі стратегії:
видалити ключ із кешу;
повторити оновлення;
відправити операцію в чергу для повторної обробки.
null без продуманої політикиЯкщо об’єкт не знайдено, постійно повторювані запити можуть створювати навантаження на базу даних. У деяких системах відсутні значення кешують на короткий час. Але це потрібно робити обережно: новостворений об’єкт може тимчасово залишатися прихованим через такий запис.
Write-Through є синхронним щодо основного сховища: операція запису не вважається завершеною, доки база даних не підтвердила зміни.
Якщо застосунок спочатку відповідає клієнту, а запис виконує у фоновому режимі, це вже інша модель із відкладеною синхронізацією, а не звичайний Write-Through.
Навіть при Write-Through кеш може втратити актуальність, якщо дані змінюються в обхід цього кешового шару. TTL обмежує час життя такої застарілої копії.
Read-Through автоматично завантажує дані з основного сховища під час промаху кешу.
Write-Through синхронно записує зміни в основне сховище та оновлює кеш.
Read-Through оптимізує читання й приховує логіку заповнення кешу.
Write-Through зменшує ризик отримання старих даних після запису.
Для надійної роботи потрібно продумати порядок операцій і помилки синхронізації.
Патерни можна поєднувати, коли система має багато читань і потребує актуального кешу після змін.