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

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

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


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

[Перевод] Интервью с Дэвидом Гобелем

Дэвид любезно согласился дать LEAF очень интересное интервью. Дэвид Гобель – изобретатель, филантроп, футурист и ярый сторонник технологий омоложения; вместе с Обри де Греем он известен как один из основателей Methuselah Foundation и как автор концепции Longevity Escape Velocity (LEV), ...

10 долларов на хостинг: 20 лет назад и сегодня

Всё кругом дорожает, а технологии дешевеют. Когда-то компьютер или мобильный телефон могли позволить себе единицы, сейчас эти устройства есть в каждой российской семье. Цена мегабайта за последние 20 лет упала в несколько тысяч раз. Ещё один пример — хостинг. В ...