W poprzednich artykułach pisałem o architekturze i systemie zarządzania treścią. Ten dotyczy szybkości i przygotowania bloga do skalowania. Ładny interfejs nic nie da, jeśli strony ładują się wolno.
Przetwarzanie asynchroniczne przez Jobs
Jedna z kluczowych decyzji to przeniesienie ciężkich operacji do zadań w tle. Dobrym przykładem jest śledzenie wyświetleń:
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()
{
// Sprawdzamy, czy już nie było wyświetlenia z tego IP w ciągu ostatnich 24 godzin
$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,
]);
}
}
}
W kontrolerze tylko wrzucamy zadanie do kolejki:
public function show(string $language, string $slug)
{
$content = Content::where('slug', $slug)
->where('language', $language)
->firstOrFail();
$post = $content->post()
->with(['author', 'categories', 'tags'])
->first();
// Asynchroniczna rejestracja wyświetlenia
PostViewTracker::dispatch(
request()->ip(),
$post,
$content
);
return view('blog.show', compact('post', 'content'));
}
Liczenie wyświetleń
Zamiast zwykłego zwiększania licznika używamy modelu opartego na zdarzeniach:
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) {
// Aktualizujemy licznik tylko po pomyślnym utworzeniu rekordu
$model->post->increment('initial_view_count');
// Unieważniamy cache
Cache::tags(['posts', "post-{$model->post_id}"])->flush();
});
}
}
Harmonogram publikacji
Zaplanowane wpisy publikują się automatycznie dzięki komendom Laravela:
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() // Zapobiegamy 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,
]);
// Wysyłamy powiadomienia do subskrybentów
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;
}
}
Rejestracja w harmonogramie:
// app/Providers/AppServiceProvider.php
$schedule->command(PublishScheduledPosts::class)
->everyMinute()
->withoutOverlapping()
->runInBackground();
Optymalizacja zapytań do bazy danych
Zapytania N+1 były jednym z naszych głównych problemów z wydajnością. Rozwiązaliśmy je przez eager loading i zoptymalizowane scopes:
// Źle - problem N+1
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name; // Dodatkowe zapytanie dla każdego postu
}
// Dobrze - eager loading
$posts = Post::with([
'author',
'categories',
'tags',
'contents' => function ($query) {
$query->where('language', app()->getLocale());
}
])->get();
Scopes dla bardziej złożonych zapytań:
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);
}]);
}
// Użycie
$posts = Post::published()
->withLocalContent()
->orderBy('published_at', 'desc')
->paginate(10);
Cachowanie na wielu poziomach
Cache działa na trzech poziomach:
1. Cachowanie zapytań
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. Cachowanie na poziomie HTTP
// Middleware do cachowania odpowiedzi HTTP
class CacheResponse
{
public function handle($request, Closure $next)
{
// Cachujemy tylko zapytania GET dla gości
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. Cachowanie kanału 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();
});
}
Optymalizacja frontendu
1. Leniwe ładowanie komponentów
// Zamiast bezpośredniego importu
import PostEditor from '@/components/PostEditor.vue';
// Używamy dynamicznego importu
const PostEditor = () => import('@/components/PostEditor.vue');
2. Wirtualizacja długich list
<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';
// Komponent renderuje tylko widoczne elementy
// oszczędzając pamięć i przyspieszając renderowanie
</script>
3. Debounce dla wyszukiwania
const debouncedSearch = debounce(() => {
performSearch();
}, 500);
watch(() => form.search, (newSearch) => {
if (newSearch.length >= 3 || newSearch.length === 0) {
debouncedSearch();
}
});
Monitoring wydajności
Obserwujemy kilka kluczowych metryk:
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(),
]);
// Alert przy wolnym ładowaniu
if ($loadTime > 2.0) {
Log::warning("Slow page load detected", [
'page' => $page,
'load_time' => $loadTime,
]);
}
}
}
Optymalizacja bazy danych
Dobre indeksy mają ogromne znaczenie dla wydajności:
Schema::table('admin_blog_posts', function (Blueprint $table) {
// Złożony indeks dla częstych zapytań
$table->index(['published_at', 'archived_at']);
// Indeks do sortowania
$table->index('created_at');
});
Schema::table('admin_blog_posts_contents', function (Blueprint $table) {
// Unikalny złożony indeks
$table->unique(['post_id', 'language']);
// Indeks do wyszukiwania po slug
$table->index('slug');
});
Wyniki
Po wszystkich zmianach zmierzyliśmy:
Czas ładowania strony głównej: poniżej 200 ms
Czas ładowania wpisu: poniżej 150 ms
Przepustowość: ponad 10 000 zapytań na sekundę
Zużycie pamięci: o 60% mniejsze dzięki cachowaniu
Narzędzia do analizy
Do monitoringu używamy kilku narzędzi:
# Laravel Debugbar do rozwoju
composer require barryvdh/laravel-debugbar --dev
# Telescope do monitoringu produkcyjnego
composer require laravel/telescope
# Analiza zapytań
DB::enableQueryLog();
// ... kod ...
dd(DB::getQueryLog());
Wnioski
Nie było jednej wielkiej optymalizacji. Szybkość dało wiele drobnych usprawnień:
Ciężkie operacje działają asynchronicznie
Indeksy w bazie są używane poprawnie
Cache działa na kilku poziomach
Frontend jest zoptymalizowany
Monitoring działa cały czas
Następny artykuł dotyczy doświadczenia użytkownika: interfejsu, systemu filtrowania i trybu podglądu dla autorów.
Ciąg dalszy nastąpi.



