awesome-everything EN
↑ Обратно к восхождению

Архитектура бэкенда

Проследи один запрос: каждый юнит на одном пути кода

Суть Проведи один запрос через стек — и семь юнитов встают слоями: lifecycle принимает и парсит, middleware и DI подключают зависимости, async I/O и пул несут даунстрим-вызов, breaker его охраняет, ключ идемпотентности делает запись безопасной для ретрая, а shutdown ждёт её.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на middle-высоте — в небе
◷ 16 min

Чистейший способ увидеть семь механизмов как одну систему — перестать говорить о них абстрактно и проследить один запрос весь путь вниз. Возьми конкретный: POST /payments, списать с карты, записать результат. Этот запрос не посещает один юнит — он касается всех их, по порядку, на одном пути кода. Он приходит на сокет, что юнит lifecycle научил тебя принимать и парсить. Он проходит через цепочку middleware, что аутентифицирует его и стартует таймаут, с зависимостями, поданными DI-контейнером. Обработчик бежит на event loop, что не должен блокироваться, так что вызов БД асинхронен; этот вызов одалживает соединение из пула; соединение пула говорит с платёжным провайдером через circuit breaker; запись, что он делает, охранена ключом идемпотентности, так что ретрай не может списать дважды. И под всем этим обработчик graceful shutdown ждёт — если SIGTERM приземлится, пока этот запрос в полёте, каждый слой, что он коснулся, должен размотаться в правильном порядке. Один запрос, семь юнитов, один непрерывный путь. Проследи его раз — и тезис «система, не стопка» из прошлого урока перестаёт быть лозунгом и становится вещью, на которую можно показать пальцем.

Счастливый путь, слой за слоем

Проследи POST /payments от сокета до ответа — и юниты предстают как стопка слоёв, каждый передаёт следующему:

  1. Приём и парсинг (lifecycle). Сервер принимает соединение, читает запрос с сокета, парсит заголовки и тело. Backpressure и лимиты размера тела живут здесь — кривое или раздутое тело отвергается до того, как стоит хоть что-то даунстриму.
  2. Middleware и DI. Запрос проходит через цепочку: логирование, auth, rate-limit и, что критично, место, где бюджет таймаута ставится на весь запрос. DI-контейнер подаёт обработчику его зависимости — платёжный клиент, репозиторий — уже сконструированные и заскоупленные под этот запрос.
  3. Обработчик на event loop (async I/O). Обработчик бежит, не блокируя loop. Каждый I/O-вызов await’ится, так что пока этот запрос ждёт БД или провайдера, тот же loop обслуживает тысячи других запросов.
  4. Одолжить соединение (пул). Запись в БД нуждается в соединении, так что обработчик берёт одно из пула — с таймаутом получения, так что насыщенный пул падает быстро, а не висит вечно.
  5. Вызов провайдера через breaker. Списание идёт к внешнему платёжному провайдеру, обёрнутое в circuit breaker и его собственный таймаут. Если провайдер падает, breaker коротит и обработчик берёт свой фолбэк, а не наваливает сверху.
  6. Записать идемпотентно. Результат персистится под ключом идемпотентности, так что если клиент переретраит тот же POST, сервер вернёт исходный результат, а не спишет дважды.
  7. Ответить, затем shutdown ждёт. Ответ пишется обратно. Если SIGTERM пришёл посреди списания, обработчик shutdown держал дверь: он перестал принимать новые запросы, держал этот живым и снесёт пул и loop только когда этот запрос ответит.

Бюджет таймаута пронизывает всё это

Единая нить, что связывает эти слои, — дедлайн. Таймаут, поставленный в middleware (шаг 2), не только для обработчика — он должен быть разделён между всем даунстримом. Таймаут получения из пула, таймаут вызова провайдера и таймаут записи в БД все должны влезть внутрь общего бюджета запроса, с запасом. Если бюджет запроса 3 секунды, нельзя дать вызову провайдера 3-секундный таймаут, потому что тогда медленное получение плюс медленная запись пролетают дедлайн без времени ответить. Это урок про хвост латентности из юнита lifecycle, и урок про таймаут получения из юнита пулинга, и урок про таймаут вызова из юнита breaker, все примирённые на одном запросе: бюджеты вложены, не независимы, и должны суммироваться меньше целого.

Один путь, один порядок

Заметь, порядок вынужден, не произволен. Ты парсишь до маршрутизации, получаешь соединение до его использования, ставишь breaker вокруг вызова, что он охраняет, и пишешь идемпотентно до ответа. И shutdown разматывает этот же граф в обратную — перестать принимать, дренировать в полёте, затем закрыть пул последним — что ровно правило обратного порядка зависимостей из юнита graceful shutdown. Путь запроса и путь shutdown — один и тот же граф зависимостей, прочитанный в противоположных направлениях.

Почему это работает

Почему бюджет таймаута должен делиться вниз по стеку, а не просто ставиться один щедрый таймаут на весь запрос, давая каждому вызову брать сколько нужно? Потому что один внешний таймаут говорит тебе, что запрос был слишком медленным, но даёт каждому внутреннему слою разрешение съесть весь бюджет в одиночку, что делает сбой и более поздним, и менее диагностируемым. Если у запроса 3 секунды, а у вызова провайдера нет своего таймаута, зависший провайдер держит соединение, слот loop и сам запрос все полные 3 секунды до срабатывания внешнего дедлайна — и в течение этих 3 секунд то пуловое соединение недоступно всем остальным, так что одна медленная зависимость тихо осушает пул. Вложенные бюджеты чинят обе проблемы: дай получению из пула несколько сотен миллисекунд, вызову провайдера секунду-другую, записи свой ломоть, каждому с запасом, так что самая внутренняя медленная вещь падает первой, быстро и конкретно, освобождая свои ресурсы обратно системе, пока ещё есть время взять фолбэк и ответить внутри внешнего дедлайна. Вот почему юниты lifecycle, пулинга и breaker каждый настаивал на таймауте со своего угла — все они описывали один общий бюджет, видимый с разных слоёв. Более глубокая идея в том, что таймаут — не страховочная сетка, что ставишь раз наверху; это контракт освобождения ресурсов, обеспеченный на каждом слое, и контракт держится, лишь если внутренние дедлайны строго меньше внешнего. Ошибись с вложением — и каждый другой механизм деградирует: пул не может защитить себя, breaker не видит чистого таймаута для счёта, а shutdown не может предсказать, сколько займёт дренаж.

ШагСлойЮнитСтраж на этом шаге
Приём и парсингLifecycle01Лимит размера тела, backpressure
Auth, ставим дедлайнMiddleware / DI02Бюджет таймаута запроса
Бежит обработчикEvent loop03Никогда не блокировать loop
Одолжить соединениеПул04Таймаут получения
Вызов провайдераBreaker06Breaker + таймаут вызова
Персистить результатИдемпотентность05Ключ идемпотентности
Ответить / размотатьShutdown07Дренаж в полёте, закрыть пул последним
Викторина

У запроса POST /payments общий бюджет 3 секунды, поставленный в middleware. Вызову провайдера дан свой 3-секундный таймаут без меньших внутренних дедлайнов. Что идёт не так?

Викторина

Как путь запроса связан с путём разборки при graceful shutdown?

Расставь шаги по порядку

Расставь один запрос POST /payments, спускающийся по стеку:

  1. 1 Принять соединение и распарсить тело (lifecycle), обеспечивая лимиты размера
  2. 2 Прогнать middleware: auth, затем поставить общий бюджет таймаута запроса; DI подаёт зависимости
  3. 3 Получить пуловое соединение с таймаутом получения внутри бюджета
  4. 4 Вызвать провайдера через circuit breaker, затем персистить результат под ключом идемпотентности
Вспомните перед уходом
  1. 01
    Проведи POST /payments вниз по стеку и назови стража на каждом слое.
  2. 02
    Почему бюджет таймаута должен быть вложен вниз по стеку и как это связывает юниты lifecycle, пулинга и breaker?
Итог

Чтобы увидеть систему вместо стопки, ты следуешь за одним запросом — POST /payments — от сокета до ответа, и семь юнитов встают одним упорядоченным путём: приём и парсинг, middleware и DI ставят дедлайн и подключают зависимости, обработчик бежит асинхронно на loop, он одалживает пуловое соединение, вызывает провайдера через breaker, пишет под ключом идемпотентности, отвечает, а обработчик shutdown ждёт под всем этим всё время. Дедлайн — нить, что сшивает слои: бюджет запроса, поставленный в middleware, должен делиться так, чтобы каждый внутренний таймаут — получение, вызов провайдера, запись — влез внутрь него с запасом, вложенный, а не независимый, так что самая внутренняя медленная вещь падает первой и освобождает свои ресурсы вовремя для ответа. Порядок вынужден, и shutdown разматывает ровно тот же граф зависимостей в обратную — перестать принимать, дренировать в полёте, закрыть пул последним — так что путь запроса и путь разборки — один граф, прочитанный в обе стороны. Мы теперь видели все семь кооперирующимися на счастливом пути; следующий урок поднимает давление и смотрит, как они падают вместе, где корректное поведение одного механизма становится плохим входом следующего.

Связанные уроки
Продолжить восхождение ↑Когда сбои складываются: каскад, что ни один юнит не мог показать
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.