MidТеория7 min

Масштабируемые системы

Горизонтальное и вертикальное масштабирование, stateless и stateful архитектуры, capacity planning на PHP-примерах

Что такое масштабируемость

Масштабируемость -- это способность системы обрабатывать возрастающую нагрузку путем добавления ресурсов. Система масштабируема, если увеличение ресурсов пропорционально увеличивает пропускную способность.

Виды масштабирования

Тип Описание Пример
Вертикальное (Scale Up) Увеличение мощности одного сервера 4 CPU -> 32 CPU
Горизонтальное (Scale Out) Добавление серверов 1 сервер -> 10 серверов

Вертикальное масштабирование

Преимущества

  • Простота: не нужно менять архитектуру
  • Нет проблем с распределением данных
  • Консистентность данных гарантирована
  • Меньше сетевых вызовов

Ограничения

  • Физический потолок (самый мощный сервер конечен)
  • Стоимость растет нелинейно (x2 CPU != x2 цена, обычно x3-4)
  • Single point of failure
  • Простой при апгрейде
<?php

declare(strict_types=1);

/**
 * Vertical scaling: optimize single server performance
 *
 * When vertical scaling is appropriate:
 * - Database server (before sharding)
 * - Cache server (Redis with lots of RAM)
 * - Compute-heavy tasks (video transcoding)
 */

// Optimize query for single powerful database server
final class OptimizedProductRepository
{
    public function __construct(
        private readonly \PDO $db,
    ) {}

    /**
     * Uses database features that benefit from vertical scaling:
     * - Complex query with joins (more CPU)
     * - Large result set caching (more RAM)
     * - Index scans on big tables (faster SSD)
     */
    public function searchProducts(ProductSearchCriteria $criteria): ProductSearchResult
    {
        $sql = "
            WITH ranked_products AS (
                SELECT
                    p.id, p.name, p.price,
                    p.rating_avg, p.review_count,
                    c.name as category_name,
                    ts_rank(p.search_vector, plainto_tsquery(:query)) as relevance
                FROM products p
                JOIN categories c ON c.id = p.category_id
                WHERE p.search_vector @@ plainto_tsquery(:query)
                AND p.price BETWEEN :min_price AND :max_price
                AND p.is_active = true
            )
            SELECT *, count(*) OVER() as total_count
            FROM ranked_products
            ORDER BY relevance DESC, rating_avg DESC
            LIMIT :limit OFFSET 0
        ";

        $stmt = $this->db->prepare($sql);
        $stmt->execute([
            'query' => $criteria->query,
            'min_price' => $criteria->minPrice,
            'max_price' => $criteria->maxPrice,
            'limit' => $criteria->limit,
        ]);

        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);
        $total = $rows[0]['total_count'] ?? 0;

        return new ProductSearchResult(
            products: array_map(Product::fromRow(...), $rows),
            total: (int) $total,
        );
    }
}

Горизонтальное масштабирование

Преимущества

  • Теоретически неограниченное масштабирование
  • Отказоустойчивость (нет single point of failure)
  • Стоимость растет линейно
  • Можно масштабировать по частям

Требования

  • Stateless приложение (или externalized state)
  • Балансировщик нагрузки
  • Общее хранилище (БД, кэш)
  • Распределенные сессии (если нужны)

Stateless vs Stateful

Stateless архитектура

В stateless архитектуре сервер не хранит информации о клиенте между запросами. Все необходимое приходит с каждым запросом.

<?php

declare(strict_types=1);

/**
 * Stateless API: each request is self-contained.
 * Any server can handle any request.
 * This is the foundation of horizontal scaling.
 */
final class StatelessOrderController
{
    public function __construct(
        private readonly OrderService $orderService,
        private readonly TokenValidator $tokenValidator,
    ) {}

    /**
     * Completely stateless endpoint:
     * - Authentication via JWT (no server-side sessions)
     * - All data comes from request + database
     * - No server-local state
     */
    public function createOrder(Request $request): JsonResponse
    {
        // Auth info comes from the token, not a server session
        $userId = $this->tokenValidator->getUserId(
            $request->getHeader('Authorization')
        );

        $command = new CreateOrderCommand(
            userId: $userId,
            items: $request->json('items'),
            shippingAddress: $request->json('shipping_address'),
            idempotencyKey: $request->getHeader('Idempotency-Key'),
        );

        $order = $this->orderService->create($command);

        return new JsonResponse($order->toArray(), 201);
    }
}

/**
 * JWT token validator: no server state needed
 */
final class JwtTokenValidator implements TokenValidator
{
    public function __construct(
        private readonly string $publicKey,
    ) {}

    public function getUserId(string $authHeader): string
    {
        $token = str_replace('Bearer ', '', $authHeader);

        // Validate signature using public key (no DB call needed)
        $payload = $this->decodeAndVerify($token);

        if ($payload['exp'] < time()) {
            throw new TokenExpiredException();
        }

        return $payload['sub']; // User ID from token payload
    }

