Go Caching Best Practices
Grace Collins
Solutions Engineer · Leapcell

Caching ist unerlässlich, um API-Anwendungen zu beschleunigen. Wenn also eine hohe Leistung erforderlich ist, ist Caching in der Designphase unerlässlich.
Bei der Erwägung von Caching in der Designphase ist es am wichtigsten, abzuschätzen, wie viel Speicher benötigt wird.
Zuerst müssen wir genau klären, welche Daten gecacht werden müssen.
In Anwendungen mit kontinuierlich wachsenden Nutzerzahlen ist es nicht möglich, alle verwendeten Daten zu cachen.
Dies liegt daran, dass der lokale Speicher der Anwendung durch die physischen Ressourcen eines einzelnen Rechners begrenzt ist. Wenn Daten ohne Einschränkung gecacht werden, führt dies letztendlich zu OOM (Out of Memory), was dazu führt, dass die Anwendung zwangsweise beendet wird.
Wenn ein verteilter Cache verwendet wird, zwingen uns die hohen Hardwarekosten auch zu Kompromissen.
Wären die physischen Ressourcen unbegrenzt, wäre es natürlich am besten, alles in den schnellsten physischen Geräten zu speichern.
Aber reale Geschäftsszenarien erlauben dies nicht. Daher müssen wir Daten in heiße und kalte Daten einteilen und kalte Daten sogar entsprechend archivieren und komprimieren und sie auf wirtschaftlicheren Medien speichern.
Die Analyse, welche Daten im lokalen Speicher gespeichert werden können, ist der erste Schritt zur Implementierung eines effektiven lokalen Caching.
Ausbalancieren von zustandsbehafteten und zustandslosen Anwendungen
Sobald Daten lokal in der Anwendung gespeichert sind, ist die Anwendung in einem verteilten System nicht mehr zustandslos.
Nehmen wir als Beispiel eine Web-Backend-Anwendung: Wenn wir 10 Pods als Backend-Anwendungen bereitstellen und einer der Pods, die Anfragen bearbeiten, Caching hinzufügt, sind die entsprechenden Daten nicht zugänglich, wenn dieselbe Anfrage an einen anderen Pod weitergeleitet wird.
Es gibt drei Lösungen:
- Verwenden Sie verteiltes Caching wie Redis
- Leiten Sie dieselbe Anfrage an denselben Pod weiter
- Cachen Sie dieselben Daten in jedem Pod
Die erste Methode bedarf keiner weiteren Erklärung; dadurch wird die Speicherung im Wesentlichen zentralisiert.
Die zweite Methode erfordert spezifische Identifikationsinformationen, wie z. B. die UID des Benutzers, um eine spezielle Weiterleitungslogik zu implementieren, und ist durch praktische Szenarien begrenzt.
Die dritte Methode verbraucht mehr Speicherplatz. Im Vergleich zur zweiten Methode müssen wir Daten in jedem Pod speichern. Obwohl es nicht als völlig zustandslos angesehen werden kann, ist die Wahrscheinlichkeit einer Cache-Penetration geringer als bei der zweiten Methode. Dies liegt daran, dass andere Pods die Anfrage weiterhin normal verarbeiten können, wenn das Gateway Anfragen nicht an den Pod mit den spezifischen Daten weiterleiten kann.
Es gibt keine Universallösung; wählen Sie die Methode basierend auf den tatsächlichen Szenarien. Je weiter der Cache jedoch von der Anwendung entfernt ist, desto länger dauert der Zugriff.
Goim maximiert Cache-Treffer auch durch Speicherausrichtung.
Wenn die CPU Berechnungen durchführt, sucht sie zuerst nach den benötigten Daten in L1, dann in L2 und dann in L3 Cache. Wenn die Daten in keinem dieser Caches gefunden werden, müssen sie aus dem Hauptspeicher abgerufen werden. Je weiter die Daten entfernt sind, desto länger dauert die Berechnung.
Eviction-Richtlinie
Wenn eine strenge Kontrolle der Speichergröße für den Cache erforderlich ist, können Sie die LRU-Richtlinie (Least Recently Used) verwenden, um den Speicher zu verwalten. Sehen wir uns die Go-Implementierung des LRU-Cache an.
LRU-Cache
Der LRU-Cache eignet sich für Szenarien, in denen Sie die Cache-Größe steuern und weniger häufig verwendete Elemente automatisch entfernen müssen.
Wenn Sie beispielsweise nur 128 Schlüssel-Wert-Paare speichern möchten, fügt der LRU-Cache so lange neue Einträge hinzu, bis das Limit erreicht ist. Immer wenn auf ein zwischengespeichertes Element zugegriffen oder ein neuer Wert hinzugefügt wird, wird sein Schlüssel nach vorne verschoben, wodurch verhindert wird, dass er entfernt wird.
https://github.com/hashicorp/golang-lru ist eine Go-Implementierung des LRU-Cache.
Sehen wir uns einen Beispieltest an, um zu sehen, wie LRU verwendet wird:
func TestLRU(t *testing.T) { l, _ := lru.New for i := 0; i < 256; i++ { l.Add(i, i+1) } // Value has not been evicted value, ok := l.Get(200) assert.Equal(t, true, ok) assert.Equal(t, 201, value.(int)) // Value has already been evicted value, ok = l.Get(1) assert.Equal(t, false, ok) assert.Equal(t, nil, value) }
Wie Sie sehen, wurde der Schlüssel 200 nicht entfernt, sodass er weiterhin zugänglich ist.
Der Schlüssel 1 hat jedoch die Cache-Größenbeschränkung von 128 überschritten, sodass er bereits entfernt wurde und nicht mehr abgerufen werden kann.
Dies ist nützlich, wenn die Datenmenge, die Sie speichern möchten, zu groß ist. Die am häufigsten verwendeten Daten werden immer nach vorne verschoben, wodurch die Cache-Trefferrate erhöht wird.
Die interne Implementierung des Open-Source-Pakets verwendet eine verkettete Liste, um alle zwischengespeicherten Elemente zu verwalten.
Jedes Mal, wenn Add
aufgerufen wird und der Schlüssel bereits vorhanden ist, wird er nach vorne verschoben.
func (l *LruList[K, V]) move(e, at *Entry[K, V]) { if e == at { return } e.prev.next = e.next e.next.prev = e.prev e.prev = at e.next = at.next e.prev.next = e e.next.prev = e }
Wenn der Schlüssel nicht vorhanden ist, wird er mit der Methode insert
eingefügt.
func (l *LruList[K, V]) insert(e, at *Entry[K, V]) *Entry[K, V] { e.prev = at e.next = at.next e.prev.next = e e.next.prev = e e.list = l l.len++ return e }
Wenn die Größe des Cache überschritten wurde, wird das Element am Ende der Liste entfernt – das älteste und am wenigsten verwendete.
func (c *LRU[K, V]) removeOldest() { if ent := c.evictList.Back(); ent != nil { c.removeElement(ent) } } func (c *LRU[K, V]) removeElement(e *internal.Entry[K, V]) { c.evictList.Remove(e) delete(c.items, e.Key) // Callback after deleting key if c.onEvict != nil { c.onEvict(e.Key, e.Value) } } func (l *LruList[K, V]) Remove(e *Entry[K, V]) V { e.prev.next = e.next e.next.prev = e.prev // Prevent memory leak, set to nil e.next = nil e.prev = nil e.list = nil l.len-- return e.Value }
Cache-Aktualisierungen
Zeitnahe Cache-Aktualisierungen in verteilten Systemen können Dateninkonsistenzen reduzieren.
Für verschiedene Szenarien sind unterschiedliche Methoden geeignet.
Es gibt verschiedene Situationen beim Abrufen von zwischengespeicherten Daten. Für eine beliebte Rangliste, die nicht benutzerbezogen ist, müssen wir diese Daten beispielsweise im lokalen Cache jedes Pods pflegen. Wenn ein Schreib- oder Aktualisierungsvorgang auftritt, müssen alle Pods benachrichtigt werden, um ihren Cache zu aktualisieren.
Wenn die Daten für jeden Benutzer spezifisch sind, ist es vorzuziehen, die Anfrage in einem festen Pod zu bearbeiten und Anfragen mithilfe einer Benutzerkennung (UID) an denselben Pod weiterzuleiten. Dadurch wird vermieden, dass mehrere Kopien auf verschiedenen Pods gespeichert werden, und der Speicherverbrauch wird reduziert.
Meistens möchten wir, dass unsere Anwendungen zustandslos sind, sodass dieser Teil der zwischengespeicherten Daten in Redis gespeichert wird.
Es gibt drei Hauptstrategien für verteilte Cache-Aktualisierungen: Cache-Aside (Bypass), Write-Through und Write-Back.
Cache-Aside-Strategie (Bypass)
Die Cache-Aside-Strategie (Bypass) ist die, die wir am häufigsten verwenden. Beim Aktualisieren von Daten löschen wir zuerst den Cache und schreiben dann in die Datenbank. Wenn die Daten das nächste Mal gelesen und der Cache als fehlend festgestellt wird, werden sie aus der Datenbank abgerufen und der Cache wird aktualisiert.
Diese Strategie kann bei extrem hoher Lese-QPS zu Inkonsistenzen führen. Das heißt, nachdem der Cache gelöscht, aber bevor die Datenbank aktualisiert wurde, kann eine Leseanfrage eingehen und den alten Wert in den Cache neu laden, sodass nachfolgende Leseanfragen weiterhin den alten Wert aus der Datenbank erhalten.
Obwohl die Wahrscheinlichkeit, dass dies tatsächlich geschieht, gering ist, müssen Sie das Szenario sorgfältig bewerten. Wenn solche Inkonsistenzen für Ihr System katastrophal sind, kann diese Strategie nicht verwendet werden.
Wenn diese Situation akzeptabel ist, Sie aber dennoch Inkonsistenzen minimieren möchten, können Sie eine Cache-Ablaufzeit festlegen. Wenn kein Schreibvorgang stattfindet, läuft der Cache proaktiv ab und aktualisiert die Cache-Daten.
Write-Through- und Write-Back-Strategien
Write-Through- und Write-Back-Strategien aktualisieren beide zuerst den Cache und schreiben dann in die Datenbank – der Unterschied besteht darin, ob die Aktualisierung einzeln oder in Batches erfolgt.
Ein wesentlicher Nachteil dieser Strategien ist, dass Daten leicht verloren gehen können. Obwohl Redis Persistenzstrategien wie das Zurückschreiben auf die Festplatte unterstützt, kann der Verlust von auch nur einer Sekunde an Daten aufgrund eines Serverabsturzes für Anwendungen mit hoher QPS eine enorme Menge sein. Daher müssen Sie eine Entscheidung basierend auf Ihrem Unternehmen und dem tatsächlichen Szenario treffen.
Wenn Redis Ihre Leistungsanforderungen immer noch nicht erfüllt, müssen Sie zwischengespeicherte Inhalte direkt in Anwendungsvariablen (lokaler Cache) speichern, sodass Benutzeranfragen direkt aus dem Speicher ohne Netzwerkanfragen bedient werden.
Im Folgenden werden wir Strategien zum Aktualisieren lokaler Caches in verteilten Szenarien erörtern.
Aktive Benachrichtigungsaktualisierung (ähnlich der Cache-Aside-Strategie)
In verteilten Systemen können Sie ETCD-Broadcasts verwenden, um Cache-Aktualisierungen schnell zu verbreiten, ohne auf das nächste Abrufen zum Neuladen der Daten zu warten.
Dieser Ansatz hat jedoch ein Problem. Wenn beispielsweise zum Zeitpunkt T1 eine Cache-Aktualisierungsbenachrichtigung gesendet wird, die Downstream-Dienste jedoch noch nicht mit der Aktualisierung fertig sind. Zum Zeitpunkt T2 = T1 + 1s wird ein weiteres Cache-Aktualisierungssignal gesendet, während die Aktualisierung um T1 noch nicht abgeschlossen ist.
Dies kann dazu führen, dass der neuere Wert um T2 aufgrund von Unterschieden in der Aktualisierungsgeschwindigkeit durch den älteren Wert von T1 überschrieben wird.
Dies kann behoben werden, indem eine monoton steigende Versionsnummer hinzugefügt wird. Wenn die T2-Version der Daten wirksam wird, kann die T1-Version den Cache nicht mehr aktualisieren, wodurch vermieden wird, dass der neue Wert mit dem alten überschrieben wird.
Mit aktiven Benachrichtigungen können Sie den relevanten Schlüssel angeben, um nur bestimmte zwischengespeicherte Elemente zu aktualisieren, wodurch die hohe Last vermieden wird, die durch das gleichzeitige Aktualisieren aller zwischengespeicherten Daten verursacht wird.
Diese Aktualisierungsstrategie ähnelt der Cache-Aside-Strategie, mit dem Unterschied, dass Sie den lokalen Cache anstelle des verteilten Cache aktualisieren.
Warten auf das Ablaufen des Cache
Dieser Ansatz eignet sich, wenn keine strikte Datenkonsistenz erforderlich ist. Für lokale Caches wird die Wartungsstrategie komplexer, wenn Sie Aktualisierungen an alle Pods weitergeben möchten.
Sie können das Open-Source-Paket https://github.com/patrickmn/go-cache von Go verwenden, um die Cache-Ablaufzeit im Speicher zu verwalten, ohne Ihre eigene Logik zu implementieren.
Sehen wir uns an, wie go-cache lokales Caching implementiert:
Go Cache
https://github.com/patrickmn/go-cache ist ein Open-Source-Paket für lokales Caching für Go.
Intern werden Daten in einer Map gespeichert.
type Cache struct { *cache } type cache struct { defaultExpiration time.Duration items map[string]Item mu sync.RWMutex onEvicted func(string, interface{}) janitor *janitor }
Das Feld items
speichert alle relevanten Daten.
Jedes Mal, wenn Sie Set
oder Get
verwenden, wird die Map items
bearbeitet.
Der janitor
löscht regelmäßig abgelaufene Schlüssel in einem bestimmten Intervall.
func (j *janitor) Run(c *cache) { ticker := time.NewTicker(j.Interval) for { select { case <-ticker.C: c.DeleteExpired() case <-j.stop: ticker.Stop() return } } }
Es verwendet einen Ticker, um Signale auszulösen, und ruft regelmäßig die Methode DeleteExpired
auf, um abgelaufene Schlüssel zu entfernen.
func (c *cache) DeleteExpired() { // Key-value pairs to be evicted var evictedItems []keyAndValue now := time.Now().UnixNano() c.mu.Lock() // Find and delete expired keys for k, v := range c.items { if v.Expiration > 0 && now > v.Expiration { ov, evicted := c.delete(k) if evicted { evictedItems = append(evictedItems, keyAndValue{k, ov}) } } } c.mu.Unlock() // Callback after eviction, if any for _, v := range evictedItems { c.onEvicted(v.key, v.value) } }
Aus dem Code geht hervor, dass die Cache-Ablaufzeit von der regelmäßigen Entfernung abhängt.
Was passiert also, wenn wir versuchen, einen Schlüssel abzurufen, der abgelaufen ist, aber noch nicht gelöscht wurde?
Beim Abrufen von Daten überprüft der Cache auch, ob der Schlüssel abgelaufen ist.
func (c *cache) Get(k string) (interface{}, bool) { c.mu.RLock() // Return directly if not found item, found := c.items[k] if !found { c.mu.RUnlock() return nil, false } // If the item has expired, return nil and wait for periodic deletion if item.Expiration > 0 { if time.Now().UnixNano() > item.Expiration { c.mu.RUnlock() return nil, false } } c.mu.RUnlock() return item.Object, true }
Sie können sehen, dass bei jedem Abrufen eines Werts die Ablaufzeit überprüft wird, um sicherzustellen, dass keine abgelaufenen Schlüssel-Wert-Paare zurückgegeben werden.
Cache Warming
Wie werden Daten beim Start vorgeladen, soll vor dem Start auf den Abschluss der Initialisierung gewartet werden, soll ein segmentierter Start zulässig sein und wird die gleichzeitige Beladung die Middleware belasten – all dies sind Probleme, die beim Cache Warming beim Start berücksichtigt werden müssen.
Wenn Sie warten, bis alle Initialisierungen abgeschlossen sind, bevor Sie den Vorladeprozess starten, der Gesamtressourcenverbrauch jedoch hoch ist, können Sie die Initialisierung und das Vorladen parallel ausführen. Sie müssen jedoch sicherstellen, dass bestimmte Schlüsselkomponenten (z. B. Datenbankverbindungen, Netzwerkdienste usw.) bereits verfügbar sind, um zu vermeiden, dass während des Vorladens Ressourcen nicht verfügbar sind.
Wenn Anfragen vor Abschluss des Ladevorgangs eingehen, muss eine geeignete Fallback-Strategie vorhanden sein, um normale Antworten sicherzustellen.
Der Vorteil des segmentierten Ladens besteht darin, dass es die Initialisierungszeit durch Parallelität reduzieren kann, aber das gleichzeitige Vorladen – obwohl es die Effizienz verbessert – belastet auch die Middleware (z. B. Cache-Server, Datenbanken usw.).
Während der Codierung ist es erforderlich, die Fähigkeit des Systems zur gleichzeitigen Verarbeitung zu bewerten und ein angemessenes Parallelitätslimit festzulegen. Das Anwenden von Ratenbegrenzungsmechanismen kann dazu beitragen, den gleichzeitigen Druck zu verringern und eine Überlastung der Middleware zu verhindern.
In Go können Sie auch Kanäle verwenden, um die Parallelität zu begrenzen.
Cache Warming spielt in realen Produktionsszenarien eine wichtige Rolle. Während der Bereitstellung verschwindet der lokale Cache der Anwendung nach dem Neustart. Bei Rolling Updates gibt es mindestens einen Pod, der Daten vom Ursprung abrufen muss. Wenn die QPS extrem hoch ist, kann die Spitzen-QPS für diesen einzelnen Pod die Datenbank überlasten und einen kaskadierenden Ausfall (Avalanche-Effekt) verursachen.
Es gibt zwei Möglichkeiten, mit dieser Situation umzugehen: Eine besteht darin, Versionsaktualisierungen während der Hauptverkehrszeiten zu vermeiden und sie stattdessen während der verkehrsarmen Zeiten zu planen – dies ist auf Überwachungs-Dashboards leicht zu erkennen.
Die andere Möglichkeit besteht darin, Daten beim Start vorzuladen und Dienste erst nach Abschluss des Ladevorgangs bereitzustellen. Dies kann jedoch die Startzeiten verlängern, wenn aufgrund einer fehlerhaften Version ein Rollback erforderlich ist, wodurch ein schnelles Rollback erschwert wird.
Beide Ansätze haben ihre Vor- und Nachteile. In realen Szenarien sollten Sie je nach Ihren spezifischen Bedürfnissen wählen. Am wichtigsten ist es, die Abhängigkeit von Sonderfällen zu minimieren: Je mehr Abhängigkeiten Sie während der Freigabe haben, desto wahrscheinlicher treten Probleme auf.
Wir sind Leapcell, Ihre erste Wahl für das Hosten von Go-Projekten.
Leapcell ist die Serverless-Plattform der nächsten Generation für Webhosting, asynchrone Aufgaben und Redis:
Unterstützung mehrerer Sprachen
- Entwickeln Sie mit Node.js, Python, Go oder Rust.
Stellen Sie unbegrenzt Projekte kostenlos bereit
- Zahlen Sie nur für die Nutzung – keine Anfragen, keine Gebühren.
Unschlagbare Kosteneffizienz
- Pay-as-you-go ohne Leerlaufgebühren.
- Beispiel: 25 US-Dollar unterstützen 6,94 Millionen Anfragen bei einer durchschnittlichen Antwortzeit von 60 ms.
Optimierte Entwicklererfahrung
- Intuitive Benutzeroberfläche für mühelose Einrichtung.
- Vollautomatische CI/CD-Pipelines und GitOps-Integration.
- Echtzeitmetriken und -protokollierung für verwertbare Erkenntnisse.
Mühelose Skalierbarkeit und hohe Leistung
- Automatische Skalierung zur einfachen Bewältigung hoher Parallelität.
- Kein operativer Aufwand – konzentrieren Sie sich einfach auf das Erstellen.
Erfahren Sie mehr in der Dokumentation!
Folgen Sie uns auf X: @LeapcellHQ