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;
}
}
package service
// ReportGenerator generates monthly reports.
// With Go pprof, profiling is built-in:
// go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
type ReportGenerator struct {
orders OrderRepository
formatter ReportFormatter
}
// GenerateMonthlyReport produces a report for the given month.
func (g *ReportGenerator) GenerateMonthlyReport(year, month int) (*Report, error) {
orders, err := g.orders.FindByMonth(year, month)
if err != nil {
return nil, fmt.Errorf("fetch orders: %w", err)
}
aggregated := g.aggregateByCategory(orders)
return g.formatter.Format(aggregated), nil
}
func (g *ReportGenerator) aggregateByCategory(orders []Order) map[string]*CategorySummary {
categories := make(map[string]*CategorySummary)
for _, order := range orders {
cat := order.Category()
if _, ok := categories[cat]; !ok {
categories[cat] = &CategorySummary{Name: cat}
}
categories[cat].AddOrder(order)
}
return categories
}
namespace App.Service;
// ReportGenerator produces monthly reports.
// .NET has no Xdebug: profiling is done out of process with the dotnet tools:
// dotnet-trace collect --process-id <pid> --profile cpu-sampling
// dotnet-counters monitor --process-id <pid>
public sealed class ReportGenerator(IOrderRepository orders, IReportFormatter formatter)
{
// Produce a report for the given month.
public async Task<Report> GenerateMonthlyReportAsync(int year, int month, CancellationToken ct = default)
{
// Under a sampling profiler, this shows the time breakdown per call
var monthlyOrders = await orders.FindByMonthAsync(year, month, ct);
var aggregated = AggregateByCategory(monthlyOrders);
return formatter.Format(aggregated);
}
private static Dictionary<string, CategorySummary> AggregateByCategory(IEnumerable<Order> orders)
{
var categories = new Dictionary<string, CategorySummary>();
foreach (var order in orders)
{
if (!categories.TryGetValue(order.Category, out var summary))
{
summary = new CategorySummary(order.Category);
categories[order.Category] = summary;
}
summary.AddOrder(order);
}
return categories;
}
}
from collections.abc import Iterable
class ReportGenerator:
"""Produces monthly reports.
Python has no Xdebug: use cProfile in-process, or py-spy out of process:
py-spy record -o profile.svg --pid <pid> --duration 30
python -m cProfile -o report.prof script.py
"""
def __init__(self, orders: OrderRepository, formatter: ReportFormatter) -> None:
self._orders = orders
self._formatter = formatter
def generate_monthly_report(self, year: int, month: int) -> Report:
"""Produce a report for the given month."""
# Under a sampling profiler, this shows the time breakdown per call
orders = self._orders.find_by_month(year, month)
aggregated = self._aggregate_by_category(orders)
return self._formatter.format(aggregated)
@staticmethod
def _aggregate_by_category(orders: Iterable[Order]) -> dict[str, CategorySummary]:
categories: dict[str, CategorySummary] = {}
for order in orders:
summary = categories.get(order.category)
if summary is None:
summary = CategorySummary(order.category)
categories[order.category] = summary
summary.add_order(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 = [];
}
}
package perf
import (
"runtime"
"sort"
"sync"
"time"
)
// Profiler provides manual instrumentation for performance measurement.
type Profiler struct {
mu sync.Mutex
timers map[string]timerEntry
results map[string]*profileResult
}
type timerEntry struct {
start time.Time
memoryStart uint64
}
type profileResult struct {
Duration time.Duration
Memory int64
Calls int
}
// ProfileResult holds formatted profiling data.
type ProfileResult struct {
DurationMs float64 `json:"duration_ms"`
MemoryMB float64 `json:"memory_mb"`
Calls int `json:"calls"`
AvgMs float64 `json:"avg_ms"`
}
// NewProfiler creates a new Profiler.
func NewProfiler() *Profiler {
return &Profiler{
timers: make(map[string]timerEntry),
results: make(map[string]*profileResult),
}
}
// Start begins timing a labeled operation.
func (p *Profiler) Start(label string) {
var m runtime.MemStats
runtime.ReadMemStats(&m)
p.mu.Lock()
p.timers[label] = timerEntry{start: time.Now(), memoryStart: m.TotalAlloc}
p.mu.Unlock()
}
// Stop ends timing and records the result.
func (p *Profiler) Stop(label string) {
elapsed := time.Since(p.timers[label].start)
var m runtime.MemStats
runtime.ReadMemStats(&m)
p.mu.Lock()
defer p.mu.Unlock()
timer, ok := p.timers[label]
if !ok {
return
}
memDelta := int64(m.TotalAlloc - timer.memoryStart)
if _, exists := p.results[label]; !exists {
p.results[label] = &profileResult{}
}
p.results[label].Duration += elapsed
p.results[label].Memory += memDelta
p.results[label].Calls++
delete(p.timers, label)
}
// Measure profiles a function and returns its result.
func Measure[T any](p *Profiler, label string, fn func() (T, error)) (T, error) {
p.Start(label)
defer p.Stop(label)
return fn()
}
// GetResults returns profiling results sorted by duration.
func (p *Profiler) GetResults() map[string]ProfileResult {
p.mu.Lock()
defer p.mu.Unlock()
out := make(map[string]ProfileResult, len(p.results))
for label, r := range p.results {
ms := float64(r.Duration.Microseconds()) / 1000.0
out[label] = ProfileResult{
DurationMs: ms,
MemoryMB: float64(r.Memory) / (1024 * 1024),
Calls: r.Calls,
AvgMs: ms / float64(r.Calls),
}
}
return out
}
namespace App.Performance;
using System.Collections.Concurrent;
using System.Diagnostics;
// Formatted profiling data for a single label.
public readonly record struct ProfileResult(
double DurationMs,
double MemoryMb,
int Calls,
double AvgMs);
// Manual instrumentation for performance measurement.
// Use when a full profiler (dotnet-trace, PerfView) is not available.
public sealed class Profiler
{
private sealed record TimerEntry(long StartTimestamp, long AllocatedStart);
private sealed class Accumulator
{
public double DurationMs;
public long AllocatedBytes;
public int Calls;
}
private readonly ConcurrentDictionary<string, TimerEntry> _timers = new();
private readonly ConcurrentDictionary<string, Accumulator> _results = new();
public void Start(string label) =>
_timers[label] = new TimerEntry(Stopwatch.GetTimestamp(), GC.GetAllocatedBytesForCurrentThread());
public void Stop(string label)
{
if (!_timers.TryRemove(label, out var timer))
{
return;
}
var elapsed = Stopwatch.GetElapsedTime(timer.StartTimestamp);
var allocatedDelta = GC.GetAllocatedBytesForCurrentThread() - timer.AllocatedStart;
var accumulator = _results.GetOrAdd(label, _ => new Accumulator());
lock (accumulator)
{
accumulator.DurationMs += elapsed.TotalMilliseconds;
accumulator.AllocatedBytes += allocatedDelta;
accumulator.Calls++;
}
}
// Profile a callback and return its result.
public T Measure<T>(string label, Func<T> callback)
{
Start(label);
try
{
return callback();
}
finally
{
Stop(label);
}
}
// Profile an async callback and return its result.
public async Task<T> MeasureAsync<T>(string label, Func<Task<T>> callback)
{
Start(label);
try
{
return await callback();
}
finally
{
Stop(label);
}
}
// Profiling results sorted by total duration, descending.
public IReadOnlyDictionary<string, ProfileResult> GetResults() =>
_results
.Select(entry => (entry.Key, Result: new ProfileResult(
Math.Round(entry.Value.DurationMs, 3),
Math.Round(entry.Value.AllocatedBytes / 1048576.0, 2),
entry.Value.Calls,
Math.Round(entry.Value.DurationMs / entry.Value.Calls, 3))))
.OrderByDescending(entry => entry.Result.DurationMs)
.ToDictionary(entry => entry.Key, entry => entry.Result);
public void Reset()
{
_timers.Clear();
_results.Clear();
}
}
import time
import tracemalloc
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class ProfileResult:
"""Formatted profiling data for a single label."""
duration_ms: float
memory_mb: float
calls: int
avg_ms: float
@dataclass(slots=True)
class _Accumulator:
duration_ms: float = 0.0
memory_bytes: int = 0
calls: int = 0
class Profiler:
"""Manual instrumentation for performance measurement.
Use when a full profiler (cProfile, py-spy) is not available.
Memory numbers require tracemalloc.start() before the first measurement.
"""
def __init__(self) -> None:
self._timers: dict[str, tuple[int, int]] = {}
self._results: dict[str, _Accumulator] = {}
def start(self, label: str) -> None:
current, _ = tracemalloc.get_traced_memory()
self._timers[label] = (time.perf_counter_ns(), current)
def stop(self, label: str) -> None:
timer = self._timers.pop(label, None)
if timer is None:
return
start_ns, memory_start = timer
duration_ns = time.perf_counter_ns() - start_ns
current, _ = tracemalloc.get_traced_memory()
accumulator = self._results.setdefault(label, _Accumulator())
accumulator.duration_ms += duration_ns / 1_000_000
accumulator.memory_bytes += current - memory_start
accumulator.calls += 1
@contextmanager
def measure(self, label: str) -> Iterator[None]:
"""Profile a block of code. The idiomatic Python analogue of a callback."""
self.start(label)
try:
yield
finally:
self.stop(label)
def get_results(self) -> dict[str, ProfileResult]:
"""Profiling results sorted by total duration, descending."""
formatted = {
label: ProfileResult(
duration_ms=round(accumulator.duration_ms, 3),
memory_mb=round(accumulator.memory_bytes / 1048576, 2),
calls=accumulator.calls,
avg_ms=round(accumulator.duration_ms / accumulator.calls, 3),
)
for label, accumulator in self._results.items()
}
return dict(sorted(formatted.items(), key=lambda item: item[1].duration_ms, reverse=True))
def reset(self) -> None:
self._timers.clear()
self._results.clear()
### Пример использования
<?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'],
);
}
package monitor
import (
"runtime"
"runtime/debug"
)
// RuntimeStatus holds Go runtime performance info (analogous to OPcache status).
type RuntimeStatus struct {
GoVersion string `json:"go_version"`
NumGoroutines int `json:"num_goroutines"`
HeapAllocMB float64 `json:"heap_alloc_mb"`
HeapSysMB float64 `json:"heap_sys_mb"`
HeapObjects uint64 `json:"heap_objects"`
GCPauseMs float64 `json:"gc_pause_ms"`
NumGC uint32 `json:"num_gc"`
}
// GetRuntimeStatus returns current Go runtime metrics.
func GetRuntimeStatus() RuntimeStatus {
var m runtime.MemStats
runtime.ReadMemStats(&m)
var lastPause float64
if m.NumGC > 0 {
lastPause = float64(m.PauseNs[(m.NumGC+255)%256]) / 1e6
}
return RuntimeStatus{
GoVersion: runtime.Version(),
NumGoroutines: runtime.NumGoroutine(),
HeapAllocMB: float64(m.HeapAlloc) / (1024 * 1024),
HeapSysMB: float64(m.HeapSys) / (1024 * 1024),
HeapObjects: m.HeapObjects,
GCPauseMs: lastPause,
NumGC: m.NumGC,
}
}
// GetWarnings checks runtime health and returns warnings.
func GetWarnings() []string {
status := GetRuntimeStatus()
var warnings []string
if status.NumGoroutines > 10000 {
warnings = append(warnings, "Too many goroutines (>10000), possible leak")
}
if status.HeapAllocMB > 1024 {
warnings = append(warnings, "Heap allocation exceeds 1GB")
}
if status.GCPauseMs > 10 {
warnings = append(warnings, "GC pause > 10ms, consider tuning GOGC")
}
return warnings
}
namespace App.Monitoring;
using System.Runtime;
using System.Runtime.InteropServices;
// .NET has no OPcache: IL is JIT-compiled and cached by the runtime itself.
// The operational analogue is watching GC and tiered-compilation health.
// Production tuning lives in the csproj / runtimeconfig.json:
// <ServerGarbageCollection>true</ServerGarbageCollection>
// <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
// <TieredPGO>true</TieredPGO>
// <PublishReadyToRun>true</PublishReadyToRun> AOT-precompiled IL, closest to OPcache
// Current .NET runtime performance info.
public readonly record struct RuntimeStatus(
string FrameworkVersion,
bool ServerGc,
double HeapSizeMb,
double CommittedMb,
double GcPauseTimePercent,
int Gen2Collections);
public sealed class RuntimeMonitor
{
// Read the current runtime metrics.
public RuntimeStatus GetStatus()
{
var info = GC.GetGCMemoryInfo();
return new RuntimeStatus(
FrameworkVersion: RuntimeInformation.FrameworkDescription,
ServerGc: GCSettings.IsServerGC,
HeapSizeMb: Math.Round(info.HeapSizeBytes / 1048576.0, 2),
CommittedMb: Math.Round(info.TotalCommittedBytes / 1048576.0, 2),
GcPauseTimePercent: Math.Round(info.PauseTimePercentage, 2),
Gen2Collections: GC.CollectionCount(2));
}
// Check whether the runtime needs attention.
public IReadOnlyList<string> GetWarnings()
{
var status = GetStatus();
var warnings = new List<string>();
if (!status.ServerGc)
{
warnings.Add("Workstation GC under a server workload, enable ServerGarbageCollection");
}
if (status.GcPauseTimePercent > 5)
{
warnings.Add($"GC pause time is {status.GcPauseTimePercent}% of wall clock, review the allocation rate");
}
if (status.HeapSizeMb > 1024)
{
warnings.Add("Managed heap exceeds 1GB");
}
return warnings;
}
}
import gc
import platform
import sys
from dataclasses import dataclass
# CPython has no OPcache: bytecode is compiled once and cached in __pycache__.
# The operational analogue is watching the GC and the bytecode cache:
# python -m compileall -q . pre-compile bytecode at image build time
# PYTHONDONTWRITEBYTECODE=0 keep the .pyc cache enabled (default)
# gc.freeze() move startup objects out of GC scans after fork
@dataclass(frozen=True, slots=True)
class RuntimeStatus:
"""Current interpreter performance info."""
python_version: str
gc_enabled: bool
tracked_objects: int
gen2_collections: int
uncollectable: int
bytecode_cache_enabled: bool
class RuntimeMonitor:
"""Reports interpreter health, the analogue of an OPcache status check."""
def get_status(self) -> RuntimeStatus:
"""Read the current runtime metrics."""
stats = gc.get_stats()
return RuntimeStatus(
python_version=platform.python_version(),
gc_enabled=gc.isenabled(),
tracked_objects=len(gc.get_objects()),
gen2_collections=stats[2]["collections"],
uncollectable=len(gc.garbage),
bytecode_cache_enabled=not sys.dont_write_bytecode,
)
def get_warnings(self) -> list[str]:
"""Check whether the interpreter needs attention."""
status = self.get_status()
warnings: list[str] = []
if not status.bytecode_cache_enabled:
warnings.append("Bytecode cache disabled, every import recompiles the source")
if status.uncollectable:
warnings.append(
f"{status.uncollectable} uncollectable objects, reference cycles are leaking memory"
)
if status.tracked_objects > 5_000_000:
warnings.append("Over 5M GC-tracked objects, consider __slots__ or gc.freeze()")
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;
}
}
package repository
import (
"context"
"database/sql"
"fmt"
"strings"
)
// OptimizedOrderRepository demonstrates N+1 avoidance in Go.
type OptimizedOrderRepository struct {
db *sql.DB
}
// GetOrdersWithItemsBad demonstrates the N+1 problem.
func (r *OptimizedOrderRepository) GetOrdersWithItemsBad(ctx context.Context, ids []int) ([]Order, error) {
var orders []Order
for _, id := range ids {
// Query 1 per order: N+1 problem!
order, _ := r.getOrder(ctx, id)
items, _ := r.getItems(ctx, id)
order.Items = items
orders = append(orders, order)
}
return orders, nil
}
// GetOrdersWithItemsGood uses only 2 queries regardless of N.
func (r *OptimizedOrderRepository) GetOrdersWithItemsGood(ctx context.Context, ids []int) ([]Order, error) {
if len(ids) == 0 {
return nil, nil
}
placeholders := make([]string, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args[i] = id
}
inClause := strings.Join(placeholders, ",")
// Query 1: all orders at once
query := fmt.Sprintf("SELECT id, user_id, total FROM orders WHERE id IN (%s)", inClause)
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("fetch orders: %w", err)
}
defer rows.Close()
orderMap := make(map[int]*Order)
var orders []Order
for rows.Next() {
var o Order
if err := rows.Scan(&o.ID, &o.UserID, &o.Total); err != nil {
return nil, err
}
orders = append(orders, o)
orderMap[o.ID] = &orders[len(orders)-1]
}
// Query 2: all items at once
query = fmt.Sprintf("SELECT order_id, product, qty FROM order_items WHERE order_id IN (%s)", inClause)
itemRows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("fetch items: %w", err)
}
defer itemRows.Close()
for itemRows.Next() {
var orderID int
var item Item
if err := itemRows.Scan(&orderID, &item.Product, &item.Qty); err != nil {
return nil, err
}
if o, ok := orderMap[orderID]; ok {
o.Items = append(o.Items, item)
}
}
return orders, nil
}
namespace App.Repository;
using Microsoft.EntityFrameworkCore;
// OptimizedOrderRepository demonstrates N+1 avoidance with EF Core.
public sealed class OptimizedOrderRepository(AppDbContext db)
{
// BAD: N+1 problem — one query per order, plus one per item collection.
public async Task<List<Order>> GetOrdersWithItemsBadAsync(
IReadOnlyList<int> orderIds,
CancellationToken ct = default)
{
var orders = new List<Order>();
foreach (var id in orderIds)
{
// Query 1 per order
var order = await db.Orders.SingleAsync(o => o.Id == id, ct);
// Query N: items fetched separately for each order (N+1!)
order.Items = await db.OrderItems.Where(i => i.OrderId == id).ToListAsync(ct);
orders.Add(order);
}
return orders;
}
// GOOD: two queries total, regardless of N.
public async Task<List<Order>> GetOrdersWithItemsGoodAsync(
IReadOnlyList<int> orderIds,
CancellationToken ct = default)
{
if (orderIds.Count == 0)
{
return [];
}
// EF Core batches the includes; grouping happens in the query pipeline,
// so there is no manual regrouping step as in PHP or Go
return await db.Orders
.Where(order => orderIds.Contains(order.Id))
.Include(order => order.Items)
.AsSplitQuery() // Two queries instead of one wide cartesian join
.AsNoTracking()
.ToListAsync(ct);
}
}
from collections.abc import Sequence
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
class OptimizedOrderRepository:
"""Demonstrates N+1 avoidance with SQLAlchemy 2.0."""
def __init__(self, session: Session) -> None:
self._session = session
def get_orders_with_items_bad(self, order_ids: Sequence[int]) -> list[Order]:
"""BAD: N+1 problem — lazy loading fires one query per order."""
orders = []
for order_id in order_ids:
# Query 1 per order
order = self._session.get(Order, order_id)
if order is None:
continue
# Query N: touching the lazy relationship triggers another SELECT (N+1!)
_ = order.items
orders.append(order)
return orders
def get_orders_with_items_good(self, order_ids: Sequence[int]) -> list[Order]:
"""GOOD: two queries total, regardless of N."""
if not order_ids:
return []
# selectinload issues a second IN-query for all items and wires them
# to their parents, so there is no manual regrouping step
stmt = (
select(Order)
.where(Order.id.in_(order_ids))
.options(selectinload(Order.items))
)
return list(self._session.scalars(stmt))
## Кеширование
Стратегии кеширования
Стратегия
Описание
Когда
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);
}
}
package cache
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
// CacheAside implements the cache-aside (lazy-loading) pattern.
type CacheAside struct {
rdb *redis.Client
defaultTTL time.Duration
}
// NewCacheAside creates a CacheAside with the given Redis client and TTL.
func NewCacheAside(rdb *redis.Client, defaultTTL time.Duration) *CacheAside {
return &CacheAside{rdb: rdb, defaultTTL: defaultTTL}
}
// Remember gets from cache or computes and stores the result.
func Remember[T any](c *CacheAside, ctx context.Context, key string, compute func() (T, error), ttl ...time.Duration) (T, error) {
var zero T
cacheTTL := c.defaultTTL
if len(ttl) > 0 {
cacheTTL = ttl[0]
}
// Try cache first
cached, err := c.rdb.Get(ctx, key).Bytes()
if err == nil {
var val T
if err := json.Unmarshal(cached, &val); err == nil {
return val, nil
}
}
// Compute value
val, err := compute()
if err != nil {
return zero, err
}
// Store in cache
data, err := json.Marshal(val)
if err == nil {
c.rdb.Set(ctx, key, data, cacheTTL)
}
return val, nil
}
// Forget invalidates a cache entry.
func (c *CacheAside) Forget(ctx context.Context, key string) error {
return c.rdb.Del(ctx, key).Err()
}
// ForgetByPattern invalidates cache entries matching a pattern.
func (c *CacheAside) ForgetByPattern(ctx context.Context, pattern string) (int64, error) {
keys, err := c.rdb.Keys(ctx, pattern).Result()
if err != nil || len(keys) == 0 {
return 0, err
}
return c.rdb.Del(ctx, keys...).Result()
}
namespace App.Cache;
using System.Text.Json;
using StackExchange.Redis;
// CacheAside implements the cache-aside (lazy-loading) pattern.
public sealed class CacheAside(IConnectionMultiplexer redis, TimeSpan? defaultTtl = null)
{
private readonly IDatabase _db = redis.GetDatabase();
private readonly TimeSpan _defaultTtl = defaultTtl ?? TimeSpan.FromHours(1);
// Get from cache or compute and store.
public async Task<T> RememberAsync<T>(string key, Func<Task<T>> compute, TimeSpan? ttl = null)
{
var cached = await _db.StringGetAsync(key);
if (cached.HasValue)
{
var value = JsonSerializer.Deserialize<T>(cached!);
if (value is not null)
{
return value;
}
}
var computed = await compute();
await _db.StringSetAsync(key, JsonSerializer.Serialize(computed), ttl ?? _defaultTtl);
return computed;
}
// Invalidate a cache entry.
public Task<bool> ForgetAsync(string key) => _db.KeyDeleteAsync(key);
// Invalidate by pattern. KeysAsync uses SCAN, so it never blocks the server
// the way the raw KEYS command does.
public async Task<long> ForgetByPatternAsync(string pattern)
{
var server = redis.GetServer(redis.GetEndPoints().First());
var deleted = 0L;
await foreach (var key in server.KeysAsync(_db.Database, pattern, pageSize: 500))
{
if (await _db.KeyDeleteAsync(key))
{
deleted++;
}
}
return deleted;
}
}
import json
from collections.abc import Callable
from typing import Any
import redis
class CacheAside:
"""Implements the cache-aside (lazy-loading) pattern."""
def __init__(self, client: redis.Redis, default_ttl: int = 3600) -> None:
self._redis = client
self._default_ttl = default_ttl
def remember(self, key: str, compute: Callable[[], Any], ttl: int | None = None) -> Any:
"""Get from cache or compute and store."""
cached = self._redis.get(key)
if cached is not None:
return json.loads(cached)
value = compute()
self._redis.setex(key, ttl or self._default_ttl, json.dumps(value))
return value
def forget(self, key: str) -> int:
"""Invalidate a cache entry."""
return self._redis.delete(key)
def forget_by_pattern(self, pattern: str) -> int:
"""Invalidate by pattern. scan_iter is cursor-based and never blocks
the server the way the raw KEYS command does.
"""
deleted = 0
for key in self._redis.scan_iter(match=pattern, count=500):
deleted += self._redis.delete(key)
return deleted
## 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. Сначала профилирование, потом оптимизация самого медленного участка.