Rails-Cache-Invalidierung auf Feldebene: Eine DSL-Lösung
Veröffentlicht: 2022-03-11In der modernen Webentwicklung ist Caching eine schnelle und leistungsstarke Möglichkeit, die Dinge zu beschleunigen. Bei richtiger Ausführung kann Caching die Gesamtleistung Ihrer Anwendung erheblich verbessern. Wenn es falsch gemacht wird, endet es mit Sicherheit in einer Katastrophe.
Cache-Invalidierung ist, wie Sie vielleicht wissen, eines der drei schwierigsten Probleme in der Informatik – die anderen beiden sind Benennen von Dingen und Off-by-One-Fehler. Ein einfacher Ausweg besteht darin, alles links und rechts ungültig zu machen, wenn sich etwas ändert. Aber das widerspricht dem Zweck des Cachings. Sie möchten den Cache nur dann ungültig machen, wenn es absolut notwendig ist.
Wenn Sie das Caching optimal nutzen möchten, müssen Sie sehr genau darauf achten, was Sie ungültig machen, und Ihre Anwendung davor bewahren, wertvolle Ressourcen für wiederholte Arbeit zu verschwenden.
In diesem Blogbeitrag lernen Sie eine Technik kennen, mit der Sie besser kontrollieren können, wie sich Rails-Caches verhalten: insbesondere die Implementierung der Cache-Invalidierung auf Feldebene. Diese Technik stützt sich auf Rails ActiveRecord und ActiveSupport::Concern
sowie auf die Manipulation des Verhaltens der touch
-Methode.
Dieser Blogbeitrag basiert auf meinen jüngsten Erfahrungen in einem Projekt, bei dem wir nach der Implementierung der Cache-Invalidierung auf Feldebene eine deutliche Leistungsverbesserung festgestellt haben. Es hat dazu beigetragen, unnötige Cache-Invalidierungen und das wiederholte Rendern von Vorlagen zu reduzieren.
Schienen, Rubin und Leistung
Ruby ist nicht die schnellste Sprache, aber insgesamt eine geeignete Option, wenn es um die Entwicklungsgeschwindigkeit geht. Darüber hinaus geben die Metaprogrammierung und die integrierten domänenspezifischen Sprachfunktionen (DSL) dem Entwickler eine enorme Flexibilität.
Es gibt Studien wie die von Jakob Nielsen, die uns zeigen, dass wir unseren Fokus verlieren, wenn eine Aufgabe länger als 10 Sekunden dauert. Und die Wiedererlangung unseres Fokus braucht Zeit. Das kann also unerwartet kostspielig werden.
Leider ist es in Ruby on Rails sehr einfach, diese 10-Sekunden-Schwelle mit der Vorlagengenerierung zu überschreiten. Sie werden das nicht in einer „Hallo Welt“-App oder einem kleinen Haustierprojekt sehen, aber in realen Projekten, wo viele Dinge auf eine einzige Seite geladen werden, kann die Vorlagenerstellung sehr leicht ins Stocken geraten.
Und genau das musste ich in meinem Projekt lösen.
Einfache Optimierungen
Aber wie genau beschleunigt man die Dinge?
Die Antwort: Benchmarken und optimieren.
In meinem Projekt waren zwei sehr effektive Schritte zur Optimierung:
- Eliminierung von N+1-Abfragen
- Einführung einer guten Caching-Technik für Vorlagen
N+1 Abfragen
Das Beheben von N+1-Abfragen ist einfach. Was Sie tun können, ist, Ihre Protokolldateien zu überprüfen – wann immer Sie mehrere SQL-Abfragen wie die folgenden in Ihren Protokollen sehen, beseitigen Sie sie, indem Sie sie durch eifriges Laden ersetzen:
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' = ?
Dafür gibt es ein Juwel namens Bullet, das dabei hilft, diese Ineffizienz zu erkennen. Sie können auch jeden der Anwendungsfälle durchgehen und in der Zwischenzeit die Protokolle überprüfen, indem Sie sie anhand des obigen Musters überprüfen. Indem Sie alle N+1-Ineffizienzen eliminieren, können Sie sicher genug sein, dass Sie Ihre Datenbank nicht überlasten und Ihre Zeit, die Sie mit ActiveRecord verbringen, erheblich sinken wird.
Nach diesen Änderungen lief mein Projekt bereits zügiger. Aber ich beschloss, es auf die nächste Stufe zu bringen und zu sehen, ob ich die Ladezeit noch weiter verkürzen könnte. Es gab immer noch ziemlich viel unnötiges Rendering in den Templates, und letztendlich half hier das Zwischenspeichern von Fragmenten.
Fragment-Caching
Das Zwischenspeichern von Fragmenten trägt im Allgemeinen dazu bei, die Zeit für die Vorlagenerstellung erheblich zu verkürzen. Aber das standardmäßige Rails-Cache-Verhalten war für mein Projekt nicht geeignet.
Die Idee hinter dem Zwischenspeichern von Rails-Fragmenten ist brillant. Es bietet einen supereinfachen und effektiven Caching-Mechanismus.
Die Autoren von Ruby On Rails haben in Signal v. Noise einen sehr guten Artikel darüber geschrieben, wie Fragment-Caching funktioniert.
Nehmen wir an, Sie haben eine kleine Benutzeroberfläche, die einige Felder einer Entität anzeigt.
- Beim Laden der Seite berechnet Rails den
cache_key
basierend auf der Klasse der Entität und dem Feldupdated_at
. - Unter Verwendung dieses
cache_key
prüft es, ob es etwas im Cache gibt, das diesem Schlüssel zugeordnet ist. - Wenn sich nichts im Cache befindet, wird der HTML-Code für dieses Fragment für die Ansicht gerendert (und der neu gerenderte Inhalt wird im Cache gespeichert).
- Wenn im Cache bereits Inhalte mit diesem Schlüssel vorhanden sind, wird die Ansicht mit den Inhalten des Caches gerendert.
Dies impliziert, dass der Cache niemals explizit ungültig gemacht werden muss. Immer wenn wir die Entität ändern und die Seite neu laden, wird neuer Cache-Inhalt für die Entität gerendert.
Rails bietet standardmäßig auch die Möglichkeit, den Cache der übergeordneten Entitäten ungültig zu machen, falls sich das Kind ändert:
belongs_to :parent_entity, touch: true
Wenn dies in einem Modell enthalten ist, berührt es automatisch das Elternteil, wenn das Kind berührt wird . Hier erfahren Sie mehr über touch
. Damit bietet uns Rails eine einfache und effiziente Möglichkeit, den Cache für unsere übergeordneten Entitäten gleichzeitig mit dem Cache für die untergeordneten Entitäten ungültig zu machen.
Zwischenspeichern in Rails
Das Caching in Rails wird jedoch erstellt, um Benutzerschnittstellen bereitzustellen, bei denen das HTML-Fragment, das die übergeordnete Entität darstellt, HTML-Fragmente enthält, die nur die untergeordneten Entitäten der übergeordneten Entität darstellen. Mit anderen Worten, das HTML-Fragment, das die untergeordneten Entitäten in diesem Paradigma darstellt, kann keine Felder der übergeordneten Entität enthalten.
Aber das ist nicht das, was in der realen Welt passiert. Möglicherweise müssen Sie in Ihrer Rails-Anwendung Dinge tun, die gegen diese Bedingung verstoßen.
Wie würden Sie mit einer Situation umgehen, in der die Benutzeroberfläche Felder einer übergeordneten Entität innerhalb des HTML-Fragments anzeigt, das die untergeordnete Entität darstellt?

