As filas de mensagens agora são bastante prevalentes – há tantas delas aparecendo tão rápido que você pensaria que eram rabbits com um suprimento ilimitado de celery, resultando em uma situação kafkaesque tomar uma decisão é como tentar pegar um stream em suas mãos. Se houvesse menos serviços simples que pudessem ajudar na publicação e assinatura, seria muito mais fácil fazer uma escolha 😕 de esforço zero
Quer os usemos sozinhos no estado em que se encontram para mover dados entre partes do nosso aplicativo, ou como parte integrante da arquitetura (como sistemas orientados a eventos), as filas de mensagens vieram para ficar. De certa forma, eles estiveram aqui o tempo todo, só que sem tantos nomes. Mas quais são eles? Por que são úteis? E como usá-los de forma eficaz? Qual implementação escolhemos? Será que importa mesmo qual usamos? E precisamos aprender cada um deles individualmente, ou existem conceitos mais gerais que se aplicam a todas as filas de mensagens?
Neste guia, falaremos sobre:
- O que são filas de mensagens e seu histórico.
- Por que eles são úteis e quais modelos mentais usar ao raciocinar sobre eles.
- A entrega garante que os sistemas de enfileiramento façam (semântica pelo menos uma vez, no máximo uma vez e exatamente uma vez).
- Ordenação e garantias FIFO e como elas afetam o sequenciamento, paralelismo e desempenho.
- Padrões para distribuição e fan-in: entrega de uma mensagem para vários sistemas ou mensagens de muitos sistemas em um.
- Notas sobre os prós e contras de muitos sistemas populares disponíveis hoje.
O que são filas de mensagens?
As filas de mensagens são uma maneira de transferir informações entre dois sistemas. Essas informações — uma mensagem — podem ser dados, metadados, sinais ou uma combinação dos três. Os sistemas que estão enviando e recebendo mensagens podem ser processos no mesmo computador, módulos do mesmo aplicativo, serviços que podem estar sendo executados em computadores diferentes ou pilhas de tecnologia, ou tipos totalmente diferentes de sistemas — como transferir informações do seu software para um e-mail ou um SMS na rede de celular.
A ideia de um sistema de mensagens existe há muito tempo, desde as caixas de mensagens usadas para mover informações entre pessoas ou departamentos de escritório (literalmente de onde vêm as palavras caixa de entrada e caixa de saída), até telegramas, até o serviço postal ou de correio local. Os sistemas de mensagens no mundo físico que mais se aproximam do que temos na computação são provavelmente os tubos pnuemáticos que moviam mensagens através de edifícios e cidades usando ar comprimido até algumas décadas atrás (e ainda são usados em alguns lugares hoje).
Os tipos de mensagens que transferimos hoje podem ser uma nota de que algo técnico aconteceu, como o uso da CPU excedendo um limite, ou um evento comercial de interesse, como um cliente fazendo um pedido, ou um sinal, como um comando que diz a outro serviço para fazer algo. O conteúdo de cada mensagem será conduzido inteiramente pela arquitetura do seu aplicativo e suas finalidades — portanto, para o resto deste guia, não precisamos nos preocupar com o que está dentro de uma mensagem — estamos mais preocupados com a forma como a mensagem vai do sistema de onde ela se origina (o produtor, a fonte, o editor ou o remetente) para o sistema para onde ela deve ir (o consumidor, assinante, destino ou destinatário).
E por que precisamos deles?
Precisamos de filas de mensagens porque nenhum sistema existe ou funciona isoladamente — todos os sistemas precisam se comunicar com outros sistemas de maneiras estruturadas que ambos possam entender e em uma velocidade controlada que ambos possam lidar. Qualquer processo não trivial precisa de uma maneira de mover informações entre cada etapa do processo; Qualquer fluxo de trabalho precisa de uma maneira de mover o produto intermediário entre os estágios desse fluxo de trabalho. As filas de mensagens são uma ótima maneira de lidar com esse movimento. Há muitas maneiras de obter essas mensagens usando chamadas de API, sistemas de arquivos ou muitos outros abusos da ordem natural das coisas; Mas tudo isso são implementações ad-hoc da fila de mensagens que às vezes nos recusamos a reconhecer que precisamos.
O modelo mental mais simples para uma fila de mensagens é um tubo muito longo no qual você pode rolar uma bola. Você escreve sua mensagem em uma bola, enrola-a no tubo e alguém ou outra coisa a recebe na outra ponta. Há uma série de benefícios interessantes com este modelo, alguns dos quais são:
- Não precisamos nos preocupar com quem ou o que vai receber a mensagem – essa é uma responsabilidade a menos para o remetente se preocupar.
- Não precisamos nos preocupar com quando o receptor vai pegar a mensagem.
- Podemos colocar quantas mensagens quisermos no tubo (vamos supor que temos um tubo infinitamente longo) em qualquer velocidade que nos seja confortável.
- O receptor nunca será impactado por nossas ações – ele retirará quantas mensagens quiser no que for confortável para ele.
- Nem o emissor nem o receptor estão preocupados com o funcionamento do outro.
- Nem o emissor nem o receptor estão preocupados com a capacidade ou a carga do outro.
- Nenhum dos dois sistemas está preocupado com onde o outro está – eles podem ou não residir no mesmo computador, rede, continente ou mesmo no mesmo planeta.
Cada uma dessas vantagens (e esta nem é uma lista exaustiva) tem benefícios muito importantes no desenvolvimento de software – o que todas elas têm em comum é o desacoplamento. Um sistema é dissociado do outro em termos de responsabilidade, tempo, largura de banda, funcionamento interno, carga e geografia. E o desacoplamento é uma parte muito desejável de qualquer sistema distribuído ou complexo — quanto mais desacopladas forem as partes do sistema, mais fácil será construí-las, testá-las, executá-las, mantê-las e dimensioná-las de forma independente.
A maioria dos sistemas também interage com outros sistemas externos ou de terceiros — se criarmos um site de compras, poderemos interagir com um processador de pagamento e, digamos, tentamos nos comunicar diretamente com o processador de pagamento a cada clique do usuário. Se nosso sistema está sob carga pesada, também estamos sujeitando o outro sistema à mesma carga. E vice-versa: se nosso provedor de pagamento precisar nos enviar milhões de informações sobre nossos status de pagamento anteriores, é melhor que nosso sistema esteja pronto. Os dois sistemas estão agora acoplados. As decisões e ações tomadas por um sistema têm um impacto significativo sobre o outro, portanto, as necessidades de ambos precisam ser levadas em conta ao tomar cada decisão. Adicione bastante outros sistemas à mistura, como sistemas de logística ou entrega, e rapidamente temos uma bagunça paralisante que torna difícil decidir qualquer coisa. Se um sistema cai, os outros sistemas também caíram, sem culpa própria.
Também estamos em apuros se quisermos trocar qualquer um desses sistemas por outro, como um novo processador de pagamento ou sistema de entrega. Teríamos que fazer alterações profundas em vários lugares em nosso aplicativo, e é ainda mais difícil criar código para dividir nossas mensagens entre vários provedores — podemos usar uma proporção para balanceá-las ou dividi-las por geografia; ou alternar dinamicamente entre eles com base na disponibilidade ou custo de cada provedor.
As filas de mensagens oferecem o desacoplamento que resolve muitos desses problemas. Se configurarmos uma fila entre dois sistemas que precisam se comunicar um com o outro, eles agora podem realizar seu trabalho sem ter que se preocupar um com o outro – colocamos nossas mensagens direcionadas a qualquer sistema em uma fila e esperamos que as informações do outro sistema cheguem até nós através de outra fila também. Agora temos pontos claros nos quais podemos adicionar regras ou fazer as mudanças necessárias, sem que o sistema saiba ou se importe com o que é diferente.
Então, qual é o problema?
Mas será que as filas de mensagens são o Santo Graal da computação? Eles resolvem todos os problemas do mundo? Não, claro que não. Há muitas situações em que podemos não querer usá-los. E certamente não queremos usar uma fila só porque temos uma facilmente disponível e achamos que pode ser divertido. Existem alguns sistemas que são realmente simples que simplesmente não exigem isso – uma fila de mensagens é uma maneira de reduzir a complexidade dos sistemas de comunicação, mas dois sistemas de comunicação sempre serão mais complexos do que um sistema que não precisa se comunicar. Se você tem um sistema que é simples o suficiente para não exigir comunicação com nenhum outro, simplesmente não há nenhuma razão para entrar em uma fila.
Há também sistemas que se comunicam entre si, mas onde a complexidade adicionada por essa comunicação adicionada é insignificante e não vale a pena se preocupar. Ou, mais frequentemente, os sistemas já estão acoplados, no sentido de que todos eles precisam trabalhar juntos para funcionar. Um exemplo realmente comum é um servidor de aplicativos e um serviço de dados (em um sistema OLTP). Não adianta muito desacoplá-los de uma fila, porque nenhum dos dois pode fazer nada de útil sem o envolvimento direto do outro.
Além disso, há desempenho a ser considerado — o objetivo de dissociar dois sistemas em relação ao tempo e à carga é para que cada um possa processar informações em seu próprio ritmo — mas certamente não gostaríamos que isso acontecesse em aplicativos sensíveis ao desempenho ou sistemas em tempo real. Uma fila pode nos ajudar a processar mais trabalho ao mesmo tempo (o receptor pode ter muitos processos trabalhando em paralelo nas mensagens que você envia), mas removerá todas as garantias que precisamos sobre o tempo exato necessário para cada peça de trabalho. Se a previsibilidade é mais importante do que a taxa de transferência, estamos melhor sem uma fila.
O uso de uma fila pode aumentar o tempo necessário para processar cada mensagem individual, mas permitirá que você processe muito mais mensagens ao mesmo tempo em computadores diferentes, portanto, o número total de mensagens processadas por minuto ou hora, ou taxa de transferência, aumentará.
Se temos vários sistemas que precisam se comunicar, e essa comunicação precisa ser durável (se colocamos uma mensagem em uma fila, queremos ter certeza de que o sistema de mensagens não vai “esquecer” dela) e desacoplada, uma fila de mensagens é indispensável.
Discutindo semântica
Simplesmente não há como aprender sobre filas de mensagens sem ler e/ou discutir sobre garantias de entrega e semântica, então podemos chegar a isso rapidamente. As pessoas que criam filas de mensagens alegarão que seu sistema oferece uma das três garantias de entrega — que cada mensagem que você colocar na fila será entregue:
- pelo menos uma vez.
- no máximo uma vez.
- exatamente uma vez.
O que garante que estamos usando terá um enorme impacto no design e no funcionamento do nosso sistema, então vamos descompactar cada um deles um por um.
pelo menos uma vez
Este é o mecanismo de entrega mais comum, e é o mais simples de raciocinar e implementar. Se eu tiver uma mensagem para você, vou lê-la para você, e continuar fazendo isso repetidamente até que você a reconheça. É isso. Em um sistema que funciona pelo menos uma vez, isso significa que quando você recebe uma mensagem da fila e não a exclui/reconhece, você a receberá novamente no futuro e continuará a recebê-la até excluí-la/reconhecê-la explicitamente.
A razão pela qual essa é a garantia mais comum é que ela é simples e faz o trabalho 100% do tempo — não há nenhum caso de borda em que a mensagem se perca. Mesmo que o receptor trave antes de reconhecer a mensagem, ele simplesmente receberá a mesma mensagem novamente. O outro lado é que você, como receptor, precisa planejar receber a mesma mensagem várias vezes, mesmo que não tenha necessariamente sofrido um acidente. Isso ocorre porque oferecer pelo menos uma vez é a maneira mais simples de proteger o serviço de fila de perder mensagens também — se sua confirmação não chegar ao sistema de filas pela rede, a mensagem será enviada novamente. Se houver um problema persistindo sua confirmação, a mensagem será enviada novamente. Se o sistema de enfileiramento for reiniciado antes que ele possa acompanhar corretamente o que foi enviado a você, a mensagem será enviada novamente. Este remédio simples de enviar a mensagem novamente em caso de qualquer problema de qualquer lado é o que torna esta garantia tão confiável.
Mas será que a duplicação/repetição de mensagens é um problema? Isso depende muito de você e de seu aplicativo ou caso de uso. Se a mensagem for um carimbo de data/hora e uma medição, por exemplo, não há problema em receber um milhão de duplicatas. Mas se você está movimentando dinheiro com base nas mensagens, definitivamente é um problema. Nesses casos, você precisará ter um banco de dados transacional (ACID) na extremidade de recebimento e, talvez, registrar o ID da mensagem em um índice exclusivo para que ele não possa ser repetido. Isso é chamado de usar um token de idempotência ou _tombstone — _when você age em uma mensagem, armazena um marcador permanente exclusivo para acompanhar suas ações, geralmente na mesma transação de banco de dados que a própria ação. O impede que você repita essa ação novamente, mesmo que a mensagem esteja duplicada.
Se você lida com duplicação, ou se suas mensagens são naturalmente resistentes à duplicação, seus sistemas são considerados idempotentes. Isso significa que você pode lidar com o recebimento da mesma mensagem várias vezes com segurança, sem corromper seu trabalho. Isso também significa que você pode tolerar que o remetente envie a mesma mensagem várias vezes — lembre-se de que os remetentes geralmente operam no princípio de pelo menos uma vez ao enviar mensagens também. Se os remetentes não conseguirem registrar o fato de terem enviado uma mensagem específica, eles simplesmente a enviarão novamente. Os remetentes são então responsáveis por garantir que eles usem a mesma lápide ou token de idempotência se e quando reenviarem mensagens.
No máximo uma vez
Esta é uma semântica bastante rara, usada para mensagens onde a duplicação é tão terrivelmente explosiva (ou a mensagem tão totalmente sem importância) que preferimos não enviar a mensagem, em vez de enviá-la duas vezes. No máximo uma vez implica que o sistema de enfileiramento tentará entregar a mensagem para você uma vez, mas é isso. Se você receber e reconhecer a mensagem tudo está bem, mas se você não receber, ou algo der errado, essa mensagem será perdida para sempre – seja porque o sistema de filas se esforçou muito para gravar a entrega para você antes de tentar enviá-la (caso a mensagem seja horrivelmente explosiva), ou nem mesmo se preocupou em gravar a mensagem, e está apenas passando como um roteador passa um pacote UDP.
Essa semântica geralmente entra em jogo para sistemas de mensagens que estão agindo como roteadores de informações sem monitoração de estado; ou nos casos em que uma mensagem repetida é tão destrutiva que uma investigação ou reconciliação é necessária no caso de haver alguma falha.
Exatamente uma vez
Este é o Santo Graal das mensagens, e também a fonte de muito óleo de cobra. Isso implica que cada mensagem é garantida para ser entregue e processada exatamente uma vez, nem mais nem menos. Todo mundo que constrói ou usa sistemas distribuídos tem um ponto em suas vidas em que eles pensam “quão difícil isso pode ser?”, e então eles (1) aprendem por que é impossível, descobrem a idempotência e usam pelo menos uma vez, ou (2) tentam construir um sistema “exatamente uma vez” e vendê-lo por muito dinheiro para aqueles que ainda não descobriram (1).
A impossibilidade de entrega exata uma vez decorre de dois fatos básicos:
- Remetentes e destinatários são imperfeitos
- as redes são imperfeitas
Se você pensar sobre o problema profundamente, há muitas coisas que podem dar errado:
- um remetente pode não conseguir gravar (esquece) que enviou a mensagem
- A chamada de rede para enviar a mensagem pode falhar
- O banco de dados do sistema de mensagens pode não ser capaz de gravar a mensagem
- A confirmação de que o sistema de mensagens gravou a mensagem pode não chegar ao remetente pela rede
- O remetente pode não conseguir registrar a confirmação de que o sistema de mensagens recebeu a mensagem
Digamos que tudo corra bem ao enviar a mensagem — quando o sistema de mensagens tenta entregar a mensagem ao destinatário:
- A mensagem pode não chegar ao receptor pela rede
- O receptor pode não ser capaz de gravar a mensagem em seu banco de dados
- A confirmação do receptor pode não chegar ao sistema de mensagens pela rede
- O banco de dados do sistema de mensagens pode não ser capaz de registrar que a mensagem foi entregue
Dadas todas as coisas que podem dar errado, é impossível para qualquer sistema de mensagens garantir a entrega exatamente uma vez. Mesmo que o sistema de mensagens seja divino em sua perfeição, a maioria das coisas que podem dar errado estão fora dele ou nas redes de interconexão. Alguns sistemas tentam usar a frase “exatamente uma vez” de qualquer maneira, geralmente porque afirmam que sua implementação nunca terá nenhum dos problemas do sistema de mensagens mencionados acima – mas isso não significa que todo o sistema seja magicamente abençoado com semântica exata uma vez, mesmo que as afirmações sejam realmente verdadeiras. Isso geralmente significa que o sistema de enfileiramento tem alguma forma de pedido, bloqueio, hashing, temporizadores e tokens de idempotência que garantirão que ele nunca reentregue uma mensagem que já foi excluída/reconhecida – mas isso não significa que todo o sistema, incluindo editor + fila + assinante, ganhou garantias completas de uma única vez.
A maioria dos bons engenheiros de sistemas de mensagens entende isso e explicará aos seus usuários por que essa semântica é inviável. A maneira mais simples e confiável de lidar com mensagens é voltar ao básico e abraçar pelo menos uma vez medidas de idempotência em todos os pontos do processo de envio, recebimento e fila: se no início você não tiver sucesso, tente novamente, tente novamente, tente novamente…
Ordenação vs paralelismo
Após a semântica de entrega, outra pergunta comum na mente das pessoas é “por que não podemos simplesmente processar mensagens em paralelo enquanto também nos certificamos de processá-las em ordem?”. Infelizmente, essa é mais uma troca que nos é imposta pela tirania da lógica. Fazer trabalho em sequência e fazer várias peças de trabalho ao mesmo tempo estão sempre em conflito um com o outro. A maioria dos sistemas de fila de mensagens pedirá que você escolha um — o AWS SQS começou priorizando o paralelismo em vez da ordenação estrita; mas recentemente introduziu um sistema de fila FIFO separado (primeiro a entrar, primeiro a sair) também, que mantém uma ordem sequencial rigorosa. Antes de fazer uma escolha entre os dois, vamos analisar qual é a diferença e por que precisa haver uma diferença.
Voltando à nossa metáfora anterior para uma fila – um longo tubo no qual rolamos mensagens escritas em uma bola – provavelmente imaginamos que o tubo fosse apenas um pouco mais largo do que uma única bola. Realmente não há como as bolas ultrapassarem ou passarem umas às outras dentro do tubo, então a única maneira de um receptor conseguir essas mensagens é uma a uma, na ordem em que foram colocadas. Isso garante uma ordenação rigorosa, mas impõe fortes limitações ao nosso receptor. Só pode haver um agente no lado do receptor que está processando cada mensagem — se houvesse mais de uma, não haveria garantia de que as mensagens foram processadas em ordem. Como cada novo agente poderia processar cada mensagem de forma independente, cada um deles poderia concluir e iniciar a próxima mensagem a qualquer momento. Se o for dois agentes, A & B e o Agente A receberão a primeira mensagem e o Agente B a segunda; O Agente B pode concluir o processamento da segunda mensagem e iniciar na terceira mensagem antes mesmo que o Agente A termine de processar a primeira mensagem. Embora as mensagens tenham sido recebidas da fila estritamente na ordem em que foram colocadas, se houver vários agentes de recebimento, não há como dizer que as mensagens serão processadas nessa ordem.
Os agentes poderiam usar um bloqueio distribuído de algum tipo para coordenar uns com os outros, mas isso é basicamente o mesmo que ter apenas um agente – o bloqueio só permitiria que um agente trabalhasse a qualquer momento. Isso também significa que um agente travando resultaria em um impasse sem nenhum trabalho sendo feito.
Uma maneira de o sistema de mensagens garantir a ordem seria o tubo se recusar a dar a próxima bola até e a menos que a última bola recebida tenha sido destruída (a última mensagem foi excluída/reconhecida). Isso é o que as filas FIFO em geral farão — elas fornecerão a próxima mensagem somente depois que a última for reconhecida ou excluída — mas isso significa que apenas um agente pode estar trabalhando por vez, mesmo que haja N agentes esperando para receber mensagens da fila.
Às vezes, é exatamente isso que queremos. Algumas operações são mais fáceis de controlar eficazmente quando temos de lidar apenas com um único agente, como a aplicação de regras sobre transacções financeiras; respeitar os limites de taxa; ou, geralmente, processar mensagens cujos formatos foram projetados supondo que elas seriam sempre processadas em ordem. Mas muitos desses “benefícios” não estão realmente vindo da decisão de usar a ordenação FIFO – qualquer cenário em que tenhamos N receptores que devem de alguma forma coordenar seu trabalho uns com os outros se beneficiará do caso especial de N = 1. A principal conclusão é que exigir um pedido garantido significa que temos que processar mensagens sequencialmente em apenas um receptor por vez.
Essa restrição também coloca uma forte pressão sobre o sistema de filas, então você descobrirá que as filas FIFO geralmente são mais caras e têm menos capacidade do que suas contrapartes paralelas. Isso ocorre porque os mesmos limites lógicos também se aplicam à implementação interna do sistema de enfileiramento — a maioria do trabalho precisa ser restrita a um único agente ou servidor, e esse sistema precisa ser mantido confiável. Qualquer esforço para adicionar redundância requer coordenação síncrona entre o mestre e os serviços de backup para manter as garantias de pedido. No AWS SQS, as filas FIFO são cerca de 2 vezes mais caras do que as filas paralelas e são restritas a algumas centenas de mensagens por segundo quando é necessário um pedido FIFO rigoroso.
Portanto, a única maneira de avançar com uma fila de mensagens FIFO é aceitar que toda a arquitetura de processamento de mensagens terá um limite de velocidade intrínseco. Muitos sistemas suportarão cabeçalhos de grupo dentro da fila para indicar em quais mensagens queremos ordenar rigorosamente – podemos dizer que todas as mensagens sob o título “pagamentos” precisam ser FIFO, e todas as mensagens sob “pedidos” precisam ser FIFO, mas não precisam ser FIFO em relação umas às outras. Isso permite alguma paralelização dentro da fila (como ter dois tubos FIFO em vez de um), mas precisamos lembrar que a largura de banda da mensagem dentro de cada título de grupo ainda será limitada.
Paralelo != Aleatório
Isso significa que a ordenação em filas paralelas é completamente aleatória? Às vezes, sim, mas na maioria das vezes não. No SQS, a analogia é mais que, em vez de ter um tubo do emissor para o receptor, existem vários tubos. Eles também podem se ramificar ou se juntar uns aos outros ao longo do caminho. Isso não significa que a ordem das mensagens que você rola é intencionalmente aleatória de alguma forma — em um grande número de mensagens, você ainda esperaria que as mensagens anteriores fossem geralmente recebidas antes das posteriores. Esta é mais uma ordem de melhor esforço, onde algum esforço é feito para manter a ordem intacta, mas como já é logicamente impossível, simplesmente não é uma grande prioridade para o sistema. Isso também permite que um sistema de mensagens como o SQS aumente para uma capacidade quase infinita — porque se você estiver rolando muitas mensagens, o sistema de filas pode simplesmente adicionar mais tubos. E como você pode imaginar, isso suportará qualquer número de receptores ao mesmo tempo, e qualquer número de remetentes também. Essa simplicidade é o que permite que o SQS escale para números alucinantes, incluindo um caso em que havia uma fila com mais de 250 bilhões de mensagens esperando para serem consumidas, com o receptor lendo e reconhecendo mais de um milhão de mensagens por segundo. E essa é apenas uma fila operada por um cliente.
A maioria dos problemas que parecem ter uma exigência de FIFO difícil muitas vezes pode se prestar a paralelismo e entrega fora de ordem com um pouco de criatividade. O remetente adicionando um carimbo de data/hora na mensagem é uma maneira de ajudar com isso, como no caso em que as mensagens são medidas em que apenas a última importa. Em um sistema mais transacional, o remetente muitas vezes pode adicionar um contador monotonicamente crescente nas mensagens. Se isso for impossível, talvez possamos lidar com isso com base no conteúdo da mensagem – se formos enviar mensagens com a porcentagem de um arquivo baixado, por exemplo, ver 41%, 42% e 43% sempre significa que o valor atual é 43% – mesmo que os vejamos fora de ordem como 41%, 43% e 42%.
Embora muitas vezes seja uma má ideia mudar nossos sistemas para acomodar as ferramentas que usamos, projetar nossas mensagens para permitir a entrega fora de ordem e a idempotência torna o sistema mais resiliente em geral, ao mesmo tempo em que permite o uso de mais sistemas de mensagens paralelos — muitas vezes economizando tempo, dinheiro e muito trabalho operacional.
Ventilador Out / In
Ao construir um sistema distribuído, muitas vezes há a necessidade de ter a mesma mensagem enviada para vários receptores — além do receptor usual da mensagem, muitas vezes também queremos que a mesma mensagem seja enviada para outros lugares, como um arquivo, um log de auditoria (para verificações de conformidade e segurança) ou um analisador para nossos painéis. Se você estiver usando uma arquitetura orientada a eventos com muitos serviços, convém usar um único barramento de eventos em seu aplicativo, onde todas as mensagens postadas nesse barramento de eventos são enviadas automaticamente para todos os seus serviços. Isso é chamado de problema de distribuição, em que uma mensagem de um produtor precisa chegar a muitos consumidores.
O problema inverso, em que um único receptor é encarregado de ler as mensagens postadas em várias filas também é comum — no exemplo que consideramos acima, um receptor que estivesse arquivando todas as mensagens ou criando um log de auditoria provavelmente receberia todas as mensagens geradas em uma organização, em todas as filas. Também é comum em arquiteturas de serviço ter uma função como notificações tratadas separadamente — portanto, um sistema de notificação pode precisar receber mensagens sobre novos pedidos confirmados, pagamentos com falha, envio bem-sucedido e muito mais. Trata-se de um problema de torcida, em que as mensagens de muitos produtores precisam chegar ao mesmo consumidor.
Se todos os produtores estiverem colocando suas mensagens diretamente em filas, esse seria um problema realmente difícil de resolver – teríamos que de alguma forma interceptar nossas filas e copiar as mensagens de forma confiável em várias filas. Construir, configurar e manter esse quadro de distribuição simplesmente não vale a pena o tempo ou o esforço, especialmente quando poderíamos apenas usar tópicos.
Uma maneira de pensar sobre os tópicos é que eles são semelhantes aos títulos que você veria em um quadro de avisos em uma escola ou escritório. Os produtores postam mensagens sob um tópico específico em um quadro, e todos os interessados nesse tópico verão a mensagem. A maneira mais comum de os sistemas de mensagens enviarem as mensagens para os receptores interessados é uma solicitação HTTP(S), às vezes também chamada de webhook. Em um sistema baseado em push, como uma solicitação HTTP, a mensagem é enviada para o receptor, esteja ela pronta ou não. Isso reintroduz o acoplamento de que falamos anteriormente e que queremos evitar – não queremos uma situação em que nosso receptor colapse sob a carga esmagadora de dezenas / centenas / milhares / milhões de ganchos de teia em um curto período de tempo. A resposta aqui, novamente, é apenas usar uma fila de mensagens para absorver as mensagens dos tópicos em qualquer taxa que eles são gerados. Os receptores podem então processá-los em seu próprio ritmo.
Copiar automaticamente uma mensagem de um tópico para uma ou mais filas não é estritamente um recurso de fila de mensagens, mas é complementar — a maioria dos sistemas de mensagens completos oferecerá uma maneira de fazer isso. Os produtores ainda continuarão a colocar mensagens em um único lugar, como de costume, mas isso será um tópico, e internamente as mensagens serão copiadas para várias filas, cada uma das quais será lida por seus respectivos receptores.
Na AWS, o serviço que fornece mensagens baseadas em tópicos é o Simple Notification Service (SNS). Aqui você cria um tópico e publica mensagens nele — a API para publicar uma mensagem em um tópico do SNS é muito semelhante à de publicar uma mensagem em uma fila do SQS, e a maioria dos produtores não precisa se preocupar com a diferença. O SNS tem então opções disponíveis para publicar essa mensagem em qualquer número de filas SQS subscritas (sem custos adicionais). Cada uma dessas filas SQS inscritas seria então processada por seus respectivos receptores.
Se você estiver trabalhando com um sistema diferente como o Apache Kafka, você verá conceitos semelhantes lá também – você terá tópicos nos quais você publica mensagens, e qualquer número de consumidores pode ler todas as mensagens em um tópico. O sistema Pub/Sub do Google também integra tópicos e filas.
Essa combinação desses cenários é comum o suficiente para que haja um padrão simples e bem estabelecido para lidar com isso:
- Publique cada mensagem em um tópico apropriado.
- Crie uma fila para cada receptor.
- Vincule a fila de cada receptor aos tópicos em que o receptor está interessado.
Como geralmente é possível inscrever uma fila para qualquer número de tópicos, não há encanamento extra necessário em um receptor para processar mensagens de vários tópicos. E, claro, é possível ter qualquer número de filas de mensagens inscritas em um único tópico. Esse tipo de configuração suporta tanto fan-out quanto fan-in, e mantém sua arquitetura aberta a expansão e mudanças no futuro.
Poison Pills & Letras mortas
Por mais mórbido que isso pareça, ao configurar sistemas para conversar com vários outros sistemas, certamente haverá erros que acontecerão. O problema comum é que um assinante está conectado para receber mensagens de um tópico sobre o qual não sabe nada em um formato de mensagem que não entende. O que acontece? O assinante ignora a mensagem? Ou reconhece/apaga? Não seria errado ignorá-lo, porque a mensagem continuaria voltando repetidas vezes em um sistema pelo menos uma vez? Mas não é pior apagar/reconhecer uma mensagem que não estamos manuseando? Antes de buscarmos livros de filosofia feitos de árvores caídas na floresta, podemos configurar uma fila de letras mortas em nossa fila. Este é um recurso que muitos sistemas de fila nos oferecem, onde se o sistema vir uma mensagem sendo enviada para processamento repetidamente, sem sucesso todas as vezes, ele irá movê-la para uma fila especial chamada fila de letra morta. Gostaríamos de ligar essa fila a algum tipo de alarme, para sabermos rapidamente que algo estranho está acontecendo.
Um cenário muito pior é aquele em que a mensagem é explosiva de alguma forma — talvez esteja formatada em XML em vez de JSON ou contenha conteúdo gerado pelo usuário carregando um ataque de entrada malformado que faz com que seu código de análise trave… Seu assinante acabou de engolir uma pílula de veneno. O que acontece quando essa pílula chega ao assinante é fortemente dependente de sua pilha de tecnologia, então é desnecessário dizer que você quer pensar cuidadosamente sobre o tratamento de erros e exceções no código do assinante. A boa notícia é que, se você configurou uma fila de letras mortas, apenas falhar silenciosamente pode ser uma boa opção. A pílula de veneno acabará aparecendo na fila de cartas mortas e poderá ser examinada. Mesmo que a mensagem suspeita esteja travando seu assinante, executar uma reinicialização automatizada com um gerenciador de processos geralmente é suficiente para repetir a mensagem tantas vezes que a move para a fila de letras mortas. Você precisa se certificar de que não há implicações de segurança, no entanto, e lembre-se de que este é um ataque DoS fácil.
Lembre-se de sempre verificar suas mensagens recebidas, tanto em termos de se a mensagem está estruturada da maneira que você espera que ela seja, quanto se você é o destinatário pretendido.
A lista Q
Aqui está uma lista de alguns dos sistemas de enfileiramento de mensagens mais populares disponíveis no momento, com uma lista de como os conceitos que vimos até agora se aplicam a cada um deles. Esta não é uma lista exaustiva, é claro, então deixe-me saber @sudhirj se você acha que há algum que está faltando ou deturpado.
AWS SNS & SQS
A AWS executa dois serviços que se integram entre si para fornecer funções completas de enfileiramento de mensagens. O serviço SQS é uma fila de mensagens pura — ele permite que você crie uma fila, envie uma mensagem e receba uma mensagem. É isso. A API ReceiveMessage na fila do SQS é somente pull, portanto, você precisará chamá-la sempre que o receptor estiver pronto para processar uma mensagem. Há uma opção WaitTimeSeconds para bloquear a espera de chamada por uma mensagem por até 20 segundos, portanto, um padrão eficaz é sondar a API ReceiveMessage em um loop infinito com a espera de 20 segundos ativada.
Os tópicos e funções de fan-out / fan-in vêm com a integração do SNS, que trabalha na construção de um tópico. Isso permite que uma mensagem seja postada em um tópico, em vez de uma fila. Em seguida, você pode inscrever qualquer número de filas SQS em um tópico, para que as mensagens publicadas no tópico sejam copiadas para todas as filas inscritas rapidamente sem custo adicional. Você vai querer ativar a opção de mensagem bruta, que torna a postagem de uma mensagem em um tópico do SNS efetivamente equivalente a postá-la em uma fila SQS — nenhuma transformação ou empacotamento de qualquer tipo será aplicado à mensagem.
SQS & SNS são ambos serviços totalmente gerenciados, portanto, não há servidores para manter ou software para instalar. Você é cobrado com base no número de mensagens enviadas e recebidas, e a AWS lida com o dimensionamento para qualquer carga.
As opções FIFO estão disponíveis no SNS e no SQS, com diferentes garantias de preços e capacidade. A AWS usa o termo ID do grupo de mensagens para designar um título de grupo sob o qual todas as mensagens são FIFO. As mensagens dentro de um título de grupo são entregues na ordem, não distribuindo a próxima mensagem até que a mensagem anterior seja excluída.
Google Pub/Sub
O Google fornece o serviço Pub/Sub como parte de sua plataforma de nuvem para lidar com filas de mensagens e tópicos em um serviço integrado. O conceito de tópicos existe como você esperaria, enquanto uma fila é chamada de assinatura. Como esperado, associar várias assinaturas a um tópico copiará a mensagem para todas as assinaturas associadas. Além de permitir que os assinantes sondem ou retirem mensagens da assinatura, o Pub/Sub também pode fazer um POST estilo webhook da mensagem em seu servidor, permitindo que você a reconheça com um código de status de retorno de sucesso.
Este também é um sistema totalmente gerenciado, como a AWS. Você é cobrado com base no número de mensagens enviadas, e o Google lida com o dimensionamento do sistema para qualquer capacidade necessária. Ele também tem alguns recursos que não estão disponíveis no combo SNS + SQS, como permitir que você examine seu registro histórico usando carimbos de data/hora e mensagens de repetição.
A funcionalidade FIFO existe dentro do contexto de uma chave de pedido, o que permite garantir que as mensagens dentro de uma chave de pedido sejam processadas em sequência, não fornecendo a próxima mensagem até que a anterior tenha sido reconhecida.
AWS EventBridge
Uma nova oferta da AWS, o EventBridge oferece um barramento de eventos totalmente gerenciado — essa é uma variação do conceito de filas e tópicos, em que todas as mensagens são postadas em um único barramento sem separação de tópicos visível. Em vez disso, cada mensagem precisa ser estruturada de acordo com um formato padrão que tenha informações sobre o tópico da mensagem dentro dela. O ônibus então lerá a mensagem e a encaminhará para qualquer assinante que tenha manifestado interesse em receber as mensagens sobre esse tópico. O mecanismo de entrega real do barramento para o assinante pode ser uma fila SQS, webhooks ou muitas outras opções específicas da plataforma. Isso facilita o gerenciamento do barramento de eventos como um painel de controle baseado em regras configurável separadamente, além de permitir plug-ins fáceis para arquivamento, auditoria, monitoramento, alerta, repetição, etc.
Córregos Redis
O Redis tem um recurso Streams relativamente novo que funciona muito bem para filas de mensagens. Ele funciona criando tópicos em tempo real e adicionando mensagens a eles com o comando XADD. Ler as mensagens diretamente fora do tópico é possível com o XREAD, para que cada assinante possa manter seu próprio estado (o último deslocamento de leitura) para ler as mensagens. Para evitar que cada assinante tenha que manter seu estado atual, faz mais sentido criar grupos de consumidores usando XGROUP CREATE, que são os equivalentes de filas. Cada mensagem enviada para um tópico torna-se disponível independentemente em cada grupo de consumidores, que pode ser subscrito com XREADGROUP. As mensagens podem ser reconhecidas com XACK.
Os Redis Streams são automaticamente ordenados por FIFO usando um carimbo de data/hora que pode ser gerado automaticamente ou definido manualmente. Isso também significa que as mensagens só podem ser processadas por um agente consumidor por vez. Para contornar essa limitação e trabalhar com muitos agentes de consumo em paralelo, há um padrão separado não baseado em fluxos descrito na documentação de [RPOPLPUSH](https://redis.io/commands/rpoplpush#pattern-reliable-queue) — basicamente mensagens LPUSH em um tópico e, em seguida, RPOPLPUSH em outras listas, cada uma representando uma fila ou, mais precisamente, seu trabalho em andamento. O LREM trabalha para excluir/reconhecer a mensagem.
Redis é um sistema de código aberto que você pode instalar e manter-se ou encontrar hospedagem gerenciada para. Dependendo de quão durável você precisa que o sistema seja, você pode querer descobrir o melhor mecanismo de persistência para usar.
Apache Kafka
Kakfa é um popular corretor de mensagens que trabalha os conceitos de produtores que publicam mensagens, chamadas de eventos, para tópicos. Os eventos em um tópico são divididos em partições, usando uma chave de partição dentro do tópico, e a ordem FIFO é mantida dentro de cada partição. Os eventos podem ser transmitidos aos consumidores por meio de um soquete ou consultados pelos consumidores para uma abordagem mais dissociada. Para os consumidores que não querem manter o estado, o conceito de grupo de consumidores se aplica, assim como o Redis Streams. Um grupo de consumidores é efetivamente uma fila, onde cada evento postado em um tópico está disponível para processamento em cada grupo de consumidores associado.
Kafka é de código aberto, mas é complicado de instalar e manter, o que o torna adequado para projetos e equipes maiores. Ele é dimensionado com base em quão bem você divide seus eventos em partições — quanto mais partições você tiver, mais Kafka pode distribuir o trabalho, e cada partição tem apenas tanta capacidade quanto o servidor encarregado de gerenciá-la. Opções de hospedagem gerenciada estão disponíveis, mas tendem a ter altos custos básicos em comparação com serviços gerenciados como SNS + SQS, Pub / Sub ou RabbitMQ.
RabbitMQ
RabbitMQ é um popular agente de mensagens de código aberto que suporta uma variedade de protocolos, com suporte direto para os conceitos de tópicos e filas. O RabbitMQ opera nos modos no máximo uma vez e no mínimo uma vez, com no máximo uma vez sendo um modo rápido baseado em memória que ocasionalmente grava mensagens no disco, se necessário (você pode escolher entre filas pesisted ou transitórias). Se você quiser um sistema mais confiável, mas mais lento, pelo menos uma vez, poderá usar as operações descritas no guia de confiabilidade para solicitar confirmações ao publicar mensagens e exigir confirmações obrigatórias ao lê-las. As filas são FIFO por padrão, com a opção de impor o processamento sequencial com confirmações.
QNSQ
A primeira fila de mensagens verdadeiramente distribuída nesta lista, o NSQ é interessante porque é construído a partir do grupo até ser descentralizado. Não há um único ponto ao qual se conectar para publicar ou assinar mensagens — cada nó NSQ é efetivamente um servidor completo e conversa com todos os outros nós. Os nós permitem que você publique mensagens em tópicos, e cada tópico pode ser vinculado a um ou mais canais, que são o equivalente a filas. Cada mensagem publicada em um tópico está disponível em todos os seus canais vinculados.
O NSQ tem como padrão mensagens não duráveis, pelo menos uma vez, não ordenadas, mas tem algumas opções de configuração para ajustar as coisas. Vale especialmente a pena considerar se seus servidores estão altamente conectados uns com os outros e você quer um sistema sem um único ponto de falha.
NATS
O NATS é um sistema de mensagens distribuído de alto desempenho feito para mensagens rápidas na memória. Ele suporta transmissão baseada em tópicos (tópicos são chamados de assuntos), onde todas as mensagens enviadas para o assunto são enviadas para todos os agentes assinantes, e filas distribuídas, onde cada mensagem na fila é enviada para qualquer agente assinante. Não há uma maneira interna de ter tópicos vinculados a filas, mas isso deve ser possível fazer programaticamente.
O NATS suporta entrega no máximo uma e pelo menos uma vez, fornecendo um sistema de streaming e um sistema de persistência experimental. Ele também tem suporte para assinar vários tópicos com base em padrões de nome de assunto, o que torna mais fácil fazer fan-in e multilocação.
O NATS funciona muito bem quando você precisa de um sistema distribuído de alta taxa de transferência — também é muito fácil de executar e oferece suporte a topologia de rede complexa, como ter clusters regionais com conexões entre eles.
A Mensagem da Cauda
Essas são apenas algumas das opções disponíveis no momento, e ainda mais estão sendo desenvolvidas à medida que a computação distribuída se desenvolve e os provedores de nuvem crescem. Descobri que o importante ao avaliar ou usar sistemas de filas é entender a semântica e as garantias que eles oferecem. Faço isso lendo suas visões gerais arquitetônicas para ter uma ideia aproximada de como elas são implementadas. Sob a superfície, os mesmos conceitos se aplicam a todos eles, apenas sob diferentes nomes e opções de configuração.
Se você estiver executando suas cargas de trabalho em um provedor de nuvem específico, o sistema de tópico/fila padrão que eles oferecem geralmente funcionará bem, desde que você entenda qual semântica eles estão oferecendo em cada modo. Se você estiver gerenciando sua própria instalação de um sistema de filas, a mesma coisa se aplica, exceto que você precisa se preocupar muito mais com o limite imposto pelas decisões operacionais que está tomando, como quantos nós você está executando, configuração de failover, espaço em disco, etc.
Obrigado por ler, entre em contato comigo @sudhirj ou participe da discussão no Hacker News se tiver dúvidas ou discordar de alguma coisa.
Agradecimentos especiais a @svethacvl pela revisão e @wallyqs pelas notas sobre o NATS.