Ruby on Rails

Este artigo investiga como migrar aplicativos rails multilocatários para um back-end de armazenamento citus. Nele, usamos a joia Ruby activerecord-multi-tenant para facilitar a expansão.

Essa joia Ruby evoluiu de nossa experiência trabalhando com clientes dimensionando seus aplicativos multilocatários. Ele corrige algumas restrições que ActiveRecord e Rails têm atualmente quando se trata de compilação de consulta automática. Essa joia se baseia na excelente biblioteca de acts_as_tenant e a estende para o caso de uso específico de um banco de dados multilocatário distribuído, como o Citus.

Preparando-se para escalar horizontalmente um aplicativo multilocatário

Inicialmente, você geralmente começa com todos os locatários colocados em um único nó de banco de dados. Em seguida, usando uma estrutura como Ruby on Rails e ActiveRecord, você carrega os dados de um determinado locatário quando atende a uma solicitação da Web que retorna os dados do locatário.

O ActiveRecord faz algumas suposições sobre o armazenamento de dados que limitam suas opções de expansão. Em particular, o ActiveRecord apresenta um padrão em que você normaliza os dados e os divide em muitos modelos distintos identificados por uma única id coluna, com várias belongs_to relações que vinculam objetos de volta a um locatário ou cliente:

# typical pattern with multiple belongs_to relationships

class Customer < ActiveRecord::Base
  has_many :sites
end
class Site < ActiveRecord::Base
  belongs_to :customer
  has_many :page_views
end
class PageView < ActiveRecord::Base
  belongs_to :site
end

No exemplo anterior, a coisa complicada com esse padrão é que, para encontrar todas as exibições de página para um cliente, você precisa consultar todos os sites de um cliente primeiro. Essa consulta se torna um problema quando você começa a fragmentar dados e, em particular, quando você executa consultas UPDATE ou DELETE em modelos aninhados, como exibições de página.

Há algumas etapas que você pode executar hoje, para facilitar o dimensionamento no futuro:

1. Introduza uma coluna para o tenant_id em cada registro que pertence a um locatário

Para escalar verticalmente um modelo multilocatário, é essencial que você possa localizar todos os registros que pertencem a um locatário rapidamente. A maneira mais fácil de obter essa estrutura é adicionar uma tenant_id coluna (ou coluna "customer_id", etc.) em cada objeto que pertence a um locatário e fazer o backup dos dados existentes para que essa coluna seja definida corretamente.

Quando você migra para um banco de dados multilocatário distribuído como o Citus no futuro, essa etapa é necessária – mas se você fizer essa modificação antes, poderá apenas COPIAR sobre seus dados, sem fazer mais modificações de dados.

2. Usar restrições UNIQUE que incluem o tenant_id

Restrições exclusivas e de chave estrangeira em valores diferentes do tenant_id apresenta um problema em qualquer sistema distribuído, pois é difícil garantir que nenhum dois nós aceitem o mesmo valor exclusivo. A imposição da restrição exigiria verificações caras dos dados em todos os nós.

Para resolver esse problema para os modelos que estão logicamente relacionados a um repositório (o locatário do nosso aplicativo), você deve adicionar store_id às restrições. Essa restrição efetivamente define o escopo dos objetos exclusivamente dentro de um determinado repositório. Isso ajuda a adicionar o conceito de locação aos seus modelos, tornando o sistema multilocatário mais robusto.

Por exemplo, Rails cria uma chave primária por padrão, que inclui apenas o id registro:

Indexes:
  "page_views_pkey" PRIMARY KEY, btree (id)

Você deve modificar essa chave primária para incluir também o tenant_id:

ALTER TABLE page_views DROP CONSTRAINT page_views_pkey;
ALTER TABLE page_views ADD PRIMARY KEY(id, customer_id);

Uma exceção a essa regra pode ser um email ou coluna de nome de usuário em uma tabela de usuários (a menos que você dê a cada locatário sua própria página de entrada). Devido a essa exceção, depois de escalar horizontalmente, normalmente recomendamos dividir essas colunas de suas tabelas distribuídas e colocá-las como uma tabela local no nó coordenador do Citus.

3. Inclua o tenant_id em todas as consultas, mesmo quando você pode localizar um objeto usando seu próprio object_id

A maneira mais fácil de executar uma consulta SQL típica em um sistema distribuído sem restrições é sempre acessar dados que residem em um único nó, determinado pelo locatário que você está acessando.

Por esse motivo, depois de usar um sistema distribuído como o Citus, recomendamos que você sempre especifique o tenant_id e a própria ID de um objeto para consultas. Adicionar o tenant_id permite que o coordenador localize seus dados rapidamente e encaminhe a consulta para um único fragmento. Caso contrário, o coordenador deve ir para cada fragmento no sistema individualmente e perguntar ao fragmento se ele sabe o object_id determinado.

Atualizando o aplicativo Rails

Você pode começar incluindo seu Gemfile, executando bundle installe anotando gem 'activerecord-multi-tenant' seus modelos ActiveRecord assim:

class PageView < ActiveRecord::Base
  multi_tenant :customer
  # ...
end

Nesse caso, customer é o modelo de locatário e sua page_views tabela precisa ter uma customer_id coluna que faça referência ao cliente à qual o modo de exibição de página pertence.

A gem Ruby activerecord-multi-tenant tem como objetivo facilitar a implementação das alterações de dados anteriores em um aplicativo rails típico.

Observação

A biblioteca depende da coluna tenant_id para estar presente e não nula para todas as linhas. No entanto, geralmente, é útil que a biblioteca defina o tenant_id para novos registros, enquanto o backfilling ausente tenant_id valores em registros existentes como uma tarefa em segundo plano. Esse método facilita a introdução ao activerecord-multi-tenant.

Para dar suporte a esse método, a biblioteca tem um modo somente gravação. Nesse modo, a coluna tenant_id não é filtrada em consultas, mas é definida corretamente para novos registros. Para habilitá-lo, inclua a seguinte configuração em um inicializador rails:

MultiTenant.enable_write_only_mode

Quando estiver pronto para impor a locação, adicione uma restrição NOT NULL à coluna tenant_id e remova a linha do inicializador.

Conforme mencionado no início, adicionando multi_tenant :customer anotações aos modelos, a biblioteca cuida automaticamente da inclusão do tenant_id com todas as consultas.

Para que isso funcione, você sempre precisa especificar qual locatário está acessando, especificando-o por solicitação:

class ApplicationController < ActionController::Base
  # Opt-into the "set_current_tenant" controller helpers by specifying this:
  set_current_tenant_through_filter

  before_filter :set_customer_as_tenant

  def set_customer_as_tenant
    customer = Customer.find(session[:current_customer_id])
    set_current_tenant(customer) # Set the tenant
  end
end

Ou encapsulando seu código em um bloco. Por exemplo, para tarefas de plano de fundo e manutenção:

customer = Customer.find(session[:current_customer_id])
# ...
MultiTenant.with(customer) do
  site = Site.find(params[:site_id])

  # Modifications automatically include tenant_id
  site.update! last_accessed_at: Time.now

  # Queries also include tenant_id automatically
  site.page_views.count
end

Depois de estar pronto para usar um banco de dados multilocatário distribuído, como o Citus, tudo o que você precisa é de alguns ajustes em suas migrações e você está pronto para ir:

class InitialTables < ActiveRecord::Migration
  def up
    create_table :page_views, partition_key: :customer_id do |t|
      t.references :customer, null: false
      t.references :site, null: false

      t.text :url, null: false
      ...
      t.timestamps null: false
    end
    create_distributed_table :page_views, :account_id
  end

  def down
    drop_table :page_views
  end
end

Nossa biblioteca adiciona o partition_key: :customer_id. ao Rails' create_table. Essa adição garante que a chave primária inclua a coluna tenant_id e create_distributed_table, o que permite ao Citus escalar horizontalmente os dados para vários nós.

Atualizando o conjunto de testes

Se o conjunto de testes do aplicativo Rails usar a database_cleaner joia para redefinir o banco de dados de teste entre as execuções, use a estratégia de "truncamento" em vez de "transação". Ocasionalmente, vemos falhas durante as reversões de transação nos testes. A documentação do database_cleaner tem instruções para alterar a estratégia de limpeza.

Integração contínua

A maneira mais fácil de executar um cluster Citus na integração contínua é usando os contêineres oficiais do Citus Docker. Veja como fazer isso no Circle CI em particular.

  1. Copie https://github.com/citusdata/docker/blob/master/docker-compose.yml para o projeto Rails e nomeie-o citus-docker-compose.yml.

  2. Atualize a steps: seção em .circleci/config.yml. Esse código inicia um nó de trabalho e coordenador:

    steps:
      - setup_remote_docker:
          docker_layer_caching: true
      - run:
          name: Install Docker Compose
          command: |
            curl -L https://github.com/docker/compose/releases/download/1.19.0/docker-compose-`uname -s`-`uname -m` > ~/docker-compose
            chmod +x ~/docker-compose
            mv ~/docker-compose /usr/local/bin/docker-compose
    
      - checkout
    
      - run:
          name: Starting Citus Cluster
          command: docker-compose -f citus-docker-compose.yml up -d
    
  3. Faça com que o conjunto de testes se conecte ao banco de dados no Docker.localhost:5432

Aplicativo de exemplo

Se você estiver interessado em um exemplo mais completo, confira nosso aplicativo de referência que mostra um aplicativo SaaS de exemplo simplificado para análise de anúncios.

Captura de tela do painel no aplicativo de referência citus-example-ad-analytics que mostra uma campanha publicitária.

Como você pode ver na captura de tela, a maioria dos dados está associada ao cliente conectado no momento , mesmo que sejam dados analíticos complexos, todos os dados são acessados no contexto de um único cliente ou locatário.