Todo el curso ha ejecutado SQL desde psql: escribes, pulsas Intro, lees el resultado. En una aplicación real nada de eso pasa. Hay un proceso web que atiende cientos de peticiones por segundo, cada una con unos milisegundos de presupuesto, que no puede permitirse abrir una conexión, que ejecuta las consultas desde código en otro lenguaje, que muchas veces no las escribe siquiera —las genera un ORM— y en el que un error de diseño no se manifiesta como un mensaje sino como "la web va lenta desde ayer".
Esta lección es ese entorno: cómo se conecta una aplicación, cómo ejecuta y cómo gestiona sus transacciones, qué aporta y qué quita un ORM, los cuatro antipatrones que matan una web —empezando por el N+1 que 08-04 dejó pendiente—, los patrones que sí funcionan y la lista de comprobación para cuando algo va mal. Con ella se cierra el módulo y quedas listo para el proyecto final.
Contenido
- La conexión y el pool
- Ejecutar SQL desde el código
- ORM frente a SQL a mano
- Los antipatrones que matan una web
- Patrones útiles
- Operación: despliegue, monitorización y timeouts
- "La web va lenta": lista de comprobación
- Errores Comunes y Consejos
- Ejercicios
- Conclusión del módulo
- La conexión y el pool
Una conexión a PostgreSQL empieza con una cadena de conexión, que en formato URI es la forma canónica:
postgresql://web_prod:[email protected]:5432/tiendaverde?sslmode=verify-full&application_name=tv-web&connect_timeout=5
Cuatro cosas que hay que mirar siempre: el usuario —web_prod, no el superusuario ni el propietario (11-03)—; el sslmode, que debe ser al menos require y preferiblemente verify-full, porque prefer acepta silenciosamente una conexión sin cifrar; el application_name, que aparecerá en pg_stat_activity y te dirá qué servicio está lanzando la consulta que ahoga la base; y que la clave nunca está en el repositorio, sino en una variable de entorno o en un gestor de secretos.
Por qué hace falta un pool
Abrir una conexión a PostgreSQL es caro. No es un socket y ya: el servidor crea un proceso del sistema operativo por conexión, negocia TLS, autentica e inicializa su memoria. Son del orden de decenas de milisegundos, frente a los 0,2 ms que tarda la consulta que ibas a lanzar. Si abres y cierras una conexión por petición, el 99 % del tiempo se va en la conexión.
Y no se arregla abriendo muchas y dejándolas abiertas: cada conexión ociosa consume memoria y max_connections (por omisión 100) tiene un límite que, superado, hace fallar las peticiones. La solución es un pool: un conjunto pequeño de conexiones ya abiertas que las peticiones toman prestadas y devuelven.
flowchart LR
P1["petición 1"] --> POOL
P2["petición 2"] --> POOL
P3["petición 3"] --> POOL
PN["petición N"] --> POOL
POOL["<b>pool</b><br/>10 conexiones abiertas<br/>préstamo y devolución"] --> DB[("PostgreSQL<br/>10 procesos")]
El tamaño sorprende a todo el mundo: es pequeño. Una regla de partida muy usada es núcleos × 2 + husos de disco, que para una máquina normal da entre 10 y 20 conexiones, no doscientas. El motivo es que la base de datos no va más rápido por recibir más peticiones a la vez: si tiene 8 núcleos, 200 consultas simultáneas no se ejecutan en paralelo, se pelean. Un pool pequeño encola en la aplicación, que es donde se puede esperar ordenadamente, en lugar de saturar el servidor.
| Parámetro del pool | Qué controla | Valor de partida |
|---|---|---|
| Tamaño máximo | Conexiones simultáneas al servidor | 10-20 por instancia de aplicación |
| Tiempo de espera para obtener conexión | Cuánto espera una petición antes de fallar | 2-5 s (mejor fallar rápido que colgarse) |
| Vida máxima de una conexión | Reciclado periódico | 30 min: evita fugas y facilita el fallo controlado |
| Tiempo ocioso máximo | Cuándo se cierran las sobrantes | 10 min |
⚠️ Cuidado con multiplicar. El límite que importa es el total: 8 instancias de la aplicación con un pool de 20 son 160 conexiones, más las del proceso de tareas, más las de los informes. Ese número tiene que caber en
max_connections, con margen para las conexiones de administración.
PgBouncer
Cuando hay muchas instancias, o cuando la plataforma crea procesos por petición (el modelo clásico de PHP), se pone un pooler externo: PgBouncer se sitúa entre la aplicación y PostgreSQL y multiplexa cientos de conexiones de cliente sobre unas pocas reales.
| Modo | Cuándo devuelve la conexión al pool | Uso |
|---|---|---|
session |
Al desconectar el cliente | Compatible con todo; multiplexa poco |
transaction |
Al terminar cada transacción | El habitual: gran multiplexación |
statement |
Al terminar cada sentencia | Muy agresivo; prohíbe las transacciones de varias sentencias |
El modo transaction tiene letra pequeña y hay que conocerla: como la conexión cambia entre transacciones, deja de funcionar todo lo que vive en la sesión — sentencias preparadas con nombre, SET de sesión (usa SET LOCAL), tablas temporales, LISTEN/NOTIFY y los advisory locks de sesión de 09-05. Si tu ORM usa preparadas con nombre, hay que desactivarlas o usar una versión de PgBouncer que las soporte.
- Ejecutar SQL desde el código
Tres piezas, con lo aprendido en 11-03 y en el módulo 9:
# 1. Consulta parametrizada: el listado de últimos pedidos, en UNA consulta
SQL_ULTIMOS = """
SELECT pe.id, pe.fecha_pedido, c.nombre || ' ' || c.apellidos AS cliente, pe.estado,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS total,
COUNT(lp.id) AS lineas
FROM pedidos AS pe
JOIN clientes AS c ON c.id = pe.cliente_id
LEFT JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id
GROUP BY pe.id, pe.fecha_pedido, c.nombre, c.apellidos, pe.estado
ORDER BY pe.fecha_pedido DESC, pe.id DESC
LIMIT %(limite)s"""
with pool.connection() as conn, conn.cursor() as cur:
cur.execute(SQL_ULTIMOS, {"limite": 5})
filas = cur.fetchall()| id | fecha_pedido | cliente | estado | total | lineas |
|---|---|---|---|---|---|
| 20 | 2026-02-21 | Camille Dubois | pendiente | 22.60 | 2 |
| 19 | 2026-02-09 | Pau Llorens Vidal | pagado | 26.73 | 1 |
| 18 | 2026-01-27 | Ana Belmonte Roca | pagado | 28.10 | 2 |
| 17 | 2026-01-13 | Sofia Moreira Costa | enviado | 47.00 | 2 |
| 16 | 2025-12-19 | Javier Ortega Ruiz | enviado | 31.18 | 2 |
Una consulta, cinco filas, todo lo que la pantalla necesita: el nombre del cliente, el estado, el total y el número de líneas. Guarda esta consulta, porque el apartado 4 la va a comparar con la versión que lanza una consulta más por cada pedido de la lista.
RETURNING: recuperar el id sin una segunda consulta
Al insertar necesitas el id generado. La forma correcta es RETURNING (05-02), no un SELECT MAX(id) posterior —que además es incorrecto con concurrencia—:
cur.execute("""INSERT INTO pedidos (cliente_id, empleado_id, fecha_pedido, estado, metodo_pago, gastos_envio)
VALUES (%s, %s, CURRENT_DATE, 'pendiente', %s, %s)
RETURNING id, fecha_pedido""",
(cliente_id, None, metodo_pago, portes))
pedido_id, fecha = cur.fetchone()La transacción desde la aplicación
El patrón de 09-03, escrito en código. Todo lo que debe pasar junto va dentro del mismo bloque:
conn = pool.getconn()
try:
with conn: # abre transacción; commit al salir sin error
with conn.cursor() as cur:
cur.execute(SQL_INSERT_PEDIDO, (...))
pedido_id = cur.fetchone()[0]
cur.executemany(SQL_INSERT_LINEA, lineas) # varias filas, un viaje
cur.execute(SQL_DESCONTAR_STOCK, (...))
except UniqueViolation: # errores esperados: mensaje al usuario
raise ErrorDeNegocio("Ese pedido ya existe")
except Exception:
conn.rollback(); raise # inesperados: deshacer y propagar
finally:
pool.putconn(conn) # ⬅️ devolver SIEMPRE la conexión al poolCuatro reglas que se aprenden a base de incidentes: rollback en todo error, porque una transacción abierta mantiene bloqueos y bloquea el VACUUM (09-02); devolver la conexión en el finally, o el pool se agota y la aplicación se congela entera; mantener la transacción lo más corta posible (apartado 4); y distinguir el error esperado del inesperado — una violación de UNIQUE es un mensaje al usuario, no un error 500.
- ORM frente a SQL a mano
Un ORM (Object-Relational Mapper) traduce entre las tablas y los objetos del lenguaje: Pedido.objects.filter(estado="pagado") se convierte en un SELECT.
| Aporta | Quita |
|---|---|
| Mapeo automático fila ↔ objeto, sin código repetitivo | Control del SQL generado: no sabes qué se ejecuta hasta mirarlo |
| Migraciones integradas con el modelo (05-06) | Previsibilidad del rendimiento: un cambio inocente dispara un N+1 |
| Seguridad por defecto: parametriza siempre (11-03) | Consultas analíticas: ventanas, CTE y FILTER se expresan mal o no se expresan |
| Productividad en el CRUD, que es el 80 % del código | Una capa más que aprender, depurar y actualizar |
| Portabilidad entre motores y caché de identidad | La ilusión de que no hace falta saber SQL |
| ORM | Entorno | Nota |
|---|---|---|
| SQLAlchemy | Python | Dos capas: Core (SQL expresivo) y ORM. La más potente para escapar del ORM sin salir de él |
| Django ORM | Python | Muy productivo e integrado; select_related / prefetch_related para el N+1 |
| Prisma | Node/TypeScript | Esquema declarativo y tipos generados; $queryRaw para SQL a mano |
| Hibernate / JPA | Java | El más veterano; JOIN FETCH contra el N+1 y carga perezosa por omisión |
| Eloquent | PHP/Laravel | Muy legible; with() para carga anticipada |
El criterio del curso: ORM para el CRUD, SQL a mano para los informes y las consultas críticas. No es un término medio cobarde: es que las dos cosas son problemas distintos. "Guardar un pedido y sus líneas" es exactamente lo que un ORM hace bien. "La facturación mensual con acumulado, media móvil y variación" es exactamente lo que hace mal.
Y este es el ejemplo concreto, la consulta del apartado 2: un JOIN a dos tablas, un LEFT JOIN que no debe perder pedidos, un GROUP BY, una expresión de importe con descuento y un ORDER BY con desempate. Ningún ORM la escribe bien sin ayuda: o hace tres consultas, o trae objetos completos para contar sus líneas en memoria, o genera un GROUP BY con todas las columnas del modelo. Escríbela a mano, guárdala en un fichero .sql del repositorio (11-02) y ejecútala con el conector del ORM — todos permiten hacerlo. Y cuando lo hagas, sigue parametrizando: el raw()/text()/$queryRaw es exactamente donde vuelve la inyección SQL (11-03).
- Los antipatrones que matan una web
4.1. N+1 — cierra 08-04
| Síntoma | La pantalla tarda segundos; el registro muestra decenas o cientos de consultas por petición, todas rapidísimas; no aparece en el ranking de consultas lentas |
| Causa | Se consulta la lista, y después una consulta por elemento para traer un dato relacionado. Casi siempre lo genera un ORM al acceder a una propiedad dentro de un bucle |
| Arreglo | Un JOIN, o la carga anticipada del ORM |
# ⚠️ INCORRECTA: 1 + 20 = 21 consultas
pedidos = Pedido.objects.all()[:20] # 1 consulta
for p in pedidos:
print(p.cliente.nombre) # ⬅️ 1 consulta por vuelta, invisible en el código
# ✅ CORRECTA: 1 consulta
pedidos = Pedido.objects.select_related("cliente").all()[:20]21 consultas frente a 1 para pintar la misma tabla. Y lo que hace grave al N+1 es cómo escala, porque el coste no es el tiempo de cada consulta sino el coste fijo que se paga en cada una: viaje de red, análisis, planificación:
| Elementos | Latencia 1 ms | Latencia 20 ms (base en otra región) |
|---|---|---|
| 20 con N+1 | ~25 ms | ~420 ms |
| 500 con N+1 | ~520 ms | ~10 s |
Cualquier número con JOIN |
~2-4 ms | ~25 ms |
Cómo detectarlo: cuenta las consultas por petición. Un contador en el middleware que registre "esta petición ha hecho 61 consultas" encuentra un N+1 en cinco minutos; pg_stat_statements lo delata como una consulta con mean_exec_time mínimo y un número de calls disparatado, que es la razón de ordenar por tiempo total. Las herramientas de cada entorno: django-debug-toolbar, Rails Bullet, Laravel Telescope, Hibernate statistics.
4.2. Traer todas las filas y paginar en memoria
| Síntoma | Consumo de memoria disparado; la pantalla va bien en desarrollo y se muere en producción |
| Causa | SELECT * FROM pedidos y luego filas[100:120] en el lenguaje. Con 20 pedidos funciona; con 2 millones, no |
| Arreglo | WHERE, ORDER BY y LIMIT en SQL. Y para páginas profundas, keyset en lugar de OFFSET (02-06, 08-04) |
-- ⚠️ Página 1000 con OFFSET: lee y descarta 20 000 filas
SELECT id, fecha_pedido FROM pedidos ORDER BY fecha_pedido DESC, id DESC LIMIT 20 OFFSET 20000;
-- ✅ Keyset: se posiciona en el índice. Coste constante, sea la página que sea
SELECT id, fecha_pedido FROM pedidos
WHERE (fecha_pedido, id) < (:ultima_fecha, :ultimo_id)
ORDER BY fecha_pedido DESC, id DESC LIMIT 20;Sobre los 20 pedidos del curso, la segunda con (:ultima_fecha, :ultimo_id) = ('2026-01-13', 17) devuelve los pedidos 16, 15, 14, 13 y 12 con LIMIT 5: exactamente la página siguiente a la del apartado 2, sin releer nada.
4.3. Transacción abierta esperando algo lento
| Síntoma | Bloqueos, esperas, idle in transaction en pg_stat_activity, tablas que no se limpian |
| Causa | BEGIN … llamada a la pasarela de pago (2 s) … COMMIT. La transacción retiene bloqueos mientras se espera a un tercero |
| Arreglo | La llamada externa, fuera de la transacción. Primero la llamada, luego una transacción corta que registre el resultado |
Es lo de 09-01 llevado al mundo real, y el daño va más allá de esa petición: una transacción larga impide que VACUUM limpie las versiones antiguas de fila en toda la base (09-02), así que una sola petición lenta degrada a todas las demás. Vigila idle in transaction y pon idle_in_transaction_session_timeout.
4.4. Falta de índices en lo que la web filtra
| Síntoma | Todo bien hasta que la tabla crece; Seq Scan en el plan (08-05) |
| Causa | La web filtra y ordena por columnas sin índice, y PostgreSQL no indexa las claves foráneas automáticamente (08-01) |
| Arreglo | Índices sobre las FK (lineas_pedido(pedido_id), pedidos(cliente_id)) y sobre lo que ordena la pantalla (pedidos(fecha_pedido DESC, id DESC)) |
La forma sistemática de encontrarlos: lista las pantallas, y para cada una escribe el WHERE y el ORDER BY que ejecuta. Esa lista es tu lista de índices candidatos — y solo esa, porque cada índice que sobra frena todas las escrituras (08-02).
- Patrones útiles
- Paginación por cursor para scroll infinito y APIs. La respuesta lleva un
next_cursoropaco (la tupla de la última fila, codificada) y el cliente lo devuelve. Coste constante y sin filas repetidas o perdidas cuando alguien inserta mientras el usuario navega. - Filtros opcionales con el patrón de 11-01, y
LIMITdefensivo en toda consulta expuesta al usuario: aunque la interfaz solo permita pedir 50, la API debe imponer un máximo (LEAST(:limite, 100)). Sin él, alguien pedirá?limit=1000000el día que más tráfico tengas. - Caché para lo que cambia poco y se lee mucho: el catálogo, las categorías, la portada. Y con el aviso de siempre: la invalidación es el problema difícil. Empieza por caducidad por tiempo, que es sencilla y predecible, y deja la invalidación por eventos para cuando la necesites de verdad.
- Colas de trabajos con
SELECT ... FOR UPDATE SKIP LOCKED(09-05). Varios procesos trabajadores toman tareas de la misma tabla sin pisarse ni esperarse: cada uno se salta las filas ya bloqueadas por otro. Es el patrón canónico para enviar correos, generar facturas o procesar imágenes sin montar un sistema de colas aparte.
UPDATE trabajos SET estado = 'en_curso', tomado_en = now()
WHERE id = (SELECT id FROM trabajos WHERE estado = 'pendiente'
ORDER BY creado_en FOR UPDATE SKIP LOCKED LIMIT 1)
RETURNING id, payload;- JSON directamente desde PostgreSQL (10-06):
jsonb_build_object+jsonb_aggdevuelven el pedido con sus líneas anidadas en una fila y una consulta, en lugar de traer filas planas y reensamblarlas en el lenguaje. Cuándo compensa: respuestas muy anidadas que la aplicación solo reenvía tal cual. Cuándo no: si la aplicación tiene que recorrer, validar o transformar el objeto —entonces prefiere filas tipadas—, si el JSON se vuelve enorme, o si mezclarlo con el ORM obliga a mantener dos formas de leer lo mismo.
- Operación: despliegue, monitorización y timeouts
Migraciones compatibles hacia atrás
Durante un despliegue conviven, aunque sea unos segundos, el código nuevo y el viejo. Si la migración y el código se despliegan a la vez y la migración rompe el esquema anterior, la versión antigua falla mientras dure el cambio. De ahí el expand/contract de 05-06, en tres despliegues:
| Fase | Qué se hace | Compatible con |
|---|---|---|
| 1. Expand | Añadir la columna nueva nullable o con DEFAULT; escribir en las dos |
Código viejo y nuevo |
| 2. Migrar y desplegar | Rellenar los datos por lotes; el código nuevo lee la columna nueva | Código nuevo |
| 3. Contract | Cuando nadie usa la vieja: NOT NULL, borrar la antigua |
— |
Y dos detalles operativos que evitan una caída: CREATE INDEX CONCURRENTLY, porque un CREATE INDEX normal bloquea las escrituras de la tabla mientras se construye; y lock_timeout corto antes de un ALTER TABLE, para que la migración falle en tres segundos en lugar de encolar todas las consultas detrás de un bloqueo que no consigue (09-05).
Monitorización y timeouts
SELECT LEFT(query, 60) AS consulta, calls, ROUND(total_exec_time::numeric, 1) AS ms_total,
ROUND(mean_exec_time::numeric, 2) AS ms_media
FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;Ordena por total_exec_time, no por la media: es lo único que hace visible el N+1, cuya media es minúscula y cuyo total es enorme. Y tres ajustes que deberían estar puestos desde el primer día:
| Ajuste | Para qué | Valor de partida |
|---|---|---|
statement_timeout |
Ninguna consulta de la web debe durar minutos | 5-15 s en el rol de la aplicación |
idle_in_transaction_session_timeout |
Matar transacciones olvidadas abiertas | 30-60 s |
log_min_duration_statement |
Registrar lo que pase de un umbral | 200-1000 ms |
Ponlos por rol: ALTER ROLE web_prod SET statement_timeout = '10s'; deja que los informes y las migraciones, con otros roles, tengan sus propios límites.
- "La web va lenta": lista de comprobación
En este orden, porque va de lo más probable y barato a lo más raro y caro:
- ¿Es todo o una pantalla? Si es una, mira sus consultas; si es todo, sospecha del servidor, del pool o de un bloqueo.
- ¿Cuántas consultas hace esa petición? Un contador. Si son decenas, es un N+1 y ya has terminado.
- ¿Se agota el pool? Si las peticiones esperan a obtener conexión, el problema no está en la base: es el pool, o transacciones que no se cierran. Mira
idle in transaction. pg_stat_statementspor tiempo total. Las tres primeras consultas suelen explicar el 80 % de la carga.EXPLAIN ANALYZEde la sospechosa (08-05). ¿Seq Scandonde debería haber índice? ¿Estimaciones lejanas de la realidad?- ¿Hay bloqueos?
pg_locksconpg_stat_activity: una consulta enLockno es lenta, está esperando (09-05). - ¿Han cambiado las estadísticas o el volumen? ¿Hubo carga masiva sin
ANALYZE(08-04)? - ¿Se traen filas de más? Una consulta rápida que devuelve 200 000 filas satura la red y la memoria de la aplicación.
- Y solo entonces, el servidor: CPU, memoria, disco,
VACUUMpendiente, número de conexiones.
La regla es la de 08-04 aplicada a la web: mide antes de tocar. Y cuenta consultas, no solo milisegundos, porque el problema más común de todos no aparece en ningún ranking de consultas lentas.
Errores Comunes y Consejos
- Abrir una conexión por petición. El coste de conexión supera con mucho al de la consulta. Usa un pool.
- Configurar el pool con 200 conexiones "por si acaso". La base no va más rápido por recibir más a la vez; el pool pequeño encola donde se puede esperar. Y recuerda multiplicar por el número de instancias.
- No devolver la conexión al pool. Un
returnen medio de untrysinfinallyagota el pool y congela la aplicación entera. - Dejar una transacción abierta durante una llamada externa. Bloqueos,
idle in transactiony unVACUUMque no puede trabajar. - Confiar en que el ORM hará lo correcto. Mira el SQL que genera: casi todos tienen un modo de registro. Lo que no miras, no lo sabes. Y usar
raw()concatenando: ahí vuelve la inyección (11-03). - Paginar con
OFFSETen una API. Lento en páginas profundas y con filas repetidas o perdidas si alguien inserta. - No poner
LIMITen una consulta expuesta al usuario. Alguien pedirá un millón de filas. - Desplegar migración y código a la vez con un cambio rompedor. Expand/contract, y
CREATE INDEX CONCURRENTLY. - Consejo: registra el número de consultas y el tiempo de base de datos de cada petición. Con esas dos métricas, los problemas de este apartado se ven antes de que los vea un usuario.
- Consejo: pon
application_nameen la cadena de conexión. Cuando la base sufra, sabrás qué servicio la está machacando en lugar de adivinarlo. - Consejo: guarda las consultas complejas en ficheros
.sqldel repositorio, no incrustadas entre líneas de código. Se revisan mejor, se prueban enpsqly se pueden formatear (11-02).
Ejercicios
Ejercicio 1
La ficha de cliente muestra sus datos, sus pedidos y, por cada pedido, sus líneas con el nombre del producto y su categoría. El registro dice 23 consultas por carga en la ficha de Lucía.
- ¿De dónde salen exactamente las 23? (Pista: Lucía tiene 3 pedidos, de 3 líneas cada uno.)
- Reduce la ficha a dos consultas y escríbelas.
- ¿Por qué dos y no una?
Ejercicio 2
Tu aplicación corre en 6 instancias, cada una con un pool de 25 conexiones. Hay además un proceso de tareas en segundo plano con 10 y un servidor de informes con 5. max_connections está en 100.
- ¿Cuál es el problema y qué error verán los usuarios?
- Da tres soluciones, de la más barata a la más cara.
- Si eliges PgBouncer en modo
transaction, ¿qué tienes que revisar en el código?
Ejercicio 3
Una API devuelve el detalle de un pedido con su cliente y sus líneas. El equipo debate entre (a) tres consultas y reensamblar en el código, (b) una consulta con JOIN y reensamblar, (c) una consulta que devuelve el JSON ya montado (10-06).
- Escribe la opción (c) para el pedido 1 y di qué devuelve.
- Da un argumento a favor y uno en contra de cada opción.
- ¿Cuál elegirías si la API tiene que devolver 50 pedidos en la misma respuesta?
Soluciones
Solución 1
1. 1 para el cliente + 1 para su lista de pedidos + 3 (una por pedido, para traer sus líneas) + 9 (una por línea, para el nombre del producto) + 9 (una por línea, para la categoría del producto) = 23. Es un N+1 anidado: cada nivel de la plantilla multiplica al anterior, y por eso el número crece tan deprisa. Con un cliente de 20 pedidos de 5 líneas serían 1 + 1 + 20 + 100 + 100 = 222 consultas para una sola pantalla.
2. Dos consultas: una para la cabecera de los pedidos y otra para todas sus líneas de una vez, con el producto ya unido.
-- (a) Cliente y sus pedidos con totales, en una consulta
SELECT pe.id, pe.fecha_pedido, pe.estado, COUNT(lp.id) AS lineas,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS total
FROM pedidos AS pe LEFT JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id
WHERE pe.cliente_id = %(cliente_id)s
GROUP BY pe.id, pe.fecha_pedido, pe.estado ORDER BY pe.fecha_pedido DESC;
-- (b) TODAS las líneas de TODOS esos pedidos, de una vez
SELECT lp.pedido_id, p.nombre AS producto, lp.cantidad,
ROUND(lp.cantidad * lp.precio_unitario * (1 - lp.descuento), 2) AS importe
FROM lineas_pedido AS lp JOIN productos AS p ON p.id = lp.producto_id
WHERE lp.pedido_id = ANY(%(ids)s)
ORDER BY lp.pedido_id, lp.id;Para Lucía (cliente 1), la primera devuelve 3 filas —los pedidos 1, 5 y 15, con 42,10 €, 32,10 € y 33,40 €, que suman sus 107,60 € canónicos— y la segunda, 9 filas. Fíjate en = ANY(%(ids)s) con un array: es la forma correcta de pasar una lista de identificadores sin construir el SQL concatenando (11-03).
3. Porque cabecera y detalle tienen cardinalidades distintas. Con una sola consulta, el total del pedido se repetiría en cada una de sus líneas y habría que deduplicarlo en el código, o habría que agregar las líneas a JSON. Dos consultas, cada una con la granularidad de lo que devuelve, son más claras y más eficientes que una que multiplica filas. La regla del "carga anticipada" de todos los ORM es exactamente esta: una consulta por nivel, no una por elemento.
Solución 2
1. 6 × 25 + 10 + 5 = 165 conexiones posibles contra un max_connections de 100. En cuanto haya carga, PostgreSQL rechazará las nuevas con FATAL: sorry, too many clients already, y los usuarios verán errores 500 intermitentes — intermitentes y por tanto difíciles de diagnosticar, porque solo aparecen en los picos. Peor aún, puede impedir incluso conectarse para administrar si no quedan huecos reservados (superuser_reserved_connections).
2. (a) Bajar el tamaño del pool a 10 por instancia: 6 × 10 + 10 + 5 = 75, con margen. Es gratis, inmediato y probablemente no empeorará el rendimiento, porque 165 consultas simultáneas no caben en los núcleos de la máquina de todos modos. (b) Poner PgBouncer en modo transaction: cientos de conexiones de cliente sobre 20 reales. (c) Subir max_connections y la memoria del servidor: es la más cara, exige reinicio y solo traslada el problema, porque cada conexión es un proceso con su memoria.
3. Con transaction la conexión cambia entre transacciones, así que hay que revisar: sentencias preparadas con nombre (muchos conectores las usan por omisión: desactivarlas o usar la versión de PgBouncer que las soporta), SET de sesión (cambiar a SET LOCAL dentro de la transacción — incluido el SET app.usuario de la auditoría de 11-01 y el de RLS de 11-03), tablas temporales, LISTEN/NOTIFY y advisory locks de sesión (09-05). Todo lo que dependa de "seguir en la misma sesión" deja de funcionar.
Solución 3
1. Es la consulta de 10-06: jsonb_build_object con el id, la fecha y los portes, el cliente anidado en otro objeto y las líneas en un jsonb_agg correlacionado. Devuelve una fila y una columna con el pedido 1 completo: Lucía Martínez Soler, España, y sus tres líneas —aceite 23,90 €, arroz 11,70 € e infusión 6,50 €— que suman los 42,10 € del pedido.
2. (a) Tres consultas: a favor, es la más simple y cada consulta es trivial de cachear e indexar; en contra, son tres viajes de red. (b) JOIN y reensamblar: a favor, un solo viaje y datos tipados que la aplicación puede transformar; en contra, repite la cabecera en cada línea y obliga a agrupar en el código, que es trabajo manual y propenso a errores. (c) JSON desde PostgreSQL: a favor, un viaje y cero código de reensamblado; en contra, la aplicación recibe un texto opaco que no puede transformar sin analizarlo, se pierde el tipado y se acopla la forma de la respuesta de la API al SQL — cambiar un campo del contrato obliga a tocar la consulta.
3. Con 50 pedidos, (c), y por un motivo concreto: es la única que no crece en número de viajes ni obliga a agrupar 50 cabeceras con sus ~120 líneas en el código. La (a) se convertiría en un N+1 si se hace por pedido —habría que reescribirla como dos consultas con = ANY(...), que es la solución del ejercicio 1—, y la (b) devolvería 120 filas con la cabecera repetida. La condición para que (c) sea buena idea sigue siendo la misma: que la API reenvíe el JSON tal cual. Si tiene que tocarlo, vuelve la (a) en su versión de dos consultas.
Conclusión del módulo
Así llega el SQL a una aplicación real:
- La conexión lleva usuario de mínimo privilegio, TLS (
verify-full, noprefer) yapplication_name, y la clave nunca en el repositorio. Y va siempre por un pool, porque abrir una conexión cuesta decenas de milisegundos y cada una es un proceso en el servidor. El tamaño es pequeño —10-20 por instancia— y hay que multiplicarlo por el número de instancias. PgBouncer en modotransactionmultiplexa cientos de clientes sobre pocas conexiones reales, a cambio de perder todo lo que vive en la sesión. - Desde el código: consultas parametrizadas (11-03),
RETURNINGpara el id (05-02) y el bloquetry/commit/except rollback/finally putconn(09-03), con la transacción lo más corta posible y la conexión devuelta siempre. - ORM frente a SQL: el ORM da mapeo, migraciones, seguridad por defecto y productividad; quita control del SQL y previsibilidad del rendimiento. El criterio: ORM para el CRUD, SQL a mano para los informes y las consultas críticas — como el listado de últimos pedidos, que ningún ORM escribe bien.
- Los antipatrones: el N+1, que convierte 1 consulta en 21 o en 501 y no aparece en ningún ranking de consultas lentas —cierra la promesa de 08-04—; traer todo y paginar en memoria, que se arregla con
LIMITy con keyset; la transacción abierta esperando una API externa, que retiene bloqueos y frena elVACUUMde toda la base; y la falta de índices en las columnas por las que filtra la web, empezando por las claves foráneas. - Los patrones: paginación por cursor, filtros opcionales,
LIMITdefensivo en toda consulta expuesta, caché con caducidad por tiempo, colas conSKIP LOCKEDy JSON directo desde PostgreSQL cuando la API solo reenvía. - La operación: migraciones compatibles hacia atrás en expand/contract,
CREATE INDEX CONCURRENTLY,lock_timeoutantes de unALTER,pg_stat_statementsordenado por tiempo total, ystatement_timeouteidle_in_transaction_session_timeoutpor rol. Más la lista de nueve puntos para cuando "la web va lenta", que empieza por contar consultas.
Y con esto se cierra el módulo 11 y, con él, el aprendizaje. En cinco lecciones has pasado de conocer el lenguaje a conocer el oficio: los casos de uso que reaparecen en todos los proyectos y qué herramienta resuelve cada uno; las mejores prácticas de nomenclatura, formato, diseño, fiabilidad y proceso, con su catálogo de antipatrones y su checklist; la seguridad, con la inyección SQL y su única defensa real, el modelo de roles y permisos de PostgreSQL y el trato de los datos personales; el análisis de datos, donde las funciones de ventana se convirtieron en informes y donde aprendiste que definir y validar importa más que consultar; y el desarrollo web, donde el SQL se ejecuta de verdad.
Queda una cosa, y es la única que no se aprende leyendo: hacerlo tú. En el módulo 12, Proyecto final, montarás un sistema completo de principio a fin: la descripción del proyecto y su contexto de negocio, los requisitos que debe cumplir, la implementación paso a paso —modelo, DDL, datos, consultas, índices, vistas, seguridad y rendimiento—, las soluciones comentadas con las decisiones justificadas una a una, y la presentación de los resultados. Todo lo de doce módulos, junto y en un solo trabajo. Es el momento de dejar de seguir un curso y empezar a construir.
Curso de SQL
Módulo 1: Introducción a SQL
- ¿Qué es SQL?
- Configurando tu entorno SQL
- Sintaxis básica de SQL
- Entendiendo bases de datos y tablas
- El modelo relacional: claves primarias y foráneas
- La base de datos del curso: TiendaVerde
Módulo 2: Consultas básicas de SQL
- Instrucción SELECT
- Alias, expresiones y columnas calculadas
- Filtrando datos con WHERE
- DISTINCT y eliminación de duplicados
- Ordenando datos con ORDER BY
- Limitando resultados con LIMIT
Módulo 3: Trabajando con múltiples tablas
- Operaciones JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN y CROSS JOIN
- Uniones de conjuntos: UNION, INTERSECT y EXCEPT
Módulo 4: Filtrado avanzado de datos
- Usando LIKE para coincidencia de patrones
- Operadores IN y BETWEEN
- Valores NULL y IS NULL
- Funciones de agregación: COUNT, SUM, AVG, MIN y MAX
- Agregando datos con GROUP BY
- Cláusula HAVING
Módulo 5: Manipulación de datos
- Creando tablas y restricciones con CREATE TABLE
- Instrucción INSERT
- Instrucción UPDATE
- Instrucción DELETE
- Instrucción UPSERT (MERGE)
- Modificando el esquema: ALTER TABLE y migraciones seguras
Módulo 6: Funciones avanzadas de SQL
- Funciones de cadena
- Funciones numéricas
- Funciones de fecha y hora
- Conversión de tipos y manejo de NULL: CAST y COALESCE
- Expresiones condicionales
Módulo 7: Subconsultas y consultas anidadas
- Introducción a subconsultas
- Subconsultas correlacionadas
- EXISTS y NOT EXISTS
- Usando subconsultas en cláusulas SELECT, FROM y WHERE
- Subconsultas o JOIN: cuál elegir
Módulo 8: Índices y optimización de rendimiento
- Entendiendo los índices
- Creación y gestión de índices
- Tipos de índice y cuándo no indexar
- Técnicas de optimización de consultas
- Análisis del rendimiento de consultas
Módulo 9: Transacciones y concurrencia
- Introducción a las transacciones
- Propiedades ACID
- Instrucciones de control de transacciones
- Niveles de aislamiento y anomalías de concurrencia
- Manejo de concurrencia: bloqueos e interbloqueos
Módulo 10: Temas avanzados
- Vistas
- Expresiones de tabla comunes (CTE)
- Funciones de ventana
- Procedimientos almacenados
- Triggers
- JSON y datos semiestructurados
Módulo 11: SQL en la práctica
- Casos de uso en el mundo real
- Mejores prácticas
- Seguridad: inyección SQL, permisos y roles
- SQL para análisis de datos
- SQL en desarrollo web
