Зачем кэшировать
Кэширование -- это самый эффективный способ улучшить производительность системы. Правильно настроенный кэш может снизить нагрузку на базу данных в 10-100 раз и уменьшить время ответа с сотен миллисекунд до единиц.
Уровни кэширования
Клиент (браузер)
└─ CDN (географически близко)
└─ Reverse Proxy (Nginx, Varnish)
└─ Application Cache (Redis, Memcached)
└─ Database Cache (query cache, buffer pool)
Стратегия 1: Cache-Aside (Lazy Loading)
Самая распространенная стратегия. Приложение управляет кэшем самостоятельно.
Как работает
- Приложение проверяет кэш
- Если данные есть (cache hit) -- возвращает из кэша
- Если данных нет (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.