Invalidación de caché de Rails a nivel de campo: una solución DSL

Publicado: 2022-03-11

En el desarrollo web moderno, el almacenamiento en caché es una forma rápida y poderosa de acelerar las cosas. Cuando se hace correctamente, el almacenamiento en caché puede generar mejoras significativas en el rendimiento general de su aplicación. Cuando se hace mal, definitivamente terminará en un desastre.

La invalidación de la memoria caché, como sabrá, es uno de los tres problemas más difíciles de la informática; los otros dos son los errores de nomenclatura y errores de uno. Una salida fácil es invalidar todo, a izquierda y derecha, siempre que algo cambie. Pero eso anula el propósito del almacenamiento en caché. Desea invalidar el caché solo cuando sea absolutamente necesario.

Si desea aprovechar al máximo el almacenamiento en caché, debe ser muy particular acerca de lo que invalida y evitar que su aplicación desperdicie valiosos recursos en el trabajo repetido.

Invalidación de caché de Rails a nivel de campo

En esta publicación de blog, aprenderá una técnica para tener un mejor control sobre cómo se comportan los cachés de Rails: específicamente, implementar la invalidación de caché a nivel de campo. Esta técnica se basa en Rails ActiveRecord y ActiveSupport::Concern , así como en la manipulación del comportamiento del método touch .

Esta publicación de blog se basa en mis experiencias recientes en un proyecto en el que vimos una mejora significativa en el rendimiento después de implementar la invalidación de caché a nivel de campo. Ayudó a reducir las invalidaciones de caché innecesarias y la representación repetida de plantillas.

Rieles, Ruby y rendimiento

Ruby no es el lenguaje más rápido, pero en general, es una opción adecuada en lo que respecta a la velocidad de desarrollo. Además, su metaprogramación y sus capacidades integradas de lenguaje específico de dominio (DSL) brindan al desarrollador una gran flexibilidad.

Hay estudios como el estudio de Jakob Nielsen que nos muestran que si una tarea toma más de 10 segundos, perderemos el foco. Y recuperar nuestro enfoque lleva tiempo. Así que esto puede ser inesperadamente costoso.

Desafortunadamente, en Ruby on Rails, es muy fácil superar ese umbral de 10 segundos con la generación de plantillas. No verá que eso suceda en ninguna aplicación de "hola mundo" o proyecto favorito a pequeña escala, pero en proyectos del mundo real donde muchas cosas se cargan en una sola página, créanme, la generación de plantillas puede comenzar a arrastrarse muy fácilmente.

Y eso es exactamente lo que tenía que resolver en mi proyecto.

Optimizaciones simples

Pero, ¿cómo aceleras exactamente las cosas?

La respuesta: comparar y optimizar.

En mi proyecto, dos pasos muy efectivos en la optimización fueron:

  • Eliminando consultas N+1
  • Presentamos una buena técnica de almacenamiento en caché para plantillas

Consultas N+1

Resolver consultas N+1 es fácil. Lo que puede hacer es verificar sus archivos de registro: cada vez que vea varias consultas SQL como las que se muestran a continuación en sus registros, elimínelas reemplazándolas con una carga ansiosa:

 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' = ?

Hay una joya para esto que se llama bala para ayudar a detectar esta ineficiencia. También puede recorrer cada uno de los casos de uso y, mientras tanto, verificar los registros comparándolos con el patrón anterior. Al eliminar todas las ineficiencias de N+1, puede estar lo suficientemente seguro de que no sobrecargará su base de datos y el tiempo que dedica a ActiveRecord se reducirá significativamente.

Después de hacer estos cambios, mi proyecto ya estaba funcionando más rápido. Pero decidí llevarlo al siguiente nivel y ver si podía reducir aún más el tiempo de carga. Todavía había un poco de procesamiento innecesario en las plantillas y, en última instancia, ahí es donde ayudó el almacenamiento en caché de fragmentos.

Almacenamiento en caché de fragmentos

El almacenamiento en caché de fragmentos generalmente ayuda a reducir significativamente el tiempo de generación de plantillas. Pero el comportamiento predeterminado de la memoria caché de Rails no funcionaba para mi proyecto.

La idea detrás del almacenamiento en caché de fragmentos de Rails es brillante. Proporciona un mecanismo de almacenamiento en caché súper simple y efectivo.

Los autores de Ruby On Rails han escrito un muy buen artículo en Signal v. Noise sobre cómo funciona el almacenamiento en caché de fragmentos.

Digamos que tiene un poco de interfaz de usuario que muestra algunos campos de una entidad.

  • En la carga de la página, Rails calcula la cache_key en función de la clase de la entidad y el campo updated_at .
  • Usando esa cache_key , verifica si hay algo en el caché asociado con esa clave.
  • Si no hay nada en la memoria caché, el código HTML de ese fragmento se representa para la vista (y el contenido recién representado se almacena en la memoria caché).
  • Si hay algún contenido existente en la memoria caché con esa clave, la vista se representa con el contenido de la memoria caché.

Esto implica que el caché nunca necesita ser invalidado explícitamente. Cada vez que cambiamos la entidad y recargamos la página, se representa nuevo contenido de caché para la entidad.

Rails, de forma predeterminada, también ofrece la capacidad de invalidar el caché de las entidades principales en caso de que el elemento secundario cambie:

 belongs_to :parent_entity, touch: true

