Кэширование в Laravel
Кэширование - критически важный аспект производительности приложения. Laravel предоставляет унифицированный API для работы с различными бэкендами кэширования.
Конфигурация
// config/cache.php
return [
'default' => env('CACHE_STORE', 'database'),
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
],
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'),
];
Основные операции
Получение данных
use Illuminate\Support\Facades\Cache;
// Get value with default
$value = Cache::get('key');
$value = Cache::get('key', 'default');
$value = Cache::get('key', fn () => $this->computeExpensiveValue());
// Check if key exists
if (Cache::has('key')) {
// Key exists and is not null
}
if (Cache::missing('key')) {
// Key does not exist
}
// Get multiple values
$values = Cache::many(['key1', 'key2', 'key3']);
// ['key1' => 'value1', 'key2' => null, 'key3' => 'value3']
// Get and delete
$value = Cache::pull('key');
$value = Cache::pull('key', 'default');
Сохранение данных
// Store with TTL (seconds or DateTime)
Cache::put('key', 'value', 3600); // 3600 seconds
Cache::put('key', 'value', now()->addHours(2)); // 2 hours
Cache::put('key', 'value', now()->addMinutes(30)); // 30 minutes
// Store multiple
Cache::putMany([
'key1' => 'value1',
'key2' => 'value2',
], 3600);
// Store forever (no expiration)
Cache::forever('key', 'value');
// Store if not exists (atomic)
$added = Cache::add('key', 'value', 3600);
// Returns true if stored, false if key already exists
// Increment / Decrement
Cache::increment('counter');
Cache::increment('counter', 5);
Cache::decrement('counter');
Cache::decrement('counter', 3);
Удаление данных
// Remove specific key
Cache::forget('key');
// Clear entire cache
Cache::flush();
Cache::remember и Cache::rememberForever
Самые часто используемые методы. Получают значение из кэша или вычисляют и сохраняют:
// Remember: get from cache or compute and store
$users = Cache::remember('users:active', 3600, function () {
return User::where('is_active', true)
->with('roles')
->orderBy('name')
->get();
});
// Remember forever
$settings = Cache::rememberForever('app:settings', function () {
return Setting::all()->pluck('value', 'key')->toArray();
});
// With dynamic key
$userPosts = Cache::remember(
"users:{$userId}:posts:page:{$page}",
now()->addMinutes(15),
fn () => Post::where('user_id', $userId)
->latest()
->paginate(20, ['*'], 'page', $page)
);
Практический пример: сервис с кэшированием
declare(strict_types=1);
namespace App\Services;
use App\Models\Category;
use App\Models\Product;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Cache;
final class CatalogService
{
private const CACHE_TTL = 3600; // 1 hour
/**
* Get category tree with caching.
*
* @return Collection<int, Category>
*/
public function getCategoryTree(): Collection
{
return Cache::remember('catalog:categories:tree', self::CACHE_TTL, function () {
return Category::with('children')
->whereNull('parent_id')
->orderBy('sort_order')
->get();
});
}
/**
* Get products for category with caching.
*
* @return Collection<int, Product>
*/
public function getProductsByCategory(int $categoryId, string $sortBy = 'name'): Collection
{
$cacheKey = "catalog:category:{$categoryId}:products:sort:{$sortBy}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($categoryId, $sortBy) {
return Product::where('category_id', $categoryId)
->where('is_published', true)
->orderBy($sortBy)
->get();
});
}
/**
* Clear catalog cache when data changes.
*/
public function clearCatalogCache(?int $categoryId = null): void
{
if ($categoryId) {
// Clear specific category cache
Cache::forget("catalog:category:{$categoryId}:products:sort:name");
Cache::forget("catalog:category:{$categoryId}:products:sort:price");
Cache::forget("catalog:category:{$categoryId}:products:sort:created_at");
}
// Always clear category tree
Cache::forget('catalog:categories:tree');
}
}
Cache Tags
Cache tags позволяют группировать связанные записи и удалять их разом. Поддерживаются только redis и memcached драйверами.
// Store with tags
Cache::tags(['users', 'profiles'])->put("user:{$id}:profile", $profile, 3600);
Cache::tags(['users', 'posts'])->put("user:{$id}:posts", $posts, 3600);
Cache::tags(['posts'])->put("post:{$postId}", $post, 3600);
// Retrieve tagged cache
$profile = Cache::tags(['users', 'profiles'])->get("user:{$id}:profile");
// Remember with tags
$stats = Cache::tags(['users', 'stats'])->remember(
"user:{$id}:stats",
now()->addMinutes(30),
fn () => $this->calculateUserStats($id)
);
// Flush ALL items with specific tag
Cache::tags(['users'])->flush();
// Removes: user:1:profile, user:1:posts, user:2:profile, etc.
Cache::tags(['posts'])->flush();
// Removes: user:1:posts, post:1, post:2, etc.
// But user:1:profile stays (it's not tagged with 'posts')
::alert{type="danger"}
Критически важно для экзамена: Cache tags НЕ поддерживаются драйверами file, database и dynamodb. Использование tags с этими драйверами приведёт к исключению. Tags работают только с redis и memcached.
::
Практический пример с тегами
declare(strict_types=1);
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
final class UserCacheService
{
public function getUserProfile(int $userId): array
{
return Cache::tags(['users', "user:{$userId}"])->remember(
"user:{$userId}:profile",
3600,
fn () => User::with(['roles', 'permissions'])->findOrFail($userId)->toArray()
);
}
public function getUserOrders(int $userId): array
{
return Cache::tags(['users', "user:{$userId}", 'orders'])->remember(
"user:{$userId}:orders",
1800,
fn () => Order::where('user_id', $userId)->latest()->limit(50)->get()->toArray()
);
}
/**
* Clear all cache for specific user.
*/
public function clearUserCache(int $userId): void
{
Cache::tags(["user:{$userId}"])->flush();
}
/**
* Clear all user-related cache.
*/
public function clearAllUsersCache(): void
{
Cache::tags(['users'])->flush();
}
/**
* Clear all order-related cache.
*/
public function clearOrdersCache(): void
{
Cache::tags(['orders'])->flush();
}
}
Атомарные блокировки (Atomic Locks)
Атомарные блокировки предотвращают race conditions при конкурентном доступе:
use Illuminate\Support\Facades\Cache;
// Acquire lock
$lock = Cache::lock('processing-order-' . $orderId, seconds: 10);
if ($lock->get()) {
try {
// Critical section - only one process at a time
$this->processOrder($orderId);
} finally {
// ALWAYS release in finally block
$lock->release();
}
}
Block - ожидание блокировки
$lock = Cache::lock('report-generation', seconds: 120);
try {
// Wait up to 10 seconds to acquire lock
$lock->block(10);
// Lock acquired - generate report
$this->generateReport();
} catch (LockTimeoutException $e) {
$this->error('Could not acquire lock - another report is being generated.');
} finally {
$lock?->release();
}
Блокировка с callback
// Simplified syntax with callback
Cache::lock('order-processing', 10)->block(5, function () use ($order) {
// Lock acquired, process order
$this->processOrder($order);
// Lock automatically released after callback
});
Владение блокировкой
// Get lock owner token
$lock = Cache::lock('deployment', 600);
$lock->get();
$ownerToken = $lock->owner();
// Store token (e.g., in job payload)
// Later, release lock using token from another process
Cache::restoreLock('deployment', $ownerToken)->release();
Практический пример: предотвращение дублирования платежей
declare(strict_types=1);
namespace App\Services;
use App\Exceptions\PaymentAlreadyProcessingException;
use App\Models\Order;
use Illuminate\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
final class PaymentService
{
public function processPayment(Order $order): PaymentResult
{
$lockKey = "payment:order:{$order->id}";
$lock = Cache::lock($lockKey, seconds: 60);
try {
// Try to acquire lock, wait up to 5 seconds
$lock->block(5);
} catch (LockTimeoutException) {
throw new PaymentAlreadyProcessingException(
"Payment for order {$order->id} is already being processed."
);
}
try {
// Double-check order status after acquiring lock
$order->refresh();
if ($order->isPaid()) {
return PaymentResult::alreadyPaid();
}
// Process payment
$result = $this->gateway->charge($order);
// Update order
$order->update([
'status' => 'paid',
'paid_at' => now(),
'payment_id' => $result->transactionId,
]);
return $result;
} finally {
$lock->release();
}
}
}
Работа с конкретным кэш-хранилищем
// Use specific store
$value = Cache::store('redis')->get('key');
Cache::store('file')->put('key', 'value', 3600);
// Use different store for different data
$hotData = Cache::store('redis')->remember('hot:data', 60, fn () => /* ... */);
$coldData = Cache::store('file')->remember('cold:data', 86400, fn () => /* ... */);
Очистка кэша
# Clear all cache
php artisan cache:clear
# Clear specific store
php artisan cache:clear --store=redis
# Clear config cache
php artisan config:clear
# Clear route cache
php artisan route:clear
# Clear view cache
php artisan view:clear
# Clear compiled classes
php artisan clear-compiled
# Clear everything
php artisan optimize:clear
Кэширование конфигурации и маршрутов
# Cache configuration (MUST for production)
php artisan config:cache
# Cache routes (MUST for production)
php artisan route:cache
# Cache views (recommended for production)
php artisan view:cache
# Cache events (auto-discovery cache)
php artisan event:cache
# All-in-one optimization
php artisan optimize
::alert{type="warning"}
Для экзамена: php artisan config:cache объединяет все конфигурационные файлы в один кэшированный файл. После кэширования env() функция возвращает null при вызове вне конфигурационных файлов. Все вызовы env() должны быть ТОЛЬКО в файлах config/*.php.
::
Cache Events
use Illuminate\Cache\Events\CacheHit;
use Illuminate\Cache\Events\CacheMissed;
use Illuminate\Cache\Events\KeyWritten;
use Illuminate\Cache\Events\KeyForgotten;
// Listen to cache events
Event::listen(CacheHit::class, function (CacheHit $event) {
Log::debug('Cache hit', ['key' => $event->key]);
});
Event::listen(CacheMissed::class, function (CacheMissed $event) {
Log::debug('Cache miss', ['key' => $event->key]);
});
Тестирование кэша
declare(strict_types=1);
namespace Tests\Feature;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
final class CatalogServiceTest extends TestCase
{
public function test_categories_are_cached(): void
{
Category::factory()->count(5)->create();
// First call - cache miss
$service = app(CatalogService::class);
$categories = $service->getCategoryTree();
$this->assertCount(5, $categories);
// Verify data is in cache
$this->assertTrue(Cache::has('catalog:categories:tree'));
// Delete from DB - cached data should still be returned
Category::query()->delete();
$cached = $service->getCategoryTree();
$this->assertCount(5, $cached);
}
public function test_cache_is_cleared_on_update(): void
{
Cache::put('catalog:categories:tree', 'old_data', 3600);
$service = app(CatalogService::class);
$service->clearCatalogCache();
$this->assertFalse(Cache::has('catalog:categories:tree'));
}
public function test_atomic_lock_prevents_duplicate(): void
{
$lock = Cache::lock('test-lock', 10);
$lock->get();
// Second attempt should fail
$secondLock = Cache::lock('test-lock', 10);
$this->assertFalse($secondLock->get());
$lock->release();
}
}