Przejdź do treści

Jak przyspieszyliśmy bloga: kolejki, cache i indeksy

Zadania w tle, liczenie wyświetleń, naprawa zapytań N+1, cachowanie na kilku poziomach, poprawki we frontendzie i indeksy w bazie: co zrobiliśmy, żeby blog działał szybko przy rosnącym ruchu.

AutorDział IT
  • 5 min czytania
  • 210 wyświetleń
Jak przyspieszyliśmy bloga: kolejki, cache i indeksy

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.

Tagi

Udostępnij ten artykuł

Wróć do bloga

Pliki cookies

Używamy niezbędnych plików cookies i jednego funkcjonalnego, tego tylko za Twoją zgodą. Statystyki odwiedzin liczymy na serwerze, bez cookies. Polityka cookies