Rustango docs
← Anleitungen

Caching

Caching speichert das Ergebnis teurer Arbeit — eine schwere Query, ein gerendertes Fragment, einen Drittanbieter-API-Aufruf — sodass der nächste Request es sofort erhält, statt es neu zu berechnen. Rustango gibt dir einen Cache-Trait mit austauschbaren Backends (In-Memory, Redis, Datenbank), einen Compute-on-Miss-Helfer (get_or_set) und typisierte JSON-Helfer. Tausche das Backend, ohne eine einzige Aufrufstelle anzufassen: die Aufrufe bleiben gleich, nur die Konfiguration ändert sich.

Caching in Rustango: get_or_set checks the cache, runs the factory only on a miss, stores the result with a TTL, and serves hits instantly; the same Cache trait backs InMemory, Redis, and DB

Ein Begriff hier neu für dich? Cache, TTL, Key, Backend — siehe das Glossar.

Quelle: rustango::cache (Cache, InMemoryCache, NullCache, get_or_set, get_json, set_json, BoxedCache, from_settings) — hinter der cache-Feature (standardmäßig aktiv). RedisCache benötigt die cache-redis-Feature (standardmäßig aus).

Ausführbare Version: jeder Codeausschnitt ist aus cache_doc.rs kopiert (cargo test -p rustango --test cache_doc); das Datenbank-Backend wird per Dogfooding auf SQLite durch cache_db_backend_sqlite_live.rs erprobt.

Inhaltsverzeichnis


Schritt 1 — Ein Backend wählen

Jedes Backend implementiert denselben Cache-Trait, dein Code ist also identisch, welches du auch wählst. Der App-Code hält einen BoxedCache (Arc<dyn Cache>) und nennt nie den konkreten Typ:

use rustango::cache::{BoxedCache, InMemoryCache};
use std::sync::Arc;

let cache: BoxedCache = Arc::new(InMemoryCache::new());
BackendFeatureVerwenden für
InMemoryCachecacheDev, Tests, Einzelprozess (HashMap pro Prozess + TTL)
RedisCachecache-redisProduktion; über Replicas geteilt
DatabaseCachecacheProduktion ohne Redis; eine rustango_cache-Tabelle
NullCachecacheCaching deaktivieren (jeder Read verfehlt) — praktisch in Tests

Schritt 2 — get / set / delete

Der Kern des Traits sind vier async-Methoden. set nimmt ein optionales TTL (None = kein Ablauf); get gibt Option<String> zurück (None bei einem Miss):

use rustango::cache::{Cache, InMemoryCache};

let cache = InMemoryCache::new();

assert_eq!(cache.get("greeting").await?, None);     // miss
cache.set("greeting", "hello", None).await?;        // store, no expiry
assert_eq!(cache.get("greeting").await?.as_deref(), Some("hello"));
assert!(cache.exists("greeting").await?);

cache.delete("greeting").await?;                    // gone

Es gibt auch Batch-Varianten — get_many / set_many / delete_many.


Schritt 3 — get_or_set (Cache-Aside)

Das ist die, zu der du am häufigsten greifst. get_or_set gibt den gecachten Wert zurück, oder — bei einem Miss — führt deine Factory aus, speichert das Ergebnis mit einem TTL und gibt es zurück. Die Factory läuft nur bei einem Miss:

use rustango::cache::get_or_set;
use std::time::Duration;

let stats: HomeStats = get_or_set(
    &*cache,                       // &dyn Cache
    "home:stats",
    || async { compute_home_stats(&pool).await },   // runs only on a miss
    Some(Duration::from_secs(60)),                   // cache for 60s
).await?;

Der zugrunde liegende Test ruft get_or_set zweimal für denselben Key auf und stellt sicher, dass die Factory genau einmal lief — der zweite Aufruf wird aus dem Cache bedient.

Bei Schreibvorgängen invalidieren. Cache-Aside bedeutet veraltete Daten, bis das TTL abläuft. Für Daten, die sich ändern, mache auch delete(key), wenn du sie schreibst — z. B. aus einem post_save-Signal — damit der nächste Read neu berechnet.


Typisierte JSON-Werte

get_json / set_json serialisieren jeden Serialize/Deserialize-Typ nach JSON, sodass du Structs und Listen cachst, nicht nur Strings:

