Construindo um serviço web de alta disponibilidade sem banco de dados
(blog.screenshotbot.io)- O Screenshotbot usa o estado em RAM do processo do servidor web como armazenamento de dados, sem um DB separado, e recupera o estado após falhas com snapshots e logs de transações
- Reduz a serialização SQL, idas e voltas ao DB e serviços separados de tarefas em segundo plano para simplificar a experiência de desenvolvimento em um único processo, aproveitando índices em memória e processamento baseado em threads
- A alta disponibilidade é obtida replicando o log de transações em 3 servidores com o algoritmo de consenso Raft; se o líder falhar, um novo líder assume as requisições em poucos segundos
- A implementação real combina Common Lisp, bknr.datastore, o bknr.cluster open source, Baidu Braft e EFS; reinicializações do servidor normalmente acontecem apenas uma vez a cada 1–2 meses
- Mesmo quando o CI de um cliente enterprise conhecido chama a API centenas de vezes a cada commit e Pull Request, o uso de CPU em uma máquina de 4 cores e 16 GB chega no máximo a cerca de 20%, principalmente por processamento de imagens
Uma estrutura que trata o estado em RAM como banco de dados
- Serviços web tradicionais geralmente escolhem um framework web como Rails, Django ou Node junto com um banco de dados como MySQL, PostgreSQL ou MongoDB
- Nos últimos 10 anos, surgiram mudanças de ambiente que tornam essa abordagem digna de ser reavaliada
- Discos ficaram mais rápidos com NVMe
- Discos como EBS/EFS ficaram mais robustos
- RAM ficou mais barata, permitindo que os dados da maioria das startups caibam em RAM
- É possível alugar máquinas com centenas de cores
- Em 2014, o algoritmo de consenso Raft foi publicado, e aumentou o número de implementações robustas
- Esta arquitetura combina o serviço web e a instância de banco de dados, usando o estado de memória do processo como se fosse um banco de dados
- Diferentemente do SQLite, ela não serializa os dados para armazená-los; lida diretamente com objetos e campos em RAM como estado da aplicação
Explore: como simplificar a iteração inicial do produto
- Quando todos os dados estão em RAM, não é preciso serializá-los por meio de consultas SQL, e também se reduz a arquitetura em que vários servidores front-end se conectam a um único DB
- Se a carga aumentar, é possível responder usando um servidor maior, com mais RAM e CPU
- Para índices, em vez de B-trees ajustadas à latência de disco, podem ser usadas estruturas como tabelas hash em memória
- No Screenshotbot, índices usando functional collections foram importantes para a escalabilidade
- Como a leitura de dados é uma leitura em RAM, não são necessárias estruturas especiais nem Async-IO para reduzir idas e voltas ao DB
- Tarefas em segundo plano se tornam threads executadas dentro do mesmo processo grande, e a concorrência pode ser resolvida em grande parte com mutexes em memória e condition variables
Recuperação de falhas: snapshots e logs de transações
- Para lidar com crashes do processo, todo o estado em RAM é salvo periodicamente como snapshot
- Mudanças ocorridas após o último snapshot são primeiro registradas em disco como log de transações, e só então o estado em memória é alterado
foo.setBar(2)registra uma transação dizendo que o campobardefoomudou para 2 e depois altera o campo realnew Foo()registra uma transação dizendo que um objeto Foo foi criado e depois retorna o novo objeto
- Ao reiniciar, o sistema primeiro lê o snapshot e depois reproduz o log de transações para recuperar o estado
- Alterações de índices não precisam entrar no log de transações
- Por exemplo, se houver um índice no campo
bardeFoo,setBaratualiza o índice, e essa atualização também ocorre novamente durante o carregamento do snapshot ou a reprodução das transações
- Por exemplo, se houver um índice no campo
O tipo de código viabilizado por um único processo
- Como todas as requisições são processadas no mesmo processo, é possível armazenar closures na memória do servidor e usá-las no tratamento de requisições de página
- URLs do Screenshotbot no formato
https://screenshotbot.io/n/nnnnnnnsão mapeadas para closures internas - Como a closure mantém referências a objetos, há menos necessidade de passar IDs de objetos ou serializar objetos a cada transição de página
- Depuração, profiling e monitoramento também ficam restritos a um único serviço
- Desaparecem pontos de observação separados de DB, como o MySQL slow query log
- Se um único serviço cair, o site também cai, mas a visão é que, quando há apenas um serviço e um servidor, a probabilidade de falha também é menor
- Se o servidor morrer, a AWS pode subir um novo servidor em poucos minutos
- O código de testes também fica mais simples, pois não é necessário fazer mock do banco de dados
Expand: obtendo alta disponibilidade com Raft
- Quando clientes de alto risco exigem 99,999% de disponibilidade, uma arquitetura de servidor único não é suficiente
- Em caso de falha do servidor, podem ser necessários alguns minutos até a AWS subi-lo novamente
- O processo também pode levar alguns minutos para restaurar o snapshot em disco
- Durante uma nova implantação, a reinicialização do serviço pode deixar o servidor fora do ar por vários minutos
- O algoritmo de consenso Raft replica o log de transações do servidor web/banco de dados, que é uma máquina de estados finita
- Com replicação em 3 máquinas, se o líder falhar, um novo líder é eleito em poucos segundos e continua processando requisições
- Sem mudar muito a forma como o desenvolvedor escreve código, é possível transformar um serviço simples em algo parecido com um banco de dados de alta disponibilidade
- Em uma configuração baseada em Raft, também é possível fazer deploy rolling sem derrubar os servidores
Extract: sharding e gargalos previstos
- Na etapa de atender mais grandes clientes, é possível aplicar sharding, como grandes empresas fazem em bancos de dados
- Cada shard pode se tornar seu próprio cluster
- O Screenshotbot já oferece um cluster dedicado para cada cliente enterprise
- A Meta migrou para Raft para lidar com replicação de clusters MySQL, e o Screenshotbot usa uma abordagem semelhante sem um banco de dados separado
- O principal candidato a gargalo é a escalabilidade da commit-thread
- Threads de leitura paralelizam bem
- Uma única commit-thread aplica as transações uma por uma
- Como o Raft faz commit de várias transações juntas no disco, a latência de disco não é importante
- O ponto de preocupação é quando o custo de CPU para aplicar transações ultrapassa o desempenho de um único core
- Nesse caso, é possível fazer profiling do custo de commit e mover parte do trabalho para fora da thread de transação, ou considerar sharding
A stack real do Screenshotbot
- O Screenshotbot usa Common Lisp
- A implementação inicial usava MySQL, mas ele foi substituído por bknr.datastore porque era difícil lidar com concorrência no MySQL
- bknr.datastore é uma biblioteca que oferece para Common Lisp a estrutura descrita na etapa Explore
- Nessa estrutura, threads dentro de um único processo tratam as requisições web, portanto multithreading forte é importante
- Por causa desse requisito, Ruby e Python são considerados inadequados
- Como closures são mantidas na memória do servidor, é difícil reiniciar o servidor com frequência
- Ao reiniciar, as closures são perdidas
- Em vez disso, o código é atualizado com hot reloading no processo em execução
- Common Lisp oferece recursos como
reinitialize-instance, um padrão para atualizar objetos existentes quando definições de classes mudam
Cluster, armazenamento de arquivos e escala operacional
- Atualmente, reinicializações do servidor normalmente ocorrem cerca de uma vez a cada 1–2 meses
- Quando uma reinicialização é necessária, é feito um rolling restart no cluster Raft
- Cada instalação usa um cluster de 3 servidores, configuração que tolera a queda de 1 servidor
- Kubernetes não é usado atualmente, e ainda não é considerado necessário
- A implementação de Raft é uma biblioteca própria criada sobre o bknr.datastore
- bknr.cluster foi lançado como open source
- Internamente, ele usa o Braft da Baidu
- O Braft lida com snapshots em segundo plano, permitindo que o servidor continue processando requisições durante a criação do snapshot
- Arquivos de imagem ou blobs que não devem entrar no datastore são armazenados em EFS, compartilhado pelos três servidores
- EFS é NFS de alta disponibilidade
- É considerado mais fácil de trabalhar que S3, pois não exige tratar condições de erro separadamente
- Como escreve em disco sem interagir com servidores externos, a testabilidade também é maior
Desempenho atual e escopo de aplicação
- O Screenshotbot atende alguns grandes clientes enterprise e, em especial, um cliente bastante conhecido
- Ele roda no CI desse cliente e recebe centenas de requisições de API a cada commit e Pull Request
- Mesmo com essa carga, uma máquina de 4 cores e 16 GB é suficiente para processar as requisições
- Servidores de réplica também usam máquinas semelhantes e ficam ociosos na maior parte do tempo
- O uso de CPU chega no máximo a cerca de 20%
- A maior parte do uso de CPU vem do processamento de imagens
- É importante não projetar assumindo uma escala maior do que a necessária
- Se você escolher Common Lisp, a configuração relacionada está disponível no Screenshotbot OSS
1 comentários
Opiniões no Hacker News
Essa arquitetura é mais ou menos parecida com a forma como o Nomad, o Consul e o Vault da HashiCorp foram criados (sou um dos mantenedores do Nomad). Com certeza é uma arquitetura peculiar, mas, depois que você se acostuma, a experiência de desenvolvimento é bem boa
Como o estado em memória pode ficar no formato que você quiser, dá para criar diretamente funções de indexação e consulta sob medida para a aplicação. Você até pode usar SQLite
:memory:na FSM do Raft, mas, se conseguir criar ou encontrar um armazenamento transacional em memória (nós usamos nosso próprio go-memdb), a leitura de estado vira simplesmente uma chamada de função. Também é simples evitar leituras obsoletas ou viés de escrita. Como todo objeto escrito tem um índice Raft, dá para criar uma API do tipo “consulte o objeto foo em um follower, mas espere pelo menos até o índice 123”. Isso remove muita da “mágica” que normalmente é empurrada para um RDBMS ou armazenamento externoDito isso, eu teria cautela ao escolher essa arquitetura para uma startup nova fora da área de “infraestrutura”. Na prática, você acaba criando seu próprio banco de dados. É preciso escolher bem ou escrever por conta própria componentes básicos como RPC entre nós, persistência em disco e armazenamento transacional de estado em memória. Upgrades são especialmente difíceis, e o código novo pode tentar escrever no log do Raft entidades que nós em versões antigas não entendem. Pior ainda: a forma de processamento pode mudar e fazer com que nós antigos interpretem algo de maneira errada. Não existe almoço grátis
:memory:na FSM do Raft” era o desenho básico que o rqlite[1] usou nos primeiros cerca de 7 anos. Mas o rqlite migrou para SQLite baseado em disco, e, usando modo WAL ePRAGMA synchronous=OFF[2], ficou tão rápido quanto escrever na RAM, ou perto o suficiente. Com isso, também foi possível evitar as limitações de um banco SQLite:memory:, entre elas o limite máximo de 2 GB. Deveríamos ter usado o modo em disco desde o início, mas só percebemos agoraComo o rqlite usa a mesma biblioteca Raft[3] que o Nomad, talvez você já saiba parte disso
O problema de upgrades existe de verdade. Fico curioso se no Nomad vocês o encontram com frequência em campo. Em 10 anos de desenvolvimento do rqlite, introduzir um novo tipo de Raft Entry foi algo muito raro, e usuários reais só se depararam com isso uma vez. Uma forma de lidar com isso é primeiro distribuir uma versão que entende o novo tipo, mas nunca o escreve, e, depois que essa versão estiver totalmente instalada, atualizar para uma versão que de fato usa o novo tipo. Mas nunca fizemos isso na prática, e também exige disciplina dos usuários finais
[1] https://www.rqlite.io
[2] Pode soar perigoso, mas, no desenho atual do rqlite, na inicialização o banco SQLite principal é totalmente reconstruído a partir do log do Raft (o log do Raft recebe fsync a cada escrita). Então, mesmo que o banco SQLite seja corrompido por uma queda de energia ou algo parecido, isso não tem grande importância, já que o banco SQLite não é o armazenamento de dados autoritativo do rqlite
[3] https://github.com/hashicorp/raft
Dito isso, quando se começa a usar replicação na fase Expand, surgem desafios de engenharia, sim. Ainda assim, são problemas solucionáveis. No Common Lisp, o fato de poder fazer hot reload do código também ajuda, porque torna algumas migrações muito mais fáceis
Décadas atrás, PG escreveu que não usava banco de dados na Viaweb, e que parecia estranho que apps web virassem uma espécie de frontend de banco de dados, quando apps desktop não eram assim[0]. O HN também não usa banco de dados
Mas, como apps desktop e móveis modernos passaram a usar bancos de dados com frequência, geralmente SQLite, isso já não é uma afirmação tão válida. Ficou claro que armazenamento e consultas relacionais de dados são bastante úteis em uma gama muito ampla de aplicações
[0] https://www.paulgraham.com/vwfaq.html
O vwfaq ainda diz que os dados são lidos do disco, e também fala em “iniciar um processo para responder a uma requisição HTTP”. Aqui, “banco de dados” parece significar um servidor separado que persiste os dados, e uma arquitetura em que é preciso se comunicar com outro servidor para buscá-los
Por essa definição, claro que SQLite fica fora da categoria de banco de dados. E, se você já está lendo dados do disco, então está usando um banco de dados ou implementou uma camada de persistência própria ad hoc. A questão é se ler dados do SQLite na inicialização do app ainda conta como usar um banco de dados
O problema dessa linha de raciocínio é que ela ignora que o ponto central de um banco de dados é armazenar e recuperar dados de forma conveniente, sem precisar se preocupar com detalhes de baixo nível. Armazenar dados em um banco de dados não quer dizer subir uma instância do Postgres em algum lugar e buscar os dados pela web. Se você mantém todos os dados em memória e tem um processo que salva snapshots em disco com uma estrutura de dados baseada em log, parabéns: você acabou de criar seu próprio banco de dados
Em segundo lugar, nos últimos 20 anos as consultas em RDBMS ficaram muito mais simples. Surgiram todos os tipos de ORMs e mapeadores de linhas que reduzem código repetitivo
Também surgiram recursos avançados, como busca de texto completo, úteis para apps desktop e móveis. Hoje, usar um RDBMS em um app desktop é uma boa escolha
A web ficou muito maior, e muitas dessas práticas provavelmente não funcionariam hoje. Se alguém empurrasse uma correção ao vivo para uma máquina de produção, com o nível atual de exigência de testes, enquanto fala com um cliente no telefone, muitos aqui questionariam meu estado mental
Entendo a vontade de experimentar algo interessante, mas fazer isso para evitar aprender o básico de MySQL ou Postgres parece uma enorme perda de tempo. É só construir em cima disso e pronto, especialmente se estiver rodando em nuvem pública
Não compro os argumentos sobre aumento de latência de ida e volta ou problemas de concorrência. Para o segundo caso, há soluções simples, como tunagem básica ou isolar clientes barulhentos. Em outro post do blog deles, eles falam da possibilidade de adicionar 10 milhões de linhas por dia e de desafios de indexação, mas isso literalmente não é nada. Mesmo 10 vezes isso, na minha opinião, ainda não justificaria fazer engenharia de uma solução sob medida
Até que isso seja realmente necessário, “worse is better” está certo; e, quando chegar a hora, você saberá com clareza. Nesse ponto, também saberá qual é o gargalo, e poderá reagir de forma mais inteligente do que superdimensionar tudo desde o início
Mas é desconcertante o blogueiro dizer que não escolhe SQLite porque ele pode ter recursos desnecessários. Isso é absurdo e não justifica nada
O texto soa como se partisse de uma solução ruim para um problema hipotético e depois fizesse uma tentativa desesperada de empilhar argumentos forçados para rejeitar a solução óbvia
Fico pensando se estou errado, ou se esse esforço extra, controverso mas aparentemente desnecessário, faz parte do produto e é uma forma de vencer nesse espaço
Começa com “não estou falando de algo como SQLite, em que os dados ainda são serializados”, mas acaba em um log de transações próprio que precisa de serialização e também de replicação, o que é estranho. Replicação de banco de dados já funciona assim mesmo.
Se toda a carga vai para um único servidor, basta rodar o banco de dados nesse servidor e esquecer coisas como “uma arquitetura especial para reduzir idas e vindas ao banco de dados”. Se todos os dados cabem na RAM, se quiser use um ramdisk para o banco de dados e replique para armazenamento persistente com ferramentas padrão. Isso é de fato simples.
Então, basicamente, parece https://litestream.io/. Graças à configuração explícita com Raft, o failover talvez seja mais rápido. Não sou usuário do Litestream, então não sei as diferenças sutis, mas soa muito parecido.
Apesar dessa simplificação excessiva, gosto bastante da ideia em si, e acho que o texto defende os conceitos razoavelmente bem. Em muitos sistemas, mesmo que tenham um sucesso anormal, isso escalaria o suficiente para sustentar a maior parte ou até todo o negócio, e o desempenho seria absurdamente bom comparado a praticamente qualquer alternativa.
Basta dar um cold start no banco de dados e executar um
selectbem grande duas vezes.Bancos de dados transacionais são simples nas fases Expand e Extract, mas na fase Explore viram uma carga extra, porque fazem você se concentrar em problemas de infraestrutura em vez do produto. Na fase Explore, não há clientes, então não há dados, e a confiabilidade dos dados não é importante.
A abordagem de manter tudo em memória com bknr.datastore (sem replicação) é simples na fase Explore. Mas, ao entrar na fase Expand, surge a carga operacional de garantir consistência dos dados.
Ainda assim, quando você chega à fase Expand, já validou o produto e escreveu bastante código. Reescrever tudo para um banco de dados transacional não faz sentido; é mais fácil adicionar replicação Raft por cima.
A lógica deste texto é desconcertante. Isso seria uma forma de tornar aplicações com estado mais simples e mais rápidas?
As premissas são fracas e as afirmações, exageradas. O autor exagera a dificuldade da serialização para fazer um argumento fraco parecer mais forte.
Pessoas assim geralmente são difíceis de trabalhar. Que bom que encontrei uma startup para afundar, de modo que eu não precise lidar com elas.
Ao iniciar um novo projeto, a estrutura de dados costuma ser uma “lista de itens com atributos”. Por exemplo, estou escrevendo agora um app de fitness, e os dados são uma lista de exercícios; cada exercício tem título, descrição, URL de vídeo e outros atributos.
Normalmente começo colocando esses itens em arquivos YAML no diretório
data. Na prática, é um dialeto YAML customizado, sem as esquisitices do YAML original. Cada valor é uma string, sem conversões mágicas de tipo. Criar um novo item é só fazervim crunches.yamle inserir os dados; editar e excluir também é muito fácil nessa estrutura de dados.Quando o projeto cresce, normalmente crio um schema de DB e movo os itens para MariaDB ou SQLite.
Desta vez, pretendo mover os itens (exercícios) para uma coluna JSON em um DB SQLite. Vou armazenar todos os atributos de um item em um único campo JSON e escrever um pequeno explorador de DB que permita editar campos JSON como se fossem YAML. A ideia é manter a conveniência de editar dados legíveis por humanos.
Escrever o explorador de DB parece bem direto. Com um pouco de ncurses, dá para navegar pelas tabelas, selecionar uma, navegar pelas linhas e inserir ou excluir linhas. Ao editar um campo, ele abre o Vim. Se o campo for JSON, converte para YAML antes de enviar ao Vim e, quando o usuário sai do Vim, converte de volta para JSON.
O que foi descrito no começo do texto era basicamente parecido com a forma como máquinas NUMA funcionavam (por exemplo, SGI Altix ou UV). Além disso, as vantagens que eles alegavam eram baixa latência e a capacidade de paralelizar o trabalho com multithreading em uma grande RAM. Clustering surgiu como uma alternativa de baixo custo a máquinas de mais de 1 milhão de dólares. Também há semelhanças com a persistência do AS/400, em que, se o app simplesmente escreve na memória, isso é mapeado de forma transparente para o disco.
Agora estamos meio que voltando no tempo, com hardware barato, para as vantagens de máquinas NUMA em cluster. Houve melhorias nesse processo, e o texto foi uma leitura interessante.
Outra técnica antiga era remover a pilha TCP/IP dentro do cluster, eliminando os problemas associados. Soluções como Active Messages eram uma camada fina sobre o hardware. Também existem projetos de roteadores de rede com consistência forte embutida. Há bastante coisa que eles podem fazer.
Se crescerem, há também oportunidades de hardware. No lado de CPU, a SGI fez duas coisas. As máquinas NUMA aumentaram o número de CPUs e a quantidade de RAM em um único sistema, e permitiram plugar FPGAs diretamente no barramento de memória para usá-los como aceleradores customizados. Por fim, alguns artigos de ciência da computação modificaram o conjunto de instruções do processador, a rede interna do chip etc. para eliminar ou reduzir gargalos de multithreading. Chips como o OpenPiton aumentam o número de núcleos com cores abertos e customizáveis (por exemplo, 32).
“Imagine as coisas incríveis que poderíamos construir se não precisássemos serializar dados em consultas SQL” já existe em implementações suficientemente maduras do modelo de atores[0], por exemplo no Akka Event Sourcing[1]. Também aborda o seguinte problema:
“Mas a parte importante é como se recuperar quando um processo sofre crash. A resposta é fácil: basta tirar snapshots periódicos de toda a RAM”
Isso é resolvido essencialmente sem precisar criar uma “nova arquitetura para desenvolvimento web”. Também há tentativas open source de explorar o protocolo RAFT usando atores aqui[2] e aqui[3]
0 - https://en.wikipedia.org/wiki/History_of_the_Actor_model
1 - https://doc.akka.io/docs/akka/current/typed/persistence.html
2 - https://github.com/Michael-Dratch/RAFT_Implementation
3 - https://github.com/invkrh/akka-raft
Se você escolher algo como Cosmos DB, MongoDB ou DynamoDB como provedor de persistência, também pode consultar o estado persistido
https://learn.microsoft.com/en-us/dotnet/orleans/grains/grai...
https://learn.microsoft.com/en-us/dotnet/orleans/grains/tran...
https://learn.microsoft.com/en-us/dotnet/orleans/grains/even...
Meu primeiro pensamento foi: “Ah, é o jeito que eu fazia quando escrevia em Common Lisp; que divertido alguém ter redescoberto essa técnica”
Mas não era isso; havia simplesmente mais gente de Lisp
“RAM é muito barata” talvez seja um dos maiores equívocos dos desenvolvedores
SSDs melhoraram de 100 a 10000 vezes em throughput ou IOPS, e o desempenho por dólar das vCPUs também subiu de 20 a 50 vezes. Saímos de 45/32 nm e chegamos agora a 5 nm/3 nm, e o número de instruções por ciclo também aumentou muito
Mas o preço da RAM quase não caiu tanto quanto CPU ou SSD. Ela pode ter ficado muito mais rápida, é possível instalar muito mais memória com chips de maior densidade, e os canais podem ter aumentado de dual para 8 ou 12. Mas, olhando os preços spot de DRAM de 2008 a 2022, o preço mínimo da DRAM ficou três vezes no mesmo patamar, cerca de US$ 2,80/GB. No mesmo período, dentro dos ciclos, oscilou entre US$ 6 e US$ 8/GB. Ou seja, se você comprou DRAM nos pontos mínimos ou máximos dos últimos cerca de 15 anos, ignorando a inflação, o preço provavelmente foi parecido, na faixa de aproximadamente ±10–20%
Só em meados de 2022 a barreira de US$ 2,80/GB foi rompida, caindo para perto de US$ 1/GB, antes de se estabilizar em cerca de US$ 2/GB para DDR5
Hoje dá para colocar 4 TB de RAM em um servidor. Isso não significa que DRAM seja absurdamente barata. O desenvolvedor médio, ou o desenvolvedor de big tech, passou a ganhar muito mais do que em 2010, então sente que RAM ficou muito mais acessível. Na prática, mesmo no ponto mais baixo dos últimos 15 anos, a queda no preço da DRAM foi, no máximo, pouco mais de 2 vezes. E há uma grande chance de os preços da DRAM dispararem de novo em 1 ou 2 anos
Como exemplo simples, se há 20 anos um determinado nó fosse limitado a 16 GB de RAM, seriam necessários 256 nós para obter 4 TB de RAM no sistema (excluindo o overhead de cada SO)
Hoje, um único nó consegue colocar esses 4 TB inteiros em um só chassi
O custo total dos próprios chips de RAM pode não ter mudado, mas o custo de realmente usar essa RAM em um sistema físico caiu drasticamente