Главная Веб-разработка Почему ваше приложение тормозит: архитектурные bottlenecks, которые никто не замечает

Почему ваше приложение тормозит: архитектурные bottlenecks, которые никто не замечает

от admin

Почему приложения тормозят даже на мощных серверах? Рассказываем, как находить и устранять архитектурные bottleneck’и: от GC-пауз и блокирующих операций до ошибок проектирования.

238 открытий224 показов

Ваше приложение тормозит, хотя сервер мощный, код вроде нормальный, а метрики — не очень-то и загружены? Возможно, вы столкнулись с архитектурным bottleneck’ом — скрытым ограничением, которое убивает производительность под нагрузкой. Вместе с Никитой Ульшиным, тимлидом команды разработки архитектурных инструментов в Т-Банк и автором тг-каналов Никита Ульшин про IT и ТехнофITнес | Никита Ульшин, разбираем типовые узкие места в системах, от неэффективной аллокации до однопоточных участков, и показываем пошаговый подход к их поиску и устранению.

Почему ваше приложение тормозит: архитектурные bottlenecks, которые никто не замечает

Никита Ульшин

Тимлид команды разработки архитектурных инструментов в Т-Банк

Почему «тормоза» — это не всегда про код

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

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

Как я хотел создать ИИ-ассистента, а в итоге развернул свой первый сервер с n8n, Docker и Nginxtproger.ru

Вот что тормозит, когда код ни при чём:

Архитектура

  • Цепочки вызовов: каждый хоп — +latency.
  • Синхронные зависимости: завис один сервис — тормозят все.
  • Общие ресурсы: shared state, глобальные очереди, блокировки.

Базы данных

  • Нет индексов или плохой план запроса.
  • N+1-запросы — особенно при неосторожной ORM.
  • Частый доступ к одним таблицам — гонка за IO и блоки.
  • Нет шардинга или репликации в масштабируемой нагрузке.

Сеть

  • Высокая задержка между регионами (особенно multi-cloud).
  • Потери пакетов, DNS-проблемы, нестабильность.
  • Отсутствие connection reuse: TLS-handshake на каждый вызов.

Очереди и брокеры

  • Один медленный потребитель тормозит всех.
  • Нет защиты от от backpressure или дедупликации.
  • Неправильные batch- и ack-настройки.

Аллокаторы и GC

  • Фрагментация памяти, нагрузка на GC.
  • Финалайзеры, слабые ссылки, плохо настроенный heap.

Конфигурации

  • Маленький пул соединений — пул заканчивается, запросы ждут.
  • Агрессивные retry-политики — система перегружается.
  • CPU throttling в Kubernetes — контейнеру не хватает ресурсов.

Сериализация и форматы

  • Перегруженные JSON/Protobuf.
  • Вложенные base64 + gzip + JSON — боли сериализации.

Почему bottlenecks остаются невидимыми до продакшена

Многие из них — не баги, а последствия архитектурных решений. В дев-среде всё летает, потому что:

  • мало данных,
  • нет конкуренции за ресурсы,
  • нет распределённой нагрузки.

В проде начинается хаос. Рассмотрим на конкретных примерах:

  • На деве — 2 запроса в секунду, в проде — миллион. Кто-то запустил маркетинговую рассылку, аналитик тащит big report, а клиент ретраит ошибки.
  • Архитектура «в лоб»: всё синхронно, каждый сервис вызывает по цепочке десяток других. Пока нагрузка мала — все живет. Как только одно звено не выдерживает — перестает жить.
  • Иллюзия масштабируемости: до 100 пользователей — всё окей, на 1000 — каскадная деградация.
  • «Невидимая боль» между сервисами: каждый по отдельности быстрый, но блокирует друг друга на базе.

Слои архитектуры и где в них зарыты проблемы

Когда приложение тормозит, мы часто копаемся в отдельных компонентах — фронте, бэке, базе. Но bottleneck может быть не внутри компонента, а между ними: в конфигурации, связях, сетевых задержках, очередях вызовов.

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

На уровне клиента

  • Тяжёлые бандлы. Огромный JS, куча аналитики, трекеры — и вот страница загружается 8 секунд на старом телефоне.
  • Чрезмерные запросы. После загрузки — 10 fetch в секунду. API захлёбывается, браузер — тоже.
  • Нет кеша. Даже статичные справочники каждый раз грузятся заново.
  • Лаги рендеринга. Бэкенд дал ответ за 100ms, но интерфейс лагает — сложные таблицы, графики, reflow.

