Caching-Strategien
Performance & Skalierung

Caching-Strategien: Redis, APCu & Query-Cache

Carola Schulte
24. November 2025
26 min

"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

DatentypTTL-EmpfehlungInvalidierung
Konfiguration5-60 minBei Deploy/Admin-Änderung
User-Session-DatenSession-LifetimeBei Logout/Änderung
Produktkatalog1-5 minBei Produkt-Update
Aggregierte Reports5-60 minTTL-basiert
API-Responses (extern)Je nach APICache-Control Header
Computed Permissions1-5 minBei 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

FeatureRedisMemcached
DatenstrukturenStrings, Lists, Sets, Hashes, Sorted SetsNur Strings
PersistenzRDB + AOFNein
Pub/SubJaNein
Lua ScriptingJaNein
ClusterRedis ClusterClient-seitiges Sharding
Memory EfficiencyGutBesser (einfacher)
Max Value Size512MB1MB

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

Carola Schulte

Ü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