Рассмотрим следующую задачу. Нам необходимо делать вызовы стороннего 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 проектах с состоянием гонки и какие подходы вы применяли?

Комментарии (21)


  1. Alexandroppolus
    11.10.2021 08:49
    +2

    Боролся, применял то же самое.

    Есть ещё другой кейс гонки - когда нет смысла кэшировать, например там форма с каким-то набором значений, либо результат может меняться. Там проблема не в повторном запросе, а в том, что первый запрос, задержавшись в пути, может прийти после ответа на следующий запрос и накрыть своим устаревшим результатом. Там тоже стандартный подход: держать в замыкании колбэка "id запроса" и сравнивать с текущим актуальным id.


  1. aamonster
    11.10.2021 12:33

    Как всё-таки мало в JavaScript проблем с многозадачностью... Не то что C++, где после четырёх callTimes++ в четырёх тредах не можешь быть уверен, чему будет равно значение callTimes.
    ЗЫ: Хотя и кооперативная многозадачность может подкинуть сюрприз. Однажды столкнулся: отлаживаю код в Safari, нажимаю F6 (step over) – и отрабатывает код из очереди промисов. При том что никакого await не было, в главный цикл мы не попадали. Просто Safari решил, что остановка в отладчике – как раз подходящее время проверить очередь промисов.


  1. nextdesu
    11.10.2021 12:48

    А это необходимо для работы создавать функцию getOrSet в вызове метода или можно её вынести в отдельный метод?


    1. PaulIsh Автор
      11.10.2021 12:48

      можно вынести в отдельный метод


      1. nextdesu
        11.10.2021 13:34

        Понял, благодарю


  1. Able1991
    11.10.2021 13:41

    Комментарий с примером кода почему то отклонили...

    Вопрос такой, как вы будете решать задачу когда нода будет запускаться не в одном инстансе?

    Не легче сразу прикрутить redlock если используете редис для Кеша? Ваше решение явно не production ready


    1. PaulIsh Автор
      11.10.2021 13:43
      +1

      Потому что в примере было не по теме. Вы решаете задачу как поднять несколько сервисов и синхронизировать между ними запись/чтение кеша Redis. В теме статьи, на примере работы с кешем, рассказывается о гонках и как с ними бороться. Безусловно, в этой статье я не расписываю много дополнительных деталей, поскольку они лишние для понимая проблемы.


      1. Able1991
        11.10.2021 13:47
        -1

        Вы даёте заведомо неправильное решение, даже маленький проект в проде будут запускать минимум с пм2 в кластер моде, для чего тогда ваше решение?


        1. PaulIsh Автор
          11.10.2021 14:01

          Я даю решение как победить гонку. Пример призван продемонстрировать проблему. Мыслите шире, отвяжитесь от кешей и моего примера. У вас есть просто несколько параллельных вызовов асинхронного кода, в котором делаются вызовы другого асинхронного кода с await. Их можно решить тем же подходом - всем вызовам подсунуть один и тот же промис.

          Статья не про синхронизацию кеша между разными процессами.


          1. Able1991
            11.10.2021 14:15
            +1

            Я абсолютно абстрагирован, и говорю что для решения race condition в NodeJS нужно использовать блокировки, если речь не о работе с бд, то для этого используется distributed lock, редлок с редисом это один из возможных вариантов. Ваше решение - не решение, так как оно не работает реальном мире, где никто не крутит сервисы на ноде в одном инстансе

            Кстати в яваскрипте нет параллельных вызовов метода. Все вызовы в этом языке последовательные, подменяете понятия и ещё больше запутываете читателя.

            Лучше напишите как работают промисы и эвент луп


      1. faiwer
        12.10.2021 14:38

        Не стоит отклонять комментарии просто потому, что вы с ними не согласны. ИМХО. Вот если человек какую-то грубость написал или ошибся статьёй, то другое дело.


        1. PaulIsh Автор
          12.10.2021 18:33

          По мне, так человек статьей ошибся. Я говорю в статье про шаблон, который при переносе из синхронного кода на асинхронный приводит к проблеме. Для демонстрации проблемы я выбрал приближенный к жизни пример.

          Вот вам еще пример с тем же шаблоном, но без Redis.

          4 раза вместо 1 создаем клиента и подключаемся к нему
          class DummyClient {
              async connect() {
                  return new Promise(resolve => {
                      setTimeout(() => {
                          console.log('connect');
                          resolve()
                      }, 2000);
                  })
              }
          
              async doSomething() {
                  console.log('done something')
              }
          }
          
          class ApiWrapper {
              #client;
          
              async getClient() {
                  if (!this.#client) {
                      const client = new DummyClient();
                      await client.connect();
                      this.#client = client;
                  }
                  return this.#client;
              }
          
              async doSomething() {
                  const client = await this.getClient();
                  return client.doSomething();
              }
          }
          
          const run = async () => {
              const api = new ApiWrapper();
          
              await Promise.all([api.doSomething(), api.doSomething(), api.doSomething(), api.doSomething()]);
          }
          
          run();
          /* клиент создан 4 раза вместо одного */


          1. faiwer
            12.10.2021 18:46

            По мне, так человек статьей ошибся.

            Не, не ошибся. Я же вижу в его комментариях, как и в комментариях от mark_ablov, о чём идёт речь. Вы просто зря в статье про nodejs и redis написали. Там в общем случае ваш паттерн неуместен. В браузерной вкладке — ок, там всего 1 поток и гонки имеют скорее логический характер. Но когда много процессов вы так гонки не почините. Максимум замаскируете.


          1. Able1991
            14.10.2021 18:26

            Пример не про race condition а про понимание как работают промисы


        1. Able1991
          14.10.2021 18:18

          А комментарии автор что ли может отклонить?) Вот это новость


    1. mark_ablov
      11.10.2021 16:47

      Кроме redlock'a можно и pubsub использовать, который предоставляется редисом из коробки. Правда там тоже нужно код аккуратно написать, дабы не нарваться на другие race condition'ы уже.


      1. Able1991
        11.10.2021 17:09

        Как, если не секрет, тут прикрутить пабсаб?


        1. mark_ablov
          11.10.2021 17:21

          Если в двух словах, то храним в redis'e не только кэш, но и флаг того что у нас есть inflight запрос.

            const cached = await dbService.get(cacheKey(id));
            if (cached) {
              return cached;
            }
          
            // need to pre-subscribe, if we would subscribe after flag checking we can miss event
            const fetchedEvent = eventForFetchedData(id);
            const inflightPromise = new Promise((resolve) => {
              messageBusService.sub(fetchedEvent, ({ data }) => {
                messageBusService.unsub(fetchedEvent).catch();
                resolve(data);
              });
            });
          
            const fetchStartEvent = eventForFetchStart(vin);
            const fetchStartPromise = new Promise((resolve) => {
              messageBusService.sub(fetchStartEvent, ({ tag }) => {
                messageBusService.unsub(fetchStartEvent).catch();
                resolve(tag);
              });
            });
          
            const inflightRequest = await dbService.get(cacheKeyForInflightFlag(id));
            if (inflightRequest !== null) {
              return inflightPricePromise;
            }
          
            const requesterId = getRequesterId();
            await messageBusService.pub(fetchStartEvent, { tag: requesterId });
            const firstRequesterId = await fetchStartPromise;
            // we lost that race, some other thread was first to send message to message bus,
            // that it is going to fetch data!
            if (firstRequesterId !== requesterId) {
              return inflightPromise;
            }
          
            messageBusService.unsub(fetchedEvent).catch();
            const data = await callToAPI(id);
            await dbService.set(cacheKey(id), data);
            await dbService.del(cacheKeyForInflightFlag(id));
            // order is important, we need to clean flag first, otherwise consumer can subscribe
            // inbetween pub and del operations and get stuck
            await messageBusService.pub(fetchedEvent, { data });
            return data;


          1. Able1991
            14.10.2021 18:31

            Выглядит сложно, используйте редлок)


  1. mark_ablov
    11.10.2021 16:48

    Забавно: мы похожую задачку даём на техническом интервью) Не всё же алгоритмами мучать - нужно что-то и из реального мира проверять.


  1. sasha-hohloma
    28.10.2021 11:06

    Если говорить про блокировку на Redis для борьбы с гонкой, то у них в доках описан паттерн блокировки с использованием SETNX, просто и без redlock. Решение рабочее, я таким образом успешно ставлю на паузу 40 параллельных запросов в 4 инстанса, запущенных через pm2