Pontos Principais
- Em comparação com as soluções de mensagens de 10 anos atrás, os serviços de mensagens baseados na nuvem adotam uma abordagem mais prática em relação às garantias de transação;
- É possível entrar em um estado inconsistente acidentalmente quando estamos lidando com interações de filas múltiplas;
- Os padrões da caixa de entrada e saída podem ajudar a conectar as filas com as transações do banco de dados;
- Para ajudar a deduplicação, idempotência e transações, precisamos garantir que cada mensagem tenha um identificador exclusivo;
- Não podemos confundir processadores de eventos baseados em log com filas e por isso necessitamos determinar o que precisamos, antes de escolher entre um e outro.
Os serviços de mensagens baseados na nuvem têm suporte transacional diferente daqueles anteriores? Se sim, quais são as implicações? Nesta entrevista com o especialista em sistemas distribuídos Udi Dahan explora essa questão.
InfoQ: Conte-nos quem você é?
Udi Dahan: Meu nome é Udi Dahan. Sou o fundador da NServiceBus e CEO da Particular Software. Criamos um middleware de mensagens para permitir que as pessoas criem sistemas de negócios complexos de maneira mais rápida, fácil e confiável.
InfoQ: O que é uma "transação"?
Dahan: Há duas explicações. A Primeira é técnica. Pense em transações ACID (atomicidade, consistência, isolamento e durabilidade). Acho que a melhor resposta relacionada ao negócio que poderia dar é que as transações são uma ferramenta para garantir que o sistema permaneça em um estado consistente e que não contenha dados imprecisos que tornem o sistema inutilizável e sem conformidade com alguma regulação.
InfoQ: Como é a jornada no middleware que gerenciava transações há 10 anos atrás e como as transações funcionam em um broker de mensagens baseado na nuvem, nos dias de hoje.
Dahan: Primeiro, devemos mencionar que as transações começaram no mundo dos bancos de dados. E como mencionei, tudo se baseia na consistência de dados.
Agora, é importante mencionar por que o isolamento é incluído como parte dos atributos das transações. Uma das principais considerações de negócio é, um sistema poderá continuar a funcionar corretamente quando vários usuários ou atores, podendo ser sistemas diferentes operando em conjuntos de dados em paralelo? Não queremos que o sistema se comporte corretamente somente quando tivermos um usuário por vez operando em um registro específico, pois teremos várias coisas acontecendo em paralelo no mundo em que vivemos hoje.
A premissa é que tudo está interconectado e os usuários esperam se conectar com seus dados e colaborar com outras pessoas em qualquer conjunto de dados em tempo real em qualquer parte do mundo.
Os sistemas de mensagens foram introduzidos como uma maneira de fornecer algum elemento de mensagem confiável passando por distâncias maiores. Consideremos o cenário em que estamos transferindo dinheiro de uma conta para outra. Não existe a possibilidade, nem mesmo o desejo, de uma instituição financeira bloquear os registros de outras bases de dados de qualquer outra instituição financeira ao redor do planeta.
Então, as mensagens foram introduzidas como um lugar temporário que não está em nosso banco de dados ou no banco de dados terceiro. E então podemos movimentar o dinheiro por meio desses túneis altamente confiáveis, onde cada etapa da jornada pode ser uma transação: do nosso banco de dados para uma fila de saída e da nossa fila de saída para uma fila intermediária, de uma fila intermediária para outra fila intermediária, daí para a fila de entrada terceira e da fila de entrada terceira para o banco de dados terceiro. Enquanto cada um desses passos for confiável e transacional, todo o processo poderia ser garantido como seguro na perspectiva dos negócios.
E foi aí que as mensagens começaram a entrar e tomar parte em fluxos de processamento de negócios transacionais em maior escala no mundo, porque as transações eram consideradas uma parte muito importante da infraestrutura de mensagens desde o início.
InfoQ: Na computação em nuvem é diferente? Percebemos que as garantias e expectativas do Azure Service Bus ou Amazon SQS são diferentes daquelas disponíveis no Enterprise Service Bus que usávamos há 10 ou 20 anos?
Dahan: Na prática, vemos uma diferença. Não é apenas por ser da nuvem. Acho que os sistemas de enfileiramento de mensagens de código-fonte aberto, como o RabbitMQ que, acredito, prosseguiram com o SQS e o Azure Service Bus, provavelmente foram os primeiros a serem realizados sem transações. Havia também o ActiveMQ, que afirmava que suportava transações, mas era uma implementação não confiável e não podíamos confiar piamente.
Então acredito que a principal coisa que aconteceu foi que o NoSQL se tornou um item preferencial. E isso realmente acabou com o desejo do setor por garantias transacionais, lideradas principalmente pelo MongoDB. Queremos que as coisas sejam em larga escala e para isso estamos dispostos a abrir mão de algumas funcionalidades.
Nos ambientes de nuvem, estamos vendo um efeito cascata dessa perda de transações. Agora, vale a pena notar que, quando estamos migrando para um ambiente em nuvem, onde temos milhares, senão dezenas de milhares de máquinas distribuídas em todo o mundo, a ideia de transações precisa ser definida adequadamente. Caso contrário, é como voltar ao exemplo bancário de abrir uma transação global cruzada que está bloqueando recursos em todo o mundo! E isso nunca funcionou. Portanto, o fato da infraestrutura de nuvem dizer que não oferecemos suporte a transações é porque não é viável fornecer esses tipos de garantias transacionais globais.
No entanto, acho que essa consideração deixou de ser uma questão de infraestrutura para ser uma responsabilidade do desenvolvedor de negócios de dizer: "Eu não sei. Os dados serão eventualmente consistentes. Está tudo bem.", sem nem perceber que não podemos simplesmente falar do encantamento da consistência eventual e isso acontecerá magicamente.
Então, o risco é que acabemos em um estado eventualmente inconsistente, porque não compensa suficientemente a falta de transações de infraestrutura.
InfoQ: Poderia mostrar um exemplo de como entro em um estado acidentalmente inconsistente?
Dahan: Na verdade, há uma lista muito longa de exemplos de sistemas que podem acabar em um estado eventualmente inconsistente, dependendo da natureza da infraestrutura que estamos usando e de como estamos usando.
Alguns sistemas de filas, ambos no datacenter local ou na nuvem, não suportam transações com várias filas. Então, se estamos recebendo de uma fila e estamos enviando mensagens para várias outras filas, existe o elemento de dizer, estamos enviando essas mensagens para as outras filas e depois o sistema deve se virar para reconhecer a primeira mensagem. Mas pode ser que tenhamos uma falha parcial, onde algumas das mensagens saem, outras não, dependendo de como falamos com o message broker. Por exemplo, alguns dos agentes têm um modo assíncrono de comunicação com os clientes e, no RabbitMQ, isso é conhecido como "confirmação do publicador", que está desativado por padrão.
Portanto, pode haver uma situação em que estou enviando mensagens, mas o cliente não está conectado com o broker instantaneamente e o código continua sendo executado. Portanto, algumas das mensagens não foram enviadas e o código pode não saber disso quando começar a receber a primeira mensagem da fila. Poderíamos acabar em uma situação em que estamos integrando a vários sistemas, mas um deles não recebe a mensagem. E o código do cliente não recebe uma exceção, ou pode receber uma exceção, mas será obtido de forma assíncrona! E então cabe a mim, o desenvolvedor do negócio, descobrir o que devo fazer sobre isso. O comportamento padrão em que os desenvolvedores se envolvem é "Ah, houve uma exceção, irei registrá-la e alguém analisará isso mais tarde".
Agora, esse é um cenário simples. Vou usar outro que envolve a comunicação com o banco de dados, no qual temos vários sistemas de negócios com uma combinação de mensagens entrando e saindo. Então, digamos que estejamos inserindo uma entidade em uma tabela e que o banco de dados esteja devolvendo algum tipo de identificador incremental que usaremos no caso de publicar novamente. Em um cenário de varejo, gostaríamos de comprar algo e inserimos este algo na tabela de pedidos, recebemos o ID do pedido 12345 e depois publicamos um evento dizendo que recebemos o Pedido 12345. Agora, dependendo da maneira como falamos no banco de dados, quando confirmamos a transação o banco de dados pode enfrentar um impasse porque outra pessoa está fazendo algo com esses dados ao mesmo tempo e é necessário reverter e tentar novamente.
O código está dizendo "Ok, faça um rollback, tente novamente". Reconhecerei negativamente a mensagem original recebida e a processarei novamente. O evento de saída que publiquei diz que recebi um pedido deste cliente com um ID gerado pelo banco de dados que é 12345. Essa mensagem pode escapar do limite dessa transação, porque não há nenhuma transação distribuída que registre a fila de entrada, a fila de saída e o banco de dados.
Agora temos um sistema em fluxo que recebe esse evento que representa informações sobre este cliente. Isso envolverá cobrança, promoções de marketing, contabilidade, etc. Começaremos a obter esses sistemas em fluxo que estão conectados aos identificadores de entidade errados e ninguém sabe que isso está acontecendo até ser muito tarde, quando um cliente nos telefona e explica que algo está errado. E a pessoa do suporte está tentando descobrir o que aconteceu. E é realmente difícil naquele momento, com todos os vários sistemas, descobrir o que deu errado, qual deveria ter sido o estado certo e que outras coisas potenciais mais adiante nos sistemas com os quais esses sistemas estão falando também se tornaram inconsistentes.
Esses são os tipos de coisas que geralmente não surgem nos testes. Porque a maioria das pessoas não testa os sistemas em cenários simultâneos e não tenta simular falhas de transação, de reversão, etc.
Supõe-se que isso seja resolvido na biblioteca do cliente da Amazon SQS ou do Azure Service Bus. Em outras palavras, os desenvolvedores confiam nos fornecedores e nos produtores dessas bibliotecas para fornecer os recursos que devem ser suficientes para criar um código comercial simples. Não estamos falando de ciência de foguetes aqui, certo, tire uma mensagem da fila, insira um registro em um banco de dados e publique um evento, entendemos que muitos códigos estão escritos para fazer isso. Mas há um eventual risco de inconsistência lá.
InfoQ: Como o desenvolvedor deve abordar isso da maneira correta? Qual é uma boa maneira de ser mais seguro nesse contexto?
Dahan: Bem, verifica-se que há um grande número de padrões relativamente simples em torno de como trabalhamos com um sistema de filas, como distribuímos as transações lógicas nos bancos de dados e como estas transações voltam pelo sistema de filas. Há dois padrões básicos que caminham juntos. São os padrões de caixa de entrada e caixa de saída.
Para que funcionem, as mensagens precisam ter um identificador para que possamos identificá-las exclusivamente e, em seguida, tentar novamente ou desduplicar, caso seja apropriado.
A maioria dos sistemas de enfileiramento fornecem deduplicação com base em identificadores de mensagem e alguns sistemas de enfileiramento nem impõem o uso de identificadores de mensagem. É apenas deixado como opcional. Então esse é o número um. Precisamos nos certificar de fornecer identificadores exclusivos para todas as nossas mensagens.
Quando possuímos um código comercial que desejamos enviar mensagens, em vez de falar diretamente com o broker, o que desejamos fazer é conversar com esse componente da caixa de saída que publicará essa mensagem. Agora essa caixa de saída, em vez de falar diretamente com o sistema de filas, inscreve a mesma transação técnica no armazenamento de dados e mantém essas mensagens em uma tabela no mesmo local para que possa fazer parte da mesma transação técnica.
Portanto, em essência, estamos introduzindo outro nível de durabilidade semi-local nas mensagens, de modo que tudo o que o usuário peça para enviar seja parte integrante da mesma transação do banco de dados. Isso significa que, se a transação do banco de dados reverter devido a qualquer tipo de falha, os dados corporativos não serão revertidos, mas sim, todas as mensagens que foram enviadas e, assim, evitaremos que o problema de mensagens com dados corporativos incorretos escapem do limite da transação.
Então esse é o padrão da caixa de saída. Agora, a outra parte é a caixa de entrada. Digamos que exista o caso de tirar uma mensagem da fila, na qual possui um identificador de mensagem e invocamos a lógica comercial que atualiza as entidades comerciais e colocamos as coisas na caixa de saída. Agora, esse código está pronto para publicar as mensagens, mas o endpoint falha logo após confirmar a transação no banco de dados. O que acontece depois? É aí que a caixa de entrada está entrando, porque quando a mensagem volta a ser repetida e é retirada da fila, a caixa de entrada pega esse identificador de mensagem, vai para as tabelas de mensagens enviadas e vê que já processou esta mensagem e sabe que estas são todas as mensagens enviadas que precisam ser enviadas. Por isso, a caixa conversará com o intermediário de mensagens e solicitará que envie todas as mensagens, além de saber como NÃO reinvocar a lógica de negócios, porque foi processado com êxito.
Portanto, isso nos dá um tipo de idempotência já incorporada, sem que os desenvolvedores tenham que escrever a idempotência nos próprios handlers. Que coincidentemente, é uma das coisas que sempre tive problemas com a comunidade REST, pois sempre discutem em torno deste tema! Ah, basta torná-lo idempotente, como se isso fosse uma coisa simples de ser feita. É como uma consistência eventual. Se dissermos o suficiente, talvez seja verdade! Não, a idempotência, na verdade, não é uma coisa trivial para ser imposta. "Ah é sim. Apenas precisamos verificar, para ver se já processamos essa coisa antes." E se for uma atualização? Claro, uma inserção, podemos verificar se foi concluída anteriormente, mas como verificamos uma atualização? "Basta verificar os dados da empresa e ver se não foram alterados." Sério? E se estivermos operando em um mundo simultâneo em que vários usuários estão atualizando os mesmos dados ao mesmo tempo? Portanto, o atualiza com êxito, mas o código é revertido por qualquer motivo, e outro código entra e faz atualizações subsequentes. Quando tentamos novamente e analisamos o estado, achamos que é diferente do que tínhamos antes e repetimos o processamento sem perceber que estamos realmente substituindo algo incorretamente.
Portanto, a idempotência, diante das atualizações em um mundo simultâneo, não é algo trivial para ser imposta se estivermos fazendo REST ou mensagens. É por isso que tivemos transações desde o início. Então, temos essa caixa de entrada, temos a caixa de saída e os IDs das mensagens. E quando estivermos enviando as mensagens também poderá ocorrer um acidente. Portanto, precisamos preservar o uso do mesmo identificador de mensagens sempre que repetir a emissão das mensagens da caixa de saída. A razão pela qual isso é importante é que pode haver sistemas em fluxo que podem receber essas mensagens duas vezes. Portanto, se preservarmos o mesmo identificador de mensagem, a caixa de entrada poderá desduplicar com êxito essas coisas.
Desse modo, existem várias técnicas aqui relacionadas ao gerenciamento de IDs de mensagens, eliminação de duplicação de entrada, idempotência, captura e armazenamento de mensagens na saída e gerenciamento de transações na camada de armazenamento de dados. Cada um deles por si só não é realmente difícil. Mas reuni-los da maneira correta e fazer com que funcionem para o RabbitMQ, o Amazon SQS e o Azure Service Bus e, em seguida, entre as tecnologias de banco de dados suportadas existentes, pode ser complicado. Foi exatamente por isso que criei o NServiceBus porque disse que isso é muito difícil! O desenvolvedor de negócios médio deve se concentrar no código de negócios, em vez de ter que descobrir como implementar todo esse tipo de padrão de mensagens de middleware para obter consistência eventual, em vez de inconsistência eventual.
InfoQ: Então o NServiceBus pega esses padrões e os incorpora nas bibliotecas para que os desenvolvedores não tenham que lidar com isso? Como o NServiceBus resolve esse problema?
Dahan: O NServiceBus fornece uma estrutura na qual todas essas coisas já estão configuradas lá, nos modos "seguros por padrão".
Novamente, essa é uma das coisas que discordo sobre a direção em que nossa indústria tomou. Somos muito orientados para o benchmark. Diremos "RabbitMQ faz 60.000 mensagens por segundo. Isso não é nada já que o ZeroMQ faz 120.000 mensagens por segundo". Se não for seguro, realmente importa o quão rápido a solução é? Gostaríamos de dirigir junto com nossa família em um carro que pode fazer de zero a 120 quilômetros em dois segundos, mas sem cintos de segurança, airbags ou portas?
Quero primeiro estar seguro e rápido depois. Portanto, o que fizemos com o NServiceBus foi fornecer essa segurança em primeiro lugar e colocar todas essas funcionalidades para que, por padrão, não percamos nada.
E testamos isso em todas as pilhas e em todos os vários modos de falha e podemos garantir que não perderemos nossas mensagens ou que nossos dados comerciais não se tornarão inconsistentes, se apenas usarmos conforme o planejado. Se quisermos acelerar mais, venham conversar conosco e podemos começar a falar sobre alguns ajustes. Mas quando se trata de dados, fluxos de trabalho comerciais que realmente desejamos estarem corretas, prontas para uso, nosso serviço é a solução.
InfoQ: A introdução de mais sistemas de log distribuídos no estilo Kafka - onde podemos ter o ID da mensagem, reproduzir o feed de dados - muda a opinião sobre o que é necessário para a integração?
Dahan: Então o Kafka é uma ótima plataforma de streaming de dados, os desenvolvedores realmente construíram algo incrível. A questão que quero levantar é que o Kafka está sendo apresentado como algo muito mais do que isso. E algo que pode ser usado para os casos de uso que descrevi. Agora, a principal distinção entre, digamos, um message broker no qual tenhamos uma mensagem de cada vez, processamento altamente consistente e confiável, versus uma plataforma de streaming de dados em que estamos mais interessados no processamento de um grande número de mensagens em lote do que operar uma unidade por vez. Em outras palavras, são aplicações para diferentes contextos.
Então, digamos que estejamos em um cenário da Internet das Coisas (IoT) e estamos obtendo informações de sensores que estão a bordo de trens ou caminhões que dizem a velocidade, posição e nível de combustível atual. Se não processarmos com êxito todas essas leituras, receberemos uma informação e a leitura antiga não será mais tão interessante. Portanto, não precisamos de um processamento altamente consistente e único. Na verdade, é muito ineficiente tentar processar esse volume de dados de maneira sequencial, ou seja, um item de cada vez!
Existem vários domínios assim em que lidamos mais com um fluxo de dados e nosso interesse é processar o mais rápido possível e com muitos deles ao mesmo tempo. Esse é o domínio do fluxo de dados.
Agora, muitas vezes, fora desses domínios, os eventos do negócio podem ser descobertos pela própria lógica de negócios. "Acho que temos um caminhão quebrado, precisamos agir sobre isso." Esse é um evento de negócio. Não gostaríamos de perder o evento sobre um caminhão quebrado. É aí que estamos nos movendo entre os mundos da transmissão de dados para o processamento de eventos de negócios.
Sobre a proposta do Kafka, diria que isso é verdade para muitas tecnologias, gostaria de dizer que pode usá-lo dessa maneira ou também podemos dar suporte a esse cenário. A questão é: bem, quanto trabalho extra precisamos fazer na plataforma principal para que seja bem-sucedido?
Com Kafka, é bastante. Na verdade, tentamos fazer isso, pois os clientes vinham perguntando por que o NServiceBus não oferece suporte ao Kafka. E tivemos uma grande discussão sobre o domínio da arquitetura que oferecemos agora. Mas a resposta técnica principal é que, em essência, precisamos criar uma fila em cima do Kafka primeiro e depois construir o NServiceBus em cima dessa fila subjacente.
E antes de mais nada, é muito difícil. Segundo, não é realmente escalável. Então, algumas das coisas que tornam o Kafka realmente bom no que faz é que foram capazes de fazer escolhas de design porque não precisavam oferecer suporte à semântica de filas. Onde, se soubessem que precisavam oferecer suporte à semântica de filas, teriam projetado de uma maneira diferente.
InfoQ: Algum comentário final? Cobrimos muitas perguntas até aqui!
Dahan: Para as pessoas que nunca usaram o NServiceBus antes, não deixe o nome te impressionar! Às vezes, as pessoas ouvem o "barramento de serviço" e pensam que esse tipo de ESB é pesado. E só queremos ter algo leve e simples. Na verdade, são apenas alguns pacotes NuGet para desenvolvedores .NET. Experimente a funcionalidade de "Início Rápido", que teremos algo funcional em 15 minutos ou menos. É realmente fácil de utilizar. E seremos capazes de oferecer suporte a praticamente todas as cargas de trabalho que podemos oferecer.
Sobre o entrevistado
Udi Dahan é um dos principais especialistas do mundo em Arquitetura Orientada a Serviços e Design Orientado a Domínio e também o criador do NServiceBus, o barramento de serviço mais popular para .NET.