О нас Руководства Проекты Контакты
Админка
пожалуйста подождите

Введение

Кэширование — один из наиболее эффективных способов повысить производительность API и снизить нагрузку на сервер. Сохраняя часто запрашиваемые данные ближе к потребителю, кэширование уменьшает задержки, нагрузку на базу данных и затраты на пропускную способность. В этом руководстве рассматриваются практические стратегии кэширования для web API.

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

Client ──> CDN ──> API Gateway ──> Application ──> Database
↓ ↓ ↓ ↓
Browser Edge In-Memory Query Cache
Storage Cache (Redis)

Каждый уровень служит разным целям:

  • На стороне клиента: полностью сокращает количество запросов
  • CDN/Edge: кэширует в географически распределённых точках
  • Приложение: кэширует вычисленные результаты
  • База данных: кэширует результаты запросов

HTTP-заголовки кэширования

Cache-Control

// Ответ Laravel с заголовками кэширования
return response()
->json($data)
->header('Cache-Control', 'public, max-age=3600') // Кэшировать на 1 час
->header('ETag', md5(json_encode($data)));
// Директивы Cache-Control
// public: CDN и браузер могут кэшировать
// private: кэшировать может только браузер
// no-cache: перед использованием кэша требуется повторная проверка
// no-store: не кэшировать вообще
// max-age: секунды до устаревания
// s-maxage: максимальный возраст для общих кэшей (CDN)

ETag и условные запросы

// Middleware Laravel для поддержки ETag
class ETagMiddleware
{
public function handle($request, Closure $next)
{
$response = $next($request);
if ($request->isMethod('GET')) {
$etag = md5($response->getContent());
$response->header('ETag', '"' . $etag . '"');
// Проверить заголовок If-None-Match
$requestEtag = $request->header('If-None-Match');
if ($requestEtag && $requestEtag === '"' . $etag . '"') {
return response()->noContent(304);
}
}
return $response;
}
}

Last-Modified

public function show(Product $product)
{
$lastModified = $product->updated_at;
// Проверить заголовок If-Modified-Since
$ifModifiedSince = request()->header('If-Modified-Since');
if ($ifModifiedSince) {
$ifModifiedSince = Carbon::parse($ifModifiedSince);
if ($lastModified <= $ifModifiedSince) {
return response()->noContent(304);
}
}
return response()
->json($product)
->header('Last-Modified', $lastModified->toRfc7231String())
->header('Cache-Control', 'private, max-age=60');
}

Кэширование на уровне приложения

Кэширование Redis в Laravel

// Базовое кэширование
$products = Cache::remember('products.all', 3600, function () {
return Product::with('category')->get();
});
// Кэширование с тегами (для инвалидации)
$product = Cache::tags(['products'])->remember(
"products.{$id}",
3600,
function () use ($id) {
return Product::with('reviews')->find($id);
}
);
// Инвалидировать по тегу
Cache::tags(['products'])->flush();
// Кэширование с блокировкой (предотвращение stampede)
$value = Cache::lock('processing')->get(function () {
return expensiveOperation();
});

Паттерны кэширования

Cache-Aside (Lazy Loading)

class ProductRepository
{
public function find(int $id): ?Product
{
$cacheKey = "product:{$id}";
// Сначала попытаться взять из кэша
$product = Cache::get($cacheKey);
if ($product === null) {
// Промах кэша — загрузить из базы данных
$product = Product::find($id);
if ($product) {
Cache::put($cacheKey, $product, 3600);
}
}
return $product;
}
public function update(int $id, array $data): Product
{
$product = Product::findOrFail($id);
$product->update($data);
// Инвалидировать кэш
Cache::forget("product:{$id}");
return $product;
}
}

Write-Through

public function update(int $id, array $data): Product
{
$product = Product::findOrFail($id);
$product->update($data);
// Немедленно обновить кэш
Cache::put("product:{$id}", $product, 3600);
return $product;
}

Cache-Aside со Stale-While-Revalidate

public function getProducts(): array
{
$cacheKey = 'products.list';
$staleKey = 'products.list.stale';
// Проверить свежий кэш
$fresh = Cache::get($cacheKey);
if ($fresh !== null) {
return $fresh;
}
// Проверить устаревший кэш и запустить фоновое обновление
$stale = Cache::get($staleKey);
if ($stale !== null) {
// Отправить задачу на обновление кэша
RefreshProductsCacheJob::dispatch();
return $stale;
}
// Кэша нет — получить данные, затем закэшировать
$products = $this->fetchProducts();
Cache::put($cacheKey, $products, 60); // Свежий в течение 1 минуты
Cache::put($staleKey, $products, 3600); // Устаревший в течение 1 часа
return $products;
}

Кэширование результатов запросов

Кэш запросов базы данных

// Кэширование запросов Laravel
$products = Product::where('active', true)
->orderBy('name')
->cacheFor(now()->addMinutes(30))
->cacheTags(['products'])
->get();

Кэширование вычисляемых значений

class DashboardService
{
public function getStatistics(): array
{
return Cache::remember('dashboard.stats', 300, function () {
return [
'total_orders' => Order::count(),
'revenue' => Order::sum('total'),
'avg_order_value' => Order::avg('total'),
'top_products' => $this->getTopProducts(),
];
});
}
private function getTopProducts(): Collection
{
return Product::withCount('orderItems')
->orderByDesc('order_items_count')
->limit(10)
->get();
}
}

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

Инвалидация на основе событий

// Слушатель событий
class InvalidateProductCache
{
public function handle(ProductUpdated $event): void
{
$product = $event->product;
// Очистить конкретный продукт
Cache::forget("product:{$product->id}");
// Очистить связанные кэши
Cache::tags(['products', 'categories'])->flush();
// Очистить кэши списков
Cache::forget('products.list');
Cache::forget("category:{$product->category_id}.products");
}
}
// Observer модели
class ProductObserver
{
public function saved(Product $product): void
{
Cache::forget("product:{$product->id}");
}
public function deleted(Product $product): void
{
Cache::forget("product:{$product->id}");
}
}

Инвалидация на основе времени

// Короткий TTL для часто изменяющихся данных
Cache::put('active_users', $count, 60); // 1 минута
// Более длинный TTL для стабильных данных
Cache::put('site_settings', $settings, 86400); // 1 день
// Прогрессивный TTL в зависимости от возраста
$ttl = min(86400, $product->created_at->diffInSeconds(now()) / 10);
Cache::put("product:{$id}", $product, $ttl);

Кэширование CDN

Заголовки кэширования Cloudflare

// Кэшировать на CDN на 1 час
return response()
->json($data)
->header('Cache-Control', 'public, s-maxage=3600, max-age=60');
// Vary по заголовку Accept (JSON vs HTML)
return response()
->json($data)
->header('Cache-Control', 'public, max-age=300')
->header('Vary', 'Accept, Accept-Encoding');

Очистка кэша CDN

// API Cloudflare
use Illuminate\Support\Facades\Http;
class CloudflareService
{
public function purgeUrls(array $urls): void
{
Http::withHeaders([
'Authorization' => 'Bearer ' . config('services.cloudflare.token'),
'Content-Type' => 'application/json',
])->delete(
"https://api.cloudflare.com/client/v4/zones/{$this->zoneId}/purge_cache",
['files' => $urls]
);
}
public function purgeByTags(array $tags): void
{
Http::withHeaders([
'Authorization' => 'Bearer ' . config('services.cloudflare.token'),
])->delete(
"https://api.cloudflare.com/client/v4/zones/{$this->zoneId}/purge_cache",
['tags' => $tags]
);
}
}

Сжатие ответов

// Конфигурация Nginx
gzip on;
gzip_types application/json text/plain application/xml;
gzip_min_length 1000;
// Middleware Laravel для сжатия JSON
class CompressResponse
{
public function handle($request, Closure $next)
{
$response = $next($request);
if ($request->header('Accept-Encoding') &&
str_contains($request->header('Accept-Encoding'), 'gzip')) {
$content = gzencode($response->getContent(), 6);
$response->setContent($content);
$response->header('Content-Encoding', 'gzip');
$response->header('Content-Length', strlen($content));
}
return $response;
}
}

Кэширование на стороне клиента

Управление кэшем браузера

// Service Worker для офлайн-кэширования
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/api/')) {
event.respondWith(
caches.open('api-cache').then((cache) => {
return fetch(event.request)
.then((response) => {
cache.put(event.request, response.clone());
return response;
})
.catch(() => cache.match(event.request));
})
);
}
});

Кэширование React Query

import { useQuery, QueryClient } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 минут
cacheTime: 30 * 60 * 1000, // 30 минут
},
},
});
function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: () => fetch('/api/products').then(r => r.json()),
staleTime: 60000, // Рассмотреть свежесть в течение 1 минуты
});
}

Мониторинг и метрики

