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

Какие тормоза обычно появляются у новых API
Новые API редко тормозят из-за одной большой ошибки. Чаще это набор мелких решений, которые в сумме дают рост задержек, а потом внезапные таймауты.
У AI-сгенерированных ручек есть типичный перекос: они стараются быть "умными" и делают слишком много работы за один запрос. Самое частое - несколько лишних походов в базу (N+1), дополнительные проверки прав на каждую строку, запросы "на всякий случай", а иногда и повторное чтение одной и той же сущности в разных местах обработчика.
Симптомы обычно выглядят так:
- p95 и p99 растут быстрее, чем средняя задержка
- всплески CPU на приложении или на Postgres без видимого роста трафика
- много ожидания соединений к базе (очередь в пуле)
- редкие, но массовые таймауты при пиках
- сеть и ответы становятся тяжелыми, хотя данные "те же"
По узким местам картина чаще всего делится на три зоны. База данных: плохой план запроса, отсутствие нужного индекса, слишком широкие SELECT, блокировки. Сеть: большие JSON-ответы, лишние поля, отсутствие сжатия на уровне инфраструктуры. Сериализация: вы отдаете массивы из тысяч объектов, и CPU тратится на сборку структуры, маршалинг и копирование.
Простой пример: ручка "список заказов" сначала выбирает 50 заказов, а потом для каждого отдельно тянет клиента и статус. На тестовых данных это незаметно, но с реальной базой получаются 101 запрос, рост p95 и постоянные пики нагрузки.
До первого трафика важнее всего исправить вещи, которые могут сломать сервис сразу: адекватный пул соединений в Go, лимиты на запросы и таймауты, защита от случайной выдачи "всего" без пагинации, базовые индексы под самые частые фильтры.
А вот тонкую полировку можно отложить: идеальная форма JSON для всех клиентов, микропобеды в аллокациях, рефакторинг схемы. Если вы собираете API в TakProsto и потом экспортируете код, эти ранние проверки особенно полезны: они ловят "слишком много запросов" еще до того, как появятся реальные пользователи.
Быстрые проверки перед первыми реальными пользователями
Перед тем как начинать тонкую настройку производительности Go и Postgres, соберите минимум цифр. Без них вы будете менять параметры вслепую и спорить, что именно «стало быстрее».
Первый набор метрик, который стоит иметь под рукой:
- p50 и p95 времени ответа (по основным ручкам)
- RPS (сколько запросов в секунду держите без деградации)
- процент ошибок (5xx, таймауты, отмены запросов)
- активные соединения к Postgres (и сколько из них занято)
- список самых медленных запросов (top по времени и по частоте)
Дальше сделайте простую нагрузочную проверку: выберите 1-2 самых важных эндпоинта и постепенно увеличивайте параллельность (например, 10, 25, 50, 100 одновременных запросов). Цель не «побить рекорд», а найти точку, где p95 резко растет или появляются ошибки. Часто сюрприз в том, что сервер по CPU еще жив, а база уже захлебнулась из-за очереди на соединения или блокировок.
Проверьте ограничения по умолчанию, которые незаметно режут скорость. Например, слишком маленький пул соединений в приложении, слишком большой и убивающий Postgres количеством коннектов, или отсутствие лимитов на «тяжелые» выборки (списки без ограничений, сортировка без индекса). Еще одна частая проблема: долгие запросы копятся, потому что никто их не обрывает.
Минимальный набор таймаутов в API, чтобы не зависать бесконечно:
- таймаут запроса на уровне HTTP сервера
- дедлайн на обработку запроса через context
- таймаут на запрос к базе (и отдельный на транзакцию)
- ограничение на максимальный размер ответа и тела запроса
Простой сценарий: у вас есть эндпоинт списка. Без таймаутов и лимитов один «тяжелый» запрос может занять все соединения к базе, и остальные начнут ждать. С таймаутами и ограничениями сервис скорее вернет понятную ошибку, чем уронит весь API.
Пул соединений в Go: настройка без догадок
В Go объект sql.DB часто путают с одним подключением к базе. На самом деле это менеджер пула: он хранит и переиспользует соединения, открывает новые до лимита и раздает их горутинам. Если лимиты не заданы, сервис может внезапно упереться в очередь ожидания соединения или, наоборот, открыть слишком много подключений и «прибить» Postgres.
Базовая настройка занимает пару минут и уже сильно помогает, когда вы делаете настройку производительности Go и Postgres перед первым трафиком.
Быстрая настройка: 3 параметра, которые реально важны
Начните с простых значений и меняйте их только после измерений:
// 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полезен в долгоживущих сервисах: иногда сетевые условия меняются, соединения «подвисают», и обновление по времени снижает шанс странных ошибок.
Главный сигнал, что пул настроен плохо: время ответа растет, а база при этом не перегружена. Часто причина в том, что запросы стоят в очереди за свободным соединением.
Что логировать, чтобы не гадать
Добавьте метрики и логи хотя бы на уровне обработки запроса:
- общее время запроса к БД и число строк в ответе
- время ожидания соединения из пула (сколько запрос простоял до начала выполнения)
- количество ошибок таймаута и повторов (ретраев)
- текущие значения пула (open, in-use, idle)
Пример из практики: при генерации API (например, в TakProsto) легко получить несколько параллельных запросов на один экран. Если пул маленький, все упрется в ожидание соединения. Если слишком большой, Postgres начнет тратить время на переключение контекста и память, и станет хуже всем.
Лимиты и защита 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; - Проверьте, есть ли очередь ожиданий (lock/wait): поле
wait_eventвpg_stat_activity
Если вы видите всплески соединений и короткие запросы, которые начинают ждать, часто помогает PgBouncer. Он решает конкретную проблему: много коротких подключений от приложения. PgBouncer держит небольшой пул коннектов к Postgres и раздает их запросам, а не заставляет базу создавать новые процессы на каждый чих.
Даже при нормальном пуле бывают «зависания», которые тихо съедают коннекты. Поставьте ограничители, чтобы база сама чистила проблемные случаи: statement_timeout (чтобы не выполнялись бесконечные запросы) и idle_in_transaction_session_timeout (чтобы транзакции не висели без дела, держа блокировки). Типичный сценарий - обработчик открыл транзакцию, а потом ждет внешний вызов или упал до коммита. Такие сессии незаметно забивают лимит соединений и тормозят всех.
Если вы собираете API в TakProsto и планируете рост нагрузки, договоритесь о простом правиле: соединений меньше, чем кажется нужным, и таймауты включены по умолчанию. Это дешевле, чем искать «куда делись коннекты» в день запуска.
Планы запросов: как читать EXPLAIN и что менять
Когда 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;
В плане ищите несколько красных флагов. Они часто прямо указывают, что менять:
- Seq Scan по большой таблице: обычно не хватает индекса по условиям фильтра.
- Sort (особенно с большим числом строк): нет индекса под ORDER BY, или сортировка происходит после JOIN.
- Nested Loop с большим количеством повторов: один из JOIN возвращает слишком много строк, и база крутит цикл тысячи раз.
- Actual rows сильно больше/меньше, чем estimated rows: статистика устарела или условия плохо подходят под индекс.
- Buffers: много чтений с диска, хотя вы ожидали кеш.
Фраза "строк больше, чем ожидалось" опасна тем, что оптимизатор выбирает план, рассчитанный на маленький результат. В реальности он получает в 10-100 раз больше строк, и внезапно "быстрый" Nested Loop превращается в убийцу latency.
Что обычно помогает без переписывания всего сервиса:
- Уточнить условия: убрать лишние OR, привести типы (не сравнивать text с uuid), не оборачивать колонку в функции.
- Поменять порядок JOIN или сделать фильтрацию раньше (например, через подзапрос с LIMIT).
- Выбирать только нужные поля (особенно если таблица широкая).
- Добавить индекс под фильтр и сортировку (часто составной: (user_id, created_at DESC)).
- Обновить статистику (ANALYZE) после загрузок и миграций.
Мини-проверка: если запрос с LIMIT 20 все равно читает сотни тысяч строк, проблема почти всегда в индексе или в порядке выполнения (сортировка и JOIN происходят слишком рано).
Индексы для API: простые правила, которые работают
Большинство «тормозов» в 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 vs keyset
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). - Возвращайте клиенту курсор (пара значений или упакованную строку) вместе со списком.
- Выбирайте размер страницы по умолчанию 20-50, а максимальный
limitжестко ограничивайте (например, 100-200). - Для тяжелых списков сначала отдавайте только нужные поля, без “полных объектов”.
Если вы делаете API на Go + Postgres (в том числе в TakProsto), keyset-пагинация обычно дает самый быстрый выигрыш без сложного тюнинга: запросы становятся короткими, индексами пользоваться легче, а задержки не растут вместе с номером страницы.
JSON-ответы: меньше работы и меньше байт
JSON часто становится скрытым тормозом: база читает лишнее, приложение тратит CPU на сборку структуры, а сеть гоняет килобайты, которые клиент даже не использует. В настройке производительности Go и Postgres это один из самых быстрых способов выиграть время отклика.
Первое правило простое: не тяните лишние колонки. Если на экране списка нужны id, name, status и updated_at, не выбирайте description, большие текстовые поля и тем более JSONB с деталями. Делайте отдельные DTO для списка и для карточки. Это уменьшает и нагрузку на Postgres, и работу сериализации в Go.
Где формировать JSON: в приложении или в Postgres? Обычно проще поддерживать сборку в Go: вы видите типы, проще тестировать, легче менять формат. Генерация JSON в Postgres (json_build_object, агрегации) полезна, когда нужно собрать вложенные структуры одним запросом и вы точно контролируете план, но цена ошибки выше: сложнее читать запросы и легко случайно сделать тяжелую агрегацию.
Чтобы ответ был меньше, начните с формы данных:
- Снижайте вложенность, если клиенту не нужны глубокие деревья (лучше отдельный запрос за деталями).
- Не дублируйте одно и то же поле в разных местах объекта.
- Возвращайте числа и даты в стабильном формате, без лишних строковых полей вроде "pretty".
- Включайте компрессию на сервере (gzip или brotli), особенно для списков.
Пример: список из 50 сущностей. Если вы отдаете полный объект с 20 полями и вложенным meta, ответ может быть 80-150 КБ. Если оставить 5-6 полей для списка и сжать, часто получается 10-30 КБ. Разница заметна даже на локальной сети.
Кэширование на уровне API помогает, но не все можно кэшировать безопасно. Хорошо кэшируются публичные справочники, неизменяемые конфиги, списки без персональных данных. Осторожнее с ответами, зависящими от пользователя, ролей и времени: кэш нужно ключевать по параметрам, языку, правам и версии данных. В TakProsto, где API часто генерируются быстро, добавьте простое правило: кешируйте только то, что вы готовы отдавать любому пользователю при тех же параметрах запроса, и задавайте короткий TTL, пока нагрузка не стала предсказуемой.
Частые ошибки и ловушки при тюнинге Go + Postgres
Самая частая причина внезапных тормозов - не «медленный Postgres», а несколько мелких решений, которые вместе превращают API в перегретую систему. При настройке производительности Go и Postgres полезно сначала проверить типовые ловушки и убрать их до того, как придут первые реальные пользователи.
Пул соединений, который атакует вашу же базу
Слишком большой пул в Go выглядит безопасно: «пусть будет побольше, чтобы не ждать». На деле это часто эффект «сам себе DDoS». Когда каждый запрос к API может занять соединение, база начинает тратить время на планирование, контекстные переключения и ожидания, а не на полезную работу.
Простой признак: при росте нагрузки увеличивается не только p95, но и время в очередях, а CPU на базе подпрыгивает даже на простых запросах.
N+1 запросов, особенно после генерации кода
N+1 часто появляется незаметно, когда вы берете список сущностей, а потом для каждой подгружаете связанные данные отдельным запросом. В vibe-coding платформах это может всплыть и в сгенерированном коде, если структура ответа задумывалась «как в UI», а не «как в SQL».
Мини-сценарий: эндпоинт /orders отдает 50 заказов, а затем для каждого делает запрос за пользователем и суммой. Получается 101 запрос вместо 1-2, и задержка растет не плавно, а ступенькой.
Нет лимитов и есть тяжелые фильтры
Если endpoint принимает limit без верхней границы, вы сами разрешаете клиенту запросить «все сразу». То же с фильтрами: поиск по неиндексированному полю или сортировка по вычисляемому значению быстро превращаются в полный скан таблицы.
Долгие транзакции и забытые блокировки
Долгая транзакция удерживает ресурсы и может блокировать других. Частая ошибка - открыть транзакцию, сделать несколько запросов, а затем ждать внешнего ответа. Еще хуже - забытый SELECT ... FOR UPDATE, который нужен только при реальном конфликте записи.
Смешивание чтения и записи без необходимости
Часто логика «прочитать, посчитать, обновить счетчик» делается одним большим запросом или транзакцией «на всякий случай». Если обновление можно вынести отдельно или делать реже, вы уменьшите блокировки и нагрузку на WAL.
Короткий чек перед запуском:
- Ограничьте max limit и валидируйте сортировки и фильтры.
- Проверьте, сколько SQL-запросов делает один HTTP-запрос.
- Убедитесь, что транзакции живут миллисекунды, а не секунды.
- Не держите FOR UPDATE там, где нет гонки на запись.
- Сведите запись (счетчики, логи) к минимально необходимому.
Пример: список сущностей в API до и после оптимизации
Представим эндпоинт /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)показывает, что используется индекс и падает число прочитанных страниц.- Время запроса и p95 на одинаковом наборе тестовых данных заметно ниже.
- На нагрузке меньше активных соединений и меньше ожиданий по CPU/IO.
- Размер ответа в байтах уменьшается, а значит быстрее сеть и клиент.
Если вы собираете API в TakProsto, полезно сохранить этот сценарий как шаблон: список почти всегда лучше начинать с keyset + правильного индекса + минимального набора полей.
Следующие шаги: закрепить настройки и не откатиться назад
Хорошая настройка производительности Go и Postgres обычно ломается не из-за "плохого Postgres", а из-за мелких изменений в новых эндпоинтах. Поэтому цель после тюнинга простая: зафиксировать базовые правила и сделать так, чтобы команда нарушала их как можно реже.
Перед релизом пройдитесь по короткому чек-листу. Он занимает 10 минут, но спасает от типовых провалов на первых пользователях:
- Пул соединений в Go настроен осознанно: max open, max idle, время жизни соединений, контекстные таймауты на запрос.
- В Postgres есть лимиты от перегруза: max_connections и понятная политика, что будет при пике (очередь в приложении, а не лавина коннектов).
- Критичные запросы проверены через EXPLAIN: нет явных последовательных сканов там, где ожидается индекс.
- Для списков и фильтров есть индексы под реальные WHERE и ORDER BY, а пагинация выбрана заранее (keyset там, где рост данных быстрый).
- JSON-ответы не раздуты: возвращаете только нужные поля, без тяжелых вложений "на всякий случай".
Дальше важно автоматизировать то, что люди чаще всего забывают. Например, держите пару эталонных шаблонов запросов (поиск, список, детальная карточка), и договоритесь об одном формате пагинации для всех коллекций. Полезная привычка - включить логирование медленных запросов и собирать их в отдельный список для еженедельного разбора.
Чтобы не спорить на каждом PR, закрепите правила для новых эндпоинтов и код-ревью. Подойдет простой набор:
- Для каждого списка обязателен лимит и стабильная сортировка.
- Любой новый фильтр требует проверки индекса или аргумента, почему он не нужен.
- В каждом хендлере есть таймаут и понятная обработка отмены запроса.
- Нельзя добавлять поля в JSON без оценки размера и стоимости сборки.
- Для изменений SQL есть "контрольный" EXPLAIN до и после.
Если вы собираете API через TakProsto, используйте planning mode, чтобы заранее описать списки, фильтры и формат ответов. Перед изменениями делайте снимок проекта, чтобы можно было быстро откатиться, если новый запрос неожиданно стал дорогим. А когда логика устоялась, экспортируйте код и прогоните финальную проверку под нагрузкой уже на вашей инфраструктуре и с вашими настройками Postgres.