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

pn_37n_daf8ssnbb8s3qbcrnjv0.jpeg

Мы попробуем разобраться — как можно снизить нагрузку на серверное железо, обеспечив при этом максимальную производительность 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-соединение для всего приложения, сколько бы вкладок у него не было, что существенно сократит нагрузку на железо наших серверов и, как следствие, позволит держать больший онлайн.

© Habrahabr.ru