Антипаттерны микросервисов
Пять способов получить всю боль распределённой системы и ни одного её преимущества — с признаками, по которым их узнают, и планом лечения.
Антипаттерн — решение, которое выглядит разумным, охотно повторяется из проекта в проект и систематически делает хуже. Опасен он не глупостью, а правдоподобностью: каждый шаг к нему кажется логичным.
Ни одна команда не решает «а давайте построим распределённый монолит». Его строят по одному разумному компромиссу за раз: тут срочно, там временно, здесь «потом отрефакторим». Поэтому антипаттерны стоит знать в лицо — чтобы узнавать их на второй неделе, а не на второй год.
Каждый разбор ниже устроен одинаково: признаки (что вы наблюдаете), как до этого дошли и лечение. Ставьте диагноз по фактам, а не по ощущениям: считайте общие таблицы, длину цепочек вызовов, число совместных релизов.
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 обязателен на входе и выходе каждого сервиса и в каждом сообщении брокера — иначе отладка невозможна.
- Общий корень — связанность и невидимость. Начинать лечение всегда стоит с наблюдаемости.