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.
Optionen
Abschnitt betitelt „Optionen“| Feld | Builder | Default | Bedeutung |
|---|---|---|---|
maxEntries | withMaxEntries(n) | 10000 | LRU-Schranke für gespeicherte Einträge. Infinity = unbegrenzt. |
cleanupMs | withCleanupMs(ms) | 60000 | Intervall des Hintergrund-Sweeps für abgelaufene Einträge (ms). 0 / Infinity schaltet den Sweep ab. |
prefixQuotas | withPrefixQuotas(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.
Wann einsetzen
Abschnitt betitelt „Wann einsetzen“Drei Szenarien:
- Tests — schnell, kein I/O, sauberes Teardown via
close(). - Single-Process-Produktion — ein Prozess, kein Bedarf, Cache-State über Pods zu teilen.
- 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.
Die Operationen
Abschnitt betitelt „Die Operationen“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 storedawait 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 keysawait cache.close(); // stops the sweep + clears the MapsetIfAbsent 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.
TTL-Behandlung
Abschnitt betitelt „TTL-Behandlung“await cache.set('key', value, 60_000); // expires at now + 60sawait cache.set('key', value); // no TTL — lives until evicted/deletedExpiry 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.
Standardmäßig begrenzt (LRU)
Abschnitt betitelt „Standardmäßig begrenzt (LRU)“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.
Was Eviction schützt und was nicht
Abschnitt betitelt „Was Eviction schützt und was nicht“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 einincr-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/msetschreibt. 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
prefixQuotasauf (nächster Abschnitt). - Eine Garantie, die du selbst mit einem einfachen
setablegst, wird nicht erkannt — der Cache kann sie nicht von einem Response-Body unterscheiden. Schreibe den Claim mitsetIfAbsent, oder nimm ihn mitacquireLock. - Ein Claim ohne TTL ist nicht geschützt. Ein unbegrenztes Lock ist
genau die Verklemmung, vor der
setIfAbsentwarnt, 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.
Eine Instanz nach Key-Präfix aufteilen
Abschnitt betitelt „Eine Instanz nach Key-Präfix aufteilen“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 dieidem:-Reservierung und evictieren sich weiterhin gegenseitig. Nur ein Key-Raum je Aufrufer hilft hier (einidentity-Scope über eine bekannte, kleine Menge von Mandanten, jeder einzeln reserviert) — undIdempotency-Keyallein ist keiner. - Sie lässt die Map nicht wachsen.
maxEntriesbleibt 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.
Was eine verlorene Garantie kostet
Abschnitt betitelt „Was eine verlorene Garantie kostet“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
acquireLockauf diesem Key gelingt und vergibt dasselbe Lock ein zweites Mal. Dasrelease()des ursprünglichen Halters liefert dannfalse, 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.
Ein Cache pro Consumer
Abschnitt betitelt „Ein Cache pro Consumer“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.
Über das System teilen
Abschnitt betitelt „Über das System teilen“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.
Wann er für Produktion falsch ist
Abschnitt betitelt „Wann er für Produktion falsch ist“Wohin als Nächstes
Abschnitt betitelt „Wohin als Nächstes“- Cache-Übersicht — das große Bild.
- Redis-Cache — Multi-Process- Alternative.
- Memcached-Cache — Alternative.
- CachedSnapshotStore — ein Konsument der Cache-Abstraktion.
