MAATRIX / Блог / Фронт в Европе, база в России: сколько такая схема стоит в миллисекундах на запрос

Фронт в Европе, база в России: сколько такая схема стоит в миллисекундах на запрос

MAATRIX

Полгода назад фронтенд вынесли в Германию или Нидерланды — ради части аудитории, юрисдикции или просто потому, что там оказался удобный провайдер. База данных как стояла в России, так и осталась: переносить её не было ни повода, ни времени. Сайт формально работает, но страницы, где на экране собирается больше одной сущности — профиль с заказами, каталог с остатками, дашборд с несколькими виджетами — ощутимо тормозят, и никто толком не может объяснить почему: канал до пользователя в порядке, сервер не перегружен, а ответ всё равно приходит с заметной задержкой. Разберём механику этой задержки — почему она не складывается один раз за запрос пользователя, а умножается на число обращений к базе внутри одной операции — и что с этим реально можно сделать, не переписывая систему с нуля.

Почему задержка не складывается, а умножается

RTT (round-trip time) — это время на путь запроса от сервера приложения до базы данных и обратно. Величина RTT зависит от физического расстояния и маршрута: внутри одного дата-центра, где сервер приложения и база стоят в соседних стойках, RTT измеряется долями миллисекунды — подробно из чего он там складывается, разобрано в статье про задержку между двумя серверами в одной стойке. Между разными странами RTT — это уже не доли миллисекунды, а единицы-десятки миллисекунд, и конкретное значение сильно зависит от пары точек и маршрута — стоит измерить ping или mtr до фактического хоста базы, а не полагаться на усреднённые ориентиры.

Ключевая ошибка в оценке цены схемы «фронт в одной стране, база в другой» — считать, что задержка добавляется один раз за пользовательский запрос. Это не так. Задержка добавляется за каждое сетевое обращение к базе, которое делает код приложения при обработке этого запроса. Если обработчик одной HTTP-ручки делает пять последовательных SQL-запросов — потому что сначала нужно получить пользователя, потом его права, потом список заказов, потом для каждого заказа его позиции — RTT платится пять раз подряд, а не один. Запросы внутри одной транзакции или одного обработчика почти всегда выполняются последовательно: следующий запрос зависит от результата предыдущего (нужно id пользователя, чтобы запросить его заказы), поэтому распараллелить их обычно нельзя, и RTT-стоимость складывается линейно.

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

Практический пример: что происходит на одной «обычной» странице

Возьмём типичный кейс — страницу профиля пользователя с последними заказами. Чтобы её собрать, обработчик может (цифры ниже — иллюстрация конкретного случая, а не универсальный норматив) сделать что-то вроде:

SELECT * FROM users WHERE id = $1;                    -- 1 запрос
SELECT * FROM user_roles WHERE user_id = $1;           -- 1 запрос
SELECT * FROM orders WHERE user_id = $1 ORDER BY created_at DESC LIMIT 10;  -- 1 запрос
-- а дальше для каждого из 10 заказов отдельно:
SELECT * FROM order_items WHERE order_id = $1;         -- N запросов (классический N+1)

Это 3 + 10 = 13 последовательных обращений к базе на одну загрузку страницы — обычная ситуация для ORM с ленивой подгрузкой связей, если её не контролировать. Если RTT между фронтендом и базой измеряется единицами-десятками миллисекунд (напомним: конкретное значение для вашей пары стран стоит измерить, а не брать из головы), 13 последовательных обращений добавляют к времени ответа совокупный сетевой оверхед, кратный этому RTT — и это только сетевая часть, до времени выполнения самих запросов на стороне базы и до логики приложения. При той же архитектуре, но с базой в том же дата-центре, что и фронтенд, эти 13 обращений почти не были бы заметны на общем фоне.

Важный нюанс: connection pooling (pgbouncer, встроенный пул драйвера) здесь не помогает. Пул убирает накладные расходы на установку нового TCP-соединения и TLS-хендшейк, но не убирает RTT самого запроса — SQL всё равно должен долететь до базы и ответ должен вернуться по уже установленному, но всё равно физически удалённому соединению.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Запись — отдельная история: цена консистентности

