Invalidação de cache Rails em nível de campo: uma solução DSL
Publicados: 2022-03-11No desenvolvimento web moderno, o cache é uma maneira rápida e poderosa de acelerar as coisas. Quando feito corretamente, o armazenamento em cache pode trazer melhorias significativas para o desempenho geral do seu aplicativo. Quando feito de forma errada, definitivamente terminará em desastre.
A invalidação de cache, como você deve saber, é um dos três problemas mais difíceis em ciência da computação – os outros dois são nomes de coisas e erros de um por um. Uma saída fácil é invalidar tudo, à esquerda e à direita, sempre que algo muda. Mas isso anula o propósito do cache. Você deseja invalidar o cache somente quando for absolutamente necessário.
Se você deseja tirar o máximo proveito do cache, precisa ser muito específico sobre o que invalida e evitar que seu aplicativo desperdice recursos preciosos em trabalhos repetidos.
Neste post do blog, você aprenderá uma técnica para ter melhor controle sobre como os caches Rails se comportam: especificamente, implementando a invalidação de cache em nível de campo. Esta técnica se baseia no Rails ActiveRecord e ActiveSupport::Concern
, bem como na manipulação do comportamento do método de touch
.
Esta postagem de blog é baseada em minhas experiências recentes em um projeto em que vimos uma melhoria significativa no desempenho após a implementação da invalidação de cache em nível de campo. Isso ajudou a reduzir invalidações de cache desnecessárias e renderização repetida de modelos.
Rails, Ruby e Desempenho
Ruby não é a linguagem mais rápida, mas no geral, é uma opção adequada no que diz respeito à velocidade de desenvolvimento. Além disso, seus recursos de metaprogramação e linguagem específica de domínio (DSL) integrados dão ao desenvolvedor uma tremenda flexibilidade.
Existem estudos por aí como o estudo de Jakob Nielsen que nos mostram que se uma tarefa demorar mais de 10 segundos, perderemos o foco. E recuperar nosso foco leva tempo. Portanto, isso pode ser inesperadamente caro.
Infelizmente, em Ruby on Rails, é super fácil exceder esse limite de 10 segundos com a geração de modelos. Você não verá isso acontecer em nenhum aplicativo “hello world” ou projeto de estimação de pequena escala, mas em projetos do mundo real, onde muitas coisas são carregadas em uma única página, acredite, a geração de modelos pode facilmente começar a se arrastar.
E foi exatamente isso que eu tive que resolver no meu projeto.
Otimizações simples
Mas como exatamente você acelera as coisas?
A resposta: Benchmark e otimizar.
No meu projeto, duas etapas muito eficazes na otimização foram:
- Eliminando consultas N+1
- Apresentando uma boa técnica de cache para templates
Consultas N+1
Corrigir consultas N+1 é fácil. O que você pode fazer é verificar seus arquivos de log - sempre que vir várias consultas SQL como as abaixo em seus logs, elimine-as substituindo-as por carregamento antecipado:
Learning Load (0.4ms) SELECT 'learnings'.* FROM 'learnings' WHERE 'project'.'id' = ? Learning Load (0.3ms) SELECT 'learnings'.* FROM 'learnings' WHERE 'project'.'id' = ? Learning Load (0.4ms) SELECT 'learnings'.* FROM 'learnings' WHERE 'project'.'id' = ?
Há uma jóia para isso que é chamada de bala para ajudar a detectar essa ineficiência. Você também pode percorrer cada um dos casos de uso e, enquanto isso, verificar os logs inspecionando-os em relação ao padrão acima. Ao eliminar todas as ineficiências N+1, você pode ter certeza de que não sobrecarregará seu banco de dados e o tempo gasto no ActiveRecord diminuirá significativamente.
Depois de fazer essas alterações, meu projeto já estava rodando mais rapidamente. Mas decidi levar isso para o próximo nível e ver se conseguia reduzir ainda mais o tempo de carregamento. Ainda havia um pouco de renderização desnecessária acontecendo nos modelos e, finalmente, foi aí que o cache de fragmentos ajudou.
Fragmento de Cache
O cache de fragmentos geralmente ajuda a reduzir significativamente o tempo de geração do modelo. Mas o comportamento padrão do cache do Rails não foi suficiente para o meu projeto.
A ideia por trás do cache de fragmentos do Rails é brilhante. Ele fornece um mecanismo de cache super simples e eficaz.
Os autores do Ruby On Rails escreveram um artigo muito bom no Signal v. Noise sobre como funciona o cache de fragmentos.
Digamos que você tenha uma interface de usuário que mostra alguns campos de uma entidade.
- No carregamento da página, o Rails calcula o
cache_key
com base na classe da entidade e no campoupdated_at
. - Usando esse
cache_key
, ele verifica se há algo no cache associado a essa chave. - Se não houver nada no cache, o código HTML desse fragmento será renderizado para a exibição (e o conteúdo recém renderizado será armazenado no cache).
- Se houver algum conteúdo existente no cache com essa chave, a exibição será renderizada com o conteúdo do cache.
Isso implica que o cache nunca precisa ser invalidado explicitamente. Sempre que alteramos a entidade e recarregamos a página, um novo conteúdo de cache é renderizado para a entidade.
Rails, por padrão, também oferece a capacidade de invalidar o cache das entidades pai caso o filho mude:
belongs_to :parent_entity, touch: true
Isso, quando incluído em um modelo, tocará automaticamente o pai quando a criança for tocada . Você pode aprender mais sobre touch
aqui. Com isso, o Rails nos fornece uma maneira simples e eficiente de invalidar o cache das nossas entidades pai simultaneamente com o cache das entidades filhas.
Cache em Rails
No entanto, o cache no Rails é criado para servir interfaces de usuário onde o fragmento HTML que representa a entidade pai contém fragmentos HTML que representam apenas as entidades filhas do pai. Em outras palavras, o fragmento HTML que representa as entidades filhas nesse paradigma não pode conter campos da entidade pai.
Mas não é isso que acontece no mundo real. Você pode muito bem precisar fazer coisas em sua aplicação Rails que violem esta condição.
Como você lidaria com uma situação em que a interface do usuário mostra campos de uma entidade pai dentro do fragmento HTML que representa a entidade filha?
Se o filho contém campos da entidade pai, você está com problemas com o comportamento de invalidação de cache padrão do Rails.

