DEV Community

Como aceleramos em 90% a execução das nossas migrações em Rails

Nota: Este post também está disponível em inglês.

Ao usar Rails, todos precisamos criar, deletar ou reverter migrations, já que são um recurso necessário para estruturar o banco de dados ao construir sua aplicação. Migrations, no entanto, enfrentam um problema de escalabilidade: quanto mais migrations são adicionadas, mais tempo leva para executá-las para refletir as mudanças no schema do banco de dados. Além disso, se você troca de branch e executa alguma migration, seu schema.rb pode ficar com resquícios de outras branches, o que pode forçar você a recriar todo o banco de dados. Esse processo não é particularmente problemático em projetos pequenos, mas no nosso caso, com 6 anos de migrations, reconstruir o banco demorava muito. Além disso, como usamos muitos bancos de dados diferentes com o mesmo schema, criar novos bancos estava ficando lento, tornando a criação de novos ambientes mais trabalhosa do que deveria.

Ao buscar soluções para esse problema, a primeira ideia pode ser tentar otimizar o código das migrations de alguma forma. Otimizar o código das migrations ajuda em parte, mas não traria uma melhoria significativa na velocidade.

Primeiro, precisamos considerar que, por padrão, cada migration no Rails é executada em uma nova transaction, o que faz sentido. Você não quer lidar com uma migration pela metade em seu sistema de produção. No entanto, essa abordagem cria problemas de performance, já que executar migrations sequencialmente, cada uma em sua própria transaction, pode ficar muito lento conforme o número de migrations aumenta. No nosso caso, precisamos criar novos ambientes frequentemente, e esses novos ambientes precisam ser migrados e populados do zero. Além disso, muitas vezes os desenvolvedores podem precisar trocar de uma branch para outra e eles precisam reconstruir todo o banco de dados se não quiserem incluir resquícios indesejados no schema.rb.

Além da questão da transaction, quando rodamos uma migration, o Rails adiciona seu timestamp na tabela schema_migrations. Para verificar se uma migration já foi executada, o Rails pega o timestamp da migration e tenta encontrar esse timestamp na tabela. Se encontrar, a migration é pulada; caso contrário, ela é executada. Note que o Rails verifica o timestamp da migration em relação ao conteúdo da tabela, mas o inverso não ocorre. Ou seja, não há problema em ter entradas na tabela que não existem na pasta migrate, e podemos adicionar um timestamp manualmente se não quisermos rodar uma migration em um ambiente específico.

Percebemos que, embora nossas migrations fossem muito lentas, o schema:load era rápido, muito mais rápido que executar as migrations. Porém, a task schema:load não era adequada para ambientes de produção. O schema:load não é capaz de criar views nem executar código Ruby presente nas migrations. Por exemplo, se tivéssemos um código SQL criando uma view, ela não seria criada no schema:load. Além disso, algumas migrations executavam código Ruby para criar registros (User.create!(name: 'John'), por exemplo), e seria arriscado ignorar essas criações sem garantir que estavam presentes no seeds.
O desafio, então, era carregar o schema e ainda assim criar esses registros que só as migrations conseguiam. Trouxemos, assim, uma solução que engloba o melhor dos dois lados.

Atenção: considere que essa solução remove a vantagem de ser agnóstico em relação ao banco de dados que o schema.rb oferece. Ao gerar o arquivo .sql, você está criando um arquivo que funciona para o seu adaptador de banco atual, não necessariamente para outros. Além disso, essa solução pode não ser recomendada se você não tem um grande problema com migrations lentas, especialmente porque o Rails não possui suporte oficial a esse tipo de otimização, e manter o histórico e a reversibilidade das migrations pode ser melhor na maioria dos casos.

A primeira ideia foi criar um arquivo .sql que fizesse o mesmo que as migrations e então carregá-lo. Pensamos em usar o structure.sql como solução, já que o schema.rb não reflete a criação de views, mas o structure.sql sim. Também precisávamos limitar quais migrations seriam transformadas em SQL puro, pois queríamos poder reverter facilmente as mais recentes se necessário, e garantir que todos os ambientes já tivessem rodado as migrations que seriam convertidas. Como percebemos que todos os ambientes já tinham rodado todas as migrations de 2024, decidimos manter todas até 2024 como baseline. Todas as migrations de 2025 em diante não seriam convertidas para SQL puro e continuariam sendo executadas normalmente.

No entanto, essa solução tinha um problema: o structure.sql não reflete registros criados ou atualizados durante as migrations. Embora seja esperado que os desenvolvedores sempre criem uma cópia dos updates/creates no seeds.rb quando necessário em novos ambientes, não havia garantia de que todo create/update que deveria ser portado estava refletido no seeds. Precisávamos então rastrear quais migrations tinham esse problema, mas tínhamos literalmente milhares de migrations e ler uma a uma não era viável nem seguro. Por isso, precisávamos rastrear automaticamente quais migrations faziam insert ou update, para pelo menos excluir da análise as que não criavam registros, que eram a maioria. Criamos então um initializer que só roda durante o processo de migration e loga quais migrations fazem esse tipo de operação. Note que esse initializer foi gerado por IA e não segue as melhores práticas, mas foi usado uma vez e removido do código, então tudo bem.

config/initializers/migration_data_logger.rb

if defined?(ActiveRecord::Migration)
  module MigrationDataLogger
    class << self
      attr_accessor :enabled

      def start_logging
        @enabled = true
        @log_file = File.open(Rails.root.join('log/migration_data_changes.log'), 'a')
        @current_migration = nil
        puts 'Migration logging started - writing to log/migration_data_changes.log'
      end

      def stop_logging
        @enabled = false
        @log_file&.close
        puts 'Migration logging stopped'
      end

      def log(message)
        return unless @enabled

        @log_file.puts "[#{Time.current}] #{@current_migration}: #{message}"
        @log_file.flush
      end

      def set_migration(migration)
        @current_migration = "#{migration.version} - #{migration.name}"
      end
    end
  end

  ActiveSupport::Notifications.subscribe('sql.active_record') do |_, started, finished, _, payload|
    MigrationDataLogger.log("#{payload[:sql]}") if MigrationDataLogger.enabled && payload[:sql] =~ /^(INSERT|UPDATE|DELETE)/i
  end

  ActiveRecord::Migration.prepend(Module.new do
    def migrate(direction)
      MigrationDataLogger.start_logging
      MigrationDataLogger.set_migration(self)
      super
      MigrationDataLogger.stop_logging
    end
  end)
end
Enter fullscreen mode Exit fullscreen mode

Com os nomes dessas migrations em mãos, reduzimos de milhares para apenas 70 o número de migrations que precisaríamos analisar manualmente. Foi um número bom para análise manual, então não avançamos mais com o script, mas uma forma mais automatizada seria capturar todos os SQLs executados e descartar os que afetaram zero linhas. Depois disso, movemos todo o código que estava em migrations mas funcionava como seed para o db/seeds.rb, e finalmente pudemos nos livrar das migrations antigas sem nos preocupar com os registros criados.

Após mapear o que deveria ser movido para o seeds, começamos o processo de obter o SQL pré-2025. O primeiro passo foi adicionar a seguinte linha ao application.rb:

config.active_record.schema_format = :sql
Enter fullscreen mode Exit fullscreen mode

Depois, movemos todas as migrations pós-2024 para outra pasta (para não serem executadas), e reconstruímos o banco. Assim, obtivemos um arquivo SQL que refletia todas as migrations pré-2025 e podia ser facilmente carregado. Esse novo arquivo, chamado structure.sql, foi renomeado para structure_baseline.sql.
Depois disso, deletamos todas as migrations pré-2025 e devolvemos as pós-2024 para a pasta migrate.
Em seguida, geramos manualmente uma migration com timestamp do final de 2024, que seria executada após a última migration de 2024. Por exemplo, se a última migration tinha timestamp 20241230xxxxx, geraríamos uma com timestamp 20241231xxxx. Inserimos esse timestamp em todos os ambientes existentes editando diretamente a tabela schema_migrations. Assim, conseguimos ter uma migration que carrega todo o conteúdo do banco, mas nunca é executada em ambientes já existentes. Lembre-se: para funcionar, é preciso garantir que todos os ambientes já rodaram todas as migrations que estão sendo transformadas em SQL puro.
Com esses preparativos, criamos a seguinte migration:

class LoadBaselineSchema < ActiveRecord::Migration[7.0]
  disable_ddl_transaction!

  def change
    ActiveRecord::Base.connection.execute(File.read(Rails.root.join('db/structure_baseline.sql')))
  end
end
Enter fullscreen mode Exit fullscreen mode

Atenção: repare que o disable_ddl_transaction! faz a migration rodar sem transação no banco, o que pode ser um problema dependendo do seu contexto. Provavelmente você vai querer remover essa linha (ou pelo menos condicionar para rodar só em ambiente local). Optamos por usar porque acelera bastante o carregamento do .sql e só vai rodar em ambientes novos. Se algum problema acontecer durante o carregamento, basta recriar o banco, já que é um ambiente novo.
Depois disso, removemos a linha adicionada no application.rb, já que a mudança definitiva do schema.rb para structure.sql seria feita em outro pull request para revisão adequada. Você pode explicitar no application.rb que quer usar ruby ao invés de sql, se preferir:

config.active_record.schema_format = :ruby
Enter fullscreen mode Exit fullscreen mode

Queríamos então rodar as migrations para gerar novamente o schema.rb. Porém, ao tentar carregar o structure_baseline.sql, o Rails tentava recriar as tabelas padrão — ar_internal_metadata e schema_migrations. Modificamos manualmente as linhas do structure.sql
CREATE TABLE public.ar_internal_metadata e CREATE TABLE public.schema_migrations para incluir um IF NOT EXISTS.
Também tivemos problemas com os índices dessas tabelas, e adicionamos condições para só criar os índices se eles não existirem.

ALTER TABLE ONLY public.ar_internal_metadata
    ADD CONSTRAINT ar_internal_metadata_pkey PRIMARY KEY (key);
ALTER TABLE ONLY public.schema_migrations
    ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version);
Enter fullscreen mode Exit fullscreen mode

se tornou

DO $$
BEGIN
    IF NOT EXISTS (
        SELECT 1 FROM pg_constraint
        WHERE conname = 'ar_internal_metadata_pkey'
    ) THEN
        ALTER TABLE ONLY public.ar_internal_metadata
            ADD CONSTRAINT ar_internal_metadata_pkey PRIMARY KEY (key);
    END IF;
END
$$;

DO $$
BEGIN
    IF NOT EXISTS (
        SELECT 1 FROM pg_constraint
        WHERE conname = 'schema_migrations_pkey'
    ) THEN
        ALTER TABLE ONLY public.schema_migrations
            ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version);
    END IF;
END
$$;
Enter fullscreen mode Exit fullscreen mode

Deletar essas criações de tabela e índices pode ser uma solução melhor, já que o Rails as cria automaticamente, mas não testamos essa abordagem. A partir daí, o db:migrate deve funcionar.
Você também pode precisar deletar a linha COMMENT ON EXTENSION pg_trgm IS 'text similarity measurement and index searching based on trigrams'; também caso esteja rodando com um usuário sem permissão para comentar extensões.

Com essas modificações, nosso comando db:migrate ficou muito mais rápido, mostrando resultados de cerca de 90% de otimização.

Top comments (0)