- 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
Opiniões no Hacker News
Usam a abordagem de "database-per-tenant" com cerca de 1 milhão de usuários
Gosta de SQLite, mas se pergunta se bancos de dados OLTP tradicionais precisam descarregar parte dos índices da memória
A maioria das pessoas não precisa de banco de dados por tenant, e essa não é a abordagem comum
Como abordagem intermediária, dá para considerar o seguinte
Por coincidência, está trabalhando no FeebDB para Elixir
O Forward Email faz algo parecido usando um db sqlite criptografado para cada mailbox/usuário
O nome é excelente. Lembra o Sean Connery
O workflow de "database per tenant" ainda está só começando
Já usou algo parecido no passado e ficou muito satisfeito
rm username.sqlQuando os dados ficam isolados entre si e não há problemas de escala dentro de um único tenant, é difícil errar no design