Invalidarea memoriei cache a șinelor la nivel de câmp: o soluție DSL

Publicat: 2022-03-11

În dezvoltarea web modernă, memorarea în cache este o modalitate rapidă și puternică de a accelera lucrurile. Când este făcută corect, memorarea în cache poate aduce îmbunătățiri semnificative performanței generale a aplicației dvs. Când este făcut greșit, cu siguranță se va termina cu un dezastru.

Invalidarea memoriei cache, după cum probabil știți, este una dintre cele trei probleme cele mai grele din informatică — celelalte două fiind denumirea lucrurilor și erorile separate. O cale de ieșire ușoară este să invalidezi totul, la stânga și la dreapta, ori de câte ori se schimbă ceva. Dar asta înfrânge scopul stocării în cache. Doriți să invalidați memoria cache numai atunci când este absolut necesar.

Dacă doriți să profitați la maximum de stocarea în cache, trebuie să fiți foarte precis cu privire la ceea ce invalidați și să salvați aplicația dvs. de la irosirea resurselor prețioase în munca repetă.

Invalidarea cache-ului Rails la nivel de câmp

În această postare pe blog, veți învăța o tehnică pentru a avea un control mai bun asupra modului în care se comportă cache-urile Rails: în special, implementarea invalidării cache-ului la nivel de câmp. Această tehnică se bazează pe Rails ActiveRecord și ActiveSupport::Concern , precum și pe manipularea comportamentului metodei touch .

Această postare pe blog se bazează pe experiențele mele recente într-un proiect în care am observat o îmbunătățire semnificativă a performanței după implementarea invalidării cache-ului la nivel de câmp. A ajutat la reducerea invalidărilor inutile ale memoriei cache și redarea repetată a șabloanelor.

Sine, Ruby și Performanță

Ruby nu este cel mai rapid limbaj, dar, în general, este o opțiune potrivită în ceea ce privește viteza de dezvoltare. În plus, capabilitățile sale de metaprogramare și limbajul specific domeniului (DSL) încorporate oferă dezvoltatorului o flexibilitate extraordinară.

Există studii precum studiul lui Jakob Nielsen care ne arată că, dacă o sarcină durează mai mult de 10 secunde, ne vom pierde concentrarea. Și pentru a ne recăpăta concentrarea necesită timp. Deci, acest lucru poate fi neașteptat de costisitor.

Din păcate, în Ruby on Rails, este foarte ușor să depășești acel prag de 10 secunde cu generarea șablonului. Nu veți vedea că acest lucru se întâmplă în nicio aplicație „hello world” sau în niciun proiect pentru animale de companie la scară mică, dar în proiectele din lumea reală în care o mulțime de lucruri sunt încărcate pe o singură pagină, credeți-mă, generarea de șabloane poate începe foarte ușor să treacă.

Și, exact asta a trebuit să rezolv în proiectul meu.

Optimizări simple

Dar cum anume accelerezi lucrurile?

Răspunsul: comparați și optimizați.

În proiectul meu, doi pași foarte eficienți în optimizare au fost:

  • Eliminarea N+1 interogări
  • Introducerea unei bune tehnici de stocare în cache pentru șabloane

N+1 interogări

Remedierea N+1 interogări este ușoară. Ce puteți face este să vă verificați fișierele jurnal - ori de câte ori vedeți mai multe interogări SQL precum cele de mai jos în jurnalele dvs., eliminați-le prin înlocuirea lor cu încărcare dornică:

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

Există o bijuterie pentru aceasta care se numește glonț pentru a ajuta la detectarea acestei ineficiențe. Puteți, de asemenea, să parcurgeți fiecare dintre cazurile de utilizare și, între timp, să verificați jurnalele inspectându-le în raport cu modelul de mai sus. Prin eliminarea tuturor ineficiențelor N+1, puteți fi suficient de sigur că nu vă veți supraîncărca baza de date, iar timpul petrecut pe ActiveRecord va scădea semnificativ.

