Як пришвидшити систему з допомогою кешування. Стратегії та підходи

Привіт! Мене звати Максим, я 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 не повинна перевищувати 20-30% від всієї кількості запитів.

Варіанти рішення:

  • Перевірити, чи не замалий 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 також не тільки про читання, а і про запис даних. Порядок дій простий: записуємо дані в кеш, який одразу переносимо до основного сховища.

Перевага такого підходу в тому, що кеш завжди міститиме актуальну інформацію, і при читанні жодного разу немає потреби звертатися до основного сховища.

Недоліком є те, що потрібно робити два записи одночасно: у кеш, а потім до основного сховища. Коли писати потрібно багато й часто, це може бути проблемою. Додатково, такий підхід збільшує витрати, адже потрібно мати велику кількість швидкої пам’яті.

Висновок

Отже, кешування є ефективним підходом для покращення продуктивності та швидкості роботи системи. Оскільки деякі операції є доволі ресурсомісткими й часто повторюються, використання одного з перелічених патернів або їхня комбінація позитивно вплине на швидкість роботи клієнта та бекенду.

Також це допоможе оптимізувати витрати та підвищить можливості системи до масштабування.

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось19
До обраногоВ обраному11
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Підписатись на коментарі