Temos uma situação em que tenho que lidar com um influxo maciço de eventos chegando ao nosso servidor, a cerca de 1000 eventos por segundo, em média (o pico pode ser ~ 2000).
O problema
Nosso sistema está hospedado no Heroku e usa um Heroku Postgres DB relativamente caro , que permite um máximo de 500 conexões de DB. Usamos o pool de conexões para conectar-se do servidor ao banco de dados.
Os eventos ocorrem mais rapidamente do que o pool de conexões do banco de dados pode suportar
O problema que temos é que os eventos são mais rápidos do que o pool de conexões pode suportar. Quando uma conexão termina a viagem de ida e volta da rede do servidor para o banco de dados, para que possa ser liberada de volta ao pool, mais n
eventos adicionais ocorrem.
Eventualmente, os eventos se acumulam, esperando para serem salvos e, como não há conexões disponíveis no pool, eles atingem o tempo limite e todo o sistema fica inoperante.
Resolvemos a emergência emitindo os eventos ofensivos de alta frequência em um ritmo mais lento dos clientes, mas ainda queremos saber como lidar com esses cenários no caso de precisarmos lidar com esses eventos de alta frequência.
Restrições
Outros clientes podem querer ler eventos simultaneamente
Outros clientes solicitam continuamente a leitura de todos os eventos com uma chave específica, mesmo que ainda não estejam salvos no banco de dados.
Um cliente pode consultar GET api/v1/events?clientId=1
e obter todos os eventos enviados pelo cliente 1, mesmo que esses eventos ainda não tenham sido salvos no banco de dados.
Existem exemplos de "sala de aula" sobre como lidar com isso?
Soluções possíveis
Enfileire os eventos em nosso servidor
Podemos enfileirar os eventos no servidor (com a fila tendo uma simultaneidade máxima de 400 para que o pool de conexões não se esgote).
Essa é uma má ideia porque:
- Ele consumirá a memória disponível do servidor. Os eventos enfileirados empilhados consumirão grandes quantidades de RAM.
- Nossos servidores são reiniciados uma vez a cada 24 horas . Este é um limite rígido imposto pelo Heroku. O servidor pode reiniciar enquanto os eventos estão na fila, causando a perda dos eventos na fila.
- Introduz o estado no servidor, prejudicando a escalabilidade. Se tivermos uma configuração para vários servidores e um cliente quiser ler todos os eventos enfileirados + salvos, não saberemos em qual servidor os eventos enfileirados estão.
Use uma fila de mensagens separada
Suponho que poderíamos usar uma fila de mensagens (como o RabbitMQ ?), Onde bombeamos as mensagens e, por outro lado, existe outro servidor que trata apenas de salvar os eventos no banco de dados.
Não tenho certeza se as filas de mensagens permitem a consulta de eventos em fila de espera (que ainda não foram salvos). Se outro cliente quiser ler as mensagens de outro cliente, posso obter as mensagens salvas do banco de dados e as mensagens pendentes da fila e concatená-los juntos para que eu possa enviá-los de volta ao cliente de solicitação de leitura.
Use vários bancos de dados, cada um salvando uma parte das mensagens com um servidor coordenador de banco de dados central para gerenciá-las
Outra solução que temos é usar vários bancos de dados, com um "coordenador de banco de dados / balanceador de carga" central. Ao receber um evento, esse coordenador escolheria um dos bancos de dados para escrever a mensagem. Isso deve permitir o uso de vários bancos de dados Heroku, aumentando o limite de conexão para 500 x número de bancos de dados.
Em uma consulta de leitura, esse coordenador pode emitir SELECT
consultas para cada banco de dados, mesclar todos os resultados e enviá-los de volta ao cliente que solicitou a leitura.
Essa é uma má ideia porque:
- Essa ideia parece ... ahem ... excesso de engenharia? Seria um pesadelo para gerenciar também (backups etc.). É complicado criar e manter e, a menos que seja absolutamente necessário, soa como uma violação do KISS .
- Sacrifica a consistência . Fazer transações em vários bancos de dados não é possível se seguirmos essa ideia.
fonte
ANALYZE
as próprias consultas e elas não são um problema. Também construí um protótipo para testar a hipótese do conjunto de conexões e verifiquei que esse é realmente o problema. O banco de dados e o próprio servidor vivem em máquinas diferentes, daí a latência. Além disso, não queremos desistir do Heroku, a menos que seja absolutamente necessário, não estar preocupado com implantações é uma grande vantagem para nós.select null
em 500 conexões. Aposto que você encontrará que o pool de conexões não é o problema lá.Respostas:
Fluxo de entrada
Não está claro se seus 1000 eventos / segundo representam picos ou se é uma carga contínua:
Solução proposta
Intuitivamente, nos dois casos, eu optaria por um fluxo de eventos baseado em Kafka :
Isso é altamente escalável em todos os níveis:
Oferecendo eventos ainda não gravados no banco de dados para clientes
Você deseja que seus clientes possam acessar também as informações ainda no canal e ainda não gravadas no banco de dados. Isso é um pouco mais delicado.
Opção 1: usando um cache para complementar consultas de banco de dados
Não analisei em profundidade, mas a primeira ideia que me veio à mente seria tornar o (s) processador (es) de consulta um (s) consumidor (es) dos tópicos kafka, mas em um grupo diferente de consumidores kafka . O processador de solicitação receberá todas as mensagens que o gravador de banco de dados receberá, mas de forma independente. Ele poderia mantê-los em um cache local. As consultas seriam executadas no cache do banco de dados + (+ eliminação de duplicatas).
O design ficaria assim:
A escalabilidade dessa camada de consulta pode ser alcançada adicionando mais processadores de consulta (cada um em seu próprio grupo de consumidores).
Opção 2: projetar uma API dupla
Uma abordagem melhor do IMHO seria oferecer uma API dupla (use o mecanismo do grupo de consumidores separado):
A vantagem é que você deixa o cliente decidir o que é interessante. Isso pode evitar que você mescle sistematicamente os dados do banco de dados com os dados recém-descontados, quando o cliente estiver interessado apenas em novos eventos recebidos. Se a mesclagem delicada entre eventos novos e arquivados for realmente necessária, o cliente precisará organizá-lo.
Variantes
Propus o kafka porque foi projetado para volumes muito altos com mensagens persistentes, para que você possa reiniciar os servidores, se necessário.
Você pode construir uma arquitetura semelhante com o RabbitMQ. No entanto, se você precisar de filas persistentes, isso poderá diminuir o desempenho . Além disso, até onde eu sei, a única maneira de obter o consumo paralelo das mesmas mensagens por vários leitores (por exemplo, gravador + cache) com o RabbitMQ é clonar as filas . Portanto, uma maior escalabilidade pode ter um preço mais alto.
fonte
a distributed database (for example using a specialization of the server by group of keys)
? Também porque Kafka em vez de RabbitMQ? Existe uma razão específica para escolher uma sobre a outra?Use multiple databases
ideia, mas você está dizendo que eu não deveria distribuir aleatoriamente (ou round-robin) as mensagens para cada um dos bancos de dados. Certo?Meu palpite é que você precisa explorar mais cuidadosamente uma abordagem que você rejeitou
Minha sugestão seria começar a ler os vários artigos publicados sobre a arquitetura LMAX . Eles conseguiram fazer o trabalho em lotes de alto volume para seus casos de uso, e pode ser possível fazer com que suas compensações se pareçam mais com as deles.
Além disso, você pode querer ver se consegue tirar as leituras do caminho - idealmente, você gostaria de poder escalá-las independentemente das gravações. Isso pode significar examinar o CQRS (segregação de responsabilidade da consulta de comando).
Em um sistema distribuído, acho que você pode ter certeza de que as mensagens serão perdidas. Você pode mitigar parte do impacto disso, ponderando suas barreiras de sequência (por exemplo - garantindo que a gravação no armazenamento durável ocorra antes que o evento seja compartilhado fora do sistema).
Talvez - seria mais provável que você analisasse os limites de sua empresa para ver se há lugares naturais para fragmentar os dados.
Bem, suponho que poderia haver, mas não era para onde eu estava indo. O ponto é que o design deveria ter incorporado a robustez necessária para progredir diante da perda de mensagens.
O que isso geralmente parece é um modelo baseado em pull com notificações. O provedor grava as mensagens em uma loja durável solicitada. O consumidor puxa as mensagens da loja, acompanhando sua própria marca d'água. As notificações por push são usadas como um dispositivo de redução de latência - mas se a notificação for perdida, a mensagem ainda será buscada (eventualmente) porque o consumidor está recebendo um agendamento regular (a diferença é que, se a notificação for recebida, a solicitação ocorrerá mais cedo) )
Consulte Mensagens confiáveis sem transações distribuídas, de Udi Dahan (já referenciado por Andy ) e Dados poliglotas de Greg Young.
fonte
In a distributed system, I think you can be pretty confident that messages are going to get lost
. Verdade? Há casos em que a perda de dados é uma troca aceitável? Fiquei com a impressão de que perder dados = falha.Se bem entendi, o fluxo atual é:
Nesse caso, acho que a primeira alteração no design seria parar de ter seu código de manipulação uniforme retornando conexões ao pool em todos os eventos. Em vez disso, crie um pool de processos / threads de inserção 1 a 1 com o número de conexões com o banco de dados. Cada um deles manterá uma conexão de banco de dados dedicada.
Usando algum tipo de fila simultânea, você faz com que esses encadeamentos puxem as mensagens da fila simultânea e as insiram. Em teoria, eles nunca precisam retornar a conexão ao pool ou solicitar uma nova, mas você pode precisar criar um tratamento caso a conexão fique ruim. Pode ser mais fácil eliminar o encadeamento / processo e iniciar um novo.
Isso deve eliminar efetivamente a sobrecarga do conjunto de conexões. Obviamente, você precisará executar pelo menos 1000 eventos / conexões por segundo em cada conexão. Convém tentar diferentes números de conexões, pois ter 500 conexões trabalhando nas mesmas tabelas pode criar uma contenção no banco de dados, mas essa é uma questão totalmente diferente. Outra coisa a considerar é o uso de inserções em lote, ou seja, cada thread extrai várias mensagens e as envia de uma só vez. Além disso, evite ter várias conexões tentando atualizar as mesmas linhas.
fonte
Premissas
Vou assumir que a carga que você descreve é constante, pois esse é o cenário mais difícil de resolver.
Também vou assumir que você tem alguma maneira de executar cargas de trabalho de longa duração acionadas fora do processo de seu aplicativo da web.
Solução
Supondo que você tenha identificado corretamente seu gargalo - latência entre seu processo e o banco de dados do Postgres - esse é o principal problema a ser resolvido. A solução precisa levar em conta sua restrição de consistência com outros clientes que desejam ler os eventos assim que possível após o recebimento.
Para resolver o problema de latência, você precisa trabalhar de maneira a minimizar a quantidade de latência incorrida por evento a ser armazenada. Essa é a principal coisa que você precisa alcançar se não estiver disposto ou não puder alterar o hardware . Como você está nos serviços PaaS e não tem controle sobre hardware ou rede, a única maneira de reduzir a latência por evento será com algum tipo de gravação em lote de eventos.
Você precisará armazenar localmente uma fila de eventos que seja liberada e gravada periodicamente no seu banco de dados, assim que atingir um determinado tamanho ou depois de um período de tempo decorrido. Um processo precisará monitorar essa fila para acionar a liberação para a loja. Deve haver muitos exemplos de como gerenciar uma fila simultânea que é liberada periodicamente no seu idioma de escolha - Aqui está um exemplo em C # , do coletor periódico de lotes da popular biblioteca de registro Serilog.
Essa resposta do SO descreve a maneira mais rápida de liberar dados no Postgres - embora isso exija que o lote armazene a fila no disco e é provável que haja um problema a ser resolvido quando o disco desaparecer após a reinicialização no Heroku.
Limitação
Outra resposta já mencionou o CQRS , e essa é a abordagem correta para resolver a restrição. Você deseja hidratar os modelos de leitura à medida que cada evento é processado - um padrão Mediador pode ajudar a encapsular um evento e distribuí-lo para vários manipuladores em processo. Portanto, um manipulador pode adicionar o evento ao seu modelo de leitura que está na memória que os clientes podem consultar e outro manipulador pode ser responsável por enfileirar o evento para sua eventual gravação em lote.
O principal benefício do CQRS é que você desacopla seus modelos conceituais de leitura e gravação - que é uma maneira elegante de dizer que você escreve em um modelo e que lê de outro modelo totalmente diferente. Para obter benefícios de escalabilidade do CQRS, geralmente você deseja garantir que cada modelo seja armazenado separadamente, de maneira ideal para seus padrões de uso. Nesse caso, podemos usar um modelo de leitura agregado - por exemplo, um cache Redis, ou simplesmente na memória - para garantir que nossas leituras sejam rápidas e consistentes, enquanto ainda usamos nosso banco de dados transacional para gravar nossos dados.
fonte
Este é um problema se cada processo precisar de uma conexão com o banco de dados. O sistema deve ser projetado para que você tenha um conjunto de trabalhadores em que cada trabalhador precise apenas de uma conexão com o banco de dados e cada trabalhador possa processar vários eventos.
A fila de mensagens pode ser usada com esse design. Você precisa de produtores de mensagens que enviam eventos para a fila de mensagens e os trabalhadores (consumidores) processam as mensagens da fila.
Essa restrição só é possível se os eventos armazenados no banco de dados sem nenhum processamento (eventos brutos). Se os eventos estiverem sendo processados antes de serem armazenados no banco de dados, a única maneira de obtê-los é pelo banco de dados.
Se os clientes querem apenas consultar eventos brutos, sugiro usar o mecanismo de pesquisa como o Elastic Search. Você ainda receberá a API de consulta / pesquisa gratuitamente.
Como parece importante a consulta de eventos antes que eles sejam salvos no banco de dados, uma solução simples como o Elastic Search deve funcionar. Basicamente, você apenas armazena todos os eventos e não duplica os mesmos dados, copiando-os para o banco de dados.
O dimensionamento da pesquisa elástica é fácil, mas mesmo com a configuração básica, é de alto desempenho.
Quando você precisar processar, seu processo poderá obter os eventos do ES, processá-los e armazená-los no banco de dados. Não sei qual é o nível de desempenho que você precisa desse processamento, mas seria completamente separado da consulta aos eventos do ES. Você não deve ter problema de conexão, pois pode ter um número fixo de trabalhadores e cada um com uma conexão com o banco de dados.
fonte
Eventos de 1k ou 2k (5KB) por segundo não é muito para um banco de dados se ele tiver um esquema e mecanismo de armazenamento apropriados. Conforme sugerido por @eddyce, um mestre com um ou mais escravos pode separar as consultas de leitura da confirmação de gravações. O uso de menos conexões com o banco de dados proporcionará uma melhor taxa de transferência geral.
Para esses pedidos, eles precisariam também ler do banco de dados mestre, pois haveria um atraso na replicação para os escravos de leitura.
Eu usei o MySQL (Percona) com o mecanismo TokuDB para gravações de volume muito alto. Há também o mecanismo MyRocks baseado no LSMtrees, que é bom para cargas de gravação. Para ambos os mecanismos e, provavelmente, também para o PostgreSQL, existem configurações para o isolamento de transações e o comportamento de sincronização de confirmação, que podem aumentar drasticamente a capacidade de gravação. No passado, aceitávamos até 1s de dados perdidos que eram relatados ao cliente db como confirmados. Em outros casos, havia SSDs com bateria para evitar perdas.
Afirma-se que o Amazon RDS Aurora no sabor MySQL possui uma taxa de transferência de gravação 6x mais alta com replicação de custo zero (semelhante aos escravos que compartilham um sistema de arquivos com o mestre). O sabor do Aurora PostgreSQL também possui um mecanismo de replicação avançado diferente.
fonte
Eu soltaria o heroku todos juntos, ou seja, eu abandonaria uma abordagem centralizada: várias gravações que atingem o pico da conexão máxima do pool são uma das principais razões pelas quais os clusters db foram inventados, principalmente porque você não carrega a gravação db (s) com solicitações de leitura que podem ser executadas por outros db's no cluster, além disso, eu tentaria com uma topologia mestre-escravo - como alguém já mencionou, ter suas próprias instalações de db permitiria ajustar o conjunto sistema para garantir que o tempo de propagação da consulta seja tratado corretamente.
Boa sorte
fonte