У попередніх статтях я писав про архітектуру та систему керування контентом. Ця присвячена швидкості та підготовці блогу до масштабування. Гарний інтерфейс не допоможе, якщо сторінки завантажуються повільно.
Асинхронна обробка через Jobs
Одне з ключових рішень: винести важкі операції у фонові завдання. Добрий приклад, відстеження переглядів:
class PostViewTracker implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected string $ipAddress;
protected Post $post;
protected Content $content;
public function __construct(string $ipAddress, Post $post, Content $content)
{
$this->ipAddress = $ipAddress;
$this->post = $post;
$this->content = $content;
}
public function handle()
{
// Перевіряємо, чи не було вже перегляду з цього IP за останні 24 години
$recentView = View::where('post_id', $this->post->id)
->where('ip_address', $this->ipAddress)
->where('created_at', '>', now()->subDay())
->exists();
if (!$recentView) {
View::create([
'post_id' => $this->post->id,
'content_id' => $this->content->id,
'ip_address' => $this->ipAddress,
]);
}
}
}
У контролері ми лише ставимо завдання в чергу:
public function show(string $language, string $slug)
{
$content = Content::where('slug', $slug)
->where('language', $language)
->firstOrFail();
$post = $content->post()
->with(['author', 'categories', 'tags'])
->first();
// Асинхронна реєстрація перегляду
PostViewTracker::dispatch(
request()->ip(),
$post,
$content
);
return view('blog.show', compact('post', 'content'));
}
Підрахунок переглядів
Замість простого збільшення лічильника ми використовуємо модель на подіях:
class View extends Model
{
protected $table = 'admin_blog_posts_views';
protected $fillable = [
'uuid',
'post_id',
'content_id',
'ip_address',
];
public static function boot()
{
parent::boot();
static::created(function (View $model) {
// Оновлюємо лічильник тільки після успішного створення запису
$model->post->increment('initial_view_count');
// Інвалідуємо кеш
Cache::tags(['posts', "post-{$model->post_id}"])->flush();
});
}
}
Планувальник публікацій
Заплановані пости публікуються автоматично за допомогою команд Laravel:
class PublishScheduledPosts extends Command
{
protected $signature = 'blog:publish-scheduled {--dry-run}';
public function handle(): int
{
$isDryRun = $this->option('dry-run');
$now = now();
$scheduledPosts = Post::whereNotNull('scheduled_at')
->whereNull('published_at')
->where('scheduled_at', '<=', $now)
->lockForUpdate() // Запобігаємо race conditions
->get();
if ($scheduledPosts->isEmpty()) {
$this->info('No posts scheduled for publishing.');
return self::SUCCESS;
}
$this->info("Found {$scheduledPosts->count()} post(s) ready for publishing:");
foreach ($scheduledPosts as $post) {
try {
if (!$isDryRun) {
$post->update([
'published_at' => $now,
'scheduled_at' => null,
]);
// Відправляємо сповіщення підписникам
PublishPostNotification::dispatch($post);
Log::info("Published scheduled post", [
'post_id' => $post->id,
'scheduled_at' => $post->scheduled_at,
]);
}
$this->info("✓ Published post ID: {$post->id}");
} catch (Exception $e) {
$this->error("✗ Failed to publish post {$post->id}: {$e->getMessage()}");
Log::error("Failed to publish scheduled post", [
'post_id' => $post->id,
'error' => $e->getMessage(),
]);
}
}
return self::SUCCESS;
}
}
Реєстрація в планувальнику:
// app/Providers/AppServiceProvider.php
$schedule->command(PublishScheduledPosts::class)
->everyMinute()
->withoutOverlapping()
->runInBackground();
Оптимізація запитів до бази даних
N+1 запити були однією з наших головних проблем із продуктивністю. Ми розв'язали її через eager loading та оптимізовані scopes:
// Погано - N+1 проблема
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name; // Додатковий запит для кожного поста
}
// Добре - eager loading
$posts = Post::with([
'author',
'categories',
'tags',
'contents' => function ($query) {
$query->where('language', app()->getLocale());
}
])->get();
Scopes для складніших запитів:
public function scopePublished(Builder $query): Builder
{
return $query->whereNotNull('published_at')
->where('published_at', '<=', now())
->whereNull('archived_at');
}
public function scopeWithLocalContent(Builder $query, string $language = null): Builder
{
$language = $language ?? app()->getLocale();
return $query->with(['contents' => function ($q) use ($language) {
$q->where('language', $language);
}]);
}
// Використання
$posts = Post::published()
->withLocalContent()
->orderBy('published_at', 'desc')
->paginate(10);
Кешування на кількох рівнях
Кеш працює на трьох рівнях:
1. Кешування запитів
class PostRepository
{
public function getPublishedPosts(int $page = 1, int $perPage = 10)
{
$cacheKey = "posts.published.{$page}.{$perPage}." . app()->getLocale();
return Cache::tags(['posts', 'published'])
->remember($cacheKey, now()->addMinutes(10), function () use ($page, $perPage) {
return Post::published()
->withLocalContent()
->with(['author', 'categories', 'tags'])
->orderBy('published_at', 'desc')
->paginate($perPage, ['*'], 'page', $page);
});
}
public function clearCache(): void
{
Cache::tags(['posts'])->flush();
}
}
2. Кешування на рівні HTTP
// Middleware для кешування HTTP відповідей
class CacheResponse
{
public function handle($request, Closure $next)
{
// Кешуємо тільки GET запити для гостей
if ($request->isMethod('GET') && !auth()->check()) {
$key = 'response.' . md5($request->fullUrl());
return Cache::remember($key, now()->addMinutes(5), function () use ($request, $next) {
return $next($request);
});
}
return $next($request);
}
}
3. Кешування RSS-стрічки
public static function getFeedItems()
{
return Cache::remember('feed_items', now()->addMinutes(10), function () {
return self::published()
->where(function(Builder $query) {
$query->whereNull('is_generated')
->orWhere('is_generated', false);
})
->with(['author'])
->latest('published_at')
->take(10)
->get();
});
}
Оптимізація фронтенду
1. Ліниве завантаження компонентів
// Замість прямого імпорту
import PostEditor from '@/components/PostEditor.vue';
// Використовуємо динамічний імпорт
const PostEditor = () => import('@/components/PostEditor.vue');
2. Віртуалізація довгих списків
<template>
<VirtualList
:items="posts"
:item-height="120"
:buffer="5"
v-slot="{ item }"
>
<PostCard :post="item" />
</VirtualList>
</template>
<script setup>
import { VirtualList } from '@tanstack/vue-virtual';
// Компонент рендерить тільки видимі елементи
// економлячи пам'ять та прискорюючи відтворення
</script>
3. Debounce для пошуку
const debouncedSearch = debounce(() => {
performSearch();
}, 500);
watch(() => form.search, (newSearch) => {
if (newSearch.length >= 3 || newSearch.length === 0) {
debouncedSearch();
}
});
Моніторинг продуктивності
Ми стежимо за кількома ключовими метриками:
class PerformanceMonitor
{
public static function trackPageLoad(string $page, float $loadTime)
{
DB::table('performance_metrics')->insert([
'page' => $page,
'load_time' => $loadTime,
'memory_usage' => memory_get_peak_usage(true),
'created_at' => now(),
]);
// Алерт при повільному завантаженні
if ($loadTime > 2.0) {
Log::warning("Slow page load detected", [
'page' => $page,
'load_time' => $loadTime,
]);
}
}
}
Оптимізація бази даних
Правильні індекси дуже важливі для продуктивності:
Schema::table('admin_blog_posts', function (Blueprint $table) {
// Складений індекс для частих запитів
$table->index(['published_at', 'archived_at']);
// Індекс для сортування
$table->index('created_at');
});
Schema::table('admin_blog_posts_contents', function (Blueprint $table) {
// Унікальний складений індекс
$table->unique(['post_id', 'language']);
// Індекс для пошуку по slug
$table->index('slug');
});
Результати
Після всіх змін ми виміряли:
Час завантаження головної сторінки: менш як 200 мс
Час завантаження посту: менш як 150 мс
Пропускна здатність: понад 10 000 запитів на секунду
Використання пам'яті: на 60% менше завдяки кешуванню
Інструменти для аналізу
Для моніторингу ми використовуємо кілька інструментів:
# Laravel Debugbar для розробки
composer require barryvdh/laravel-debugbar --dev
# Telescope для production моніторингу
composer require laravel/telescope
# Query аналіз
DB::enableQueryLog();
// ... код ...
dd(DB::getQueryLog());
Висновки
Однієї великої оптимізації не було. Швидкість дало багато дрібних покращень:
Важкі операції виконуються асинхронно
Індекси в базі використовуються правильно
Кеш працює на кількох рівнях
Фронтенд оптимізований
Моніторинг працює постійно
Наступна стаття про користувацький досвід: інтерфейс, систему фільтрації та режим попереднього перегляду для авторів.
Продовження буде.



