Como escalar: sistemas financeiros

Adriano Croco
9 min readJun 21, 2021

--

Olá!

Eu gosto muito do assunto performance em se tratando de sistemas. Me fascina pensar em como consigo ser mais eficiente com código e repensando a arquitetura para atender a uma alta carga de processamento.

Além do fator técnico, escala pode ser algo crucial para o sucesso ou bancarrota de um negócio.

O intuito desse texto é passar por alguns cenários e tentar demonstrar a importância de pensar escala como se deve e o mais importante: aos poucos, para facilitar o entendimento.

Como pré-requisito, sugiro a leitura desse texto para melhor aproveitamento.

Imagine que você precisa fazer o sistema de um banco. Por onde você começaria?

É possível resolver esse problema de diversas formas, mas, vamos começar do mais básico: registro de lançamentos.

Vamos super que o core banking da operação precise suportar milhões de operações. Para você construir qualquer serviço financeiro em cima disso (seja transferência, pagamentos ou algo similar), é razoável supor que o extrato e saldo sejam pré-requisitos.

Vamos começar pensando em como representar o lançamento em si de forma sistêmica. O que é o mínimo que precisamos?

Eu sugiro pelo menos o valor do lançamento e se é entrada/saída (ou crédito/débito). Se você nunca pensou em sistemas desse tipo, a dica é: não existe deleção de registros em se tratando de sistemas financeiros, apenas entradas e saídas. Você confiaria em um banco que apaga registros? A idéia é mais ou menos por aí.

A nomenclatura pode confundir também: nesse contexto, crédito é entrada de dinheiro e débito é saída.

Com isso dito, temos:

modelo de dados do lançamento

Restrições importantes: o valor tem que ser sempre positivo e a coluna tipo indica qual a direção do dinheiro. Você pode armazenar valores negativos e fazer a conta de acordo com o tipo do lançamento, caso queira. Como é uma questão de preferência identificar com tipos ao invés de usar números negativos, então, continuemos.

O problema com esse modelo é que não é possível extrair o saldo dessa tabela, porque eu não consigo agrupar por cliente (ou conta, que na minha opinião é uma abstração melhor nesse caso) e sumarizar os valores. Com isso sendo levado em consideração, temos:

lançamento com a adição da coluna IdConta

Para fins de brevidade, assuma que existe uma tabela de contas em algum lugar e está relacionada ao cliente via chaves estrangeiras e afins.

Com esse modelo de dados definido, vamos para a parte de escrita na tabela em si:

fluxo básico — síncrono

Ou seja, é um fluxo síncrono, que usa somente uma base de dados como armazenamento de dados que vem de uma API funcionando com REST ou até SOAP — apesar de ser possível, por favor não use SOAP em 2021.

Com isso em pé e funcionando, vamos inserir alguns valores para termos um ponto de partida:

query para popular alguns lançamentos como exemplo

Agora só falta o saldo: obtido através da sumarização dos valores, agrupados por conta e com os valores de saída (tipo 2), considerados como negativos para que os valores se comportem como uma conta real, com entradas e saídas:

resultado da query de saldo

Pronto. Temos um mecanismo básico de extrato e saldo. Essa query tem um problema crítico que vamos explorar mais pra frente.

Agora imagine que tivemos um pico de acessos simultâneos e o síncrono está gerando gargalos (exemplo: a API cai devido a acessos simultâneos). Podemos fazer uma expansão simples para suportar mais acessos usando uma técnica de indireção e usar uma fila para tornar o processo async:

fluxo assíncrono

Dessa forma, conseguimos os seguintes ganhos: não importa qual seja o mecanismo de fila (seja RabbitMQ, Pub/Sub ou Kafka), nos atende. Eu consigo também fazer a API em uma tecnologia (em NodeJS) e o consumidor da fila (que é quem de fato escreve no banco de dados) em Golang, por exemplo.

