Настройка производительности Go и Postgres: пул соединений, планы запросов, индексы, пагинация и JSON-ответы. Проверки перед первыми пользователями.

Новые API редко тормозят из-за одной большой ошибки. Чаще это набор мелких решений, которые в сумме дают рост задержек, а потом внезапные таймауты.
У AI-сгенерированных ручек есть типичный перекос: они стараются быть "умными" и делают слишком много работы за один запрос. Самое частое - несколько лишних походов в базу (N+1), дополнительные проверки прав на каждую строку, запросы "на всякий случай", а иногда и повторное чтение одной и той же сущности в разных местах обработчика.
Симптомы обычно выглядят так:
По узким местам картина чаще всего делится на три зоны. База данных: плохой план запроса, отсутствие нужного индекса, слишком широкие SELECT, блокировки. Сеть: большие JSON-ответы, лишние поля, отсутствие сжатия на уровне инфраструктуры. Сериализация: вы отдаете массивы из тысяч объектов, и CPU тратится на сборку структуры, маршалинг и копирование.
Простой пример: ручка "список заказов" сначала выбирает 50 заказов, а потом для каждого отдельно тянет клиента и статус. На тестовых данных это незаметно, но с реальной базой получаются 101 запрос, рост p95 и постоянные пики нагрузки.
До первого трафика важнее всего исправить вещи, которые могут сломать сервис сразу: адекватный пул соединений в Go, лимиты на запросы и таймауты, защита от случайной выдачи "всего" без пагинации, базовые индексы под самые частые фильтры.
А вот тонкую полировку можно отложить: идеальная форма JSON для всех клиентов, микропобеды в аллокациях, рефакторинг схемы. Если вы собираете API в TakProsto и потом экспортируете код, эти ранние проверки особенно полезны: они ловят "слишком много запросов" еще до того, как появятся реальные пользователи.
Перед тем как начинать тонкую настройку производительности Go и Postgres, соберите минимум цифр. Без них вы будете менять параметры вслепую и спорить, что именно «стало быстрее».
Первый набор метрик, который стоит иметь под рукой:
Дальше сделайте простую нагрузочную проверку: выберите 1-2 самых важных эндпоинта и постепенно увеличивайте параллельность (например, 10, 25, 50, 100 одновременных запросов). Цель не «побить рекорд», а найти точку, где p95 резко растет или появляются ошибки. Часто сюрприз в том, что сервер по CPU еще жив, а база уже захлебнулась из-за очереди на соединения или блокировок.
Проверьте ограничения по умолчанию, которые незаметно режут скорость. Например, слишком маленький пул соединений в приложении, слишком большой и убивающий Postgres количеством коннектов, или отсутствие лимитов на «тяжелые» выборки (списки без ограничений, сортировка без индекса). Еще одна частая проблема: долгие запросы копятся, потому что никто их не обрывает.
Минимальный набор таймаутов в API, чтобы не зависать бесконечно:
Простой сценарий: у вас есть эндпоинт списка. Без таймаутов и лимитов один «тяжелый» запрос может занять все соединения к базе, и остальные начнут ждать. С таймаутами и ограничениями сервис скорее вернет понятную ошибку, чем уронит весь API.
В Go объект sql.DB часто путают с одним подключением к базе. На самом деле это менеджер пула: он хранит и переиспользует соединения, открывает новые до лимита и раздает их горутинам. Если лимиты не заданы, сервис может внезапно упереться в очередь ожидания соединения или, наоборот, открыть слишком много подключений и «прибить» Postgres.
Базовая настройка занимает пару минут и уже сильно помогает, когда вы делаете настройку производительности Go и Postgres перед первым трафиком.
Начните с простых значений и меняйте их только после измерений:
// db, _ := sql.Open("pgx", dsn)
db.SetMaxOpenConns(20) // верхняя граница одновременных соединений
db.SetMaxIdleConns(10) // сколько держать «теплыми» без работы
db.SetConnMaxLifetime(30 * time.Minute) // обновлять соединения, чтобы не копить «старые»
Как подобрать числа:
MaxOpenConns задавайте от лимита Postgres на соединения и количества инстансов вашего API. Если в Postgres доступно 100 соединений, а у вас 4 реплики сервиса, то 20-25 на реплику обычно безопаснее, чем «как получится».MaxIdleConns держите меньше MaxOpenConns. Идея простая: прогревать частые запросы, но не занимать соединения впрок.ConnMaxLifetime полезен в долгоживущих сервисах: иногда сетевые условия меняются, соединения «подвисают», и обновление по времени снижает шанс странных ошибок.Главный сигнал, что пул настроен плохо: время ответа растет, а база при этом не перегружена. Часто причина в том, что запросы стоят в очереди за свободным соединением.
Добавьте метрики и логи хотя бы на уровне обработки запроса:
Пример из практики: при генерации API (например, в TakProsto) легко получить несколько параллельных запросов на один экран. Если пул маленький, все упрется в ожидание соединения. Если слишком большой, Postgres начнет тратить время на переключение контекста и память, и станет хуже всем.
Слишком много соединений к Postgres почти всегда делает хуже. Каждое соединение - это память, процессы и переключения контекста. Когда сервис под нагрузкой открывает сотни коннектов, база начинает тратить силы не на запросы, а на обслуживание «толпы».
Первая цель в настройке производительности Go и Postgres - понять, сколько соединений реально нужно вашему API. Часто достаточно десятков, а не сотен: если запросы быстрые и индексированные, один коннект успевает обслужить много запросов подряд.
Быстрая проверка занимает пару минут. Важно смотреть не только max_connections, но и реальное число активных сессий и ожиданий:
SHOW max_connections;SELECT count(*) FROM pg_stat_activity;SELECT pid, state, now()-xact_start AS age, query FROM pg_stat_activity WHERE xact_start IS NOT NULL ORDER BY age DESC LIMIT 5;wait_event в pg_stat_activityЕсли вы видите всплески соединений и короткие запросы, которые начинают ждать, часто помогает PgBouncer. Он решает конкретную проблему: много коротких подключений от приложения. PgBouncer держит небольшой пул коннектов к Postgres и раздает их запросам, а не заставляет базу создавать новые процессы на каждый чих.
Даже при нормальном пуле бывают «зависания», которые тихо съедают коннекты. Поставьте ограничители, чтобы база сама чистила проблемные случаи: statement_timeout (чтобы не выполнялись бесконечные запросы) и idle_in_transaction_session_timeout (чтобы транзакции не висели без дела, держа блокировки). Типичный сценарий - обработчик открыл транзакцию, а потом ждет внешний вызов или упал до коммита. Такие сессии незаметно забивают лимит соединений и тормозят всех.
Если вы собираете API в TakProsto и планируете рост нагрузки, договоритесь о простом правиле: соединений меньше, чем кажется нужным, и таймауты включены по умолчанию. Это дешевле, чем искать «куда делись коннекты» в день запуска.
Когда API начинает тормозить, гадать бесполезно. Самый быстрый способ понять, почему запрос медленный, это посмотреть план. Для настройки производительности Go и Postgres это почти всегда первый полезный шаг, особенно если эндпоинт сгенерирован быстро (например, в TakProsto) и вы еще не успели проверить, что реально делает база.
Порядок действий простой: найдите конкретный медленный эндпоинт, выпишите SQL, подставьте типичные параметры и запустите план с фактическим временем.
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title, created_at
FROM posts
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 20;
В плане ищите несколько красных флагов. Они часто прямо указывают, что менять:
Фраза "строк больше, чем ожидалось" опасна тем, что оптимизатор выбирает план, рассчитанный на маленький результат. В реальности он получает в 10-100 раз больше строк, и внезапно "быстрый" Nested Loop превращается в убийцу latency.
Что обычно помогает без переписывания всего сервиса:
Мини-проверка: если запрос с LIMIT 20 все равно читает сотни тысяч строк, проблема почти всегда в индексе или в порядке выполнения (сортировка и JOIN происходят слишком рано).
Большинство «тормозов» в Postgres для API начинается не с Go, а с того, что база вынуждена читать слишком много строк. Правильные индексы почти всегда дают самый быстрый и понятный выигрыш, особенно когда вы делаете настройка производительности Go и Postgres перед первыми реальными пользователями.
Почти всегда нужны индексы по тем полям, по которым вы фильтруете и связываете данные. Если в запросах есть WHERE status = ..., WHERE user_id = ..., WHERE created_at >= ..., то без индекса база часто скатывается в последовательное чтение таблицы. Отдельно проверьте внешние ключи: Postgres не создает индекс автоматически на колонке-ссылке. Если у вас часто ищут «все заказы пользователя», индекс на orders(user_id) обязателен.
Составной индекс помогает, когда в WHERE несколько условий, а не одно. Главное правило простое: ставьте первыми те колонки, которые чаще всего встречаются в фильтрах и лучше «сужают» выборку, а затем те, по которым вы сортируете.
Например, типичный запрос списка: WHERE user_id = $1 AND status = $2 ORDER BY created_at DESC LIMIT 20. Для него часто хорошо работает индекс (user_id, status, created_at DESC). Если же status редко используется, лучше (user_id, created_at DESC).
Если вы сортируете по created_at и делаете пагинацию, индекс должен поддерживать этот порядок. Тогда Postgres может читать строки уже в нужной сортировке и не тратить время на лишний Sort.
Проверьте себя по короткому чек-листу:
WHERE фильтры.ORDER BY.Важно не переборщить. Каждый индекс занимает место и замедляет INSERT/UPDATE/DELETE, потому что его тоже нужно обновлять. Хорошая практика: добавлять индекс под конкретный медленный запрос, а не «на всякий случай». В проектах, которые быстро собирают через TakProsto, это особенно полезно: вы быстро видите реальные запросы и добавляете только то, что действительно нужно.
OFFSET выглядит удобно: LIMIT 50 OFFSET 5000 и готово. Проблема в том, что Postgres все равно должен “пройти” первые 5000 строк, чтобы отдать следующие 50. На маленьких таблицах вы этого не заметите, но по мере роста данных задержка увеличивается, а нагрузка на CPU и диск становится неприятной. Для API, особенно с автогенерированными эндпоинтами, это частая причина “внезапных” тормозов.
Keyset (cursor) пагинация работает иначе: вы не просите “страницу номер N”, вы просите “все, что идет после последней записи, которую я видел”. Тогда база использует индекс и сразу прыгает в нужное место.
Пример для сортировки по времени создания, со стабильным порядком (важно добавлять второе поле, чтобы не было дублей при одинаковом created_at):
-- первая страница
SELECT id, created_at, title
FROM items
WHERE user_id = $1
ORDER BY created_at DESC, id DESC
LIMIT $2;
-- следующая страница (передаем last_created_at и last_id с предыдущей)
SELECT id, created_at, title
FROM items
WHERE user_id = $1
AND (created_at, id) < ($3, $4)
ORDER BY created_at DESC, id DESC
LIMIT $2;
Такой подход дает три важных эффекта: скорость почти не зависит от глубины, порядок стабильный, и между страницами не появляются дубли. Без tie-breaker (например, только created_at) вы рискуете увидеть повторяющиеся записи или пропуски, особенно если новые строки активно добавляются.
Чтобы это работало предсказуемо, держите простые правила:
ORDER BY и добавляйте уникальный “хвост” (обычно id).limit жестко ограничивайте (например, 100-200).Если вы делаете API на Go + Postgres (в том числе в TakProsto), keyset-пагинация обычно дает самый быстрый выигрыш без сложного тюнинга: запросы становятся короткими, индексами пользоваться легче, а задержки не растут вместе с номером страницы.
JSON часто становится скрытым тормозом: база читает лишнее, приложение тратит CPU на сборку структуры, а сеть гоняет килобайты, которые клиент даже не использует. В настройке производительности Go и Postgres это один из самых быстрых способов выиграть время отклика.
Первое правило простое: не тяните лишние колонки. Если на экране списка нужны id, name, status и updated_at, не выбирайте description, большие текстовые поля и тем более JSONB с деталями. Делайте отдельные DTO для списка и для карточки. Это уменьшает и нагрузку на Postgres, и работу сериализации в Go.
Где формировать JSON: в приложении или в Postgres? Обычно проще поддерживать сборку в Go: вы видите типы, проще тестировать, легче менять формат. Генерация JSON в Postgres (json_build_object, агрегации) полезна, когда нужно собрать вложенные структуры одним запросом и вы точно контролируете план, но цена ошибки выше: сложнее читать запросы и легко случайно сделать тяжелую агрегацию.
Чтобы ответ был меньше, начните с формы данных:
Пример: список из 50 сущностей. Если вы отдаете полный объект с 20 полями и вложенным meta, ответ может быть 80-150 КБ. Если оставить 5-6 полей для списка и сжать, часто получается 10-30 КБ. Разница заметна даже на локальной сети.
Кэширование на уровне API помогает, но не все можно кэшировать безопасно. Хорошо кэшируются публичные справочники, неизменяемые конфиги, списки без персональных данных. Осторожнее с ответами, зависящими от пользователя, ролей и времени: кэш нужно ключевать по параметрам, языку, правам и версии данных. В TakProsto, где API часто генерируются быстро, добавьте простое правило: кешируйте только то, что вы готовы отдавать любому пользователю при тех же параметрах запроса, и задавайте короткий TTL, пока нагрузка не стала предсказуемой.
Самая частая причина внезапных тормозов - не «медленный Postgres», а несколько мелких решений, которые вместе превращают API в перегретую систему. При настройке производительности Go и Postgres полезно сначала проверить типовые ловушки и убрать их до того, как придут первые реальные пользователи.
Слишком большой пул в Go выглядит безопасно: «пусть будет побольше, чтобы не ждать». На деле это часто эффект «сам себе DDoS». Когда каждый запрос к API может занять соединение, база начинает тратить время на планирование, контекстные переключения и ожидания, а не на полезную работу.
Простой признак: при росте нагрузки увеличивается не только p95, но и время в очередях, а CPU на базе подпрыгивает даже на простых запросах.
N+1 часто появляется незаметно, когда вы берете список сущностей, а потом для каждой подгружаете связанные данные отдельным запросом. В vibe-coding платформах это может всплыть и в сгенерированном коде, если структура ответа задумывалась «как в UI», а не «как в SQL».
Мини-сценарий: эндпоинт /orders отдает 50 заказов, а затем для каждого делает запрос за пользователем и суммой. Получается 101 запрос вместо 1-2, и задержка растет не плавно, а ступенькой.
Если endpoint принимает limit без верхней границы, вы сами разрешаете клиенту запросить «все сразу». То же с фильтрами: поиск по неиндексированному полю или сортировка по вычисляемому значению быстро превращаются в полный скан таблицы.
Долгая транзакция удерживает ресурсы и может блокировать других. Частая ошибка - открыть транзакцию, сделать несколько запросов, а затем ждать внешнего ответа. Еще хуже - забытый SELECT ... FOR UPDATE, который нужен только при реальном конфликте записи.
Часто логика «прочитать, посчитать, обновить счетчик» делается одним большим запросом или транзакцией «на всякий случай». Если обновление можно вынести отдельно или делать реже, вы уменьшите блокировки и нагрузку на WAL.
Короткий чек перед запуском:
Представим эндпоинт /orders: список заказов с фильтром по статусу и диапазону дат. Такой запрос часто появляется в первых версиях API (в том числе в сгенерированных сервисах на Go + PostgreSQL), и именно на нем быстро всплывают проблемы с p95.
До оптимизации типичная реализация выглядит так: пагинация через OFFSET, выборка SELECT *, а для каждой строки еще делаются дополнительные запросы (например, чтобы подтянуть пользователя или позиции заказа). На маленьких данных все быстро, но после роста таблицы p95 начинает прыгать, а база тратит время на пропуск строк и лишний ввод-вывод.
Вот что меняем.
-- До
SELECT *
FROM orders
WHERE status = $1
AND created_at >= $2 AND created_at < $3
ORDER BY created_at DESC
LIMIT $4 OFFSET $5;
-- После (keyset)
SELECT id, status, created_at, total_sum
FROM orders
WHERE status = $1
AND created_at >= $2 AND created_at < $3
AND (created_at, id) < ($4, $5)
ORDER BY created_at DESC, id DESC
LIMIT $6;
-- Индекс под запрос
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_status_created_id
ON orders (status, created_at DESC, id DESC);
Смысл правок простой: keyset пагинация не заставляет Postgres «пролистывать» тысячи строк, индекс совпадает с фильтрами и сортировкой, а узкий SELECT уменьшает работу по чтению и сборке JSON-ответа.
Быстрая проверка «стало лучше» перед реальными пользователями:
EXPLAIN (ANALYZE, BUFFERS) показывает, что используется индекс и падает число прочитанных страниц.Если вы собираете API в TakProsto, полезно сохранить этот сценарий как шаблон: список почти всегда лучше начинать с keyset + правильного индекса + минимального набора полей.
Хорошая настройка производительности Go и Postgres обычно ломается не из-за "плохого Postgres", а из-за мелких изменений в новых эндпоинтах. Поэтому цель после тюнинга простая: зафиксировать базовые правила и сделать так, чтобы команда нарушала их как можно реже.
Перед релизом пройдитесь по короткому чек-листу. Он занимает 10 минут, но спасает от типовых провалов на первых пользователях:
Дальше важно автоматизировать то, что люди чаще всего забывают. Например, держите пару эталонных шаблонов запросов (поиск, список, детальная карточка), и договоритесь об одном формате пагинации для всех коллекций. Полезная привычка - включить логирование медленных запросов и собирать их в отдельный список для еженедельного разбора.
Чтобы не спорить на каждом PR, закрепите правила для новых эндпоинтов и код-ревью. Подойдет простой набор:
Если вы собираете API через TakProsto, используйте planning mode, чтобы заранее описать списки, фильтры и формат ответов. Перед изменениями делайте снимок проекта, чтобы можно было быстро откатиться, если новый запрос неожиданно стал дорогим. А когда логика устоялась, экспортируйте код и прогоните финальную проверку под нагрузкой уже на вашей инфраструктуре и с вашими настройками Postgres.
Лучший способ понять возможности ТакПросто — попробовать самому.