MidПрактика22 min

Performance Engineering

Профилирование, анализ узких мест, оптимизация latency. Xdebug, Blackfire, OPcache в PHP

Основы Performance Engineering

Performance engineering — системный подход к обеспечению производительности на всех этапах жизненного цикла системы. Не просто «оптимизация», а процесс проектирования, тестирования и мониторинга производительности.

Ключевые метрики производительности

Метрика Описание Типичные цели
Latency (p50) Медианное время ответа < 100ms
Latency (p95) 95-й перцентиль < 300ms
Latency (p99) 99-й перцентиль < 1000ms
Throughput Запросов в секунду (RPS) Зависит от нагрузки
TTFB Time To First Byte < 200ms
Error Rate Процент ошибок < 0.1%
Saturation Загрузка ресурсов < 70%

Важно: Всегда измеряйте перцентили, а не среднее. Среднее время ответа 50ms может скрывать p99 = 5s, что означает, что 1% пользователей ждёт 5 секунд.

Профилирование PHP

Типы профилирования

Тип Инструмент Когда использовать
CPU profiling Xdebug, Blackfire Медленные вычисления
Memory profiling Xdebug, php-meminfo Утечки памяти
I/O profiling Blackfire, strace Медленные запросы к БД/сети
Opcode analysis OPcache, VLD Оптимизация кода на уровне VM

Xdebug Profiling

Xdebug генерирует cachegrind-файлы, которые можно анализировать в KCachegrind/QCachegrind.

<?php

declare(strict_types=1);

// php.ini configuration for profiling
// xdebug.mode=profile
// xdebug.output_dir=/tmp/xdebug
// xdebug.profiler_output_name=cachegrind.out.%R.%t

/**
 * Manual profiling trigger via environment variable.
 * Enable: XDEBUG_TRIGGER=1 php script.php
 */

// Example: profiling a slow operation
namespace App\Service;

final class ReportGenerator
{
    public function __construct(
        private readonly OrderRepository $orders,
        private readonly ReportFormatter $formatter,
    ) {}

    public function generateMonthlyReport(int $year, int $month): Report
    {
        // With Xdebug profiling enabled, this will show
        // time breakdown for each method call

        $orders = $this->orders->findByMonth($year, $month);

        $aggregated = $this->aggregateByCategory($orders);

        return $this->formatter->format($aggregated);
    }

    /**
     * @param array<Order> $orders
     * @return array<string, CategorySummary>
     */
    private function aggregateByCategory(array $orders): array
    {
        $categories = [];

        foreach ($orders as $order) {
            $cat = $order->getCategory();
            $categories[$cat] ??= new CategorySummary($cat);
            $categories[$cat]->addOrder($order);
        }

        return $categories;
    }
}
### Blackfire Profiling

Blackfire обеспечивает production-safe профилирование без оверхеда.

<?php

declare(strict_types=1);

namespace App\Performance;

/**
 * Manual instrumentation for performance measurement.
 * Use when Blackfire/Xdebug is not available.
 */
final class Profiler
{
    /** @var array<string, array{start: float, memory_start: int}> */
    private array $timers = [];

    /** @var array<string, array{duration_ms: float, memory_bytes: int, calls: int}> */
    private array $results = [];

    public function start(string $label): void
    {
        $this->timers[$label] = [
            'start' => hrtime(true),
            'memory_start' => memory_get_usage(true),
        ];
    }

    public function stop(string $label): void
    {
        if (!isset($this->timers[$label])) {
            return;
        }

        $timer = $this->timers[$label];
        $durationNs = hrtime(true) - $timer['start'];
        $memoryDelta = memory_get_usage(true) - $timer['memory_start'];

        $this->results[$label] ??= ['duration_ms' => 0, 'memory_bytes' => 0, 'calls' => 0];
        $this->results[$label]['duration_ms'] += $durationNs / 1_000_000;
        $this->results[$label]['memory_bytes'] += $memoryDelta;
        $this->results[$label]['calls']++;

        unset($this->timers[$label]);
    }

    /**
     * Profile a callable and return its result.
     *
     * @template T
     * @param callable(): T $callback
     * @return T
     */
    public function measure(string $label, callable $callback): mixed
    {
        $this->start($label);

        try {
            return $callback();
        } finally {
            $this->stop($label);
        }
    }

