Что такое масштабируемость
Масштабируемость -- это способность системы обрабатывать возрастающую нагрузку путем добавления ресурсов. Система масштабируема, если увеличение ресурсов пропорционально увеличивает пропускную способность.
Виды масштабирования
| Тип | Описание | Пример |
|---|---|---|
| Вертикальное (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 сервисы, экстернализованное состояние, разделение чтения и записи -- это фундамент, на котором строится масштабируемая система. Начинайте просто и масштабируйте по мере роста, а не заранее.