Pruebas de Carga y Ajustes Finales

Lectura
30 min~5 min lectura

Concepto clave

Las pruebas de carga en PostgreSQL son como simular el Black Friday en un centro comercial virtual: sometes tu base de datos a miles de usuarios concurrentes para identificar cuellos de botella antes del lanzamiento real. En sistemas de reservas de alta demanda, un segundo de latencia puede significar miles de transacciones perdidas. Estas pruebas no solo miden el rendimiento, sino que revelan cómo se comportan los bloqueos, las transacciones largas y la escalabilidad horizontal bajo estrés.

Imagina un estadio donde 50,000 personas intentan comprar boletos simultáneamente. Sin pruebas de carga, tu base de datos podría colapsar como una taquilla con un solo empleado. El objetivo es transformar ese caos en un flujo ordenado, donde cada transacción se complete en milisegundos, manteniendo la integridad ACID incluso cuando 100 conexiones intentan reservar el mismo asiento.

Cómo funciona en la práctica

Para un sistema de reservas, ejecutarás pruebas que simulen picos de 10,000 usuarios en 5 minutos. Primero, preparas datos realistas: 100,000 eventos con asientos disponibles. Luego, configuras herramientas como pgbench o scripts personalizados que ejecuten transacciones concurrentes. El proceso clave es monitorear métricas en tiempo real:

  1. Latencia de consultas (95% bajo 100ms)
  2. Transacciones por segundo (TPS)
  3. Bloqueos y deadlocks en pg_stat_activity
  4. Uso de CPU y memoria del servidor PostgreSQL

Por ejemplo, al detectar que las consultas de SELECT asiento FROM reservas WHERE evento_id = X se ralentizan, aplicarás un índice parcial solo para eventos activos. Así, optimizas sin sobrecargar el sistema.

Código en acción

Configuración inicial de pgbench para simular reservas:

-- Crear datos de prueba
CREATE TABLE eventos (
    id SERIAL PRIMARY KEY,
    nombre VARCHAR(100),
    asientos_disponibles INT CHECK (asientos_disponibles >= 0),
    version INT DEFAULT 0 -- Para optimistic locking
);

CREATE TABLE reservas (
    id BIGSERIAL PRIMARY KEY,
    evento_id INT REFERENCES eventos(id),
    usuario_id INT,
    asiento INT,
    creado_en TIMESTAMP DEFAULT NOW(),
    UNIQUE(evento_id, asiento)
);

-- Insertar 1000 eventos con 100 asientos cada uno
INSERT INTO eventos (nombre, asientos_disponibles)
SELECT 'Evento ' || generate_series(1,1000), 100;

Script personalizado para pruebas de concurrencia:

import psycopg2
import concurrent.futures
import time

def reservar_asiento(evento_id, usuario_id):
    conn = psycopg2.connect("dbname=reservas user=postgres")
    cursor = conn.cursor()
    try:
        cursor.execute("BEGIN")
        # Verificar disponibilidad con row locking
        cursor.execute("""
            SELECT asientos_disponibles 
            FROM eventos 
            WHERE id = %s FOR UPDATE
        """, (evento_id,))
        disponible = cursor.fetchone()[0]
        if disponible > 0:
            cursor.execute("""
                UPDATE eventos 
                SET asientos_disponibles = asientos_disponibles - 1,
                    version = version + 1
                WHERE id = %s
                RETURNING version
            """, (evento_id,))
            nuevo_version = cursor.fetchone()[0]
            cursor.execute("""
                INSERT INTO reservas (evento_id, usuario_id, asiento)
                VALUES (%s, %s, %s)
            """, (evento_id, usuario_id, disponible))
            conn.commit()
            return f"Reserva exitosa, versión {nuevo_version}"
        else:
            conn.rollback()
            return "Sin asientos disponibles"
    except Exception as e:
        conn.rollback()
        return f"Error: {e}"
    finally:
        conn.close()

# Simular 100 usuarios concurrentes
evento_id = 1
with concurrent.futures.ThreadPoolExecutor(max_workers=100) as executor:
    futures = [executor.submit(reservar_asiento, evento_id, i) for i in range(100)]
    for future in concurrent.futures.as_completed(futures):
        print(future.result())