După ce am făcut aceste modificări, proiectul meu rula deja mai rapid. Dar am decis să o duc la următorul nivel și să văd dacă pot reduce și mai mult timpul de încărcare. În șabloane a existat încă un pic de randare inutilă și, în cele din urmă, acolo a ajutat stocarea în cache a fragmentelor.

Memorarea în cache a fragmentelor

Memorarea în cache a fragmentelor ajută, în general, la reducerea semnificativă a timpului de generare a șablonului. Dar comportamentul implicit de cache Rails nu a fost tăiat pentru proiectul meu.

Ideea din spatele stocării în cache a fragmentelor Rails este genială. Oferă un mecanism de stocare în cache super simplu și eficient.

Autorii cărții Ruby On Rails au scris un articol foarte bun în Signal v. Noise despre modul în care funcționează stocarea în cache a fragmentelor.

Să presupunem că aveți un pic de interfață cu utilizatorul care arată unele câmpuri ale unei entități.

  • La încărcarea paginii, Rails calculează cache_key pe baza clasei entității și a câmpului updated_at .
  • Folosind acea cache_key , verifică dacă există ceva în cache asociat cu acea cheie.
  • Dacă nu există nimic în cache, atunci codul HTML pentru fragmentul respectiv este redat pentru vizualizare (iar conținutul nou redat este stocat în cache).
  • Dacă există conținut existent în cache cu acea cheie, atunci vizualizarea este redată cu conținutul cache-ului.

Aceasta înseamnă că memoria cache nu trebuie să fie niciodată invalidată în mod explicit. Ori de câte ori schimbăm entitatea și reîncărcăm pagina, un nou conținut cache este redat pentru entitate.

Rails, în mod implicit, oferă și capacitatea de a invalida memoria cache a entităților părinte în cazul în care copilul se schimbă:

 belongs_to :parent_entity, touch: true

Acesta, atunci când este inclus într-un model, va atinge automat părintele atunci când copilul este atins . Puteți afla mai multe despre touch aici. Prin aceasta, Rails ne oferă o modalitate simplă și eficientă de a invalida cache-ul pentru entitățile noastre părinte simultan cu cache-ul pentru entitățile copil.

Memorarea în cache în șine

Cu toate acestea, memorarea în cache în Rails este creată pentru a servi interfețe cu utilizatorul în care fragmentul HTML care reprezintă entitatea părinte conține fragmente HTML care reprezintă numai entitățile copil ale părintelui. Cu alte cuvinte, fragmentul HTML care reprezintă entitățile copil din această paradigmă nu poate conține câmpuri din entitatea părinte.

Dar nu asta se întâmplă în lumea reală. Este posibil să aveți nevoie să faceți lucruri în aplicația Rails care încalcă această condiție.

Cum ați gestiona o situație în care interfața cu utilizatorul arată câmpuri ale unei entități părinte în interiorul fragmentului HTML reprezentând entitatea copil?

Fragmente pentru entitățile copil care se referă la câmpurile entităților părinte

Dacă copilul conține câmpuri de la entitatea părinte, atunci aveți probleme cu comportamentul implicit de invalidare a cache-ului Rails.

De fiecare dată când acele câmpuri prezentate de la entitatea părinte sunt modificate, va trebui să atingeți toate entitățile copil care aparțin acelui părinte. De exemplu, dacă Parent1 este modificat, va trebui să vă asigurați că memoria cache pentru vizualizările Child1 și Child2 sunt ambele invalidate.

Evident, acest lucru poate cauza un blocaj uriaș de performanță. Atingerea fiecărei entități copil ori de câte ori un părinte s-a schimbat ar avea ca rezultat o mulțime de interogări la baza de date fără un motiv întemeiat.

Un alt scenariu similar este atunci când entitățile asociate cu asociația has_and_belongs_to au fost prezentate în listă, iar modificarea acelor entități a început o cascadă de invalidare a cache-ului prin lanțul de asociere.

