Skip to content

Cache

Multi-Layer Caching mit automatischem Write-Through: von In-Memory bis zur Datenbank.

Einführung

Caching in Unternehmensanwendungen ist selten so einfach wie "Redis installieren und fertig". Unterschiedliche Daten haben unterschiedliche Anforderungen: Session-Daten müssen blitzschnell sein, Konfigurationswerte sollen einen Server-Neustart überleben, und manche Werte brauchen beides: schnellen Zugriff mit persistentem Fallback.

jardisadapter/cache löst das mit einem Multi-Layer-Ansatz. Statt sich für einen Cache-Typ zu entscheiden, stapelt man mehrere Layer übereinander. Der schnellste wird zuerst gefragt, bei einem Miss wandert die Anfrage automatisch nach unten, und ein Treffer wird automatisch in die oberen Layer zurückgeschrieben. Das Ergebnis: optimale Performance bei maximaler Zuverlässigkeit.

  • PSR-16 kompatibel — Standard-Interface, austauschbar mit jeder PSR-16-Implementierung
  • 5 Adapter — Memory, APCu, Redis, Database und Null (Graceful Degradation)
  • Automatischer Write-Through — Cache-Miss in L1? Der Wert aus L3 wird automatisch in L1 und L2 nachgefüllt
  • Namespace-Isolation — mehrere Anwendungen oder BoundedContexts teilen sich einen Cache-Server ohne Konflikte
  • Graceful Degradation — ein ausgefallener Layer bricht die Anwendung nicht

Installation

bash
composer require jardisadapter/cache

GitHub: jardisAdapter/cache

Optionale PHP-Extensions:

ExtensionFür
ext-redisCacheRedis (empfohlen für Produktion)
ext-apcuCacheApcu (Worker-Scope Caching)
ext-pdoCacheDatabase (persistenter Cache)

Grundlegende Nutzung

Ein-Layer-Setup

php
use JardisAdapter\Cache\Cache;
use JardisAdapter\Cache\Adapter\CacheRedis;

$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$cache = new Cache([
    new CacheRedis($redis, 'myapp'),
]);

// PSR-16 API
$cache->set('user:42', $userData, 300);  // 5 Minuten TTL
$user = $cache->get('user:42');
$cache->delete('user:42');
$cache->has('user:42');                  // bool
$cache->clear();                         // Namespace-aware

Multi-Layer-Setup

Der eigentliche Mehrwert, Layer von schnell (oben) nach persistent (unten):

php
use JardisAdapter\Cache\Cache;
use JardisAdapter\Cache\Adapter\CacheMemory;
use JardisAdapter\Cache\Adapter\CacheRedis;
use JardisAdapter\Cache\Adapter\CacheDatabase;

$cache = new Cache([
    new CacheMemory('orders'),           // L1: PHP-Array, request-scoped
    new CacheRedis($redis, 'orders'),    // L2: Redis, verteilt
    new CacheDatabase($pdo, namespace: 'orders'),  // L3: Datenbank, persistent
]);

Was passiert bei $cache->get('order:42')?

  1. L1 (Memory) wird gefragt → Miss
  2. L2 (Redis) wird gefragt → Miss
  3. L3 (Database) wird gefragt → Treffer!
  4. Der Wert wird automatisch in L2 und L1 zurückgeschrieben (Write-Through)
  5. Nächster Zugriff: L1 liefert sofort

Was passiert bei $cache->set('order:42', $data, 600)?

Der Wert wird in alle drei Layer gleichzeitig geschrieben. delete() und clear() funktionieren analog: alle Layer bleiben konsistent.

Adapter im Detail

CacheMemory — Request-Scope

php
use JardisAdapter\Cache\Adapter\CacheMemory;

$cache = new CacheMemory('myapp');

PHP-Array im Arbeitsspeicher. Lebt nur für die Dauer des aktuellen Requests. Kein Netzwerk-Overhead, kein Serialisierung: die schnellste Option. Ideal als L1, damit häufig gelesene Werte nicht bei jedem Zugriff Redis oder die Datenbank belasten.

CacheApcu — Worker-Scope

php
use JardisAdapter\Cache\Adapter\CacheApcu;

$cache = new CacheApcu('myapp');