    /**
     * Get profiling results sorted by duration.
     *
     * @return array<string, array{duration_ms: float, memory_mb: float, calls: int, avg_ms: float}>
     */
    public function getResults(): array
    {
        $formatted = [];

        foreach ($this->results as $label => $data) {
            $formatted[$label] = [
                'duration_ms' => round($data['duration_ms'], 3),
                'memory_mb' => round($data['memory_bytes'] / 1048576, 2),
                'calls' => $data['calls'],
                'avg_ms' => round($data['duration_ms'] / $data['calls'], 3),
            ];
        }

        // Sort by duration descending
        uasort($formatted, static fn(array $a, array $b) => $b['duration_ms'] <=> $a['duration_ms']);

        return $formatted;
    }

    public function reset(): void
    {
        $this->timers = [];
        $this->results = [];
    }
}
### Пример использования
<?php

declare(strict_types=1);

$profiler = new Profiler();

// Profile database queries
$users = $profiler->measure('db.users.fetch', function () use ($repository) {
    return $repository->findActiveUsers();
});

// Profile business logic
$report = $profiler->measure('report.generate', function () use ($users, $generator) {
    return $generator->generateReport($users);
});

// Profile serialization
$json = $profiler->measure('response.serialize', function () use ($report) {
    return json_encode($report, JSON_THROW_ON_ERROR);
});

// Output results
foreach ($profiler->getResults() as $label => $data) {
    printf(
        "%s: %.2fms (calls: %d, avg: %.2fms, mem: %.1fMB)\n",
        $label,
        $data['duration_ms'],
        $data['calls'],
        $data['avg_ms'],
        $data['memory_mb'],
    );
}
## OPcache — оптимизация PHP

OPcache компилирует PHP-скрипты в байткод и кешируeт его в shared memory. Это одна из самых эффективных оптимизаций для PHP.

Конфигурация OPcache для production

<?php

// Recommended php.ini settings for production

// opcache.enable=1
// opcache.memory_consumption=256          ; MB for cached scripts
// opcache.interned_strings_buffer=16      ; MB for interned strings
// opcache.max_accelerated_files=20000     ; Max cached scripts
// opcache.validate_timestamps=0           ; Don't check file changes (deploy = restart)
// opcache.revalidate_freq=0              ; Only matters if validate_timestamps=1
// opcache.save_comments=1                 ; Needed for annotations/attributes
// opcache.enable_file_override=1          ; Override file_exists/is_file
// opcache.jit=1255                        ; Enable JIT compilation
// opcache.jit_buffer_size=128M            ; JIT buffer size

/**
 * OPcache status checker for monitoring.
 */
final readonly class OpcacheMonitor
{
    /**
     * @return array{
     *     enabled: bool,
     *     used_memory_mb: float,
     *     free_memory_mb: float,
     *     usage_percent: float,
     *     cached_scripts: int,
     *     hit_rate: float,
     *     jit_enabled: bool,
     * }
     */
    public function getStatus(): array
    {
        $status = opcache_get_status(false);

        if ($status === false) {
            return ['enabled' => false];
        }

        $memory = $status['memory_usage'];
        $stats = $status['opcache_statistics'];
        $totalMemory = $memory['used_memory'] + $memory['free_memory'];

        return [
            'enabled' => true,
            'used_memory_mb' => round($memory['used_memory'] / 1048576, 2),
            'free_memory_mb' => round($memory['free_memory'] / 1048576, 2),
            'usage_percent' => round(($memory['used_memory'] / $totalMemory) * 100, 1),
            'cached_scripts' => $stats['num_cached_scripts'],
            'hit_rate' => round($stats['opcache_hit_rate'], 2),
            'jit_enabled' => isset($status['jit']) && $status['jit']['enabled'],
        ];
    }

    /**
     * Check if OPcache needs attention.
     *
     * @return array<string>
     */
    public function getWarnings(): array
    {
        $status = $this->getStatus();
        $warnings = [];

        if (!$status['enabled']) {
            $warnings[] = 'OPcache is disabled';
            return $warnings;
        }

        if ($status['usage_percent'] > 90) {
            $warnings[] = 'OPcache memory usage above 90%, increase opcache.memory_consumption';
        }

        if ($status['hit_rate'] < 95 && $status['cached_scripts'] > 100) {
            $warnings[] = sprintf('Low hit rate: %.1f%%. Check validate_timestamps setting', $status['hit_rate']);
        }

        return $warnings;
    }
}
## Анализ узких мест

Методология анализа

