Guía de ajuste de rendimiento Go + Postgres para APIs generadas por IA: gestionar pool de conexiones, revisar planes de consulta, indexar inteligentemente, paginar sin degradar y optimizar JSON.

Las APIs generadas por IA pueden parecer rápidas en pruebas iniciales. Llamas un endpoint unas cuantas veces, el conjunto de datos es pequeño y las peticiones llegan de una en una. Luego llega el tráfico real: endpoints mezclados, cargas en ráfaga, caches más fríos y más filas de las previstas. El mismo código puede empezar a sentirse aleatoriamente lento aunque en realidad nada se haya roto.
La lentitud suele aparecer de varias formas: picos de latencia (la mayoría de peticiones están bien, pero algunas tardan 5x a 50x más), timeouts (un pequeño porcentaje falla) o CPU alta (CPU de Postgres por trabajo de consultas, o CPU de Go por JSON, goroutines, logging y reintentos).
Un escenario común es un endpoint de lista con un filtro flexible que devuelve un JSON grande. En una base de datos de prueba escanea unos pocos miles de filas y termina rápido. En producción escanea unos millones de filas, las ordena y solo después aplica un LIMIT. La API sigue “funcionando”, pero la latencia p95 se dispara y algunas peticiones hacen timeout durante ráfagas.
Para separar la lentitud de la base de datos de la lentitud de la app, mantén el modelo mental simple.
Si la base de datos es la lenta, tu handler en Go pasa la mayor parte del tiempo esperando la consulta. También puedes ver muchas peticiones “en vuelo” mientras la CPU de Go aparece normal.
Si la app es la lenta, la consulta termina rápido, pero se pierde tiempo después de la consulta: construir grandes objetos de respuesta, serializar JSON, ejecutar consultas extra por fila o hacer demasiado trabajo por petición. La CPU y memoria de Go suben, y la latencia crece con el tamaño de la respuesta.
“Lo suficientemente bien” antes del lanzamiento no es perfección. Para muchos endpoints CRUD, apunta a una latencia p95 estable (no solo la media), comportamiento predecible bajo ráfagas y sin timeouts en tu pico esperado. La meta es sencilla: nada de peticiones sorprendentemente lentas cuando crecen los datos y el tráfico, y señales claras cuando algo deriva.
Antes de tunear nada, decide qué significa “bueno” para tu API. Sin una línea base, es fácil pasar horas cambiando ajustes y no saber si mejoraste o solo moviste el cuello de botella.
Tres números suelen contar la mayor parte de la historia:
p95 es la métrica del “mal día”. Si p95 es alto pero la media está bien, un pequeño conjunto de peticiones está haciendo demasiado trabajo, quedando bloqueado por locks o disparando planes lentos.
Haz visibles las consultas lentas temprano. En Postgres, habilita el registro de consultas lentas con un umbral bajo para pruebas pre-lanzamiento (por ejemplo, 100–200 ms) y registra la sentencia completa para poder copiarla a un cliente SQL. Mantén esto temporal. Registrar todas las consultas lentas en producción se vuelve ruidoso rápido.
Después, prueba con peticiones que parezcan reales, no solo una ruta “hello world”. Un pequeño conjunto basta si coincide con lo que los usuarios harán: una llamada de lista con filtros y orden, una página de detalle con un par de joins, un create o update con validación y una consulta tipo búsqueda con coincidencias parciales.
Si generas endpoints a partir de una especificación (por ejemplo, con una herramienta de generación como Koder.ai), ejecuta el mismo puñado de peticiones repetidamente con entradas consistentes. Eso hace que cambios como índices, ajustes de paginación y reescrituras de consultas sean fáciles de medir.
Finalmente, elige un objetivo que puedas decir en voz alta. Ejemplo: “La mayoría de peticiones se mantienen por debajo de 200 ms p95 con 50 usuarios concurrentes, y los errores por debajo de 0.5%.” Los números exactos dependen del producto, pero un objetivo claro evita ajustes interminables.
Un pool de conexiones mantiene un número limitado de conexiones abiertas a la base de datos y las reutiliza. Sin pool, cada petición puede abrir una conexión nueva, y Postgres pierde tiempo y memoria gestionando sesiones en vez de ejecutar consultas.
La meta es mantener a Postgres ocupado haciendo trabajo útil, no cambiando contexto entre demasiadas conexiones. Este suele ser el primer beneficio significativo, especialmente para APIs generadas por IA que pueden convertirse en endpoints muy conversadores.
En Go normalmente ajustas max open connections, max idle connections y la vida de las conexiones. Un punto de partida seguro para muchas APIs pequeñas es un pequeño múltiplo de tus núcleos de CPU (a menudo 5 a 20 conexiones totales), con un número similar en idle, y reciclar conexiones periódicamente (por ejemplo, cada 30 a 60 minutos).
Si ejecutas varias instancias de la API, recuerda que el pool se multiplica. Un pool de 20 conexiones en 10 instancias son 200 conexiones contra Postgres, y así los equipos se topan inesperadamente con límites de conexión.
Los problemas de pool se sienten distintos a una SQL lenta.
Si el pool es muy pequeño, las peticiones esperan antes incluso de llegar a Postgres. La latencia hace picos, pero la CPU de la base de datos y los tiempos de consulta pueden parecer normales.
Si el pool es demasiado grande, Postgres parece sobrecargado: muchas sesiones activas, presión de memoria y latencias desiguales entre endpoints.
Una forma rápida de separar ambos es cronometrar las llamadas a BD en dos partes: tiempo esperando una conexión vs tiempo ejecutando la consulta. Si la mayor parte del tiempo es “esperando”, el pool es el cuello de botella. Si la mayor parte es “en consulta”, enfócate en SQL e índices.
Comprobaciones rápidas útiles:
max_connections.Si usas pgxpool, obtienes un pool orientado a Postgres con estadísticas claras y buenos valores predeterminados para comportamiento de Postgres. Si usas database/sql, tienes una interfaz estándar que funciona entre bases de datos, pero necesitas ser explícito sobre la configuración del pool y el comportamiento del driver.
Una regla práctica: si estás 100% con Postgres y quieres control directo, pgxpool suele ser más sencillo. Si dependes de librerías que esperan database/sql, quédate con él, configura el pool explícitamente y mide las esperas.
Ejemplo: un endpoint que lista órdenes puede tardar 20 ms, pero con 100 usuarios concurrentes salta a 2 s. Si los logs muestran 1.9 s esperando una conexión, tunear la consulta no ayudará hasta que el pool y el total de conexiones a Postgres estén dimensionados correctamente.
Cuando un endpoint se siente lento, revisa qué está haciendo Postgres. Una lectura rápida de EXPLAIN suele señalar la solución en minutos.
Ejecuta esto en el SQL exacto que envía tu 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;
Unas pocas líneas importan más. Observa el nodo superior (lo que Postgres eligió) y los totales al final (cuánto tardó). Luego compara filas estimadas vs reales. Brechas grandes suelen indicar que el planero (planner) se equivocó.
Si ves Index Scan o Index Only Scan, Postgres está usando un índice, lo cual suele ser bueno. Bitmap Heap Scan puede ser aceptable para coincidencias de tamaño medio. Seq Scan significa que leyó toda la tabla, lo cual solo está bien cuando la tabla es pequeña o casi todas las filas coinciden.
Señales de alerta comunes:
ORDER BY)Los planes lentos suelen venir de unos pocos patrones:
WHERE + ORDER BY (por ejemplo, (user_id, status, created_at))WHERE (por ejemplo, WHERE lower(email) = $1), que pueden forzar scans a menos que añadas un índice de expresión coincidenteSi el plan parece raro y las estimaciones están muy fuera, las estadísticas suelen estar obsoletas. Ejecuta ANALYZE (o deja que autovacuum lo haga) para que Postgres aprenda los recuentos actuales y la distribución de valores. Esto importa después de grandes importaciones o cuando nuevos endpoints empiezan a escribir muchos datos rápidamente.
Los índices solo ayudan cuando coinciden con cómo consultas los datos. Si los creas por suposiciones, obtienes escrituras más lentas, mayor almacenamiento y poco o ningún beneficio en lecturas.
Una forma práctica de pensarlo: un índice es un atajo para una pregunta específica. Si tu API hace otra pregunta, Postgres ignora el atajo.
Si un endpoint filtra por account_id y ordena por created_at DESC, un índice compuesto suele superar a dos índices separados. Ayuda a Postgres a encontrar las filas correctas y devolverlas en el orden adecuado con menos trabajo.
Reglas prácticas que suelen funcionar:
Ejemplo: si tu API tiene GET /orders?status=paid y siempre muestra lo más nuevo primero, un índice como (status, created_at DESC) encaja bien. Si la mayoría de consultas también filtra por cliente, (customer_id, status, created_at) puede ser mejor, pero solo si es así como el endpoint se ejecuta en producción.
Si la mayor parte del tráfico golpea una porción estrecha de filas, un índice parcial puede ser más barato y rápido. Por ejemplo, si tu app lee mayormente registros activos, indexar solo WHERE active = true mantiene el índice más pequeño y con más probabilidad de permanecer en memoria.
Para confirmar que un índice ayuda, haz comprobaciones rápidas:
EXPLAIN (o EXPLAIN ANALYZE en un entorno seguro) y busca un index scan que coincida con tu consulta.Elimina índices no usados con cuidado. Revisa estadísticas de uso (por ejemplo, si un índice ha sido escaneado). Suprime uno a la vez en ventanas de bajo riesgo y ten un plan de reversión. Los índices no usados no son inofensivos: ralentizan inserts y updates en cada escritura.
La paginación suele ser donde una API rápida empieza a sentirse lenta, aun cuando la base de datos está sana. Trata la paginación como un problema de diseño de consulta, no como un detalle UI.
LIMIT/OFFSET parece simple, pero las páginas profundas suelen costar más. Postgres aún tiene que saltarse (y a menudo ordenar) las filas que omites. La página 1 puede tocar unas pocas docenas de filas. La página 500 puede obligar al DB a escanear y descartar decenas de miles solo para devolver 20 resultados.
También puede crear resultados inestables cuando se insertan o eliminan filas entre peticiones. Los usuarios pueden ver duplicados o perder items porque el significado de “fila 10.000” cambia conforme la tabla cambia.
La paginación por keyset hace otra pregunta: “Dame las siguientes 20 filas después de la última que vi.” Eso mantiene a la BD trabajando sobre un trozo pequeño y consistente.
Una versión simple usa un id creciente:
SELECT id, created_at, title
FROM posts
WHERE id > $1
ORDER BY id
LIMIT 20;
Tu API devuelve un next_cursor igual al último id de la página. La siguiente petición usa ese valor como $1.
Para orden temporal, usa un orden estable y rompe empates. created_at solo no basta si dos filas comparten el mismo timestamp. Usa un cursor compuesto:
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
Algunas reglas para evitar duplicados y pérdidas:
ORDER BY (usualmente id).created_at e id juntos).Una razón sorprendentemente común por la que una API se siente lenta no es la base de datos, sino la respuesta. Un JSON grande tarda más en construirse, en enviarse y en parsearse en el cliente. La ganancia más rápida suele ser devolver menos.
Comienza por tu SELECT. Si un endpoint necesita solo id, name y status, pide esas columnas y nada más. SELECT * se hace más pesado con el tiempo a medida que las tablas ganan texto largo, blobs JSON y columnas de auditoría.
Otra ralentización frecuente es construir respuestas en N+1: obtienes una lista de 50 ítems y luego ejecutas 50 consultas más para adjuntar datos relacionados. Puede pasar las pruebas y luego colapsar con tráfico real. Prefiere una sola consulta que devuelva lo que necesitas (joins cuidadosos) o dos consultas donde la segunda agrupe por IDs.
Algunas formas de mantener payloads pequeños sin romper clientes:
include= (o una máscara fields=) para que las respuestas de lista sean ligeras y las de detalle opten por extras.Ambos pueden ser rápidos. Elige según lo que optimices.
Las funciones JSON de Postgres (jsonb_build_object, json_agg) son útiles cuando quieres menos viajes y formas predecibles desde una consulta. Modelar en Go es útil cuando necesitas lógica condicional, reutilizar structs o mantener SQL más fácil de mantener. Si tu SQL para construir JSON se vuelve difícil de leer, también se vuelve difícil de tunear.
Una buena regla: deja que Postgres filtre, ordene y agregue. Luego deja a Go encargarse de la presentación final.
Si generas APIs rápidamente (por ejemplo con Koder.ai), añadir flags de include temprano ayuda a evitar endpoints que se hinchan con el tiempo. También te da una forma segura de añadir campos sin hacer cada respuesta más pesada.
No necesitas un laboratorio enorme para detectar la mayoría de problemas de rendimiento. Un pase corto y repetible saca a la luz los problemas que se convierten en outages cuando llega el tráfico, especialmente si el punto de partida es código generado que planeas enviar.
Antes de cambiar nada, anota una línea base pequeña:
Empieza pequeño, cambia una cosa a la vez y vuelve a probar tras cada cambio.
Ejecuta una prueba de carga de 10 a 15 minutos que se parezca al uso real. Golpea los endpoints que verán tus primeros usuarios (login, listas, búsqueda, crear). Luego ordena rutas por latencia p95 y tiempo total gastado.
Revisa presión de conexiones antes de tunear SQL. Un pool demasiado grande satura Postgres. Un pool demasiado pequeño crea esperas largas. Busca tiempo de espera creciente para adquirir una conexión y recuentos de conexiones que suben en ráfaga. Ajusta límites de pool e idle primero, luego vuelve a ejecutar la misma carga.
EXPLAIN de las consultas más lentas y arregla la mayor señal de alarma. Los culpables habituales son scans completos en tablas grandes, sorts en grandes conjuntos de resultados y joins que explotan el recuento de filas. Elige la consulta peor y hazla aburrida.
Añade o ajusta un índice, luego vuelve a probar. Los índices ayudan cuando coinciden con tu WHERE y ORDER BY. No añadas cinco a la vez. Si tu endpoint lento es “listar órdenes por user_id ordenadas por created_at”, un índice compuesto en (user_id, created_at) puede ser la diferencia entre instantáneo y doloroso.
Reduce respuestas y paginación, luego vuelve a probar. Si un endpoint devuelve 50 filas con blobs JSON grandes, tu base de datos, la red y el cliente pagan el precio. Devuelve solo los campos que la UI necesita y prefiere paginación que no se vuelva más lenta al crecer las tablas.
Lleva un registro simple de cambios: qué cambió, por qué y qué movió en p95. Si un cambio no mejora tu línea base, reviértelo y sigue adelante.
La mayoría de problemas de rendimiento en APIs Go sobre Postgres son auto-infligidos. La buena noticia es que unas pocas comprobaciones detectan muchos de ellos antes de que llegue tráfico real.
Una trampa clásica es tratar el tamaño del pool como un control de velocidad. Ponerlo “tan alto como sea posible” suele hacer todo más lento. Postgres pasa más tiempo gestionando sesiones, memoria y locks, y tu app empieza a hacer timeouts en oleadas. Un pool más pequeño y estable con concurrencia predecible suele ganar.
Otro error común es “indexar todo”. Índices extra pueden ayudar lecturas, pero también ralentizan escrituras y pueden cambiar planes de consulta de forma sorprendente. Si tu API inserta o actualiza con frecuencia, cada índice adicional añade trabajo. Mide antes y después, y revisa planes tras añadir un índice.
La deuda de paginación se cuela silenciosamente. La paginación por offset parece bien al principio, luego la p95 sube con el tiempo porque la BD tiene que avanzar por más filas.
El tamaño del payload JSON es otro impuesto oculto. La compresión ayuda con el ancho de banda, pero no elimina el coste de construir, asignar y parsear objetos grandes. Recorta campos, evita anidamientos profundos y devuelve solo lo que la pantalla necesita.
Si solo observas la media de tiempo de respuesta, te perderás donde empieza el dolor real del usuario. p95 (y a veces p99) es donde la saturación del pool, esperas por locks y planes lentos aparecen primero.
Un chequeo rápido pre-lanzamiento:
EXPLAIN después de añadir índices o cambiar filtros.Antes de que lleguen usuarios reales, quieres evidencia de que tu API se mantiene predecible bajo estrés. La meta no son números perfectos: es detectar los pocos problemas que causan timeouts, picos o una base de datos que deja de aceptar trabajo.
Ejecuta comprobaciones en un entorno staging que se parezca a producción (rango de tamaño de BD similar, mismos índices, mismos ajustes de pool): mide la latencia p95 por endpoint clave bajo carga, captura tus consultas lentas principales por tiempo total, vigila el tiempo de espera en el pool, ejecuta EXPLAIN (ANALYZE, BUFFERS) en la peor consulta para confirmar que usa el índice esperado y verifica tamaños de payload en tus rutas más ocupadas.
Luego haz una corrida de peor caso que imite cómo se rompen los productos: solicita una página profunda, aplica el filtro más amplio y pruébalo con un arranque en frío (reinicia la API y golpea la misma petición primero). Si la paginación profunda se vuelve más lenta en cada página, cambia a paginación por cursor antes del lanzamiento.
Anota tus valores por defecto para que el equipo mantenga elecciones consistentes: límites y timeouts del pool, reglas de paginación (tamaño máximo de página, si se permite offset, formato de cursor), reglas de consulta (seleccionar solo columnas necesarias, evitar SELECT *, limitar filtros caros) y reglas de logging (umbral de consulta lenta, cuánto tiempo guardar muestras, cómo etiquetar endpoints).
Si generas y exportas servicios Go + Postgres con Koder.ai, hacer un pequeño pase de planificación antes del despliegue ayuda a mantener filtros, paginación y formas de respuesta intencionales. Una vez que empieces a afinar índices y formas de consulta, las snapshots y la reversión facilitan deshacer un “arreglo” que ayuda a un endpoint pero perjudica a otros. Si quieres un lugar único para iterar ese flujo de trabajo, Koder.ai en koder.ai está diseñado para generar y refinar esos servicios mediante chat y luego exportar el código cuando estés listo.
Comienza separando el tiempo de espera en BD del trabajo en la app.
Añade mediciones simples alrededor de “esperar conexión” y “ejecución de consulta” para ver qué lado domina.
Usa una línea base pequeña que puedas repetir:
Elige un objetivo claro como “p95 por debajo de 200 ms con 50 usuarios concurrentes, errores por debajo de 0.5%”. Cambia solo una cosa a la vez y vuelve a probar con la misma mezcla de peticiones.
Activa el logging de consultas lentas con un umbral bajo en pruebas pre-lanzamiento (por ejemplo, 100–200 ms) y registra la sentencia completa para poder copiarla a un cliente SQL.
Mantenlo temporal:
Cuando identifiques los peores culpables, pasa a muestreo o sube el umbral.
Un valor práctico por defecto es un pequeño múltiplo de los núcleos de CPU por instancia de API, a menudo 5–20 conexiones abiertas máximas, con un número similar de conexiones inactivas y reciclando conexiones cada 30–60 minutos.
Dos fallos comunes:
Recuerda que los pools se multiplican entre instancias (20 conexiones × 10 instancias = 200 conexiones).
Mide las llamadas a la BD en dos partes:
Si la mayor parte del tiempo es espera en el pool, ajusta el tamaño del pool, timeouts y el número de instancias. Si la mayor parte es ejecución de consulta, enfócate en EXPLAIN e índices.
También confirma que cierras y liberas conexiones puntualmente para devolverlas al pool.
Ejecuta EXPLAIN (ANALYZE, BUFFERS) en el SQL exacto que envía tu API y busca:
Los índices deben coincidir con lo que realmente hace el endpoint: filtros + orden.
Enfoque por defecto recomendable:
WHERE + ORDER BY.Usa un índice parcial cuando la mayor parte del tráfico accede a un subconjunto predecible de filas.
Patrón de ejemplo:
active = trueUn índice parcial como ... WHERE active = true se mantiene más pequeño, cabe más en memoria y reduce el overhead de escritura frente a indexarlo todo.
Confirma con que Postgres realmente lo usa para tus consultas de alto tráfico.
LIMIT/OFFSET se vuelve más lento en páginas profundas porque Postgres aún tiene que avanzar (y a menudo ordenar) las filas que se saltan. La página 1 puede tocar unas decenas de filas; la página 500 puede obligar a escanear y descartar decenas de miles solo para devolver 20 resultados.
Prefiere paginación por keyset (cursor):
Normalmente sí en endpoints de lista. La respuesta más rápida es la que no envías.
Ganancias prácticas:
SELECT *).rowsORDER BY)Arregla la mayor señal de alarma primero; no intentes tunear todo a la vez.
Ejemplo: si filtras por user_id y ordenas por lo más nuevo, un índice (user_id, created_at DESC) suele marcar la diferencia entre p95 estable y picos.
EXPLAINid).ORDER BY idéntico entre peticiones.(created_at, id) o similar en un cursor.Así el coste de cada página se mantiene aproximadamente constante a medida que la tabla crece.
include=fields=Reducir el tamaño del payload suele bajar la CPU de Go, la presión de memoria y la latencia en la cola.