Решаем проблему миллиона открытых вкладок или «помогаем железу выживать»


Мы попробуем разобраться — как можно снизить нагрузку на серверное железо, обеспечив при этом максимальную производительность Web-приложения.


В разработке больших высоконагруженных проектов с огромным онлайном часто приходится думать, как снизить нагрузку на сервера, особенно при работе в webSocket'ами и динамически изменяемыми интерфейсами. К нам приходит 100500 пользователей и мы имеем 100500 открытых соединений по сокетам. А если каждый из них откроет по 2 вкладки — это уже *201000 соединений. А если пять?


Рассмотрим тривиальный пример. Имеем, допустим, Twitch.tv, который для каждого пользователя поднимает WS соединение. Онлайн у такого проекта огромный, значит важна каждая деталь. Мы не можем позволить себе открывать на каждой вкладке новое WS-соединение, поддерживая старое, ибо железа нужно немерено для этого.


Рождается идея — а что, если WS соединения поднимать лишь в одной вкладке и всегда держать его открытым, а в новых не инициализировать подключение, а просто слушать из соседней вкладки? Именно о реализации этой идеи я и хочу рассказать.


Логическое поведение вкладок в браузере


  1. Открываем первую вкладку, помечаем ее, как Primary
  2. Запускаем проверку — если вкладка is_primary, то поднимаем WS-соединение
  3. Работаем...
  4. Открываем вторую вкладку (дублируем окно, вводим адрес вручную, открываем в новой вкладке, неважно)
  5. Из новой вкладки смотрим есть ли где-то Primary-вкладка. Если "да", то текущую помечаем Secondary и ждем, что будет происходить.
  6. Открываем еще 10 вкладок. И все ждут.
  7. В какой-то момент закрывается Primary-вкладка. Перед своей смертью она кричит всем о своей погибели. Все в шоке.
  8. И тут все вкладки пытаются мигом стать Primary. Реакция у всех разная (рандомная) и кто успел, того и тапки. Как только одна из вкладок сумела стать is_primary, она всем кричит о том, что место занято. После этого у себя поднимает заново WS-соединение. Работаем. Остальные ждут.
  9. И т.д. Падальщики ждут смерти Primary-вкладки, чтобы встать на ее место.

Техническая сторона вопроса


Для общения между вкладками мы будем использовать то, что связывает их в рамках одного домена — localStorage. Обращения к нему не затратны по ресурсам железа пользователя и отклик от них весьма быстр. Вокруг него и строится вся задумка.


Есть библиотека, которая уже долгое время не поддерживается создателем, но можно сделать ее локальный форк, как я и поступил. Из нее мы достаем файл:


/intercom.js


Суть библиотеки в том, что она позволяет общаться евентами emit/on между вкладками используя для этого localStorage.


После этого нам нужен инструмент, позволяющий "лочить" (блокировать изменения) некий ключ в localStorage, не позволяя его никому изменять без необходимых прав. Для этого была написана маленькая библиотека "locableStorage", суть которой заключена в функции trySyncLock()


Код библиотеки locableStorage
(function () {

    function now() {
        return new Date().getTime();
    }

    function someNumber() {
        return Math.random() * 1000000000 | 0;
    }

    let myId = now() + ":" + someNumber();

    function getter(lskey) {
        return function () {
            let value = localStorage[lskey];
            if (!value)
                return null;

            let splitted = value.split(/\|/);
            if (parseInt(splitted[1]) < now()) {
                return null;
            }
            return splitted[0];
        }
    }

    function _mutexTransaction(key, callback, synchronous) {
        let xKey = key + "__MUTEX_x",
            yKey = key + "__MUTEX_y",
            getY = getter(yKey);

        function criticalSection() {
            try {
                callback();
            } finally {
                localStorage.removeItem(yKey);
            }
        }

        localStorage[xKey] = myId;
        if (getY()) {
            if (!synchronous)
                setTimeout(function () {
                    _mutexTransaction(key, callback);
                }, 0);
            return false;
        }
        localStorage[yKey] = myId + "|" + (now() + 40);

        if (localStorage[xKey] !== myId) {
            if (!synchronous) {
                setTimeout(function () {
                    if (getY() !== myId) {
                        setTimeout(function () {
                            _mutexTransaction(key, callback);
                        }, 0);
                    } else {
                        criticalSection();
                    }
                }, 50)
            }
            return false;
        } else {
            criticalSection();
            return true;
        }
    }

    function lockImpl(key, callback, maxDuration, synchronous) {

        maxDuration = maxDuration || 5000;

        let mutexKey = key + "__MUTEX",
            getMutex = getter(mutexKey),
            mutexValue = myId + ":" + someNumber() + "|" + (now() + maxDuration);

        function restart() {
            setTimeout(function () {
                lockImpl(key, callback, maxDuration);
            }, 10);
        }

        if (getMutex()) {
            if (!synchronous)
                restart();
            return false;
        }

        let aquiredSynchronously = _mutexTransaction(key, function () {
            if (getMutex()) {
                if (!synchronous)
                    restart();
                return false;
            }
            localStorage[mutexKey] = mutexValue;
            if (!synchronous)
                setTimeout(mutexAquired, 0)
        }, synchronous);

        if (synchronous && aquiredSynchronously) {
            mutexAquired();
            return true;
        }
        return false;

        function mutexAquired() {
            try {
                callback();
            } finally {
                _mutexTransaction(key, function () {
                    if (localStorage[mutexKey] !== mutexValue)
                        throw key + " was locked by a different process while I held the lock"

                    localStorage.removeItem(mutexKey);
                });
            }
        }

    }

    window.LockableStorage = {
        lock: function (key, callback, maxDuration) {
            lockImpl(key, callback, maxDuration, false)
        },
        trySyncLock: function (key, callback, maxDuration) {
            return lockImpl(key, callback, maxDuration, true)
        }
    };
})();

Теперь необходимо объединить все в единый механизм, который и позволит реализовать задуманное.


Код реализации
if (Intercom.supported) {
        let intercom = Intercom.getInstance(), //Intercom singleton
            period_heart_bit = 1, //LocalStorage update frequency
            wsId = someNumber() + Date.now(), //Current tab ID
            primaryStatus = false, //Primary window tab status
            refreshIntervalId,
            count = 0, //Counter. Delete this
            intFast; //Timer

        window.webSocketInit = webSocketInit;
        window.semiCloseTab = semiCloseTab;

        intercom.on('incoming', data => {
            document.getElementById('counter').innerHTML = data.data;
            document.getElementById('socketStatus').innerHTML = primaryStatus.toString();
            return false;
        });

        /**
         * Random number
         * @returns {number} - number
         */
        function someNumber() {
            return Math.random() * 1000000000 | 0;
        }

        /**
         * Try do something
         */
        function webSocketInit() {
            // Check for crash or loss network
            let forceOpen = false,
                wsLU = localStorage.wsLU;

            if (wsLU) {
                let diff = Date.now() - parseInt(wsLU);
                forceOpen = diff > period_heart_bit * 5 * 1000;
            }

            //Double checked locking
            if (!localStorage.wsOpen || localStorage.wsOpen !== "true" || forceOpen) {

                LockableStorage.trySyncLock("wsOpen", function () {
                    if (!localStorage.wsOpen || localStorage.wsOpen !== "true" || forceOpen) {
                        localStorage.wsOpen = true;
                        localStorage.wsId = wsId;
                        localStorage.wsLU = Date.now();

                        //TODO this app logic that must be SingleTab ----------------------------
                        primaryStatus = true;
                        intFast = setInterval(() => {
                            intercom.emit('incoming', {data: count});
                            count++
                        }, 1000);
                        //TODO ------------------------------------------------------------------

                        startHeartBitInterval();
                    }
                });
            }
        }

        /**
         * Show singleTab app status
         */
        setInterval(() => {
            document.getElementById('wsopen').innerHTML = localStorage.wsOpen;
        }, 200);

        /**
         * Update localStorage info
         */
        function startHeartBitInterval() {
            refreshIntervalId = setInterval(function () {
                localStorage.wsLU = Date.now();
            }, period_heart_bit * 1000);
        }

        /**
         * Close tab action
         */
        intercom.on('TAB_CLOSED', function (data) {
            if (localStorage.wsId !== wsId) {
                count = data.count;
                setTimeout(() => {
                    webSocketInit()
                }, parseInt(getRandomArbitary(1, 1000), 10)); //Init after random time. Important!
            }
        });

        function getRandomArbitary(min, max) {
            return Math.random() * (max - min) + min;
        }

        /**
         * Action after some tab closed
         */
        window.onbeforeunload = function () {
            if (primaryStatus) {
                localStorage.setItem('wsOpen', false);
                clearInterval(refreshIntervalId);
                intercom.emit('TAB_CLOSED', {count: count});
            }
        };

        /**
         * Emulate close window
         */
        function semiCloseTab() {
            if (primaryStatus) {
                localStorage.setItem('wsOpen', false);
                clearInterval(refreshIntervalId);
                clearInterval(intFast);
                intercom.emit('TAB_CLOSED', {count: count});
            }
        }

        webSocketInit() //Try do something

    } else {
        alert('intercom.js is not supported by your browser.');
    }

Теперь на пальцах объясню, что здесь происходит.


Демо-проект на GitHub


Шаг 1. Открытие первой вкладки


Данный пример реализует таймер, работающий в нескольких вкладах, но вычисления которого происходит лишь в одной. Код таймера можно заменить на что угодно, например, на инициализацию WS-соединения. при запуске сразу выполняется webSocketInit(), что в первой вкладке приведет нас к запуску счетчика (открытию сокета), а так же к запуску таймера startHeartBitInterval() обновления значения ключа "wsLU" в localStorage. Данный ключ отвечает за время создания и поддержания активности Primary-вкладки. Это ключевой элемент всей конструкции. Одновременно создается ключ "wsOpen", который отвечает за статус работы счетчика (или открытие WS-соединения) и переменная "primaryStatus", делающая текущую вкладку главной, становится истиной. Получение любого события из счетчика (WS-соединения) будет эмитится в Intercom, конструкцией:


intercom.emit('incoming', {data: count});

Шаг 2. Открытие второй вкладки


Открытие второй, третьей и любой другой вкладки вызовет webSocketInit(), после чего в бой вступает ключ "wsLU" и "forceOpen". Если код:


if (wsLU) {
    let diff = Date.now() - parseInt(wsLU);
    forceOpen = diff > period_heart_bit * 5 * 1000;
}

… приведет к тому, что "forceOpen" станет true, то счетчик остановится и начнется заново, но этого не произойдет, т.к. diff не будет больше заданного значения, ибо ключ wsLU поддерживается актуальным Primary-вкладкой. Все Secondary-вкладки будут слушать события, которые им отдает Primary-вкладка через Intercom, конструкцией:


intercom.on('incoming', data => {
    document.getElementById('counter').innerHTML = data.data;
    document.getElementById('socketStatus').innerHTML = primaryStatus.toString();
    return false;
});

Шаг 3. Закрытие вкладки


Закрытие вкладок вызывает в современных браузерах событие onbeforeunload. Мы обрабатываем его следующим образом:


window.onbeforeunload = function () {
    if (primaryStatus) {
        localStorage.setItem('wsOpen', false);
        clearInterval(refreshIntervalId);
        intercom.emit('TAB_CLOSED', {count: count});
    }
};

Нужно обратить внимание, что вызов всех методов произойдет лишь в Primary-вкладке. При закрытии любой Secondary-вкладки ничего со счетчиком происходить не будет. Нужно лишь убрать прослушку событий, чтобы освободить память. Но если мы закрыли Primary-вкладку, то мы поставим wsOpen в значение false и отпавим событие TAB_CLOSED. Все открытые табы тут же отреагируют на него:


intercom.on('TAB_CLOSED', function (data) {
    if (localStorage.wsId !== wsId) {
        count = data.count;
        setTimeout(() => {
            webSocketInit()
        }, parseInt(getRandomArbitary(1, 1000), 10)); //Init after random time. Important!
    }
});

Вот здесь и начинается магия. Функция...


getRandomArbitary(1, 1000)
function getRandomArbitary(min, max) {
    return Math.random() * (max - min) + min;
}

… позволяет вызывать инициализацию сокета (в нашем случае счетчика) через разные промежутки времени, что дает возможность какой-то из Secondary-вкладок успеть стать Primary и записать инфу об этом в localStorage. Пошаманив в цифрами (1, 1000) можно добиться максимально быстрого отклика вкладок. Остальные Secondary-вкладки остаются слушать события и реагировать на них, ожидая смерти Primary.


Итог


Мы получили конструкцию, которая позволяет держать лишь одно webSocket-соединение для всего приложения, сколько бы вкладок у него не было, что существенно сократит нагрузку на железо наших серверов и, как следствие, позволит держать больший онлайн.

Поделиться публикацией
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 54
    +1
    Решить бы еще на клиентской стороне эту проблему…
      0
      Так это и есть решение проблемы именно на клиентской стороне)
        +2
        Я имею ввиду чтобы куча вкладок не убивала клиентскую машину с точки зрения расхода памяти=)
        Полезность вашего решения я не отрицаю, разумеется.
          +3
          Тоже подумал про проблемы браузера когда увидел название. Не пойму почему до сих пор не сделали опцию полной выгрузки вкладки (или всех неактивных вкладок за последние 30 минут например) в свап с заморозкой всех её скриптов по желанию пользователя.
            +1
            Да часто и перезагрузить не жалко — трафик дешевле, чем память.
              +3
              НЕТ! Очень плохое решение! У меня иногда вкладка с набранным текстом висит неделю. А ещё прогресс в играх, выбранные опции и так далее. Не все сайты умеют сохранять state корректно. А если ещё и браузер начнёт вкладки убивать из-за того что я открыл больше одной — это совсем зашквар
                +1
                Если это выбирает пользователь (и по умолчанию вуключено), то все Ок. Но вообще, я за решение от firk. Только надо не системный своп использовать, а просто сделать правильный дисковый кеш.

                Но лично я не отказался бы и просто выгружать вкладки, которые не были активны в течение суток. Еще можно детектировать наличие фокуса на текстовых полях и тд.
                  0
                  Да, это действительно больно. Я как-то обновился на Dev версию Chrome, которая так делала. Это слишком сильно мешало.
                0
                иногда во вкладке играет музыка, например, или ожидается событие с внешней стороны — мессенджеры, фронтенды к сервисам и так далее. Нельзя просто так брать и замораживать все вкладки как поведение по умолчанию. Сейчас уже используются технологии энергосбережения, когда выделяется меньше CPU фоновым вкладкам и тд, этого достаточно.
                Как опцию или фичу — можно
                  +1
                  CPU не узкое место в этом вопросе. Браузер за пару часов забивает память, система убирает другие приложения в своп. А потом вы переходите в любимую IDE и ждете, пока оно оттуда откопается — отзывчивость системы падает радикально.
                  0
                  Chrome + The Great Suspender более менее похоже и делают (насколько можно для расширения сделать). И да — введенный во вкладке текст блокирует ее увод. Играющая музыка — учитывается. Есть белые списки…
            +1
            А зачем эта драка за главенство, если можно просто отдать его старшей по времени создания среди живых, да хоть и младшей?
              0
              Проблема в том, что умирающая вкладка не знает ничего о других. О их количестве и есть ли они вообще. В свою очередь Secondary не знают ничего друг о друге.
              Можно хранить массив из времени создания всех вкладок, но это более ресурсоемкая задача — гонять массивы
                0
                В LocalStorage нельзя прописать список вкладок с id/timestamp?
                  0

                  Можно. И надо хранить массив открытых вкладок там же. А при закрытии одной из них пробегать счета по всем и сравнивать их по timestamp. Если вкладок >10 это уже будет дольше, чем мой вариант.

              +2
              А чем SharedWorker не устроил?
                0
                SharedWorker — интересная задумка, но поддержка браузеров очень слабая. На мобильных, так вовсе работать не будет. А мое решение абсолютно универсально в плане браузеров и устройств, т.к. localStorage поддерживается везде.
                0
                Вы не первый решали такую задачу)
                  0
                  Данную реализацию не встречал, но спасибо за наводку. Покопаюсь в ней)
                  0

                  Неужели юзеры и правда открывают так много вкладок, что это становится проблемой? #янерепрезантативен

                    0
                    У меня как-то в доке уже не помещались окна от хрома, в каждом из которых было открыто вкладок как на иллюстрации.
                      0
                      Бывает такое, что при онлайне в 10 000 юзеров на сайте в каждой вкладке есть чат, в который приходят сообщения. Если мы откроем несколько, хотя бы 2, вкладки у каждого, то наш сервер уже будет загибаться, чтобы всем все отправить.
                      Либо нужно очень мощное железо, что дорого, либо подобное решение)
                        0
                        Вы трафик через webRTC гоняйте — всё легче чем множество веб сокетов.
                          0
                          С данной технологией не знаком, но выглядит многообещающе. Ознакомлюсь, спасибо!)
                            0
                            А насколько у него хорошо с совместимостью? Ещё год назад использовать его было практически невозможно
                          0

                          Да нет, я понял, какую проблему вы решаете:)
                          Я просто не понимаю, зачем юзеры открывают по две вкладки с одним и тем же.

                            +2

                            Допустим, это twitch. У меня открыта главная страница со списком стримов, вторая на втором мониторе с активным стоимость, который я смотрю и ещё одна на фоне, я просто хочу посмотреть, что там происходит.
                            Или же это сайт-биржа. Я слежу сразу за несколькими котировками валюты в реалтайме

                              0

                              … с активным стримом...

                                0
                                А можно ли так сделать — при уходе вкладки в фон закрывать сокет, а при приходе на вкладку опять открывать?
                                P.S. да, сработает лишь в ряде случаев, т.к. оперативно обновлятся фоновая вкладка не будет.
                                  +2

                                  Это решение породит куда больше проблем. В браузерах есть событие о том, что пользователь сделала вкладку аквтивной или ушел с нее. Т.е. мы можем открывать и закрывать соединения, а вот на сервере активные клиенты все равно будут какое-то время существовать и им будет все отдаваться в сокеты. Они будут умирать через некое время, что в моменте может порождать большое количество мертвых, но активных соединений. Это же даёт возможность при переключении их вкладки во вкладку быстро положить даже самый сильный бекенд

                                    0
                                    Понял, спасибо.
                              0
                              Я например на хабре открываю статьи на почитать потом. «Потом» иногда оказывается… сильно не скоро.
                              В моем случае — хабр можно заменить и например на Gitlab.com/СИ/Goodreads…
                                +1
                                Как тут ниже заметили, «потом прочитаю». Вот прям сейчас у меня только хабровых вкладок открыто десятка два — пробежался быстренько по /all, пооткрывал потенциально интересное и потихоньку просматриваю. Или ютупчик пока гундосят нечто невнятное — поглядываешь что он там еще предлагает по теме и в фоновую вкладку открываешь. На другом видео из списка — повторить. Таким образом открывается 100500 вкладок которые уже изучаешь…
                            0
                            это уже давно придумали, например реализации в логуксе уже года 2:
                            github.com/logux/logux-client/blob/master/cross-tab-client.js
                              0
                              Еще одна реализация, подобная той, что писал KYuri. Надо будет посмотреть.
                              Именно для этого мы здесь и собрались, чтобы находить лучшие решения)
                                0
                                У меня проблема была в том, что на Хабре я не смог ничего подобного найти, а разобраться самому во всех тонкостях работы такой большой библиотеки сложновато
                                0
                                Еще одна реализация, подобная той, что писал KYuri. Надо будет посмотреть.
                                Именно для этого мы здесь и собрались, чтобы находить лучшие решения)
                                  0
                                  Интересно как оно работает с «засыпанием» (hibernation) вкладок в Vivaldi.
                                  Содержимое вкладки при этом стирается и заменяется «пустой» вкладкой.
                                    +1

                                    Вот как. Если мы выйдем из гибернации и сторедж будет пустой, то произойдет инициализация подключения. И будет работать механизм конкуренции вкладок. Та, кто первая успеет вызвать коннект, та и станет главной.

                                    0
                                    примерно такое делал для первого ангуляра)
                                    github.com/klerick/fs-socket-io
                                      0
                                      Эта идея у меня как раз в первом ангуляре и нашла свое первое воплощение)
                                      0
                                      А как получается, если открыты не вкладки, а окна? Иногда могут открыть одно и то же в разных окнах. Зачем — не спрашивайте, но такое бывает
                                        0
                                        localStorage на то и local, что существует локально. Он существует только в рамках одного окна.
                                          0
                                          Разве? Как может существовать в рамках одного окна то, что сохраняется при закрытии браузера?
                                            0
                                            Хм… да, вот здесь я ошибся. Не знал о таком поведение localStorage. Если открыть один и тот же домен в нескольких! окнах!, то и LS у них будет общий. приношу извинения за смуту. Занятная механика)
                                              0

                                              А разве браузеру не все равно — разные это вкладки или окна? Это уже детали отображения, не более

                                                0
                                                То есть, primary-вкладка будет определяться и в других окнах?
                                          • НЛО прилетело и опубликовало эту надпись здесь
                                              0
                                              Самый полезное, функциональное и удобное расширение для работы с кучей вкладок — Tabs Outliner. Не благодарите :)
                                              0
                                              function now() {
                                                  return new Date().getTime();
                                              }
                                              

                                              Лучше заменить на
                                              function now() {
                                                  return Date.now();
                                              }
                                              
                                                –1

                                                А не логичнее ли в современных реалиях вынести общий коннект в ServiceWorker? Он будет гарантированно один на все вкладки одного сайта. Так же из плюсов вижу, что браузер его прибьет сам (а с ним и коннект) при длительном отсутствии активности во всех подключившихся к воркеру вкладок.

                                                  0

                                                  А почему бы не сделать так:


                                                  1. Открываем вкладку, спрашиваем, кто главный, если тишина — становимся главным, иначе говорим главному свой ид
                                                  2. в beforeunload для слейвов отправляем главному свой ID и говорим прощай
                                                  3. в beforeunload для главного посмотреть список слейвов, и явно указать кто будет следующим главным.

                                                  Думаю будет меньше плясок с рандомами...

                                                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                                  Самое читаемое