Zum Inhalt springen
Deutsch

In-Memory-Cache

InMemoryCache ist die Default-Cache-Implementierung. Sie ist eine Map mit drei Dingen obendrauf: LRU-Eviction begrenzt durch maxEntries, fauler TTL pro Eintrag und einem optionalen Hintergrund-Sweep, der abgelaufene, nicht mehr angefasste Einträge zurückgewinnt. In-Process, keine Abhängigkeiten, geht bei Neustart verloren.

import { InMemoryCache, InMemoryCacheOptions } from 'actor-ts/cache';
// Defaults: maxEntries 10 000, cleanupMs 60 000.
const cache = new InMemoryCache();
// Or tune it with the builder:
const cacheOptions = InMemoryCacheOptions.create()
.withMaxEntries(50_000)
.withCleanupMs(30_000);
const tuned = new InMemoryCache(cacheOptions);

Ein einfaches Objekt ist die Kurzform-Alternative — new InMemoryCache({ maxEntries: 50_000, cleanupMs: 30_000 }). Eine TTL wird weiterhin pro Aufruf übergeben (siehe TTL-Behandlung), nicht beim Konstruieren.

FeldBuilderDefaultBedeutung
maxEntrieswithMaxEntries(n)10000LRU-Schranke für gespeicherte Einträge. Infinity = unbegrenzt.
cleanupMswithCleanupMs(ms)60000Intervall des Hintergrund-Sweeps für abgelaufene Einträge (ms). 0 / Infinity schaltet den Sweep ab.
prefixQuotaswithPrefixQuotas(table)(keine)Reservierungen je Key-Präfix, { 'rsp:': 8000, 'idem:': 2000 }. Ohne Angabe bleibt die Map ungeteilt — siehe eine Instanz nach Key-Präfix aufteilen.

Werte werden einmalig beim Konstruieren auf den gemergten Settings validiert: maxEntries muss eine positive Ganzzahl (oder Infinity) sein, cleanupMs eine nicht-negative Zahl (oder Infinity) und jede Quote in prefixQuotas eine positive Ganzzahl unter einem nicht-leeren Präfix, wobei die Quoten in Summe höchstens maxEntries ergeben dürfen. Ein ungültiger Wert wirft OptionsError — der Builder, ein einfaches Objekt und HOCON treffen alle dieselbe Prüfung.

Drei Szenarien:

  1. Tests — schnell, kein I/O, sauberes Teardown via close().
  2. Single-Process-Produktion — ein Prozess, kein Bedarf, Cache-State über Pods zu teilen.
  3. Dev / lokal — derselbe Code ohne Redis auf dem Laptop.

Für Multi-Process-Deployments nimm stattdessen Redis oder Memcached — sonst hielte jeder Prozess seine eigene, unabhängige Kopie.

InMemoryCache implementiert die vollständige Cache-Fläche:

await cache.set('user:1', user, 60_000); // value + optional TTL (ms)
const hit = await cache.get<User>('user:1'); // Option<User> — None on miss/expiry
await cache.setIfAbsent('lock:job', '1', 30_000); // true iff it was stored
await cache.incr('ratelimit:1.2.3.4', 60_000); // atomic ++, returns the new count
const many = await cache.mget<User>(['user:1', 'user:2']); // Map<string, User>
await cache.mset(new Map([['a', user1], ['b', user2]]), 60_000);
await cache.delete('user:1', 'user:2'); // one or many keys
await cache.close(); // stops the sweep + clears the Map

setIfAbsent ist hier aus einem strukturellen Grund atomar, nicht aus einem protokollarischen: Lesen und Schreiben liegen im selben synchronen Block, und die single-threaded Event-Loop kann keinen anderen Aufrufer dazwischenschieben. Das gilt nur innerhalb eines Prozesses — zwei Node-Prozesse haben je ihre eigene Map, ein lock:job-Key sichert zwischen ihnen also nichts ab. Für prozessübergreifendes Locking nimm acquireLock über RedisCache.

get gibt ein Option<V> zurück — None bei einem Miss oder nach Ablauf.

await cache.set('key', value, 60_000); // expires at now + 60s
await cache.set('key', value); // no TTL — lives until evicted/deleted

Expiry hat zwei Pfade. Er ist faul beim Zugriff — die Deadline eines Eintrags wird bei jedem get, mget, incr und setIfAbsent geprüft, und ein abgelaufener Eintrag wird an dieser Stelle verworfen. Ein Hintergrund-Sweep läuft dann alle cleanupMs (Default 60 s) und gewinnt abgelaufene Einträge zurück, die nie wieder angefasst werden, damit sie nicht auf einen Read wartend in der Map liegen. Setze cleanupMs auf 0 / Infinity, um den Sweep abzuschalten und dich allein auf faule Expiry zu verlassen.

