Zum Inhalt springen
Deutsch

Cache im Überblick

Das Cache-Interface ist der opportunistische Key/Value-Cache des Frameworks. Sieben Operationen decken ~95 % der realen Fälle ab: get, set, atomares Inkrement, set-if-absent, delete, Bulk-Get, Bulk-Set.

Genutzt von:

interface Cache {
get<V>(key: string): Promise<Option<V>>;
set<V>(key: string, value: V, ttlMs?: number): Promise<void>;
incr(key: string, ttlMs?: number): Promise<number>;
setIfAbsent<V>(key: string, value: V, ttlMs?: number): Promise<boolean>;
delete(...keys: string[]): Promise<void>;
mget<V>(keys: string[]): Promise<Map<string, V>>;
mset<V>(entries: ReadonlyMap<string, V>, ttlMs?: number): Promise<void>;
}

Kleine Oberfläche — keine Pattern-Scans, kein Pub/Sub (der Cluster hat sein eigenes Pub/Sub).

BackendEinsatz
InMemoryCacheSingle-Pod / Tests. In-Process-Map.
RedisCacheMulti-Pod-Produktion. Umschließt ioredis.
MemcachedCacheMulti-Pod, wo Memcached passt. Umschließt memjs.

Wähle nach Deployment-Form:

  • Single-Pod — InMemoryCache. Schnell, keine extra Peer-Deps.
  • Multi-Pod mit bereits vorhandenem Redis — RedisCache.
  • Multi-Pod mit bereits vorhandenem Memcached — MemcachedCache.
  • Multi-Pod ohne Präferenz — RedisCache. Mehr Features (Pub/Sub, Persistenz usw.) und das größere Ökosystem.

Caches sind per Design verlustbehaftet. Ein get, das None zurückgibt, heißt “nicht gecacht” — Aufgabe des Aufrufers ist, auf die Quelle der Wahrheit zurückzufallen:

const cached = await cache.get<User>(`user:${id}`);
if (cached.isSome()) return cached.value;
const user = await db.users.findById(id); // ← Quelle der Wahrheit
await cache.set(`user:${id}`, user, 60_000);
return user;

Wenn set fehlschlägt (Redis down, Netzwerk-Aussetzer), bleibt der Cache leer — aber der Aufruf liefert trotzdem die richtige Antwort (die Quelle der Wahrheit wurde konsultiert).

Cache-Implementierungen liefern bei transienten Fehlern Defaults zurück, statt zu werfen. Missbrauch (schlechte TTL, malformed Value) wirft.

await cache.set('key', value, 60_000); // läuft in 60s ab
await cache.set('key', value); // kein Ablauf
  • Mit ttlMs — Eintrag läuft nach dem Fenster ab.
  • Ohne — Eintrag bleibt, bis er evicted wird (LRU bei InMemoryCache; Backend-Policy bei Redis / Memcached).

Die meisten Einsätze: immer eine TTL setzen. Einträge ohne TTL wachsen bis zur Eviction; explizite TTLs sind vorhersehbar.

const count = await cache.incr(`requests:${userId}`, 60_000);
if (count > 100) throw new Error('rate limit');

incr liefert den neuen Zählerstand nach dem Inkrement. Wenn ttlMs gesetzt ist UND der Zähler gerade erst erzeugt wurde (count === 1), wird die TTL gesetzt.

Wird von der Rate-Limit-Middleware für Fixed-Window-Counter genutzt.

const got = await cache.setIfAbsent('lock:key', 'me', 5_000);
if (got) {
// Ich habe das Rennen gewonnen; mach die Arbeit
} else {
// Jemand anderes hat es
}

Atomarer Schreibvorgang im CAS-Stil. Wird von der Idempotency- Key-Middleware genutzt, um “ich bin der erste Request mit diesem Key” zu erkennen.

Atomarität ist eine Garantie, kein Best Effort. Jedes Backend bildet das auf ein einzelnes natives Compare-and-Set ab — Redis SET … NX, Memcached ADD, und bei InMemoryCache ein Map-Lese/Schreib-Paar, das die single-threaded Event-Loop nicht verschränken kann. Bei N nebenläufigen Aufrufern sieht genau einer true. Kein Backend implementiert das als get-dann-set — dabei gäbe es ein Fenster, in dem zwei Aufrufer den Key beide als abwesend sehen und beide schreiben.

ttlMs gilt nur für den Schreibvorgang, der gewinnt — ein verlierender Aufruf lässt die Ablaufzeit des vorhandenen Eintrags unangetastet, eine Retry-Schleife kann also niemals fremden Besitz verlängern. Die Sub-Sekunden-Genauigkeit hängt vom Backend ab: Memcached rundet auf volle Sekunden auf, mit 1 s als Untergrenze.

setIfAbsent liefert die Acquire-Hälfte eines Locks. Bei der Release-Hälfte gehen selbstgebaute Varianten schief, deshalb bringt das Framework eine mit:

import { acquireLock } from 'actor-ts';
const lock = await acquireLock(cache, 'lock:nightly-report', 30_000);
if (lock.isNone()) return; // jemand anderes ist dran
try {
await generateReport();
} finally {
const released = await lock.value.release();
if (!released) log.warn('report ran past its 30s lock TTL');
}

acquireLock schreibt ein zufälliges 128-Bit-Token als Wert, und release() löscht den Key nur, wenn dieses Token noch dort steht. Die naive Variante — cache.delete(key) im finally — ist in dem Moment falsch, in dem ein Halter seine TTL überzieht: sein Eintrag ist längst weg, jemand anderes hat einen eigenen geschrieben, und das bedingungslose Löschen wirft den neuen Besitzer raus, während der noch arbeitet. Genau das meldet der false-Rückgabewert: das Lock war abgelaufen, der eben ausgeführte Abschnitt war also womöglich gar nicht exklusiv.

ttlMs ist bewusst Pflicht. Ablauf ist der einzige Weg zurück, wenn ein Halter abstürzt oder hängt — ein Lock ohne TTL verklemmt für immer, sobald ein Prozess im falschen Moment stirbt.

const users = await cache.mget<User>(['user:1', 'user:2', 'user:3']);
// → Map<string, User> — fehlende Keys nicht enthalten

Round-Trip-Optimierung. Entscheidend für Hydratation geteilter Entitäten nach einem Sharding-Rebalance — den State jeder aktiven Entität in einem Redis-Call statt N holen.

mset ist das Gegenstück:

await cache.mset(new Map([
['user:1', user1],
['user:2', user2],
]), 60_000);

Die Cache-API-Referenz deckt das volle Interface ab.