Caching-Strategien: Redis, APCu & Query-Cache
"Die Produktseite lädt 2 Sekunden." "Der Dashboard-Query braucht 800ms." "Bei Traffic-Spikes geht die DB in die Knie." – Klassische Symptome für fehlendes oder falsches Caching. Richtig eingesetzt reduziert Caching Latenz um 90%+ und spart Server-Kosten.
TL;DR – Die Kurzfassung
- APCu: Schnellster Cache, aber nur lokal (Single-Server/Worker)
- Redis: Verteilter Cache, Persistenz, Pub/Sub, Datenstrukturen
- Application-Level Query Result Cache: Ergebnisse teurer DB-Queries cachen (NICHT der deprecated MySQL Query Cache!)
- Goldene Regel: Cache-Invalidierung ist das schwere Problem, nicht das Caching selbst
- TTL-Strategie: Lieber kurze TTLs + Cache-Warming als lange TTLs + Stale-Daten
Wann cachen? Wann nicht?
┌─────────────────────────────────────────────────────────────────┐
│ CACHING ENTSCHEIDUNGSBAUM │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─── Daten ändern sich häufig (<1min)? │
│ │ JA → Kein Cache ODER sehr kurze TTL (5-30s) │
│ │ NEIN ↓ │
│ │ │
│ ├─── Berechnung/Query teuer (>50ms)? │
│ │ NEIN → Kein Cache nötig, DB ist schnell genug │
│ │ JA ↓ │
│ │ │
│ ├─── Daten user-spezifisch? │
│ │ JA → Cache mit User-ID im Key │
│ │ NEIN → Shared Cache (höhere Hit-Rate) │
│ │ │
│ ├─── Multi-Server Setup? │
│ │ JA → Redis/Memcached (verteilt) │
│ │ NEIN → APCu (schneller, einfacher) │
│ │ │
│ └─── Konsistenz kritisch? │
│ JA → Explizite Invalidierung + kurze TTL │
│ NEIN → TTL-basiert, eventual consistency OK │
│ │
└─────────────────────────────────────────────────────────────────┘
Was gut cachebar ist
| Datentyp | TTL-Empfehlung | Invalidierung |
|---|---|---|
| Konfiguration | 5-60 min | Bei Deploy/Admin-Änderung |
| User-Session-Daten | Session-Lifetime | Bei Logout/Änderung |
| Produktkatalog | 1-5 min | Bei Produkt-Update |
| Aggregierte Reports | 5-60 min | TTL-basiert |
| API-Responses (extern) | Je nach API | Cache-Control Header |
| Computed Permissions | 1-5 min | Bei Rollen-Änderung |
Was NICHT cachebar ist
- Klassische Echtzeit-Daten: Aktienkurse, Live-Scores (bei WebSockets/Streams gelten andere Regeln)
- Schreib-intensive Daten: Zähler die ständig inkrementieren (→ Redis INCR)
- Sicherheitskritische Tokens: CSRF, Nonces (müssen frisch sein)
- Transaktionale Daten: Kontostand während Buchung
APCu: Der lokale Turbo
APCu (APC User Cache) ist ein Shared-Memory-Cache im PHP-Prozess. Extrem schnell (Mikrosekunden), aber nur lokal verfügbar.
Wann APCu?
- Single-Server Setup
- Daten die pro Worker gleich sind (Config, Übersetzungen)
- Als L1-Cache vor Redis (Two-Level-Caching)
- OPcache für Code, APCu für Daten
<?php
declare(strict_types=1);
final class ApcuCache
{
public function get(string $key, callable $loader, int $ttl = 300): mixed
{
// APCu verfügbar?
if (!extension_loaded('apcu') || !apcu_enabled()) {
return $loader();
}
$value = apcu_fetch($key, $success);
if ($success) {
return $value;
}
// Cache-Miss: Loader ausführen und cachen
$value = $loader();
apcu_store($key, $value, $ttl);
return $value;
}
public function delete(string $key): bool
{
return apcu_delete($key);
}
public function clear(): bool
{
return apcu_clear_cache();
}
public function set(string $key, mixed $value, int $ttl = 300): bool
{
if (!extension_loaded('apcu') || !apcu_enabled()) {
return false;
}
return apcu_store($key, $value, $ttl);
}
/**
* Atomic increment (gut für Counters, Rate-Limiting)
*/
public function increment(string $key, int $step = 1, int $ttl = 60): int
{
if (!extension_loaded('apcu') || !apcu_enabled()) {
return 0;
}
// Erst Key initialisieren (falls nicht existent), dann incrementen
// apcu_add() macht nichts wenn Key bereits existiert
apcu_add($key, 0, $ttl);
return apcu_inc($key, $step);
}
}
// Nutzung
$cache = new ApcuCache();
$config = $cache->get('app:config', function () {
return $this->loadConfigFromDatabase();
}, ttl: 300);
// Rate-Limiting mit APCu
$requests = $cache->increment("rate:{$userId}:" . date('YmdHi'), ttl: 60);
if ($requests > 100) {
throw new TooManyRequestsException();
}
APCu Konfiguration (php.ini)
; APCu aktivieren
apc.enabled = 1
; Shared Memory Größe (je nach RAM)
apc.shm_size = 128M
; TTL für Garbage Collection (0 = nie automatisch löschen)
apc.ttl = 0
apc.gc_ttl = 3600
; CLI aktivieren (für Tests/Scripts)
apc.enable_cli = 1
APCu Gotchas
// ⚠️ APCu ist NICHT shared zwischen PHP-FPM Pools!
// Jeder Pool hat eigenen Shared Memory
// ⚠️ Bei Deployment: Cache wird NICHT automatisch invalidiert
// → apcu_clear_cache() nach Deploy oder php-fpm reload
// ⚠️ Große Objekte serialisieren langsam
// → Nur kleine Datenstrukturen cachen
// ⚠️ APCu in CLI ist isoliert vom FPM-Cache
// → apc.enable_cli = 1 nur für Tests
Redis: Der verteilte Alleskönner
Redis ist mehr als ein Cache: In-Memory-Datenbank mit Persistenz, Pub/Sub, Lua-Scripting und atomaren Operationen.
Redis vs. Memcached
| Feature | Redis | Memcached |
|---|---|---|
| Datenstrukturen | Strings, Lists, Sets, Hashes, Sorted Sets | Nur Strings |
| Persistenz | RDB + AOF | Nein |
| Pub/Sub | Ja | Nein |
| Lua Scripting | Ja | Nein |
| Cluster | Redis Cluster | Client-seitiges Sharding |
| Memory Efficiency | Gut | Besser (einfacher) |
| Max Value Size | 512MB | 1MB |
Meine Empfehlung: Redis. Memcached nur wenn bereits vorhanden oder extrem simple Key-Value-Anforderungen.
Redis Cache-Wrapper
<?php
declare(strict_types=1);
final class RedisCache
{
private \Redis $redis;
public function __construct(
string $host = '127.0.0.1',
int $port = 6379,
?string $password = null,
int $database = 0,
private string $prefix = 'cache:',
) {
$this->redis = new \Redis();
$this->redis->connect($host, $port, 2.0); // 2s timeout
if ($password !== null) {
$this->redis->auth($password);
}
$this->redis->select($database);
// Serializer für komplexe Datentypen
$this->redis->setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_PHP);
}
public function get(string $key): mixed
{
$value = $this->redis->get($this->prefix . $key);
return $value === false ? null : $value;
}
public function set(string $key, mixed $value, int $ttl = 300): bool
{
return $this->redis->setex($this->prefix . $key, $ttl, $value);
}
/**
* Get-or-Set Pattern mit Lock gegen Cache Stampede
* Für robustere Stampede-Prevention: siehe StampedeProtectedCache
*/
public function remember(string $key, callable $loader, int $ttl = 300): mixed
{
$value = $this->get($key);
if ($value !== null) {
return $value;
}
// Cache Stampede Prevention mit Lock
$lockKey = $this->prefix . "lock:{$key}";
$maxWaitMs = 5000;
$waited = 0;
while ($waited < $maxWaitMs) {
$locked = $this->redis->set($lockKey, '1', ['NX', 'EX' => 10]);
if ($locked) {
try {
// Double-check nach Lock
$value = $this->get($key);
if ($value !== null) {
return $value;
}
// Loader ausführen und cachen
$value = $loader();
$this->set($key, $value, $ttl);
return $value;
} finally {
$this->redis->del($lockKey);
}
}
// Warten und erneut versuchen
usleep(50_000); // 50ms
$waited += 50;
// Vielleicht hat anderer Prozess gecached
$value = $this->get($key);
if ($value !== null) {
return $value;
}
}
// Timeout: selbst laden als Fallback
$value = $loader();
$this->set($key, $value, $ttl);
return $value;
}
public function delete(string $key): bool
{
return $this->redis->del($this->prefix . $key) > 0;
}
/**
* Pattern-basiertes Löschen (z.B. alle User-Caches)
* ⚠️ KEYS ist O(n) - in Produktion besser SCAN nutzen!
*/
public function deletePattern(string $pattern): int
{
$keys = $this->redis->keys($this->prefix . $pattern);
if (empty($keys)) {
return 0;
}
return $this->redis->del($keys);
}
/**
* Sicheres Pattern-Delete mit SCAN (non-blocking)
*/
public function deletePatternSafe(string $pattern): int
{
$deleted = 0;
$it = 0; // Iterator bei 0 starten
do {
// SCAN gibt [iterator, keys] zurück
$keys = $this->redis->scan(
$it,
$this->prefix . $pattern,
100 // 100 Keys pro Iteration
);
if ($keys !== false && !empty($keys)) {
$deleted += $this->redis->del($keys);
}
} while ($it > 0); // Iterator 0 = fertig
return $deleted;
}
/**
* Tags für gruppenweise Invalidierung
*
* ⚠️ WICHTIG: $key OHNE Prefix übergeben!
* Der Prefix wird intern hinzugefügt.
* Falsch: setWithTags("cache:product:123", ...)
* Richtig: setWithTags("product:123", ...)
*/
public function setWithTags(string $key, mixed $value, array $tags, int $ttl = 300): bool
{
$pipeline = $this->redis->pipeline();
// Wert speichern (mit Prefix)
$pipeline->setex($this->prefix . $key, $ttl, $value);
// Key OHNE Prefix im Tag-Set speichern
// (invalidateTag() fügt Prefix beim Löschen hinzu)
foreach ($tags as $tag) {
$pipeline->sAdd($this->prefix . "tag:{$tag}", $key);
$pipeline->expire($this->prefix . "tag:{$tag}", $ttl + 60);
}
$pipeline->exec();
return true;
}
public function invalidateTag(string $tag): int
{
$tagKey = $this->prefix . "tag:{$tag}";
$keys = $this->redis->sMembers($tagKey);
if (empty($keys)) {
return 0;
}
$fullKeys = array_map(fn($k) => $this->prefix . $k, $keys);
$deleted = $this->redis->del($fullKeys);
$this->redis->del($tagKey);
return $deleted;
}
/**
* Atomares Inkrement (für VersionedCache, Counters)
*/
public function incr(string $key): int
{
return $this->redis->incr($this->prefix . $key);
}
/**
* Pub/Sub für Cache-Invalidierung über Server hinweg
* ⚠️ Für subscribe() separate Redis-Connection nutzen!
*/
public function publish(string $channel, string $message): int
{
return $this->redis->publish($channel, $message);
}
public function subscribe(string $channel, callable $callback): void
{
// ⚠️ Blockiert! Nur in separatem Worker-Prozess nutzen
$this->redis->subscribe([$channel], function ($redis, $chan, $msg) use ($callback) {
$callback($msg);
});
}
}
Redis Datenstrukturen nutzen
<?php
// Hash für strukturierte Daten (besser als JSON-Serialisierung)
$this->redis->hMSet('user:123', [
'name' => 'Max',
'email' => 'max@example.com',
'role' => 'admin',
]);
$this->redis->expire('user:123', 3600);
// Einzelnes Feld lesen/updaten
$name = $this->redis->hGet('user:123', 'name');
$this->redis->hSet('user:123', 'last_login', time());
// Sorted Set für Leaderboards/Rankings
$this->redis->zAdd('leaderboard', [
'player:1' => 1500,
'player:2' => 2300,
'player:3' => 1800,
]);
// Top 10 holen
$topPlayers = $this->redis->zRevRange('leaderboard', 0, 9, true);
// Set für einzigartige Werte (z.B. aktive Sessions)
$this->redis->sAdd('active_users', 'user:123', 'user:456');
$activeCount = $this->redis->sCard('active_users');
$isActive = $this->redis->sIsMember('active_users', 'user:123');
// List für Queues (siehe Background-Jobs Artikel)
$this->redis->lPush('queue:emails', json_encode($job));
$job = $this->redis->brPop(['queue:emails'], 30);
Query-Cache: Teure Queries beschleunigen
Nicht zu verwechseln mit dem MySQL Query Cache (deprecated seit 8.0). Wir cachen Query-Ergebnisse in der Applikation.
Query-Cache Pattern
<?php
declare(strict_types=1);
final class CachedRepository
{
public function __construct(
private PDO $db,
private RedisCache $cache,
private int $defaultTtl = 300,
) {}
/**
* Produkt mit Eager-Loaded Relations
*/
public function findProductWithDetails(string $productId): ?array
{
$cacheKey = "product:{$productId}:details";
return $this->cache->remember($cacheKey, function () use ($productId) {
// Teurer Join-Query
$sql = "
SELECT p.*,
c.name as category_name,
json_agg(DISTINCT i.*) as images,
json_agg(DISTINCT v.*) as variants,
AVG(r.rating) as avg_rating,
COUNT(DISTINCT r.id) as review_count
FROM products p
LEFT JOIN categories c ON c.id = p.category_id
LEFT JOIN product_images i ON i.product_id = p.id
LEFT JOIN product_variants v ON v.product_id = p.id
LEFT JOIN reviews r ON r.product_id = p.id
WHERE p.id = :id
GROUP BY p.id, c.id
";
$stmt = $this->db->prepare($sql);
$stmt->execute(['id' => $productId]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}, $this->defaultTtl);
}
/**
* Kategorie-Liste (selten geändert, häufig abgefragt)
*/
public function getAllCategories(): array
{
return $this->cache->remember('categories:all', function () {
$sql = "SELECT * FROM categories ORDER BY sort_order, name";
return $this->db->query($sql)->fetchAll(PDO::FETCH_ASSOC);
}, ttl: 3600); // 1h TTL, selten geändert
}
/**
* Dashboard-Aggregation (teuer zu berechnen)
*/
public function getDashboardStats(string $tenantId): array
{
$cacheKey = "dashboard:{$tenantId}:stats";
return $this->cache->remember($cacheKey, function () use ($tenantId) {
// Mehrere teure Queries...
return [
'total_orders' => $this->countOrders($tenantId),
'revenue_mtd' => $this->calculateRevenue($tenantId, 'month'),
'revenue_ytd' => $this->calculateRevenue($tenantId, 'year'),
'top_products' => $this->getTopProducts($tenantId, 10),
'generated_at' => time(),
];
}, ttl: 300); // 5min für Dashboard OK
}
/**
* Cache invalidieren bei Änderungen
*/
public function updateProduct(string $productId, array $data): void
{
// Update in DB...
$this->db->prepare("UPDATE products SET ... WHERE id = :id")
->execute([...]);
// Alle relevanten Caches invalidieren
$this->cache->delete("product:{$productId}:details");
$this->cache->invalidateTag("product:{$productId}");
// Auch Listen-Caches invalidieren
$this->cache->deletePatternSafe("products:list:*");
}
}
Cache-Key Design
<?php
final class CacheKeyBuilder
{
/**
* Konsistente, aussagekräftige Cache-Keys
*/
public static function build(string $entity, string $id, ?string $variant = null): string
{
// Format: entity:id[:variant]
// Beispiele:
// product:abc123
// product:abc123:details
// user:456:permissions
// tenant:xyz:config
$key = "{$entity}:{$id}";
if ($variant !== null) {
$key .= ":{$variant}";
}
return $key;
}
/**
* Key für Listen mit Filtern
*/
public static function listKey(string $entity, array $filters = [], ?int $page = null): string
{
// Sortierte Filter für konsistente Keys
ksort($filters);
$parts = [$entity, 'list'];
foreach ($filters as $key => $value) {
if (is_array($value)) {
sort($value);
$value = implode(',', $value);
}
$parts[] = "{$key}:{$value}";
}
if ($page !== null) {
$parts[] = "page:{$page}";
}
return implode(':', $parts);
}
/**
* Key für Multi-Tenant Apps
*
* ⚠️ WICHTIG: In SaaS/Multi-Tenant Apps IMMER Tenant im Key!
* Ausnahme: Bewusst globale Daten (z.B. Währungskurse, Feiertage)
* Produktkatalog, Kategorien etc. sind oft tenant-spezifisch!
*/
public static function tenantKey(string $tenantId, string $entity, string $id): string
{
return "tenant:{$tenantId}:{$entity}:{$id}";
}
}
// Nutzung
$key = CacheKeyBuilder::build('product', 'abc123', 'details');
// → "product:abc123:details"
$listKey = CacheKeyBuilder::listKey('products', [
'category' => 'electronics',
'status' => 'active',
], page: 2);
// → "products:list:category:electronics:status:active:page:2"
Cache-Invalidierung: Das schwere Problem
"There are only two hard things in Computer Science: cache invalidation and naming things." – Phil Karlton
Strategie 1: TTL-basiert (einfach)
// Einfach, aber eventual consistency
$cache->set('config', $config, ttl: 300); // Max 5min stale
// Gut für:
// - Aggregierte Reports
// - Statische Inhalte
// - Daten wo leichte Verzögerung OK ist
Strategie 2: Explizite Invalidierung (konsistent)
<?php
// Bei jeder Änderung explizit invalidieren
final class ProductService
{
public function update(string $id, array $data): void
{
$this->repository->update($id, $data);
// Direkt invalidieren
$this->cache->delete("product:{$id}");
$this->cache->delete("product:{$id}:details");
// Event für andere Subscriber
$this->events->dispatch(new ProductUpdated($id));
}
}
// Event-basierte Invalidierung
final class CacheInvalidationSubscriber
{
#[EventSubscriber(ProductUpdated::class)]
public function onProductUpdated(ProductUpdated $event): void
{
$this->cache->invalidateTag("product:{$event->productId}");
// Auch verwandte Caches
$product = $this->repository->find($event->productId);
$this->cache->invalidateTag("category:{$product->categoryId}");
}
}
Strategie 3: Versionierte Keys
<?php
// Statt Invalidierung: neue Version = neuer Key
final class VersionedCache
{
public function __construct(
private RedisCache $cache,
) {}
public function get(string $entity, string $id): mixed
{
$version = $this->getVersion($entity, $id);
$key = "{$entity}:{$id}:v{$version}";
return $this->cache->get($key);
}
public function set(string $entity, string $id, mixed $value, int $ttl = 300): void
{
$version = $this->getVersion($entity, $id);
$key = "{$entity}:{$id}:v{$version}";
$this->cache->set($key, $value, $ttl);
}
public function invalidate(string $entity, string $id): void
{
// Einfach Version erhöhen → alter Cache wird ignoriert
$this->incrementVersion($entity, $id);
// Alte Versionen verfallen durch TTL automatisch
}
private function getVersion(string $entity, string $id): int
{
return (int) $this->cache->get("version:{$entity}:{$id}") ?: 1;
}
private function incrementVersion(string $entity, string $id): int
{
$key = "version:{$entity}:{$id}";
// Atomares Inkrement (über RedisCache::incr())
return $this->cache->incr($key);
}
}
Strategie 4: Tag-basierte Invalidierung
<?php
// Cache mit Tags speichern
$cache->setWithTags(
"product:{$id}:details",
$productDetails,
tags: [
"product:{$id}",
"category:{$categoryId}",
"tenant:{$tenantId}",
],
ttl: 3600
);
// Bei Produktänderung
$cache->invalidateTag("product:{$id}");
// → Löscht product:123:details und alle anderen mit diesem Tag
// Bei Kategorieänderung
$cache->invalidateTag("category:{$categoryId}");
// → Löscht ALLE Produkte dieser Kategorie
Cache Stampede Prevention
Cache Stampede (auch "Thundering Herd"): Wenn der Cache leer ist und 100 Requests gleichzeitig denselben teuren Query ausführen.
┌─────────────────────────────────────────────────────────────────┐
│ CACHE STAMPEDE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ OHNE Protection: │
│ ──────────────── │
│ Request 1 ─→ Cache Miss ─→ Query DB (2s) ─→ Cache Set │
│ Request 2 ─→ Cache Miss ─→ Query DB (2s) ─→ Cache Set │
│ Request 3 ─→ Cache Miss ─→ Query DB (2s) ─→ Cache Set │
│ ... │
│ Request 100 ─→ Cache Miss ─→ Query DB (2s) ─→ Cache Set │
│ │
│ → 100 identische DB-Queries! DB überlastet! │
│ │
│ MIT Lock-Protection: │
│ ──────────────────── │
│ Request 1 ─→ Cache Miss ─→ Lock ─→ Query DB ─→ Cache Set │
│ Request 2 ─→ Cache Miss ─→ Lock exists ─→ Wait ─→ Cache Hit │
│ Request 3 ─→ Cache Miss ─→ Lock exists ─→ Wait ─→ Cache Hit │
│ │
│ → Nur 1 DB-Query! │
│ │
└─────────────────────────────────────────────────────────────────┘
Lock-basierte Prevention
<?php
final class StampedeProtectedCache
{
public function __construct(
private RedisCache $cache,
private \Redis $redis, // Separate Connection für Locks
private int $lockTtl = 10,
private int $maxWaitMs = 5000,
private int $waitIntervalMs = 50,
) {}
public function remember(string $key, callable $loader, int $ttl = 300): mixed
{
// 1. Versuche aus Cache zu lesen
$value = $this->cache->get($key);
if ($value !== null) {
return $value;
}
// 2. Versuche Lock zu bekommen
$lockKey = "lock:{$key}";
$lockAcquired = $this->acquireLock($lockKey);
if ($lockAcquired) {
try {
// Double-check nach Lock
$value = $this->cache->get($key);
if ($value !== null) {
return $value;
}
// Loader ausführen und cachen
$value = $loader();
$this->cache->set($key, $value, $ttl);
return $value;
} finally {
$this->releaseLock($lockKey);
}
}
// 3. Lock nicht bekommen → warten auf anderen Prozess
return $this->waitForCache($key, $loader, $ttl);
}
private function acquireLock(string $lockKey): bool
{
return $this->redis->set(
$lockKey,
getmypid(),
['NX', 'EX' => $this->lockTtl]
);
}
private function releaseLock(string $lockKey): void
{
// Nur eigenen Lock löschen (Lua für Atomarität)
$script = "
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
";
$this->redis->eval($script, [$lockKey, getmypid()], 1);
}
private function waitForCache(string $key, callable $loader, int $ttl): mixed
{
$waited = 0;
while ($waited < $this->maxWaitMs) {
usleep($this->waitIntervalMs * 1000);
$waited += $this->waitIntervalMs;
$value = $this->cache->get($key);
if ($value !== null) {
return $value;
}
}
// Timeout: selbst laden (Fallback)
$value = $loader();
$this->cache->set($key, $value, $ttl);
return $value;
}
}
Probabilistic Early Expiration
<?php
// Cache vor Ablauf mit Wahrscheinlichkeit refreshen
final class EarlyExpirationCache
{
public function get(string $key, callable $loader, int $ttl = 300): mixed
{
$data = $this->cache->get($key);
if ($data === null) {
return $this->refresh($key, $loader, $ttl);
}
// Prüfe ob early refresh sinnvoll
$meta = $this->cache->get("{$key}:meta");
if ($meta && $this->shouldRefreshEarly($meta, $ttl)) {
// Async refresh (nicht blockierend)
$this->scheduleRefresh($key, $loader, $ttl);
}
return $data;
}
private function shouldRefreshEarly(array $meta, int $ttl): bool
{
$age = time() - $meta['created_at'];
$remaining = $ttl - $age;
// Je näher am Ablauf, desto höher die Wahrscheinlichkeit
if ($remaining <= 0) {
return true;
}
$probability = 1 - ($remaining / $ttl);
return (mt_rand(0, 100) / 100) < $probability;
}
}
Two-Level Caching: APCu + Redis
Kombiniere lokalen Speed (APCu) mit verteilter Konsistenz (Redis).
<?php
final class TwoLevelCache
{
public function __construct(
private ApcuCache $l1, // Lokal, schnell
private RedisCache $l2, // Verteilt, konsistent
private int $l1Ttl = 60, // L1 kürzer als L2!
) {}
public function get(string $key): mixed
{
// 1. L1 (APCu) prüfen
$value = $this->l1->get($key);
if ($value !== null) {
return $value;
}
// 2. L2 (Redis) prüfen
$value = $this->l2->get($key);
if ($value !== null) {
// In L1 nachladen
$this->l1->set($key, $value, $this->l1Ttl);
return $value;
}
return null;
}
public function set(string $key, mixed $value, int $ttl = 300): void
{
// In beide Level schreiben
$this->l2->set($key, $value, $ttl);
$this->l1->set($key, $value, min($this->l1Ttl, $ttl));
}
public function delete(string $key): void
{
// Aus beiden Level löschen
$this->l1->delete($key);
$this->l2->delete($key);
// Problem: Andere Server haben noch L1-Cache!
// Lösung: Pub/Sub für Invalidierung
$this->l2->publish('cache:invalidate', $key);
}
/**
* Subscriber für Cache-Invalidierung (in Worker laufen lassen)
*/
public function subscribeInvalidations(): void
{
$this->l2->subscribe('cache:invalidate', function ($key) {
$this->l1->delete($key);
});
}
}
// ⚠️ L1-TTL muss KÜRZER sein als L2-TTL!
// Sonst: L2 invalidiert, L1 serviert weiter stale Daten
Cache Warming
Kritische Caches vor Traffic befüllen statt auf Cold-Start warten.
<?php
final class CacheWarmer
{
public function __construct(
private RedisCache $cache,
private ProductRepository $products,
private CategoryRepository $categories,
private LoggerInterface $logger,
) {}
/**
* Nach Deployment oder Cache-Flush ausführen
*/
public function warmAll(): void
{
$this->logger->info('Starting cache warming...');
$this->warmCategories();
$this->warmTopProducts();
$this->warmConfig();
$this->logger->info('Cache warming complete');
}
private function warmCategories(): void
{
$categories = $this->categories->findAll();
$this->cache->set('categories:all', $categories, ttl: 3600);
foreach ($categories as $category) {
$this->cache->set(
"category:{$category['id']}",
$category,
ttl: 3600
);
}
$this->logger->debug('Warmed categories', ['count' => count($categories)]);
}
private function warmTopProducts(): void
{
// Top 100 meistbesuchte Produkte
$topProducts = $this->products->findMostViewed(100);
foreach ($topProducts as $product) {
$details = $this->products->findWithDetails($product['id']);
$this->cache->set(
"product:{$product['id']}:details",
$details,
ttl: 300
);
}
$this->logger->debug('Warmed top products', ['count' => count($topProducts)]);
}
private function warmConfig(): void
{
$config = $this->loadConfigFromDatabase();
$this->cache->set('app:config', $config, ttl: 3600);
}
}
// In deploy.sh:
// php bin/console cache:warm
Monitoring & Metriken
<?php
final class CacheMetrics
{
private int $hits = 0;
private int $misses = 0;
public function recordHit(string $key): void
{
$this->hits++;
$this->prometheus->counter('cache_hits_total', ['key_prefix' => $this->getPrefix($key)]);
}
public function recordMiss(string $key): void
{
$this->misses++;
$this->prometheus->counter('cache_misses_total', ['key_prefix' => $this->getPrefix($key)]);
}
public function getHitRate(): float
{
$total = $this->hits + $this->misses;
return $total > 0 ? $this->hits / $total : 0.0;
}
private function getPrefix(string $key): string
{
return explode(':', $key)[0] ?? 'unknown';
}
}
// Prometheus Alerts
// alert: CacheHitRateLow
// expr: rate(cache_hits_total[5m]) / (rate(cache_hits_total[5m]) + rate(cache_misses_total[5m])) < 0.8
// labels:
// severity: warning
// annotations:
// summary: "Cache hit rate below 80%"
Redis Monitoring
# Redis CLI Monitoring
redis-cli INFO stats | grep -E "(hits|misses|keys)"
# Keyspace-Hitrate
redis-cli INFO stats | grep keyspace
# Memory Usage
redis-cli INFO memory
# Slow Queries (>10ms)
redis-cli SLOWLOG GET 10
# Live-Monitoring
redis-cli MONITOR # ⚠️ Nur kurz, hohe Last!
Checkliste
┌─────────────────────────────────────────────────────────────────┐
│ CACHING CHECKLISTE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ DESIGN │
│ □ Cache-Key-Strategie definiert (konsistent, aussagekräftig) │
│ □ TTL-Werte dokumentiert und begründet │
│ □ Invalidierungs-Strategie festgelegt │
│ □ Multi-Tenant: tenant_id im Cache-Key │
│ │
│ IMPLEMENTATION │
│ □ Cache Stampede Prevention (Locking oder probabilistic) │
│ □ Graceful Degradation wenn Cache nicht erreichbar │
│ □ Serializer konfiguriert (JSON/PHP/igbinary) │
│ □ Connection Pooling / Persistent Connections │
│ │
│ OPERATIONS │
│ □ Cache-Warming nach Deployment │
│ □ Monitoring: Hit-Rate, Memory, Latenz │
│ □ Alerting bei niedriger Hit-Rate oder Errors │
│ □ Redis Persistence konfiguriert (wenn nötig) │
│ │
│ TESTING │
│ □ Tests mit und ohne Cache (Cache disabled) │
│ □ TTL-Verhalten getestet │
│ □ Invalidierung getestet │
│ │
└─────────────────────────────────────────────────────────────────┘
Fazit
Caching ist kein Allheilmittel, aber richtig eingesetzt ein Gamechanger:
- APCu: Für lokale, schnelle Caches (Config, Translations, Single-Server)
- Redis: Für verteilte Systeme, komplexe Datenstrukturen, Persistenz
- Query-Cache: Teure Aggregationen, Joins, Reports cachen
- Invalidierung: TTL + explizite Invalidierung kombinieren
- Stampede Prevention: Locking bei teuren Queries
- Two-Level: APCu als L1 vor Redis für maximale Performance
Mein Stack: Redis als primärer Cache mit Tag-basierter Invalidierung. APCu nur für Config und wirklich statische Daten. TTLs eher kurz (5min) + explizite Invalidierung bei Writes.
Weiterführende Ressourcen
Über Carola Schulte
Software-Architektin mit 25+ Jahren Erfahrung. Spezialisiert auf robuste Business-Apps mit PHP/PostgreSQL, Security-by-Design und DSGVO-konforme Systeme. 1,8M+ Lines of Code in Produktion.
Performance-Probleme in Ihrer App?
Lassen Sie uns analysieren, wo Caching Ihre Applikation schneller machen kann – kostenlos und unverbindlich.
Kostenloses Erstgespräch