1 pontos por GN⁺ 2025-04-29 | 1 comentários | Compartilhar no WhatsApp
  • Explica como construir uma estrutura que usa um banco de dados separado para cada tenant no Rails e os desafios desse processo
  • O ActiveRecord foi projetado, por padrão, assumindo uma única conexão com banco de dados, então alternar conexões por tenant é algo complexo e delicado
  • Propõe uma forma de usar o recurso connected_to do Rails 6+ para alternar conexões dinamicamente em tempo de execução
  • O SQLite3 é adequado para lidar com muitos bancos de dados pequenos e independentes, facilitando backup, depuração e remoção
  • Destaca que, diferentemente da infraestrutura do Rails, que evoluiu focada em otimização para sistemas de grande escala, também é possível uma arquitetura centrada em bancos de dados pequenos e independentes

Por que usar um banco de dados separado para cada tenant

  • Ao separar por unidade de tenant (Site), que opera de forma independente dentro do modelo de dados, o isolamento e o gerenciamento dos dados ficam mais simples
  • Armazenar os dados de cada tenant em um banco separado também é vantajoso para expansão em sites de grande porte e para questões de segurança
  • Com SQLite, é possível operar o banco de dados com apenas um arquivo, sem configuração de servidor, o que traz simplicidade e flexibilidade

O que torna isso difícil no Rails

  • A operação básica de open/close do SQLite é muito simples, mas o ActiveRecord possui internamente uma estrutura complexa de gerenciamento de conexões
  • O ActiveRecord foi projetado com uma estrutura em que a conexão fica vinculada ao modelo, o que dificulta a troca de tenant em tempo de execução
  • Pool de conexões, cache de consultas e cache de esquema dependem todos da conexão, então mudar a conexão a cada vez é custoso

Histórico do gerenciamento de múltiplos bancos de dados no Rails

  • Rails 1: era possível definir o banco por ActiveRecord::Base
  • Rails 3: introdução do pool de conexões
  • Rails 4: adição de connection_handling
  • Rails 6: introdução de connected_to
  • Rails 7: expansão do connected_to e suporte a sharding
  • Mesmo assim, cenários como “adicionar/remover tenants dinamicamente em tempo de execução” ainda não são suportados nativamente

Vantagens de bancos de dados por tenant

  • Como é possível fazer backup ou restauração apenas do arquivo de cada tenant, operação e depuração ficam mais simples
  • Remover um tenant pode ser tão simples quanto apagar o arquivo (unlink)
  • Servidores de banco de dados de grande porte são otimizados para bancos na casa de dezenas de terabytes, enquanto o SQLite é otimizado para milhares de pequenos bancos de dados
  • Na prática, a iCloud também adota uma estrutura em que milhões de pequenos bancos SQLite são armazenados sobre Cassandra

Processo de resolução do problema

  • A abordagem anterior (establish_connection manual) causava erro ConnectionNotEstablished em ambientes com múltiplas conexões
  • Seguindo o modelo do Rails 6+, a estrutura foi alterada para deixar o Rails cuidar disso, em vez de gerenciar manualmente o pool de conexões
  • Um connection pool é criado dinamicamente para cada tenant, e o trabalho é encapsulado com um bloco connected_to
  • Com middleware, a abordagem foi aprimorada para preparar e liberar dinamicamente a conexão de banco necessária no momento da requisição

Padrão central de código

  • Verifica o pool de conexões e o cria caso não exista
MUX.synchronize do  
  if ActiveRecord::Base.connection_handler.connection_pool_list(role_name).none?  
    ActiveRecord::Base.connection_handler.establish_connection(database_config_hash, role: role_name)  
  end  
end  
  • Após conectar, executa as consultas com segurança dentro do bloco connected_to
ActiveRecord::Base.connected_to(role: role_name) do  
  pages = Page.order(created_at: :desc).limit(10)  
end  

Tratamento de streaming no Rack

  • Quando a resposta do Rack é em streaming, a conexão é fechada com segurança usando Rack::BodyProxy e Fiber para controlar o gerenciamento da conexão
connected_to_context_fiber = Fiber.new do  
  ActiveRecord::Base.connected_to(role: role_name) do  
    Fiber.yield  
  end  
end  
connected_to_context_fiber.resume  
  
status, headers, body = @app.call(env)  
body_with_close = Rack::BodyProxy.new(body) { connected_to_context_fiber.resume }  
  
[status, headers, body_with_close]  

Estrutura final do middleware

  • Foi criado o middleware Shardine::Middleware, que a cada requisição encontra a conexão de banco adequada, alterna com connected_to e faz a limpeza ao fim da resposta
  • Pode ser aplicado no arquivo config.ru do projeto Rails da seguinte forma
use Shardine::Middleware do |env|  
  site_name = env["SERVER_NAME"]  
  {adapter: "sqlite3", database: "sites/#{site_name}.sqlite3"}  
end  

Desafios restantes

  • No ActiveRecord 6, o recurso shard ainda não foi usado, mas em versões posteriores também será possível separar leitura e escrita
  • A limpeza do pool de conexões ao remover tenants ainda não foi implementada, porque ainda não se mostrou necessária
  • No futuro, arquiteturas que lidam com “muitos bancos de dados pequenos” podem ganhar ainda mais atenção

1 comentários

 
GN⁺ 2025-04-29
Opiniões no Hacker News
  • Usam a abordagem de "database-per-tenant" com cerca de 1 milhão de usuários

    • Essa abordagem é adequada para apps centrados em leitura, e a maioria dos tenants é pequena e não tem muitos registros por tabela, então até joins complexos são muito rápidos
    • O principal problema é que cada banco de dados precisa ser migrado individualmente, então o tempo de release pode aumentar bastante
    • Se ocorrer drift de esquema ou de dados, o release para e é preciso descobrir por que a funcionalidade não está funcionando em alguns tenants
  • Gosta de SQLite, mas se pergunta se bancos de dados OLTP tradicionais precisam descarregar parte dos índices da memória

    • Com um banco de dados por usuário, nada precisa ser mantido em memória para usuários inativos ou para usuários ativos apenas em outras instâncias
    • Isso é semelhante à situação de JSON no Mongo, e o Postgres é duas vezes mais rápido que o Mongo
  • A maioria das pessoas não precisa de banco de dados por tenant, e essa não é a abordagem comum

    • Existem casos específicos em que isso compensa as desvantagens, como migrações e drift de esquema
    • Só porque você pode usar não significa que deve usar
    • É preciso avançar com cuidado e saber que realmente precisa de banco de dados por tenant
  • Como abordagem intermediária, dá para considerar o seguinte

    • Identificar os N principais tenants
    • Separar os DBs desses tenants
    • Os N principais são definidos com base em IOPS, importância (em termos de receita) etc.
    • O modelo de dados deve ser projetado para permitir extrair as linhas correspondentes a cada tenant
  • Por coincidência, está trabalhando no FeebDB para Elixir

    • Pode ser visto como uma alternativa ao Ecto, que não funciona bem quando há milhares de bancos de dados
    • Começou principalmente como um experimento divertido, mas teria sido muito útil em todos os lugares onde trabalhou no passado
    • O objetivo é eliminar ou reduzir os problemas típicos da abordagem de banco de dados por tenant
    • Garantia de um único writer por banco de dados
    • Gerenciamento de conexão aprimorado para todos os tenants
    • Suporte a migrações e backups quando necessário
    • Suporte a operações de map/reduce/filter em vários DBs
    • Suporte a implantação em cluster
  • O Forward Email faz algo parecido usando um db sqlite criptografado para cada mailbox/usuário

    • É uma ótima forma de diferenciar a proteção por usuário
  • O nome é excelente. Lembra o Sean Connery

  • O workflow de "database per tenant" ainda está só começando

    • James Edward Gray falou sobre isso na RailsConf de 2012
  • Já usou algo parecido no passado e ficou muito satisfeito

    • Se o usuário quiser os dados, é possível fornecer o banco de dados inteiro
    • Se o usuário apagar a conta, dá para resolver simplesmente com rm username.sql
    • Compliance fica muito mais fácil
  • Quando os dados ficam isolados entre si e não há problemas de escala dentro de um único tenant, é difícil errar no design

    • Quase qualquer coisa vai funcionar