На уровне API Gateway / BFF

  • Непрозрачная маршрутизация — скрытые таймауты, ошибки ретраев.
  • Сборка ответов из микросервисов — цепочка из 5 вызовов на каждый клиентский запрос. 
  • Синхронные вызовы без деградации — если один микросервис недоступен, падает весь endpoint.

На уровне Backend / бизнес-логики

  • Цепочки вызовов. Один сервис вызывает другой, тот — ещё один… И так далее.
  • Ограниченный пул соединений — сервис готов работать, но все worker-ы ждут ресурсы.
  • Логика в цикле по данным — N+1-запросы, сериализация, дублирование работы.

На уровне базы данных / кэша

  • Full table scan. Работает быстро на 1000 строках. Умирает на 10 миллионах.
  • Локи и гонка за ресурсы. Один UPDATE лочит строку, остальные ждут. 
  • Кэш не промахивается, но и не помогает — stale данные, повторные чтения, инвалидация не работают, как ожидалось.

На уровне внешних сервисов (API, брокеры, платёжки)

  • Без timeout и fallback. Внешний API залип — вы вместе с ним.
  • QPS-лимиты. Вы не знали, а вас уже зарейт-лимитили.
  • Нестабильная сеть. DNS тормозит, соединения рвутся, задержки скачут между регионами.

Оптимизация кода важна. Но если система тормозит, искать нужно по всей цепочке — от кнопки на фронте до брокера в соседнем дата-центре.

Есть архитектурные паттерны риска, которые часто приводят к тормозам через полгода. Во-первых, общий ресурс на всех: один Redis, один Kafka-топик, одна таблица. Пока нагрузка маленькая — ок. Потом начинается бойня за доступ. Во-вторых, микросервисный монолит: формально разделены, но живут как один. Масштабировать невозможно. В-третьих, цепочки вызовов: 5–6 сервисов на путь одного запроса. Один подвис — и вся цепочка тормозит.Никита УльшинТимлид команды разработки архитектурных инструментов в Т-Банк

Базы данных: когда даже SELECT тормозит всё

В бэкенде одна из самых коварных фраз — «ну это же просто SELECT, что с ним будет». Ответ — сначала ничего. Но со временем появляется 10 миллионов строк, и ваш innocuous SELECT превращается в full scan, который лочит диск, ест CPU и тормозит всё, что движется.

Типичная ситуация: в таблицу пишут JSONB без нормализации и индексов. Пока строк мало — жить можно. Но при росте объёмов каждый запрос превращается в медленное последовательное чтение. Это не просто «долго», а начинает мешать соседним транзакциям: подгружаются stale данные, вытесняется кэш, система начинает проседать вся — вплоть до API.

Отдельная ловушка — смешение OLTP и OLAP нагрузки. Днём — INSERT и UPDATE от клиентов, ночью — фоновая аналитика, высчитывающая отчёт за год с десятком JOIN и чтением всей таблицы. Если все запросы идут в один инстанс, происходит конкуренция за ресурсы: аналитика сбивает кеш, вызывает блокировки, а продакшн-метрики начинают сыпаться.

Как понять, что тормоза идут из базы, а не из бэкенда?

Вот несколько типичных признаков:

  • Бэкенд «висит» на запросах к базе — в логах видно, что приложение ждёт результат запроса (долгий await, query(), findMany() и т. д.).
  • Latency растёт, а CPU и память в норме — сервис сам по себе не перегружен, но ответ приходит медленно.
  • В базе фиксируются медленные запросы — например, SELECT или JOIN занимают секунды, хотя раньше проходили за миллисекунды.

Чтобы подтвердить гипотезу, можно:

  • Включить логирование SQL-запросов с таймингом;
  • Посмотреть трейс: если шаг DB заметно длиннее остальных — это тревожный сигнал;
  • Запустить EXPLAIN ANALYZE на типовые запросы и проверить, где зарыта сложность.

Архитектура доступа к данным: как закладываются тормоза

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

На что здесь стоит обратить внимание:

  • Количество и характер обращений. Один пользовательский запрос не должен порождать десятки обращений к базе. Планирование кэшей, агрегаций, prefetch, нормализация — всё это часть архитектуры.
  • Уровень абстракции над данными. ORM может быть удобным, но часто скрывает десятки неэффективных SELECT’ов. Нужно понимать, какие запросы реально идут в базу, и не превращать каждый use case в потенциальный bottleneck.
  • Стратегия хранения и агрегаций. Если вы пытаетесь на лету агрегировать 100 миллионов строк без шардинга и партиционирования — тормоза неизбежны. Архитектура должна учитывать нагрузку, частоту агрегаций, структуру данных.

Очереди и событийные системы: скрытые замедления

