Перейти до вмісту

Як ми прискорили блог: черги, кеш та індекси

Фонові завдання, підрахунок переглядів, виправлення N+1 запитів, кешування на кількох рівнях, правки у фронтенді й індекси в базі: що ми зробили, щоб блог працював швидко при зростанні трафіку.

АвторВідділ ІТ
  • 5 хв читання
  • 210 переглядів
Як ми прискорили блог: черги, кеш та індекси

У попередніх статтях я писав про архітектуру та систему керування контентом. Ця присвячена швидкості та підготовці блогу до масштабування. Гарний інтерфейс не допоможе, якщо сторінки завантажуються повільно.

Асинхронна обробка через 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());

Висновки

Однієї великої оптимізації не було. Швидкість дало багато дрібних покращень:

  • Важкі операції виконуються асинхронно

  • Індекси в базі використовуються правильно

  • Кеш працює на кількох рівнях

  • Фронтенд оптимізований

  • Моніторинг працює постійно

Наступна стаття про користувацький досвід: інтерфейс, систему фільтрації та режим попереднього перегляду для авторів.

Продовження буде.

Теги

Поділитися статтею

Яку LLM обрати для бізнесу: GPT, Claude, Gemini чи open-source

Сильні сторони GPT, Claude, Gemini та open-source моделей, підказки, коли обирати кожну, і шість кроків вибору з урахуванням вартості, безпеки даних та інтеграції.

  • 10 хв читання
  • 219 переглядів

AI-агенти в компанії: де вони окупаються, а де вистачить скрипта

Чим AI-агент відрізняється від чат-бота й RPA, у яких процесах він дає вимірювану віддачу і чому більшість пілотів не доходить до продакшну. У кінці тест із п'яти запитань перед стартом проєкту.

  • 9 хв читання
  • 202 перегляди

Чому ШІ посилює хаос у невпорядкованих компаніях

ШІ примножує все, що знаходить у компанії, зокрема безлад у даних і незадокументовані процеси. Розбираємо, чому впровадження провалюються і в якій послідовності діяти, щоб воно окупилося.

  • 9 хв читання
  • 143 перегляди
Повернутися до блогу

Файли cookies

Ми використовуємо необхідні файли cookies та один функціональний, який вмикаємо лише за вашою згодою. Статистику відвідувань рахуємо на сервері, без cookies. Політика cookies