Возьмём обычную страницу личного кабинета. Пользователь открывает «Мои заказы», и backend последовательно делает вот что: проверяет сессию, читает профиль, достаёт список заказов, затем для каждого заказа отдельным запросом подтягивает позиции, потом для каждой позиции читает название товара, а в конце смотрит, нет ли непрочитанных уведомлений. На локальной машине разработчика всё это выглядит как мгновенная страница. На графике загрузки сервера нет ничего подозрительного: процессор скучает, память свободна. А пользователь ждёт почти секунду и недоумевает.
Если backend стоит в одной стране, а база данных в другой, у этой страницы появляется скрытая статья расходов, которую не видно ни в top, ни в профилировщике запросов. Это не вычисления и не диски. Это ожидание ответа. Каждый короткий запрос к базе — маленький диалог, и у каждого диалога есть цена, которая определяется расстоянием между узлами, а не мощностью машины.
Арифметика одной страницы
Для расчёта возьмём условные числа, они не измерены ни на каком реальном проекте и нужны только для демонстрации механики. Пусть круг «запрос ушёл — ответ вернулся» между зарубежным backend и базой в России занимает 40 мс. Пусть сама база отвечает на простой запрос за 1 мс, а процессор backend тратит на разбор ответа ещё 0,5 мс.
Теперь посчитаем страницу из нашей сцены. Допустим, в кабинете 20 заказов, у каждого в среднем 3 позиции.
- Сессия: 1 запрос.
- Профиль: 1 запрос.
- Список заказов: 1 запрос.
- Позиции по каждому заказу: 20 запросов.
- Название товара для каждой позиции: 60 запросов.
- Уведомления: 1 запрос.
Итого 84 последовательных обращения. Каждое стоит примерно 40 + 1 + 0,5 = 41,5 мс, то есть всего около 3,5 секунды. Из них полезной работы — меньше 130 мс. Остальное — ожидание дороги туда и обратно.
Для сравнения представим, что backend и база стоят рядом и круг занимает 1 мс. Те же 84 запроса дадут примерно 84 × 2,5 = 210 мс. Страница получилась бы терпимой. Вот почему код, который «нормально работал» при разработке, в раздельной схеме внезапно превращается в тормоз: количество обращений не изменилось, изменилась цена каждого.
Важный вывод из этой арифметики: время операции растёт как число последовательных обменов, умноженное на задержку канала. Добавление CPU уменьшит только крошечное слагаемое 0,5 мс. На 40-миллисекундную часть процессор никак не влияет.
Данные и обмены — разные величины
Часто в таких ситуациях первым делом вспоминают про пропускную способность канала: «может, тарифа не хватает?». Но это другая ось. Ответ на запрос позиций заказа весит пару килобайт; даже по скромному каналу он передаётся за доли миллисекунды. Сеть здесь ждёт не потому, что узкая, а потому, что длинная: пакет должен физически добраться до базы, а ответ вернуться.
Полезно разделить две стоимости:
- Стоимость обмена — фиксированная плата за каждый «вопрос-ответ», не зависящая от размера ответа. Определяется задержкой маршрута.
- Стоимость объёма — время, нужное, чтобы протолкнуть байты через канал. Определяется пропускной способностью.
Страница с 84 мелкими запросами страдает почти целиком от первой. Страница, которая выгружает отчёт на 200 МБ одним запросом, — от второй. Лечатся они по-разному: первая — сокращением числа диалогов, вторая — сжатием, фильтрацией на стороне базы, постраничной выдачей.
Есть и ещё одна ловушка: к стоимости обмена при открытии нового соединения добавляются рукопожатия. Утилита curl разделяет их в метриках: time_namelookup — до завершения DNS, time_connect — до установки TCP-соединения, time_appconnect — до конца TLS-рукопожатия. По документации curl эти значения накопительные: каждое отсчитывается от начала операции, поэтому складывать их как независимые этапы нельзя, а чтобы получить длительность отдельного шага, нужно вычитать предыдущее значение. Эти же рассуждения помогают понять, во что обходится «соединение на каждый запрос»: если приложение не держит пул соединений и открывает новое на каждое обращение, к 40 мс самого запроса добавятся ещё несколько кругов на установку TCP и шифрованного канала. Пул соединений в такой схеме — не оптимизация, а необходимость.
N+1: когда цикл превращается в караван
Классический источник лишних обменов — ситуация, которую называют N+1. Один запрос получает список из N записей, а затем в цикле для каждой записи выполняется ещё один запрос. Почти всегда это происходит не от злого умысла, а из-за удобной абстракции: ORM лениво подгружает связанные объекты, когда к ним впервые обращаются в шаблоне. Разработчик пишет order.items и не видит, что за этим стоит поход в другую страну.
Вернёмся к нашей странице. Позиции 20 заказов можно получить не двадцатью запросами, а одним, передав список идентификаторов:
SELECT order_id, product_id, qty, price
FROM order_items
WHERE order_id = ANY($1);Названия товаров — вторым запросом по списку идентификаторов товаров (или соединением JOIN в том же запросе). Тогда страница сворачивается примерно до шести обменов: сессия, профиль, заказы, позиции (с товарами), уведомления. При прежних условных числах это около 6 × 41,5 ≈ 250 мс вместо 3,5 секунды. Данных по сети передано примерно столько же, а ждали мы в четырнадцать раз меньше.
Тут стоит заметить, что объединение запросов — не бесплатное. Соединение с большим числом строк может «размножить» данные: если к заказу присоединить позиции и товары одним JOIN, строки заказа будут повторяться на каждую позицию. Иногда два-три простых запроса со списками идентификаторов дают меньше трафика и проще разбираются в коде, чем один громоздкий. Это выбор в зависимости от формы данных, а не универсальное правило.
Ещё нюанс: независимые запросы можно запускать параллельно. Профиль и уведомления друг от друга не зависят; если выполнить их одновременно по разным соединениям из пула, общее ожидание будет равно самому медленному из них, а не сумме. Но параллелизм ограничен пулом: десять одновременных запросов на странице при пуле в пять соединений всё равно встанут в очередь, а под нагрузкой десятки пользователей быстро исчерпают пул и начнут ждать уже друг друга.
Pipeline: отправить не дожидаясь
Бывает, что запросы нельзя склеить в один, но и ждать ответа на каждый не обязательно. Для таких случаев у PostgreSQL есть pipeline mode в клиентской библиотеке libpq. Согласно документации, он позволяет отправлять следующие запросы, не дожидаясь чтения результата предыдущего, и этим сокращает ожидание сетевых обменов. Представьте, что вместо «спросил — подождал — спросил — подождал» клиент выстраивает вопросы подряд, а ответы потом читает по очереди.
В нашей арифметике это означает: пакет из пяти независимых запросов, отправленный конвейером, может стоить примерно одного круга задержки плюс время обработки самих запросов, а не пяти кругов. Если подставить условные 40 мс, то вместо 200 мс ожидания получаем порядок 45 мс. Это оценка механизма, а не измерение; реальный выигрыш зависит от драйвера, от того, как он этот режим поддерживает, и от самих запросов.
У режима есть цена, о которой та же документация предупреждает прямо: приложению приходится управлять очередью результатов, и расход памяти может вырасти, потому что ответы накапливаются, пока их не прочитают. Если конвейеризировать тысячи запросов с большими ответами, можно получить не ускорение, а раздувшийся буфер. Поэтому пакеты разумно резать на порции и читать результаты по мере прихода.
Теперь о том, чего делать нельзя. Пакетирование не безопасно для любой транзакционной логики автоматически. Если второй запрос зависит от результата первого — например, нужно прочитать баланс и только потом решить, списывать ли средства, — отправить их «вслепую» не получится: решение принимается в приложении, а оно ещё не получило ответ. Если внутри транзакции один из запросов в конвейере завершится ошибкой, последующие запросы того же пакета, уже отправленные на сервер, будут обработаны в контексте проваленной транзакции, и поведение придётся отдельно продумывать. Безопасный кандидат на конвейер — независимые чтения и операции, для которых порядок и частичный отказ понятны заранее. Всё остальное лучше переносить на сторону базы (хранимая процедура или один составной запрос), чтобы решение принималось там, где данные.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Локальный кеш: ответы, которые не нужно спрашивать
Самый быстрый обмен — тот, которого не было. Если справочник товаров меняется раз в час, а страница читает его на каждый показ, имеет смысл держать копию рядом с backend: в памяти процесса, в локальном Redis или Memcached на том же сервере. В нашей сцене это убирает целый класс запросов: названия товаров, курсы валют, настройки тарифов, права ролей.
Но кеш — это договор о допустимом устаревании, и его стоит заключать осознанно. Полезно разложить данные по трём корзинам:
| Тип данных | Примеры | Отношение к кешу |
|---|---|---|
| Редко меняются, ошибка не страшна | категории, названия товаров, тексты интерфейса | кеш с таймером в минуты или часы |
| Меняются часто, небольшая задержка допустима | счётчики просмотров, рейтинги, остатки для витрины | короткий кеш в секунды, возможна фоновая загрузка |
| Должны быть точными в момент действия | баланс, наличие при оформлении, права после смены пароля | читать из первоисточника, кеш только как подсказка |
У кеша есть и менее очевидные границы. Первая — инвалидация: если запись изменилась в базе, кто сообщит об этом каждому экземпляру backend? При нескольких серверах приложения у каждого свой локальный кеш, и в течение времени жизни записи они могут показывать разное. Вторая — «холодный старт»: после перезапуска кеш пуст, и несколько сотен одновременных запросов могут одновременно кинуться в базу через границу за одним и тем же значением. Против этого помогает объединение одинаковых запросов (только один идёт за данными, остальные ждут его результат) и небольшой случайный разброс времени жизни записей. Третья — отрицательные ответы: если «товара нет» не кешировать, то запросы к несуществующим идентификаторам будут проходить сквозь кеш каждый раз.
И четвёртая, самая важная: кеш не должен тихо превращаться в источник истины. Решения, которые нельзя отменить — списание, подтверждение заказа, выдача доступа, — не стоит принимать по копии, возраст которой неизвестен.
Реплика у backend: когда копия — целая база
Если читающих запросов много и они разнообразны, кешировать каждый вручную неудобно. Тогда возникает мысль держать рядом с зарубежным backend читающую реплику базы. Ради неё придётся принять несколько условий, и документация PostgreSQL про резервные серверы описывает их достаточно трезво.
Асинхронная передача журнала WAL оставляет окно, в котором последние изменения на основном сервере ещё не дошли до реплики. Для чтений это значит, что пользователь может сохранить профиль, обновить страницу и увидеть старые данные, потому что запрос ушёл на реплику, которая отстала. Синхронная фиксация, наоборот, может ждать подтверждения от standby, и тогда каждая запись снова платит за дорогу через границу — вы вернули себе ту самую задержку, только в другом месте. Кроме того, канал должен справляться со скоростью образования WAL: если журнал создаётся быстрее, чем уходит по сети, реплика будет отставать всё сильнее, а после обрыва связи ей придётся догонять основной сервер.
Практическое следствие: реплика у backend хороша для чтений, терпимых к небольшому отставанию (каталоги, отчёты, ленты), и плоха для сценария «записал — сразу прочитал». Такие сценарии либо направляют на основную базу, либо временно привязывают сессию пользователя к первоисточнику после записи. Запись при этом всё равно идёт в основную базу, поэтому записывающие операции придётся оптимизировать теми же методами: меньше обменов, пакеты, осмысленные транзакции.
Асинхронность: не ждать, если пользователю это не нужно
Не всё, что приложение делает по запросу пользователя, должно закончиться до ответа. Запись аудита, обновление счётчиков, отправка уведомлений, пересчёт аналитики — всё это можно отложить в очередь и выполнить пачкой. Пользователь получает ответ после одного обмена с базой (сохранить главное), а остальное уходит фоном. Пачки по 100 событий вместо 100 отдельных вставок экономят 99 кругов.
Цена такого решения — согласованность и обработка отказов. Если backend упал после ответа пользователю, но до записи события из очереди, событие надо либо восстановить, либо смириться с потерей; очередь в памяти процесса этого не переживёт. Для действительно важных событий нужна надёжная очередь или запись в локальный журнал с последующей доставкой. И повторная обработка должна быть безопасной: если одно событие придёт дважды, итог не должен удвоиться. Это требует осознанного дизайна, а не просто нового идентификатора попытки.
Что держать рядом, а что можно оставить на расстоянии
Теперь можно ответить на вопрос, с которого мы начали. Зарубежный backend с российской базой тормозит при свободном CPU потому, что операция состоит из цепочки ожиданий, а не из вычислений. Выбор расположения компонентов — это, по сути, решение о том, какие диалоги допустимо вести через границу, а какие нет.
Ориентир такой: рядом должны находиться компоненты, которые ведут между собой частый, мелкий и последовательный разговор. Если backend делает в среднем 5–10 обращений на операцию и они зависимы друг от друга, расстояние почувствуется сразу. Если операция — один запрос с большим ответом или фоновая выгрузка, расстояние почти незаметно.
- Вместе: backend и база для транзакционных сценариев с чтениями и записями внутри одной операции; backend и кеш; backend и очередь задач.
- Можно раздельно: фронтовый слой и статика, пакетные выгрузки, репликация для отчётов, фоновые синхронизации, интеграции, где операция по природе длинная.
- Требует решения под задачу: личные кабинеты и API, где пользователь ждёт ответа: либо сокращаем обмены до минимума, либо переносим backend туда, где база.
Если по продуктовым причинам backend нужно держать в одной локации, а база остаётся в другой, сначала сократите диалоги: найдите N+1, объедините зависимые чтения, запустите независимые параллельно, вынесите справочники в локальный кеш, отложите то, что не требует ответа сразу. Затем замерьте. Для измерения полезно логировать число запросов на каждый HTTP-запрос и время, потраченное на ожидание базы, отдельно от времени обработки: такой показатель сразу покажет, что на странице 84 обмена, а не 6. Профилировщик СУБД этого не покажет, потому что для самой базы каждый запрос был быстрым.
Подбирая ресурсы под проверку, начните с небольшого плана и тестируйте именно сценарий. На VPS MAATRIX доступны NVMe, процессоры AMD EPYC и root-доступ, а в публичном предложении перечислены локации UK, US, RU и FR; можно развернуть тестовый backend в одной локации, а базу или реплику — в другой и сравнить, как меняется число обменов и время ответа. Ресурсы тарифов (от 1 vCPU и 2 ГБ у Ba до 16 vCPU и 32 ГБ у Djed) — отправная точка для теста, а не обещание ёмкости. Для оплаты российской картой или через СБП есть отдельная заметка: как оплатить зарубежный VPS из России.
Прежде чем покупать процессор
Вернёмся к странице с заказами. В исходном виде она делала 84 обмена и ждала около трёх с половиной секунд, из которых вычислений было ничтожно мало. Покупка более мощного сервера сократила бы эти 0,5 мс на запрос до, скажем, 0,3 — разница, которую пользователь не заметит. Объединённые запросы, список идентификаторов вместо цикла, параллельное чтение независимого и короткий кеш справочников уводят время к четверти секунды без единого дополнительного ядра.
Поэтому разумный порядок действий такой: сначала посчитать, сколько раз операция ходит в базу и сколько из этих обращений ждут друг друга; затем убрать лишние и склеить родственные; затем решить, что можно взять из кеша, а что нельзя; и только после этого смотреть, не нужно ли переселить компоненты поближе друг к другу. Процессор нужен, когда он действительно занят. Пока он простаивает, а страница медленная, приложение, скорее всего, слишком много разговаривает через границу, и лучше научить его говорить короче.