APCu nutzt Shared Memory des PHP-Workers. Werte überleben zwischen Requests desselben Workers: schneller als Redis, aber nicht über mehrere Server verteilt. Ideal als Zwischenschicht zwischen Memory und Redis.

clear() ohne Namespace

clear() ohne Namespace leert den gesamten APCu-Cache aller Anwendungen auf diesem Worker. Immer einen Namespace setzen.

CacheRedis — Verteilter Cache

php
use JardisAdapter\Cache\Adapter\CacheRedis;

$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$cache = new CacheRedis($redis, 'myapp');

Der Standard für verteiltes Caching. Die Redis-Instanz wird injiziert: kein internes Connection-Management. Dadurch lässt sich die Verbindung teilen (z.B. mit dem Messaging-System).

php
// Redis-Instanz zurückholen (z.B. für Connection-Sharing)
$sharedRedis = $cache->getConnection();

clear() mit Namespace nutzt SCAN in Batches von 100 Keys: produktionssicher, blockiert Redis nicht.

CacheDatabase — Persistenter Cache

php
use JardisAdapter\Cache\Adapter\CacheDatabase;

$cache = new CacheDatabase(
    pdo: $pdo,
    cacheTable: 'cache',           // Tabellenname (Standard: 'cache')
    cacheKeyField: 'cache_key',    // Key-Spalte
    cacheValueField: 'cache_value', // Value-Spalte
    cacheExpiresAt: 'expires_at',  // TTL-Spalte
    namespace: 'myapp',
);

PDO-basierter Cache für langlebige Werte, die auch einen Redis-Neustart überleben sollen. Alle Feld- und Tabellennamen sind konfigurierbar.

Benötigtes Schema:

sql
CREATE TABLE cache (
    cache_key   TEXT PRIMARY KEY,
    cache_value TEXT NOT NULL,
    expires_at  INTEGER
);
CREATE INDEX idx_cache_expires_at ON cache(expires_at);

Abgelaufene Einträge aufräumen:

php
$cache->cleanExpired();  // DELETE WHERE expires_at <= NOW()

cleanExpired() muss explizit aufgerufen werden (z.B. per Cron-Job). Es gibt keinen automatischen Hintergrundprozess.

CacheNull — Graceful Degradation

php
use JardisAdapter\Cache\Adapter\CacheNull;

$cache = new CacheNull();

Null Object Pattern: alle Operationen sind No-Ops. get() liefert immer den Default, set() gibt immer true zurück. Wird intern vom Cache-Orchestrator verwendet, wenn keine Layer übergeben werden. Nützlich für Tests oder wenn Caching optional sein soll.

Namespace-Isolation

Jeder Adapter akzeptiert einen Namespace-String. Intern wird der Cache-Key als namespace + sha256(originalKey) gehasht. Dadurch können mehrere Anwendungen oder BoundedContexts denselben Cache-Server teilen:

php
$salesCache    = new Cache([new CacheRedis($redis, 'sales')]);
$billingCache  = new Cache([new CacheRedis($redis, 'billing')]);

$salesCache->set('order:1', $salesOrder);
$billingCache->set('order:1', $billingOrder);

// Kein Konflikt — verschiedene Namespaces, verschiedene Keys
$salesCache->clear();   // Löscht nur 'sales'-Keys

TTL (Time-to-Live)

php
// Sekunden
$cache->set('key', $value, 300);           // 5 Minuten

// DateInterval
$cache->set('key', $value, new DateInterval('PT1H'));  // 1 Stunde

// Kein Ablauf
$cache->set('key', $value);                // Permanent (bis clear/delete)

// Sofort abgelaufen (= delete)
$cache->set('key', $value, 0);             // Wird als delete() behandelt

TTL und Write-Through

Beim automatischen Write-Through (L3-Treffer → L1-Backfill) wird keine TTL propagiert. Der Wert in L1 lebt ohne Ablaufzeit. Bei CacheMemory ist das unproblematisch (Request-Scope), bei APCu greift die native Eviction.

Fehlerbehandlung

Das Package folgt dem Prinzip der Graceful Degradation:

SituationVerhalten
Redis nicht erreichbarget() → Default, set()false
PDO-Fehlerget() → Default, set()false
Leerer/ungültiger KeyInvalidArgumentException (PSR-16 konform)
Keine Layer konfiguriertInterner CacheNull — Anwendung läuft weiter