incr setzt die TTL nur, wenn es den Zähler erzeugt (der Wert wird 1); spätere Inkremente erneuern sie nicht — die richtige Semantik für einen Fixed-Window-Rate-Limiter.

InMemoryCache ist LRU-begrenzt bei maxEntries (Default 10 000): das Einfügen eines neuen Keys über die Schranke hinaus evictet den am längsten nicht genutzten Eintrag. Genau das verhindert, dass eine Flut eindeutiger, nie erneut gelesener Keys — etwa angreiferseitig gewählte Idempotency-Key- oder Rate-Limit-Keys — die Map unbegrenzt wachsen lässt.

Nur ein Read zählt als Nutzung. get, incr und mget schieben einen Key ans zuletzt-genutzte Ende; set, mset und setIfAbsent nicht. Ein heißer Key überlebt also — aber ein Eintrag, der einmal geschrieben und nie zurückgelesen wird (ein Idempotency-Record, der noch auf den Retry des Clients wartet), altert ab dem Moment der Speicherung auf die Eviction zu.

Aktualität ist allerdings nicht das einzige Kriterium — sie ist das letzte von dreien. Die Eviction fragt zuerst, zu welchem Key-Präfix ein Eintrag gehört, dann was er trägt, und erst danach, wie kürzlich er gelesen wurde. Um die beiden vorderen Fragen geht es im nächsten Abschnitt.

Setze maxEntries: Infinity, um die Eviction ganz abzuschalten. Tu das nur, wenn du den Key-Raum kontrollierst — eine unbegrenzte Map läuft dem Prozess irgendwann in einen OOM.

Die Map wird in zwei Hälften geführt, und der Write, der einen Eintrag erzeugt hat, entscheidet, in welcher er landet:

  • Trägt eine Garantie — ein setIfAbsent-Claim (ein Lock, ein Idempotency-Marker) oder ein incr-Zähler (ein Rate-Limit-Fenster), mit endlicher TTL. Für diese ist der Cache die Quelle der Wahrheit: einen davon zu verlieren kostet keinen Round-Trip, es entwertet die Garantie.
  • Opportunistisch — alles, was set / mset schreibt. Dahinter steht eine Quelle der Wahrheit, ein Verlust ist also ein Cache-Miss, den der Aufrufer bereits behandelt.

Die Eviction leert zuerst die opportunistische Hälfte, beginnend am zuletzt-genutzten Ende. Eine Flut eindeutiger Response-Cache-Keys kann damit kein gehaltenes Lock, keinen Rate-Limit-Zähler eines anderen Clients und keinen gespeicherten Idempotency-Record mehr aus der Map drängen — vorher konnte sie das, und zwar in der Default-Konfiguration:

import { acquireLock, CacheExtensionId } from 'actor-ts/cache';
const cache = system.extension(CacheExtensionId).cache(); // maxEntries 10 000
const lock = await acquireLock(cache, 'lock:nightly-report', 60_000);
// ...danach 10 000 eindeutige `cache.set(...)`-Writes über dieselbe Instanz.
// Das Lock steht weiterhin: jeder dieser Writes ist opportunistisch,
// und die opportunistische Hälfte wird zuerst geleert.

Ein set übernimmt eine Garantie in genau einem Fall: wenn es einen lebenden Claim unter demselben Key ersetzt. Das ist die Form, die idempotent benutzt — den Key mit setIfAbsent beanspruchen, dann den Marker mit der fertigen Antwort überschreiben — und beide Hälften davon müssen geschützt sein, sonst liegt der Record das gesamte Fenster offen, in dem der Retry des Clients lebt. Außerhalb dieses Falls ist ein set nie geschützt, egal welche TTL es trägt: jeder gecachte Response-Body ist ein set mit endlicher TTL, diese zu schützen würde also alles schützen und damit nichts.

maxEntries bleibt eine harte Schranke — und das ist die Grenze

Abschnitt betitelt „maxEntries bleibt eine harte Schranke — und das ist die Grenze“