Событийные архитектуры и очереди вроде Kafka или RabbitMQ часто воспринимаются как универсальное средство от всех проблем: «Сделаем асинхронно — и всё полетит». Но это не серебряная пуля. Очередь не решает проблему — она её откладывает. А иногда сама становится bottleneck’ом.

Типовые ситуации, когда очередь тормозит систему:

  • Медленные консьюмеры. Очередь принимает сообщения с высокой скоростью, но обработчики не успевают, и начинается накопление беклога. В результате задержки накапливаются, SLA летит, а вы только недоумеваете, почему всё так тормозит.
  • Неправильный размер batch’ей. В Kafka и аналогах размер и частота batch’ей критичны. Слишком большие — ждём, пока соберётся партия. Слишком маленькие — тратим ресурсы на лишнюю загрузку и пересылку. В итоге получаем либо высокую задержку, либо низкую производительность.
  • Неравномерное партиционирование. Если сообщения распределяются по партициям неравномерно, то часть узлов будет простаивать, а часть захлёбываться под нагрузкой. Одна горячая партиция может стать bottleneck’ом всей системы.
  • Проблемы с retry. Если нет чёткой стратегии повторных попыток, логирования и DLQ (dead-letter queue), битое событие может крутиться в системе бесконечно, тормозя всё остальное. Также если что-то пошло не так и включились retry-механизмы, задержка может вырасти в разы. Особенно если есть backoff или дедлоки.

Почему «асинхронно» ≠ «мгновенно»

Асинхронность — это не про скорость, а про отложенность. Событие отправлено — но когда оно будет обработано, никто не знает. Через секунду, через минуту, через час?

Типичные причины задержек в асинхронной обработке:

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

Что происходит, когда очередь переполнена — и почему это тяжело заметить

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

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

Для защиты от переполнения очереди нужно мониторить несколько важных показателей:

  • Глубина очереди (в Kafka — consumer lag, в RabbitMQ — queue.messages_ready или queue.messages): показывает, сколько сообщений накопилось и ждёт обработки.
  • Publish rate: сколько сообщений в секунду публикуется.
  • Processing rate: сколько сообщений в секунду обрабатывается.
  • Утилизация ресурсов воркеров (CPU, memory, I/O).
  • End-to-end latency: сколько времени проходит от публикации события до его обработки.

Есть самые частые причины накопления сообщений:

1) Медленные потребители, которые не успевают обработать весь поток сообщений.
2) Шумная архитектура: 10 сообщений там, где можно было обойтись и одним.
3) Неправильное масштабирование брокера (например, неправильно выбранный ключ партиционирования в Kafka).Никита УльшинТимлид команды разработки архитектурных инструментов в Т-Банк

API и микросервисы: где тормозит взаимодействие

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

Читать также:
«Слежка за культурой», чат-боты в полиции и «отравление» кэша

Что тормозит в микросервисах?

Много лишних вызовов

Один пользовательский запрос вызывает лавину внутренних HTTP/gRPC-запросов. Каждый из них — это:

  • сетевые задержки (latency),
  • сериализация/десериализация данных,
  • ретраи при сбоях,
  • риск таймаутов и обрывов.

Если таких вызовов много, задержка накапливается каскадом.

Цепочка зависимостей

Классика жанра: заказ зависит от оплаты, оплата — от биллинга, биллинг — ещё от трёх микросервисов.

Если тормозит хотя бы один, сыплется всё. Получается эффект домино и каскадные отказы, где неполадка в одном звене рвёт всю цепочку.

Нет fallback’а и таймаутов

Если вызов к нужному сервису не отвечает, а у вас нет fallback’а или таймаута — всё встаёт. Особенно плохо, если вызов синхронный и блокирует поток.

Наносервисы

Иногда архитекторы увлекаются и дробят сервисы до абсурда. Начинается с «давайте переиспользуем», а заканчивается 10 API-вызовами ради одного действия.

Вместо бизнес-логики система начинает заниматься оркестрацией — координирует сама себя.

Кэш и CDN: почему «ускорители» тоже могут тормозить

Кэш традиционно считается палочкой-выручалочкой для производительности. «Добавим кэш — и всё полетит», — думают команды, особенно под нагрузкой. Но в реальности кэш может не только не ускорить, но и наоборот — замедлить или даже уронить систему, если использовать его без понимания.

Вот несколько типичных ошибок, которые приводят к деградации производительности:

Невалидные или слишком агрессивные стратегии

Если кэш живёт слишком долго — пользователи получают устаревшие данные. Если обновляется слишком часто — создаются лишняя нагрузка на базу и почти постоянные «промахи».