use rustango::cache::{get_json, set_json};

#[derive(serde::Serialize, serde::Deserialize)]
struct Profile { id: i64, name: String }

set_json(&*cache, "profile:7", &profile, None).await?;
let back: Option<Profile> = get_json(&*cache, "profile:7").await?;   // None on a miss

(get_or_set verwendet diese unter der Haube, weshalb sein Werttyp Serialize + Deserialize sein muss.)


TTL und Ablauf

Übergib eine Duration an set (oder get_or_set) und der Eintrag verschwindet danach. Verifiziert: ein 50-ms-Eintrag ist sofort lesbar und nach 80 ms weg.

cache.set("flash", "x", Some(Duration::from_millis(50))).await?;
// ...50ms later...
assert_eq!(cache.get("flash").await?, None);   // expired

InMemoryCache::with_default_ttl(d) setzt ein Standard-TTL, das angewendet wird, wenn du None übergibst.

Ein TTL ist eine Obergrenze, keine Garantie

InMemoryCache ist standardmäßig größenbegrenzt — 256 MiB oder 100 000 Einträge, je nachdem, was zuerst erreicht wird — mit annähernder LRU-Verdrängung. Eine Flut eindeutiger Schlüssel kann den Prozess so nicht unbegrenzt wachsen lassen, bedeutet aber auch: ein Eintrag kann vor Ablauf seines TTL verschwinden, wenn er beim Erreichen des Budgets der am längsten unbenutzte ist. Die Verdrängung entfernt zuerst bereits abgelaufene Einträge, dann die am längsten unbenutzten, bis beide Budgets eingehalten sind.

Behandle einen Cache-Read also auch innerhalb des TTL als „kann fehlen". Das gilt für jedes Cache-Backend, hier hat es aber eine Ursache, über die du nachdenken und an der du drehen kannst:

InMemoryCache::new()
    .with_max_bytes(512 * 1024 * 1024)   // Budget anheben
    .with_max_entries(0)                 // 0 = unbegrenzt (Verhalten vor der Begrenzung)

Das TTL selbst wird faul beim Lesen durchgesetzt — es gibt keinen Hintergrund-Thread zur Verdrängung, ein abgelaufener Eintrag belegt seinen Platz also weiter, bis etwas ihn abfragt oder die Verdrängung ihn erreicht.


Backends tauschen

Weil alles der Cache-Trait ist, ist der Wechsel von In-Memory zu Redis eine einzeilige Änderung beim Start — meist von der Konfiguration gesteuert, sodass sie sich pro Umgebung unterscheidet:

// Build the cache from `[cache]` settings. `from_settings_async` is the one
// to reach for — it can build the backends that need to connect.
let cache: BoxedCache = rustango::cache::from_settings_async(&settings.cache).await?;
backendfrom_settings (sync)from_settings_async
memory, null, file✓✓
redispanik — synchron nicht baubar✓
dbpanik — braucht einen &Pool zur LaufzeitFehler; dort bauen, wo der Pool ist

