Разговор про архитектуру сайта обычно начинается с модного слова и заканчивается счётом за инфраструктуру. Между этими двумя точками лежит длинный путь из решений, каждое из которых тяжело отменить. Монолит, собранный пять лет назад командой из трёх человек, отлично работал, пока в нём было два десятка экранов. Потом появились новые отделы, релизы стали пересекаться, а правка одной формы внезапно ломала отчётность. Знакомая картина: система не сломалась, она просто перестала поспевать за бизнесом. Микросервисы не лечат эту болезнь сами по себе. Они меняют природу сложности: вместо одного большого клубка проблем появляется много маленьких, каждая со своей зоной ответственности и своим владельцем. Такой обмен выгоден не всегда, и профессиональный разбор начинается именно с честной оценки цены.
Монолит держится на простых решениях и упирается в координацию команд
Пока продукт делает одна команда, монолит почти всегда выигрывает. Один репозиторий, одна сборка, одна база данных, один деплой. Транзакция меняет заказ и запись остатков на складе в одном блоке, и никакой распределённой согласованности придумывать не нужно. Разработчик открывает проект, видит весь код и может проследить путь запроса от маршрута до SQL-запроса за десять минут.
Предел наступает не по техническим причинам, а по организационным. Когда над кодовой базой работают пять команд, каждая из них ждёт остальные. Слияние веток превращается в отдельный вид деятельности, а выпуск релиза требует синхронизации всех участников. Код при этом остаётся рабочим, страдает скорость изменений. Хороший признак того, что монолит ещё не пора трогать: команда помещается за два стола, а релизы не мешают друг другу. Плохой признак: перед каждым спринтом устраивается общий сбор, где выясняется, кто кого блокирует.
Разделение на сервисы начинается с границ предметной области
Самая частая ошибка при переходе - резать систему по техническим слоям. Получается сервис пользователей, сервис заказов, сервис уведомлений, и между ними гуляют запросы, потому что бизнес-операция всё равно проходит через все три. Правильная граница проходит там, где меняется словарь. Оформление заказа, учёт остатков и биллинг говорят на разных языках, у них разные правила и разный ритм изменений. Вот это и есть кандидаты на отдельные сервисы.
Практический приём: собрать список бизнес-событий и посмотреть, какие данные меняются вместе. Если поля всегда обновляются одной операцией и никто не читает их по отдельности, делить их на разные сервисы бессмысленно, получится распределённая транзакция на ровном месте. Наоборот, если части одной сущности нужны разным командам с разной частотой, граница напрашивается сама.
Пример структуры каталогов после разделения:
services/
orders/
cmd/api/main.go
internal/order/ доменные правила
internal/storage/ репозиторий
catalog/
billing/
notifications/
deploy/
orders.yaml
catalog.yaml
Каждый сервис владеет своей схемой данных и не пускает соседей в свои таблицы. Общение идёт по сети, а не по общим индексам. Это правило нарушают чаще всего, и именно оно превращает микросервисы в тот же монолит, только медленнее.
Сеть между сервисами приносит новые классы отказов
Локальный вызов метода либо работает, либо бросает исключение. Сетевой вызов умеет гораздо больше: он может идти две секунды, вернуть половину ответа, выполниться дважды или не выполниться вовсе. Отсюда набор обязательных техник.
Таймауты ставятся на каждый вызов, и они короткие. Ретраи применяются только к идемпотентным операциям и с экспоненциальной задержкой, иначе один сбойный сервис умножает нагрузку на себя же. Предохранитель размыкается, когда доля ошибок превышает порог, и перестаёт бить в падающий сервис. Идемпотентный ключ на операции списания денег спасает от двойного начисления при повторной доставке.
Схема обработки платежа с ключом идемпотентности:
import hashlib
import redis
r = redis.Redis()
def make_key(order_id: str, attempt: str) -> str:
raw = f"{order_id}:{attempt}".encode()
return "pay:" + hashlib.sha256(raw).hexdigest()
def charge(order_id: str, attempt: str, amount: int) -> str:
import time
key = make_key(order_id, attempt)
result_key = key + ":result"
stored = r.get(result_key)
if stored is not None:
return stored.decode()
# первый запрос занимает слот и выполняет списание
if r.set(key, "charging", nx=True, ex=86400):
result = gateway.charge(order_id, amount)
r.set(result_key, result, ex=86400)
return result
# параллельный запрос ждёт готовый результат вместо второго списания
for _ in range(50):
stored = r.get(result_key)
if stored is not None:
return stored.decode()
time.sleep(0.1)
raise RuntimeError("платёж ещё выполняется, запросите статус позже")
Второй вызов с тем же ключом не уходит в платёжный шлюз, а либо возвращает сохранённый результат, либо коротко ждёт его: без обработки статуса выполнения параллельный запрос упал бы на пустом ответе, а это уже классическое состояние гонки. Простая техника, которая на практике снимает большинство инцидентов с двойными списаниями.
Отдельная история - выбор способа общения между сервисами, потому что он влияет на связанность сильнее, чем внутренний код. Синхронный вызов по протоколу запрос-ответ прост для понимания, но жёстко связывает участников: пока сосед отвечает, вызывающий ждёт и держит соединение. Асинхронная передача событий разрывает эту связь. Сервис заказов публикует событие об оформлении, а учёт остатков и уведомления подписываются на него независимо. Падение подписчика не блокирует оформление заказа.
У асинхронности своя цена: доставка события может задерживаться, порядок сообщений не гарантирован по умолчанию, а повторная доставка становится нормой, а не исключением. Поэтому обработчик строят идемпотентным, а рядом держат хранилище обработанных идентификаторов. Событийная схема требует дисциплины, зато позволяет добавлять новых потребителей без правок в источнике. Промежуточное решение - версионирование контрактов. Пока новый потребитель может читать старый формат, а старый игнорирует незнакомые поля, сервисы обновляются независимо. Контракт превращается в публичный документ, изменение которого проходит через ревью так же строго, как изменение денежной логики.
Наблюдаемость заменяет чтение стека вызовов и ускоряет разбор сбоев
В монолите сбой виден в одном логе. В распределённой системе запрос проходит через пять сервисов, и без сквозного идентификатора разбираться в нём бессмысленно. Идентификатор трассировки присваивается на входе, передаётся в заголовке и попадает во все записи логов. Дальше строится дерево вызовов, где видно, какой именно участок съел время.
Метрики делятся на три группы: задержка, частота ошибок и насыщение ресурсов. Отдельно считается задержка на 95-м и 99-м процентиле, потому что среднее значение умеет скрывать проблемы. Если сотня запросов идёт за 40 миллисекунд, а один за четыре секунды, среднее будет прекрасным, а жалобы никуда не денутся. Полезно заранее договориться о бюджете ошибок: если сервису разрешено терять один запрос из тысячи, это число становится общим ориентиром. Пока укладываемся, выпускаем новые версии спокойно, вышли за границу - сначала чиним надёжность.
Развёртывание и откат решают судьбу всей затеи. Микросервисы окупаются только при быстром и предсказуемом деплое. Ручная выкладка по инструкции из десяти пунктов сводит преимущества к нулю: сервисы становятся мелкими, а ручной работы столько же. Поэтому конвейер сборки, тесты и автоматическая выкладка идут первыми, а разделение - вторым.
Полезная практика - держать две версии рядом. Новая версия поднимается, получает малую долю трафика, показатели сравниваются с прежней, и только затем происходит полное переключение. Если что-то пошло не так, трафик возвращается на предыдущую версию одной командой. Такой порядок позволяет выпускать изменения каждый день без страха.
Схема канареечного выпуска выглядит так:
apiVersion: apps/v1
kind: Deployment
metadata:
name: catalog
spec:
replicas: 10
strategy:
rollingUpdate:
maxSurge: 2
maxUnavailable: 0
template:
metadata:
labels:
app: catalog
track: stable
Десять реплик, две дополнительные на время обновления, ни одна рабочая не гасится заранее. Трафик перетекает постепенно, метрики снимаются на каждом шаге, откат занимает секунды.
Миграция идёт постепенно через шов между старым и новым кодом
Переписать систему целиком за один квартал не получается почти ни у кого. Рабочий путь выглядит скучнее: сначала рядом с монолитом поднимается фасад, который принимает часть трафика, затем одна функция выделяется в сервис, потом вторая. Старый код продолжает жить и обслуживать остальные экраны.
Порядок шагов выглядит так:
- выделить одну функцию с понятной границей и измерить её текущую нагрузку;
- поднять сервис, который умеет работать параллельно с прежней реализацией;
- направить на новый код малую долю трафика и сравнить показатели с эталоном;
- перенести данные с двусторонней синхронизацией на время перехода;
- увеличивать долю трафика, пока старая ветка не перестанет получать запросы;
- удалить мёртвый код и общие таблицы.
Обратный путь должен оставаться открытым до конца. Пока переключатель позволяет вернуть трафик на старую реализацию за минуту, риск ограничен. Отключить его стоит только после того, как новая ветка отработала несколько недель без замечаний.
Организационная структура повторяет структуру системы
Закон, который стоит помнить: архитектура связи между сервисами копирует схему общения между командами. Если один отдел владеет и заказами, и учётом остатков, и оплатами, границы между сервисами будут размытыми независимо от того, как красиво нарисована схема. И наоборот, автономная команда естественным образом строит автономный сервис.
Отсюда требование к владению. У каждого сервиса есть команда, которая отвечает за него целиком: пишет код, дежурит, чинит и решает, когда обновлять зависимости. Сервис без владельца быстро превращается в общий, то есть ничей. Через полгода никто не решается его менять, и он становится тем же монолитом, только с сетевыми вызовами. Разумный размер владения - один сервис на небольшую команду или два-три небольших сервиса на команду поопытнее.
Выбор зависит от зрелости команды, а не от моды на распределённые системы
Микросервисы требуют инфраструктуры: автоматический деплой, сбор логов, трассировка, управление секретами, отдельные окружения для каждого сервиса. Без этого переход даст не свободу, а хаос. Команда из четырёх человек, которая вручную выкладывает код по пятницам, от разбиения на двадцать сервисов станет только медленнее.
У распределённой системы есть свои измеримые расходы. Каждый новый сервис - это отдельное окружение, отдельный конвейер сборки, отдельный набор секретов и отдельный график дежурств. Если считать честно, накладные расходы на один средний сервис оцениваются в несколько часов инженерного времени в неделю. Умножаем на двадцать сервисов и получаем почти целую ставку, занятую обслуживанием каркаса.
Сравнение удобно вести по четырём числам. Время от коммита до продакшена показывает скорость выпуска. Доля неудачных деплоев говорит о качестве процесса. Среднее время восстановления после сбоя отражает зрелость наблюдения. Число сервисов на одного инженера подсказывает, не расползлась ли система. Если после разделения первые три метрики не улучшились, переход был ошибкой, и её дешевле признать сразу.
Здравый компромисс для растущего проекта - модульный монолит. Код разделён на модули с чёткими интерфейсами, каждый модуль владеет своими данными, но живёт в одном процессе и деплоится одним артефактом. Когда модуль упирается в производительность или мешает релизам, его выносят наружу. Границы уже проведены, и выделение занимает дни, а не месяцы.
Перед стартом разделения стоит пройти короткую проверку. Есть ли автоматическая сборка и выкладка для каждого сервиса. Умеет ли команда собирать логи и трассировки в одном месте. Понятны ли границы хотя бы для первого выделяемого сервиса. Готова ли инфраструктура к нескольким окружениям одновременно. Существует ли план отката для каждого шага миграции. Назначены ли владельцы для новых сервисов. Если на большинство вопросов ответ отрицательный, начинать рано: модульный монолит с чёткими внутренними границами даст больше пользы и не потребует расходов на распределённую инфраструктуру.
Правильная постановка вопроса звучит так: что именно мешает сегодня - скорость сборки, частота релизов, стоимость масштабирования или границы команд. Ответ на него и определяет архитектуру. Мода на распределённые системы меняется, а необходимость понимать собственную систему остаётся.