Хабрахабр

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

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

К нам приходит 100500 пользователей и мы имеем 100500 открытых соединений по сокетам. В разработке больших высоконагруженных проектов с огромным онлайном часто приходится думать, как снизить нагрузку на сервера, особенно при работе в webSocket'ами и динамически изменяемыми интерфейсами. А если пять? А если каждый из них откроет по 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.

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

Код библиотеки locableStorage

(function () 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-соединения. Данный пример реализует таймер, работающий в нескольких вкладах, но вычисления которого происходит лишь в одной. Данный ключ отвечает за время создания и поддержания активности Primary-вкладки. при запуске сразу выполняется webSocketInit(), что в первой вкладке приведет нас к запуску счетчика (открытию сокета), а так же к запуску таймера startHeartBitInterval() обновления значения ключа "wsLU" в localStorage. Одновременно создается ключ "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;
}

diff не будет больше заданного значения, ибо ключ wsLU поддерживается актуальным Primary-вкладкой. … приведет к тому, что "forceOpen" станет true, то счетчик остановится и начнется заново, но этого не произойдет, т.к. Все 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}); }
};

При закрытии любой Secondary-вкладки ничего со счетчиком происходить не будет. Нужно обратить внимание, что вызов всех методов произойдет лишь в Primary-вкладке. Но если мы закрыли 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;
}

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

Итог

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть