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.
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 dercache-Feature (standardmäßig aktiv).RedisCachebenötigt diecache-redis-Feature (standardmäßig aus).Ausführbare Version: jeder Codeausschnitt ist aus
cache_doc.rskopiert (cargo test -p rustango --test cache_doc); das Datenbank-Backend wird per Dogfooding auf SQLite durchcache_db_backend_sqlite_live.rserprobt.
Inhaltsverzeichnis
- Schritt 1 — Ein Backend wählen
- Schritt 2 — get / set / delete
- Schritt 3 — get_or_set (Cache-Aside)
- Typisierte JSON-Werte
- TTL und Ablauf
- Backends tauschen
- Caching unter Multi-Tenancy
- Referenz
- Siehe auch
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());
| Backend | Feature | Verwenden für |
|---|---|---|
InMemoryCache | cache | Dev, Tests, Einzelprozess (HashMap pro Prozess + TTL) |
RedisCache | cache-redis | Produktion; über Replicas geteilt |
DatabaseCache | cache | Produktion ohne Redis; eine rustango_cache-Tabelle |
NullCache | cache | Caching 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 einempost_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?;
backend | from_settings (sync) | from_settings_async |
|---|---|---|
memory, null, file | ✓ | ✓ |
redis | panik — synchron nicht baubar | ✓ |
db | panik — braucht einen &Pool zur Laufzeit | Fehler; 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 Sicherheitsgrenze | Alles 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-Enumeration | Es 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
Cacheaufbaut. - Middleware —
CacheRateLimitLayerteilt einen Zähler über Replicas hinweg via Cache. - ORM-Cookbook — gecachte Reads aus einem
post_save-Signal invalidieren.
