Migrações mais seguras com Ruby on Rails e PostgreSQL

Quando trabalhamos com grandes aplicações em produção, alterações no banco de dados exigem muito cuidado. Uma migration aparentemente simples pode causar locks, indisponibilidade, degradação de performance e impactos significativos no sistema. O problema é que muitas operações funcionam perfeitamente em ambiente local, mas se tornam custosas em produção, principalmente em…

Quando trabalhamos com grandes aplicações em produção, alterações no banco de dados exigem muito cuidado. Uma migration aparentemente simples pode causar locks, indisponibilidade, degradação de performance e impactos significativos no sistema.

O problema é que muitas operações funcionam perfeitamente em ambiente local, mas se tornam custosas em produção, principalmente em tabelas grandes e com alto volume de acesso concorrente.

Neste artigo, vamos explorar práticas para implementar migrations mais seguras utilizando Ruby on Rails com PostgreSQL, abordando locks, índices, transações, alterações estruturais e estratégias utilizadas em ambientes de produção.

Por que migrations podem ser perigosas?

Migrations podem ser perigosas em produção porque alteram a estrutura ou os dados do banco enquanto a aplicação continua em uso. Os principais riscos estão nos locks e no consumo de recursos da infraestrutura. Algumas operações podem bloquear leituras ou escritas, enquanto outras podem exigir muito processamento, principalmente em tabelas grandes.

Alguns exemplos comuns:

• Atualizar milhões de registros em uma única transação
• Alterar tipo de coluna em tabelas grandes
• Remover colunas utilizadas pela aplicação
• Criar índices sem utilizar CONCURRENTLY
• Adicionar uma coluna com valor default em versões antigas do PostgreSQL

Como locks podem impactar suas migrations

O PostgreSQL utiliza diferentes tipos de lock para garantir a consistência dos dados durante leituras, escritas e alterações estruturais. Em migrations, o ponto de atenção é que algumas operações podem adquirir locks mais restritivos, bloqueando leituras ou escritas enquanto são executadas.

Alguns exemplos de locks comuns:

ACCESS SHARE: lock mais leve, utilizado em operações de leitura, como SELECT. Ele não bloqueia outras leituras ou escritas comuns, mas entra em conflito com locks mais fortes, como ACCESS EXCLUSIVE.
ROW EXCLUSIVE: utilizado em INSERT, UPDATE e DELETE, indicando que linhas da tabela estão sendo modificadas. Normalmente não bloqueia leituras comuns.
SHARE UPDATE EXCLUSIVE: utilizado em operações de manutenção, como VACUUM, ANALYZE e CREATE INDEX CONCURRENTLY. Normalmente não bloqueia leituras e escritas comuns, mas pode bloquear ou ser bloqueado por alterações estruturais mais fortes.
ACCESS EXCLUSIVE: lock mais restritivo, utilizado em operações como DROP COLUMN, ALTER COLUMN TYPE, TRUNCATE, criação de índices sem CONCURRENTLY e outras alterações estruturais em tabelas. Enquanto ativo, pode bloquear SELECT, INSERT, UPDATE e DELETE.

Estratégias para migrations mais seguras

Migrations transacionais e não transacionais

Por padrão, o Rails executa migrations dentro de uma transaction. Isso significa que, caso ocorra algum erro, todas as alterações podem ser revertidas automaticamente. Esse comportamento aumenta a segurança das migrations, mas também possui limitações, pois algumas operações do PostgreSQL não podem ser executadas dentro de transações, como CREATE INDEX CONCURRENTLY.

Nesses casos, é necessário utilizar:

disable_ddl_transaction!

Isso faz com que a migration seja executada fora de uma transaction.

Migrations reversíveis

No Rails, muitas migrations podem ser revertidas automaticamente utilizando rollback. Quando utilizamos o método change, o Rails consegue identificar como desfazer determinadas operações.

Exemplo:

class AddStatusToUsers < ActiveRecord::Migration[7.0]
  def change
    add_column :users, :status, :string
  end
end

Nesse caso, o Rails sabe que o rollback deve remover a coluna criada. Porém, algumas operações precisam de informações suficientes para serem revertidas corretamente. Um exemplo é remover uma coluna: se o tipo da coluna for informado, o Rails consegue recriá-la no rollback.

Exemplo:

class RemoveFullNameFromUsers < ActiveRecord::Migration[7.0]
  def change
    remove_column :users, :full_name, :string
  end
end


