Почему приложения тормозят даже на мощных серверах? Рассказываем, как находить и устранять архитектурные bottleneck’и: от GC-пауз и блокирующих операций до ошибок проектирования.
238 открытий224 показов
Ваше приложение тормозит, хотя сервер мощный, код вроде нормальный, а метрики — не очень-то и загружены? Возможно, вы столкнулись с архитектурным bottleneck’ом — скрытым ограничением, которое убивает производительность под нагрузкой. Вместе с Никитой Ульшиным, тимлидом команды разработки архитектурных инструментов в Т-Банк и автором тг-каналов Никита Ульшин про IT и ТехнофITнес | Никита Ульшин, разбираем типовые узкие места в системах, от неэффективной аллокации до однопоточных участков, и показываем пошаговый подход к их поиску и устранению.
Никита Ульшин
Тимлид команды разработки архитектурных инструментов в Т-Банк
Почему «тормоза» — это не всегда про код
Когда пользователь жалуется на «медленное приложение», большинство разработчиков по привычке лезет в код. Профилируем, ищем неэффективные циклы, оптимизируем до запятых. Но в реальности причина часто не в логике, а в том, что её окружает.
Современное приложение — это сложный организм: десятки микросервисов, базы данных, кэши, брокеры, сети. И тормозить может любой из этих компонентов. А ты в это время профилируешь ни в чём не виноватый 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’и — это не баг, а симптом. И хороший архитектор — это не тот, кто устраняет тормоза, а тот, кто их не допускает.
Больше боли коддеров — тут.