Der synchrone Resolver bricht ab, statt einen anderen Backend unterzuschieben (#1400). Früher warnte er und lieferte einen In-Memory-Cache — das ist kein abgeschwächter geteilter Cache, sondern ein anderer. Ein prozesslokaler Cache multipliziert das Limit von CacheRateLimitLayer mit der Anzahl der Replicas und lässt verify_single_use nicht mehr fail-closed laufen, sodass derselbe Reset-Link einmal pro Replica funktioniert. Beides ist dokumentiert als funktionierend, weil der Cache geteilt ist, und eine Warnung beim Start erreicht niemanden, der das eine Woche später debuggt.

db lässt sich überhaupt nicht aus [cache] bauen, weil DatabaseCache einen Pool braucht, den die Settings nicht tragen:

let cache = DatabaseCache::new(pool.clone(), "rustango_cache");
cache.ensure_table().await?;
let boxed: BoxedCache = std::sync::Arc::new(cache);

In der Produktion richtest du es auf Redis (über alle deine Replicas geteilt):

use rustango::cache::RedisCache;   // needs the `cache-redis` feature
let cache: BoxedCache = std::sync::Arc::new(RedisCache::new("redis://localhost").await?);

Dieselben get / set / get_or_set-Aufrufe — nur der Konstruktor hat sich geändert.


Caching unter Multi-Tenancy

Cache ist ein flacher, &str-indizierter Store — und das macht unter Multi-Tenancy den naheliegenden Key zum undichten Key: ein Handler (oder schlimmer ein Background-Task, der gar keinen ambienten Tenant hat) schreibt "stats:monthly" für einen Tenant, und jeder andere Tenant liest es zurück.

Wickle den gemeinsamen Cache in einen ScopedCache, damit der Namespace für dich angewendet wird und die Aufrufstelle es nicht vergessen kann:

use rustango::cache::ScopedCache;

// From the Org the resolver already produced:
let cache = ScopedCache::for_tenant(shared.clone(), &t.org.slug);

cache.set("stats:monthly", &json, ttl).await?;   // stored as tenant:acme:stats:monthly
cache.get("stats:monthly").await?;               // reads only acme's entry
cache.clear().await?;                            // drops ONLY acme's entries

ScopedCache ist selbst ein Cache und passt damit überall hinein, wo ein BoxedCache erwartet wird — cache_page, cache_fragment, die Rate-Limiter, DistributedLock. Er leitet mit gemappten Keys an das innere Backend weiter, statt etwas neu zu implementieren, sodass native Primitive (Redis INCRBY, SET NX, MGET) ihre Atomarität und Batching behalten.

Atomare Zähler und Sperren. Cache::incr steckt hinter Rate-Limiting und Konto-Sperren; Cache::add (set-if-absent) steckt hinter DistributedLock. Cache::add ist bei allen dreien atomar — RedisCache (SET NX), InMemoryCache (das seine Sperre über das Read-Modify-Write hält) und DatabaseCache, das das Test-and-Set unter Zeilensperren ausführt, sodass ein Wettlauf genau einen Gewinner hat. Cache::incr ist bei den ersten beiden atomar, fällt bei DatabaseCache aber auf den nicht-atomaren Standard zurück. Ein DistributedLock ist also auf allen dreien sicher; ein Zähler, der über Replikate hinweg exakt sein muss, will Redis.

Zwei Dinge, die man wissen sollte:

Ein Namespace, keine SicherheitsgrenzeAlles liegt weiter in einem Backend, und Code mit dem ungescopeten Cache kann jeden Key lesen. Der Punkt ist, dass der ergonomische Pfad der korrekte ist.
clear() braucht Key-EnumerationEs läuft über Cache::delete_prefix, und jedes eingebaute Backend implementiert das: InMemoryCache filtert seine Map, DatabaseCache schickt ein DELETE … LIKE 'prefix%' mit escaptem %, _ und Escape-Zeichen, RedisCache nutzt SCAN+MATCH mit escapten Glob-Metazeichen, und FileCache scannt sein Verzeichnis und vergleicht den in jedem Eintrag gespeicherten Key. Ein Backend, das es nicht implementiert, liefert jetzt einen Fehler statt eines Fallbacks.

Das ungescopete Cache::clear() ist weiterhin prozessweit — greife also zur gescopeten Sicht, wann immer die Änderung eines einzelnen Tenants die Invalidierung ausgelöst hat.


Referenz

Cache-Trait: get · set(key, value, ttl) · delete · exists · get_many / set_many / delete_many · get_or(key, default).

Freie Helfer: get_or_set(cache, key, factory, ttl) · get_json / set_json · from_settings(&CacheSettings).

Was auf dem Cache aufbaut: serverseitige Sessions, verteiltes Rate Limiting (CacheRateLimitLayer), Idempotenz-Schlüssel und Feature-Flags nehmen alle einen BoxedCache — sodass eine einzige Redis-Instanz sie alle stützt.


Siehe auch

  • Hintergrund-Jobs — die andere Hälfte, um Requests schnell zu halten (Arbeit aufschieben, statt ihr Ergebnis zu cachen).
  • Sessions — ein serverseitiger Store, der auf Cache aufbaut.
  • Middleware — CacheRateLimitLayer teilt einen Zähler über Replicas hinweg via Cache.
  • ORM-Cookbook — gecachte Reads aus einem post_save-Signal invalidieren.