MidПрактика9 min

Стратегии кэширования

Cache-Aside, Write-Through, Write-Behind, Read-Through паттерны. Практические примеры с Redis и Memcached на PHP

Зачем кэшировать

Кэширование -- это самый эффективный способ улучшить производительность системы. Правильно настроенный кэш может снизить нагрузку на базу данных в 10-100 раз и уменьшить время ответа с сотен миллисекунд до единиц.

Уровни кэширования

Клиент (браузер)
  └─ CDN (географически близко)
       └─ Reverse Proxy (Nginx, Varnish)
            └─ Application Cache (Redis, Memcached)
                 └─ Database Cache (query cache, buffer pool)

Стратегия 1: Cache-Aside (Lazy Loading)

Самая распространенная стратегия. Приложение управляет кэшем самостоятельно.

Как работает

  1. Приложение проверяет кэш
  2. Если данные есть (cache hit) -- возвращает из кэша
  3. Если данных нет (cache miss) -- читает из БД, записывает в кэш
<?php

declare(strict_types=1);

/**
 * Cache-Aside pattern (also known as Lazy Loading)
 *
 * Application is responsible for reading from and writing to cache.
 * Cache and database are independent.
 *
 * Pros:
 * - Only requested data is cached (no waste)
 * - Cache failure doesn't affect the system (fallback to DB)
 * - Simple to implement
 *
 * Cons:
 * - Cache miss = 3 network calls (check cache + read DB + write cache)
 * - Data can become stale
 * - Cold cache problem (after restart, everything is a miss)
 */
final class CacheAsideProductRepository
{
    private const TTL = 3600; // 1 hour
    private const PREFIX = 'product:';

    public function __construct(
        private readonly \PDO $db,
        private readonly \Redis $cache,
        private readonly LoggerInterface $logger,
    ) {}

    public function findById(string $id): ?Product
    {
        $cacheKey = self::PREFIX . $id;

        // Step 1: Check cache
        $cached = $this->cache->get($cacheKey);
        if ($cached !== false) {
            $this->logger->debug('Cache HIT', ['key' => $cacheKey]);
            return Product::fromJson($cached);
        }

        $this->logger->debug('Cache MISS', ['key' => $cacheKey]);

        // Step 2: Read from database
        $stmt = $this->db->prepare(
            'SELECT id, name, price, description, category_id, updated_at
             FROM products WHERE id = :id'
        );
        $stmt->execute(['id' => $id]);
        $row = $stmt->fetch(\PDO::FETCH_ASSOC);

        if ($row === false) {
            // Cache null result to prevent cache stampede on missing data
            $this->cache->setex($cacheKey, 300, json_encode(null));
            return null;
        }

        $product = Product::fromRow($row);

        // Step 3: Write to cache
        $this->cache->setex($cacheKey, self::TTL, $product->toJson());

        return $product;
    }

    /**
     * On update: invalidate cache, not update it.
     * This avoids race conditions between concurrent updates.
     */
    public function update(Product $product): void
    {
        $this->db->prepare(
            'UPDATE products SET name = :name, price = :price, updated_at = NOW()
             WHERE id = :id'
        )->execute([
            'id' => $product->id,
            'name' => $product->name,
            'price' => $product->price,
        ]);

        // Invalidate cache (not update)
        $this->cache->del(self::PREFIX . $product->id);

        // Also invalidate any list caches that include this product
        $this->invalidateListCaches($product);
    }

    private function invalidateListCaches(Product $product): void
    {
        // Use tags or pattern-based invalidation
        $pattern = sprintf('product_list:category:%s:*', $product->categoryId);
        $keys = $this->cache->keys($pattern);

        if (!empty($keys)) {
            $this->cache->del(...$keys);
        }
    }
}

Стратегия 2: Write-Through

Данные записываются в кэш и базу данных одновременно. Кэш всегда актуален.

<?php

declare(strict_types=1);

/**
 * Write-Through pattern
 *
 * Every write goes through cache first, then to database.
 * Cache is always consistent with the database.
 *
 * Pros:
 * - Cache is always up-to-date
 * - Reads are always fast (no cold cache problem)
 * - Simple consistency model
 *
 * Cons:
 * - Write latency is higher (write to cache + DB)
 * - All data is cached, even rarely accessed
 * - Not suitable for write-heavy workloads
 */
final class WriteThroughUserRepository
{
    private const TTL = 7200; // 2 hours
    private const PREFIX = 'user:';

    public function __construct(
        private readonly \PDO $db,
        private readonly \Redis $cache,
    ) {}

    public function findById(string $id): ?User
    {
        $cacheKey = self::PREFIX . $id;

        // Always check cache first (should always be populated)
        $cached = $this->cache->get($cacheKey);
        if ($cached !== false) {
            return User::fromJson($cached);
        }

        // Fallback to DB (only on cold start or cache eviction)
        $user = $this->loadFromDb($id);
        if ($user !== null) {
            $this->cache->setex($cacheKey, self::TTL, $user->toJson());
        }

        return $user;
    }

    /**
     * Write-through: update cache and DB together.
     * Cache is updated BEFORE returning to the caller.
     */
    public function save(User $user): void
    {
        // Step 1: Write to database
        $this->db->prepare(
            'INSERT INTO users (id, name, email, updated_at)
             VALUES (:id, :name, :email, NOW())
             ON CONFLICT (id) DO UPDATE SET
                name = EXCLUDED.name,
                email = EXCLUDED.email,
                updated_at = NOW()'
        )->execute([
            'id' => $user->id,
            'name' => $user->name,
            'email' => $user->email,
        ]);

        // Step 2: Write to cache (synchronously)
        $this->cache->setex(
            self::PREFIX . $user->id,
            self::TTL,
            $user->toJson(),
        );
    }

    private function loadFromDb(string $id): ?User
    {
        $stmt = $this->db->prepare('SELECT * FROM users WHERE id = :id');
        $stmt->execute(['id' => $id]);
        $row = $stmt->fetch(\PDO::FETCH_ASSOC);

        return $row ? User::fromRow($row) : null;
    }
}

Стратегия 3: Write-Behind (Write-Back)

Данные записываются в кэш немедленно, а в базу данных -- асинхронно.

<?php

declare(strict_types=1);

/**
 * Write-Behind (Write-Back) pattern
 *
 * Writes go to cache immediately. Database is updated asynchronously.
 *
 * Pros:
 * - Very low write latency (only cache write is synchronous)
 * - Batching: multiple writes can be merged into one DB write
 * - Absorbs write spikes
 *
 * Cons:
 * - Risk of data loss if cache fails before DB write
 * - Complex error handling
 * - Consistency window (cache is ahead of DB)
 */
final class WriteBehindMetricsStore
{
    private const BUFFER_KEY = 'metrics:buffer';
    private const FLUSH_THRESHOLD = 100;

    public function __construct(
        private readonly \Redis $cache,
        private readonly \PDO $db,
    ) {}

    /**
     * Record a metric: write only to Redis (fast).
     * Background worker will flush to PostgreSQL.
     */
    public function record(string $metric, float $value, array $tags = []): void
    {
        $entry = json_encode([
            'metric' => $metric,
            'value' => $value,
            'tags' => $tags,
            'timestamp' => microtime(true),
        ]);

        // O(1) operation: add to Redis list
        $this->cache->rPush(self::BUFFER_KEY, $entry);

        // Auto-flush if buffer is large
        if ($this->cache->lLen(self::BUFFER_KEY) >= self::FLUSH_THRESHOLD) {
            $this->flush();
        }
    }

    /**
     * Flush buffered metrics to the database.
     * Called periodically by a background worker.
     * Uses batch insert for efficiency.
     */
    public function flush(): void
    {
        $entries = [];

        // Atomically pop all entries from the buffer
        while (($entry = $this->cache->lPop(self::BUFFER_KEY)) !== false) {
            $entries[] = json_decode($entry, true);

            // Limit batch size
            if (count($entries) >= 1000) {
                break;
            }
        }

        if (empty($entries)) {
            return;
        }

        // Batch insert into PostgreSQL
        $this->batchInsert($entries);
    }

    private function batchInsert(array $entries): void
    {
        $placeholders = [];
        $values = [];
        $i = 0;

        foreach ($entries as $entry) {
            $placeholders[] = "(:metric$i, :value$i, :tags$i, to_timestamp(:ts$i))";
            $values["metric$i"] = $entry['metric'];
            $values["value$i"] = $entry['value'];
            $values["tags$i"] = json_encode($entry['tags']);
            $values["ts$i"] = $entry['timestamp'];
            $i++;
        }

        $sql = sprintf(
            'INSERT INTO metrics (metric_name, value, tags, recorded_at) VALUES %s',
            implode(', ', $placeholders),
        );

        $this->db->prepare($sql)->execute($values);
    }
}

Стратегия 4: Read-Through

Кэш сам загружает данные из источника при cache miss. Приложение работает только с кэшем.

<?php

declare(strict_types=1);

/**
 * Read-Through pattern
 *
 * Cache itself is responsible for loading data on miss.
 * Application only interacts with the cache layer.
 *
 * Pros:
 * - Simpler application code
 * - Cache logic is centralized
 * - Easy to swap cache implementation
 *
 * Cons:
 * - Cache needs to know about the data source
 * - First request for any data is slow
 * - More complex cache implementation
 */
final class ReadThroughCache
{
    public function __construct(
        private readonly \Redis $redis,
        private readonly int $defaultTtl = 3600,
    ) {}

    /**
     * Get value from cache. If not found, call loader to fetch
     * and populate cache automatically.
     *
     * @template T
     * @param callable(): T $loader Function that loads data from source
     * @return T
     */
    public function getOrLoad(string $key, callable $loader, int $ttl = 0): mixed
    {
        $ttl = $ttl ?: $this->defaultTtl;

        // Try cache first
        $cached = $this->redis->get($key);
        if ($cached !== false) {
            return unserialize($cached);
        }

        // Cache miss: load from source
        $value = $loader();

        // Store in cache
        if ($value !== null) {
            $this->redis->setex($key, $ttl, serialize($value));
        } else {
            // Cache null values with shorter TTL (negative caching)
            $this->redis->setex($key, min($ttl, 300), serialize(null));
        }

        return $value;
    }

    /**
     * Bulk get with automatic loading of missing keys.
     *
     * @param array<string> $keys
     * @param callable(array<string>): array<string, mixed> $bulkLoader
     * @return array<string, mixed>
     */
    public function getMultiOrLoad(array $keys, callable $bulkLoader, int $ttl = 0): array
    {
        $ttl = $ttl ?: $this->defaultTtl;

        // Get all keys from cache at once
        $cached = $this->redis->mGet($keys);
        $results = [];
        $missingKeys = [];

        foreach ($keys as $i => $key) {
            if ($cached[$i] !== false) {
                $results[$key] = unserialize($cached[$i]);
            } else {
                $missingKeys[] = $key;
            }
        }

        // Load missing keys in bulk
        if (!empty($missingKeys)) {
            $loaded = $bulkLoader($missingKeys);

            // Cache all loaded values
            $pipe = $this->redis->multi(\Redis::PIPELINE);
            foreach ($loaded as $key => $value) {
                $pipe->setex($key, $ttl, serialize($value));
                $results[$key] = $value;
            }
            $pipe->exec();
        }

        return $results;
    }
}

// Usage:
// $cache = new ReadThroughCache($redis);
// $user = $cache->getOrLoad(
//     "user:$userId",
//     fn() => $userRepository->findById($userId),
//     ttl: 3600,
// );

Инвалидация кэша

"There are only two hard things in Computer Science: cache invalidation and naming things."

Стратегии инвалидации

Стратегия Описание Когда использовать
TTL (Time-to-Live) Данные автоматически устаревают Когда допустима задержка обновления
Event-based Инвалидация при изменении данных Когда нужна свежесть данных
Version-based Ключ включает версию данных Для статических ресурсов
<?php

declare(strict_types=1);

/**
 * Advanced cache invalidation with tags
 *
 * Problem: when a product updates, invalidate all caches
 * that include this product (list pages, search results, etc.)
 *
 * Solution: tag-based invalidation
 */
final class TaggedCache
{
    public function __construct(
        private readonly \Redis $redis,
    ) {}

