Рассмотрим следующую задачу. Нам необходимо делать вызовы стороннего API, которые считаются дорогими, и, следовательно, их необходимо кешировать в Redis. Мы используем современный NodeJS (версии 14+), а значит и конструкции async / await.
Напишем сначала класс обертку над вызовом API, где сам вызов будем эмулировать 2-секундным таймаутом.
class ApiWrapper { #callTimes = 0; async apiCall(payload) { return new Promise(resolve => { setTimeout(() => { this.#callTimes++; resolve(`success: ${payload}`) }, 2000); }) } get callTimes() { return this.#callTimes; } } const run = async () => { const api = new ApiWrapper(); // эмулируем параллельный вызов API 4 раза const prDirect = await Promise.all([api.apiCall('test'), api.apiCall('test'), api.apiCall('test'), api.apiCall('test')]); console.log(prDirect); // => ['success: test', 'success: test', 'success: test', 'success: test'] console.log(apiCache.callTimes); // => 4 } run();
Я специально добавил в класс счетчик вызовов callTimes - он показывает сколько раз мы вызвали метод API. В данном примере у нас 4 прямых вызова.
Теперь добавим к коду кеширование в Redis. Для этого будем использовать пакет redis@next.
Код с кеширующим методом cachedApiCall
class ApiWrapper { #client; #callTimes = 0; constructor(url) { // создаем клиента Redis this.#client = createClient({ url }); } async init() { // Подключаемся к Redis await this.#client.connect(); } async apiCall(payload) { return new Promise(resolve => { setTimeout(() => { this.#callTimes++; resolve(`success: ${payload}`) }, 2000); }) } async cachedApiCall(payload) { let data = await this.#client.get(payload); if (data === null) { // cache for 5 minutes data = await this.apiCall(payload); await this.#client.set(payload, data, { EX: 60 * 5 }); } return data; } get callTimes() { return this.#callTimes; } } const run = async () => { const api = new ApiWrapper('redis://10.250.200.9:6379/6'); await api.init(); const prCached = await Promise.all([api.cachedApiCall('test'), api.cachedApiCall('test'), api.cachedApiCall('test'), api.cachedApiCall('test')]); console.log(prCached); // => ['success: test', 'success: test', 'success: test', 'success: test'] console.log(api.callTimes); // => 4 }
Несмотря на то, что мы вызываем cachedApiCall, наш счетчик вызовов API всё равно показывает цифру 4. Это происходит из-за особенностей работы async / await.
Давайте детальней рассмотрим кеширующий метод. Я его написал так, как если бы я работал с синхронным кодом.
async cachedApiCall(payload) { // получаем данные из кеша let data = await this.#client.get(payload); // если данных нет, то вызываем API и кладем в кеш if (data === null) { // cache for 5 minutes data = await this.apiCall(payload); await this.#client.set(payload, data, { EX: 60 * 5 }); } return data; }
В коде, при обращении к асинхронным методам, я использовал await. Следовательно, как только исполнение первого вызова cachedApiCall дойдет до этой строки оно прервется, и начнет работать следующий параллельный вызов (а у нас их 4). Так будет происходить на каждом вызове await. Если бы наше обращение к cachedApiCall не вызывалось параллельно, то проблемы бы в таком коде не было. Но при параллельном вызове мы нарвались на состояние гонки. Все 4 вызова соревнуются внутри метода, и в итоге мы имеем 4 запроса на получения значения кеша, 4 вызова API, и 4 вызова на установку значения кеша.
Как можно решить такую проблему? Нужно куда-то спрятать await, например во вложенной фукнции, а сам вызов вложенной функции кешировать в памяти на время работы основой функции.
Выглядеть это будет вот так:
async cachedApiCall(payload) { // тут мы спраятали прошлый код во вложенную функцию getOrSet const getOrSet = async payload => { let data = await this.#client.get(payload); if (data === null) { // cache for 5 minutes data = await this.apiCall(payload); await this.#client.set(payload, data, { EX: 60 * 5 }); } return data; } // во временном кеше на время работы функции мы храним промисы // если находясь тут у нас есть значение в tempCache, то мы словили // параллельный вызов if (typeof this.#tempCache[payload] !== 'undefined') return this.#tempCache[payload]; // конструкция try - finally нам позволяет почистить за собой при // любом исходе try { // помещаем во временный кеш промис this.#tempCache[payload] = getOrSet(payload); // используем await, чтобы все параллельные вызовы сюда зашли return await this.#tempCache[payload]; } finally { delete this.#tempCache[payload]; } }
Теперь, при параллельном вызове cachedApiCall все получат один и тот же промис, а значит вызовы к Redis и к нашему API произойдут всего 1 раз.
Боролись ли вы в своих JavaScript проектах с состоянием гонки и какие подходы вы применяли?