9 dicas para migrações eficientes com Active Record

Publicado por Jônatas Davi Paganini no dia dev

Aqui na Resultados Digitais estamos sempre melhorando o RD Station. Algumas atualizações envolvem migrações na estrutura dos modelos que usam Active Record. Este post visa explicar nossas decisões e preferências para tornar as migrações mais rápidas e utilizar menos recursos.

Exemplo de migração

Vamos supor que temos o seguinte model Post:

1
2
3
4
class Post < ActiveRecord::Base
  attr_accessible :extra_info
  serialize :extra_info, YAML
end

Agora, vamos fazer uma alteração simples na estrutura dos dados transformando as informações de extra_info de YAML para uma nova coluna info que irá persistir em JSON.

1
add_column :posts, :info, :json

E alterar o modelo para serializar em JSON a coluna info.

1
2
3
4
5
class Post < ActiveRecord::Base
  attr_accessible :extra_info, :info
  serialize :extra_info, YAML
  serialize :info, JSON
end

Em uma migração de dados tradicional e com poucos registros, executaríamos tranquilamente o seguinte código:

1
2
3
4
Post.all.each do |post|
  post.info = post.extra_info
  post.save
end

Neste caso, a conversão é feita “automagicamente” quando usamos o encapsulamento do Active Record e a declaração do serialize.

Considerando o código acima como sendo o de uma migração default, iremos aprimorá-lo de forma incremental, explicando cada detalhe do Active Record que podemos melhorar nesta migração:

1. Use select em vez de selecionar todas colunas

O select permite selecionar campos específicos. Com isso podemos usar apenas os campos úteis na migração.

Caso a tabela possua muitas colunas, essa mudança proporcionará um ganho em performance ao evitar carregar e descarregar a memória com os atributos não utilizados.

1
2
3
4
Post.select("id,extra_info").each do |post|
  post.info = post.extra_info
  post.save
end

2. Prefira update_column a save

O update_column evita checks de validação e callbacks do ActiveRecord::Base:

1
2
3
Post.select("id,extra_info").each do |post|
  post.update_column "info", post.extra_info
end

Se a migração não envolve interação com o usuário e não é necessário revalidar ou chamar callbacks, esta é uma boa opção.

3. Use sempre find_in_batches em vez de each

Os batches permitem consultar os registros em blocos. Isso também ajuda no gerenciamento de memória por não exigir grandes carregamentos da mesma.

1
2
3
4
5
Post.select("id,extra_info").find_in_batches do |posts|
  posts.each do |post|
    post.update_column "info", post.extra_info
  end
end

Esta opção deve ser utilizada apenas em casos em que os dados são independentes e não comprometa as regras ACID do banco de dados.

4. Agrupe alterações dentro de uma transação única

Trabalhar com blocos maiores de transações permite uma melhor utilização do pool de conexão com o banco de dados. No caso de pequenas atualizações ou transações rápidas, iniciar e fazer o commit da transação custa tempo e memória.

1
2
3
4
5
6
7
Post.select("id,extra_info").find_in_batches do |posts|
  Post.transaction do
    posts.each do |post|
      post.update_column "info", post.extra_info
    end
  end
end

Por padrão o find_in_batches faz query com 1000 registros por vez. Desta forma, as atualizações serão executadas de 1000 em 1000 em vez de 1 a 1.

5. Prepare-se para falhar e continuar de onde parou

No caso de grandes migrações, esteja preparado para as coisas darem errado. Se acontecer algum problema, ou mesmo se o código for executado duas vezes, duplicar registros ou alterar erroneamente os registros atuais não deve causar prejuízos.

A condição abaixo é simples e válida para retomar apenas com os dados que não foram processados.

1
2
3
4
5
6
7
Post.where(info: nil).select("id,extra_info").find_in_batches do |posts|
  Post.transaction do
    posts.each do |post|
      post.update_column "info", post.extra_info
    end
  end
end

6. Use SQL puro em vez de encapsulamento via Active Record

Usar a conexão direta ao banco é a forma mais rápida de executar a transação. Porém, repare que muitas vezes não é uma boa opção pois, sem o encapsulamento do Active Record, não irá verificar nem os callbacks, nem observers e nem validações do modelo.

1
2
3
4
5
6
7
8
Post.where(info: nil).select("id,extra_info").find_in_batches do |posts|
  Post.transaction do
    posts.each do |post|
      info = Post.connection.quote post.extra_info.to_json
      Post.connection.execute("UPDATE posts set info = #{info} where id = #{post.id}")
    end
  end
end

7. Evite chamadas de métodos em loops

As chamadas de alguns métodos podem tornar cada interação do loop mais lenta. Pequenas modificações neste aspecto podem otimizar o processo. Por exemplo, Post.connection está sendo chamado uma série de vezes e poderia estar em uma única chamada.

1
2
3
4
5
6
7
8
9
connection = Post.connection
Post.where(info: nil).select("id,extra_info").find_in_batches do |posts|
  Post.transaction do
    posts.each do |post|
      info = connection.quote post.extra_info.to_json
      connection.execute("UPDATE posts set info = #{info} where id = #{post.id}")
    end
  end
end

8. Evite logs do ActiveRecord desnecessários

Se os logs do Active Record não fizerem sentido ou poluírem a migração, faça a migração usando ActiveRecord::Base.logger.silence englobando a migração.

1
2
3
4
5
6
7
8
9
10
11
connection = Post.connection
ActiveRecord::Base.logger.silence do
  Post.where(info: nil).select("id,extra_info").find_in_batches do |posts|
    Post.transaction do
      posts.each do |post|
        info = connection.quote post.extra_info.to_json
        connection.execute("UPDATE posts set info = #{info} where id = #{post.id}")
      end
    end
  end
end

Esta alteração só tem impacto se o processo usa disco rígido para gravar os logs. Em caso de deploy na nuvem - Heroku, por exemplo -, os logs são enviados para o STDOUT e também ao syslog via rede. Ou seja, evitar os logs não causará um impacto perceptível na execução.

Pronto! Com estas pequenas mudanças é possível explorar alguns mili/nano segundos por iteração do processo.

9. Experimente, teste e otimize

Se estiver usando uma máquina de múltiplos núcleos de processamento, não deixe de fazer uns experimentos com o pool de conexões e tente executar alguns updates em paralelo. O benchmark da migração é importante para criar uma expectativa de tempo para migrações. Por exemplo, a migração que inspirou este post era de 40 milhões de registros. Realizando uma melhoria de 10 milisegundos por registro, já são 11 horas a menos de processamento na migração.

O recado final é: Observe a memória e os processadores. Acompanhar a utilização dos recursos da máquina é um bom caminho para entender os gargalos e onde o código ainda pode melhorar.

Jônatas Davi Paganini

Jônatas Davi Paganini

Full Stack Trainer

Comentários