Микросервисы на Java
Вызов метода внутри одного приложения либо выполнился, либо бросил исключение. Вызов соседнего сервиса по сети имеет третий исход: непонятно. Ответ мог потеряться на обратном пути, и повторить запрос уже небезопасно - вдруг он выполнился.
Этот третий исход и есть главная цена распределённой системы. Всё остальное - следствия: таймауты, повторы, идемпотентность, согласованность через события. Спрашивают именно про цену, потому что выгоду все и так знают.
// Формулировки: «где проводишь границы сервисов?», «как обеспечиваешь устойчивость вызовов?», «что делать с транзакцией на два сервиса?», «когда микросервисы вредны?».
Границы и данные
Границы проводят по бизнес-областям, а не по техническим слоям. «Сервис заказов» и «сервис доставки» - осмысленно. «Сервис контроллеров» и «сервис репозиториев» - катастрофа: любая задача потребует менять оба и выкатывать их синхронно.
Признак правильной границы - независимый выпуск. Если два сервиса приходится выкатывать вместе, границы между ними на самом деле нет, есть распределённый монолит: сложность распределённой системы уже оплачена, а самостоятельности не получено.
// Отсюда жёсткое правило про данные: у каждого сервиса своя база, и ходить в чужую напрямую нельзя. Общая база превращает схему таблиц в общий контракт, который никто не может изменить в одиночку, - и независимый выпуск умирает.
- распределённый монолит
- сервисы разделены, а выкатывать приходится вместе - худшее из двух миров
Устойчивость вызовов
Каждый вызов по сети защищают тремя вещами. Таймаут обязателен всегда: вызов без него держит поток до бесконечности, и в час пик такие вызовы выедают весь пул потоков - запас рабочих потоков, которыми приложение обрабатывает запросы. Повторы - только для идемпотентных операций, с растущей паузой и добавкой случайности, иначе все клиенты синхронно ударят по восстановившемуся сервису и снова его положат.
Третье - предохранитель. Он считает ошибки и, когда их доля переходит порог, перестаёт слать запросы вовсе, отвечая сразу отказом. Это защищает и соседа от добивания, и тебя от очереди повисших вызовов. Через какое-то время он пропускает пробный запрос и, если тот прошёл, возвращается к нормальной работе.
// Отдельно про переборку: пул потоков или соединений разделяют по внешним сервисам, чтобы медленный сосед не съел ресурсы, нужные всем остальным. Без этого один зависший интеграционный вызов кладёт приложение целиком, хотя все прочие его функции исправны.
- предохранитель
- при доле ошибок выше порога перестаёт слать запросы и отвечает отказом сразу
- переборка
- раздельные пулы под разных соседей, чтобы один не съел все ресурсы
Транзакция на два сервиса и отправка событий
Общей транзакции на два сервиса нет. Вместо неё - сага: цепочка локальных транзакций, где у каждого шага есть компенсация. Оплата прошла, склад отказал - выполняется возврат средств, а не откат несуществующей общей транзакции. Итог: система какое-то время несогласована, и это принимается сознательно.
Вторая задача - атомарно сохранить данные и отправить событие. Записать в базу и следом послать сообщение брокеру нельзя: между двумя действиями приложение может упасть, и получится либо заказ без события, либо событие без заказа.
Решение называется outbox. Событие пишут В ТУ ЖЕ базу и в той же транзакции, в отдельную таблицу исходящих. Отдельный процесс читает эту таблицу и отправляет сообщения брокеру. Транзакция одна, поэтому расхождения быть не может; платой становится доставка минимум один раз, а значит получатель обязан уметь переваривать дубликаты.
- сага
- цепочка локальных транзакций с компенсацией вместо общей
- outbox
- событие пишется в ту же базу и транзакцию, отправляет его отдельный процесс
Как отвечать: «Когда микросервисы - плохая идея?»
Когда команда одна, продукт молодой и границы предметной области ещё не устоялись. Микросервисы платят за независимость команд и раздельное масштабирование, а берут за это сетевые сбои, распределённые транзакции, отдельную наблюдаемость и куда более дорогую отладку. Если независимость командам не нужна, ты платишь и не получаешь ничего. Второй случай - когда границы проведены по слоям, а не по бизнес-областям: тогда любая задача требует менять два-три сервиса и выкатывать их вместе, и это распределённый монолит, худший вариант из возможных. Мой признак готовности - независимый выпуск: если сервисы нельзя выкатывать по отдельности, границы ещё не найдены. Поэтому разумный путь - начать с модульного монолита, где границы обозначены модулями, и выносить наружу то, что действительно требует отдельного масштабирования или отдельной команды.
Почему это сильный ответ: названы условия, при которых цена не окупается, дан проверяемый признак настоящей границы и предложена альтернатива вместо голого «не надо».
На чём валят
- −Границы по слоям вместо бизнес-областей. Каждая задача трогает несколько сервисов, выкатывать приходится вместе.
- −Общая база на несколько сервисов. Схема таблиц становится общим контрактом, и независимый выпуск исчезает.
- −Вызов по сети без таймаута. В час пик такие вызовы копятся и выедают пул потоков.
- −Повторы для неидемпотентной операции. Заказ создастся дважды, а без случайной задержки все клиенты ударят разом.
- −Записать в базу и отдельно послать событие. Падение между действиями рассинхронизирует систему - для этого есть outbox.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 17, остальные разбираются в тренажёре.
- Как обеспечить согласованность операции, затрагивающей несколько сервисов (нет общей транзакции)?A)Обернуть вызовы всех сервисов в одну распределённую ACID-транзакцию с двухфазным коммитомB)Просто повторять всю операцию с нуля, пока все сервисы одновременно не ответят успехом на все шагиC)Паттерн Saga: цепочка локальных транзакций с компенсирующими действиями на сбойD)Хранить копию всех данных в одном сервисе-координаторе и менять только её, игнорируя остальные
показать ответ и разбор
+C)Паттерн Saga: цепочка локальных транзакций с компенсирующими действиями на сбой// разбор: У каждого сервиса своя БД, поэтому единой ACID-транзакции на всю бизнес-операцию нет (двухфазный коммит 2PC хрупок и блокирующ — его избегают). Согласованность обеспечивают паттерном SAGA: операцию разбивают на последовательность ЛОКАЛЬНЫХ транзакций (каждая в своём сервисе), а при сбое очередного шага запускают КОМПЕНСИРУЮЩИЕ действия, отменяющие уже сделанное (вернуть оплату, снять резерв). Итог — не мгновенная атомарность, а согласованность в конечном счёте (eventual consistency). Saga бывает хореографической (через события) или оркестрованной (координатор). Тестируют именно сбои в середине и корректность компенсаций.
- Зачем нужен circuit breaker (предохранитель) при вызовах между сервисами?A)Чтобы шифровать трафик между сервисами, разрывая незащищённые соединения при обнаружении атакиB)Чтобы распределять запросы поровну между экземплярами сервиса, работая как балансировщик нагрузкиC)Чтобы кэшировать ответы сервиса и не обращаться к нему повторно с одинаковыми параметрами запросаD)Перестать звать падающий сервис на время, чтобы не копить зависания и не ронять всю систему
показать ответ и разбор
+D)Перестать звать падающий сервис на время, чтобы не копить зависания и не ронять всю систему// разбор: Circuit breaker защищает от КАСКАДНЫХ отказов: если вызываемый сервис начал стабильно падать/тормозить, предохранитель «размыкается» и на время ПРЕКРАЩАЕТ его звать — запросы сразу получают быстрый отказ или фолбэк (кэш, заглушка, деградация), а не висят на таймаутах, копя потоки и исчерпывая ресурсы (что уронило бы и вызывающий сервис). Периодически он пробует полузакрытым состоянием, восстановился ли зависимый сервис. Работает в связке с таймаутами, ретраями с backoff и bulkhead (изоляция пулов). Реализуют через Resilience4j (в Spring). Это ключевой паттерн устойчивости распределённых систем.
- Зачем в микросервисах нужен API Gateway?A)Единая точка входа: маршрутизация, аутентификация, rate limiting, агрегация — перед сервисамиB)Чтобы хранить все данные всех сервисов централизованно и отдавать их клиентам вместо самих сервисовC)Чтобы заменить собой все внутренние сервисы одним монолитом, к которому обращается клиент напрямуюD)Чтобы компилировать код сервисов при деплое и распределять готовые артефакты между узлами кластера
показать ответ и разбор
+A)Единая точка входа: маршрутизация, аутентификация, rate limiting, агрегация — перед сервисами// разбор: API Gateway — ЕДИНАЯ точка входа для внешних клиентов перед россыпью микросервисов. Он берёт на себя СКВОЗНЫЕ задачи, которые иначе дублировались бы в каждом сервисе: маршрутизацию запроса к нужному сервису, аутентификацию/авторизацию (проверку токена), ограничение частоты (rate limiting), терминацию TLS, агрегацию нескольких вызовов в один ответ, логирование/метрики. Клиенту не нужно знать про внутреннюю топологию и адреса сервисов. Риск — сделать gateway «умным монолитом»/узким местом: бизнес-логику там не держат. Примеры: Spring Cloud Gateway, Kong, nginx.
- Что решает service discovery в микросервисах?A)Определяет, какие сервисы вообще нужно создать в системе, генерируя их код по описанию архитектурыB)Находить актуальные адреса экземпляров сервисов, которые меняются динамическиC)Хранит исходный код всех сервисов в едином реестре, откуда они разворачиваются при старте кластераD)Обнаруживает уязвимости в сервисах, сканируя их на известные бреши безопасности по расписанию
показать ответ и разбор
+B)Находить актуальные адреса экземпляров сервисов, которые меняются динамически// разбор: В облаке/оркестраторе экземпляры сервисов ЭФЕМЕРНЫ: их адреса и число меняются (автоскейл, перезапуски, падения). Service discovery — реестр, отображающий ЛОГИЧЕСКОЕ имя сервиса на актуальные СЕТЕВЫЕ адреса живых экземпляров: сервисы регистрируются при старте, а вызывающая сторона (или балансировщик) находит текущие адреса по имени, не завязываясь на статические IP. Бывает клиентский (Eureka + клиентская балансировка) и серверный (через DNS/прокси, как в Kubernetes — Service/DNS). Это делает вызовы между сервисами устойчивыми к динамике инфраструктуры. Связано со здоровьем инстансов (healthcheck) и балансировкой.
- Почему синхронный REST-вызов между сервисами создаёт временную связанность?A)Потому что синхронные вызовы физически не выходят между разными процессами без общей памяти сервисовB)Потому что REST медленнее очередей, и синхронная интеграция снижает пропускную способность вдвоеC)Вызывающий сервис ждёт ответа и падает/тормозит, если вызываемый недоступенD)Потому что при синхронном вызове оба сервиса обязаны использовать один и тот же язык программирования
показать ответ и разбор
+C)Вызывающий сервис ждёт ответа и падает/тормозит, если вызываемый недоступен// разбор: Синхронный вызов (REST/gRPC request-response) создаёт ВРЕМЕННУЮ связанность (temporal coupling): вызывающий БЛОКИРУЕТСЯ в ожидании ответа, поэтому оба сервиса должны быть доступны ОДНОВРЕМЕННО. Если вызываемый недоступен/медленный — вызывающий тоже страдает (ошибки, зависания, каскад). Асинхронный обмен через сообщения/события (Kafka/RabbitMQ) эту связанность ослабляет: отправитель публикует событие и продолжает, а получатель обработает его, когда сможет — сервисы не обязаны быть живы в один момент. Синхронность выбирают для «нужен ответ сейчас», асинхронность — для устойчивости и слабой связанности; часто комбинируют, добавляя к синхронным вызовам circuit breaker/таймауты.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.