Разъехались на несколько серверов и потеряли скорость: цена сетевых хопов
Решение разъехаться с одного сервера на несколько обычно принимают ради ускорения: каждый компонент получает свои ресурсы, не мешает соседям, масштабируется независимо. А через пару недель после переезда выясняется, что страницы стали открываться медленнее, чем на одном сервере, — при том что каждый отдельный компонент по метрикам вроде бы не перегружен. Разберём, почему так происходит: дело не в том, что разделение — плохая идея, а в том, что у него есть скрытая статья расходов, которую редко считают заранее.
Содержание
- Ожидание и реальность: почему после разделения бывает медленнее, а не быстрее
- Что физически происходит: вызов функции становится сетевым запросом
- Даже в пределах одного дата-центра: 0,2 мс — это не ноль, если их много
- Если серверы в разных дата-центрах или странах: цена растёт на порядки
- Chatty-архитектура: когда сетевые накладные расходы перевешивают изоляцию
- Практические паттерны: как вернуть скорость без отката разделения
Ожидание и реальность: почему после разделения бывает медленнее, а не быстрее
Логика в пользу разделения на первый взгляд безупречна. Пока приложение, база и фоновые задачи живут на одном сервере, они конкурируют за одну и ту же память, диск и процессор — и в момент пиковой нагрузки на один компонент страдают все остальные, даже если формально с ними всё в порядке. Разнести компоненты по отдельным серверам — значит убрать эту конкуренцию: у базы своя память под буферный пул, у приложения свои ядра CPU, у фоновых задач свой диск под очередь. С точки зрения изоляции ресурсов это действительно улучшение, и здесь ожидания не обманывают. О том, какой компонент обычно упирается в ресурсы первым и как это проверить по метрикам, а не по красивой схеме, — в статье про разделение сервера по нагрузке.
Проблема не в том, что изоляция не работает, а в том, что у неё есть цена, которую при планировании обычно не считают вовсе. Пока компоненты жили на одном сервере, обращение одного к другому — это вызов функции или обращение через localhost: практически бесплатная операция, не видная ни в одном профилировщике как отдельная строка расходов. После разделения ровно то же самое обращение — тот же по смыслу запрос «дай мне данные пользователя» или «запиши эту запись» — становится полноценным сетевым вызовом с TCP-соединением, сериализацией, передачей по проводу и десериализацией на другой стороне. Функция, которая раньше отрабатывала за микросекунды, теперь ждёт ответа с сети — и если таких обращений внутри одной операции много, эта скрытая статья расходов может перевесить весь выигрыш от изоляции, ради которого всё затевалось.
Что физически происходит: вызов функции становится сетевым запросом
Разница по порядку величины здесь принципиальная, и именно в ней корень проблемы. Вызов функции внутри одного процесса — это переход по адресу в памяти и работа со стеком вызовов, время исполнения измеряется наносекундами: процессор просто прыгает на другой участок кода, физического перемещения данных за пределы кристалла не происходит. Обращение к другому процессу на том же сервере через localhost (например, к локальному Redis или Unix-сокету соседнего сервиса) — это уже проход через сетевой стек ядра, но без физической среды передачи: доли миллисекунды, на порядки медленнее вызова функции, но всё ещё почти незаметно на фоне остальной обработки запроса.
После того как компонент переехал на другой физический сервер, та же операция — это уже настоящий сетевой запрос: пакет покидает сервер, идёт через сетевое оборудование до другого сервера, поднимается там по стеку до принимающего процесса, и путь повторяется в обратную сторону для ответа. Это TCP-соединение (или переиспользуемое, но всё равно физическое), сериализация и десериализация на обеих сторонах и, самое главное, RTT (round-trip time) — время самой дороги туда и обратно, которое никогда не равно нулю.
Три уровня для одной и той же логической операции «получить данные» выглядят по порядку величины примерно так:
| Способ обращения | Порядок задержки | Что физически происходит |
|---|---|---|
| Вызов функции в одном процессе | наносекунды | переход по адресу в памяти |
| Обращение через localhost к соседнему процессу | доли миллисекунды | проход через сетевой стек ядра без внешней среды |
| Сетевой запрос к серверу в том же дата-центре | доли миллисекунды, но выше localhost | коммутатор, кабель, обработка на сетевых картах обеих сторон |
| Сетевой запрос к серверу в другом дата-центре или стране | единицы–десятки миллисекунд и больше | физическое распространение сигнала на расстояние плюс маршрут |
Конкретные цифры для вашей пары серверов нужно измерять ping или mtr, а не брать из таблицы — важен порядок разницы между строками, а не точное число. И этот порядок — не пара процентов, а несколько десятичных порядков между верхней и нижней строкой.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать VPSДаже в пределах одного дата-центра: 0,2 мс — это не ноль, если их много
Часто разделение делают в пределах одного дата-центра или даже одной облачной зоны — и интуитивно кажется, что раз серверы физически рядом, разница с localhost должна быть незаметной. Отчасти это так: задержка между двумя серверами в соседних стойках одного ДЦ на порядки меньше, чем между разными странами, — это доли миллисекунды на round trip, а не десятки. Механика этой цифры — в статье про 0,2 мс внутри дата-центра.
Но «доли миллисекунды» — это всё ещё не ноль, и разница с вызовом функции внутри процесса — это несколько порядков, даже если оба сервера стоят в одной стойке. Пока обращение к соседнему компоненту происходило единожды за запрос, эта разница была неощутима. Проблема начинается, когда операция, которая раньше была одним вызовом функции внутри процесса, после разделения оказывается сетевым запросом, повторяющимся десятки раз за одну пользовательскую операцию — классический N+1 к разделённому сервису вместо N+1 к локальному объекту в памяти. Тридцать обращений по доли миллисекунды каждое внутри одного ДЦ — это уже не «незаметно», а вполне измеримые единицы миллисекунд, добавленные туда, где раньше не было ничего.
Отдельно стоит учитывать не только ожидание ответа, но и накладные расходы на сам факт сетевого обращения: установка соединения (если не переиспользуется через пул), сериализация и десериализация на каждой стороне, обработка в очереди сетевого стека. Эти расходы есть даже при нулевой физической задержке и добавляются к RTT, а не заменяют его.
Если серверы в разных дата-центрах или странах: цена растёт на порядки
Ситуация становится заметно хуже, если разделённые компоненты оказались не на соседних серверах одного ДЦ, а в разных дата-центрах или странах — например, фронтенд оставили где был, а базу или сервис авторизации перенесли в другой регион ради отказоустойчивости или по историческим причинам. Здесь задержка одного обращения уже не доли миллисекунды, а единицы-десятки миллисекунд на round trip, и конкретное значение сильно зависит от расстояния и маршрута — подробный разбор именно этого случая, с примером страницы профиля, делающей больше десятка последовательных запросов к базе в другой стране, — в статье про фронт и базу в разных странах.
Арифметика здесь неприятная именно потому, что задержка не добавляется один раз «за факт переезда», а умножается на число сетевых обращений внутри одной операции. Если раньше обработчик запроса делал пятнадцать обращений к данным, которые физически лежали в той же памяти или на соседнем сервере того же ДЦ, а теперь часть из них уходит в другой регион, — суммарная задержка операции может вырасти не на проценты, а в разы, просто потому что каждое из этих пятнадцати обращений теперь стоит на два-три порядка дороже. Более мощное железо на обеих сторонах эту статью расходов не лечит: она не про вычисления, она про физическое время в пути.
Chatty-архитектура: когда сетевые накладные расходы перевешивают изоляцию
«Chatty» (в переводе с английского — «болтливая») — так называют архитектуру, в которой компоненты часто и много общаются друг с другом синхронно в рамках обработки одного запроса. Пока такая архитектура жила в границах одного процесса или сервера, её болтливость была почти бесплатной: десятки внутренних вызовов не создавали заметной задержки, даже если структурно это была не самая чистая архитектура. Разделение серверов эту болтливость не устраняет — оно переносит её из бесплатной зоны в платную, и чем больше было внутренних обращений между теперь-разделёнными компонентами, тем дороже каждая операция.
Именно здесь чаще всего и обманываются ожидания. Выигрыш от изоляции ресурсов — вещь реальная, но постоянная и предсказуемая: база больше не соревнуется с приложением за память, у каждого компонента свой потолок производительности. Проигрыш от сетевых накладных расходов масштабируется вместе с числом межкомпонентных вызовов и потому может расти быстрее, чем ожидалось на этапе планирования. Для компонента, который обращается к соседу один-два раза за операцию, сетевой оверхед почти всегда меньше выигрыша от изоляции. Для компонента, который обращается к соседу десятки раз за операцию, — потому что архитектура была спроектирована как единая система с общей памятью, а не как набор независимых сервисов, — накопленная сетевая задержка вполне может превысить весь выигрыш от разделения, и тогда система становится медленнее, а не быстрее.
Определить, какой у вас случай, на глаз сложно — распределённая архитектура обрастает связями постепенно, и никто в команде обычно не держит в голове точное число обращений между двумя конкретными компонентами. Надёжный способ — посчитать: включить трассировку (или хотя бы простое логирование с меткой времени на входе и выходе каждого обращения к разделённому компоненту) и посмотреть на реальное число сетевых вызовов на типичную операцию, а не полагаться на интуицию о том, как система была спроектирована изначально.
Практические паттерны: как вернуть скорость без отката разделения
Разделение серверов почти никогда не нужно откатывать целиком — обычно достаточно снизить цену межсерверных обращений там, где она оказалась выше ожидаемой. Несколько рабочих приёмов, которые не требуют пересобирать архитектуру с нуля.
Батчинг и агрегация запросов вместо множества мелких. Если код после разделения делает цикл из отдельных запросов к соседнему сервису — сначала список идентификаторов, потом в цикле по каждому отдельный запрос за деталями, — почти всегда можно заменить это одним запросом с пачкой идентификаторов:
# Было: N отдельных сетевых запросов в цикле
for user_id in user_ids:
profile = user_service.get_profile(user_id) # отдельный RTT на каждой итерации
# Стало: один запрос с пачкой id
profiles = user_service.get_profiles_batch(user_ids) # один RTT на всю пачку
Тот же принцип работает и для записи: вместо цикла из отдельных вызовов «создать позицию заказа» — один вызов, принимающий список позиций целиком. Если разделённый сервис отдаёт данные по HTTP, стоит явно спроектировать batch-эндпоинт (POST /profiles/batch с массивом id в теле), а не рассчитывать, что клиент сам догадается не дёргать сервис в цикле.
Кеш для часто запрашиваемых, редко меняющихся данных. Если один и тот же разделённый сервис на разных запросах спрашивают об одном и том же — права доступа пользователя, справочник конфигурации, курс валюты, — стоит кешировать ответ рядом с вызывающей стороной (in-memory кеш в процессе или локальный Redis) вместо того, чтобы платить RTT за каждое обращение. Кеш убирает сетевой оверхед из горячего пути для большинства запросов, оставляя обращение к источнику только на промах кеша или по TTL. Плата за это — риск отдать чуть устаревшие данные и сложность инвалидации; для персонализированных данных (текущий баланс, статус заказа прямо сейчас) кеширование в лоб может быть неприменимо, там нужен честный ответ от источника.
Агрегирующий слой на границе. Если клиент вынужден делать несколько последовательных запросов к разным разделённым сервисам, чтобы собрать один экран, — стоит поставить между ними тонкий агрегирующий сервис (паттерн BFF, backend-for-frontend), который сам делает эти обращения параллельно на своей стороне, ближе к сервисам, и отдаёт клиенту один собранный ответ. Это не убирает RTT между агрегатором и каждым сервисом, но убирает умножение задержки на число прыжков для конечного клиента.
Пересмотр границ разделения. Иногда после подсчёта реальной цены обращений выясняется, что конкретные два компонента взаимодействуют настолько тесно и часто, что разделять их вообще не стоило — не потому что разделение как принцип неверно, а потому что для этой пары выигрыш от изоляции меньше цены сетевого обмена между ними. Это нормальный результат пересмотра, а не признание ошибки: границы разделения стоит проводить там, где взаимодействие редкое или допускает батчинг, а не там, где это выглядело логично на диаграмме. Компоненты с интенсивным синхронным обменом лучше оставить на одном сервере (или хотя бы в одном ДЦ с минимальным RTT), а разносить географически — то, что общается редко, асинхронно или пакетно.
Явная асинхронность там, где логика это позволяет. Не каждое обращение между разделёнными компонентами обязано быть синхронным просто потому, что раньше, в рамках одного процесса, оно таким было по умолчанию. Стоит пройтись по цепочке вызовов и для каждого перехода спросить: обязана ли вызывающая сторона дождаться ответа именно сейчас, или результат можно получить через очередь, отложенную обработку или отдельный запрос с фронтенда. Часто выясняется, что часть «синхронных» обращений синхронна просто по инерции от старого монолитного кода.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать VPSНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Значит ли это, что разделять сервер на несколько вообще не стоит?
Нет, изоляция ресурсов и независимое масштабирование — реальные выгоды, особенно когда один компонент растёт заметно быстрее остальных. Речь не про отказ от разделения, а про то, что у него есть вторая статья расходов — сетевая, — которую нужно посчитать заранее, а не обнаружить постфактум.
Можно ли заранее оценить, окупится ли разделение по скорости?
Приблизительно да: посчитайте, сколько раз за типичную операцию компоненты, которые собираетесь разделить, сейчас обращаются друг к другу (логированием или профилировщиком), и умножьте на измеренный RTT между будущими серверами (ping/mtr на тестовом окружении). Если сумма заметна на фоне текущего времени ответа — сначала сократите число обращений паттернами выше, а потом уже разъезжайтесь.
Поможет ли более быстрый канал или более мощные серверы?
Пропускная способность почти ни при чём — межсервисные запросы обычно небольшие по объёму, и узкое место не в мегабитах в секунду, а в задержке round trip, которая определяется расстоянием и маршрутом. Более мощные серверы ускорят обработку запроса на каждой стороне, но не уберут время в пути между ними.
Как понять, что у нас chatty-взаимодействие между уже разделёнными серверами?
Включить распределённую трассировку или хотя бы логирование с метками времени на входе и выходе каждого межсерверного обращения и посмотреть на реальное дерево вызовов типичной операции. Больше пяти-десяти последовательных сетевых обращений к разделённым компонентам — кандидат на батчинг, кеш или пересмотр границ.
Что делать, если разделение оказалось ошибкой именно для этой пары компонентов?
Свести их обратно на один сервер — рабочий, а не позорный вариант, если цена сетевого обмена между ними выше пользы от изоляции. Разделять стоит по фактической конкуренции за ресурсы и фактической частоте взаимодействия, а не по тому, что выглядело логично на схеме до появления реальных метрик.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →