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/cache';
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: unser Eintrag ist nicht mehr der unter dem Key, der eben ausgeführte Abschnitt war also womöglich gar nicht exklusiv.

ttlMs ist bewusst Pflicht. Ablauf ist der 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.