    private function decodeAndVerify(string $token): array
    {
        // JWT verification logic using public key
        $parts = explode('.', $token);
        if (count($parts) !== 3) {
            throw new InvalidTokenException();
        }

        [$header, $payload, $signature] = $parts;

        // Verify signature
        $data = "$header.$payload";
        $decodedSignature = base64_decode(strtr($signature, '-_', '+/'));

        if (!openssl_verify($data, $decodedSignature, $this->publicKey, OPENSSL_ALGO_SHA256)) {
            throw new InvalidTokenException();
        }

        return json_decode(base64_decode($payload), true);
    }
}

Stateful: как экстернализовать состояние

Если вашему приложению нужно состояние (сессии, корзина, черновики), вынесите его во внешнее хранилище.

<?php

declare(strict_types=1);

/**
 * Externalizing state: move session data from server to Redis
 *
 * Before: PHP sessions stored on local disk
 *   - Can't scale horizontally (sticky sessions needed)
 *   - Lost on server restart
 *
 * After: Sessions stored in Redis
 *   - Any server can read any session
 *   - Survives server restarts
 *   - Can be shared across services
 */
final class RedisSessionHandler implements \SessionHandlerInterface
{
    public function __construct(
        private readonly \Redis $redis,
        private readonly int $ttlSeconds = 3600,
        private readonly string $prefix = 'session:',
    ) {}

    public function open(string $path, string $name): bool
    {
        return true;
    }

    public function close(): bool
    {
        return true;
    }

    public function read(string $id): string|false
    {
        $data = $this->redis->get($this->prefix . $id);
        return $data !== false ? $data : '';
    }

    public function write(string $id, string $data): bool
    {
        return $this->redis->setex(
            $this->prefix . $id,
            $this->ttlSeconds,
            $data,
        );
    }

    public function destroy(string $id): bool
    {
        return $this->redis->del($this->prefix . $id) > 0;
    }

    public function gc(int $max_lifetime): int|false
    {
        // Redis handles TTL automatically
        return 0;
    }
}

// Registration in PHP application
// session_set_save_handler(new RedisSessionHandler($redis));
// session_start();

Capacity Planning

Capacity planning -- это процесс определения необходимых ресурсов для обработки текущей и будущей нагрузки.

Методология

<?php

declare(strict_types=1);

/**
 * Capacity planning methodology
 *
 * 1. Measure current load
 * 2. Determine growth rate
 * 3. Calculate future requirements
 * 4. Add safety margin (20-50%)
 * 5. Plan scaling actions
 */
final class CapacityPlanner
{
    /**
     * Calculate required number of application servers.
     */
    public function calculateAppServers(
        int $peakRequestsPerSecond,
        int $requestsPerServerPerSecond,
        float $safetyMargin = 0.3,
    ): int {
        // Base requirement
        $baseServers = ceil($peakRequestsPerSecond / $requestsPerServerPerSecond);

        // Add safety margin for spikes and failures
        $withMargin = ceil($baseServers * (1 + $safetyMargin));

        // Minimum 2 for redundancy
        return max(2, (int) $withMargin);
    }

    /**
     * Calculate required database connections.
     */
    public function calculateDbConnections(
        int $appServers,
        int $connectionsPerServer,
        int $maxDbConnections,
    ): array {
        $totalRequired = $appServers * $connectionsPerServer;

        return [
            'required' => $totalRequired,
            'max_available' => $maxDbConnections,
            'utilization_pct' => round($totalRequired / $maxDbConnections * 100, 1),
            'needs_pooler' => $totalRequired > $maxDbConnections * 0.8,
        ];
    }

    /**
     * Estimate storage growth.
     */
    public function estimateStorageGrowth(
        float $currentSizeGb,
        float $dailyGrowthGb,
        int $monthsAhead = 12,
    ): array {
        $projections = [];

        for ($month = 1; $month <= $monthsAhead; $month++) {
            $projectedSize = $currentSizeGb + ($dailyGrowthGb * 30 * $month);
            $projections[] = [
                'month' => $month,
                'projected_gb' => round($projectedSize, 1),
                'projected_tb' => round($projectedSize / 1024, 2),
            ];
        }

        return $projections;
    }
}

// Example calculation:
// Current: 5,000 RPS peak, each PHP server handles 500 RPS
// Servers needed: 5000/500 = 10, with 30% margin = 13 servers
// DB connections: 13 * 20 = 260 (PostgreSQL max_connections = 300)
// -> Need connection pooler (PgBouncer) at this scale

Автомасштабирование

<?php

declare(strict_types=1);

/**
 * Auto-scaling configuration (conceptual)
 *
 * In practice, auto-scaling is configured at infrastructure level
 * (Kubernetes HPA, AWS Auto Scaling, etc.)
 * But the application must report metrics for scaling decisions.
 */
final class ScalingMetricsReporter
{
    public function __construct(
        private readonly MetricsClient $metrics,
    ) {}

