Мы попробуем разобраться — как можно снизить нагрузку на серверное железо, обеспечив при этом максимальную производительность Web-приложения.
В разработке больших высоконагруженных проектов с огромным онлайном часто приходится думать, как снизить нагрузку на сервера, особенно при работе в webSocket'ами и динамически изменяемыми интерфейсами. К нам приходит 100500 пользователей и мы имеем 100500 открытых соединений по сокетам. А если каждый из них откроет по 2 вкладки — это уже *201000 соединений. А если пять?
Рассмотрим тривиальный пример. Имеем, допустим, Twitch.tv, который для каждого пользователя поднимает WS соединение. Онлайн у такого проекта огромный, значит важна каждая деталь. Мы не можем позволить себе открывать на каждой вкладке новое WS-соединение, поддерживая старое, ибо железа нужно немерено для этого.
Рождается идея — а что, если WS соединения поднимать лишь в одной вкладке и всегда держать его открытым, а в новых не инициализировать подключение, а просто слушать из соседней вкладки? Именно о реализации этой идеи я и хочу рассказать.
Логическое поведение вкладок в браузере
- Открываем первую вкладку, помечаем ее, как Primary
- Запускаем проверку — если вкладка is_primary, то поднимаем WS-соединение
- Работаем...
- Открываем вторую вкладку (дублируем окно, вводим адрес вручную, открываем в новой вкладке, неважно)
- Из новой вкладки смотрим есть ли где-то Primary-вкладка. Если "да", то текущую помечаем Secondary и ждем, что будет происходить.
- Открываем еще 10 вкладок. И все ждут.
- В какой-то момент закрывается Primary-вкладка. Перед своей смертью она кричит всем о своей погибели. Все в шоке.
- И тут все вкладки пытаются мигом стать Primary. Реакция у всех разная (рандомная) и кто успел, того и тапки. Как только одна из вкладок сумела стать is_primary, она всем кричит о том, что место занято. После этого у себя поднимает заново WS-соединение. Работаем. Остальные ждут.
- И т.д. Падальщики ждут смерти Primary-вкладки, чтобы встать на ее место.
Техническая сторона вопроса
Для общения между вкладками мы будем использовать то, что связывает их в рамках одного домена — localStorage. Обращения к нему не затратны по ресурсам железа пользователя и отклик от них весьма быстр. Вокруг него и строится вся задумка.
Есть библиотека, которая уже долгое время не поддерживается создателем, но можно сделать ее локальный форк, как я и поступил. Из нее мы достаем файл:
/intercom.js
Суть библиотеки в том, что она позволяет общаться евентами emit/on между вкладками используя для этого localStorage.
После этого нам нужен инструмент, позволяющий "лочить" (блокировать изменения) некий ключ в localStorage, не позволяя его никому изменять без необходимых прав. Для этого была написана маленькая библиотека "locableStorage", суть которой заключена в функции trySyncLock()
(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.');
}
Теперь на пальцах объясню, что здесь происходит.
Шаг 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!
}
});
Вот здесь и начинается магия. Функция...
function getRandomArbitary(min, max) {
return Math.random() * (max - min) + min;
}
… позволяет вызывать инициализацию сокета (в нашем случае счетчика) через разные промежутки времени, что дает возможность какой-то из Secondary-вкладок успеть стать Primary и записать инфу об этом в localStorage. Пошаманив в цифрами (1, 1000) можно добиться максимально быстрого отклика вкладок. Остальные Secondary-вкладки остаются слушать события и реагировать на них, ожидая смерти Primary.
Итог
Мы получили конструкцию, которая позволяет держать лишь одно webSocket-соединение для всего приложения, сколько бы вкладок у него не было, что существенно сократит нагрузку на железо наших серверов и, как следствие, позволит держать больший онлайн.
Автор: Ravy