Quando a reversão exige mais controle ou envolve uma lógica que o Rails não consegue inferir automaticamente, é mais seguro utilizar os métodos up e down explicitamente.

class ChangeUsersAgeType < ActiveRecord::Migration[7.0]
  def up
    change_column :users, :age, :bigint
  end

  def down
    change_column :users, :age, :integer
  end
end

Essa abordagem torna o comportamento da migration mais previsível durante rollbacks.

Migrations idempotentes

Uma migration idempotente é aquela que pode ser executada mais de uma vez sem gerar efeitos colaterais inesperados. Isso é importante principalmente em ambientes distribuídos e pipelines de deploy.

Exemplo:

add_column :users, :status, :string unless column_exists?(:users, :status)


Adicionando colunas de forma mais segura

Adicionar colunas em tabelas grandes exige atenção, principalmente quando envolvem DEFAULT e NOT NULL. Uma abordagem mais segura normalmente divide a alteração em etapas:

  • Adicionar a coluna permitindo NULL
  • Popular os registros existentes aos poucos
  • Definir valor default
  • Adicionar NOT NULL

Exemplo:

add_column :users, :status, :string

Depois:

User.in_batches(of: 1000) do |relation|
  relation.update_all(status: "active")
end


E somente após garantir que todos os registros possuem valor:

change_column_default :users, :status, "active"
change_column_null :users, :status, false

Essa estratégia reduz locks prolongados e evita operações pesadas em tabelas grandes.

Criando índices de forma segura

Criar índices em tabelas grandes pode causar impacto significativo dependendo da estratégia utilizada. Por padrão, o PostgreSQL bloqueia operações de escrita enquanto o índice é criado. Para reduzir esse impacto, o PostgreSQL oferece o CREATE INDEX CONCURRENTLY, permitindo que leituras e escritas continuem acontecendo durante a criação do índice.

No Rails, isso normalmente é feito assim:

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

  def change
    add_index :users, :email, algorithm: :concurrently
  end
end

O CREATE INDEX CONCURRENTLY precisa rodar fora de uma transação porque o PostgreSQL cria o índice em etapas, permitindo que a tabela continue recebendo leituras e escritas durante o processo. Por isso, no Rails, é necessário usar disable_ddl_transaction! junto com algorithm: :concurrently.

Também é comum utilizar:

disable_statement_timeout!
disable_lock_timeout

O disable_statement_timeout! evita que migrations longas sejam interrompidas por timeout.Já o disable_lock_timeout! evita falhas caso a migration demore para conseguir adquirir lock na tabela.

Preenchimento de dados em lotes

Atualizar muitos registros pode até ser feito dentro de uma migration, mas essa abordagem aumenta o tempo de deploy e pode manter a migration executando por muito tempo. Em tabelas grandes, o mais seguro costuma ser separar essa atualização em um processo assíncrono, como uma rake task, um job ou um script controlado, executando os registros em lotes.

User.in_batches(of: 1000) do |relation|
  relation.update_all(active: true)
end

Monitorando queries bloqueadas

Durante uma migration, é comum que queries fiquem aguardando liberação de lock no PostgreSQL. Quando isso acontece, podemos utilizar a view pg_stat_activity para identificar queries bloqueadas:

SELECT pid, query, state, wait_event_type, wait_event
FROM pg_stat_activity
WHERE wait_event_type IS NOT NULL;

Também é possível utilizar pg_locks para analisar locks ativos no banco.

SELECT
  pid,
  locktype,
  relation::regclass AS relation,
  mode,
  granted
FROM pg_locks
ORDER BY granted, pid DESC;

Conclusão

Implementar migrations mais seguras é uma prática essencial em aplicações Ruby on Rails que utilizam PostgreSQL em produção.

Conforme o volume de dados cresce, pequenas alterações estruturais passam a exigir maior atenção, principalmente em tabelas críticas e com alto volume de acesso concorrente.

Entender como o PostgreSQL trabalha com locks, transações e alterações estruturais ajuda a reduzir riscos durante deploys e evitar indisponibilidade da aplicação.

Criar índices concorrentemente, dividir alterações em etapas, monitorar locks e evitar operações pesadas em uma única transaction são práticas importantes para manter estabilidade e previsibilidade em ambientes de produção.

Referências

  • ATKINSON, Andrew. High Performance PostgreSQL for Rails. Pragmatic Bookshelf, 2024.
  • Documentação oficial do PostgreSQL
  • Documentação oficial do Ruby on Rails

0 Comentário