Zum Hauptinhalt springen

Redis Cache

Postbox nutzt Redis als primären Cache-Store mit PostgreSQL als Failover. Die Architektur ist auf kleine, heiße, stabile Antworten ausgelegt — Redis ist L1-Cache, PostgreSQL bleibt Source of Truth.

Setup​

Stores​

// config/cache.php
'failover' => [
'driver' => 'failover',
'stores' => ['redis', 'database'], // Redis primär, DB-Fallback
],

Connections​

DB 0: Default (Locks, Scheduler, Reverb, Queue)
DB 1: Cache (allgemeiner App-Cache)

Redis-Config (Production)​

# /etc/redis/redis.conf
maxmemory 512mb # Source-of-Truth — `REDIS_MAX_MEMORY_MB` in .env muss matchen
maxmemory-policy allkeys-lru # LRU-Eviction bei RAM-Knappheit (Page-Cache erwartet das)
save "" # Kein RDB (Cache ist flüchtig)
appendonly no # Kein AOF
requirepass <REDIS_PASSWORD>

TTL-Konfiguration​

Alle Cache-TTLs sind zentralisiert in config/postbox.php unter dem Block cache_ttl und können via .env überschrieben werden:

'cache_ttl' => [
'explore_categories' => (int) env('CACHE_TTL_EXPLORE_CATEGORIES', 86400), // 24h
'recommended_global_pool' => (int) env('CACHE_TTL_RECOMMENDED_POOL', 21600), // 6h
'redirects_map' => (int) env('CACHE_TTL_REDIRECTS_MAP', 300), // 5min
// ... ~40 weitere Keys
],

Lese-Pattern: Im Code immer Config::integer('postbox.cache_ttl.X') statt (int) config(...) — PHPStan-strict-type-konform.

Cache::remember('explore:categories', Config::integer('postbox.cache_ttl.explore_categories'), fn () => ...);

Cache-Gruppen​

CacheGroupService definiert 17 logische Gruppen mit Prefixen für SCAN-basiertes Bulk-Clearing:

GruppePrefix(e)Auto-RebuildTTL-Bereich
exploreexplore:, discover:✓24h
public_explorerpublic-explorer:✓1h–24h
recommendationsrecommended_profiles, trending_slider:✓1h–6h
pipelinepipeline:, cron:heartbeat:, health:, deploy:⚠ Cron5min–48h
youtubeyt_, youtube_✓5min–24h
instagraminstagram_✓1h
tagsblocked_tags:, tag_aliases:✓1h
adminadmin:✓30s–1h
serverserver:, db_monitoring:, pulse:, error-monitor:✓30s–24h
mailmail:flood:⚠ Counter-Reset60s–24h
video_trendsvideo-trends:✓1h–24h
collectorcollector:✓5min
explore_sectionsexplore_sections:✓1h
r2_storager2:✓ (nur bei s3/dual-write)60s–7d
rustfs_storagestorage_monitoring:✓ (nur bei rustfs/dual-write)15min
turnstileturnstile:✓5min
cache_statscache_stats:✓24h (intern)

Persistente Keys (PostgreSQL)​

Drei forever-Keys liegen explizit im database-Store, weil Redis-Restart sie sonst löscht:

Cache::store('database')->forever('tag_consolidation.established_threshold', $value);
Cache::store('database')->forever('tag_consolidation.auto_execute_high_confidence', $value);
Cache::store('database')->forever('tag_consolidation.cooldown_days', $value);

Jeder neu hinzukommende forever-Key MUSS auf database-Store gehen — siehe docs/10-entwicklung/05-troubleshooting.md.

Admin-Seite /admin/cache-management​

Bietet:

  • Redis-Status-Dashboard: Memory, Hit-Rate, Fragmentation, Evicted Keys, Total Keys, Uptime
  • Cache-Gruppen mit Key-Anzahl + "Leeren"-Button (queued via ClearCacheGroup Job + Reverb-Rückmeldung)
  • TTL-Übersicht aus config('postbox.cache_ttl') — alle Keys + lesbare TTL-Werte
  • Cache-Warming-Status des letzten cache:warm-Laufs
  • Hit-Rate-Chart der letzten 24h aus cache_monitoring_snapshots (Snapshot alle 15min)
  • Danger Zone Flush-All mit Bestätigungsmodal

Health-Check /up_system​

Der Endpoint enthält jetzt einen dedizierten REDIS-Check mit Kennzahlen:

REDIS RUNNING
keys=1115 mem=7.2% hit_rate=94.3% frag=1.1

Status-Codes:

  • RUNNING — alles im grünen Bereich
  • WARNING — evicted_keys > 0 ODER memory > 80% ODER fragmentation > 1.5
  • FAILED — Redis nicht erreichbar

Cache-Warming nach Deploy​

# Warmt die wichtigsten Caches proaktiv
php artisan cache:warm

# Optionen
php artisan cache:warm --rate=50 # Custom rate (Keys pro Minute)
php artisan cache:warm --group=explore # Nur eine Gruppe
php artisan cache:warm --dry-run # Zeigt was gewärmt würde

Rate-Limiting verhindert DB-Last-Spitze nach Deploy. Standard: 30 Keys/min via CACHE_WARM_RATE_PER_MINUTE. 0 deaktiviert Auto-Warming.

Hit-Rate-Tracking (E7)​

Laravel feuert CacheHit/CacheMissed-Events bei jedem Cache-Zugriff. Der CacheHitRateTracker aggregiert pro Request in-memory + flusht am Request-Ende als Redis-Pipeline:

cache_stats:{group}:{date}:hits    (TTL 24h)
cache_stats:{group}:{date}:misses (TTL 24h)

Der SnapshotCacheMetrics Job (alle 15min) übernimmt diese Counter in die cache_monitoring_snapshots-Tabelle für Charts. Snapshots älter als 7 Tage werden täglich gepruned.

Web-Vitals Redis-Buffer (E13)​

POST /api/web-vitals schreibt nicht mehr direkt in PostgreSQL, sondern pusht raw Messwerte in einen Redis-Buffer:

Browser → POST /api/web-vitals
→ Redis: RPUSH pb:web-vitals:buffer {json} (~0.1ms)
→ 200 {"ok": true}

Scheduled Job (alle 5 Min):
web-vitals:flush
→ LRANGE + LTRIM (atomar — keine Race-Condition mit neuen Einträgen)
→ Aggregation nach (date, url, metric_name, device_type)
→ 1 Bulk-Upsert pro Gruppe in PostgreSQL

Vorteil: Response-Time <1ms statt ~5ms vorher, DB-Queries skalieren nicht mehr mit Page-Views (nur mit unique URL/Metric-Kombinationen).

Datenverlust-Risiko: max. 5 Min bei Redis-Restart. Akzeptabel weil Web Vitals statistische Daten sind.

Fallstricke​

  1. Redis-Neustart verliert Cache — automatisch via Cache::remember() rebuilt + forever-Keys liegen in PostgreSQL.

  2. FLUSHDB killt Scheduler-Locks — Admin-Seite warnt explizit. Empfohlen: nur Gruppen leeren, FLUSHDB nur als letztes Mittel.

  3. phpredis-Crash — Failover-Store fängt das ab (PostgreSQL-Cache als Fallback).

  4. RAM-Überlauf — maxmemory 512mb + allkeys-lru Eviction. Monitoring warnt ab 80 %.

    Was allkeys-lru für Zustandsdaten bedeutet: Die Policy wirft bei Speicherdruck den am längsten ungenutzten Key — unabhängig davon, ob er eine TTL hat. Cron-Heartbeats von Tagesjobs (cron:heartbeat:*) sind damit die anfälligsten Keys im ganzen Store: einmal pro Tag geschrieben, selten gelesen, also praktisch immer die kältesten. Deshalb hat CronStatusService eine DB-Persistenz als Rückfallebene — sie ist kein Beiwerk, sondern der eigentliche Eviction-Schutz.

    Stand 2026-07-31 (gemessen auf PROD): 504,6 MB von 512 MB belegt (98,6 %), evicted_keys: 0. Es wurde also noch nichts geworfen, aber der Store steht unmittelbar vor der Schwelle. REDIS_MAX_MEMORY_MB in der .env muss zu maxmemory in der redis.conf passen — weicht der Wert ab, rechnet PageCacheService sein Budget gegen eine Zahl, die es nicht gibt, und füllt weiter bis zur Eviction.

  5. SCAN-Performance — Bei <10K Keys vernachlässigbar (~10ms). KEYS-Befehl ist verboten (blockiert Redis).

Migration vs. Database-Cache​

AspektDatabase-Cache (alt)Redis-Cache (neu)
Read-Latenz~2-5ms (SELECT)sub-ms
Write-Latenz~2-5ms (INSERT/UPDATE)sub-ms
Atomare Counter❌ (race-condition)✓ (INCR)
Persistenz✓❌ (RAM, flüchtig)
Bulk-ClearSQL LIKESCAN + DEL (non-blocking)
forever-Keys✓❌ → bleiben in DB
Failover—DB als Fallback

Rollback​

Bei Problemen einfach .env zurück:

CACHE_STORE=database

Plus php artisan config:cache. Die cache + cache_locks Tabellen existieren weiterhin → kein Datenverlust.