Asociația „Are și aparține”.

 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

Deci, pentru interfața de utilizator de mai sus, ar fi ilogic să atingeți participantul sau evenimentul atunci când locația utilizatorului se schimbă. Dar ar trebui să atingem atât evenimentul, cât și participantul atunci când numele utilizatorului se schimbă, nu-i așa?

Prin urmare, tehnicile din articolul Signal v. Noise sunt ineficiente pentru anumite instanțe UI/UX, așa cum este descris mai sus.

Deși Rails este super eficient pentru lucruri simple, proiectele reale au propriile lor complicații.

Invalidarea memoriei cache a șinelor la nivel de câmp

În proiectele mele, am folosit un mic Ruby DSL pentru a gestiona situații precum cele de mai sus. Vă permite să specificați declarativ câmpurile care vor declanșa invalidarea cache-ului prin asociații.

Să aruncăm o privire la câteva exemple de cazuri în care ajută cu adevărat:

Exemplul 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

Acest fragment folosește abilitățile de metaprogramare și capacitățile interioare DSL ale Ruby.

Pentru a fi mai specific, doar o schimbare a numelui în eveniment va invalida cache-ul fragment al sarcinilor aferente acestuia. Schimbarea altor câmpuri ale evenimentului, cum ar fi scopul sau locația, nu va invalida cache-ul fragment al sarcinii. Aș numi acest control al invalidării cache-ului la nivel de câmp .

Fragment pentru o entitate de eveniment cu doar câmpul de nume

Exemplul 2:

Să aruncăm o privire la un exemplu care arată invalidarea cache-ului prin lanțul de asociere has_many .

Fragmentul de interfață cu utilizatorul prezentat mai jos arată o sarcină și proprietarul acesteia:

Fragment pentru o entitate de eveniment cu numele proprietarului evenimentului

Pentru această interfață cu utilizatorul, fragmentul HTML care reprezintă sarcina ar trebui să fie invalidat numai atunci când sarcina se schimbă sau când se schimbă numele proprietarului. Dacă toate celelalte câmpuri ale proprietarului (cum ar fi fusul orar sau preferințele) se modifică, atunci memoria cache a sarcinilor ar trebui lăsată intactă.

Acest lucru se realizează folosind DSL-ul prezentat aici:

 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

Implementarea DSL

Esența principală a DSL este metoda touch . Primul său argument este o asociere, iar următorul argument este o listă de câmpuri care declanșează touch acelei asocieri:

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

Această metodă este furnizată de modulul 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

În acest cod, punctul principal este că stocăm argumentele apelului touch . Apoi, înainte de a salva entitatea, marchem asocierea murdară dacă câmpul specificat a fost modificat. Atingem entitățile din acea asociație după ce salvăm dacă asociația a fost murdară.

Apoi, partea privată a preocupării este:

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

În metoda check_touchable_entities , verificăm dacă câmpul declarat s-a schimbat . Dacă da, marcăm asocierea ca murdară setând meta_info[association] la true .

Apoi, după salvarea entității, verificăm asociațiile noastre murdare și atingem entitățile din ea dacă este necesar:

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

Și, asta este! Acum puteți efectua invalidarea cache-ului la nivel de câmp în Rails cu un simplu DSL.

Concluzie

Memorarea în cache pe șine promite îmbunătățiri ale performanței aplicației dvs. cu relativă ușurință. Cu toate acestea, aplicațiile din lumea reală pot fi complicate și adesea prezintă provocări unice. Comportamentul implicit al cache-ului Rails funcționează bine pentru majoritatea scenariilor, dar există anumite scenarii în care puțin mai multă optimizare a invalidării cache-ului poate merge mult.

Acum că știți cum să implementați invalidarea cache-ului la nivel de câmp în Rails, puteți preveni invalidările inutile ale cache-urilor în aplicația dvs.