    /**
     * Set a value with associated tags.
     * Tags allow bulk invalidation of related cache entries.
     *
     * @param array<string> $tags
     */
    public function set(string $key, mixed $value, int $ttl, array $tags = []): void
    {
        $this->redis->setex($key, $ttl, serialize($value));

        // Register this key under each tag
        foreach ($tags as $tag) {
            $tagKey = "tag:$tag";
            $this->redis->sAdd($tagKey, $key);
            $this->redis->expire($tagKey, $ttl + 3600); // Tag lives slightly longer
        }
    }

    public function get(string $key): mixed
    {
        $value = $this->redis->get($key);
        return $value !== false ? unserialize($value) : null;
    }

    /**
     * Invalidate all cache entries associated with a tag.
     * Example: invalidateByTag('product:123') clears all caches
     * that included product 123.
     */
    public function invalidateByTag(string $tag): int
    {
        $tagKey = "tag:$tag";
        $keys = $this->redis->sMembers($tagKey);

        if (empty($keys)) {
            return 0;
        }

        // Delete all tagged keys
        $deleted = $this->redis->del(...$keys);

        // Delete the tag set itself
        $this->redis->del($tagKey);

        return $deleted;
    }
}

// Usage:
// $cache->set('product_list:category:5:page:1', $products, 3600, [
//     'product:101',
//     'product:102',
//     'category:5',
// ]);
//
// When product 101 is updated:
// $cache->invalidateByTag('product:101');
// This clears ALL cache entries that included product 101

Cache Stampede Protection

Когда кэш устаревает, множество параллельных запросов одновременно обращаются к базе данных. Это называется cache stampede (или thundering herd).

<?php

declare(strict_types=1);

/**
 * Cache stampede protection using distributed lock
 *
 * Without protection: 100 concurrent requests on cache miss
 *   = 100 identical DB queries
 *
 * With protection: 1 request fetches data, 99 wait for cache
 */
final class StampedeProtectedCache
{
    public function __construct(
        private readonly \Redis $redis,
        private readonly int $lockTimeoutMs = 5000,
    ) {}

    /**
     * Get from cache with stampede protection.
     * Only one process will call the loader on cache miss.
     */
    public function getOrLoad(string $key, callable $loader, int $ttl = 3600): mixed
    {
        // Try cache
        $cached = $this->redis->get($key);
        if ($cached !== false) {
            return unserialize($cached);
        }

        // Cache miss: try to acquire lock
        $lockKey = "lock:$key";
        $lockValue = bin2hex(random_bytes(16));
        $acquired = $this->redis->set($lockKey, $lockValue, [
            'NX' => true,
            'PX' => $this->lockTimeoutMs,
        ]);

        if ($acquired) {
            // We got the lock: load data and populate cache
            try {
                $value = $loader();
                $this->redis->setex($key, $ttl, serialize($value));
                return $value;
            } finally {
                // Release lock (only if we still own it)
                $script = "
                    if redis.call('get', KEYS[1]) == ARGV[1] then
                        return redis.call('del', KEYS[1])
                    end
                    return 0
                ";
                $this->redis->eval($script, [$lockKey, $lockValue], 1);
            }
        }

        // Someone else is loading: wait and retry
        $attempts = 0;
        while ($attempts < 50) { // Max 5 seconds (50 * 100ms)
            usleep(100_000); // 100ms

            $cached = $this->redis->get($key);
            if ($cached !== false) {
                return unserialize($cached);
            }

            $attempts++;
        }

        // Timeout: load data ourselves (fallback)
        return $loader();
    }
}

Сравнение стратегий

Стратегия Консистентность Write Latency Read Latency Сложность
Cache-Aside Eventual Низкая Miss: высокая Низкая
Write-Through Strong Высокая Всегда низкая Средняя
Write-Behind Eventual Очень низкая Всегда низкая Высокая
Read-Through Eventual N/A Miss: высокая Средняя

Выводы

Выбор стратегии кэширования зависит от характера нагрузки. Для read-heavy с допустимой задержкой -- Cache-Aside. Для систем, где актуальность критична -- Write-Through. Для write-heavy -- Write-Behind. Не забывайте про инвалидацию и защиту от stampede.