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
composer require jardisadapter/cacheGitHub: jardisAdapter/cache
Optionale PHP-Extensions:
| Extension | Für |
|---|---|
ext-redis | CacheRedis (empfohlen für Produktion) |
ext-apcu | CacheApcu (Worker-Scope Caching) |
ext-pdo | CacheDatabase (persistenter Cache) |
Grundlegende Nutzung
Ein-Layer-Setup
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-awareMulti-Layer-Setup
Der eigentliche Mehrwert, Layer von schnell (oben) nach persistent (unten):
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')?
- L1 (Memory) wird gefragt → Miss
- L2 (Redis) wird gefragt → Miss
- L3 (Database) wird gefragt → Treffer!
- Der Wert wird automatisch in L2 und L1 zurückgeschrieben (Write-Through)
- 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
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
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
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).
// 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
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:
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:
$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
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:
$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'-KeysTTL (Time-to-Live)
// 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() behandeltTTL 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:
| Situation | Verhalten |
|---|---|
| Redis nicht erreichbar | get() → Default, set() → false |
| PDO-Fehler | get() → Default, set() → false |
| Leerer/ungültiger Key | InvalidArgumentException (PSR-16 konform) |
| Keine Layer konfiguriert | Interner 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, SerialisierungRead-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 konformAPI-Referenz
Cache (Orchestrator)
| Methode | Signatur | Beschreibung |
|---|---|---|
get | get(string $key, mixed $default = null): mixed | Liest aus Layer-Kette, Write-Through bei Treffer |
set | set(string $key, mixed $value, null|int|DateInterval $ttl = null): bool | Schreibt in alle Layer |
delete | delete(string $key): bool | Löscht aus allen Layern |
clear | clear(): bool | Leert alle Layer (namespace-aware) |
has | has(string $key): bool | Prüft Layer-Kette |
getMultiple | getMultiple(iterable $keys, mixed $default = null): iterable | Bulk-Read |
setMultiple | setMultiple(iterable $values, null|int|DateInterval $ttl = null): bool | Bulk-Write |
deleteMultiple | deleteMultiple(iterable $keys): bool | Bulk-Delete |
getLayers | getLayers(): array<int, CacheInterface> | Introspection |
CacheDatabase (Zusätzlich)
| Methode | Signatur | Beschreibung |
|---|---|---|
cleanExpired | cleanExpired(): bool | Abgelaufene Einträge löschen |
getConnection | getConnection(): PDO | PDO-Instanz zurückholen |
CacheRedis (Zusätzlich)
| Methode | Signatur | Beschreibung |
|---|---|---|
getConnection | getConnection(): Redis | Redis-Instanz zurückholen |
Vollständiges Beispiel
Ein typisches Setup für eine Jardis-Anwendung, mit drei Layern und einem geplanten Cleanup:
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();