Профилирование и оптимизация PHP
Правило оптимизации
"Premature optimization is the root of all evil" — Donald Knuth
Никогда не оптимизируйте без профилирования. Сначала измерьте, потом оптимизируйте.
Правильный порядок:
1. Написать работающий код
2. Написать тесты
3. Профилировать (найти bottleneck)
4. Оптимизировать ТОЛЬКО bottleneck
5. Измерить снова (подтвердить улучшение)
6. Повторить при необходимости
Ручное измерение
microtime и hrtime
<?php
declare(strict_types=1);
// microtime(true) — float seconds (microsecond precision)
$start = microtime(true);
// ... code to measure ...
$elapsed = microtime(true) - $start;
echo "Time: " . number_format($elapsed * 1000, 2) . " ms\n";
// hrtime(true) — nanoseconds (most precise, monotonic clock)
$start = hrtime(true);
// ... code to measure ...
$elapsed = hrtime(true) - $start;
echo "Time: " . number_format($elapsed / 1_000_000, 2) . " ms\n";
// hrtime() without true — [seconds, nanoseconds]
$start = hrtime();
// ... code ...
$end = hrtime();
$nanos = ($end[0] - $start[0]) * 1_000_000_000 + ($end[1] - $start[1]);
Почему hrtime лучше microtime:
hrtime()использует монотонные часы, которые не зависят от изменений системного времени (NTP sync, DST). Для бенчмарков всегда предпочитайтеhrtime(true).
Профилирование памяти
<?php
declare(strict_types=1);
// Memory profiling
function profileMemory(string $label, Closure $callback): void
{
gc_collect_cycles();
$memBefore = memory_get_usage();
$peakBefore = memory_get_peak_usage();
$result = $callback();
gc_collect_cycles();
$memAfter = memory_get_usage();
$peakAfter = memory_get_peak_usage();
printf(
"[%s] Memory: %+.2f KB, Peak: %+.2f KB\n",
$label,
($memAfter - $memBefore) / 1024,
($peakAfter - $peakBefore) / 1024,
);
return $result;
}
// Usage
profileMemory('Array creation', function (): void {
$data = range(1, 100_000);
// Memory: ~3200 KB
});
profileMemory('Generator', function (): void {
$gen = (function () {
for ($i = 1; $i <= 100_000; $i++) {
yield $i;
}
})();
foreach ($gen as $val) {
// Process
}
// Memory: ~0.5 KB
});
Простой profiler
<?php
declare(strict_types=1);
/**
* Simple inline profiler for development.
*/
final class Profiler
{
/** @var array<string, array{start: int, memory: int}> */
private static array $timers = [];
/** @var array<string, array{time: float, memory: int, calls: int}> */
private static array $results = [];
public static function start(string $label): void
{
self::$timers[$label] = [
'start' => hrtime(true),
'memory' => memory_get_usage(),
];
}
public static function stop(string $label): void
{
if (!isset(self::$timers[$label])) {
return;
}
$elapsed = hrtime(true) - self::$timers[$label]['start'];
$memoryDelta = memory_get_usage() - self::$timers[$label]['memory'];
if (!isset(self::$results[$label])) {
self::$results[$label] = ['time' => 0, 'memory' => 0, 'calls' => 0];
}
self::$results[$label]['time'] += $elapsed / 1_000_000; // ms
self::$results[$label]['memory'] += $memoryDelta;
self::$results[$label]['calls']++;
unset(self::$timers[$label]);
}
/**
* Profile a closure and return its result.
*
* @template T
* @param string $label
* @param Closure(): T $callback
* @return T
*/
public static function measure(string $label, Closure $callback): mixed
{
self::start($label);
try {
return $callback();
} finally {
self::stop($label);
}
}
public static function report(): void
{
echo "\n=== Profiler Report ===\n";
echo str_pad('Label', 30) . str_pad('Time (ms)', 15)
. str_pad('Memory', 15) . "Calls\n";
echo str_repeat('-', 75) . "\n";
// Sort by time descending
uasort(self::$results, fn ($a, $b) => $b['time'] <=> $a['time']);
foreach (self::$results as $label => $data) {
printf(
"%s%s%s%d\n",
str_pad($label, 30),
str_pad(number_format($data['time'], 2), 15),
str_pad(self::formatBytes($data['memory']), 15),
$data['calls'],
);
}
}
private static function formatBytes(int $bytes): string
{
$sign = $bytes < 0 ? '-' : '+';
$bytes = abs($bytes);
if ($bytes >= 1024 * 1024) {
return $sign . number_format($bytes / 1024 / 1024, 2) . ' MB';
}
if ($bytes >= 1024) {
return $sign . number_format($bytes / 1024, 2) . ' KB';
}
return $sign . $bytes . ' B';
}
}
// Usage
Profiler::start('total');
$data = Profiler::measure('generate data', function (): array {
return range(1, 50_000);
});
Profiler::start('sort');
sort($data);
Profiler::stop('sort');
Profiler::measure('serialize', function () use ($data): string {
return json_encode($data);
});
Profiler::stop('total');
Profiler::report();
Xdebug Profiler
Xdebug может создавать файлы профилирования в формате Cachegrind, которые анализируются инструментами визуализации.
; php.ini — Xdebug profiling configuration
xdebug.mode=profile
xdebug.output_dir=/tmp/xdebug-profiles
xdebug.profiler_output_name=cachegrind.out.%p.%t
; Trigger profiling only with parameter (not every request)
xdebug.start_with_request=trigger
; Then: http://example.com/?XDEBUG_PROFILE=1
; Or header: Cookie: XDEBUG_PROFILE=1
# Analyze cachegrind files:
# KCachegrind (Linux/macOS)
kcachegrind /tmp/xdebug-profiles/cachegrind.out.12345
# QCachegrind (macOS via Homebrew)
brew install qcachegrind
qcachegrind /tmp/xdebug-profiles/cachegrind.out.12345
# webgrind (web-based, PHP)
# https://github.com/jokkedk/webgrind
Что даёт Xdebug profiler
Cachegrind output содержит:
├── Дерево вызовов (call tree)
├── Время каждой функции (self time / inclusive time)
├── Количество вызовов каждой функции
├── Процент общего времени
└── Вызывающая → вызываемая связь
Что искать:
1. Функции с наибольшим inclusive time (bottleneck)
2. Функции вызванные слишком много раз
3. Неожиданно медленные операции
4. N+1 проблемы (много одинаковых SQL-запросов)
Blackfire.io
Blackfire — коммерческий профилировщик с минимальным overhead. В отличие от Xdebug, может использоваться в production.
# Install Blackfire
# 1. Install probe (PHP extension)
# 2. Install agent (daemon)
# 3. Install CLI client
# Profile from CLI
blackfire run php script.php
# Profile HTTP request
blackfire curl http://localhost/api/users
# Continuous profiling in production
# Configure in blackfire.yaml
<?php
declare(strict_types=1);
// Blackfire SDK — manual instrumentation
use Blackfire\Client;
use Blackfire\Profile\Configuration;
$blackfire = new Client();
$config = new Configuration();
$config->setTitle('User API Profile');
$config->assert('metrics.sql.queries.count < 10', 'Too many queries');
$config->assert('main.wall_time < 200ms', 'Too slow');
$probe = $blackfire->createProbe($config);
// ... code to profile ...
$profile = $blackfire->endProbe($probe);
echo "Profile URL: {$profile->getUrl()}\n";
Blackfire assertions (CI/CD)
# .blackfire.yaml — performance tests
tests:
"Homepage should be fast":
path: /
assertions:
- "metrics.http.requests.count < 5"
- "main.wall_time < 100ms"
- "main.peak_memory < 20mb"
- "metrics.sql.queries.count < 3"
"API Users Endpoint":
path: /api/v1/users
assertions:
- "main.wall_time < 50ms"
- "metrics.sql.queries.count == 1"
SPX (Simple Profiling Extension)
SPX — легковесный профилировщик с встроенным веб-интерфейсом. Бесплатный и с минимальным overhead.
# Install SPX
git clone https://github.com/NoiseByNorthworst/php-spx.git
cd php-spx
phpize && ./configure && make && make install
; php.ini
extension=spx.so
spx.http_enabled=1
spx.http_key=MySecretKey
spx.http_ip_whitelist="127.0.0.1"
; Access UI: http://localhost/?SPX_KEY=MySecretKey&SPX_UI_URI=/
; Profile request: http://localhost/page?SPX_KEY=MySecretKey&SPX_ENABLED=1
Оптимизация кода
Сравнение строк
<?php
declare(strict_types=1);
$string = 'hello world';
$iterations = 1_000_000;
// === faster than == (no type juggling)
// Benchmark:
$start = hrtime(true);
for ($i = 0; $i < $iterations; $i++) {
$result = $string === 'hello world'; // Strict
}
$strict = hrtime(true) - $start;
$start = hrtime(true);
for ($i = 0; $i < $iterations; $i++) {
$result = $string == 'hello world'; // Loose
}
$loose = hrtime(true) - $start;
printf("=== : %.2f ms\n", $strict / 1_000_000);
printf("== : %.2f ms\n", $loose / 1_000_000);
// === is typically 10-20% faster
isset() vs array_key_exists()
<?php
declare(strict_types=1);
$data = ['key' => null, 'other' => 'value'];
$iterations = 1_000_000;
// isset() — faster, but returns false for null values
// array_key_exists() — slower, but handles null correctly
$start = hrtime(true);
for ($i = 0; $i < $iterations; $i++) {
$exists = isset($data['key']); // false (key exists but value is null!)
}
$issetTime = hrtime(true) - $start;
$start = hrtime(true);
for ($i = 0; $i < $iterations; $i++) {
$exists = array_key_exists('key', $data); // true
}
$akeTime = hrtime(true) - $start;
printf("isset(): %.2f ms\n", $issetTime / 1_000_000);
printf("array_key_exists(): %.2f ms\n", $akeTime / 1_000_000);
// isset() is 2-3x faster
// Use isset() when:
// - You don't need to distinguish null from missing key
// - Performance matters (hot paths)
// Use array_key_exists() when:
// - null is a valid value
// - Correctness more important than speed
foreach vs array_map vs for
<?php
declare(strict_types=1);
$data = range(1, 100_000);
$iterations = 10;
// foreach — usually fastest for simple transformations
$start = hrtime(true);
for ($i = 0; $i < $iterations; $i++) {
$result = [];
foreach ($data as $val) {
$result[] = $val * 2;
}
}
$foreachTime = hrtime(true) - $start;
// array_map — cleaner syntax, slight overhead
$start = hrtime(true);
for ($i = 0; $i < $iterations; $i++) {
$result = array_map(fn (int $val) => $val * 2, $data);
}
$mapTime = hrtime(true) - $start;
// for loop with index
$start = hrtime(true);
$count = count($data);
for ($i = 0; $i < $iterations; $i++) {
$result = [];
for ($j = 0; $j < $count; $j++) {
$result[] = $data[$j] * 2;
}
}
$forTime = hrtime(true) - $start;
printf("foreach: %.2f ms\n", $foreachTime / 1_000_000);
printf("array_map: %.2f ms\n", $mapTime / 1_000_000);
printf("for: %.2f ms\n", $forTime / 1_000_000);
// foreach ≈ for > array_map (map has closure call overhead)
Generators для экономии памяти
<?php
declare(strict_types=1);
// Processing large CSV: array vs generator
// BAD: loads entire file into memory
function readCsvAll(string $path): array
{
$rows = [];
$handle = fopen($path, 'r');
while (($row = fgetcsv($handle)) !== false) {
$rows[] = $row;
}
fclose($handle);
return $rows;
}
// For 1 million rows: ~500 MB memory!
// GOOD: generator — one row at a time
function readCsvStream(string $path): Generator
{
$handle = fopen($path, 'r');
while (($row = fgetcsv($handle)) !== false) {
yield $row;
}
fclose($handle);
}
// For 1 million rows: ~0.5 KB memory!
// Usage is identical:
foreach (readCsvStream('/data/large.csv') as $row) {
processRow($row);
}
function processRow(array $row): void {}
SplFixedArray vs Array
<?php
declare(strict_types=1);
// Regular array: hash table, flexible, more memory
// SplFixedArray: C-like array, fixed size, less memory
$size = 1_000_000;
$memBefore = memory_get_usage();
$arr = [];
for ($i = 0; $i < $size; $i++) {
$arr[$i] = $i;
}
$arrMemory = memory_get_usage() - $memBefore;
unset($arr);
gc_collect_cycles();
$memBefore = memory_get_usage();
$fixed = new SplFixedArray($size);
for ($i = 0; $i < $size; $i++) {
$fixed[$i] = $i;
}
$fixedMemory = memory_get_usage() - $memBefore;
printf("Regular array: %s\n", number_format($arrMemory / 1024 / 1024, 2) . ' MB');
printf("SplFixedArray: %s\n", number_format($fixedMemory / 1024 / 1024, 2) . ' MB');
// SplFixedArray uses ~40-50% less memory for large numeric arrays
Предвычисления и кеширование
<?php
declare(strict_types=1);
// BAD: recalculate on every iteration
function processItemsBad(array $items, array $config): void
{
foreach ($items as $item) {
// strlen, strtolower called N times with same value
if (strlen($config['prefix']) > 0) {
$name = strtolower($config['prefix']) . '_' . $item;
}
}
}
// GOOD: precompute outside loop
function processItemsGood(array $items, array $config): void
{
$hasPrefix = strlen($config['prefix']) > 0;
$prefix = $hasPrefix ? strtolower($config['prefix']) . '_' : '';
foreach ($items as $item) {
$name = $prefix . $item;
}
}
// BAD: count() in loop condition (recalculated each iteration for some types)
for ($i = 0; $i < count($array); $i++) {
// count() called each iteration
}
// GOOD: precompute count
$count = count($array);
for ($i = 0; $i < $count; $i++) {
// Direct comparison with cached value
}
Строки: конкатенация vs implode
<?php
declare(strict_types=1);
$parts = array_fill(0, 10_000, 'segment');
// Concatenation in loop: O(n²) memory copies
$start = hrtime(true);
$result = '';
foreach ($parts as $part) {
$result .= $part . ','; // Each .= copies entire string
}
$concatTime = hrtime(true) - $start;
// implode: single allocation
$start = hrtime(true);
$result = implode(',', $parts);
$implodeTime = hrtime(true) - $start;
printf("Concat loop: %.2f ms\n", $concatTime / 1_000_000);
printf("implode(): %.2f ms\n", $implodeTime / 1_000_000);
// implode is 5-20x faster for large arrays
// Alternative: collect in array, then implode
$start = hrtime(true);
$buffer = [];
foreach ($parts as $part) {
$buffer[] = $part; // Array append is fast
}
$result = implode(',', $buffer);
$bufferTime = hrtime(true) - $start;
printf("Buffer+join: %.2f ms\n", $bufferTime / 1_000_000);
// Almost as fast as direct implode
Типизация улучшает JIT
<?php
declare(strict_types=1);
// Type hints help JIT optimize better
// Without types — JIT can't optimize well
function addUntyped($a, $b)
{
return $a + $b; // JIT doesn't know types → generic path
}
// With types — JIT optimizes to native CPU instructions
function addTyped(int $a, int $b): int
{
return $a + $b; // JIT knows both are int → native integer add
}
// This matters most in tight loops
function sumUntyped(array $data)
{
$sum = 0;
foreach ($data as $val) {
$sum += $val;
}
return $sum;
}
function sumTyped(array $data): int
{
$sum = 0;
foreach ($data as $val) {
$sum += (int) $val;
}
return $sum;
}
Оптимизация SQL-запросов
N+1 проблема
<?php
declare(strict_types=1);
// N+1 problem — the most common performance killer
// BAD: N+1 queries
// 1 query for posts + N queries for authors = N+1 total
$posts = $pdo->query("SELECT * FROM posts LIMIT 100")->fetchAll();
foreach ($posts as $post) {
// This executes 100 separate queries!
$author = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$author->execute([$post['author_id']]);
$post['author'] = $author->fetch();
}
// Total: 101 queries
// GOOD: Eager loading with JOIN (1 query)
$stmt = $pdo->query("
SELECT p.*, u.name as author_name, u.email as author_email
FROM posts p
JOIN users u ON u.id = p.author_id
LIMIT 100
");
$posts = $stmt->fetchAll();
// Total: 1 query
// GOOD: Batch loading with IN (2 queries)
$posts = $pdo->query("SELECT * FROM posts LIMIT 100")->fetchAll();
$authorIds = array_unique(array_column($posts, 'author_id'));
if (!empty($authorIds)) {
$placeholders = implode(',', array_fill(0, count($authorIds), '?'));
$stmt = $pdo->prepare("SELECT * FROM users WHERE id IN ({$placeholders})");
$stmt->execute($authorIds);
$authors = $stmt->fetchAll(PDO::FETCH_UNIQUE); // Indexed by id
foreach ($posts as &$post) {
$post['author'] = $authors[$post['author_id']] ?? null;
}
}
// Total: 2 queries (regardless of number of posts)
Индексы
<?php
declare(strict_types=1);
// ALWAYS check query plans with EXPLAIN
// Without index:
// SELECT * FROM orders WHERE user_id = 123;
// → Seq Scan on orders (cost=0.00..1234.00 rows=1000000)
// Scans ALL rows!
// With index:
// CREATE INDEX idx_orders_user_id ON orders(user_id);
// SELECT * FROM orders WHERE user_id = 123;
// → Index Scan using idx_orders_user_id (cost=0.00..8.00 rows=10)
// Scans only matching rows
// Composite index for common queries:
// SELECT * FROM orders WHERE user_id = 123 AND status = 'active'
// ORDER BY created_at DESC;
// → CREATE INDEX idx_orders_user_status_created
// ON orders(user_id, status, created_at DESC);
// Check missing indexes (PostgreSQL):
// SELECT schemaname, relname, seq_scan, idx_scan
// FROM pg_stat_user_tables
// WHERE seq_scan > idx_scan AND seq_scan > 1000;
Пагинация: keyset vs OFFSET
<?php
declare(strict_types=1);
// BAD: OFFSET pagination — gets slower with each page
// Page 1000: database reads and discards 999,000 rows!
$page = 1000;
$perPage = 20;
$offset = ($page - 1) * $perPage;
$pdo->query("SELECT * FROM articles ORDER BY id DESC LIMIT {$perPage} OFFSET {$offset}");
// Slow: O(offset + limit)
// GOOD: Keyset (cursor) pagination — constant speed
$lastId = 12345; // Last ID from previous page
$pdo->prepare("
SELECT * FROM articles
WHERE id < ?
ORDER BY id DESC
LIMIT 20
");
$stmt->execute([$lastId]);
// Fast: O(limit) — always scans only 20 rows
PHP-FPM Tuning
<?php
declare(strict_types=1);
// Calculate optimal PHP-FPM settings
// Step 1: Measure average worker memory
// Run: ps -eo pid,rss,command | grep php-fpm | awk '{sum+=$2; n++} END {print sum/n/1024 " MB"}'
$avgWorkerMemoryMb = 40; // Example: 40 MB per worker
// Step 2: Available RAM for PHP
$totalRamMb = 4096; // 4 GB server
$reservedMb = 1024; // RAM for OS, nginx, PostgreSQL, etc.
$phpRamMb = $totalRamMb - $reservedMb; // 3072 MB for PHP
// Step 3: Calculate max_children
$maxChildren = (int) floor($phpRamMb / $avgWorkerMemoryMb);
echo "Recommended pm.max_children = {$maxChildren}\n"; // 76
// Step 4: Dynamic pool settings
$startServers = (int) ($maxChildren * 0.25); // 25% at startup
$minSpare = (int) ($maxChildren * 0.25); // Min idle
$maxSpare = (int) ($maxChildren * 0.75); // Max idle
echo <<<CONFIG
[www]
pm = dynamic
pm.max_children = {$maxChildren}
pm.start_servers = {$startServers}
pm.min_spare_servers = {$minSpare}
pm.max_spare_servers = {$maxSpare}
pm.max_requests = 1000
; Slow log (find slow requests)
request_slowlog_timeout = 5s
slowlog = /var/log/php-fpm/slow.log
; Hard timeout
request_terminate_timeout = 30s
CONFIG;
Чеклист оптимизации
<?php
declare(strict_types=1);
/**
* Performance optimization checklist for PHP applications.
*/
// === INFRASTRUCTURE (biggest impact) ===
// [ ] OPcache enabled with proper settings
// [ ] JIT enabled (tracing mode)
// [ ] Preloading configured
// [ ] PHP-FPM tuned (pm.max_children)
// [ ] Nginx configured (gzip, keepalive, static files)
// === DATABASE (usually the bottleneck) ===
// [ ] N+1 queries eliminated
// [ ] Indexes on filtered/sorted columns
// [ ] Keyset pagination (no OFFSET)
// [ ] Connection pooling (PgBouncer)
// [ ] Query caching (Redis/APCu)
// [ ] EXPLAIN on slow queries
// === APPLICATION CODE ===
// [ ] Lazy loading (load data only when needed)
// [ ] Generators for large datasets
// [ ] APCu for frequently accessed data
// [ ] Redis for shared cache
// [ ] Strict types + type hints (helps JIT)
// [ ] Avoid unnecessary object creation in loops
// [ ] Precompute loop-invariant values
// === MONITORING ===
// [ ] Slow query log enabled (PostgreSQL)
// [ ] PHP-FPM slow log enabled
// [ ] APM tool (Blackfire, New Relic, Datadog)
// [ ] Memory monitoring for workers
// [ ] Response time tracking
Практический пример: оптимизация эндпоинта
<?php
declare(strict_types=1);
// BEFORE optimization: 850ms, 45MB memory
class OrderController
{
public function listOrders(int $userId): array
{
// Problem 1: No pagination
$orders = $this->orderRepository->findByUser($userId);
// Problem 2: N+1 for products
foreach ($orders as &$order) {
$order['products'] = $this->productRepository
->findByOrderId($order['id']);
// Problem 3: N+1 for product images
foreach ($order['products'] as &$product) {
$product['image'] = $this->imageService
->getUrl($product['image_id']);
}
}
// Problem 4: No caching
return $orders;
}
}
// AFTER optimization: 35ms, 2MB memory
class OptimizedOrderController
{
public function __construct(
private readonly OrderRepository $orderRepository,
private readonly AppCache $cache,
) {}
public function listOrders(int $userId, ?int $lastId = null): array
{
$cacheKey = "orders:{$userId}:{$lastId}";
// Fix 4: Cache responses
return $this->cache->remember($cacheKey, function () use ($userId, $lastId): array {
// Fix 1: Keyset pagination
// Fix 2: JOIN to eliminate N+1
// Fix 3: Single query with all data
return $this->orderRepository->findByUserWithProducts(
userId: $userId,
afterId: $lastId,
limit: 20,
);
}, ttl: 60);
}
}
// SQL: Single query with JOINs
// SELECT o.*, p.name, p.price, i.url as image_url
// FROM orders o
// JOIN order_items oi ON oi.order_id = o.id
// JOIN products p ON p.id = oi.product_id
// LEFT JOIN images i ON i.id = p.image_id
// WHERE o.user_id = ? AND o.id < ?
// ORDER BY o.id DESC
// LIMIT 20