Антипаттерны микросервисов

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

Антипаттерн — решение, которое выглядит разумным, охотно повторяется из проекта в проект и систематически делает хуже. Опасен он не глупостью, а правдоподобностью: каждый шаг к нему кажется логичным.

Ни одна команда не решает «а давайте построим распределённый монолит». Его строят по одному разумному компромиссу за раз: тут срочно, там временно, здесь «потом отрефакторим». Поэтому антипаттерны стоит знать в лицо — чтобы узнавать их на второй неделе, а не на второй год.

Каждый разбор ниже устроен одинаково: признаки (что вы наблюдаете), как до этого дошли и лечение. Ставьте диагноз по фактам, а не по ощущениям: считайте общие таблицы, длину цепочек вызовов, число совместных релизов.

1. Распределённый монолит

Самый дорогой из всех: вы платите полную цену распределённости — сеть, сериализация, отладка, инфраструктура — и не получаете взамен главного, независимого деплоя.

Признаки

  • Тест на пятницу. Можете ли вы выкатить один сервис в пятницу вечером, не выкатывая ничего другого? Если ответ «нет, сначала надо выложить users, потом orders, потом billing, и строго в этом порядке» — диагноз поставлен.
  • В релиз-плане есть слово «одновременно». Существует чат «релизный поезд», где договариваются об окне выкатки всех сервисов сразу.
  • Общая библиотека с DTO и моделями, которую импортируют все сервисы: поменял поле — пересобрал и выложил девять сервисов.
  • Откат одного сервиса ломает соседей.
  • Локально ничего не работает: чтобы запустить один сервис, нужно поднять ещё шесть.

Как до этого дошли

Резали не по бизнес-возможностям, а по техническим слоям (сервис контроллеров, сервис бизнес-логики, сервис доступа к данным) или просто по таблицам. При таком делении почти любая пользовательская фича задевает все сервисы разом — иначе и быть не может.

Лечение

  • Провести границы заново — по bounded context, а не по слоям: фича «оформить заказ» должна укладываться в один сервис.
  • Убить общую библиотеку моделей. Пусть каждый сервис держит свою копию структуры входящих данных: дублирование трёх полей дешевле, чем связность всех девяти релизов.
  • Ввести контракты с обратной совместимостью: новое поле добавляем, старое не удаляем сразу — тогда порядок выкатки перестаёт быть важен.
  • Если границы безнадёжны — слить сервисы обратно в один. Это не поражение, а лечение: монолит с хорошими модулями лучше плохо нарезанных микросервисов.

2. Общая база данных

Формально сервисы разные, но лезут в одни и те же таблицы. База превращается в скрытый интеграционный интерфейс, о котором никто не договаривался.

Признаки

  • Два сервиса пишут в одну таблицу.
  • Миграция схемы в одном сервисе роняет другой — причём об этом узнают на проде.
  • Никто не может ответить на вопрос «кто владелец таблицы orders?».
  • Оптимизация запроса в одном сервисе замедляет другой: индексы и блокировки общие.

Лечение

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

# Карта владения таблицами (лежит в репозитории, обновляется в PR)

orders-service   ВЛАДЕЕТ: orders, order_items
users-service    ВЛАДЕЕТ: users, addresses
billing-service  ВЛАДЕЕТ: invoices, payments

НАРУШЕНИЯ (чинить в таком порядке):
  billing-service   ПИШЕТ в orders.status      -> заменить на событие OrderPaid
  analytics-service ЧИТАЕТ users, orders       -> перевести на реплику для чтения
  orders-service    ЧИТАЕТ users.email         -> запрашивать через API users-service
  • Чужая запись заменяется событием или вызовом API владельца. Никаких исключений: право писать в таблицу — это и есть право владения.
  • Чужое чтение — на выбор: API владельца, реплика для чтения, локальная копия нужных полей, наполняемая событиями.
  • Внешние ключи между доменами удаляются: они физически запрещают разъехаться по разным базам.
  • Как временный контракт годится представление (view) поверх чужих таблиц: владелец гарантирует его стабильность и волен менять физическую схему под ним.

3. Наносервисы

Дробление ради дробления: сервис размером с функцию. «Микро» приняли за указание к размеру в строках кода, хотя оно про границу ответственности.

Признаки

  • В сервисе один эндпоинт и сорок строк логики — а вокруг Dockerfile, пайплайн, конфиг, алерты и дежурство.
  • Команда из шести человек владеет тридцатью репозиториями.
  • Простая фича требует согласованного PR в четыре репозитория.
  • Инфраструктурного кода в проекте больше, чем бизнес-логики.
  • На вопрос «где считается скидка?» никто не отвечает уверенно.

Лечение

  • Рабочая эвристика: сервис = бизнес-возможность («управление заказами»), а не действие («расчёт скидки»). Действие — это метод внутри сервиса.
  • Вторая эвристика, организационная: одна команда владеет 1–3 сервисами. Если на человека приходится по пять сервисов — вы дробили не систему, а внимание людей.
  • Сливайте обратно. Два сервиса, которые всегда выкатываются вместе и общаются только друг с другом, — это один сервис.

4. Синхронная цепочка на пять звеньев

Запрос от пользователя идёт в gateway, тот дёргает orders, orders — users, users — billing, billing — notifications. Каждый вызов синхронный: все ждут всех. Здесь ломается сразу и надёжность, и скорость.

Доступность перемножается. Пусть у каждого сервиса очень приличные 99,9%. Посмотрим, что станет с цепочкой.

# Доступность синхронной цепочки: каждое звено умножает вероятность успеха
uptime = 0.999  # 99.9% у каждого сервиса — "три девятки"

print("звеньев | доступность цепочки | простой в год")
for links in range(1, 8):
    chain = uptime ** links
    downtime_min = (1 - chain) * 365 * 24 * 60
    print(f"{links:^7} | {chain * 100:18.3f}% | {downtime_min:7.0f} мин")

Результат:

звеньев | доступность цепочки | простой в год
   1    |             99.900% |     526 мин
   2    |             99.800% |    1051 мин
   3    |             99.700% |    1575 мин
   4    |             99.601% |    2099 мин
   5    |             99.501% |    2623 мин
   6    |             99.401% |    3146 мин
   7    |             99.302% |    3668 мин

Пять «надёжных» сервисов дают 99,5% — это почти двое суток недоступности в год, и ни один сервис в отдельности при этом не виноват. Хуже того, отказ последнего звена (отправка уведомления!) валит оформление заказа: пользователь не может купить, потому что не отправляется письмо.

Задержка складывается. Пять звеньев по 50 мс — это 250 мс в лучшем случае, а хвосты (p99) складываются ещё злее: медленный ответ где-то в глубине превращается в таймаут наверху.

Лечение

  • Событие вместо вызова для всего, что не нужно прямо сейчас. Уведомление — классика: orders публикует OrderCreated и отвечает пользователю, notifications разбирает событие своим темпом. Цепочка укорачивается на звено, а падение notifications больше не мешает покупать.
  • Локальная копия данных. Если orders каждый раз ходит в users за одним лишь e-mail — пусть хранит его у себя, обновляя по событиям.
  • Правило глубины. Договоритесь: не больше двух синхронных прыжков на пользовательский запрос. Понадобился третий — либо границы неверны, либо вызов должен стать асинхронным.
  • Параллельность вместо последовательности. Если gateway всё же обязан спросить троих — пусть спрашивает их одновременно, а не по очереди.
  • И обязательный минимум из урока про отказоустойчивость: таймаут на каждом вызове, ретраи с джиттером, Circuit Breaker.

5. Жизнь без трейсинга

Двенадцать сервисов, у каждого свои логи. Пользователь жалуется: «оплата висит минуту». Куда смотреть?

Признаки

  • Разбор инцидента = созвон на шесть человек, каждый грепает свои логи по времени и по e-mail пользователя.
  • На вопрос «чей сервис тормозит» отвечают «не наш» все шестеро — и все искренне.
  • Строку лога невозможно связать с конкретным пользовательским запросом.
  • Метрик достаточно, чтобы узнать что сломалось, но не где.

Лечение

Сквозной trace-id: идентификатор, который рождается на границе системы и передаётся дальше в каждом HTTP-заголовке и в каждом сообщении брокера. Стандарт — W3C Trace Context (заголовок traceparent), инструмент — OpenTelemetry.

# Заголовок, который обязан прокидывать КАЖДЫЙ сервис (W3C Trace Context)
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             ^^ ^------------ trace-id -------------^ ^-- span-id --^