Toda vez que esses campos apresentados da entidade pai forem modificados, você precisará tocar em todas as entidades filhas pertencentes a esse pai. Por exemplo, se Parent1
for modificado, você precisará certificar-se de que o cache para as Child1
e Child2
sejam invalidados.
Obviamente, isso pode causar um grande gargalo de desempenho. Tocar em todas as entidades filhas sempre que um pai mudasse resultaria em muitas consultas de banco de dados sem um bom motivo.
Outro cenário semelhante é quando as entidades associadas à associação has_and_belongs_to
foram apresentadas na lista e a modificação dessas entidades iniciou uma cascata de invalidação de cache por meio da cadeia de associação.
class Event < ActiveRecord::Base has_many :participants has_many :users, through: :participants end class Participant < ActiveRecord::Base belongs_to :event belongs_to :user end class User < ActiveRecord::Base has_many :participants has_many :events, through :participants end
Portanto, para a interface de usuário acima, seria ilógico tocar no participante ou no evento quando a localização do usuário mudar. Mas devemos tocar tanto no evento quanto no participante quando o nome do usuário mudar, não é?
Portanto, as técnicas no artigo Signal v. Noise são ineficientes para determinadas instâncias de UI/UX, conforme descrito acima.
Embora o Rails seja super eficaz para coisas simples, projetos reais têm suas próprias complicações.
Invalidação de Cache Rails em Nível de Campo
Em meus projetos, tenho usado uma pequena DSL Ruby para lidar com situações como as acima. Ele permite especificar declarativamente os campos que acionarão a invalidação do cache por meio das associações.
Vamos dar uma olhada em alguns exemplos de onde isso realmente ajuda:
Exemplo 1:
class Event < ActiveRecord::Base include Touchable ... has_many :tasks ... touch :tasks, in_case_of_modified_fields: [:name] ... end class Task < ActiveRecord::Base belongs_to :event end
Este trecho aproveita as habilidades de metaprogramação e os recursos internos de DSL do Ruby.
Para ser mais específico, apenas uma mudança de nome no evento invalidará o cache de fragmentos de suas tarefas relacionadas. Alterar outros campos do evento, como propósito ou local, não invalidará o cache de fragmentos da tarefa. Eu chamaria isso de controle de invalidação de cache refinado em nível de campo .
Exemplo 2:
Vamos dar uma olhada em um exemplo que mostra a invalidação de cache através da cadeia de associação has_many
.
O fragmento de interface do usuário mostrado abaixo mostra uma tarefa e seu proprietário:
Para esta interface de usuário, o fragmento HTML que representa a tarefa deve ser invalidado somente quando a tarefa for alterada ou quando o nome do proprietário for alterado. Se todos os outros campos do proprietário (como fuso horário ou preferências) forem alterados, o cache de tarefas deverá permanecer intacto.
Isto é conseguido usando o DSL mostrado aqui:
class User < ActiveRecord::Base include Touchable touch :tasks, in_case_of_modified_fields: [:first_name, :last_name] ... end class Task < ActiveRecord::Base has_one owner, class_name: :User end
Implementação do DSL
A principal essência do DSL é o método de touch
. Seu primeiro argumento é uma associação, e o próximo argumento é uma lista de campos que aciona o touch
nessa associação:
touch :tasks, in_case_of_modified_fields: [:first_name, :last_name]
Este método é fornecido pelo módulo Touchable
:
module Touchable extend ActiveSupport::Concern included do before_save :check_touchable_entities after_save :touch_marked_entities end module ClassMethods def touch association, options @touchable_associations ||= {} @touchable_associations[association] = options end end end
Neste código, o ponto principal é que armazenamos os argumentos da chamada de touch
. Então, antes de salvar a entidade, marcamos a associação como suja se o campo especificado foi modificado. Tocamos as entidades nessa associação depois de salvar se a associação estava suja.
Então, a parte privada da preocupação é:
... private def klass_level_meta_info self.class.instance_variable_get('@touchable_associations') end def meta_info @meta_info ||= {} end def check_touchable_entities return unless klass_level_meta_info.present? klass_level_meta_info.each_pair do |association, change_triggering_fields| if any_of_the_declared_field_changed?(change_triggering_fields) meta_info[association] = true end end end def any_of_the_declared_field_changed?(options) (options[:in_case_of_modified_fields] & changes.keys.map{|x|x.to_sym}).present? end …
No método check_touchable_entities
, verificamos se o campo declarado mudou . Nesse caso, marcamos a associação como suja definindo o meta_info[association]
como true
.
Então, depois de salvar a entidade, verificamos nossas associações sujas e tocamos as entidades nela, se necessário:
… def touch_marked_entities return unless klass_level_meta_info.present? klass_level_meta_info.each_key do |association_key| if meta_info[association_key] association = send(association_key) association.update_all(updated_at: Time.zone.now) meta_info[association_key] = false end end end …
E, é isso! Agora você pode executar a invalidação de cache em nível de campo no Rails com uma DSL simples.
Conclusão
O cache Rails promete melhorias de desempenho em seu aplicativo com relativa facilidade. No entanto, as aplicações do mundo real podem ser complicadas e muitas vezes apresentam desafios únicos. O comportamento de cache padrão do Rails funciona bem para a maioria dos cenários, mas há certos cenários em que um pouco mais de otimização na invalidação de cache pode ajudar bastante.
Agora que você sabe como implementar a invalidação de cache em nível de campo no Rails, você pode evitar invalidações desnecessárias de caches em sua aplicação.