1. Измерить → Определить baseline
2. Найти bottleneck → Профилирование
3. Оптимизировать → Одно изменение за раз
4. Проверить → Сравнить с baseline
5. Повторить → Пока не достигнута цель

Типичные bottleneck-и в PHP-приложениях

Bottleneck Симптом Решение
N+1 запросы Много SQL-запросов Eager loading, JOIN
Отсутствие кеширования Повторные вычисления Redis/Memcached
Синхронные I/O Высокая latency Очереди, async
Большие payload-ы Медленная сериализация Pagination, sparse fields
Отсутствие индексов Медленные запросы EXPLAIN ANALYZE
Memory leaks Рост потребления памяти Profiling, generators

Оптимизация запросов к БД

<?php

declare(strict_types=1);

namespace App\Repository;

use Doctrine\DBAL\Connection;

final readonly class OptimizedOrderRepository
{
    public function __construct(
        private Connection $connection,
    ) {}

    /**
     * BAD: N+1 problem — one query per order item.
     */
    public function getOrdersWithItemsBad(array $orderIds): array
    {
        $orders = [];

        // Query 1: fetch orders
        foreach ($orderIds as $id) {
            $order = $this->connection->fetchAssociative(
                'SELECT * FROM orders WHERE id = ?',
                [$id],
            );

            // Query N: fetch items for each order (N+1!)
            $order['items'] = $this->connection->fetchAllAssociative(
                'SELECT * FROM order_items WHERE order_id = ?',
                [$id],
            );

            $orders[] = $order;
        }

        return $orders;
    }

    /**
     * GOOD: Two queries total, regardless of N.
     */
    public function getOrdersWithItemsGood(array $orderIds): array
    {
        if (empty($orderIds)) {
            return [];
        }

        $placeholders = implode(',', array_fill(0, count($orderIds), '?'));

        // Query 1: all orders in one shot
        $orders = $this->connection->fetchAllAssociative(
            "SELECT * FROM orders WHERE id IN ({$placeholders})",
            $orderIds,
        );

        // Query 2: all items in one shot
        $items = $this->connection->fetchAllAssociative(
            "SELECT * FROM order_items WHERE order_id IN ({$placeholders})",
            $orderIds,
        );

        // Group items by order_id in PHP
        $itemsByOrder = [];
        foreach ($items as $item) {
            $itemsByOrder[$item['order_id']][] = $item;
        }

        foreach ($orders as &$order) {
            $order['items'] = $itemsByOrder[$order['id']] ?? [];
        }

        return $orders;
    }
}
## Кеширование

Стратегии кеширования

Стратегия Описание Когда
Cache-Aside Приложение управляет кешем Чтение >> записи
Write-Through Запись в кеш + БД одновременно Консистентность важна
Write-Behind Запись в кеш, БД — асинхронно Высокая нагрузка на запись
Read-Through Кеш сам загружает из источника Прозрачное кеширование
<?php

declare(strict_types=1);

namespace App\Cache;

final class CacheAside
{
    public function __construct(
        private readonly \Redis $redis,
        private readonly int $defaultTtl = 3600,
    ) {}

    /**
     * Get from cache or compute and store.
     *
     * @template T
     * @param callable(): T $compute
     * @return T
     */
    public function remember(string $key, callable $compute, ?int $ttl = null): mixed
    {
        $cached = $this->redis->get($key);

        if ($cached !== false) {
            return unserialize($cached);
        }

        $value = $compute();

        $this->redis->setex($key, $ttl ?? $this->defaultTtl, serialize($value));

        return $value;
    }

    /**
     * Invalidate cache entry.
     */
    public function forget(string $key): void
    {
        $this->redis->del($key);
    }

    /**
     * Invalidate by pattern (use with caution).
     */
    public function forgetByPattern(string $pattern): int
    {
        $keys = $this->redis->keys($pattern);

        if (empty($keys)) {
            return 0;
        }

        return $this->redis->del($keys);
    }
}
## Performance Checklist
Область Проверка
PHP OPcache включён, JIT настроен
Database Индексы на FK и WHERE-поля, EXPLAIN ANALYZE
Caching Redis для hot data, HTTP-кеширование
Network Keep-alive, gzip/brotli, HTTP/2
Code Нет N+1, generators для больших коллекций
Session Хранение в Redis, а не в файлах
Autoload Composer classmap optimized в production

Золотое правило: Не оптимизируйте без измерений. Premature optimization is the root of all evil. Сначала профилирование, потом оптимизация самого медленного участка.