
Uma pequena pergunta / resposta:
Para quem é? Pessoas que têm pouca ou nenhuma experiência com sistemas distribuídos e que estão interessadas em ver como podem ser construídas, quais padrões e soluções existem.
Por que isso? Ele mesmo se interessou em quê e como. Recolhi informações de várias fontes, decidi publicá-las de forma concentrada, porque uma vez eu mesmo gostaria de ver um trabalho semelhante. De fato, esta é uma declaração textual de minhas jogadas e pensamentos pessoais. Além disso, certamente haverá muitas correções nos comentários de pessoas conhecedoras, e esse é parcialmente o objetivo de escrever tudo isso na forma de um artigo.
Declaração do problema
Como fazer um bate-papo? Essa deve ser uma tarefa trivial, provavelmente todo segundo beckender viu sua própria, assim como os desenvolvedores de jogos fazem suas tetris / cobras, etc. usuários ativos e, em geral, foi incrivelmente legal. A clara necessidade de uma arquitetura distribuída vem disso, porque não é realista ter a capacidade atual para atender todo o número imaginário de clientes em uma máquina. Em vez de apenas ficar esperando a aparição de computadores quânticos, decidi decididamente a estudar o tópico de sistemas distribuídos.
Vale a pena notar que uma resposta rápida é muito importante, o notório em tempo real, é um bate - papo ! não entrega de correio de pombo.
% piada aleatória sobre o post russo %
Usaremos o Node.JS, é ideal para prototipagem. Para soquetes, use Socket.IO. Escreva no TypeScript.
E então o que queremos:
- Para que os usuários possam enviar mensagens uns aos outros
- Saiba quem está online / offline
Como queremos:
Servidor único
Não há nada a dizer especialmente, direto ao código. Declare a interface da mensagem:
interface Message{ roomId: string,// message: string,// }
No servidor:
io.on('connection', sock=>{ // sock.on('join', (roomId:number)=> sock.join(roomId)) // // sock.on('message', (data:Message)=> io.to(data.roomId).emit('message', data)) })
No cliente, algo como:
sock.on('connect', ()=> { const roomId = 'some room' // sock.on('message', (data:Message)=> console.log(`Message ${data.message} from ${data.roomId}`)) // sock.emit('join', roomId) // sock.emit('message', <Message>{roomId: roomId, message: 'Halo!'}) })
Você pode trabalhar com status online como este:
io.on('connection', sock=>{ // // , - // sock.on('auth', (uid:string)=> sock.join(uid)) //, , // // sock.on('isOnline', (uid:string, resp)=> resp(io.sockets.clients(uid).length > 0)) })
E no cliente:
sock.on('connect', ()=> { const uid = 'im uid, rly' // sock.emit('auth', uid) // sock.emit('isOnline', uid, (isOnline:boolean)=> console.log(`User online status is ${isOnline}`)) })
Nota: o código não foi executado, eu escrevo da memória apenas por exemplo
Assim como a lenha, geramos autorização real syudy, gerenciamento de sala (histórico de mensagens, adição / remoção de participantes) e lucro.
MAS! Mas vamos conquistar a paz mundial, o que significa que não é hora de parar, estamos avançando rapidamente:
Cluster Node.JS
Exemplos de uso do Socket.IO em muitos nós estão no site oficial . Incluindo também existe um cluster Node.JS nativo, que me pareceu inaplicável à minha tarefa: ele nos permite expandir nosso aplicativo em toda a máquina, mas não além de seu escopo, por isso definitivamente sentimos falta dele. Precisamos finalmente ir além dos limites de um pedaço de ferro!
Distribua e ande de bicicleta
Como fazer isso? Obviamente, você precisa conectar de alguma forma nossas instâncias, lançadas não apenas em casa no porão, mas também no porão vizinho. O que primeiro vem à mente: fazemos algum tipo de link intermediário que servirá como um barramento entre todos os nossos nós:

Quando um nó deseja enviar outra mensagem, ele faz uma solicitação ao Barramento e, por sua vez, o encaminha para onde é necessário, tudo é simples. Nossa rede está pronta!
FIN.
... mas não é tão simples?)
Com essa abordagem, encontramos o desempenho desse link intermediário e, na verdade, gostaríamos de entrar em contato diretamente com os nós necessários, porque o que pode ser mais rápido que a comunicação direta? Então, vamos seguir nessa direção!
O que é necessário primeiro? Na verdade, legitima uma instância para outra. Mas como o primeiro aprende sobre a existência do segundo? Mas queremos ter um número infinito deles, arbitrariamente aumentar / remover! Precisamos de um servidor mestre cujo endereço seja conhecido, todos se conectem a ele, devido ao qual conhecem todos os nós existentes na rede e compartilhem essas informações com todos.

O nó sobe, informa o mestre sobre seu despertar, fornece uma lista de outros nós ativos, nós nos conectamos a eles e é isso, a rede está pronta. O mestre pode ser cônsul ou algo assim, mas, como estamos pedalando, o mestre deve ser feito por si próprio.
Ótimo, agora temos o nosso próprio skynet! Mas a implementação atual do bate-papo não é mais adequada. Vamos realmente apresentar os requisitos:
- Quando um usuário envia uma mensagem, precisamos saber para quem ele a envia, ou seja, ter acesso aos participantes na sala.
- Quando recebemos os participantes, devemos entregar-lhes mensagens.
- Precisamos saber qual usuário está online agora.
- Por conveniência - dê aos usuários a oportunidade de assinar o status online de outros usuários, para que, em tempo real, aprendam sobre sua mudança
Vamos lidar com os usuários. Por exemplo, você pode informar ao mestre qual nó está conectado a qual nó. A situação é a seguinte:

Dois usuários estão conectados a nós diferentes. O mestre sabe disso, os nós sabem o que o mestre sabe. Quando o UsuárioB efetua login, o Nó2 notifica o Mestre, que "lembra" que o UsuárioB está conectado ao Nó2. Quando o UsuárioA deseja enviar uma mensagem do UsuárioB, você obtém a seguinte imagem:

Em princípio, tudo funciona, mas eu gostaria de evitar uma viagem extra na forma de interrogar o mestre; seria mais econômico entrar em contato imediatamente com o nó certo diretamente, porque é por isso que tudo foi iniciado. Isso pode ser feito se eles disserem a todos em torno de quais usuários estão conectados a eles, cada um deles se tornará um análogo auto-suficiente do assistente e o próprio assistente se tornará desnecessário, porque a lista da proporção "Usuário => Nó" é duplicada para todos. No início de um nó, basta conectar-se a um já em execução, puxar sua lista para si mesmo e pronto, ele também está pronto para a batalha.


Mas, como troca, obtemos uma duplicação da lista, que, embora seja uma proporção de "id do usuário -> [conexões de host]", mas com um número suficiente de usuários, ela se mostra bastante grande na memória. E, em geral, cortando você mesmo - isso claramente cheira à indústria de bicicletas. Quanto mais código, mais erros potenciais. Talvez congele esta opção e dê uma olhada no que já está pronto:
Corretores de mensagens
A entidade que implementa o mesmo "Barramento", o "link intermediário" mencionado acima. Sua tarefa é receber e entregar mensagens. Nós, como usuários, podemos assinar e enviar os nossos. Tudo é simples.
Existem o RabbitMQ e o Kafka bem conhecidos: eles apenas fazem o que entregam mensagens - esse é o objetivo deles, repleto de todas as funcionalidades necessárias para o pescoço. No mundo deles, uma mensagem deve ser entregue, não importa o quê.
Ao mesmo tempo, há Redis e seu pub / sub - o mesmo que os caras mencionados, mas mais duvidoso: ele recebe a mensagem estupidamente e a entrega ao assinante, sem filas e outras despesas gerais. Ele absolutamente não se importa com as mensagens, elas desaparecerão, se o assinante travar - ele a jogará fora e assumirá uma nova, como se jogassem um pôquer em brasa nas mãos das quais você deseja se livrar mais rapidamente. Além disso, se ele cair repentinamente - todas as mensagens também afundarão junto com ele. Em outras palavras, não há dúvida de qualquer garantia de entrega.
... e é isso que você precisa!
Bem, na verdade, apenas conversamos. Não é algum tipo de serviço crítico de dinheiro ou centro de controle de vôo espacial, mas ... apenas um bate-papo. O risco de que Pete condicional uma vez por ano não receba uma mensagem em mil - pode ser negligenciado se, em troca, obtivermos crescimento da produtividade e, com ele, o número de usuários nos mesmos dias, trocar em toda a sua glória. Além disso, ao mesmo tempo, você pode manter um histórico de mensagens em algum tipo de repositório persistente, o que significa que o Petya ainda verá a mensagem perdida recarregando a página / aplicativo. É por isso que vamos nos concentrar no pub / sub Redis, ou melhor: observe o adaptador existente para o SocketIO, mencionado no artigo no escritório. site .
Então o que é isso?
Adaptador Redis
https://github.com/socketio/socket.io-redis
Com sua ajuda, um aplicativo comum através de algumas linhas e um número mínimo de gestos se transforma em um bate-papo distribuído real! Mas como Se você olhar para dentro , verifica-se que há apenas um arquivo por meia centena de linhas.
No caso em que emitimos uma mensagem
io.emit("everyone", "hello")
é empurrado para rabanetes, transmitido para todas as outras instâncias do nosso bate-papo, que por sua vez o emitem localmente nos soquetes

A mensagem será distribuída por todos os nós, mesmo se emitirmos para um usuário específico. Ou seja, cada nó aceita todas as mensagens e já entende se precisa delas.
Além disso, foi implementado um rpc simples (chamando procedimentos remotos), que permite não apenas enviar, mas também receber respostas. Por exemplo, você pode controlar soquetes remotamente, como "quem está na sala especificada", "solicitar que o soquete entre na sala" etc.
O que pode ser feito com isso? Por exemplo, use o ID do usuário como o nome da sala (ID do usuário == ID da sala). Ao autorizar, conectar o soquete a ele e quando queremos enviar uma mensagem ao usuário - apenas um capacete nele. Além disso, podemos descobrir se o usuário está online, simplesmente olhando se há soquetes na sala especificada.
Em princípio, podemos parar por aqui, mas como sempre, não é suficiente para nós:
- Gargalo em uma única instância de rabanete
- Redundância, gostaria que os nós recebessem apenas as mensagens necessárias
À custa do primeiro parágrafo, observe algo como:
Cluster Redis
Ele conecta várias instâncias de rabanete, após o que elas funcionam como um todo. Mas como ele faz isso? Sim, assim:

... e vemos que a mensagem é duplicada para todos os membros do cluster. Ou seja, não se destina a aumentar a produtividade, mas a aumentar a confiabilidade, o que é certamente bom e necessário, mas, no nosso caso, não tem valor e não salva a situação com um gargalo, além de, em suma, ser ainda mais desperdício de recursos.

Sou iniciante, não sei muito, às vezes tenho que voltar ao pitchforking, o que faremos. Não, vamos deixar o rabanete para que não escorregue, mas você precisa pensar em algo com a arquitetura, porque a atual não é boa.
Vire na direção errada
Do que precisamos? Aumente a taxa de transferência geral. Por exemplo, vamos tentar estupidamente gerar outra instância. Imagine que o socket.io-redis possa se conectar a vários, ao enviar uma mensagem, ele seleciona aleatoriamente e assina tudo. Acontece assim:

Voila! Em geral, o problema está resolvido, rabanetes não são mais um gargalo, você pode gerar qualquer número de cópias! Mas eles se tornaram nós. Sim, sim, nossas instâncias de bate-papo ainda digerem TODAS as mensagens, para as quais não foram destinadas.
Você pode vice-versa: assine um aleatório, o que reduzirá a carga nos nós e enviará tudo:

Vimos que isso se tornou o contrário: os nós parecem mais calmos, mas a carga na instância de rabanete aumentou. Isso também não é bom. Você precisa andar de bicicleta um pouco.
Para bombear nosso sistema, deixaremos o pacote socket.io-redis em paz, embora seja legal, precisamos de mais liberdade. E assim, conectamos o rabanete:
// : const pub = new RedisClient({host: 'localhost', port: 6379})// const sub = new RedisClient({host: 'localhost', port: 6379})// // interface Message{ roomId: string,// message: string,// }
Configure nosso sistema de mensagens:
// sub.on('message', (channel:string, dataRaw:string)=> { const data = <Message>JSON.parse(dataRaw) io.to(data.roomId).emit('message', data)) }) // sub.subscribe("messagesChannel") // sock.on('join', (roomId:number)=> sock.join(roomId)) // sock.on('message', (data:Message)=> { // pub.publish("messagesChannel", JSON.stringify(data)) })
No momento, acontece como em socket.io-redis: ouvimos todas as mensagens. Agora vamos consertar isso.
Organizamos as assinaturas da seguinte forma: lembre-se do conceito com "user id == room id" e, quando o usuário aparecer, inscreva-se no canal com o mesmo nome no rabanete. Assim, nossos nós receberão apenas mensagens destinadas a eles, e não ouvirão a "transmissão inteira".
// sub.on('message', (channel:string, message:string)=> { io.to(channel).emit('message', message)) }) let UID:string|null = null; sock.on('auth', (uid:string)=> { UID = uid // - // UID sub.subscribe(UID) // sock.join(UID) }) sock.on('writeYourself', (message:string)=> { // , UID if (UID) pub.publish(UID, message) })
Impressionante, agora temos certeza de que os nós recebem apenas mensagens destinadas a eles, nada mais! Deve-se notar, no entanto, que as próprias assinaturas agora são muito, muito maiores, o que significa que elas consumirão a memória do yoy yoy, + mais operações de assinatura / cancelamento de assinatura, que são relativamente caras. Mas, de qualquer forma, isso nos dá alguma flexibilidade, você pode até parar neste momento e revisar todas as opções anteriores, já levando em conta nossa nova propriedade de nós na forma de mensagens de recebimento mais seletivas e castas. Por exemplo, os nós podem se inscrever em uma das várias instâncias de rabanete e, ao pressionar, enviar uma mensagem para todas as instâncias:

... mas, o que quer que se diga, eles ainda não oferecem extensibilidade infinita com sobrecarga razoável, você precisa dar a luz a outras opções. A certa altura, veio à minha mente o seguinte esquema: e se as instâncias de rabanete forem divididas em grupos, digamos A e B, duas instâncias em cada uma. Ao assinar, os nós são assinados por uma instância de cada grupo e, quando pressionados, eles enviam uma mensagem para todas as instâncias de um único grupo aleatório.


Assim, obtemos uma estrutura operacional com potencial infinito de capacidade de expansão em tempo real, a carga em um nó individual a qualquer momento não depende do tamanho do sistema, porque:
- A largura de banda total é dividida entre grupos, ou seja, com um aumento de usuários / atividade, simplesmente comparamos grupos adicionais.
- O gerenciamento de usuários (assinaturas) é dividido nos próprios grupos, ou seja, ao aumentar usuários / assinaturas, simplesmente aumentamos o número de instâncias nos grupos.
... e como sempre, há um "MAS": quanto mais tudo fica, mais recursos são necessários para o próximo ganho, parece-me um exorbitante trade-off.
Em geral, se você pensar sobre isso, os plugues mencionados acima não saberão qual usuário está em qual nó. Bem, de fato, se tivéssemos essas informações, poderíamos enviar as mensagens exatamente onde elas precisavam, sem duplicação desnecessária. O que tentamos fazer esse tempo todo? Eles tentaram tornar o sistema infinitamente escalável, apesar de não ter um mecanismo de endereçamento claro, que inevitavelmente chegava a um beco sem saída ou a redundância injustificada. Por exemplo, você pode recordar que o assistente atua como um "catálogo de endereços":

Algo semelhante diz a esse cara:
Para obter a localização do usuário, fazemos uma viagem de ida e volta adicional, o que é, em princípio, bom, mas não no nosso caso. Parece que estamos cavando na direção errada, precisamos de outra coisa ...
Força de hash
Existe um hash. Tem algum intervalo finito de valores. Você pode obtê-lo a partir de qualquer dado. Mas e se você dividir esse intervalo entre instâncias de rabanete? Bem, pegamos o ID do usuário, produzimos um hash e, dependendo do intervalo em que ele se inscreveu / envia para uma instância específica. Ou seja, não sabemos com antecedência onde o usuário existe, mas, depois de recebê-lo, podemos dizer com segurança que ele está na instância n, inf 100. Agora, a mesma coisa, mas com o código:
function hash(val:string):number{/**/}// -, const clients:RedisClient[] = []// const uid = "some uid"// //, // const selectedClient = clients[hash(uid) % clients.length]
Voila! Agora não somos dependentes do número de instâncias da palavra em geral, podemos escalar o quanto quisermos sem despesas gerais! Bem, sério, essa é uma opção brilhante, cujo único ponto negativo é a necessidade de reiniciar completamente o sistema ao atualizar o número de instâncias de rabanete. Existe um anel padrão e um anel de partição que permitem superar isso, mas eles não são aplicáveis em um sistema de mensagens. Bem, você pode criar a lógica de migrar assinaturas entre instâncias, mas ainda custa um código adicional de tamanho incompreensível e, como sabemos, quanto mais código, mais erros, não precisamos disso, obrigado. E, no nosso caso, o tempo de inatividade é uma compensação aceitável.
Você também pode ver o RabbitMQ com seu plug-in , que nos permite fazer o mesmo que fazemos, e + fornece a migração de assinaturas (como eu disse acima - ele está vinculado à funcionalidade da cabeça aos pés). Em princípio, você pode pegá-lo e dormir em paz, mas se alguém se atrapalhar na sintonia para trazer o modo para o tempo real, deixando apenas um recurso com um anel de hash.
Inundou o repositório no github.
Ele implementa a versão final para a qual chegamos. Além disso, há uma lógica adicional para trabalhar com salas (caixas de diálogo).
Em geral, estou satisfeito e pode ser completado.
Total
Você pode fazer qualquer coisa, mas existem recursos, e eles são finitos; portanto, você precisa se esquivar.
Começamos com total ignorância de como os sistemas distribuídos podem funcionar com padrões concretos mais ou menos tangíveis, e isso é bom.