Além disso, uma operação de lançar um evento na fila é mais barata em termos computacionais do que escrever no banco de dados. Em ultima instância, usar uma fila é como usar a memória como meio de transmissão de dados, o que gera um aumento de performance no fluxo de escrita como um todo, apesar de tornar a escrita individual de cada registro mais lenta (dado o aumento no número de passos necessários). Ou em termos técnicos, adicionamos overhead.

Partindo do pressuposto que escalar código é mais simples que escalar o banco de dados, vamos para a próxima etapa.

Imagine agora que o banco de dados continua nos atendendo, mas a aplicação mesmo assim continua caindo (e o mecanismo de filas está aguentando), podemos escalar a aplicação… simplesmente colocando mais servidores:

exemplo de fluxo em um modelo on-premise

O grande ponto aqui é que não basta simplesmente colocar 3 instâncias do código para rodar, para melhor aproveitamento é ideal usar um Load Balancer e rotear a carga de acesso de uma forma adequada.

O grande contra dessa solução é que eu terei um custo fixo de 3 servidores online o tempo todo — mesmo que não os utilize — típico de soluções on-premise.

Imaginemos que teremos que escalar a aplicação ainda mais e que ainda sim, a fila e o banco de dados estão atendendo. Podemos escalar ainda mais a aplicação usando autoscaling e kubernetes, o que nos gera algo parecido com isso:

escrita com autoscaling usando kubernetes

Isso atende uma grande quantidade de requisições. Afinal, a quantidade de máquinas irá aumentar conforme a demanda e a quantidade de consumidores, também. Aqui temos os seguintes problemas: a fila vai se tornar um gargalo em algum momento e o banco de dados possivelmente também. A busca por gargalos é contínua, pois sempre haverá o próximo ponto de ineficiência. Ou seja, resolva o problema do código e tenha problema com a fila. Resolva ambos e o banco de dados se torna o próximo gargalo e assim por diante.

Vamos resolver o problema da fila primeiro. Para que ela seja escalada de acordo, o ideal é que ocorra o aumento de instâncias de forma orquestrada com o restante das instâncias de aplicação, para que não ocorre uma quantidade grande de carga sendo usada em uma determinada instância e uma quantidade baixa em outra. Consideremos isso como uma espécie de infra-estrutura semi-atômica (o porque do semi eu explico daqui a pouco), ficando algo mais ou menos assim:

autoscaling de quase tudo

Tudo que está em azul roda dentro da orquestração do kubernetes.

Com isso o meio de processamento das mensagens escala praticamente de forma infinita, a única limitação é dinheiro (em termos de quantidade de máquinas você pode pagar) e obviamente, o limite de servidores de uma determinada região da nuvem (por mais que não pareça, até isso tem limite).

E agora, como resolvemos o problema do banco de dados?

Escalar verticalmente está fora de cogitação. Afinal, por maior que uma única instância de banco chegue em se tratando de recursos (cpu, disco e memória), ela acabará consumindo muitos recursos e tem um limite muito mais fácil de alcançar do que usar uma estrutura horizontal.

Em se tratando de escala horizontal, as técnicas de escala variam de acordo com o que você quer fazer. Caso você queira escalar a leitura e a escrita não é tão importante assim, usamos a técnica de primary/replica ou primary/secondary (por favor, não use master/slave, esses termos são danosos e obsoletos), o que acarreta em algo como isso:

exemplo de mecanismo de replicação

O que acontece aqui é os dados são escritos no banco principal e replicados para outras inúmeras instâncias. Além de uma boa performance de leitura nas réplicas, temos também tolerância a falhas, pois, caso o banco de dados principal fique fora do ar, uma das réplicas pode assumir essa função e passar a agir como banco de dados de escrita. Um ponto a se considerar é quanto tempo leva para que a réplica ocorra — dados desatualizados podem ser um problema.

Recomendo o entendimento do teorema CAP para aprender a lidar com consistência eventual e demais conceitos comuns relacionados a banco de dados distribuídos.

