Як пришвидшити систему з допомогою кешування. Стратегії та підходи
Привіт! Мене звати Максим, я Lead Back-End Engineer ІТ-компанії OBRIO з екосистеми Genesis, беру участь у розвитку застосунку Nebula. У бекенд-розробці працюю понад 5 років, мій основний стек — NodeJS і PHP.
Я працював над прискоренням систем у різних проєктах, тому вирішив у цій статті поділитися одним зі способів оптимізації — кешуванням. Далі я розкажу про різні підходи, їхні переваги та недоліки, а також труднощі, що виникали в процесі.
Що таке кешування
Кешування — це метод зберігання даних у пам’яті швидкого доступу. Цей метод спрямований на підвищення продуктивності та масштабованості системи. Він корисний, коли потрібно часто читати велику кількість одних і тих самих даних, а також писати в основне сховище. Прикладом пам’яті швидкого доступу є RAM, але це може бути і звичайна локальна пам’ять, наприклад, жорсткий диск клієнта.
Кешування може вирішити чимало проблем, пов’язаних зі швидкодією та ефективністю програмного забезпечення. Зокрема, цим підходом можна:
- зменшити час відповіді на запити, які є доволі ресурсоємними;
- знизити навантаження на сервер, зберігаючи певні результати запитів у кеші, замість того, щоби кожен раз отримувати їх із бази даних;
- оптимізувати витрати на обслуговування системи, знизивши потребу в ресурсах.
- збільшити стійкість системи, наприклад, при пікових навантаженнях або деяких видах DDoS-атак;
- реалізувати множинний запис, щоби не навантажувати основне сховище.
Для кращого розуміння кешування, наведу кілька прикладів, як це працює в нашому застосунку.
Кешування на рівні клієнта
Коли користувач вперше запитує дані, на стороні застосунку робиться запит на бекенд, а відповідь кешується на пристрої. І коли користувач захоче їх отримати повторно, застосунок звернеться до внутрішньої пам’яті пристрою.
Кешування на рівні бекенду
У застосунку є розділ, де користувач може вибрати експерта зі списку та поспілкуватися з ним. Особливість у тому, що видача експертів залежить від того, хто з них онлайн, і постійно змінюється. Тому застосунку потрібні максимально актуальні дані з бекенду. Але доволі складна модель потребує часу для формування відповіді. Щоби бекенд реагував якомога швидше, модель кожного експерта ми кешуємо окремо, формуємо колекцію та за кожним з них звертаємося до кешу.
Існують різні підходи до кешування даних: Cache-Aside, Read-Through, Write-Back, Write-Through тощо. Кожен з них має свої переваги та недоліки. Варто розуміти, що їх можна використовувати як окремо, так і поєднувати для більшої ефективності.
Cache-Aside
Напевно, це найрозповсюдженіший патерн кешування. Його суть полягає в тому, що коли ми хочемо отримати дані, а їх немає в кеші, ми звертаємося до основного сховища. Далі записуємо в кеш і тоді віддаємо результат. Наступні запити вже використовуватимуть кеш замість основного сховища.
Уявімо, що у вас є сторінка експерта, до якої дуже часто звертаються клієнти. Нам потрібно закешувати його модель, але в нього є поле reviews, яке регулярно оновлюється. Тож ми можемо робити це із затримкою в одну годину.
Простий приклад реалізації:
import * as Memcached from 'memcached'; class Expert { constructor(public id: string, public name: string, public reviews: number) {} } class CacheAside { constructor(private readonly cache: Memcached) { } public async getExpert(id) { let expert = await this.getExpertFromCache(id); if (!expert) { //Не знайшли експерта в кеші, пробуємо дістати зі сховища expert = await this.getExpertFromDatabase(id); // Кешуємо експерта по ID this.cache.set(`expert_${id}`, expert, 3600, (err: any) => { if (err) { throw Error(`Error setting messages in memcached: ${err}`); } }); } return expert; } private async getExpertFromCache(id: string) { return new Promise((resolve, reject) => { this.cache.get(`expert_${id}`, (err: any, data: any) => { if (err) { reject(Error(`Error setting messages in memcached: ${err}`)); } else { resolve(data ? new Expert(data.id, data.name, data.reviews) : null); } }); }); } private async getExpertFromDatabase(id: string) { // Замість реального запиту до БД повертаємо тестові дані return new Expert(id, `Expert with ID ${id}`, 4); } }
У такий спосіб можна кешувати дані експерта на строк в одну годину. Щойно час сплине і кеш очиститься, дані з основного сховища оновлюються.
Ще один приклад практичної реалізації Cache-Aside.
Переваги:
- просте і зрозуміле рішення;
- допомагає вирішити проблему інтенсивного зчитування;
- при вилученні даних з кешу можна працювати далі без помилок;
- реалізація з TTL дає змогу тимчасово тримати дані в кеші, коли вони потрібні, що дозволяє заощадити пам’ять.
Недоліки:
- Дані кешу не завжди будуть актуальними, тому його слід використовувати з обережністю і тільки в тих випадках, коли це не буде критичним фактором для системи.
З якими проблемами я стикався, використовуючи цей підхід
Cache stampede (або cache dog-piling)
Це відбувається тоді, коли збігає TTL ключів, і багато запитів намагаються оновити їх одночасно. Оскільки обчислення для оновлення ключів можуть бути доволі складними, ці запити створюють значне навантаження на основне сховище.
Це призводить до збоїв у роботі системи та помітного зниження її продуктивності.
Симптоми:
- Регулярне сповільнення відповіді запиту.
- Систематичне навантаження на процесор основного сховища.
Варіанти рішення:
- Метод блокування всіх запитів до ключа кешу після того, як один запит уже почав його оновлювати.
- Метод зовнішнього оновлення — запуск регулярного незалежного процесу оновлення кешу. Буде корисним при високих навантаженнях на базу даних та складних обчисленнях для оновлення кешу.
- Ймовірнісний метод — використовується для зменшення впливу проблеми cache stampede. Вона полягає в тому, що ми дозволяємо кешу завершувати свій термін дії (TTL) трохи раніше, ніж це зазвичай відбувається, з певною ймовірністю. Якщо до цього ключа звернеться запит до закінчення його TTL, ми можемо вважати, що цей ключ став застарілим, і спробувати оновити його значення в кеші.
- Об’єднати кешування з підходом Write-back або Write-Through.
Приклад рішення, коли потрібно безшовне оновлення кешу, щоби при цьому не спричинити тисняву до основного сховища.
Низький hit rate кешу
Відбувається, коли промахів до значення ключа в кеші занадто багато, і замість того, щоб ходити до кешу, ви постійно звертаєтеся до основного сховища.
Основний симптом — високе значення miss. У більшості випадків це можна побачити в системах кешування, таких як Redis, Memcached. Зазвичай кількість miss не повинна перевищувати
Варіанти рішення:
- Перевірити, чи не замалий TTL. Можливо, його потрібно збільшити, адже це значення очищається з кешу надто часто.
- Пересвідчитися, що на кеш виділено достатньо пам’яті (інакше система постійно вилучає його).
- Подумати про доцільність кешу в цій частині системи.
Read-Through Cache
Підхід Read-Through подібний до Cache-Aside, його основна ціль — допомогти при частому читанні з основного сховища. Різниця тільки у двох речах:
- програма завжди звертається до кешу. Тобто спілкування з основним сховищем переноситься на рівень постачальника кешу, що часто є окремою бібліотекою. Це дозволяє спростити код і сконцентруватися тільки на бізнес-логіці;
- у кеші зберігається та сама модель даних, що і в основному сховищі.
Цей патерн має аналогічні переваги та проблеми, як і Cache-Aside.
Write-back Cache
Часто кеш асоціюється з отриманням даних, але в такому підході він ще й допомагає продуктивніше записувати їх в основне сховище. Write-back — це метод кешування даних, коли зміни вносяться в кеш і не зберігаються в основній пам’яті до того моменту, поки не буде виконано явний запит на збереження змін.
Завдяки такому підходу, ми завжди маємо актуальну інформацію з кешу. Також ми можемо збільшити ефективність запису даних в основне сховище, особливо якщо воно підтримує множинний запис.
Простий приклад реалізації кешування повідомлень у чаті:
export interface Message { id: string; text: string; isDirty?: boolean; } export class MessagesCache { constructor(private readonly cache: Memcached) {} public async addMessage(chatId: string, message: Message): Promise<Message> { message.isDirty = true; const messages = await this.getMessages(chatId); messages.push(message); await this.replaceInCache(chatId, messages); return message; } public async getMessages(chatId: string): Promise<Message[]> { return new Promise<Message[]>((resolve, reject) => { this.cache.get(chatId, (err: any, data: any) => { if (err) { reject(err); } else { resolve(data ?? []); } }); }); } public async getDirtMessages(chatId: string): Promise<Message[]> { const messages = await this.getMessages(chatId); return messages.filter((message) => message.isDirty === true); } public async flushDirtMessages(chatId: string, messageIds: string[]): Promise<void> { let messages = await this.getMessages(chatId); messages = messages.map((message) => { if (messageIds.includes(message.id)) { message.isDirty = false; } return message; }); await this.replaceInCache(chatId, messages); } private async replaceInCache(chatId: string, messages: Message[]): Promise<void> { return new Promise<void>((resolve, reject) => { if (messages.length <= 1) { this.cache.add(chatId, messages, 0, (err) => { if (err) { reject(Error(`Error setting messages in memcached: ${err}`)); } else { resolve(); } }); } else { this.cache.replace(chatId, messages, 0, (err) => { if (err) { reject(Error(`Error setting messages in memcached: ${err}`)); } else { resolve(); } }); } }); } }
Також можна реалізувати асинхронне збереження до основного сховища.
Простий приклад:
setTimeout(async () => { const chatId = '123'; const cache = new MessagesCache(new Memcached('localhost:11211')); const dirtMessages = await cache.getDirtMessages(chatId); // тут код, який зберігає в БД, якщо є можливість реалізувати множинний запис await cache.flushDirtMessages( chatId, dirtMessages.map((message) => message.id), ); }, 60000);
Головний недолік такого підходу — він може призвести до втрати даних у разі аварійного зупинення системи. Якщо ви плануєте використовувати Write-Back кешування у своєму проєкті, обов’язково забезпечте належний рівень захисту даних та резервне копіювання.
Також цей підхід дуже добре працює разом з Cache-Aside. Наприклад, якщо вам не хочеться тримати в кеші всі повідомлення чату, а тільки першу сторінку, то на всі інші сторінки ви можете застосувати Cache-Aside і діставати їх з основного сховища за потреби.
Write-Through Cache
Патерн Write-Through також не тільки про читання, а і про запис даних. Порядок дій простий: записуємо дані в кеш, який одразу переносимо до основного сховища.
Перевага такого підходу в тому, що кеш завжди міститиме актуальну інформацію, і при читанні жодного разу немає потреби звертатися до основного сховища.
Недоліком є те, що потрібно робити два записи одночасно: у кеш, а потім до основного сховища. Коли писати потрібно багато й часто, це може бути проблемою. Додатково, такий підхід збільшує витрати, адже потрібно мати велику кількість швидкої пам’яті.
Висновок
Отже, кешування є ефективним підходом для покращення продуктивності та швидкості роботи системи. Оскільки деякі операції є доволі ресурсомісткими й часто повторюються, використання одного з перелічених патернів або їхня комбінація позитивно вплине на швидкість роботи клієнта та бекенду.
Також це допоможе оптимізувати витрати та підвищить можливості системи до масштабування.
Немає коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів