Введение
Кэширование — один из наиболее эффективных способов повысить производительность 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)
Лучшие практики
- Кэшируйте на правильном уровне — ближе к клиенту быстрее
- Используйте подходящие TTL — балансируйте свежесть и производительность
- Реализуйте инвалидацию кэша — на основе событий наиболее надёжна
- Отслеживайте hit rate кэша — стремитесь к >90%
- Избегайте кэширования пользовательских данных на общих уровнях
- Используйте теги кэша для групповой инвалидации
- Реализуйте fallback-механизмы на случай сбоев кэша
- Рассмотрите прогрев кэша для критически важных данных
- Добавляйте TTL jitter для предотвращения синхронных истечений
- Считайте Redis volatile — приложение должно работать, даже если Redis очищен
Заключение
Эффективное кэширование существенно повышает производительность API. Выстраивайте кэширование по уровням — HTTP-заголовки для CDN и браузера, Redis для данных приложения и кэш запросов для результатов базы данных. Реализуйте корректные стратегии инвалидации, обрабатывайте «thundering herd» с помощью блокировок или jitter и отслеживайте hit rate, чтобы убедиться, что кэширование эффективно. Помните: Redis — это слой оптимизации, а не источник истины.