Ein ausgefallener Cache-Layer liefert Defaults zurück, statt die Anwendung zu unterbrechen. Bei einem Multi-Layer-Setup bleibt die Anwendung funktional, solange mindestens ein Layer antwortet.

Architektur

Das Package folgt dem Closure-Orchestrator-Pattern mit Chain of Responsibility:

Cache                           ← Orchestrator (PSR-16 API)
├── CacheMemory                 ← L1: PHP-Array, request-scoped
├── CacheApcu                   ← L2: Shared Memory, worker-scoped
├── CacheRedis                  ← L3: Verteilter Cache
└── CacheDatabase               ← L4: Persistenter Cache

AbstractCache                   ← Gemeinsame Basis: Hashing, TTL, Serialisierung

Read-Strategie: L1 → L2 → L3 → L4, stoppt beim ersten Treffer, backfills automatisch.

Write-Strategie: Schreibt in alle Layer. Kein Short-Circuit: auch wenn L1 fehlschlägt, wird L2..L4 beschrieben.

Verzeichnisstruktur

src/
├── Cache.php                       ← Orchestrator
├── Adapter/
│   ├── AbstractCache.php           ← Basis: Hashing, TTL, Encoding
│   ├── CacheNull.php               ← Null Object
│   ├── CacheMemory.php             ← In-Memory
│   ├── CacheApcu.php               ← APCu
│   ├── CacheRedis.php              ← Redis
│   └── CacheDatabase.php           ← PDO
└── Exception/
    └── InvalidArgumentException.php ← PSR-16 konform

API-Referenz

Cache (Orchestrator)

MethodeSignaturBeschreibung
getget(string $key, mixed $default = null): mixedLiest aus Layer-Kette, Write-Through bei Treffer
setset(string $key, mixed $value, null|int|DateInterval $ttl = null): boolSchreibt in alle Layer
deletedelete(string $key): boolLöscht aus allen Layern
clearclear(): boolLeert alle Layer (namespace-aware)
hashas(string $key): boolPrüft Layer-Kette
getMultiplegetMultiple(iterable $keys, mixed $default = null): iterableBulk-Read
setMultiplesetMultiple(iterable $values, null|int|DateInterval $ttl = null): boolBulk-Write
deleteMultipledeleteMultiple(iterable $keys): boolBulk-Delete
getLayersgetLayers(): array<int, CacheInterface>Introspection

CacheDatabase (Zusätzlich)

MethodeSignaturBeschreibung
cleanExpiredcleanExpired(): boolAbgelaufene Einträge löschen
getConnectiongetConnection(): PDOPDO-Instanz zurückholen

CacheRedis (Zusätzlich)

MethodeSignaturBeschreibung
getConnectiongetConnection(): RedisRedis-Instanz zurückholen

Vollständiges Beispiel

Ein typisches Setup für eine Jardis-Anwendung, mit drei Layern und einem geplanten Cleanup:

php
use JardisAdapter\Cache\Cache;
use JardisAdapter\Cache\Adapter\CacheMemory;
use JardisAdapter\Cache\Adapter\CacheRedis;
use JardisAdapter\Cache\Adapter\CacheDatabase;

// Redis-Verbindung
$redis = new Redis();
$redis->connect(getenv('REDIS_HOST') ?: '127.0.0.1', 6379);

// Multi-Layer Cache
$cache = new Cache([
    new CacheMemory('shop'),
    new CacheRedis($redis, 'shop'),
    new CacheDatabase($pdo, namespace: 'shop'),
]);

// Schreiben: landet in allen drei Layern
$cache->set('product:42', [
    'name'  => 'Widget',
    'price' => 9.99,
    'stock' => 142,
], 3600);  // 1 Stunde TTL

// Lesen: L1 (Memory) liefert sofort
$product = $cache->get('product:42');

// Nach Server-Neustart: L1 ist leer, L2 (Redis) liefert
// → automatischer Write-Through füllt L1 nach

// Bulk-Operationen
$cache->setMultiple([
    'config:currency' => 'EUR',
    'config:locale'   => 'de_DE',
]);

$configs = $cache->getMultiple(['config:currency', 'config:locale']);

// Aufräumen (per Cron)
$dbCache = new CacheDatabase($pdo, namespace: 'shop');
$dbCache->cleanExpired();