Resolvemos o problema de um milhão de abas abertas ou "ajudamos o hardware a sobreviver"


Vamos tentar descobrir como reduzir a carga no hardware do servidor, garantindo o desempenho máximo dos aplicativos da Web.


No desenvolvimento de projetos grandes e de alta carga com grande disponibilidade on-line, você geralmente precisa pensar em como reduzir a carga no servidor, especialmente ao trabalhar no webSockets e mudar dinamicamente as interfaces. 100500 usuários vêm até nós e temos 100500 conexões de soquete aberto. E se cada um deles abrir 2 guias - isso é * 201.000 conexões. E se cinco?


Considere um exemplo trivial. Temos, por exemplo, Twitch.tv , que para cada usuário gera uma conexão WS. Esse projeto é enorme online, portanto, todos os detalhes são importantes. Não podemos nos dar ao luxo de abrir uma nova conexão WS em cada guia, suportando a antiga, porque a glândula precisa ser incomensurável para isso.


Nasce a idéia - e se as conexões WS forem levantadas em apenas uma guia e sempre a mantiverem abertas, e nas novas não inicializarem a conexão, mas apenas ouvir na guia vizinha? É sobre a implementação dessa idéia que eu quero contar.


Comportamento lógico do navegador


  1. Abra a primeira guia, marque-a como Principal
  2. Execute o teste - se a guia is_primary, aumente a conexão WS
  3. Estamos trabalhando ...
  4. Abra a segunda guia (duplique a janela, digite o endereço manualmente, abra em uma nova guia, não importa)
  5. Na nova guia, verifique se há em algum lugar uma guia Principal. Se sim, marque o secundário atual e aguarde o que acontecerá.
  6. Abra mais 10 guias. E todo mundo está esperando.
  7. Em algum momento, a guia Primário é fechada. Antes de sua morte, ela grita a todos sobre sua morte. Tudo está em choque.
  8. E então todas as guias estão tentando se tornar instantaneamente Primárias. A reação de todos é diferente (aleatória) e quem tem tempo, isso e chinelos. Assim que uma das abas conseguiu se tornar primária, ela grita para todos que o lugar está ocupado. Depois disso, a conexão do WS entra novamente. Nós estamos trabalhando. O resto está esperando.
  9. Etc. Os catadores estão esperando a morte da guia Primária cair em seu lugar.

O lado técnico da questão


Para se comunicar entre as guias, usaremos o que as conecta no mesmo domínio - localStorage. As chamadas para ele não são caras para os recursos de ferro do usuário e a resposta é muito rápida. Em torno disso, toda a idéia está sendo construída.


Há uma biblioteca que não é suportada pelo criador há muito tempo, mas você pode torná-la um fork local, como eu fiz. A partir dele, obtemos o arquivo:


/intercom.js


A essência da biblioteca é que ela permite que eventos de emissão / ativação se comuniquem entre guias usando localStorage para isso.


Depois disso, precisamos de uma ferramenta que permita bloquear ( bloquear alterações ) uma determinada chave no localStorage, sem permitir que ninguém a altere sem os direitos necessários. Para isso, uma pequena biblioteca chamada " locableStorage " foi escrita, cuja essência está contida na função trySyncLock ()


Código da biblioteca 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) } }; })(); 

Agora é necessário combinar tudo em um único mecanismo, o que nos permitirá implementar nossos planos.


Código de implementação
 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.'); } 

Agora, nos dedos, explicarei o que está acontecendo aqui.


Projeto de demonstração do GitHub


Etapa 1. Abrindo a primeira guia


Este exemplo implementa um cronômetro trabalhando em várias contribuições, mas o cálculo ocorre em apenas uma. O código do timer pode ser substituído por qualquer coisa, por exemplo, inicializando a conexão WS. Quando iniciado, o webSocketInit () é executado imediatamente, o que na primeira guia nos leva a iniciar o contador ( abrir o soquete ) e a iniciar o timer startHeartBitInterval () para atualizar o valor da chave " wsLU " em localStorage. Essa chave é responsável pela criação e manutenção da guia Primária. Este é um elemento chave de toda a estrutura. Ao mesmo tempo, a chave wsOpen é criada, responsável pelo status do contador (ou abrir uma conexão WS) e a variável primaryStatus , que torna a guia atual principal, se torna verdadeira. O recebimento de qualquer evento do contador (conexão WS) será emitido pelo Intercom, com o design:


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

Etapa 2. Abrindo a segunda guia


Abrir a segunda, terceira e qualquer outra guia fará com que webSocketInit () , após o qual a chave " wsLU " e " forceOpen " entrem em batalha. Se o código:


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

... fará com que forceOpen se torne verdadeiro , o contador irá parar e iniciar novamente, mas isso não acontecerá, porque diff não será maior que o valor especificado, porque a chave wsLU é suportada pela guia Primária atual. Todas as guias secundárias ouvirão os eventos que a guia Primária lhes fornecer através do Intercom, com o design:


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

Etapa 3. Fechando a guia


Fechar guias aciona o evento onbeforeunload nos navegadores modernos. Nós processamos da seguinte maneira:


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

Observe que todos os métodos serão chamados apenas na guia Primário. Quando você fecha qualquer guia Secundário, nada acontece com o contador. Você só precisa remover a escuta de eventos para liberar memória. Mas se fecharmos a guia Primário, definiremos wsOpen como false e acionar o evento TAB_CLOSED. Todas as guias abertas responderão imediatamente a ela:


 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! } }); 

É aqui que a mágica começa. Função ...


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

... permite chamar a inicialização do soquete (no nosso caso, o contador) em diferentes intervalos, o que possibilita que algumas das guias secundárias se tornem Primárias e grave as informações sobre isso no localStorage. Tendo shamanized em números (1, 1000), você pode obter a resposta mais rápida das guias. As guias secundárias restantes permanecem para ouvir os eventos e respondê-los, esperando a Primária morrer.


Sumário


Temos um design que permite manter apenas uma conexão webSocket para todo o aplicativo, independentemente de quantas guias ele tiver, o que reduzirá significativamente a carga no hardware de nossos servidores e, como resultado, permitirá que você fique mais on-line.

Source: https://habr.com/ru/post/pt415401/


All Articles