Пошук уроків, статей та іншого контенту
Розглянете видалення, оновлення й версіювання ключів для узгодження кешу зі змінами в основному сховищі.
Інвалідація кешу — це вилучення або оновлення кешованих даних після зміни запису в основному сховищі.
Кеш має прискорювати читання, але не повинен надовго зберігати значення, які вже не відповідають базі даних. Для кожної операції зміни потрібно визначити, що робити з відповідним ключем:
створення — додати нове значення або видалити пов’язаний ключ списку;
оновлення — видалити старе значення, замінити його новим або змінити версію ключа;
видалення — видалити значення з кешу або записати спеціальне значення «не знайдено».
Найпоширеніша модель — cache-aside:
спочатку читаємо кеш;
якщо значення знайдено, повертаємо його;
якщо значення відсутнє, читаємо основне сховище;
записуємо результат у кеш;
під час зміни даних спочатку успішно змінюємо сховище, а потім інвалідуємо кеш.
Найпростіша стратегія — після успішної зміни в базі даних видалити відповідний ключ із кешу.
UPDATE database
DELETE cache keyНаступне читання не знайде ключ у кеші, звернеться до бази даних і збереже актуальне значення.
Цей підхід називають invalidate on write.
Нехай у кеші є ключ:
user:42 -> { "id": 42, "name": "Олена" }Після оновлення імені:
UPDATE users SET name = 'Марія' WHERE id = 42
DELETE cache["user:42"]Наступний запит виконає:
cache miss
SELECT * FROM users WHERE id = 42
SET cache["user:42"] = новий записНебезпечна послідовність:
DELETE cache key
UPDATE databaseМіж цими операціями інший запит може прочитати старе значення з бази даних і знову покласти його в кеш. Якщо оновлення бази даних потім завершиться помилкою, кеш уже буде порожнім, хоча це не критично. Але якщо читання встигне повторно записати старе значення, воно може зберігатися до завершення TTL.
Безпечніша базова послідовність:
UPDATE database
DELETE cache keyЯкщо оновлення бази даних не вдалося, кеш не змінюється. Якщо оновлення успішне, старе кешоване значення прибирається.
Замість видалення ключа можна одразу записати нове значення:
UPDATE database
SET cache key = new valueЦе зменшує кількість cache miss після запису. Однак підхід має бути узгоджений із логікою запису:
нове значення потрібно записати лише після успішної зміни сховища;
формат даних у кеші має відповідати формату, який очікують читачі;
потрібно оновити всі пов’язані ключі.
Наприклад, якщо користувач зберігається окремо і в кешованому списку користувачів, потрібно змінити:
user:42
users:activeОновлення лише user:42 залишить список застарілим.
Тому для складніших об’єктів часто простіше інвалідувати всі пов’язані ключі, ніж намагатися вручну оновити кожну копію.
Для видалення запису потрібно спочатку видалити його з основного сховища, а потім прибрати з кешу:
DELETE FROM users WHERE id = 42
DELETE cache["user:42"]Якщо видалення в базі даних не вдалося, не варто безумовно інвалідувати кеш: запис усе ще існує, і кеш може залишатися корисним.
Після видалення запит може багато разів звертатися до бази даних за неіснуючим записом. Щоб зменшити таке навантаження, можна тимчасово кешувати результат «запис не знайдено».
Наприклад:
user:42 -> NOT_FOUNDТаке значення потрібно зберігати з коротшим TTL, ніж звичайні записи. Інакше після повторного створення користувача кеш може помилково повідомляти, що його не існує.
Під час створення запису ключ NOT_FOUND потрібно інвалідувати.
Версіювання дає змогу не перезаписувати старий ключ, а створювати новий ключ для кожної версії даних.
Наприклад:
user:42:v1
user:42:v2
user:42:v3Старі значення можна видалити пізніше або залишити до завершення TTL. Важливо, щоб читачі більше не використовували стару версію.
Один із практичних варіантів — зберігати окремий покажчик на поточну версію:
user:42:current -> v2
user:42:v2 -> { ... }Під час оновлення:
збільшуємо версію запису в основному сховищі;
записуємо нове значення під новим ключем;
змінюємо покажчик на нову версію;
стару версію видаляємо одразу або залишаємо для подальшого прибирання.
Ось спрощений, але runnable-приклад на JavaScript:
const database = new Map();
const cache = new Map();
function versionedKey(id, revision) {
return `user:${id}:v${revision}`;
}
function currentKey(id) {
return `user:${id}:current`;
}
function getUser(id) {
const pointer = cache.get(currentKey(id));
if (pointer) {
const cachedUser = cache.get(versionedKey(id, pointer));
if (cachedUser) {
console.log(`cache hit: user:${id}:v${pointer}`);
return cachedUser;
}
}
const user = database.get(id);
if (!user) {
// Кешуємо відсутній запис окремим короткоживучим значенням.
cache.set(currentKey(id), "not-found");
return null;
}
const key = versionedKey(id, user.revision);
cache.set(key, user);
cache.set(currentKey(id), String(user.revision));
console.log(`cache miss: записано ${key}`);
return user;
}
function updateUser(id, name) {
const oldUser = database.get(id);
if (!oldUser) {
throw new Error("Користувача не знайдено");
}
const newUser = {
id,
name,
revision: oldUser.revision + 1
};
// Спочатку змінюємо основне сховище.
database.set(id, newUser);
const newKey = versionedKey(id, newUser.revision);
// Записуємо нову версію і перемикаємо покажчик.
cache.set(newKey, newUser);
cache.set(currentKey(id), String(newUser.revision));
// Стару версію можна видалити після перемикання покажчика.
cache.delete(versionedKey(id, oldUser.revision));
return newUser;
}
function deleteUser(id) {
const user = database.get(id);
if (!user) {
return false;
}
// Спочатку видаляємо запис із основного сховища.
database.delete(id);
// Потім прибираємо покажчик і поточну версію з кешу.
cache.delete(currentKey(id));
cache.delete(versionedKey(id, user.revision));
return true;
}
database.set(42, {
id: 42,
name: "Олена",
revision: 1
});
console.log(getUser(42));
console.log(getUser(42));
console.log(updateUser(42, "Марія"));
console.log(getUser(42));
console.log(deleteUser(42));
console.log(getUser(42));У реальному розподіленому кеші операції зі значенням і покажчиком можуть виконуватися різними командами. Між ними інший клієнт потенційно може побачити проміжний стан. Якщо така ситуація неприпустима, потрібно використовувати механізм атомарного оновлення, транзакцію або інший контроль конкуренції, який підтримує конкретне сховище.
Версіювання ключів особливо корисне, коли:
старі й нові значення можуть бути одночасно доступними під час оновлення;
потрібно уникнути використання старого значення після перемикання версії;
кеш містить великі об’єкти, які безпечно записати під новим ключем;
потрібно інвалідувати цілий простір ключів однією зміною версії.
Для масової інвалідації можна додати версію до префікса:
catalog:v7:item:100
catalog:v7:item:101Після великої зміни збільшуємо версію:
catalog:v8:item:100Нові читання працюють лише з v8, тому всі ключі v7 логічно інвалідовані. Їх можна видалити окремим фоновим процесом або дочекатися завершення TTL.
Перевага цього підходу — не потрібно синхронно видаляти тисячі ключів. Недолік — старі значення тимчасово займають пам’ять.
Інвалідація може конфліктувати з паралельним читанням.
Розглянемо послідовність:
Запит A читає старе значення з бази даних.
Запит B оновлює запис у базі даних.
Запит B видаляє ключ із кешу.
Запит A завершує читання і записує старе значення в кеш.
У результаті після інвалідації знову з’являються застарілі дані.
Можливі способи зменшити цю проблему:
записувати в кеш версію запису та не приймати старішу версію;
використовувати версійовані ключі;
застосовувати блокування або атомарні операції;
після зміни повторно перевіряти версію перед записом у кеш;
використовувати короткий TTL як додатковий, але не єдиний захист.
Сам TTL не гарантує негайної узгодженості. Він лише обмежує час, протягом якого помилкове значення може залишатися в кеші.
Один запис часто представлений у кеші кількома способами:
user:42
users:by-email:olena@example.com
users:activeПід час оновлення або видалення потрібно скласти список усіх залежних ключів.
Практичні підходи:
централізувати формування ключів в одному модулі;
зберігати залежності між об’єктом і списками;
інвалідувати весь список, якщо його точне оновлення складне;
використовувати версію колекції, наприклад users:list:v4.
Чим більше копій одного об’єкта існує в кеші, тим складніше гарантувати їхню узгодженість. Тому кешовану структуру бажано проєктувати так, щоб кількість похідних копій була обмеженою.
INSERT у базу даних
DELETE кешованих списківЯкщо для нового запису немає інших похідних ключів, окремий ключ об’єкта можна не створювати. Він буде наповнений під час першого читання.
Найпростіший варіант:
UPDATE у базі даних
DELETE ключа об’єкта
DELETE пов’язаних ключівАльтернатива:
UPDATE у базі даних
SET нової версії в кеш
SET пов’язаних ключівDELETE у базі даних
DELETE ключа об’єкта
DELETE пов’язаних ключівЯкщо використовується кешування відсутніх значень, після видалення можна записати короткоживучий маркер NOT_FOUND.
Якщо база даних не змінилася, а кеш уже видалено, це створює непотрібний cache miss. У складніших сценаріях паралельне читання може знову записати старі дані.
Оновлення user:42 не змінює автоматично users:active або ключ пошуку за email. Усі похідні представлення потрібно враховувати.
TTL не замінює інвалідацію після запису. Він лише обмежує час життя значення.
Якщо ключ формується з версії, кожна успішна зміна повинна збільшувати версію або змінювати інший унікальний ідентифікатор. Інакше нове значення може перезаписати старе під тим самим ключем, а паралельний запит — повернути застарілий результат.
Потрібно заздалегідь визначити:
що відбувається, якщо база даних оновилася, а кеш недоступний;
чи можна продовжити операцію без оновлення кешу;
як буде відновлено кеш під час наступного читання.
Для cache-aside недоступність кешу зазвичай не повинна скасовувати успішний запис у базу даних. Наступне читання зможе отримати дані безпосередньо зі сховища.
Інвалідація синхронізує кеш зі змінами в основному сховищі.
Для cache-aside зазвичай спочатку змінюють базу даних, а потім видаляють кешований ключ.
Під час оновлення можна або видалити старе значення, або записати нове.
Під час видалення потрібно прибирати як ключ об’єкта, так і пов’язані ключі.
Версіювання створює новий ключ для нової версії та робить старий ключ неактуальним.
Для масової інвалідації зручно змінювати версію префікса ключів.
TTL є додатковим захистом, але не замінює явну інвалідацію.
Паралельні читання й записи можуть повторно записати застарілі дані, тому для критичних сценаріїв потрібні версії, атомарні операції або контроль конкуренції.