Eine Garantie zu tragen ordnet die Opfer neu; sie verhindert nie eine Eviction. Sobald jeder Eintrag der Map eine trägt, geht der am längsten nicht genutzte von diesen — die Schranke gilt also unverändert, und eine Key-Flut kann die Map weiterhin nicht wachsen lassen. Bleiben vier Dinge, die du einplanen musst:

  • Dimensioniere maxEntries über die Zahl der Claims, Zähler und Locks hinaus, die innerhalb einer TTL gleichzeitig leben. Ein Cache, der nur Locks hält, evictet an der Schranke sein ältestes Lock — und der nächste Aufrufer bekommt dann ein Lock, das jemand noch hält.
  • Die Garantie-Aufteilung ordnet Garantien nicht gegeneinander. Zwei Consumer, die je eine Garantie tragen und sich eine Instanz teilen, evictieren sich allein nach Aktualität — eine Zähler-Flut nimmt Idempotency-Records, sobald die Map nichts Billigeres mehr hält. Gib jedem Consumer seinen eigenen Cache (unten), oder teile den gemeinsamen mit prefixQuotas auf (nächster Abschnitt).
  • Eine Garantie, die du selbst mit einem einfachen set ablegst, wird nicht erkannt — der Cache kann sie nicht von einem Response-Body unterscheiden. Schreibe den Claim mit setIfAbsent, oder nimm ihn mit acquireLock.
  • Ein Claim ohne TTL ist nicht geschützt. Ein unbegrenztes Lock ist genau die Verklemmung, vor der setIfAbsent warnt, und es zu schützen würde sie dauerhaft machen — nichts würde es je ablaufen lassen.

Und nichts davon folgt einem entfernten Backend. Redis unter maxmemory-policy allkeys-lru und Memcached evictieren serverseitig, wo keine clientseitige Politik hinreicht — siehe die Memcached-Seite.

prefixQuotas teilt eine Map unter den Consumern auf, die in sie schreiben. Jeder Eintrag ist ein Key-Präfix und die Zahl der für ihn reservierten Einträge:

import { InMemoryCache, InMemoryCacheOptions } from 'actor-ts/cache';
const cacheOptions = InMemoryCacheOptions.create()
.withMaxEntries(10_000)
.withPrefixQuotas({ 'rsp:': 7_000, 'idem:': 2_000, 'rl:': 1_000 });
const shared = new InMemoryCache(cacheOptions);

Eine Quote ist Schranke und Reservierung zugleich, und beide Hälften machen sie erst zu einer Grenze statt zu einem Hinweis:

  • als Schranke nimmt ein Präfix, das seine Quote erreicht hat, sein nächstes Opfer aus sich selbst — wer also Keys unter einem Präfix erzeugen kann, evictet nur die eigenen Einträge dieses Präfixes;
  • als Reservierung stehen die Einträge, die ein Präfix unterhalb seiner Quote hält, niemandem sonst zur Verfügung — die Flut erreicht also auch keinen Rate-Limit-Zähler auf der anderen Seite der Map.

Ein Key gehört zum längsten konfigurierten Präfix, mit dem er beginnt, und zu einem gemeinsamen unreservierten Rest, wenn er mit keinem beginnt. Die Quoten müssen in Summe höchstens maxEntries ergeben: eine Reservierung, die die Map nicht einlösen kann, wird beim Konstruieren abgelehnt, statt unter Last gebrochen zu werden. Innerhalb eines Buckets ändert sich an der Garantie-Aufteilung nichts — opportunistische Einträge gehen weiterhin vor garantierten, am längsten nicht genutzte zuerst.

Zwei Dinge tut sie nicht:

  • Sie begrenzt ein Präfix, keinen Aufrufer. Zwei Clients, die Idempotency-Keys senden, teilen sich die idem:-Reservierung und evictieren sich weiterhin gegenseitig. Nur ein Key-Raum je Aufrufer hilft hier (ein identity-Scope über eine bekannte, kleine Menge von Mandanten, jeder einzeln reserviert) — und Idempotency-Key allein ist keiner.
  • Sie lässt die Map nicht wachsen. maxEntries bleibt die harte Schranke; eine Konfiguration, die jeden Platz reserviert, führt dazu, dass ein unreservierter Write einen aus einer Reservierung nimmt, statt dass die Schranke nachgibt.

Auf RedisCache oder MemcachedCache gibt es dazu kein Gegenstück — beide evictieren serverseitig, wo keine clientseitige Politik hinreicht.

Wenn die Schranke doch eine erreicht, wird nichts sichtbar. Der Eintrag ist einfach weg, weit innerhalb seiner TTL:

  • Rate-Limit — der Zähler verschwindet, der nächste Request dieses Clients startet also ein frisches Fenster bei 1. Das Limit setzt sich zurück, ohne dass es jemand erreicht hätte.
  • Idempotency — die gespeicherte Antwort verschwindet, der ehrliche Retry des Clients findet also keinen Record, beansprucht den Key neu und führt den Handler ein zweites Mal aus. Auf einem Payments-Endpoint ist das eine Doppelbelastung.
  • Lock — der Eintrag, den ein Halter geschrieben hat, verschwindet, während der Halter noch im kritischen Abschnitt steckt; das nächste acquireLock auf diesem Key gelingt und vergibt dasselbe Lock ein zweites Mal. Das release() des ursprünglichen Halters liefert dann false, was sich liest wie „der Abschnitt hat seine TTL überzogen“ und hier das Gegenteil bedeutet: der größte Teil der TTL war noch übrig. Nichts am Rückgabewert trennt die beiden Ursachen.

