Invalidazione della cache Rails a livello di campo: una soluzione DSL

Pubblicato: 2022-03-11

Nello sviluppo web moderno, la memorizzazione nella cache è un modo rapido e potente per velocizzare le cose. Se eseguita correttamente, la memorizzazione nella cache può apportare miglioramenti significativi alle prestazioni complessive dell'applicazione. Se fatto male, finirà sicuramente in un disastro.

L'invalidazione della cache, come forse saprai, è uno dei tre problemi più difficili nell'informatica: gli altri due sono le cose di denominazione e gli errori off-by-one. Una facile via d'uscita è invalidare tutto, sinistra e destra, ogni volta che qualcosa cambia. Ma ciò vanifica lo scopo della memorizzazione nella cache. Vuoi invalidare la cache solo quando è assolutamente necessario.

Se vuoi ottenere il massimo dalla memorizzazione nella cache, devi essere molto preciso su ciò che invalidi e salvare la tua applicazione dallo spreco di risorse preziose in lavori ripetuti.

Invalidazione della cache Rails a livello di campo

In questo post del blog imparerai una tecnica per avere un migliore controllo sul comportamento delle cache Rails: in particolare, implementare l'invalidazione della cache a livello di campo. Questa tecnica si basa su Rails ActiveRecord e ActiveSupport::Concern , nonché sulla manipolazione del comportamento del metodo touch .

Questo post sul blog si basa sulle mie recenti esperienze in un progetto in cui abbiamo riscontrato un miglioramento significativo delle prestazioni dopo l'implementazione dell'invalidazione della cache a livello di campo. Ha contribuito a ridurre le invalidazioni della cache non necessarie e il rendering ripetuto dei modelli.

Rails, Ruby e Performance

Ruby non è il linguaggio più veloce, ma nel complesso è un'opzione adatta per quanto riguarda la velocità di sviluppo. Inoltre, la sua metaprogrammazione e le capacità DSL (Domain-Specific Language) integrate offrono allo sviluppatore un'enorme flessibilità.

Ci sono studi là fuori come quello di Jakob Nielsen che ci mostrano che se un compito richiede più di 10 secondi, perderemo la concentrazione. E recuperare la concentrazione richiede tempo. Quindi questo può essere inaspettatamente costoso.

Sfortunatamente, in Ruby on Rails, è semplicissimo superare la soglia di 10 secondi con la generazione del template. Non lo vedrai accadere in nessuna app "ciao mondo" o progetto di animali domestici su piccola scala, ma nei progetti del mondo reale in cui molte cose vengono caricate su una singola pagina, credimi, la generazione di modelli può iniziare molto facilmente a trascinarsi.

Ed è esattamente ciò che dovevo risolvere nel mio progetto.

Ottimizzazioni semplici

Ma come si accelera esattamente le cose?

La risposta: benchmark e ottimizzazione.

Nel mio progetto, due passaggi molto efficaci nell'ottimizzazione sono stati:

  • Eliminazione di N+1 query
  • Presentazione di una buona tecnica di memorizzazione nella cache per i modelli

N+1 query

Risolvere N+1 query è facile. Quello che puoi fare è controllare i tuoi file di registro: ogni volta che vedi più query SQL come quelle seguenti nei tuoi registri, eliminale sostituendole con caricamento ansioso:

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

C'è una gemma per questo che si chiama bullet per aiutare a rilevare questa inefficienza. Puoi anche esaminare ciascuno dei casi d'uso e, nel frattempo, controllare i registri ispezionandoli rispetto allo schema sopra. Eliminando tutte le inefficienze N+1, puoi essere abbastanza sicuro di non sovraccaricare il tuo database e il tuo tempo trascorso su ActiveRecord diminuirà in modo significativo.

Dopo aver apportato queste modifiche, il mio progetto stava già funzionando più rapidamente. Ma ho deciso di portarlo al livello successivo e vedere se potevo ridurre ulteriormente il tempo di caricamento. C'era ancora un bel po' di rendering non necessario nei modelli e, in definitiva, è qui che la memorizzazione nella cache dei frammenti ha aiutato.

Memorizzazione nella cache dei frammenti

La memorizzazione nella cache dei frammenti generalmente aiuta a ridurre significativamente il tempo di generazione del modello. Ma il comportamento predefinito della cache di Rails non lo stava tagliando per il mio progetto.

L'idea alla base della memorizzazione nella cache dei frammenti di Rails è geniale. Fornisce un meccanismo di memorizzazione nella cache super semplice ed efficace.

Gli autori di Ruby On Rails hanno scritto un ottimo articolo in Signal v. Noise su come funziona la memorizzazione nella cache dei frammenti.

Diciamo che hai un po' di interfaccia utente che mostra alcuni campi di un'entità.

  • Al caricamento della pagina, Rails calcola la cache_key in base alla classe dell'entità e al campo updated_at .
  • Usando quel cache_key , controlla se c'è qualcosa nella cache associato a quella chiave.
  • Se non c'è nulla nella cache, viene eseguito il rendering del codice HTML per quel frammento per la vista (e il contenuto appena visualizzato viene archiviato nella cache).
  • Se c'è del contenuto esistente nella cache con quella chiave, la vista viene renderizzata con il contenuto della cache.

Ciò implica che la cache non deve mai essere invalidata in modo esplicito. Ogni volta che cambiamo l'entità e ricarichiamo la pagina, viene visualizzato il nuovo contenuto della cache per l'entità.

Rails, per impostazione predefinita, offre anche la possibilità di invalidare la cache delle entità padre nel caso in cui il figlio cambi:

 belongs_to :parent_entity, touch: true

