Tuning de Connection Pools en Go

En Go, sql.DB no es una conexión única, sino un pool de conexiones gestionado por el driver. Cuando realizas una consulta, el pool busca una conexión disponible; si no hay ninguna, la crea (si no se ha alcanzado el límite) o bloquea la ejecución hasta que una se libere. Esta abstracción es vital porque establecer una nueva conexión TCP y realizar el handshake de TLS/autenticación de la base de datos es una operación extremadamente costosa en términos de latencia y CPU.

Si dejas la configuración por defecto, sql.DB no tiene un límite máximo de conexiones abiertas. Esto es una bomba de tiempo en producción: ante un pico de tráfico, tu aplicación intentará abrir miles de conexiones simultáneamente, agotando los recursos de la base de datos y provocando errores de tipo too many connections (en PostgreSQL) o haciendo que el sistema colapse por el consumo de memoria. Para evitarlo, debemos tunear tres parámetros críticos: SetMaxOpenConns, SetMaxIdleConns y SetConnMaxLifetime.

El uso de un pool de conexiones es obligatorio en cualquier servicio de alta disponibilidad. Si lo configuras mal, dos escenarios catastróficos pueden ocurrir: o saturas la base de datos con demasiadas conexiones, o generas una latencia enorme porque tus goroutines pasan más tiempo esperando una conexión disponible que ejecutando consultas.

package main

import (
	"context"
	"database/sql"
	"fmt"
	"log"
	"math/rand"
	"time"

	_ "github.com/lib/pq" // Driver de ejemplo para PostgreSQL
)

func main() {
	// Simulamos la conexión a PostgreSQL
	db, err := sql.Open("postgres", "postgres://user:pass@localhost:5432/dbname?sslmode=disable")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	// --- CONFIGURACIÓN DE TUNING ---

	// Limite máximo de conexiones totales (abiertas + inactivas).
	// Un buen punto de partida para PostgreSQL es (CPUs * 2) hasta (CPUs * 4),
	// pero nunca debe exceder el max_connections configurado en el servidor DB.
	db.SetMaxOpenConns(25)

	// Cantidad máxima de conexiones que se mantendrán abiertas en el pool
	// aunque no se estén usando. Debe ser <= MaxOpenConns.
	db.SetMaxIdleConns(25)

	// Tiempo máximo de vida de una conexión. 
	// Crucial para evitar conexiones "zombies" que los load balancers 
	// (como AWS NLB) matan silenciosamente por inactividad.
	db.SetConnMaxLifetime(5 * time.Minute)

	// Tiempo máximo que una conexión puede estar inactiva antes de cerrarse.
	db.SetConnMaxIdleTime(2 * time.Minute)

	// Monitoreo en segundo plano de las estadísticas del pool
	go monitorPool(db)

	// Simulación de carga de trabajo
	for i := 0; i < 100; i++ {
		go fakeWorker(db, i)
		time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
	}

	// Mantener la ejecución para ver el monitor
	select {}
}

func fakeWorker(db *sql.DB, id int) {
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	// Simulamos una consulta que tarda algo de tiempo
	err := db.PingContext(ctx)
	if err != nil {
		// Si el pool está saturado, este error o el timeout ocurrirán frecuentemente
		fmt.Printf("[Worker %d] Error/Timeout: %v\n", id, err)
		return
	}
	time.Sleep(time.Duration(rand.Intn(500)) * time.Millisecond)
}

func monitorPool(db *sql.DB) {
	ticker := time.NewTicker(1 * time.Second)
	for range ticker.C {
		stats := db.Stats()
		// WaitCount es la métrica más importante: indica cuántas goroutines
		// han tenido que esperar porque no había conexiones disponibles.
		fmt.Printf("[MONITOR] Abiertas: %d | En uso: %d | Idle: %d | Esperas: %d | Max Esperas: %d\n",
			stats.MaxOpenConnections, stats.InUse, stats.Idle, stats.WaitCount, stats.WaitDuration)
	}
}

En el código anterior, hemos configurado el pool para que sea agresivo pero controlado. Al fijar SetMaxOpenConns(25), garantizamos que nuestra aplicación nunca intentará abrir más de 25 conexiones, protegiendo la integridad del servidor PostgreSQL. Hemos emparejado SetMaxIdleConns(25) con el máximo de conexiones abiertas para evitar el “churning” (creación y cierre constante de conexiones), lo cual es una práctica recomendada cuando se trabaja con cargas de trabajo predecibles.

El uso de db.Stats() es fundamental para el tuning. Fíjate en stats.WaitCount dentro de nuestra función monitorPool. Si ese número crece de forma constante mientras tu servicio está bajo carga, significa que tienes un cuello de botella: tus consultas son muy lentas o tu MaxOpenConns es demasiado bajo para el volumen de peticiones. Es el indicador principal para decidir si necesitas escalar horizontalmente la base de datos o aumentar el límite de conexiones permitidas.

Por último, SetConnMaxLifetime es nuestra red de seguridad contra la infraestructura de red. En entornos de nube, los balanceadores de carga suelen cerrar conexiones TCP que permanecen inactivas por un tiempo determinado. Si Go intenta usar una conexión que el balanceador ya cerró, recibirás errores de unexpected EOF o connection reset by peer. Configurar un tiempo de vida menor al timeout del balanceador asegura que Go descarte la conexión antes de que el componente de red la corte.

El error frecuente

Un error común es configurar SetMaxIdleConns con un valor significativamente menor que SetMaxOpenConns. Por ejemplo:

db.SetMaxOpenConns(100)
db.SetMaxIdleConns(2) // ¡Error potencial de rendimiento!

Si tu aplicación tiene picos de tráfico donde necesita 50 conexiones simultáneas, el pool abrirá 50 conexiones. Sin embargo, en cuanto la carga baje ligeramente, el pool destruirá 48 de ellas para cumplir con el límite de MaxIdleConns. Si la carga vuelve a subir un segundo después, el driver tendrá que realizar nuevamente el handshake de 48 conexiones nuevas. Este ciclo de “abrir y cerrar” constante se conoce como connection churning y consume muchísima CPU y latencia de red innecesariamente.

196

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio