Плейбук по оптимизации Go + Postgres для AI-генерируемых API: настройка пула, чтение планов EXPLAIN, разумные индексы, безопасная пагинация и быстрое формирование JSON.

AI-генерируемые API могут казаться быстрыми в ранних тестах. Вы несколько раз вызываете эндпоинт, набор данных маленький, запросы идут по одному. Потом приходит реальный трафик: смешанные эндпоинты, всплески нагрузки, холодные кеши и больше строк, чем вы ожидали. Тот же код может начать ощущаться случайно медленным, хотя ничего явно не сломалось.
Медленность обычно проявляется так: всплески задержек (большинство запросов в порядке, но некоторые в 5–50× медленнее), таймауты (процент неудач), или сильно загруженный CPU (Postgres тратит CPU на выполнение запросов, или Go — на JSON, горутины, логирование и ретраи).
Частый сценарий — list-эндпоинт с гибким фильтром, который возвращает большой JSON. В тестовой БД он сканирует пару тысяч строк и быстро завершает работу. В продакшне он сканирует миллионы строк, сортирует их, и только потом применяет LIMIT. API всё ещё «работает», но p95 растёт, и некоторые запросы таймаутят при всплесках.
Чтобы отделить медленную базу от медленного приложения, держите простую модель в голове.
Если база медленная, ваш Go-хендлер большую часть времени ждёт выполнения запроса. Вы также можете увидеть много запросов «в полёте», тогда как CPU Go остаётся нормальным.
Если приложение медлит, запросы завершаются быстро, но время теряется после запроса: сбор крупных ответов, маршалинг JSON, дополнительные запросы на строку или слишком много работы на один запрос. CPU Go растёт, память растёт, и задержка растёт с размером ответа.
«Достаточно хорошо» перед запуском — не обязательно идеально. Для многих CRUD-эндпоинтов стремитесь к стабильному p95 (не только к среднему), предсказуемому поведению при всплесках и отсутствию таймаутов при ожидаемом пике. Цель простая: никаких неожиданных медленных запросов при росте данных и трафика и ясные сигналы при дрейфе.
Прежде чем что-то настраивать, решите, что значит «хорошо» для вашего API. Без базовой линии легко часами менять настройки и не понять, улучшили ли вы что-то или просто сдвинули узкое место.
Три числа обычно рассказывают большую часть истории:
p95 — это метрика «плохого дня». Если p95 высокий, а среднее нормальное, значит небольшой набор запросов делает лишнюю работу, блокируется из-за локов или вызывает медленные планы.
Сделайте медленные запросы видимыми как можно раньше. В Postgres включите логирование медленных запросов с низким порогом для предрелизного тестирования (например, 100–200 ms) и логируйте полный statement, чтобы можно было скопировать его в SQL-клиент. Держите это временно: логировать все медленные запросы в продакшне быстро станет шумно.
Далее тестируйте реальными запросами, а не просто «hello world» маршрутом. Небольшой набор достаточно хорош, если он соответствует тому, что будут делать пользователи: вызов списка с фильтрами и сортировкой, страница детали с парой JOIN-ов, create/update с валидацией и поисковый запрос с частичными совпадениями.
Если вы генерируете эндпоинты из спецификации (например, с помощью инструментов вроде Koder.ai), прогоняйте тот же небольшой набор запросов многократно с одинаковыми входными данными. Это облегчает измерение эффектов от индексов, правок пагинации и переписывания запросов.
Наконец, выберите цель, которую можно озвучить. Пример: «Большинство запросов — p95 < 200 ms при 50 одновременных пользователях, ошибки < 0.5%». Конкретные числа зависят от продукта, но ясная цель предотвращает бесконечную возню.
Пул соединений ограничивает число открытых соединений к базе и переиспользует их. Без пула каждый запрос может открывать новое соединение, и Postgres тратит время и память на управление сессиями вместо выполнения запросов.
Цель — чтобы Postgres тратил ресурсы на полезную работу, а не на переключение контекста между множеством соединений. Это часто первый заметный выигрыш, особенно для AI-генерируемых API, которые незаметно превращаются в разговорные (chatty) эндпоинты.
В Go обычно настраивают max open connections, max idle connections и lifetime соединений. Безопасная отправная точка для многих небольших API — небольшое кратное числу CPU (часто 5–20 соединений), с похожим числом idle и периодической ротацией соединений (например, каждые 30–60 минут).
Если у вас несколько инстансов API, помните, что пулы суммируются. Пул на 20 соединений в 10 инстансах — это 200 соединений к Postgres, и так команды неожиданно упираются в лимиты подключений.
Проблемы с пулом ощущаются иначе, чем медленный SQL.
Если пул слишком мал, запросы ждут, прежде чем попасть в Postgres. Появляются всплески задержек, но CPU БД и время запросов могут выглядеть нормальными.
Если пул слишком велик, Postgres выглядит перегруженным: много активных сессий, давление на память и разная латентность по эндпоинтам.
Быстрый способ различить: разделите тайминг DB-вызовов на два этапа — время ожидания соединения и время выполнения запроса. Если большая часть — «ожидание», узкое место в пуле. Если время в основном «в запросе», фокус на SQL и индексах.
Полезные быстрые проверки:
max_connections.Если вы используете pgxpool, вы получаете пул, ориентированный на Postgres, с понятной статистикой и хорошими дефолтами для поведения Postgres. Если вы используете database/sql, у вас стандартный интерфейс для разных БД, но нужно явно задавать параметры пула и учитывать поведение драйвера.
Практическое правило: если вы полностью на Postgres и хотите прямой контроль, pgxpool часто проще. Если вы полагаетесь на библиотеки, ожидающие database/sql, оставайтесь с ним, явно настройте пул и измеряйте ожидания.
Пример: эндпоинт, который в норме выполняется за 20 ms, при 100 параллельных пользователях прыгает до 2 s. Если логи показывают 1.9 s ожидания соединения, настройка SQL не поможет, пока правильно не подобрать пул и общее число соединений к Postgres.
Когда эндпоинт медлит, посмотрите, что реально делает Postgres. Быстрый разбор EXPLAIN часто указывает на исправление за минуты.
Запустите это для того самого SQL, который отправляет ваш API:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, status, created_at
FROM orders
WHERE user_id = $1 AND status = $2
ORDER BY created_at DESC
LIMIT 50;
Несколько строк важны больше всего. Смотрите на верхний узел (что выбрал планировщик) и итоги внизу (сколько времени заняло). Затем сравните estimated vs actual rows. Большие расхождения обычно означают, что планировщик ошибся.
Если вы видите Index Scan или Index Only Scan, Postgres использует индекс — обычно это хорошо. Bitmap Heap Scan может быть нормой для средних выборок. Seq Scan означает чтение всей таблицы, что годится, только если таблица маленькая или почти все строки соответствуют фильтру.
Типичные сигналы тревоги:
ORDER BY)Медленные планы обычно из-за нескольких паттернов:
WHERE + ORDER BY (например, (user_id, status, created_at))WHERE (например, WHERE lower(email) = $1), которые вынуждают сканы, если не добавить индекс по выражениюЕсли план выглядит странно и оценки сильно промахиваются, вероятно, статистика устарела. Запустите ANALYZE (или дождитесь autovacuum), чтобы Postgres узнал текущие распределения значений. Это важно после больших импортов или когда новые эндпоинты быстро пишут много данных.
Индексы помогают только тогда, когда они соответствуют тому, как API запрашивает данные. Если строить их наугад, вы получите более медленные записи, больший объём хранения и мало ускорения.
Практичный подход: индекс — это ярлык для конкретного вопроса. Если API задаёт другой вопрос, Postgres проигнорирует ярлык.
Если эндпоинт фильтрует по account_id и сортирует по created_at DESC, единый составной индекс обычно лучше двух отдельных. Он помогает Postgres найти нужные строки и вернуть их в нужном порядке с меньшими затратами.
Правила, которые обычно помогают:
Пример: если ваш API GET /orders?status=paid и всегда показывает самые новые, индекс (status, created_at DESC) хорошо подходит. Если большинство запросов также фильтрует по customer, (customer_id, status, created_at) может быть лучше, но только если это действительно отражает работу эндпоинта в продакшне.
Если трафик в основном попадает в узкую часть строк, частичный индекс может быть дешевле и быстрее. Например, если приложение в основном читает активные записи, индекс только WHERE active = true делает индекс меньше и более вероятно удержится в памяти.
Чтобы подтвердить пользу индекса:
EXPLAIN (или EXPLAIN ANALYZE в безопасной среде) и ищите индексный проход, соответствующий запросу.Удаляйте неиспользуемые индексы осторожно. Проверьте статистику использования (сканируется ли индекс). Удаляйте по одному в окна низкого риска и имейте план отката. Неиспользуемые индексы — не безвредны: они замедляют вставки и обновления при каждой записи.
Пагинация — частое место, где быстрый API начинает казаться медленным, даже когда база здорова. Рассматривайте пагинацию как задачу проектирования запроса, а не как деталь UI.
LIMIT/OFFSET выглядит просто, но глубокие страницы обычно дороже. Postgres всё равно должен пройти (и часто отсортировать) пропускаемые строки. Страница 1 может трогать десятки строк. Страница 500 — может заставить БД просканировать и отбросить десятки тысяч строк, чтобы вернуть 20 результатов.
Это также даёт нестабильные результаты при вставках/удалениях между запросами — пользователи могут видеть дубликаты или пропущенные элементы, потому что смысл "строки 10 000" меняется.
Keyset-пагинация задаёт другой вопрос: «Дайте следующие 20 строк после последней увиденной». Так база работает с маленькой, стабильной частью данных.
Простой вариант на возрастающем id:
SELECT id, created_at, title
FROM posts
WHERE id > $1
ORDER BY id
LIMIT 20;
API возвращает next_cursor как последний id на странице. Следующий запрос использует это значение как $1.
Для сортировки по времени используйте стабильный порядок и дополнительный tie-breaker. created_at сам по себе недостаточен, если две строки имеют одинаковую метку времени. Используйте составной курсор:
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
Небольшие правила, чтобы избежать дубликатов и пропаж:
ORDER BY (обычно id).created_at и id вместе).Удивительно частая причина ощущения медленности — не база, а ответ. Большой JSON дольше строить, дольше отправлять и дольше парсить клиентом. Самый быстрый выигрыш часто — вернуть меньше данных.
Начните с SELECT. Если эндпоинту нужны только id, name и status, запрашивайте только эти колонки. SELECT * тихо становится тяжелей со временем, когда в таблицу добавляются большие тексты, JSON-блобы и служебные колонки.
Ещё частая медленность — паттерн N+1: вы получаете список из 50 элементов, а затем запускаете 50 дополнительных запросов, чтобы добавить связанные данные. Это может пройти в тестах, но рухнет под реальной нагрузкой. Предпочитайте один запрос, который возвращает всё нужное (осторожные JOIN-ы), или два запроса, где второй батчит по ID.
Несколько способов держать payload меньше, не ломая клиентов:
include= (или маску fields=), чтобы ответы списка оставались лёгкими, а детали — опциональными.Оба подхода могут быть быстрыми. Выбирайте в зависимости от оптимизационной цели.
Функции Postgres (jsonb_build_object, json_agg) полезны, когда нужно меньше кругов общения с БД и предсказуемая форма из одного запроса. Формирование в Go удобно, когда нужна условная логика, повторное использование структур или поддерживаемость SQL. Если SQL для построения JSON становится нечитаемым, его сложно будет оптимизировать.
Хорошее правило: пусть Postgres фильтрует, сортирует и агрегирует, а Go делает финальное представление.
Если вы быстро генерируете API (например с Koder.ai), добавление include-флагов ранним этапом помогает избежать эндпоинтов, которые раздуваются со временем. Это даёт безопасный путь добавлять поля, не утяжеляя все ответы.
Вам не нужна огромная тестовая лаборатория, чтобы поймать большинство проблем с производительностью. Короткий воспроизводимый проход выявит проблемы, которые превращаются в аварии при появлении трафика, особенно если начальная точка — сгенерированный код, который вы собираетесь выпустить.
Перед изменениями зафиксируйте простую базовую линию:
Начинайте с малого, меняйте по одному параметру и тестируйте после каждого изменения.
Запустите 10–15 минутный нагрузочный тест, имитирующий реальное использование. Попадайте в те же эндпоинты, что будут у первых пользователей (логин, списки, поиск, создание). Потом отсортируйте маршруты по p95 задержке и суммарному времени.
Проверьте давление на соединения до правки SQL. Слишком большой пул перегружает Postgres. Слишком маленький пул вызывает долгие ожидания. Смотрите на растущее время ожидания соединения и всплески количества подключений. Настройте пул и idle лимиты, затем повторите нагрузку.
EXPLAIN для самых медленных запросов и исправьте самый явный красный флаг. Обычные виновники: полные сканы больших таблиц, сортировки больших наборов результатов и JOIN-ы, которые взрывают число строк. Выберите один самый худший запрос и сделайте его «скучным» (предсказуемым).
Добавьте или поправьте один индекс, затем тестируйте снова. Индексы помогают, когда они соответствуют WHERE и ORDER BY. Не добавляйте пять сразу. Если медленный эндпоинт — «список заказов по user_id, отсортированный по created_at», составной индекс (user_id, created_at) может решить всё.
Уточните ответы и пагинацию, затем снова тестируйте. Если эндпоинт возвращает 50 строк с большими JSON-блобами, платят база, сеть и клиент. Возвращайте только поля, которые нужны UI, и предпочитайте пагинацию, которая не замедляется с ростом таблиц.
Ведите простой журнал изменений: что поменяли, почему и как двинулись p95. Если изменение не улучшило базовую линию — откатите и идите дальше.
Большинство проблем с производительностью в Go API на Postgres — самосделанные. Хорошая новость: несколько проверок ловят многие из них до прихода реального трафика.
Классическая ловушка — считать размер пула «ручкой скорости». Установка его «насколько возможно больше» часто делает всё медленнее. Postgres начинает тратить время на управление сессиями, памятью и локами, и приложение начинает таймаутить волнами. Меньший, стабильный пул с предсказуемой конкуренцией обычно выигрывает.
Ещё одна ошибка — «проиндексировать всё». Лишние индексы помогают чтениям, но замедляют записи и могут неожиданно менять планы запросов. Если API часто вставляет или обновляет, каждый лишний индекс добавляет работу. Измеряйте до и после и пересмотрите планы после добавления индекса.
Долг пагинации подкрадывается незаметно. OFFSET-пагинация выглядит нормально сначала, но со временем p95 растёт, потому что базе приходится проходить больше строк.
Размер JSON — ещё один скрытый налог. Сжатие помогает по пропускной способности, но не убирает стоимость сборки, аллокаций и парсинга больших объектов. Тримьте поля, избегайте глубокой вложенности и возвращайте только то, что нужно экрану.
Если вы смотрите только на среднее время ответа, вы пропустите точки реального пользовательского боли. p95 (и иногда p99) — там, где сначала проявляются насыщение пула, ожидания локов и медленные планы.
Короткая предрелизная проверка:
EXPLAIN после добавления индексов или изменения фильтров.Перед приходом реальных пользователей вы хотите доказательства, что API остаётся предсказуемым под нагрузкой. Цель не в идеальных цифрах, а в ловле тех проблем, которые приводят к таймаутам, всплескам или когда база перестаёт принимать работу.
Прогоняйте проверки в staging, похожем на прод (приблизительный объём БД, те же индексы, те же настройки пула): измерьте p95 по ключевым эндпоинтам под нагрузкой, зафиксируйте самые медленные запросы по суммарному времени, смотрите время ожидания пула, запускайте EXPLAIN (ANALYZE, BUFFERS) для худшего запроса, чтобы подтвердить использование ожидаемого индекса, и проверьте размеры полезной нагрузки по самым загруженным маршрутам.
Затем сделайте один worst-case прогон, который имитирует, как ломается продукт: запросите глубокую страницу, примените самый широкий фильтр и попробуйте с холодного старта (рестарт API и тот же запрос первым). Если глубокая пагинация замедляется с каждой страницей, перейдите на курсорную пагинацию до релиза.
Запишите ваши дефолты, чтобы команда дальше делала согласованные выборы: лимиты и таймауты пула, правила пагинации (максимальный размер страницы, разрешение OFFSET или только курсоры), правила запросов (выбирать только нужные колонки, избегать SELECT *, ограничивать тяжёлые фильтры) и правила логирования (порог медленных запросов, как долго хранить сэмплы, как маркировать эндпоинты).
Если вы создаёте и экспортируете Go + Postgres сервисы с Koder.ai, короткая плановая проверка перед деплоем помогает держать фильтры, пагинацию и форму ответов в курсе. Как только вы начнёте править индексы и формы запросов, снимки и откаты облегчают отмену «фикса», который помог одному эндпоинту, но повредил другим. Если хотите единое место для итераций этого рабочего процесса, Koder.ai на koder.ai создан для генерации и доработки таких сервисов через чат, с возможностью экспортировать исходники, когда вы готовы.
Начните с разделения времени ожидания БД и времени работы приложения.
Добавьте простые тайминги вокруг «ожидания соединения» и «выполнения запроса», чтобы понять, какая сторона доминирует.
Используйте небольшой воспроизводимый базовый набор метрик:
Задайте понятную цель, например «p95 < 200 ms при 50 одновременных пользователях, ошибки < 0.5%». Меняйте только по одному параметру и заново тестируйте тот же набор запросов.
Включите логирование медленных запросов с низким порогом в предрелизной среде (например, 100–200 ms) и сохраняйте полный текст запроса, чтобы можно было вставить его в SQL-клиент.
Держите это временно:
Когда найдёте основные виновники, переключитесь на выборку или повысите порог.
Практичный дефолт — небольшое кратное числу CPU на инстанс API, часто 5–20 max open connections, с похожим числом idle и ротацией соединений каждые 30–60 минут.
Два частых режима отказа:
Помните, что пулы суммируются между инстансами (20 × 10 = 200 соединений).
Замерьте DB-вызовы в двух частях:
Если большая часть — ожидание пула, перенастройте размер пула, таймауты и число инстансов. Если большая часть — выполнение запроса, фокусируйтесь на EXPLAIN и индексах.
Также убедитесь, что вы всегда закрываете rows, чтобы соединения возвращались в пул.
Запустите EXPLAIN (ANALYZE, BUFFERS) на том самом SQL, который посылает API, и смотрите:
Индексы должны соответствовать тому, как endpoint реально запрашивает данные: фильтры + порядок сортировки.
Хороший шаблон:
WHERE + ORDER BY.Частичный индекс полезен, когда трафик в основном обращается к предсказуемой подвыборке строк.
Пример: большинство чтений только для active = true — индекс ... WHERE active = true будет меньше, с большей вероятностью влезет в память и снизит накладные расходы на запись по сравнению с индексированием всего.
Подтвердите с помощью EXPLAIN, что Postgres действительно использует этот индекс для ваших горячих запросов.
LIMIT/OFFSET медлит на глубоких страницах, потому что Postgres всё равно проходит (и часто сортирует) пропускаемые строки. Страница 1 может обрабатывать десятки строк, страница 500 — десятки тысяч, чтобы вернуть 20 результатов.
Предпочитайте keyset (cursor) pagination:
Чаще всего — да для списковых эндпоинтов. Самый быстрый ответ — тот, который вы не послали.
Практичные приёмы:
SELECT *).include= или , чтобы клиенты сами включали тяжёлые поля.ORDER BY)Исправьте самый большой красный флаг в первую очередь; не пытайтесь править всё сразу.
Пример: если вы фильтруете по user_id и показываете новейшие, индекс (user_id, created_at DESC) часто решает проблему с p95.
id).ORDER BY между запросами.(created_at, id) или похожие значения в курсор.Так стоимость каждой страницы остаётся примерно постоянной по мере роста таблицы.
fields=Часто уменьшение полезной нагрузки снижает CPU Go, использование памяти и хвостовую латентность.