// Отслеживать производительность кэша
class CacheMetrics
{
public function get(string $key)
{
$start = microtime(true);
$value = Cache::get($key);
$duration = microtime(true) - $start;
if ($value !== null) {
Metrics::increment('cache.hits', ['key' => $key]);
} else {
Metrics::increment('cache.misses', ['key' => $key]);
}
Metrics::timing('cache.latency', $duration);
return $value;
}
}

Проблема «thundering herd»

Сценарий: популярный ключ (например, homepage_news) истекает в 12:00:00. Результат: 10 000 пользователей запрашивают его в 12:00:01. Все 10 000 запросов одновременно попадают в базу данных. База данных падает.

Решение 1: вероятностное раннее истечение (jitter)

Вместо жёсткого истечения храните значение delta. Если (now + delta) > expiration, пересчитайте значение до того, как оно фактически истечёт:

public function getCachedValue(string $key, int $ttl, callable $compute)
{
$cached = Cache::get($key);
if ($cached) {
// Проверить, нужно ли проактивно обновлять
$expiresAt = Cache::get("{$key}:expires_at");
$delta = rand(0, 60); // Случайный jitter
if ((time() + $delta) > $expiresAt) {
// Проактивное обновление в фоне
dispatch(new RefreshCacheJob($key, $compute));
}
return $cached;
}
$value = $compute();
Cache::put($key, $value, $ttl);
Cache::put("{$key}:expires_at", time() + $ttl, $ttl + 60);
return $value;
}

Решение 2: блокировки кэша

При промахе кэша: 1. Получите Redis Lock (SETNX lock:key 1) 2. Если заблокировано другим процессом, подождите 50 мс и повторите запрос к Redis 3. Если блокировка получена, вычислите значение, обновите Redis, снимите блокировку

public function getCachedValueWithLock(string $key, callable $compute)
{
$value = Cache::get($key);
if ($value !== null) {
return $value;
}
// Попытаться получить блокировку
$lock = Cache::lock("lock:{$key}", 10);
if ($lock->get()) {
try {
// Повторно проверить после получения блокировки
$value = Cache::get($key);
if ($value === null) {
$value = $compute();
Cache::put($key, $value, 3600);
}
} finally {
$lock->release();
}
} else {
// Подождать, пока другой процесс заполнит кэш
usleep(50000); // 50 мс
$value = Cache::get($key);
}
return $value;
}

Политики вытеснения Redis

Когда Redis заполнен, поведение зависит от политики вытеснения:

  • noeviction: возвращает ошибку при записи. (Плохо для кэшей)
  • allkeys-lru: удаляет ключи, которые использовались наименее недавно. (Стандарт для кэширования)
  • volatile-lru: удаляет LRU-ключи с установленным expire. (Хорошо, если вы используете Redis и для постоянных данных, и для кэша)

Установите подходящую политику в конфигурации Redis:

maxmemory-policy allkeys-lru

Продвинутые структуры Redis

Помимо простого key-value кэширования:

  • Sorted Sets (ZSET): отлично подходят для таблиц лидеров. ZADD scores 100 "user1"
  • HyperLogLog: подсчёт уникальных посетителей с точностью 99%, используя всего 12 KB RAM (вместо мегабайт для Set)

Лучшие практики

  1. Кэшируйте на правильном уровне — ближе к клиенту быстрее
  2. Используйте подходящие TTL — балансируйте свежесть и производительность
  3. Реализуйте инвалидацию кэша — на основе событий наиболее надёжна
  4. Отслеживайте hit rate кэша — стремитесь к >90%
  5. Избегайте кэширования пользовательских данных на общих уровнях
  6. Используйте теги кэша для групповой инвалидации
  7. Реализуйте fallback-механизмы на случай сбоев кэша
  8. Рассмотрите прогрев кэша для критически важных данных
  9. Добавляйте TTL jitter для предотвращения синхронных истечений
  10. Считайте Redis volatile — приложение должно работать, даже если Redis очищен

Заключение

Эффективное кэширование существенно повышает производительность API. Выстраивайте кэширование по уровням — HTTP-заголовки для CDN и браузера, Redis для данных приложения и кэш запросов для результатов базы данных. Реализуйте корректные стратегии инвалидации, обрабатывайте «thundering herd» с помощью блокировок или jitter и отслеживайте hit rate, чтобы убедиться, что кэширование эффективно. Помните: Redis — это слой оптимизации, а не источник истины.

 
 
 
Языки
Темы
Copyright © 1999 — 2026
Зетка Интерактив