Errores comunes

  • No probar con datos realistas: Usar solo 100 eventos cuando en producción habrá millones. Solución: Generar datasets que reflejen la distribución real de datos, incluyendo eventos populares vs. nicho.
  • Ignorar el efecto de los índices en escrituras: Agregar índices en cada columna ralentiza INSERTS/UPDATES. Solución: Usar índices parciales (ej: CREATE INDEX idx_eventos_activos ON eventos(id) WHERE asientos_disponibles > 0).
  • Olvidar el connection pooling: Abrir y cerrar conexiones por cada transacción genera overhead. Solución: Configurar PgBouncer con modo transaction pooling.
  • No monitorear deadlocks: En alta concurrencia, transacciones que actualizan múltiples tablas en orden diferente causan bloqueos circulares. Solución: Usar log_lock_waits y establecer timeouts con lock_timeout.
  • Optimizar solo para el caso promedio: Las reservas tienen picos extremos. Solución: Diseñar pruebas que simulen el peor escenario (ej: 90% de tráfico en los primeros 30 segundos).

Checklist de dominio

  1. ¿Has ejecutado pruebas con al menos 10x la carga esperada en producción?
  2. ¿Los tiempos de respuesta del 95% de las consultas están bajo 200ms durante picos?
  3. ¿Has identificado y eliminado al menos un cuello de botella mediante EXPLAIN ANALYZE?
  4. ¿Configuraste alertas para deadlocks y transacciones largas (>5s)?
  5. ¿Validaste que los índices nuevos no degradan el rendimiento de escritura en más del 10%?
  6. ¿Probaste el sistema durante 24 horas continuas para detectar memory leaks o fragmentación?
  7. ¿Documentaste los ajustes de configuración (shared_buffers, max_connections) basados en resultados de pruebas?

Optimizar un Hotspot de Reservas con Particionamiento

En este ejercicio, mejorarás el rendimiento de un sistema de reservas donde el 80% de las transacciones se concentran en el 20% de los eventos más populares. Sigue estos pasos:

  1. Prepara el entorno: Crea una base de datos reservas_load y ejecuta el script de datos iniciales del ejemplo anterior.
  2. Identifica el hotspot: Ejecuta 5000 reservas concurrentes para el evento_id=1 usando el script Python. Mide el TPS y los bloqueos con:
    SELECT wait_event_type, wait_event, query FROM pg_stat_activity WHERE wait_event IS NOT NULL;
  3. Implementa particionamiento por rango: Crea una tabla particionada para reservas por evento_id, separando eventos populares (id < 100) del resto:
    CREATE TABLE reservas (
        id BIGSERIAL,
        evento_id INT,
        usuario_id INT,
        asiento INT,
        creado_en TIMESTAMP DEFAULT NOW(),
        PRIMARY KEY (id, evento_id)
    ) PARTITION BY RANGE (evento_id);
    
    CREATE TABLE reservas_populares PARTITION OF reservas
        FOR VALUES FROM (1) TO (100);
    CREATE TABLE reservas_generales PARTITION OF reservas
        FOR VALUES FROM (100) TO (MAXVALUE);
  4. Migra los datos: Inserta los datos existentes en la nueva estructura usando INSERT INTO reservas SELECT * FROM old_reservas;
  5. Vuelve a ejecutar la prueba: Compara el TPS y la latencia antes y después del particionamiento. Verifica que las consultas a eventos populares ahora usen solo la partición pequeña.
  6. Documenta los resultados: Crea un reporte con métricas clave y justifica si el particionamiento mejoró el rendimiento en más del 30%.
Pistas
  • Usa pg_stat_user_tables para comparar secuencias de lectura/escritura entre particiones.
  • Considera agregar un índice en evento_id dentro de cada partición para búsquedas más rápidas.
  • Si el rendimiento no mejora, revisa si el plan de consultas está usando las particiones correctamente con EXPLAIN.

Evalua tu comprension

Completa el quiz interactivo de arriba para ganar XP.