Anúncio
Colegas, em meados do verão pretendo lançar outra série de artigos sobre o projeto de sistemas de filas: “The VTrade Experiment” - uma tentativa de escrever uma estrutura para sistemas de negociação. A série examinará a teoria e a prática da construção de uma bolsa, leilão e loja. Ao final do artigo, convido você a votar nos temas que mais lhe interessam.
Este é o artigo final da série sobre aplicações reativas distribuídas em Erlang/Elixir. EM
Hoje levantaremos questões de desenvolvimento da base de código e projetos em geral.
Organização de serviços
Na vida real, ao desenvolver um serviço, muitas vezes é necessário combinar vários padrões de interação em um controlador. Por exemplo, o serviço de usuários, que resolve o problema de gerenciamento de perfis de usuários do projeto, deve responder às solicitações req-resp e relatar atualizações de perfil via pub-sub. Este caso é bastante simples: por trás das mensagens existe um controlador que implementa a lógica do serviço e publica atualizações.
A situação fica mais complicada quando precisamos implementar um serviço distribuído tolerante a falhas. Vamos imaginar que os requisitos para os usuários mudaram:
- agora o serviço deve processar solicitações em 5 nós do cluster,
- ser capaz de realizar tarefas de processamento em segundo plano,
- e também poder gerenciar dinamicamente listas de assinaturas para atualizações de perfil.
Nota: Não consideramos a questão do armazenamento consistente e da replicação de dados. Vamos supor que esses problemas foram resolvidos anteriormente e que o sistema já possui uma camada de armazenamento confiável e escalável, e os manipuladores possuem mecanismos para interagir com ela.
A descrição formal do serviço aos usuários tornou-se mais complicada. Do ponto de vista do programador, as mudanças são mínimas devido ao uso de mensagens. Para satisfazer o primeiro requisito, precisamos configurar o balanceamento no ponto de troca req-resp.
A necessidade de processar tarefas em segundo plano ocorre com frequência. Nos usuários, isso pode ser a verificação de documentos do usuário, o processamento de multimídia baixada ou a sincronização de dados com mídias sociais. redes. Essas tarefas precisam ser distribuídas de alguma forma dentro do cluster e o progresso da execução monitorado. Portanto, temos duas opções de solução: usar o modelo de distribuição de tarefas do artigo anterior ou, se não for adequado, escrever um agendador de tarefas personalizado que irá gerenciar o pool de processadores da maneira que precisamos.
O ponto 3 requer a extensão do modelo pub-sub. E para implementação, após criar um ponto de troca pub-sub, precisamos lançar adicionalmente o controlador deste ponto dentro do nosso serviço. Assim, é como se estivéssemos movendo a lógica de processamento de assinaturas e cancelamentos da camada de mensagens para a implementação de usuários.
Como resultado, a decomposição do problema mostrou que para atender aos requisitos, precisamos lançar 5 instâncias do serviço em nós diferentes e criar uma entidade adicional - um controlador pub-sub, responsável pela assinatura.
Para executar 5 manipuladores, não é necessário alterar o código de serviço. A única ação adicional é estabelecer regras de equilíbrio no ponto de câmbio, das quais falaremos um pouco mais tarde.
Há também uma complexidade adicional: o controlador pub-sub e o agendador de tarefas personalizado devem funcionar em uma única cópia. Novamente, o serviço de mensagens, como fundamental, deve fornecer um mecanismo para selecionar um líder.
Seleção de líder
Em sistemas distribuídos, a eleição do líder é o procedimento para nomear um único processo responsável por escalonar o processamento distribuído de alguma carga.
Em sistemas que não são propensos à centralização, são utilizados algoritmos universais e baseados em consenso, como paxos ou raft.
Como o sistema de mensagens é um intermediário e um elemento central, ele conhece todos os controladores de serviço – candidatos a líderes. As mensagens podem nomear um líder sem votação.
Após iniciar e conectar-se ao ponto de troca, todos os serviços recebem uma mensagem do sistema #'$leader'{exchange = ?EXCHANGE, pid = LeaderPid, servers = Servers}
. Se LeaderPid
coincide com pid
processo atual, ele é apontado como líder, e a lista Servers
inclui todos os nós e seus parâmetros.
No momento em que um novo aparece e um nó de cluster funcional é desconectado, todos os controladores de serviço recebem #'$slave_up'{exchange = ?EXCHANGE, pid = SlavePid, options = SlaveOpts}
и #'$slave_down'{exchange = ?EXCHANGE, pid = SlavePid, options = SlaveOpts}
respectivamente.
Dessa forma, todos os componentes estão cientes de todas as alterações e é garantido que o cluster terá um líder a qualquer momento.
Mediadores
Para implementar processos complexos de processamento distribuído, bem como em problemas de otimização de uma arquitetura existente, é conveniente utilizar intermediários.
Para não alterar o código do serviço e resolver, por exemplo, problemas de processamento adicional, roteamento ou registro de mensagens, você pode habilitar um manipulador de proxy antes do serviço, que realizará todo o trabalho adicional.
Um exemplo clássico de otimização pub-sub é um aplicativo distribuído com um núcleo de negócios que gera eventos de atualização, como mudanças de preços no mercado, e uma camada de acesso - N servidores que fornecem uma API de websocket para clientes web.
Se você decidir de frente, o atendimento ao cliente será assim:
- o cliente estabelece conexões com a plataforma. No lado do servidor que finaliza o tráfego, é lançado um processo para atender essa conexão.
- No contexto do processo de serviço, ocorrem autorização e assinatura de atualizações. O processo chama o método subscribe para tópicos.
- Depois que um evento é gerado no kernel, ele é entregue aos processos que atendem as conexões.
Vamos imaginar que temos 50000 assinantes do tópico “notícias”. Os assinantes são distribuídos uniformemente em 5 servidores. Com isso, cada atualização, que chega ao ponto de troca, será replicada 50000 mil vezes: 10000 mil vezes em cada servidor, de acordo com o número de assinantes nele. Não é um esquema muito eficaz, certo?
Para melhorar a situação, vamos apresentar um proxy que tenha o mesmo nome do ponto de troca. O registrador de nomes global deve ser capaz de retornar o processo mais próximo por nome, isso é importante.
Vamos lançar esse proxy nos servidores da camada de acesso, e todos os nossos processos que atendem à API do websocket se inscreverão nele, e não no ponto de troca pub-sub original no kernel. O Proxy assina o núcleo apenas no caso de uma assinatura única e replica a mensagem recebida para todos os seus assinantes.
Como resultado, 5 mensagens serão enviadas entre o kernel e os servidores de acesso, em vez de 50000.
Roteamento e balanceamento
Req-Resp
Na implementação atual de mensagens, existem 7 estratégias de distribuição de solicitações:
default
. A solicitação é enviada a todos os controladores.round-robin
. As solicitações são enumeradas e distribuídas ciclicamente entre os controladores.consensus
. Os controladores que atendem o serviço são divididos em líderes e escravos. As solicitações são enviadas apenas ao líder.consensus & round-robin
. O grupo tem um líder, mas os pedidos são distribuídos entre todos os membros.sticky
. A função hash é calculada e atribuída a um manipulador específico. As solicitações subsequentes com esta assinatura vão para o mesmo manipulador.sticky-fun
. Ao inicializar o ponto de troca, a função de cálculo de hash parasticky
equilíbrio.fun
. Semelhante ao sticky-fun, só você pode redirecioná-lo, rejeitá-lo ou pré-processá-lo adicionalmente.
A estratégia de distribuição é definida quando o ponto de troca é inicializado.
Além do equilíbrio, as mensagens permitem marcar entidades. Vejamos os tipos de tags no sistema:
- Etiqueta de conexão. Permite entender por qual conexão os eventos ocorreram. Usado quando um processo controlador se conecta ao mesmo ponto de troca, mas com chaves de roteamento diferentes.
- Etiqueta de serviço. Permite combinar manipuladores em grupos para um serviço e expandir os recursos de roteamento e balanceamento. Para o padrão req-resp, o roteamento é linear. Enviamos uma solicitação para o ponto de troca e ele repassa para o serviço. Mas se precisarmos dividir os manipuladores em grupos lógicos, a divisão será feita usando tags. Ao especificar um tag, a solicitação será enviada para um grupo específico de controladores.
- Solicitar etiqueta. Permite distinguir entre respostas. Como nosso sistema é assíncrono, para processar respostas de serviço precisamos especificar uma RequestTag ao enviar uma solicitação. A partir dele poderemos entender a resposta a qual solicitação nos chegou.
Pub-sub
Para pub-sub tudo é um pouco mais simples. Temos um ponto de troca onde as mensagens são publicadas. O ponto de troca distribui mensagens entre os assinantes que assinaram as chaves de roteamento de que necessitam (podemos dizer que isso é análogo aos tópicos).
Escalabilidade e tolerância a falhas
A escalabilidade do sistema como um todo depende do grau de escalabilidade das camadas e componentes do sistema:
- Os serviços são dimensionados adicionando nós adicionais ao cluster com manipuladores para este serviço. Durante a operação experimental, você pode escolher a política de balanceamento ideal.
- O próprio serviço de mensagens dentro de um cluster separado é geralmente dimensionado movendo pontos de troca particularmente carregados para nós de cluster separados ou adicionando processos de proxy a áreas particularmente carregadas do cluster.
- A escalabilidade de todo o sistema como característica depende da flexibilidade da arquitetura e da capacidade de combinar clusters individuais em uma entidade lógica comum.
O sucesso de um projeto geralmente depende da simplicidade e da velocidade de expansão. O Messaging em sua versão atual cresce junto com o aplicativo. Mesmo que não tenhamos um cluster de 50 a 60 máquinas, podemos recorrer à federação. Infelizmente, o tema da federação está além do escopo deste artigo.
Reserva
Ao analisar o balanceamento de carga, já discutimos a redundância dos controladores de serviço. No entanto, as mensagens também devem ser reservadas. No caso de falha de um nó ou máquina, as mensagens deverão ser recuperadas automaticamente e no menor tempo possível.
Em meus projetos utilizo nós adicionais que captam a carga em caso de queda. Erlang possui uma implementação de modo distribuído padrão para aplicativos OTP. O modo distribuído executa a recuperação em caso de falha, iniciando o aplicativo com falha em outro nó iniciado anteriormente. O processo é transparente; após uma falha, o aplicativo passa automaticamente para o nó de failover. Você pode ler mais sobre esta funcionalidade
Desempenho
Vamos tentar comparar pelo menos aproximadamente o desempenho do RabbitMQ e de nossas mensagens personalizadas.
eu encontrei
No parágrafo 6.14.1.2.1.2.2. O documento original mostra o resultado do RPC CAST:
Não faremos nenhuma configuração adicional no kernel do sistema operacional ou na VM erlang com antecedência. Condições para teste:
- erl opta: +A1 +sbtu.
- O teste dentro de um único nó erlang é executado em um laptop com um antigo i7 na versão móvel.
- Os testes de cluster são realizados em servidores com rede 10G.
- O código é executado em contêineres docker. Rede em modo NAT.
Código de teste:
req_resp_bench(_) ->
W = perftest:comprehensive(10000,
fun() ->
messaging:request(?EXCHANGE, default, ping, self()),
receive
#'$msg'{message = pong} -> ok
after 5000 ->
throw(timeout)
end
end
),
true = lists:any(fun(E) -> E >= 30000 end, W),
ok.
Script 1: O teste é executado em um laptop com uma versão móvel i7 antiga. O teste, as mensagens e o serviço são executados em um nó em um contêiner Docker:
Sequential 10000 cycles in ~0 seconds (26987 cycles/s)
Sequential 20000 cycles in ~1 seconds (26915 cycles/s)
Sequential 100000 cycles in ~4 seconds (26957 cycles/s)
Parallel 2 100000 cycles in ~2 seconds (44240 cycles/s)
Parallel 4 100000 cycles in ~2 seconds (53459 cycles/s)
Parallel 10 100000 cycles in ~2 seconds (52283 cycles/s)
Parallel 100 100000 cycles in ~3 seconds (49317 cycles/s)
Script 2: 3 nós rodando em máquinas diferentes no docker (NAT).
Sequential 10000 cycles in ~1 seconds (8684 cycles/s)
Sequential 20000 cycles in ~2 seconds (8424 cycles/s)
Sequential 100000 cycles in ~12 seconds (8655 cycles/s)
Parallel 2 100000 cycles in ~7 seconds (15160 cycles/s)
Parallel 4 100000 cycles in ~5 seconds (19133 cycles/s)
Parallel 10 100000 cycles in ~4 seconds (24399 cycles/s)
Parallel 100 100000 cycles in ~3 seconds (34517 cycles/s)
Em todos os casos, a utilização da CPU não excedeu 250%
Resultados de
Espero que este ciclo não pareça um despejo mental e que minha experiência seja de benefício real tanto para pesquisadores de sistemas distribuídos quanto para profissionais que estão no início da construção de arquiteturas distribuídas para seus sistemas de negócios e estão olhando para Erlang/Elixir com interesse , mas tenho dúvidas se vale a pena...
foto
Apenas usuários registrados podem participar da pesquisa.
Quais tópicos devo abordar com mais detalhes como parte da série de experimentos VTrade?
-
Teoria: Mercados, ordens e seu timing: DAY, GTD, GTC, IOC, FOK, MOO, MOC, LOO, LOC
-
Livro de pedidos. Teoria e prática de implementação de um livro com agrupamentos
-
Visualização de negociação: ticks, barras, resoluções. Como guardar e como colar
-
Backoffice. Planejamento e desenvolvimento. Monitoramento de funcionários e investigação de incidentes
-
API. Vamos descobrir quais interfaces são necessárias e como implementá-las
-
Armazenamento de informações: PostgreSQL, Timescale, Tarantool em sistemas de negociação
-
Reatividade em sistemas de negociação
-
Outro. vou escrever nos comentários
6 usuários votaram. 4 usuários se abstiveram.
Fonte: habr.com