Die Empfehlung überlebt die Politik, denn die Politik ordnet die Opfer nur innerhalb einer Instanz neu:

import { CacheExtensionId } from 'actor-ts/cache';
const extension = system.extension(CacheExtensionId);
const limiterCache = extension.cache('rate-limit');
const idempotencyCache = extension.cache('idempotency');
const responseCache = extension.cache('response-cache');

Jeder Name löst zu einer eigenen Instanz auf, eine Flut durch den einen erreicht die anderen also überhaupt nicht. Dimensioniere maxEntries jeweils allein für den Key-Raum dieses Consumers — unter dem Namen des Caches selbst:

actor-ts.cache {
in-memory { maxEntries = 10000 } # every in-memory instance
idempotency.in-memory { maxEntries = 200000 } # just cache('idempotency')
rate-limit.in-memory { maxEntries = 50000 } # just cache('rate-limit')
}

Der Block je Name gewinnt Blatt für Blatt gegen den globalen, ein Override setzt also nur, was er nennt, und der Rest fällt weiterhin durch — die einzige Ausnahme ist prefixQuotas: eine Tabelle wird als Ganzes überlagert, denn eine halb geerbte Tabelle ist eine Summe, die niemand aufgeschrieben hat. Der Name gehört dir, deshalb sind diese Pfade nicht in reference.conf aufgeführt — aus dem gleichen Grund, aus dem actor-ts.cache.<name>.plugin es nicht ist. Ein ungültiger Wert wird beim ersten cache(name) mit einem OptionsError abgelehnt, statt die Map still auf den Default zu dimensionieren.

Wo eine Instanz wirklich geteilt werden muss — ein einziger Cache für alle drei Middlewares —, reserviere stattdessen den Anteil jedes Consumers:

actor-ts.cache.shared.in-memory {
maxEntries = 10000
prefixQuotas { "rsp:" = 7000, "idem:" = 2000, "rl:" = 1000 }
}

Die Präfixe sind die keyPrefix-Optionen der Middlewares; sie müssen in HOCON gequotet werden, weil sie einen Doppelpunkt enthalten.

So oder so verkleinert sich der Explosionsradius, statt zu verschwinden: eine Middleware, deren eigener Key-Raum angreiferkontrolliert ist, evictet weiterhin ihre eigenen Einträge, und keine Quote ändert das — ein Client mit einem IPv6-/64 erzeugt beliebig viele Rate-Limit-Keys, und jeder davon ist ein Zähler, den diese Politik unter derselben rl:-Reservierung schützt. Wo das zählt, hinterlege den sicherheitsrelevanten Consumer mit Redis (den du separat dimensionierst und überwachst) statt mit einer prozesslokalen LRU.

import { CacheExtensionId, InMemoryCache } from 'actor-ts/cache';
// The extension's `default` cache is an InMemoryCache out of the box:
const cache = system.extension(CacheExtensionId).cache();
// Override the default, or register a separate named cache:
system.extension(CacheExtensionId).setCache('default', new InMemoryCache());
const sessions = system.extension(CacheExtensionId).cache('sessions');

system.extension(CacheExtensionId).cache(name) löst einen Cache über seinen Namen auf — der Name default ist ein InMemoryCache, solange du ihn nicht ersetzt (via setCache, registerCache oder den HOCON-Pfad actor-ts.cache.<name>.plugin). HTTP-Middleware, Projection-Actors und dein eigener Code teilen sich dann eine konfigurierte Instanz, statt dass jeder seine eigene baut.

Der von der Extension gebaute In-Memory-Cache liest seine Defaults aus HOCON:

actor-ts.cache.in-memory {
maxEntries = 50000 # LRU cap
cleanupMs = 30000 # background sweep interval, ms (0 disables)
# prefixQuotas { "rsp:" = 40000, "idem:" = 10000 } # optional, off by default
}

Dieser Block gilt für jede In-Memory-Instanz; actor-ts.cache.<name>.in-memory überschreibt ihn für eine einzelne — siehe ein Cache pro Consumer.

Für einen Wegwerf-Cache konstruiere einfach direkt new InMemoryCache() — keine Extension nötig.