А если кэш сбрасывается при каждом изменении, он вообще не успевает выполнять свою роль. В итоге: нестабильное поведение, непредсказуемая производительность и раздражённые пользователи.

Перегрузка кэша

Когда кэш настолько загружен, что сам по себе начинает тормозить — это уже не помощь, а вред. Такое часто случается с Redis, Memcached или in-memory решениями, особенно если они:

  • работают на одной машине без шардинга;
  • содержат тяжёлые объекты;
  • используются как универсальный ответ на все проблемы.

В таких случаях кэш из «ускорителя» может падать под нагрузкой быстрее, чем база данных.

Кэш-миссы и лавины запросов

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

Вместо одного запроса вы получаете 10 000, и тормозит всё. Особенно критично это для систем с CDN, где внезапное истечение кэша популярных страниц может вызвать DDoS-подобную нагрузку.

Несогласованность кэшей

Если в системе несколько уровней кэширования (CDN, фронтовый кэш, Redis) и они обновляются по-разному, возникает несогласованность. Один пользователь видит новые данные, другой — старые. Один сервис работает с актуальной версией, другой — с устаревшей. Начинается охота за фантомами: баги вроде есть, но не у всех.

Вычисления и память: что не так с аллокацией

Почему мощные сервера с десятками ядер и гигабайтами памяти тормозят? Потому что производительность упирается не в «железо», а в то, как именно это железо используется. От плохого управления памятью до блокирующих операций — в системе может быть множество узких мест, которые незаметно душат throughput.

Частые и крупные аллокации

Каждый раз, когда создаётся новый объект, память выделяется из кучи. При интенсивной работе (например, при парсинге JSON или обработке больших структур) это может происходить слишком часто, и тогда запускается сборщик мусора (GC).

Сборка мусора — это не бесплатная операция: она останавливает выполнение программы. Частые GC-паузы создают «плавающую» производительность, когда всё работает быстро, но время от времени подвисает даже на секунду.

GC-паузы

Автоматическое управление памятью — благо, но оно же и ловушка. В языках вроде Go, Java или Python невозможно точно контролировать, когда сработает сборщик. Даже задержка в 50 миллисекунд может ощутимо ударить по RPS или нарушить real-time обработку.

Некоторые компании придумывают обходные пути. Так, в Twitch использовали технику memory ballast для Go-приложений, чтобы стабилизировать поведение GC и избежать всплесков пауз.

Синхронные и блокирующие операции

Если операции с файлами, сетью или базой данных выполняются синхронно, поток блокируется — и в это время не может обрабатывать другие задачи. Это особенно критично, если такой поток обслуживает пользовательские запросы.

Классический пример — однопоточный web-сервер, который просто ждёт ответ от другой системы. Или библиотека, использующая mutex.Lock() в горячем участке, из-за чего десятки горутин встают в очередь.

Ограниченные ресурсы и очереди

Даже в многопоточном приложении можно легко создать узкое место. Например:

  • доступ к объекту синхронизирован через mutex или RW-lock;
  • пул соединений имеет жёсткий лимит;
  • очередь задач не может масштабироваться под нагрузку.

В таких случаях нагрузка растёт, а пропускная способность — нет. Всё упирается в искусственное ограничение, введённое «на всякий случай».

Однопоточные горячие участки

Даже в многопоточной архитектуре может оказаться, что 80% времени тратится в одном месте, которое не масштабируется — например, в процессе сериализации, расчёта или шифрования. Иногда этот участок реализован однопоточно (особенно в Node.js, Python или старом Go-коде) и становится главным тормозом системы. Под высокой нагрузкой это становится особенно заметно.

Что делать: подход к поиску и устранению bottlenecks

Когда система тормозит, возникает соблазн сразу «что-то сделать» — добавить кэш, увеличить ресурсы, ускорить базу. Но без системного подхода можно попасть в замкнутый круг, где проблема будет возвращаться снова и снова. Чтобы избежать этого, лучше использовать пошаговую стратегию: зафиксировать симптомы, локализовать проблему, подтвердить и только потом оптимизировать.

Шаг 1. Зафиксировать симптомы и контекст

Важно понять, что именно «тормозит» и в каких условиях. Типовые вопросы:

  • Когда проявляется проблема: под нагрузкой, в пиковые часы, у конкретных пользователей?
  • Что именно работает медленно: API, отчёты, фоновая обработка?
  • Как это влияет на систему: задержки, таймауты, ошибки?

Пример: пользователи жалуются, что отчёты стали строиться дольше. Вчера — 5 секунд, сегодня — 20.

Шаг 2. Проверить очевидные метрики

