Concorrência em microserviços com Clojure e Datomic

Cesar Augusto Alcancio de Souza
12 min readNov 3, 2021
Concorrência em microserviços utilizando Clojure e Datomic

Há alguns anos atrás eu estava em uma equipe que precisava criar um produto novo para oferecer empréstimos, começamos a validar o produto com alguns serviços em Java e MySQL.

A proposta era ter uma API onde alguns parceiros pudessem integrar e criar ofertas para os nossos clientes. Posteriormente os clientes poderiam escolher a melhor oferta, submeter seus dados pessoais, passar por um processo de aprovação e receber seu empréstimo.

Só era permitido que cada parceiro criasse até três ofertas para cada cliente, toda a User Interface estava desenhada para atender apenas três ofertas. As ofertas tinham tempo de expiração e portanto era possível criar mais três ofertas apenas depois que as atuais fossem expiradas.

De forma resumida queríamos ter uma API que recebesse a oferta, e antes de inserir a oferta na base de dados, era necessário validar se já não existiam três ofertas com status disponível. Caso não existisse, a oferta seria criada, caso já existisse, seria retornado um bad request informando que já existiam ofertas disponíveis para este cliente.

A tabela de ofertas era basicamente composta por id, partner_id, customer_id, status e amount.

Exemplo de tabela de ofertas

Em um arquitetura de microserviços é muito comum que tarefas sejam executadas de maneira paralela, esse tipo de execução já acontecia com aplicações monolíticas que trabalhavam com várias threads em um mesmo servidor, porém em uma arquitetura distribuída como a de microserviços, é bastante comum que existam várias instâncias do mesmo serviço que executam a mesma tarefa ao mesmo tempo.

Várias instâncias do mesmo serviço validando e criando ofertas

Caso as três instancias façam a validação ao mesmo tempo e depois cadastrem as três ofertas cada uma, o total de ofertas será nove. Como podemos assegurar que vamos ter apenas três ofertas?

Com o Datomic podemos ver duas formas de evitar este tipo de problema. Até o final do texto você vai entender a arquitetura básica do Datomic, como preparar o seu ambiente para executar a aplicação com o Datomic, uma visão geral do projeto, simular o erro de ter mais de três ofertas e conhecer duas possibilidades de solução. Se você já conhece algum desses temas, pode pular para o seguinte. Vamos lá!

Arquitetura do Datomic

Eu escrevi uma introdução ao Datomic que explica um pouco como ele armazena os dados como datoms, se você ainda não viu, da uma olhada nele antes de continuar.

Agora que você já sabe que o Datomic armazena os dados como datoms, é importante entender um pouco como funciona a arquitetura do Datomic.

O Datomic é uma base de dados distribuída, isso significa que você pode executar os componentes de maneira independente. São basicamente três componentes.

  • Storage service: responsável por armazenar os dados, no ambiente local é utilizado o próprio file system, mas pode ser utilizado ferramentas como DynamoDB ou um banco SQL como PostgreSQL
  • Transactor: responsável por receber as transações, executá-las de maneira sequencial e armazenar no storage.
  • Peer Server: É um intermediário opcional entre a aplicação e o transactor. Aqui é onde o processamento de consultas é realizado e também pode ser adicionado camadas de cache.

No nosso exemplo, não vamos usar o Peer Server, porque existe uma forma de executar a aplicação como um “Peer Server”.

Existe basicamente duas maneiras que você pode executar sua aplicação que vai acessar o Datomic.

  • Executar a aplicação com o client library, que foi como executamos no texto de Introdução ao Datomic. Existem duas client libraries: a dev-local para provas locais (usada no Introdução ao Datomic) e a client-pro que é para o ambiente produtivo. Com a client-pro é necessário um Peer Server, porque toda a consulta será executada no Peer Server. Algumas desvantagens desse modelo client library, é que no momento que eu escrevo esse texto, a biblioteca é um pouco limitada em funções, e caso o Peer Server fique sobrecarregado todas as instancias da aplicação serão afetadas. A vantagem é a facilidade de testar o Datomic utilizando a dev-local.
  • Executar a aplicação com a peer library, nesse modelo cada aplicação é uma especie de Peer Server, por isso não é necessário criar um Peer Server aparte. O processamento das queries é realizados na aplicação. Caso alguma query tenha um alto consumo, impactará apenas a instância que está executando a consulta. Consequentemente a aplicação precisará de mais recursos de memória e CPU.
Peer library config

Vamos utilizar a peer library para o nosso exemplo.

Preparando o ambiente Datomic com Peer Library

Para utilizar a Peer Library é necessário fazer um cadastro no site do Datomic. Uma vez cadastro e autenticado, você pode acessar a página inicial e fazer o download do Datomic. Eu vou usar a versão 1.0.6316

Página para download do Datomic

O comando já vai vim com o seu password, basta trocar o $VERSION para a versão desejada e executar, por exemplo.

wget --http-user=cesar.alcancio@gmail.com --http-password=<seu-password-aqui> https://my.datomic.com/repo/com/datomic/datomic-pro/$VERSION/datomic-pro-1.0.6316.zip -O datomic-pro-$VERSION.zip

Depois de executar o download você deve instalar a biblioteca no seu Maven local com o seguindo comando.

bin/maven-install

Ou no Windows.

mvn install:install-file -DgroupId=com.datomic -DartifactId=datomic-pro -Dfile=datomic-pro-1.0.6316.jar -DpomFile=pom.xml

Agora basta criar o seu projeto Clojure utilizando Leiningen e adicionar a biblioteca. Mas antes vamos executar o Datomic Transactor.

Executando o Datomic Transactor

Depois de tudo configurado você precisa executar o transactor do Datomic para que sua aplicação possa executar as queries e adicionar e retrair datoms do Datomic.

Para isso você precisa entrar na sua página do Datomic e clicar no botão de enviar a licença para o seu e-mail.

Enviar código da licença do Datomic

Você vai receber um e-mail com a licença para executar o Datomic.

Datomic License

Dentro da pasta onde você baixou o Datomic, você vai editar um arquivo chamado datomic-pro-1.0.6316/config/samples/dev-transactor-template.properties. Nesse arquivo você vai adicionar a license-key no lugar pré estabelecido, depois do “license-key=”.

Arquivo dev-transactor-template.properties

Depois você pode executar o Datomic Transactor passando o caminho completo do arquivo de configuração:

bin/transactor /caminho/completo/datomic-pro-1.0.6316/config/dev-transactor-template.properties

E agora seu Datomic Transactor deve estar sendo executado no endereço datomic:dev://localhost:4334

Datomic transactor executando

Pronto! Agora você já tem a peer library instalada localmente com Maven para ser utilizada, e já tem o Datomic Transactor executando para ser usado. Vamos criar o projeto.

Criando o projeto Clojure para criar ofertas

Vamos criar o projeto utilizando Leiningen e o processo será o mesmo que foi utilizado no artigo sobre Introdução ao Datomic. O código completo pode ser encontrado no Github.

A única diferença é que o projeto vai usar a peer library instalada anteriormente, que pode ser importada no project.clj da seguinte maneira.

[com.datomic/datomic-pro "1.0.6316"]

Visão geral do projeto

O projeto foi dividido em basicamente 4 camadas: business, common, controllers e datomic.

Estrutura do projeto

Vamos começar pela camada do Datomic.

Datomic

Essa é a camada responsável por conectar com o datomic e realizar as operações de listar, inserir e retrair datoms.

Datomic

O config.clj vai expor as funções para conectar com o datomic, criar base de dados, função para criar o schema e etc.

O offers.clj é uma especie de Data Access Object, possui funções para listar, inserir e retrair datoms.

O schema.clj é onde fica declaro o schema da base de dados. Composto por offer/id, offer/customer-id, offer/status, offer/amount e offer/created-at.

Schema da base de dados

Business

A camada de business é onde fica a lógica de negócio. Nesse pacote vamos ter a função responsável por receber as ofertas e validar se existem mais de 3 ofertas com o status de available.

Função validar se existe mais de 3 ofertas disponíveis

A ideia é que essa função seja pura, dessa forma fica mais fácil realizar os testes unitários.

Testes unitarios

Common

Essa seção possui apenas algumas funções que vão ajudar na construção do projeto, por exemplo: retornar data e hora, gerar uuids, executar um bloco de código em várias threads e criar uma oferta fake.

Funções comuns

A macro run-threads é bastante importante pois vai permitir que possamos executar os controllers de forma paralela permitindo que seja simulado o erro de concorrência. Se você não sabe muito bem como criar macros, pode consultar esse artigo que explica como funcionam as macros em Clojure.

Controllers

Por fim o controlador, que é o responsável por conectar todos os outros pacotes. As funções no controlador vão chamar as outras funções de commons, business e datomic, executando o que chamamos de composição de funções.

O projeto possui basicamente três funções no controlador:

  • create-offer-v1! responsável por reproduzir o erro de concorrência
  • create-offer-v2! responsável por aplicar uma solução usando o transactor do datomic
  • create-offer-v3! responsável por aplicar uma solução usando o db-after do datomic
Três função de composição

Vamos ver cada função com mais detalhes.

Simulando o erro de concorrência

A função create-offer-v1! faz o processo básico, ela recebe como parâmetros o datomic (que possui o db e a conexão) e a offer. A primeira coisa que essa função faz é consultar todos as ofertas do customer id, acrescentar a oferta nova e chamar a função para checar se vai ultrapassar o limite de 3 ofertas available.

Por último, caso não ultrapasse o limite, vai registrar a nova oferta. Essa é uma função que funciona caso não exista muitas chamadas executadas de maneira paralela como explicamos no começo do texto.

Vamos executar uma prova onde 100 threads diferentes vão realizar a chamada para esta função. No final a prova vai validar se foram inseridas apenas 3 ofertas.

Validando crate-offer-v1!

Nesse caso estamos validando que no final da execução (após 1 segundo), foram inseridas mais que três ofertas, ou seja, quando chamamos a create-offer-v1! 100 vezes em paralelo mais de 3 ofertas são registradas, fazendo com que a regra não seja cumprida.

Solução utilizando o Datomic Transactor

A primeira abordagem que podemos utilizar para corrigir este problema é executar a validação dentro do transactor do Datomic. Na seção que explica a arquitetura do Datomic, vimos que o transactor é único e executa as transações de maneira sequencial, dessa forma mesmo que criarmos 100 threads em paralelo, as validações seriam executadas de maneira sequencial.

Para isso precisamos instalar a função que vai validar e inserir os datoms. Essa função é instalada no Datomic assim como o schema é instalado, portanto ela deve ser instalada como “dado” (Existe outras formas de instalar essa função, por exemplo, adicionado a função no classpath, porém vamos focar na versão mais simples no nosso código).

Função :add-offer

Na visão geral do projeto vimos o schema das ofertas, nesse caso estamos adicionando mais um “campo” que será identificado com o “:db/ident” como“:add-offer”. No “:db/fn” será definido um mapa com a linguagem, os parâmetros e o código.

  • A linguagem é Clojure, sinalizando que o que vai em “code” deve ser escrito em Clojure.
  • Como parâmetros é sempre o db em primeiro e o restante são os dados que serão enviados quando essa função será executada, no nosso caso a offer.
  • O código deve ser enviado em um único bloco, pois ele será executado no transactor. Caso seja adicionado alguma referencia a uma função que só existe no serviço mas não existe no transactor, não irá funcionar. É possível utilizar o datomic.api porque o transactor também possui essa biblioteca.

Basicamente a função vai consultar na base de dados todas as ofertas com status available, adicionar a nova oferta na lista e validar se já excedeu o limite de ofertas. Caso não exceda o limite a função deve retornar o datom que será inserido, se não vai retornar uma exceção produzida pela função cancel do datomic api. Nesse caso não foi possível reaproveitar a função de validar o limite, toda a lógica ficou acoplada a base de dados.

Para executar a função basta chamar o transact e enviar o :add-offer com a offer em vez de enviar o datom diretamente. Dessa forma a função irá receber o db e a offer enviada, irá realizar a validação e retornar o datom para ser inserido. Ou a exceção.

Executando o :add-offer

Finalmente podemos executar o mesmo teste, porém, nesse caso, a quantidade de ofertas criadas sempre será três. Funcionando corretamente!

Testando a solução com o transactor

Essa é uma possível solução, mas vale a pena considerar que é dependente do transactor, o que pode deixar as coisas um pouco menos performática por executar de forma sequencial com consulta e validação em um único ponto. Outra coisa a se considerar é a organização do código, que não fica tão bem separado entre base de dados e regras de negócio. Vamos ver a próxima alternativa.

Solução utilizando consulta em em um ponto especifico do tempo

Essa solução não utiliza o transactor function, porém utiliza o fato do transactor executar as transações em sequencias. A ideia é que cada thread registre os dados da oferta primeiro e posteriormente valide se alguma outra thread inseriu uma oferta antes. Se outra thread inseriu uma oferta antes e excedeu o limite então a thread atual deve remover sua própria oferta, caso contrário deve manter a oferta registrada.

São duas coisas que permite que essa solução funcione (1) é o fato do datomic realizar as transações em sequencia e (2) permitir consultar a base de dados como exatamente no ponto após a inserção da nova oferta. Vamos ver dois exemplos.

Consulta no momento da validação com consulta normal

Vamos avaliar como funcionaria caso fosse realizado uma consulta normal. A imagem representa no eixo x o tempo e no eixo y as threads. O azul é quando a oferta foi inserida. O amarelo é quando a thread realizou a operação de validação. Considerando que a validação amarela recebe os dados no mesmo momento em que é executada, ou seja, todos os dados antes da linha tracejada, as 4 threads vão resultar em apenas 2 ofertas e não 3 ofertas, porque a t1 e a t2 estão considerando a oferta da t4 e estão retraindo suas respectivas ofertas (o vermelho representa o delete).

Consulta uma “foto” do momento após a inserção dos dados

Agora vamos ver o mesmo exemplo, com os mesmos tempos, porém realizando uma consulta de um ponto especifico no tempo, esse tempo seria o momento exato após a inserção dos dados, como se fosse uma foto daquele momento da base de dados. Nesse caso, mesmo que a validação, em amarelo, seja realizado depois, a consulta só vai retornar os dados que foram inseridos em um ponto especifico do tempo, permitindo que no final seja inserido 3 ofertas e não apenas 2 como no exemplo anterior. Vamos ver como fica o código.

Usando o db-after

O código é muito parecido com a primeira versão, utiliza o business para validar se existem mais de três ofertas após inserir a oferta. E a parte interessante é que após inserir a oferta o datomic retorna o :db-after que pode ser extraído para realizar uma consulta de uma foto do momento exato após a inserção da oferta, funcionando como no exemplo.

Teste unitário

Realizando a mesma prova anterior, porém, com o create-offer-v3! é possível assegurar que enviando 100 requests apenas 3 ofertas permaneceram na base de dados. Obviamente em algum momento mais de 3 ofertas existiram, porém foram removidas posteriormente.

Conclusão

A ideia foi apresentar como eu poderia ter resolvido um problema que existia em um micro serviço com Java e MySQL, porém, utilizando Clojure e Datomic. Como vocês podem perceber, não existe bala de prata. Apesar do Datomic permitir que seja executadas funções no transactor, isso pode deixar os inserts mais lentos já que para inserir uma oferta é preciso fazer uma consulta e realizar uma validação dentro do transactor, que não foi escalado horizontalmente como os microserviços. Já na solução usando o db-after, caso algum serviço realize a consulta antes da thread remover o datom duplicado vai acabar vendo mais de três ofertas. Esse é um desafio bastante comum quando queremos garantir integridade e consistência em sistemas distribuídos.

--

--

Cesar Augusto Alcancio de Souza

Sofware Engineer Lead, focused on development and maintenance of products