# Логи с trace_id: инцидент собирается одним запросом в поиск
12:04:01 gateway        trace=4bf92f35 POST /api/orders               12ms
12:04:01 orders-service trace=4bf92f35 создан заказ #8812             34ms
12:04:01 users-service  trace=4bf92f35 GET /users/551                  8ms
12:04:02 billing        trace=4bf92f35 POST /charge ... ТАЙМАУТ     3002ms  <-- вот он
12:04:05 orders-service trace=4bf92f35 повтор списания               41ms

Правила приживления: заголовок обязателен на входе и на выходе каждого сервиса (иначе трасса рвётся ровно на том, кто поленился); trace_id добавляется в каждую строку лога; при публикации события id кладётся в его заголовки — иначе трасса обрывается на брокере. И не логируйте в трассы пароли, токены и номера карт: трассы читает вся команда.

Как это работает: у всех пяти один корень

Четыре из пяти антипаттернов — это связанность (coupling), просочившаяся туда, где её не ждали: в релизный план, в схему базы, в цепочку вызовов. Пятый — невидимость: система стала распределённой, а инструменты наблюдения остались монолитными. Отсюда простой способ самопроверки: если изменение в одном сервисе заставляет трогать другой — где-то течёт связанность.

СимптомДиагнозПервый шаг лечения
Сервисы выкатываются только вместе и в определённом порядкеРаспределённый монолитУбрать общую библиотеку моделей, ввести совместимые контракты
Миграция схемы ломает соседний сервисОбщая базаКарта владения таблицами, один хозяин на таблицу
Шесть человек и тридцать репозиториевНаносервисыСлить сервисы: граница = бизнес-возможность
Падение уведомлений мешает оформить заказДлинная синхронная цепочкаЗаменить дальние вызовы событиями, ограничить глубину двумя прыжками
Инцидент разбирают шесть человек по шести логамНет трейсингаСквозной trace-id (W3C traceparent) во всех вызовах и сообщениях

Частые ошибки при лечении

  • «Добавим брокер — и станет асинхронно». Если сервис публикует событие и тут же синхронно ждёт ответное — это та же цепочка, только медленнее и с новым бинарником в инфраструктуре. Kafka не лечит границы.
  • Дробить дальше, когда больно. Больно от связанности, а не от размера. Разрезав плохо очерченный сервис пополам, вы получите два плохо очерченных сервиса и вызов по сети между ними.
  • Считать слияние сервисов позором. Слить два сервиса обратно — нормальный, зрелый рефакторинг. Границы уточняются по мере понимания домена, и движение бывает в обе стороны.
  • Чинить всё сразу. Пять антипаттернов не лечатся одним кварталом. Начните с трейсинга: без него вы даже не докажете, что остальные четыре у вас есть.
  • Оставлять «временный» доступ к чужой таблице. Если он не записан в карте владения с датой, он останется навсегда.

Итоги

  • Распределённый монолит: сервисы выкатываются только вместе. Тест — можете ли выложить один сервис в пятницу вечером.
  • Общая база: у таблицы нет владельца. Лечится картой владения, событиями вместо чужой записи и удалением межсервисных внешних ключей.
  • Наносервисы: сервис размером с функцию. Граница — бизнес-возможность; команда владеет 1–3 сервисами, не тридцатью.
  • Синхронная цепочка: доступность перемножается (пять звеньев по 99,9% дают 99,5%), задержки складываются. Лечится событиями и правилом «не больше двух прыжков».
  • Нет трейсинга: сквозной trace-id обязателен на входе и выходе каждого сервиса и в каждом сообщении брокера — иначе отладка невозможна.
  • Общий корень — связанность и невидимость. Начинать лечение всегда стоит с наблюдаемости.
Проверьте себя
1. По какому признаку надёжнее всего опознать распределённый монолит?
AСервисов больше десяти, и все написаны на разных языках
BСервисы нельзя выкатить по отдельности: релиз идёт «поездом», в строгом порядке и одновременно
CСервисы общаются по HTTP, а не через брокер сообщений
DУ каждого сервиса собственная база данных
2. Пять сервисов в синхронной цепочке, у каждого доступность 99,9%. Какова доступность всей цепочки?
AТе же 99,9% — отказы независимы и не влияют друг на друга
BОколо 99,5%: вероятности перемножаются (0.999 в пятой степени)
C99,99% — резервирование сервисов повышает общую надёжность
DЗависит только от самого медленного сервиса, доступность не меняется