Esto, cuando se incluye en un modelo, tocará automáticamente al padre cuando se toque al niño. Puede obtener más información sobre touch aquí. Con esto, Rails nos brinda una forma simple y eficiente de invalidar el caché de nuestras entidades principales simultáneamente con el caché de las entidades secundarias.

Almacenamiento en caché en Rails

Sin embargo, el almacenamiento en caché en Rails se crea para servir a las interfaces de usuario donde el fragmento de HTML que representa la entidad principal contiene fragmentos de HTML que representan únicamente las entidades secundarias de la entidad principal. En otras palabras, el fragmento HTML que representa las entidades secundarias en este paradigma no puede contener campos de la entidad principal.

Pero eso no es lo que sucede en el mundo real. Es muy posible que necesite hacer cosas en su aplicación Rails que violen esta condición.

¿Cómo manejaría una situación en la que la interfaz de usuario muestra campos de una entidad principal dentro del fragmento HTML que representa a la entidad secundaria?

Fragmentos para entidades secundarias que hacen referencia a campos de entidades principales

Si el elemento secundario contiene campos de la entidad principal, entonces tiene problemas con el comportamiento de invalidación de caché predeterminado de Rails.

Cada vez que se modifiquen los campos presentados desde la entidad principal, deberá tocar todas las entidades secundarias que pertenecen a esa entidad principal. Por ejemplo, si se modifica Parent1 , deberá asegurarse de que la memoria caché para las vistas Child1 y Child2 estén invalidadas.

Obviamente, esto puede causar un gran cuello de botella en el rendimiento. Tocar cada entidad secundaria cada vez que un padre ha cambiado daría como resultado muchas consultas a la base de datos sin una buena razón.

Otro escenario similar es cuando las entidades asociadas con la asociación has_and_belongs_to se presentaron en la lista, y la modificación de esas entidades inició una cascada de invalidación de caché a través de la cadena de asociación.

Asociación "Tiene y Pertenece a"

 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

Por lo tanto, para la interfaz de usuario anterior, sería ilógico tocar al participante o al evento cuando cambia la ubicación del usuario. Pero deberíamos tocar tanto el evento como el participante cuando cambia el nombre del usuario, ¿no?

Por lo tanto, las técnicas del artículo Signal v. Noise son ineficientes para ciertas instancias de UI/UX, como se describe anteriormente.

Aunque Rails es súper efectivo para cosas simples, los proyectos reales tienen sus propias complicaciones.

Invalidación de caché de Rails a nivel de campo

En mis proyectos, he estado usando un pequeño Ruby DSL para manejar situaciones como la anterior. Le permite especificar de forma declarativa los campos que activarán la invalidación de caché a través de las asociaciones.

Echemos un vistazo a algunos ejemplos de dónde realmente ayuda:

Ejemplo 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 fragmento aprovecha las capacidades de metaprogramación y las capacidades internas de DSL de Ruby.

Para ser más específicos, solo un cambio de nombre en el evento invalidará el caché de fragmentos de sus tareas relacionadas. Cambiar otros campos del evento, como el propósito o la ubicación, no invalidará la caché de fragmentos de la tarea. Llamaría a esto control de invalidación de caché detallado a nivel de campo .

Fragmento de una entidad de evento con solo el campo de nombre

Ejemplo 2:

Echemos un vistazo a un ejemplo que muestra la invalidación de caché a través de la cadena de asociación has_many .

El fragmento de la interfaz de usuario que se muestra a continuación muestra una tarea y su propietario:

Fragmento de una entidad de evento con el nombre del propietario del evento

Para esta interfaz de usuario, el fragmento HTML que representa la tarea debe invalidarse solo cuando la tarea cambia o cuando cambia el nombre del propietario. Si todos los demás campos del propietario (como la zona horaria o las preferencias) cambian, la caché de tareas debe permanecer intacta.

Esto se logra utilizando el DSL que se muestra aquí:

 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

Implementación del DSL

La esencia principal del DSL es el método touch . Su primer argumento es una asociación, y el siguiente argumento es una lista de campos que desencadena el touch en esa asociación:

 touch :tasks, in_case_of_modified_fields: [:first_name, :last_name]

Este método lo proporciona el 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

En este código, el punto principal es que almacenamos los argumentos de la llamada touch . Luego, antes de guardar la entidad, marcamos la asociación como sucia si se modificó el campo especificado. Tocamos las entidades en esa asociación después de guardar si la asociación estaba sucia.

Entonces, la parte privada de la preocupación es:

 ... 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 …

En el método check_touchable_entities , verificamos si el campo declarado cambió . Si es así, marcamos la asociación como sucia configurando meta_info[association] en true .

Luego, después de guardar la entidad, verificamos nuestras asociaciones sucias y tocamos las entidades si es necesario:

 … 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 …

¡Y eso es todo! Ahora puede realizar la invalidación de caché a nivel de campo en Rails con un simple DSL.

Conclusión

El almacenamiento en caché de Rails promete mejoras de rendimiento en su aplicación con relativa facilidad. Sin embargo, las aplicaciones del mundo real pueden ser complicadas y, a menudo, plantean desafíos únicos. El comportamiento predeterminado de la caché de Rails funciona bien para la mayoría de los escenarios, pero hay ciertos escenarios en los que un poco más de optimización en la invalidación de la caché puede ser muy útil.

Ahora que sabe cómo implementar la invalidación de caché a nivel de campo en Rails, puede evitar invalidaciones innecesarias de cachés en su aplicación.