Questo, se incluso in un modello, toccherà automaticamente il genitore quando viene toccato il bambino. Puoi saperne di più sul touch qui. Con questo, Rails ci fornisce un modo semplice ed efficiente per invalidare la cache per le nostre entità padre contemporaneamente alla cache per le entità figlio.

Memorizzazione nella cache di Rails

Tuttavia, la memorizzazione nella cache in Rails viene creata per servire interfacce utente in cui il frammento HTML che rappresenta l'entità padre contiene frammenti HTML che rappresentano esclusivamente le entità figlio dell'entità padre. In altre parole, il frammento HTML che rappresenta le entità figlio in questo paradigma non può contenere campi dell'entità padre.

Ma non è quello che succede nel mondo reale. Potresti benissimo aver bisogno di fare cose nella tua applicazione Rails che violano questa condizione.

Come gestiresti una situazione in cui l'interfaccia utente mostra i campi di un'entità padre all'interno del frammento HTML che rappresenta l'entità figlio?

Frammenti per entità figlio che fanno riferimento a campi di entità padre

Se il figlio contiene campi dell'entità padre, allora hai problemi con il comportamento di invalidamento della cache predefinito di Rails.

Ogni volta che i campi presentati dall'entità padre vengono modificati, dovrai toccare tutte le entità figlio appartenenti a tale genitore. Ad esempio, se Parent1 viene modificato, sarà necessario assicurarsi che la cache per le Child1 e Child2 siano entrambe invalidate.

Ovviamente, questo può causare un enorme collo di bottiglia delle prestazioni. Toccare ogni entità figlio ogni volta che un genitore è cambiato comporterebbe molte query sul database senza una buona ragione.

Un altro scenario simile è quando le entità associate all'associazione has_and_belongs_to sono state presentate nell'elenco e la modifica di tali entità ha avviato una cascata di invalidamento della cache attraverso la catena di associazione.

Associazione "Ha e appartiene 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

Quindi, per l'interfaccia utente di cui sopra, sarebbe illogico toccare il partecipante o l'evento quando cambia la posizione dell'utente. Ma dovremmo toccare sia l'evento che il partecipante quando il nome dell'utente cambia, no?

Quindi le tecniche nell'articolo Signal v. Noise sono inefficienti per alcune istanze UI/UX, come descritto sopra.

Sebbene Rails sia super efficace per le cose semplici, i progetti reali hanno le loro complicazioni.

Invalidazione della cache Rails a livello di campo

Nei miei progetti, ho utilizzato una piccola Ruby DSL per gestire situazioni come quella sopra. Ti consente di specificare in modo dichiarativo i campi che attiveranno l'invalidazione della cache attraverso le associazioni.

Diamo un'occhiata ad alcuni esempi di dove aiuta davvero:

Esempio 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

Questo frammento sfrutta le capacità di metaprogrammazione e le capacità DSL interne di Ruby.

Per essere più specifici, solo una modifica del nome nell'evento invaliderà la cache dei frammenti delle relative attività. La modifica di altri campi dell'evento, come lo scopo o la posizione, non invaliderà la cache dei frammenti dell'attività. Chiamerei questo controllo di invalidamento della cache a grana fine a livello di campo .

Frammento per un'entità evento con solo il campo del nome

Esempio 2:

Diamo un'occhiata a un esempio che mostra l'invalidazione della cache attraverso la catena di associazione has_many .

Il frammento dell'interfaccia utente mostrato di seguito mostra un'attività e il suo proprietario:

Frammento per un'entità evento con il nome del proprietario dell'evento

Per questa interfaccia utente, il frammento HTML che rappresenta l'attività deve essere invalidato solo quando l'attività cambia o quando cambia il nome del proprietario. Se tutti gli altri campi del proprietario (come il fuso orario o le preferenze) cambiano, la cache delle attività dovrebbe essere lasciata intatta.

Ciò si ottiene utilizzando la DSL mostrata qui:

 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

Implementazione della DSL

L'essenza principale della DSL è il metodo touch . Il suo primo argomento è un'associazione e l'argomento successivo è un elenco di campi che attiva il touch su quell'associazione:

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

Questo metodo è fornito dal modulo 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

In questo codice, il punto principale è che memorizziamo gli argomenti della chiamata touch . Quindi, prima di salvare l'entità, segniamo l'associazione come sporca se il campo specificato è stato modificato. Tocchiamo le entità in quell'associazione dopo aver salvato se l'associazione era sporca.

Quindi, la parte privata della preoccupazione è:

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

Nel metodo check_touchable_entities , controlliamo se il campo dichiarato è cambiato . In tal caso, contrassegniamo l'associazione come sporca impostando meta_info[association] su true .

Quindi, dopo aver salvato l'entità, controlliamo le nostre associazioni sporche e tocchiamo le entità in essa contenute, se necessario:

 … 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 questo è tutto! Ora puoi eseguire l'invalidazione della cache a livello di campo in Rails con un semplice DSL.

Conclusione

La memorizzazione nella cache di Rails promette miglioramenti delle prestazioni nella tua applicazione con relativa facilità. Tuttavia, le applicazioni del mondo reale possono essere complicate e spesso pongono sfide uniche. Il comportamento predefinito della cache di Rails funziona bene per la maggior parte degli scenari, ma ci sono alcuni scenari in cui un po' più di ottimizzazione nell'invalidazione della cache può fare molto.

Ora che sai come implementare l'invalidazione della cache a livello di campo in Rails, puoi prevenire invalidazioni non necessarie delle cache nella tua applicazione.