Wenn das untergeordnete Element Felder der übergeordneten Entität enthält, haben Sie Probleme mit dem standardmäßigen Cache-Invalidierungsverhalten von Rails.
Jedes Mal, wenn diese von der übergeordneten Entität präsentierten Felder geändert werden, müssen Sie alle untergeordneten Entitäten berühren, die zu dieser übergeordneten Entität gehören. Wenn beispielsweise Parent1
geändert wird, müssen Sie sicherstellen, dass der Cache für die Ansichten Child1
und Child2
ungültig gemacht wird.
Offensichtlich kann dies zu einem enormen Leistungsengpass führen. Das Berühren jeder untergeordneten Entität, wenn sich ein übergeordnetes Element geändert hat, würde ohne triftigen Grund zu vielen Datenbankabfragen führen.
Ein weiteres ähnliches Szenario ist, wenn die Entitäten, die der has_and_belongs_to
zugeordnet sind, in der Liste angezeigt wurden und das Ändern dieser Entitäten eine Kaskade der Cache-Invalidierung durch die Zuordnungskette startete.
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
Für die obige Benutzeroberfläche wäre es also unlogisch, den Teilnehmer oder das Ereignis zu berühren, wenn sich der Standort des Benutzers ändert. Aber wir sollten sowohl das Ereignis als auch den Teilnehmer berühren, wenn sich der Name des Benutzers ändert, oder?
Daher sind die Techniken im Artikel „Signal vs. Noise“ für bestimmte UI/UX-Instanzen ineffizient, wie oben beschrieben.
Obwohl Rails für einfache Dinge super effektiv ist, haben echte Projekte ihre eigenen Komplikationen.
Rails-Cache-Invalidierung auf Feldebene
In meinen Projekten habe ich eine kleine Ruby-DSL verwendet, um Situationen wie die oben genannten zu handhaben. Es ermöglicht Ihnen, deklarativ die Felder anzugeben, die eine Cache-Invalidierung durch die Assoziationen auslösen.
Schauen wir uns ein paar Beispiele an, wo es wirklich hilft:
Beispiel 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
Dieses Snippet nutzt die Metaprogrammierungsfähigkeiten und inneren DSL-Fähigkeiten von Ruby.
Genauer gesagt wird nur eine Namensänderung im Ereignis den Fragment-Cache der zugehörigen Aufgaben ungültig machen. Das Ändern anderer Felder des Ereignisses – wie Zweck oder Ort – macht den Fragment-Cache der Aufgabe nicht ungültig. Ich würde dies als feinkörnige Cache-Invalidierungskontrolle auf Feldebene bezeichnen.
Beispiel 2:
Schauen wir uns ein Beispiel an, das die Cache-Invalidierung durch die Assoziationskette has_many
zeigt.
Das unten gezeigte Fragment der Benutzeroberfläche zeigt eine Aufgabe und ihren Besitzer:
Für diese Benutzeroberfläche sollte das HTML-Fragment, das die Aufgabe darstellt, nur ungültig gemacht werden, wenn sich die Aufgabe ändert oder wenn sich der Name des Eigentümers ändert. Wenn sich alle anderen Felder des Eigentümers (wie Zeitzone oder Einstellungen) ändern, sollte der Aufgaben-Cache intakt bleiben.
Dies wird mit der hier gezeigten DSL erreicht:
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
Implementierung des DSL
Die Hauptessenz des DSL ist die touch
-Methode. Sein erstes Argument ist eine Assoziation, und das nächste Argument ist eine Liste von Feldern, die die touch
dieser Assoziation auslösen:
touch :tasks, in_case_of_modified_fields: [:first_name, :last_name]
Diese Methode wird vom Touchable
-Modul bereitgestellt:
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 diesem Code ist der Hauptpunkt, dass wir die Argumente des touch
-Aufrufs speichern. Dann markieren wir vor dem Speichern der Entität die Assoziation als schmutzig, wenn das angegebene Feld geändert wurde. Wir berühren die Entitäten in dieser Assoziation nach dem Speichern, wenn die Assoziation schmutzig war.
Dann ist der private Teil des Anliegens:
... 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 …
In der Methode check_touchable_entities
prüfen wir, ob sich das deklarierte Feld geändert hat . Wenn dies der Fall ist, markieren wir die Zuordnung als schmutzig, indem wir meta_info[association]
auf true
setzen.
Dann überprüfen wir nach dem Speichern der Entität unsere schmutzigen Assoziationen und berühren bei Bedarf die darin enthaltenen Entitäten:
… 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 …
Und das ist alles! Jetzt können Sie Cache-Invalidierung auf Feldebene in Rails mit einer einfachen DSL durchführen.
Fazit
Rails-Caching verspricht Leistungsverbesserungen in Ihrer Anwendung mit relativer Leichtigkeit. Reale Anwendungen können jedoch kompliziert sein und stellen oft einzigartige Herausforderungen dar. Das standardmäßige Rails-Cache-Verhalten funktioniert für die meisten Szenarien gut, aber es gibt bestimmte Szenarien, in denen etwas mehr Optimierung bei der Cache-Invalidierung viel bewirken kann.
Nachdem Sie nun wissen, wie Sie die Cache-Invalidierung auf Feldebene in Rails implementieren, können Sie unnötige Invalidierungen von Caches in Ihrer Anwendung verhindern.