mais lidos
Life at Nu
Conheça a sede do Nubank em Pinheiros, São Paulo/Brasil jan 11
Design
A nova aparência do Nubank: conheça nossa nova logo maio 17
Culture & Values
Como os valores e a cultura da Nu moldam os produtos que criamos ago 7
Carreiras
Reunimos grandes mentes de diversas origens que permitem a discussão e o debate e melhoram a resolução de problemas.
Saiba mais sobre nossas carreiras



A conta digital do Nubank é criada usando a mesma stack fantástica que suporta os nossos outros produtos: Clojure, Datomic e Kafka, todos instalados na nuvem da AWS. Na equipe que cria a conta do Nubank, também somos grandes fãs do event sourcing.
Event sourcing é uma expressão sofisticada que significa uma ideia simples: em vez de armazenar o estado atual do sistema em um banco de dados, armazenamos cada passo da história até agora. Mais tarde, se necessário, podemos sempre examinar o armazenamento para responder a qualquer pergunta ou até acessar a lista inteira para obter uma visão “stateful” atualizada.
Isso soluciona muitos problemas que surgem com os sistemas distribuídos. Nós usamos muitos microsserviços no Nubank, e mantê-los consistentes entre si é um problema complexo. Descobrimos que o event sourcing nos permite dividir as coisas de uma forma que mantém essa complexidade em um nível confortável.
Esta é uma história sobre o momento em que a nossa abordagem de eventos começou a gerar situações de memória insuficiente, e como solucionamos isso sem abrir mão da consistência.
Contar dinheiro
Uma das principais características de uma conta bancária é manter a contagem atualizada de quanto dinheiro você tem nela. Obviamente, também queremos permitir que você deposite mais dinheiro e possa sacá-lo.
Depositar e sacar dinheiro do Nubank envolve algumas integrações complexas, que são muito interessantes, mas eu gostaria de falar sobre a parte de “manter a contagem atualizada”. Nesse caso, a contagem é o saldo da conta.
Uma implementação ingênua de um serviço de saldo seria assim:
Parece simples, não é?
Mas como você faria um extrato com isso? Como depurar se alguma coisa der errado? Como garantir que você nunca adicione ou remova um valor duas vezes?
Eu não vou entrar em detalhes, mas uma abordagem com event sourcing soluciona todos esses problemas (e alguns outros também). Então, eis uma forma de criar um serviço de saldo com event sourcing:
Essa é uma definição simplificada, mas suficientemente próxima. É assim que eu geralmente explico o nosso sistema nas palestras. Quando abrimos as perguntas, sempre surge uma:
“Você lê a lista inteira toda vez que alguém pede o saldo? Isso não é demorado demais?”
A minha resposta costumava ser, “está funcionando bem até agora, obrigado por perguntar”, mas isso já foi há alguns meses.
Confira nossas oportunidades de trabalho
Conheça nossas oportunidades
Sem memória
Conforme descobrimos, a velocidade não era um problema, mas os requisitos de memória se tornaram um problema. Para entender o motivo, precisamos falar um pouco sobre o Datomic.
O Datomic não é um banco de dados SQL tradicional, mas ele provavelmente também não é o que você pensa quando lê o jargão “NoSQL”. Ele oferece transações ACID totalmente consistentes e tem vários superpoderes já na configuração padrão. Temos trilhas de auditoria simples, e nunca perdemos quaisquer dados. Ele é muito impressionante, mesmo depois de trabalhar com ele por alguns anos.
Por mais que sejam interessantes, as complexidades do modelo de dados do Datomic não são muito importantes para este problema. O que é relevante é uma decisão de arquitetura específica: O Datomic separa as tarefas de gravar, armazenar e consultar dados. Em um servidor SQL tradicional, uma única máquina realiza as três tarefas. Com o Datomic, nós podemos ter (e geralmente temos) três máquinas ou clusters diferentes, um para cada tarefa.
Armazenar dados parece ser uma parte essencial de um banco de dados. De certa forma, é mesmo, mas o Datomic não soluciona esse problema diretamente. Em vez disso, ele nos permite descarregar isso para um serviço separado, como o DynamoDB.
A tarefa de gravar dados fica por conta de um processo específico, chamado de “transactor” (negociador). Todas as gravações passam por esse funil e são serializadas, processadas e validadas. Se tudo correr bem, a nossa transação é aceita e o resultado é persistido na nossa camada de armazenamento.
Como todos os dados estão na camada de armazenamento, eles podem ser acessados de qualquer lugar. Isso significa que podemos ter tantos processos de consulta quanto quisermos, todos pesquisando, filtrando e retornando os dados de interesse de forma independente. Estes processos somente leitura são chamados de peers (pares).
No Nubank, os nossos pares são as próprias instâncias dos nossos microsserviços. O mesmo contêiner, a mesma JVM que executa nosso código de aplicação, também está executando consultas, carregando coisas do DynamoDB para um enorme cache na memória, carregando dados preemptivamente antes mesmo de pedirmos isso. Isso significa que as consultas podem ser extremamente rápidas, porque a maioria dos dados de que você vai precisar estão na memória, então nenhuma viagem pela rede é necessária.
E isso também pode consumir muita memória. Nós já vimos o nosso serviço de saldos alocando vários gigabytes em alguns segundos.
Então, começamos a ver travamentos. Máquinas ficando sem memória, pausando por vários minutos para fazer a coleta de lixo. Primeiro, nossa reação foi aumentar a memória disponível para essas instâncias. A princípio, isso deu certo.
Quando chegamos a instâncias com 100 GB que estavam por um fio, percebemos que não dava mais para manter essa abordagem.
Portanto, reunimos uma força-tarefa. Criadores de perfis foram a arma escolhida, e muitos testes de carregamento foram realizados. Cortamos megabytes, otimizamos as consultas. Houve paz por algum tempo. Infelizmente, isso não durou.
O problema real estava em dois fatos básicos sobre o nosso sistema:
Nenhuma instância poderia ser grande o suficiente para manter todo o banco de dados no cache de memória, mas ainda assim, todas as instâncias tinham que conter os dados necessários para todos os clientes a cada momento. Todas as instâncias estavam gastando muito tempo e memória refazendo trabalho que alguma outra máquina já havia feito. O tempo todo. Para todo mundo.
Se pudéssemos fazer essas máquinas compartilharem uma parte do trabalho, ou melhor ainda, não deixar que refizessem o trabalho desnecessário, então haveria alguma esperança. Isso significava adicionar uma camada de cache. Mas o que é que costumam dizer sobre o cache mesmo?
Uma das coisas difíceis
Devo admitir, eu tenho um pouco de medo dos caches. Eu sei que eles existem e sei que é possível fazê-los funcionar. Eu também sei que é possível criar um programa seguro com múltiplas threads em assembly, mas prefiro não fazer isso, se puder evitar.
Eu já vi implementações de cache em que várias pessoas examinavam e revisavam o código, concluindo que ele estava correto, e ainda assim nós encontrávamos estados inválidos na produção.
Uma piada famosa sobre caching é atribuída a Phil Karlton:
“Só existem duas coisas difíceis em Ciência da Computação: invalidação de cache e nomear coisas.”
E essa piada está mesmo correta: a parte difícil não é criar o cache; a parte difícil é a invalidação do cache. E se nós pudéssemos criar um cache que nunca precisasse ser invalidado?
Nós, programadores em Clojure, nos gabamos sobre as nossas estruturas de dados prontas e imutáveis. Nunca perdemos qualquer coisa até não precisarmos mais dela, e nesse momento, podemos apenas… abandonar a referência, esquecê-la, confiar no coletor de lixo e não olhar para trás.
Será que poderíamos criar um cache que funcionasse desse jeito? Seria possível contornar a parte difícil, em vez de resolvê-la?
Armazenamento imutável
Pense nisto: um cache em que qualquer referência que você teve aponta para nada (um cache vazio) ou para algo imutável e, portanto, válido em algum momento do tempo. Quando o mundo muda, nós mudamos a referência, não aquele valor. A nova referência também deve apontar para nada ou para um novo valor imutável e válido.
É difícil rastrear de onde vêm as ideias, mas olhando para trás, acho que duas fontes tiveram mais influência.
Conceitos, técnicas e modelos de programação de computador é um livro incrível, escrito por Peter Van Roy e Seif Haridi. Um dos primeiros construtos que você aprende nesse livro é a variável lógica. As variáveis lógicas só podem ser atribuídas uma vez – elas são desvinculadas ou vinculadas a um valor específico para o resto do tempo (até o programa terminar). As variáveis lógicas me lembram de futuros e promessas e funções memorizadas.
Esse livro também mostra como usar variáveis lógicas para criar belos sistemas concorrentes com comportamento totalmente determinístico. Ocorre que se as suas variáveis se tornarem imutáveis após o vínculo e o processo que as vincula for determinístico, a concorrência é bem segura!
A outra grande inspiração foi o próprio Datomic. O Datomic armazena dados (em qualquer camada de armazenamento que queiramos usar) na forma de uma grande árvore de nós imutáveis. Cada nó imutável é identificado por uma UUID. Sempre que precisa fazer uma alteração na árvore, ele pode simplesmente criar novas versões dos nós que precisam ser modificados, até a raiz da árvore, e, finalmente, fazer uma troca atômica para a parte do armazenamento que contém a referência para a raiz atual da árvore.
Agora, imagine que um par do Datomic precisa executar uma consulta. Ela inicia na raiz da árvore, faz uma certa lógica de indexação e decide que os dados necessários estão no nó com a UUID EAE4D22F-CE79–47C4-AE5A-759A46B4D166. Agora, suponha que esse nó já esteja carregado no cache de memória. Como ele pode saber se o nó ainda é válido?
É claro que é válido! Ele é um valor imutável! Desde que iniciemos pela referência correta, certamente chegaremos a valores válidos. O Datomic, nos bastidores, tem o tipo de cache que estávamos procurando!
Outra documentação sugestiva do Datomic é esta:
“O Datomic sempre vê um indicador de registro correto, que foi colocado via um put condicional. Se algum dos nós da árvore ainda não estiver visível sob o indicador, o Datomic fica consistente, mas parcialmente indisponível, e ficará totalmente disponível quando isso eventualmente acontecer.”
Isso se parece com uma variável lógica desvinculada, que será vinculada assincronamente por alguma outra thread. Desde que aquela outra thread faça seu trabalho de forma correta e determinística, estaremos seguros!
Então, eis o que precisamos:
Qualquer armazenamento de chave-valor funcionará bem como armazenamento imutável. Nós decidimos usar o DynamoDB, pois já estamos acostumados com ele no Nubank.
Nós já sabíamos como computar os resultados que queríamos do banco de dados: estamos fazendo isso sempre que alguém faz uma solicitação HTTP. Então, uma estratégia simples que podemos usar é um cache com leitura: procurar os dados no cache, e se não estiverem lá, computá-los do zero, e então gravá-los no cache e retornar.
Agora só falta aquela referência mágica que poderemos usar como chave para o nosso armazenamento imutável. Como podemos obtê-la?
Números de versão
O Datomic foi um bom modelo até agora, vamos ver se conseguimos roubar mais algumas ideias dele. Qual é a referência válida do armazenamento do Datomic?
A resposta está naquele local específico de armazenamento que só é atualizado usando solicitações put condicionais fortemente consistentes: a chave para o nó raiz da árvore. Essa UUID aponta para a versão mais recente dos dados. Se você seguir a referência, tudo o que você vai encontrar estará atualizado. Qualquer outra coisa que você pode encontrar na camada de armazenamento é lixo antigo.
Essa ideia de identificadores de versão aparece em muitos lugares como uma forma de identificar coisas exclusivamente: repositórios do git, gerenciadores de pacote, blockchains. Eles podem ser hashes criptográficos ou nomes atribuídos por alguma autoridade central. Isso não importa, desde que eles nomeiem valores imutáveis.
O Datomic tem um tipo de número de versão que pode ser usado pelo código de aplicação. Ele é chamado de basis-t, uma referência a um momento no tempo no banco de dados. É possível compartilhar uma basis-t entre seus pares para garantir que estejam visualizando a mesma versão dos dados. A basis-t pode ser usada para visualizar o passado (ou seja, consultar o banco de dados como ele era naquele momento) ou para esperar pelo futuro (ou seja, bloquear uma thread até o par local ter recebido todos os dados até aquele momento no tempo).
E se usarmos esse número de basis-t como nossa referência?
Bem, cada transação faz o relógio do Datomic avançar, cada transação aumenta esse número de versão. Então, o seguinte aconteceria:
Então, se usarmos a basis-t como parte da nossa chave de cache, perderemos os nossos caches com muita frequência, sem um bom motivo para isso. Todas essas consultas que estamos fazendo examinam os dados de uma conta de cada vez. Perder o cache da conta A por causa de um depósito na conta B é tolice.
Podemos fazer melhor?
Contador no nível da conta
Sim!
Só o que precisamos fazer é adicionar um atributo à nossa entidade conta no Datomic. Esse atributo começa em zero. Cada vez que fazemos qualquer coisa relacionada a essa conta, aumentamos esse atributo em 1 na mesma transação. Isso nos dá um número de versão para os dados da conta, que podemos usar como chave de cache.
Como esse atributo acaba contando cada evento que acontece em uma conta, nós demos a ele um nome muito original: contador de eventos.
A estrutura geral do nosso cache com leitura é assim:
Se recebermos um novo depósito ou saque da conta 2042, aumentaremos o contador de eventos, e isso levará a um cache vazio na próxima solicitação. Isso é inevitável, pois o saldo atual de fato mudou! A parte importante é que isso não acontecerá por motivos desnecessários, como acontecia quando tentamos usar a basis-t.
O cache e o banco de dados
Nós geralmente pensamos no cache como algo que fica na frente do serviço atual, protegendo-o contra as solicitações. É isso que os CDNs fazem, por exemplo. Esse tipo de cache nos permite continuar servindo conteúdo mesmo se o serviço real estiver completamente inativo.
Porém, isso só funciona bem para conteúdo estático. Se precisarmos de dinamismo, interação e ações que vão além de qualquer estado que podemos manter no lado do cliente, teremos que executar código de aplicação no lado do servidor de qualquer maneira.
Se esse for o tipo de aplicação que estamos executando, e se a consistência for um requisito importante, faz sentido obter alguma cooperação do servidor e seu banco de dados, mesmo quando estivermos tentando servir a partir do cache.
Observe a diferença: em um CDN, consultamos o cache primeiro, e então vamos para o servidor de fato, se o cache não tiver a resposta. No esquema que descrevemos acima, a primeira coisa que fazemos é consultar o banco de dados para obter o contador de eventos. Só então podemos procurar um cache, e depois disso, reverter para o banco de dados e coletar o resto dos dados.
Podemos armazenar em cache praticamente qualquer parte de dados, mas o banco de dados ainda fornece aquela base sólida que mantém tudo consistente.
Faz sentido para o nosso caso de uso, porque a “disponibilidade a qualquer custo” não ajuda os nossos clientes. Do que adianta ver um saldo obsoleto de sua conta bancária, se eles não poderão fazer nada com isso, já que os servidores estão inativos?
Em vez de substituir o serviço, nosso primeiro objetivo com o cache era permitir que ele funcionasse usando menos recursos. E conseguimos isso: cada instância do nosso serviço exigia 90 GB de RAM, mas agora precisa de apenas 55 GB. E ainda melhor: agora somos capazes de escalar o serviço horizontalmente se a carga aumentar, porque o trabalho é efetivamente distribuído entre instâncias. A latência também diminuiu para a maioria das solicitações, o que é legal, embora não tenha sido o objetivo principal.
Com essa abordagem, conseguimos reduzir drasticamente a quantidade de trabalho repetido que fazíamos nos nossos servidores. Isso também nos permitiu suportar uma carga maior com máquinas menores, tudo sem abrir mão da consistência.
Agora, só temos que encontrar uma forma de evitar dar nomes às coisas!
(Mais artigos de Gustavo Bicalho)
(Foto por Samuel Scrimshaw em Unsplash)
Conheça nossas oportunidades