Начинаем с того, что уже доступно в мониторинге:

  • латентность API (p50, p95, p99);
  • загрузка CPU, использование памяти и диска;
  • глубина очередей и backlog;
  • паузы GC;
  • кэш hit/miss ratio.

Если латентность растёт, а CPU загружен на 20%, это может указывать на проблемы в синхронных вызовах, аллокации или неэффективной работе кэша.

Шаг 3. Сузить область через трейсинг и логирование

Чтобы выяснить, не «тормозит» ли другой сервис, используйте распределённый трейсинг (Jaeger, OpenTelemetry, Sentry Performance) или логирование таймингов внутри запроса.

Пример (Go, обёртка для http.RoundTripper):

			func LoggingTransport(rt http.RoundTripper) http.RoundTripper { 	return roundTripperFunc(func(r *http.Request) (*http.Response, error) { 		start := time.Now() 		resp, err := rt.RoundTrip(r) 		log.Printf("HTTP to %s took %v", r.URL, time.Since(start)) 		return resp, err 	}) }  		

Так можно увидеть, что, например, сторонний сервис отвечает 3 секунды, или Redis стал отдавать ответ за 300 мс вместо обычных 3 мс.

Шаг 4. Воспроизвести нагрузку

Если проблема зависит от объёма данных, воспроизведите реальный сценарий или нагрузку:

  • используйте инструменты вроде k6, Locust, Artillery;
  • проверьте реальные условия (например, отчёт за 30 дней, а не за 3).

Если латентность растёт нелинейно, это может быть признаком N+1-запросов, неоптимального SQL или проблем с аллокацией.

Шаг 5. Подтвердить проблему профилированием

Если есть подозрение на перегрузку CPU или утечку памяти, используйте профилировщики:

  • Go — pprof;
  • Java — JFR, VisualVM;
  • Node.js — clinic.js, chrome://inspect.

Пример (Go, локальный pprof):

			import _ "net/http/pprof" go http.ListenAndServe("localhost:6060", nil)  		

Профиль покажет, где тратится CPU, как распределяются аллокации, как часто запускается GC и в каком коде.

Шаг 6. Оптимизировать, перепроверить и зафиксировать

После подтверждения проблемы можно переходить к исправлениям:

  • кэшировать внешний вызов;
  • заменить тяжёлую сериализацию (например, на easyjson в Go);
  • убрать блокирующую операцию;
  • перейти на batch-обработку.

После изменений обязательно перепроверьте метрики, чтобы убедиться, что оптимизация действительно дала эффект — и не добавила новых узких мест.

Итоги: как не попасть в архитектурную ловушку

Bottleneck’и возникают даже в самых технологичных проектах. Это не всегда следствие «плохого кода» — куда чаще они появляются из-за архитектурных просчётов: недооценили нагрузку, не предусмотрели масштабирование, сделали ставку на неправильное решение.

Главная проблема в том, что узкие места проявляются не сразу, а под нагрузкой. В разработке и тестах всё летает, потому что ресурсов хватает. А вот в проде — особенно при росте аудитории — на поверхность всплывает всё, что не выдерживает реального объёма данных или конкуренции за ресурсы.

Чтобы избежать таких проблем, важно не просто писать чистый код, а мыслить системно: проектировать архитектуру так, чтобы она могла жить под давлением. Вот несколько принципов, которые помогут.

Думайте об объёмах и росте

Архитектура должна выдерживать не только текущую нагрузку, но и разумный рост. Строить систему на «миллиард пользователей» с первого дня — избыточно. Но если вы рассчитываете, что пользователей станет в 5 раз больше через год — закладывайте это сейчас. Узкие места не любят сюрпризов.

Настраивайте метрики с первого дня

Без данных вы работаете вслепую. Любой анализ будет превращаться в гадание: виноват код или Redis, где тормозит — непонятно. С самого начала следите за ключевыми метриками: latency, очереди, GC, hit/miss в кэше, ошибки. Это не «доработка потом», а часть живой системы.

Проектируйте с учётом деградации

Любая система может начать тормозить — вопрос в том, как она себя при этом поведёт. Важно предусмотреть мягкую деградацию: таймауты, очереди, резервные сценарии, ограничение входящего трафика.

Не усложняйте архитектуру без повода

Микросервисы, шины, очереди, event-driven — всё это мощные инструменты. Но сложность должна быть оправданной. Простая, понятная архитектура с мониторингом и масштабируемыми точками зачастую выигрывает у перегруженной модульной схемы, которую никто не может отладить.

Bottleneck’и — это не баг, а симптом. И хороший архитектор — это не тот, кто устраняет тормоза, а тот, кто их не допускает.

Больше боли коддеров — тут.

Похожие статьи