О насБлогКонтакты
Архитектура12 октября 2016 г. 5 мин 22

Как перейти с монолита на микросервисы на PHP: реальный опыт 2016 года

AunimedaAunimeda
📋 Содержание

Как перейти с монолита на микросервисы на 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 независимых деплоя вместо одного). Но заплатили сложностью. Честный итог: для нашего масштаба монолит с чёткой модульной структурой дал бы тот же результат с меньшими усилиями.

Читайте также

Telegram Mini App для бизнеса в Самаре: возможности и стоимость 2026aunimeda
Разработка приложений

Telegram Mini App для бизнеса в Самаре: возможности и стоимость 2026

Что такое Telegram Mini App, какой бизнес в Самаре уже использует этот формат, как это работает технически и сколько стоит разработка.

Мобильное приложение для ресторана в Самаре: зачем и сколько стоитaunimeda
Разработка приложений

Мобильное приложение для ресторана в Самаре: зачем и сколько стоит

Когда самарскому ресторану нужно собственное мобильное приложение, что оно должно уметь, как избежать ловушки агрегаторов и реальная стоимость разработки.

Разработка приложения такси в Самаре: архитектура и бюджет 2026aunimeda
Разработка приложений

Разработка приложения такси в Самаре: архитектура и бюджет 2026

Как создать приложение-такси для самарского рынка: алгоритм матчинга, геолокация, интеграция СБП и ЮKassa, конкуренция с Яндекс Go. Реальный бюджет запуска.

Нужна IT-разработка для вашего бизнеса?

Разрабатываем сайты, мобильные приложения и AI-решения для бизнеса в России. Бесплатная консультация.

Получить консультацию Все статьи