Для операций чтения цена схемы — это чистый сетевой RTT, умноженный на число запросов. Для операций записи, если есть требования к консистентности (например, синхронная репликация в удалённый регион, чтобы не терять транзакции при отказе мастера), цена выше — она может включать не только RTT самого запроса от приложения к базе, но и RTT (или больше) на подтверждение записи от реплики, прежде чем транзакция будет считаться закоммиченной. Механика того, почему синхронная запись в реплику в другой стране стоит именно так дорого и не лечится более быстрым диском или более мощным сервером, подробно разобрана в статье про реплику базы в другой стране — там же объяснено, почему это ограничение физическое (скорость света плюс маршрут), а не следствие плохой настройки.

Если операция записи состоит из нескольких отдельных INSERT или UPDATE — например, создание заказа отдельным запросом, а потом в цикле создание каждой позиции отдельным INSERT — цена умножается так же, как и при чтении, только сверху на каждую операцию может накладываться ещё и ожидание подтверждения от синхронной реплики. Страница «оформить заказ» с 10 позициями и синхронной репликацией в другую страну по сетевой составляющей может быть на порядок медленнее, чем страница просмотра каталога с тем же числом обращений к базе, но без требований синхронной записи.

Как увидеть цену в профиле, а не гадать

Прежде чем что-то оптимизировать, стоит измерить, сколько именно обращений к базе делает конкретная ручка и сколько времени они совокупно съедают. Несколько практических способов:

  • Лог медленных запросов и статистика. В PostgreSQL — расширение pg_stat_statements, которое показывает число вызовов (calls) и суммарное время (total_exec_time) на каждый уникальный запрос:
SELECT query, calls, total_exec_time, mean_exec_time
FROM pg_stat_statements
ORDER BY calls DESC
LIMIT 20;

Большое число calls для запроса, который логически должен выполняться один раз за операцию, — верный признак N+1.

  • Трассировка на стороне приложения. APM-инструменты (OpenTelemetry-совместимые трейсеры, встроенные в фреймворк профилировщики) показывают waterfall-диаграмму одного HTTP-запроса: сколько отдельных обращений к базе он породил, в каком порядке и с какими промежутками между ними — именно там обычно видно, что основная часть времени уходит не на выполнение запросов на стороне базы, а на ожидание сетевых round-trip.
  • TTFB как индикатор. Если TTFB (время до первого байта ответа) значительно больше, чем сетевой RTT до пользователя, и при этом сама база не нагружена — стоит разложить TTFB по слоям (сеть до пользователя, очередь приложения, обращения к базе, внешние API) и посмотреть, какая доля приходится именно на обращения к базе.

Практическая прикидка без разворачивания APM: посчитайте по логам среднее число запросов к базе на типичную «тяжёлую» ручку (через pg_stat_statements или лог с log_min_duration_statement = 0 на тестовом окружении), умножьте на измеренный ping/mtr RTT между фронтендом и базой — получите грубую оценку сетевого налога на эту ручку. Дальше сравните с тем, сколько времени ручка реально добавляет по метрикам — если цифры близки, вы нашли причину.

Смягчение №1 — меньше обращений к базе за один запрос

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

JOIN вместо цепочки SELECT. Вместо отдельного запроса пользователя, отдельного запроса его заказов и отдельного запроса по каждому заказу — один запрос с JOIN, который база выполнит целиком на своей стороне за один сетевой round-trip:

SELECT o.*, oi.*
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.user_id = $1
ORDER BY o.created_at DESC
LIMIT 10;

Нюанс: JOIN — не универсальное решение, у него своя цена на стороне базы (декартово произведение при неудачном плане, дублирование родительских полей в каждой строке результата), и не любой N+1 сводится к одному JOIN без потери читаемости кода. О том, как база выбирает конкретный способ выполнения JOIN и почему план может оказаться не тем, что вы ожидали, — в статье про три способа выполнить JOIN.

Батчинг вместо цикла. Если JOIN неудобен (например, нужно подгрузить заказы для 50 разных пользователей на одной странице списка), вместо 50 отдельных запросов — один запрос с IN:

SELECT * FROM orders WHERE user_id = ANY($1::int[]);

Это тот же принцип, что реализуют библиотеки вроде DataLoader в GraphQL-стеке: вместо немедленного запроса на каждый вызов «дай заказы для user_id=X» они на протяжении одного тика события собирают все запрошенные id в пачку и делают один запрос с IN (...) вместо N отдельных.

Множественная вставка вместо цикла INSERT. При записи то же самое: вместо цикла из отдельных INSERT INTO order_items VALUES (...) на каждую позицию заказа — один запрос с несколькими наборами значений:

INSERT INTO order_items (order_id, product_id, qty)
VALUES ($1,$2,$3), ($1,$4,$5), ($1,$6,$7);

Один RTT вместо N — и для записи в схеме с удалённой синхронной репликой эта экономия ощущается сильнее всего, потому что там к RTT самого запроса добавляется ещё и RTT подтверждения от реплики.

Ревизия ORM. Большинство ORM (Django ORM, SQLAlchemy, Eloquent, Prisma) умеют явно указывать eager loading — подгрузку связанных сущностей одним запросом вместо ленивой подгрузки на каждое обращение к связи в цикле шаблона. Стоит проверить select_related/prefetch_related (Django) или joinedload/selectinload (SQLAlchemy) прежде, чем оптимизировать что-либо ещё.

Смягчение №2 — кеш ближе к фронтенду, и когда пересматривать архитектуру

Если данные часто читаются, но редко меняются — каталог товаров, справочники, права доступа, конфигурация — их стоит кешировать физически рядом с фронтендом, а не запрашивать у удалённой базы на каждый пользовательский запрос. Это может быть in-memory кеш в самом процессе приложения, Redis, поднятый в том же регионе, что и фронтенд, или кеш на границе (edge/CDN) для тех ответов API, которые можно отдавать одинаково разным пользователям. О том, что реально можно кешировать на границе, а что придётся всё равно везти с сервера из-за персонализации или требований к свежести данных, — в статье про кеширование на границе.

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

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

Честная рекомендация, если схема расположения возникла исторически, а не осознанным решением: посчитайте реальную цену в миллисекундах, прежде чем решать, что с ней делать. Возьмите измеренный RTT между регионами, среднее число последовательных обращений к базе на типичную тяжёлую операцию (отдельно для чтения и записи с учётом синхронной репликации, если она есть) и перемножьте. Если причина разноса была весомой (юрисдикция, требование хранить данные в конкретной стране) — цена может быть оправданной, её нужно просто оптимизировать паттернами выше. Если причина была случайной («так исторически сложилось»), а цена ощутимая — это повод всерьёз обсудить перенос базы или её части ближе к фронтенду, а не латать симптомы.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Поможет ли более широкий канал (выше пропускная способность)?

Почти нет. Пропускная способность и задержка — разные характеристики канала. RTT определяется расстоянием и маршрутом, а не тем, сколько данных канал пропускает в секунду. SQL-запрос и его ответ — обычно небольшой объём данных, для которого узким местом почти никогда не является полоса пропускания.

Поможет ли просто более мощный сервер базы данных?

Нет, если проблема именно в сетевом RTT между обращениями. Более мощный сервер ускорит выполнение запроса на стороне базы, но не уберёт время, которое запрос и ответ тратят на дорогу между странами. Если в профиле видно, что большая часть времени — это ожидание ответа, а не выполнение на стороне базы, апгрейд сервера эту часть не тронет.

Можно ли решить проблему, просто увеличив TTL кеша?

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

Стоит ли поставить второй сервер приложения рядом с базой и балансировать между двумя фронтендами?

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

С чего начать, если непонятно, где теряется время?

С измерения, а не с оптимизации наугад: включите pg_stat_statements (или аналог для вашей СУБД), посмотрите на ручки с наибольшим числом вызовов запросов на одну операцию и сопоставьте с трейсами конкретных медленных запросов. Первые кандидаты на оптимизацию почти всегда находятся за несколько минут.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →