Как перейти с монолита на микросервисы на PHP: реальный опыт 2016 года
Коротко: Не начинайте с нуля. Разделите монолит по «швам» — границам бизнес-доменов. Каждый сервис: собственная база данных, REST API, независимый деплой. Между сервисами — HTTP или очереди (RabbitMQ/Redis). Главная ошибка: разделить слишком мелко слишком рано. Минимум для микросервиса — команда из 2+ разработчиков и чёткая бизнес-граница.
Контекст: почему мы разделяли
Монолит на Laravel 5.2:
- 80k строк кода
- 4 разработчика, один репозиторий
- Мерж-конфликты каждый день
- Деплой занимал 20 минут (все 4 человека ждали)
- Медленная часть (генерация каталога) тормозила быструю часть (оформление заказов)
Решение: 3 сервиса по независимым бизнес-доменам.
Разбивка на сервисы
Монолит → 3 сервиса:
auth-service — аутентификация, пользователи, JWT
catalog-service — товары, категории, поиск
order-service — заказы, корзина, оплата
Общение между сервисами: HTTP + JWT
<?php
// ServiceClient.php — HTTP-клиент для вызова других сервисов
class ServiceClient {
private string $baseUrl;
private string $serviceToken; // Межсервисный JWT
public function __construct(string $serviceName) {
$urls = [
'auth' => 'http://auth-service:8001',
'catalog' => 'http://catalog-service:8002',
'orders' => 'http://order-service:8003',
];
$this->baseUrl = $urls[$serviceName] ?? throw new InvalidArgumentException("Unknown service: $serviceName");
$this->serviceToken = $this->getServiceToken();
}
public function get(string $path, array $params = []): array {
return $this->request('GET', $path . '?' . http_build_query($params));
}
public function post(string $path, array $body): array {
return $this->request('POST', $path, $body);
}
private function request(string $method, string $path, array $body = []): array {
$url = $this->baseUrl . $path;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5, // 5 секунд макс — важно для Circuit Breaker
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $this->serviceToken,
'Content-Type: application/json',
'X-Service-Name: ' . config('app.service_name'),
],
]);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$response = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code >= 500) {
throw new ServiceUnavailableException("Service returned HTTP $code");
}
return json_decode($response, true);
}
private function getServiceToken(): string {
// Межсервисный токен: JWT с коротким TTL, подписан общим секретом
return \JWT::encode([
'iss' => config('app.service_name'),
'iat' => time(),
'exp' => time() + 30, // 30 секунд — только для этого запроса
'type' => 'service',
], config('app.service_secret'));
}
}
// Использование в OrderService:
$catalogClient = new ServiceClient('catalog');
$product = $catalogClient->get('/products/' . $productId);
$stock = $product['stock'];
Circuit Breaker (упрощённый)
<?php
// CircuitBreaker.php — защита от каскадных сбоев
class CircuitBreaker {
private string $service;
private int $failureThreshold = 5; // 5 ошибок подряд = разомкнуть
private int $recoveryTimeout = 30; // 30 секунд перед повторной попыткой
public function call(callable $fn): mixed {
$state = $this->getState();
$failureCount = $state['failure_count'] ?? 0;
$lastFailure = $state['last_failure'] ?? 0;
if ($failureCount >= $this->failureThreshold) {
if (time() - $lastFailure < $this->recoveryTimeout) {
// Circuit OPEN: не пытаемся вызвать сервис
throw new ServiceUnavailableException("Circuit breaker open for {$this->service}");
}
// Half-open: попробуем один раз
}
try {
$result = $fn();
$this->recordSuccess();
return $result;
} catch (Exception $e) {
$this->recordFailure();
throw $e;
}
}
private function recordFailure(): void {
$state = $this->getState();
$state['failure_count'] = ($state['failure_count'] ?? 0) + 1;
$state['last_failure'] = time();
Cache::put("cb:{$this->service}", $state, 300);
}
private function recordSuccess(): void {
Cache::forget("cb:{$this->service}");
}
private function getState(): array {
return Cache::get("cb:{$this->service}", []);
}
}
// Использование:
$cb = new CircuitBreaker('catalog');
try {
$product = $cb->call(fn() => $catalogClient->get('/products/' . $id));
} catch (ServiceUnavailableException $e) {
// Fallback: показать кэшированные данные
$product = $cachedProductData;
}
Проблема: распределённые транзакции
<?php
// Наибольшая сложность: что делать если один сервис упал в середине транзакции?
// Пример: создать заказ + списать деньги + обновить остатки
// Паттерн SAGA (2016 — мы реализовали упрощённую версию):
// 1. OrderService: создать заказ в статусе 'pending'
// 2. CatalogService: зарезервировать товар
// 3. PaymentService: списать деньги
// 4. OrderService: перевести в статус 'confirmed'
// При любой ошибке: компенсирующие транзакции (отменить предыдущие шаги)
class OrderSaga {
private array $compensations = [];
public function execute(array $orderData): Order {
// Шаг 1: создать заказ
$order = Order::create(['status' => 'pending', ...]);
$this->compensations[] = fn() => $order->delete();
try {
// Шаг 2: зарезервировать товары
$catalogClient->post('/reserve', ['items' => $orderData['items']]);
$this->compensations[] = fn() => $catalogClient->post('/release', ['items' => $orderData['items']]);
// Шаг 3: списать деньги
$payment = $paymentClient->post('/charge', ['amount' => $order->total]);
$this->compensations[] = fn() => $paymentClient->post('/refund', ['payment_id' => $payment['id']]);
// Успех
$order->update(['status' => 'confirmed']);
return $order;
} catch (Exception $e) {
// Откатить все выполненные шаги в обратном порядке
foreach (array_reverse($this->compensations) as $compensate) {
try { $compensate(); } catch (Exception $ignore) {}
}
throw $e;
}
}
}
Что не сработало
1. Слишком много маленьких сервисов. Начали с 3 — за полгода стало 11. Накладные расходы на коммуникацию съели весь выигрыш в скорости.
2. Общая база данных. Два сервиса читали из одной таблицы. При изменении схемы нужно было обновлять оба сервиса одновременно. Это не микросервисы, это монолит с сетевым оверхедом.
3. Синхронные HTTP вызовы в цепочке. OrderService → CatalogService → InventoryService. Если InventoryService задержался на 500ms — весь запрос встаёт.
Решение (принятое в 2017): Свернули обратно к 3 сервисам, добавили асинхронные очереди для некритических операций, ввели единую схему ответа API.
Вывод
Микросервисы стоят накладных расходов когда:
- Команда > 8–10 человек, несколько независимых команд
- Разные части системы требуют разных технологий
- Независимое масштабирование частей системы критично
Монолит лучше когда:
- Команда < 8 человек
- Бизнес-логика тесно переплетена
- Нет чётких бизнес-доменных границ
В 2016 году мы решили проблему деплоя (3 независимых деплоя вместо одного). Но заплатили сложностью. Честный итог: для нашего масштаба монолит с чёткой модульной структурой дал бы тот же результат с меньшими усилиями.