    /**
     * Report application-level metrics that drive auto-scaling decisions.
     * These are more accurate than CPU/memory for scaling PHP applications.
     */
    public function report(): void
    {
        // Request queue depth (most important for PHP)
        // If requests are queuing, we need more workers
        $this->metrics->gauge(
            'app_request_queue_depth',
            $this->getRequestQueueDepth(),
        );

        // Active PHP-FPM workers
        $this->metrics->gauge(
            'app_active_workers',
            $this->getActiveWorkers(),
        );

        // Response time p95
        $this->metrics->gauge(
            'app_response_time_p95_ms',
            $this->getP95ResponseTime(),
        );

        // Error rate
        $this->metrics->gauge(
            'app_error_rate_pct',
            $this->getErrorRate(),
        );
    }

    /**
     * Scaling rules (configured in infrastructure):
     *
     * Scale UP when:
     *   request_queue_depth > 10 for 2 minutes
     *   OR active_workers > 80% of max for 3 minutes
     *   OR response_time_p95 > 500ms for 5 minutes
     *
     * Scale DOWN when:
     *   active_workers < 30% of max for 10 minutes
     *   AND request_queue_depth < 2 for 10 minutes
     *
     * Limits:
     *   min_instances: 2
     *   max_instances: 50
     *   cooldown_period: 5 minutes
     */
}

Паттерны масштабирования

Паттерн 1: Read Replicas

Разделение чтения и записи для масштабирования read-heavy нагрузок.

<?php

declare(strict_types=1);

/**
 * Read replica pattern: separate read and write traffic
 */
final class ReadWriteConnectionManager
{
    public function __construct(
        private readonly \PDO $writer,
        /** @var array<\PDO> */
        private readonly array $readers,
    ) {}

    public function getWriter(): \PDO
    {
        return $this->writer;
    }

    /**
     * Get a random read replica.
     * Simple round-robin for load distribution.
     */
    public function getReader(): \PDO
    {
        if (empty($this->readers)) {
            // Fallback to writer if no replicas
            return $this->writer;
        }

        return $this->readers[array_rand($this->readers)];
    }
}

// Usage in repository
final class ProductRepository
{
    public function __construct(
        private readonly ReadWriteConnectionManager $connections,
    ) {}

    public function findById(string $id): ?Product
    {
        // Reads go to replica
        $stmt = $this->connections->getReader()->prepare(
            'SELECT * FROM products WHERE id = :id'
        );
        $stmt->execute(['id' => $id]);

        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
        return $row ? Product::fromRow($row) : null;
    }

    public function save(Product $product): void
    {
        // Writes always go to primary
        $stmt = $this->connections->getWriter()->prepare(
            'INSERT INTO products (id, name, price, updated_at)
             VALUES (:id, :name, :price, NOW())
             ON CONFLICT (id) DO UPDATE SET
                name = EXCLUDED.name,
                price = EXCLUDED.price,
                updated_at = NOW()'
        );

        $stmt->execute([
            'id' => $product->id,
            'name' => $product->name,
            'price' => $product->price,
        ]);
    }
}

Паттерн 2: CQRS Light

Разные модели для чтения и записи.

<?php

declare(strict_types=1);

/**
 * CQRS Light: separate read and write models
 * Without full event sourcing -- just optimized query models
 */

// Write model: normalized, validates business rules
final class OrderWriteService
{
    public function createOrder(CreateOrderCommand $cmd): string
    {
        // Full validation, business rules, normalized storage
        $order = Order::create($cmd);
        $this->repository->save($order);
        return $order->id;
    }
}

// Read model: denormalized, optimized for queries
final class OrderReadService
{
    public function __construct(
        private readonly \PDO $readDb, // Connects to read replica
    ) {}

    /**
     * Denormalized view optimized for the order list page.
     * Single query instead of multiple joins.
     */
    public function getOrderList(string $userId, int $limit = 20): array
    {
        $stmt = $this->readDb->prepare("
            SELECT
                o.id,
                o.status,
                o.total,
                o.created_at,
                o.item_count,
                o.first_item_name,
                o.first_item_image_url
            FROM order_list_view o
            WHERE o.user_id = :userId
            ORDER BY o.created_at DESC
            LIMIT :limit
        ");
        $stmt->execute(['userId' => $userId, 'limit' => $limit]);

        return $stmt->fetchAll(\PDO::FETCH_ASSOC);
    }
}

Признаки необходимости масштабирования

Сигнал Действие
CPU > 70% постоянно Вертикальное или горизонтальное масштабирование
Время ответа растет Кэширование, оптимизация запросов
Ошибки connection pool Добавить серверы или connection pooler
Диск заканчивается Архивация, шардирование
Очередь сообщений растет Добавить consumers

Выводы

Масштабируемость начинается с правильной архитектуры. Stateless сервисы, экстернализованное состояние, разделение чтения и записи -- это фундамент, на котором строится масштабируемая система. Начинайте просто и масштабируйте по мере роста, а не заранее.