Какие сложности возникают при разработке микросервисов?

Ответ

Переход на микросервисную архитектуру решает одни проблемы, но создает новые, связанные с распределенностью системы.

Основные сложности:

  1. Управление распределенными транзакциями. Отсутствие единой ACID-транзакции. Требуются паттерны для обеспечения согласованности:

    • Saga: Последовательность локальных транзакций, где каждая следующая компенсирует предыдущую в случае ошибки.
    • Компенсирующие транзакции (Compensating Transaction).
      public async Task<bool> PlaceOrder(Order order)
      {
      try
      {
          await _inventoryService.ReserveItemsAsync(order.Items);
          await _paymentService.ChargeAsync(order.CustomerId, order.Total);
          await _notificationService.SendConfirmationAsync(order.CustomerId);
          return true;
      }
      catch (Exception ex)
      {
          // Компенсация: откат выполненных шагов
          await _inventoryService.ReleaseItemsAsync(order.Items);
          await _paymentService.RefundAsync(order.CustomerId, order.Total);
          _logger.LogError(ex, "Order placement failed");
          return false;
      }
      }
  2. Мониторинг и трассировка (Observability). Запрос проходит через множество сервисов. Необходимы централизованное логирование, сбор метрик и распределенная трассировка (например, с использованием OpenTelemetry, Jaeger, Azure Application Insights).

  3. Сетевая надежность. Сервисы взаимодействуют по сети, которая ненадежна. Необходимо реализовывать:

    • Повторные попытки (Retries) с экспоненциальной задержкой.
    • Размыкатели цепи (Circuit Breakers) для предотвращения лавинообразных сбоев.
    • Таймауты.
      // Настройка политик устойчивости с помощью Polly
      services.AddHttpClient<InventoryServiceClient>()
      .AddTransientHttpErrorPolicy(policy => policy.WaitAndRetryAsync(3, _ => TimeSpan.FromMilliseconds(600))))
      .AddTransientHttpErrorPolicy(policy => policy.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30)));
  4. Согласованность данных (Eventual Consistency). Данные между сервисами синхронизируются не мгновенно, что усложняет логику приложения.

  5. Версионирование API. Необходимо поддерживать обратную совместимость при изменениях контрактов (например, с помощью версий в URL, заголовках или использовании форматов вроде Protobuf).

  6. Усложнение тестирования. Резко возрастает важность интеграционных, контрактных (Pact) и сквозных (E2E) тестов.

Ответ 18+ 🔞

Да ты посмотри, что эти умники с микросервисами учудили! Вроде как от монолита, этого здоровенного неповоротливого чудовища, ушли, а теперь сидишь и думаешь — а не поменяли ли мы шило на мыло, только мыло это теперь в десяти разных коробочках разбросано, и каждая ещё и по своей орбите летает.

Ну ладно, проблемы-то какие всплывают, просто пиздец.

Первая засада — транзакции, блядь.

Раньше в монолите всё было просто: начал транзакцию, наделал делов, закоммитил — либо всё, либо ничего. Красота! А теперь у тебя каждый сервис — это отдельная база, отдельная песочница. И как, спрашивается, обеспечить, чтобы в одном сервисе заказ создался, в другом деньги списались, а в третьем уведомление ушло, и чтобы если где-то посередине пиздец случился — всё как было? Никак, ёпта! ACID тебе тут не светит. Приходится выкручиваться паттернами.

Вот, например, Saga. Это когда ты делаешь цепочку маленьких локальных транзакций. Сделал шаг — идёшь дальше. А если на пятом шаге обосрался, то начинаешь ползти назад и откатывать каждый предыдущий шаг своей, блядь, компенсирующей транзакцией. Это как идти по минному полю и каждую свою ступеньку за собой стирать, на случай если подорвёшься.

Смотри, как это в коде выглядит, примерно:

public async Task<bool> PlaceOrder(Order order)
{
    try
    {
        // Шаг 1: Резервируем товар на складе
        await _inventoryService.ReserveItemsAsync(order.Items);
        // Шаг 2: Списываем бабки с карты
        await _paymentService.ChargeAsync(order.CustomerId, order.Total);
        // Шаг 3: Шлём письмо "ваш заказ оформлен"
        await _notificationService.SendConfirmationAsync(order.CustomerId);
        return true; // Ура, всё прошло!
    }
    catch (Exception ex)
    {
        // А вот тут пиздец. Надо всё откатить.
        // Отменяем резерв товара
        await _inventoryService.ReleaseItemsAsync(order.Items);
        // Возвращаем деньги (если успели списать)
        await _paymentService.RefundAsync(order.CustomerId, order.Total);
        // Логируем, что всё сломалось
        _logger.LogError(ex, "Order placement failed");
        return false; // Всё пропало, шеф.
    }
}

Выглядит-то просто, а на деле это одна сплошная головная боль. Представь, если на этапе возврата денег тоже упадёт? Короче, ад.

Вторая беда — а где, сука, что происходит?

В монолите запрос упал — ты в одном логе всё нашёл. А тут запрос, как дурак по коридору, пробежал через пять сервисов. В каком из них он споткнулся и сломал себе шею? Чтобы это понять, нужно строить целую систему observability: централизованное логирование, метрики и, самое главное, распределённую трассировку. Чтобы каждому запросу присваивался уникальный ID, и ты мог проследить его путь от начала и до позорного конца через все сервисы. Без Jaeger'а или Application Insights — ты просто слепой крот в этой архитектуре.

Третья, и самая жирная проблема — сеть она ненадёжная, как мои обещания бросить пить.

Сервисы общаются по сети. А сеть — она туда-сюда, то пакет потеряет, то таймаут, то соседний сервис лег от нагрузки. Поэтому код должен быть устойчивым. Это не просто try-catch, это целая философия:

  • Повторные попытки (Retries): Не получилось с первого раза — подожди чутка и попробуй ещё. И ещё. Но не долбись бесконечно, и не сразу, а с экспоненциальной задержкой.
  • Размыкатель цепи (Circuit Breaker): Если удалённый сервис пять раз подряд ответил ошибкой, значит, он, скорее всего, мёртв. Зачем зря тратить время и ресурсы? "Размыкай цепь" на некоторое время, пусть все запросы сразу падают с ошибкой, давая тому сервису прийти в себя. Через 30 секунд попробуешь снова — ожил, молодец, работаем дальше.
  • Таймауты: Не жди ответа вечность. Поставил разумный лимит — не ответил, пошёл нахуй, освобождаем поток.

Вот как это часто настраивают (например, через библиотеку Polly):

services.AddHttpClient<InventoryServiceClient>()
    // Попробуй ещё 3 раза с растущей задержкой, если сеть глючит
    .AddTransientHttpErrorPolicy(policy => policy.WaitAndRetryAsync(3, _ => TimeSpan.FromMilliseconds(600))))
    // Если 5 ошибок подряд — разомкни цепь на 30 секунд
    .AddTransientHttpErrorPolicy(policy => policy.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30)));

Четвёртый пункт — согласованность данных, она теперь "в конечном счёте".

Eventual Consistency, блядь. Звучит умно, а на деле означает: "Ребята, данные между сервисами рано или поздно синхронизируются, но не сейчас". Пользователь оплатил заказ, а в личном кабинете у него ещё час пишет "ожидает оплаты". Потому что событие о платеже ещё ползёт по шине. Логику приложения приходится усложнять в разы, учитывая эти временные несоответствия.

Пятое — версионирование API.

Поменял ты контракт в одном сервисе — и всё, поезд ушёл. Все, кто от него зависел, могут посыпаться. Надо аккуратно вести версии, поддерживать обратную совместимость, чтобы старые клиенты не отвалились. Добавил поле — хорошо. Убрал поле — готовься к тому, что кто-то на тебя в суд подаст.

Ну и шестое — тестирование, его нужно овердохуища.

Юнит-тесты — это только разминка. Главное теперь — интеграционные тесты (чтобы проверить, как сервисы друг с другом общаются), контрактные тесты (чтобы один сервис, меняя API, не сломал другого) и сквозные (E2E) тесты, которые имитируют действия реального пользователя через всю эту сраную паутину сервисов. Без этого всего выкатывать в прод — это как играть в русскую рулетку с автоматом Калашникова.

Короче, микросервисы — это не серебряная пуля. Это мощный, но ебаный сложный инструмент, который требует соответствующей культуры разработки, кучи инструментов и зрелой команды. А иначе получится распределённый монолит — все сложности микросервисов, но без единого преимущества. Пиздец, да и только.