Mas, ainda não é o que a gente precisa. Precisamos escalar a escrita primeiro. Para isso, podemos usar divisão e conquista aplicada a banco de dados, através de uma técnica chamada sharding. Que nada mais é que fragmentar a tabela de uma forma que ao escrever dados nela, quem escreve sabe em qual fragmento o dado será escrito. Para que isso seja possível, é necessário uma tabela de/para gerada a partir de funções hash, processo no qual pode ser representado da seguinte forma:

exemplo de funcionamento de sharding

Com essa técnica sendo utilizada, conseguimos a seguinte arquitetura:

Arquitetura adequadamente escalável com uso de sharding

Nesse caso, além do processamento de mensagens, a escrita no banco também escala: cada pedaço do fluxo de processamento de escrita é indivisível e coeso e pode ser escalado de forma independente de outros pedaços, ao contrário da arquitetura semi-atômica que mencionei anteriormente (que tinha limitação na parte de banco de dados). O ponto de atenção reside em um pequeno detalhe: se cada lançamento vai ser persistido em um fragmento do banco, como que juntamos tudo de forma coerente para ler o saldo depois?

Aqui entra um dos muitos trade-offs da computação: muitas vezes, otimizar a escrita prejudica a leitura e vice-versa.

Vamos retomar a query de saldo para explorar mecanismos mais escaláveis de leitura e tentar melhor a solução da leitura:

resultado da query de saldo

Não é necessário uma quantidade muito grande de acessos para que essa query derrube facilmente uma instância de banco de dados. Lembremos que temos uma agravante no nosso caso: o banco está fragmentado, portanto, para conseguir sumarizar todos os lançamentos de uma conta o SGBD terá que procurar os lançamentos em n fragmentos, agrupá-los, soma-los e exibi-los.

Imagine que a cada acesso de cada conta ao nosso core banking execute essa query… isso escala?

Felizmente, é possível realizar um pré-processamento dessa operação de sumarização e otimizar muito a leitura da seguinte forma:

adição da etapa de sumarização

A adição de uma etapa de sumarização resolve o gargalo de ter que efetuar a sumarização dos lançamentos em cada solicitação de leitura. Eu costumo dizer que em computação não existe a solução certa, mas que existe a errada. Nesse caso, a solução errada passa por usar triggers, pois estamos pensando em escala e geralmente esse tipo de solução adiciona um overhead no banco de dados que gera gargalos rapidamente.

Com essa restrição posta, sugiro a seguinte opção: CDC, que é um software que lê o log de transações do banco conforme ele for sendo escrito de uma forma não intrusiva (ou seja, ele gera baixo overhead). Um exemplo aqui. Com esse mecanismo no ar, é possível capturar essas alterações na tabela de extrato e repassar para o sumarizador que processa a alteração e atualiza a tabela de saldo, com o seguinte modelo de dados:

modelo de dados do saldo

Com isso, conseguimos as seguintes vantagens: temos um processo de atualização do saldo relativamente rápido, com uma escala de escrita razoável e o mais importante: queries baratas! Afinal, com o seguinte select simples obtemos o saldo nessa estrutura proposta:

Select idConta, Valor from Saldo where idConta = <idConta>

É é aqui que está o grande resultado da estrutura complexa de escrita: tornar a leitura barata. Em sistemas corporativos, a maioria das operações são de leitura e não de escrita. Pegue qualquer CRUD corporativo, apesar de 75% das operações ser de escrita (Create, Update e Delete), é a operação de Read que é mais usada na maioria dos casos. Esse é o argumento do Greg Young — Criador do CQRS — a título de curiosidade.

Essa solução proposta não é CQRS (apesar de parecer), pois não usamos Event Bus na estrutura, também a título de curiosidade.

Com tudo isso no lugar, temos uma arquitetura escalável, agora só falta os requisitos não funcionais de um sistema desse no mundo real: segurança, baixo custo de infraestrutura, tempo de resposta, ou seja, todo o resto.

Mas acho que tá bom para esse artigo né?

Espero que você tenha aprendido algo!

Até!

Você gostou do conteúdo e gostaria de fazer mentoria comigo? Clique aqui e descubra como.

--

--