19. SQL, modelado de datos e índices
Un ORM traduce objetos a filas, pero no piensa por ti. La base de datos es el único componente del stack que no puedes reiniciar sin consecuencias, el que guarda el valor real de la empresa y el que, cuando va mal, deja al descubierto todo lo que no sabías de SQL. Este capítulo baja al motor: modelo relacional, normalización, consultas de verdad, índices, planes de ejecución, transacciones y operación. PostgreSQL como referencia, con las diferencias de MySQL señaladas cuando importan.
19.1 Qué vas a poder hacer al terminar
- Diseñar un esquema relacional normalizado, con claves, restricciones y tipos correctos, y justificar cada decisión de desnormalización que tomes.
- Escribir consultas con joins, agregaciones, subconsultas correlacionadas, CTE recursivas y funciones de ventana sin recurrir a bucles en la capa de aplicación.
- Explicar el orden lógico de ejecución de un
SELECTy deducir de él por qué ciertas consultas no compilan y otras son lentas. - Leer un
EXPLAIN (ANALYZE, BUFFERS)de arriba abajo y de abajo arriba, identificar el nodo culpable y decidir si hace falta un índice, otra consulta o más estadísticas. - Diseñar índices B-tree compuestos con el orden de columnas adecuado, y detectar los que no se usan, los duplicados y los que se ignoran por culpa de tu propia consulta.
- Elegir el nivel de aislamiento correcto, prevenir actualizaciones perdidas y deadlocks, e implementar
una cola de trabajos fiable con
FOR UPDATE SKIP LOCKED. - Diagnosticar problemas de producción reales: bloat por transacciones largas, agotamiento del pool,
paginación con
OFFSET, N+1 vistos desde el lado del motor. - Saber exactamente qué SQL genera MikroORM en cada operación y cuándo conviene bajar a SQL nativo.
19.1.1 Por qué necesitas SQL aunque uses un ORM
Es la pregunta razonable: si em.find(Task, { status: 'todo' }) funciona, ¿para qué aprender
SQL? Por siete razones muy concretas, todas ellas problemas que aparecen en producción y no en desarrollo:
- El ORM traduce, no optimiza. MikroORM genera el SQL que le pides. Si pides mal, ejecuta mal. La diferencia entre 600 ms y 0,2 ms en la misma consulta no la decide el ORM: la decide el índice que tú hayas creado y el orden de sus columnas.
- El problema N+1 se ve desde la base de datos. En el código son tres líneas inocentes; en el log del motor son 2.000 sentencias idénticas. Sin saber leer ese log no lo detectas (capítulo 16).
- Hay cosas que el ORM no expresa. Funciones de ventana, CTE recursivas,
DISTINCT ON,LATERAL, agregados conFILTER, upserts complejos, búsqueda de texto completo. Antes o después escribirás SQL nativo, y más te vale que sea bueno. - Las migraciones son DDL. Una migración generada automáticamente puede bloquear una tabla de
50 millones de filas durante minutos. Saber qué hace
ALTER TABLEpor dentro es lo que separa un despliegue transparente de una caída. - La concurrencia se resuelve en el motor. Ningún ORM te salva de una actualización perdida si no entiendes el nivel de aislamiento en el que estás trabajando.
- El esquema sobrevive a la aplicación. El código se reescribe cada pocos años; los datos siguen ahí. Un modelo mal normalizado es una deuda que se paga durante toda la vida del producto.
- Es lo que se pregunta en las entrevistas. Y con razón: es el conocimiento que menos se deprecia. El SQL que aprendas hoy seguirá siendo válido dentro de veinte años.
users (usuarios), teams (equipos), projects (proyectos),
tasks (tareas), tags (etiquetas), task_tags (relación N:M entre
tareas y etiquetas) y comments (comentarios, con jerarquía de respuestas). El motor de
referencia es PostgreSQL 16; cuando MySQL 8 se comporta de otra forma, se indica de manera
explícita.
19.2 Fundamentos del modelo relacional
El modelo relacional lo publicó Edgar F. Codd en 1970 en un artículo titulado A Relational Model of Data for Large Shared Data Banks. Su idea era radical para la época: en lugar de que el programa navegue por punteros entre registros, los datos se describen como relaciones matemáticas y se consultan de forma declarativa; el sistema decide cómo obtenerlos. Cincuenta y seis años después, ese desacoplamiento entre qué quieres y cómo se obtiene sigue siendo la razón por la que un optimizador puede reescribir tu consulta y hacerla mil veces más rápida sin que tú cambies una línea.
19.2.1 Terminología: la formal y la real
| Término formal | Término habitual | Definición precisa |
|---|---|---|
| Relación | Tabla | Conjunto de tuplas con el mismo esquema. Al ser un conjunto, en teoría no hay orden ni duplicados; SQL relaja ambas cosas y trabaja con multiconjuntos (bags). |
| Tupla | Fila, registro | Un elemento de la relación: una combinación concreta de valores, uno por atributo. |
| Atributo | Columna, campo | Un par nombre–dominio. El nombre identifica; el dominio restringe. |
| Dominio | Tipo de dato | Conjunto de valores permitidos para un atributo (integer, timestamptz, un ENUM...). Es la primera línea de defensa de la integridad. |
| Grado | Número de columnas | Cuántos atributos tiene la relación. |
| Cardinalidad | Número de filas | Cuántas tuplas contiene en un instante dado. |
SELECT *, ORDER BY), y admite NULL,
que Codd nunca planteó así y que rompe la lógica booleana clásica. Los tres puntos se tratan más abajo.
19.2.2 Claves: candidata, primaria, foránea, natural y subrogada
- Superclave. Cualquier conjunto de atributos que identifica unívocamente una fila. La tabla entera siempre es superclave, así que por sí sola no dice nada útil.
- Clave candidata. Una superclave mínima: si le quitas cualquier atributo deja de
identificar. Una tabla puede tener varias. En
users, tantoidcomoemailson claves candidatas. - Clave primaria. La clave candidata que eliges como identificador oficial. Implica
NOT NULLyUNIQUE, y en PostgreSQL crea automáticamente un índice B-tree único. Las demás candidatas se declaran conUNIQUE. - Clave alternativa. Cualquier clave candidata que no es la primaria.
- Clave foránea. Un atributo (o conjunto) cuyo valor debe existir como clave en otra tabla —o ser
NULL—. Es el mecanismo que materializa las relaciones y garantiza la integridad referencial. - Clave compuesta. Formada por más de una columna. Típica en tablas puente:
task_tags (task_id, tag_id).
Natural frente a subrogada
| Criterio | Clave natural (email, NIF, ISBN) | Clave subrogada (id autoincremental, UUID) |
|---|---|---|
| Significado | Tiene sentido en el negocio | No significa nada, solo identifica |
| Estabilidad | Baja: la gente cambia de email y las normativas cambian de formato | Total: nunca hay motivo de negocio para cambiarla |
| Tamaño | Variable, a menudo grande; se propaga a todas las claves foráneas e índices | 4 u 8 bytes (int/bigint) o 16 (uuid) |
| Joins | Más costosos si es texto largo | Comparación de enteros, lo más rápido posible |
| Exposición | Filtra información del negocio | Un id secuencial revela volumen y permite enumeración |
| Veredicto | Úsala como UNIQUE, no como PK | Por defecto como PK, con la clave natural en un UNIQUE aparte |
Recomendación profesional: clave primaria subrogada bigint generada por identidad, más
una restricción UNIQUE sobre la clave natural. Así obtienes joins baratos, estabilidad total y,
además, no pierdes la garantía de unicidad del negocio. El error habitual no es elegir mal la PK: es
renunciar a la clave natural y acabar con tres usuarios con el mismo correo.
19.2.3 Integridad de entidad e integridad referencial
Codd definió dos reglas de integridad que hoy están integradas en cualquier motor:
- Integridad de entidad. Ningún componente de la clave primaria puede ser
NULL. Si lo fuera, la fila no sería identificable, y una fila no identificable no se puede actualizar ni borrar de forma fiable. - Integridad referencial. Toda clave foránea no nula debe apuntar a una fila existente. Es lo que impide que haya una tarea colgando de un proyecto borrado.
psql. Segundo, sin la restricción no hay atomicidad: entre tu
SELECT de comprobación y tu INSERT cabe una transacción concurrente que borre la
fila referenciada. Tercero, el optimizador usa las claves foráneas para eliminar joins innecesarios y para
estimar cardinalidades. Renunciar a ellas es pagar por adelantado con corrupción de datos silenciosa.
Acciones referenciales: ON DELETE y ON UPDATE
| Acción | Qué hace al borrar/actualizar la fila referenciada | Cuándo usarla en TaskFlow |
|---|---|---|
NO ACTION | Rechaza la operación. Es el valor por defecto y la comprobación se puede diferir al final de la transacción. | Por defecto, si no tienes un motivo mejor |
RESTRICT | Igual que NO ACTION pero no se puede diferir: falla inmediatamente. | comments.author_id: no quieres perder la autoría |
CASCADE | Propaga el borrado (o la actualización) a las filas hijas. | task_tags, comments.task_id: son partes de la tarea |
SET NULL | Pone la clave foránea a NULL. Requiere que la columna lo admita. | tasks.assignee_id: si se borra el usuario, la tarea queda sin asignar |
SET DEFAULT | Pone el valor por defecto de la columna, que debe existir en la tabla padre. | Raro; útil con una fila «sin categoría» |
ON DELETE CASCADE: potente y peligroso
Un DELETE FROM teams WHERE id = 7 con cascadas bien puestas puede borrar el equipo, sus
proyectos, sus tareas, sus etiquetas, sus relaciones y sus comentarios. Eso es exactamente lo que quieres…
hasta el día en que alguien lo ejecuta sin WHERE. Dos salvaguardas: reserva
CASCADE para lo que es realmente parte del padre (composición, no asociación) y usa
borrado lógico (deleted_at) para las entidades con valor histórico.
Además, PostgreSQL no crea índice en la columna de la clave foránea. Sin él, cada borrado en la tabla padre provoca un recorrido secuencial completo de la tabla hija para comprobar la restricción. Es la causa número uno de borrados inexplicablemente lentos.
19.2.4 NULL y la lógica de tres valores
NULL no es un valor: es un marcador de ausencia de información. No es cero, no es la
cadena vacía y no es false. Como no se sabe qué hay, cualquier comparación con él devuelve
UNKNOWN, un tercer valor lógico. Y el WHERE solo deja pasar las filas cuya
condición es TRUE: UNKNOWN se descarta igual que FALSE. Ahí empiezan
todas las sorpresas.
TABLAS DE VERDAD CON TRES VALORES (T = true, F = false, U = unknown/NULL)
AND │ T F U OR │ T F U NOT
─────┼─────────────── ─────┼─────────────── ─────┬─────
T │ T F U T │ T T T T │ F
F │ F F F F │ T F U F │ T
U │ U F U U │ T U U U │ U
Regla mnemotécnica: AND se queda con el "peor" valor, OR con el "mejor",
y NOT U sigue siendo U -> por eso NOT IN con nulos no devuelve nada.
-- 1) NULL no es igual a NULL: el resultado es NULL, que NO es TRUE
SELECT NULL = NULL AS igual, -- NULL
NULL <> NULL AS distinto, -- NULL
NULL IS NULL AS es_nulo, -- true
NULL IS NOT DISTINCT FROM NULL AS seguro; -- true <- comparación "null-safe"
-- 2) Aritmética y concatenación: NULL contamina toda la expresión
SELECT 100 + NULL AS suma, -- NULL
'hola ' || NULL AS concat, -- NULL
concat('hola ', NULL) AS concat_fn, -- 'hola ' <- concat() ignora nulos
coalesce(NULL, NULL, 'valor por defecto') AS con_coalesce;
-- 3) La trampa de NOT IN. Esta consulta NUNCA devuelve filas:
SELECT 3 IN (1, 2, NULL) AS dentro, -- NULL (no false)
3 NOT IN (1, 2, NULL) AS fuera; -- NULL (no true)
-- Porque 3 NOT IN (1,2,NULL) equivale a 3<>1 AND 3<>2 AND 3<>NULL
-- = TRUE AND TRUE AND UNKNOWN = UNKNOWN
-- 4) Los agregados IGNORAN los nulos, salvo count(*)
-- tasks: 5 filas, de las cuales 2 tienen assignee_id NULL y 1 estimate_hours NULL
SELECT count(*) AS filas, -- 5 cuenta filas
count(assignee_id) AS asignadas, -- 3 cuenta valores no nulos
count(DISTINCT assignee_id) AS personas,-- 2
avg(estimate_hours) AS media, -- media de 4 valores, no de 5
sum(estimate_hours) AS total; -- NULL si TODAS fueran nulas
-- 5) sum() sobre cero filas devuelve NULL, no 0. Protégelo siempre:
SELECT coalesce(sum(estimate_hours), 0) AS total_seguro
FROM tasks WHERE project_id = -1; -- 0
-- 6) DISTINCT y GROUP BY sí tratan los nulos como iguales entre sí
SELECT DISTINCT assignee_id FROM tasks; -- devuelve UNA fila con NULL
-- 7) UNIQUE permite varios NULL (son "distintos" para el índice)
-- Desde PostgreSQL 15 se puede cambiar: UNIQUE NULLS NOT DISTINCT
-- 8) La ordenación: en PostgreSQL los nulos van al final en ASC
SELECT title, due_at FROM tasks ORDER BY due_at ASC NULLS LAST;
-- Objetivo: usuarios que NO tienen ninguna tarea asignada.
-- Si UNA sola fila de tasks tiene assignee_id NULL,
-- la subconsulta devuelve un NULL y el resultado es
-- SIEMPRE cero filas. Sin error, sin aviso.
SELECT u.id, u.email
FROM users u
WHERE u.id NOT IN (SELECT t.assignee_id FROM tasks t);
-- NOT EXISTS es inmune a los nulos: evalúa por filas,
-- no compara valores. Además el planificador lo ejecuta
-- como un Anti Join, que es la estrategia óptima.
SELECT u.id, u.email
FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM tasks t
WHERE t.assignee_id = u.id
);
-- Alternativa equivalente y también correcta:
SELECT u.id, u.email
FROM users u
LEFT JOIN tasks t ON t.assignee_id = u.id
WHERE t.id IS NULL;
<=> («null-safe equal»), equivalente al
IS NOT DISTINCT FROM del estándar que implementa PostgreSQL. En cambio, MySQL ordena los
nulos primero en orden ascendente y no admite la cláusula NULLS FIRST/LAST; hay que
emular la ordenación con una expresión auxiliar. La lógica de tres valores es idéntica en ambos.
Cuándo NULL es la respuesta correcta y cuándo es un error de diseño
- Correcto: el dato es genuinamente desconocido o no aplicable.
tasks.completed_atesNULLporque la tarea no ha terminado; no existe un valor sustituto honesto. - Incorrecto: usar
NULLcomo «cero», «vacío» o «falso». Una columnais_active booleancon tres estados posibles es una fuente inagotable de errores. DecláralaNOT NULL DEFAULT true. - Síntoma de mal modelado: una tabla con doce columnas anulables que solo se rellenan en ciertas
combinaciones. Eso suele significar que hay dos o tres entidades distintas metidas a la fuerza en una
tabla. La solución es normalizar, no añadir más
CHECK. - Regla práctica: declara
NOT NULLpor defecto y permite nulos solo cuando puedas explicar, en una frase, qué significa la ausencia de ese dato.
19.3 Álgebra relacional aplicada
El álgebra relacional es el conjunto de operaciones que definen qué se puede pedir a una base de datos
relacional. No es un adorno académico: es el lenguaje interno del optimizador. Cuando PostgreSQL
reescribe tu consulta —empujando un filtro hacia dentro de una subconsulta, convirtiendo un
IN en un semi-join o eliminando un join redundante— lo que está haciendo son transformaciones
algebraicas basadas en equivalencias demostrables.
| Operación | Notación | Qué hace | SQL equivalente |
|---|---|---|---|
| Selección | σcond(R) | Filtra filas que cumplen una condición | WHERE |
| Proyección | πa,b(R) | Selecciona columnas (y elimina duplicados) | SELECT DISTINCT a, b |
| Renombrado | ρx(R) | Da otro nombre a una relación o atributo | AS |
| Producto cartesiano | R × S | Combina cada fila de R con cada fila de S | CROSS JOIN, o FROM a, b sin condición |
| Join natural | R ⋈ S | Producto filtrado por igualdad en los atributos comunes | INNER JOIN … ON / USING |
| Unión | R ∪ S | Filas de R o de S, sin duplicados | UNION |
| Diferencia | R − S | Filas de R que no están en S | EXCEPT |
| Intersección | R ∩ S | Filas presentes en ambas | INTERSECT |
| Semi-join | R ⋉ S | Filas de R que tienen pareja en S, sin duplicarlas | WHERE EXISTS, IN |
| Anti-join | R ▷ S | Filas de R que no tienen pareja en S | WHERE NOT EXISTS |
| División | R ÷ S | Filas de R emparejadas con todos los elementos de S | Doble NOT EXISTS o GROUP BY … HAVING count(*) = n |
Tres consecuencias prácticas de que esto sea un álgebra:
- La unión y la diferencia exigen compatibilidad de esquemas: mismo número de columnas y tipos
compatibles, en el mismo orden. De ahí el error
each UNION query must have the same number of columns. - Un join es un producto cartesiano con una selección encima. Por eso, si olvidas la condición del join, obtienes n×m filas: no es un fallo del motor, es exactamente lo que has pedido.
- Las operaciones se componen. El resultado de cualquiera de ellas es otra relación, y eso es lo que permite anidar subconsultas, CTE y vistas sin límite conceptual.
-- Pregunta: proyectos del equipo 1 que tienen al menos una tarea urgente (priority = 1).
-- (a) Semi-join explícito con EXISTS: la forma más clara y la más eficiente.
SELECT p.id, p.name
FROM projects p
WHERE p.team_id = 1
AND EXISTS (SELECT 1 FROM tasks t WHERE t.project_id = p.id AND t.priority = 1);
-- (b) Con IN: equivalente aquí porque tasks.project_id es NOT NULL.
SELECT p.id, p.name
FROM projects p
WHERE p.team_id = 1
AND p.id IN (SELECT t.project_id FROM tasks t WHERE t.priority = 1);
-- (c) Con JOIN: cuidado, DUPLICA proyectos (uno por tarea urgente).
-- Hay que añadir DISTINCT, lo que obliga al motor a ordenar o a agrupar.
SELECT DISTINCT p.id, p.name
FROM projects p
JOIN tasks t ON t.project_id = p.id AND t.priority = 1
WHERE p.team_id = 1;
-- (d) División relacional: proyectos que tienen tareas en TODOS los estados posibles.
SELECT p.id, p.name
FROM projects p
WHERE NOT EXISTS (
SELECT 1
FROM unnest(enum_range(NULL::task_status)) AS s(estado)
WHERE NOT EXISTS (
SELECT 1 FROM tasks t
WHERE t.project_id = p.id AND t.status = s.estado
)
);
EXISTS: expresa la intención
exacta (semi-join), no duplica filas y permite al motor parar en cuanto encuentra la primera coincidencia.
Usa JOIN cuando necesites datos de la tabla de la derecha. El DISTINCT
defensivo sobre un join es casi siempre la señal de que lo que querías era un EXISTS.
19.4 DDL: definir el esquema
El esquema es el contrato más duradero de tu sistema. El código se reescribe cada dos años; los datos y su estructura sobreviven a varias reescrituras. Merece la pena diseñarlo con cuidado.
-- Extensiones que usaremos
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- búsqueda por similitud
CREATE EXTENSION IF NOT EXISTS citext; -- texto sin distinguir mayúsculas
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email citext NOT NULL UNIQUE, -- citext: 'Ana@x.com' = 'ana@x.com'
password_hash text NOT NULL,
full_name text NOT NULL,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz,
deleted_at timestamptz,
CONSTRAINT users_full_name_not_blank CHECK (length(btrim(full_name)) > 0)
);
CREATE TABLE teams (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
slug text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Entidad pivote CON atributos: no es un simple M:N
CREATE TABLE team_members (
team_id uuid NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role text NOT NULL DEFAULT 'member',
joined_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (team_id, user_id), -- clave compuesta natural
CONSTRAINT team_members_role_valid CHECK (role IN ('owner', 'admin', 'member', 'guest'))
);
CREATE TABLE projects (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
team_id uuid NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
name text NOT NULL,
description text,
archived_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
-- El nombre es único DENTRO del equipo, no globalmente
CONSTRAINT projects_team_name_unique UNIQUE (team_id, name)
);
CREATE TYPE task_status AS ENUM ('open', 'in_progress', 'done', 'cancelled');
CREATE TABLE tasks (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
assignee_id uuid REFERENCES users(id) ON DELETE SET NULL, -- si se va, la tarea queda huérfana
title varchar(180) NOT NULL,
description text,
status task_status NOT NULL DEFAULT 'open',
priority smallint NOT NULL DEFAULT 3,
estimate_h numeric(6,2),
due_date date,
completed_at timestamptz,
version integer NOT NULL DEFAULT 1, -- bloqueo optimista (capítulo 17)
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz,
CONSTRAINT tasks_priority_range CHECK (priority BETWEEN 1 AND 5),
-- Regla de negocio expresada en el esquema: si está hecha, tiene fecha de cierre
CONSTRAINT tasks_done_needs_date CHECK (
(status <> 'done') OR (completed_at IS NOT NULL)
)
);
CREATE TABLE tags (
id serial PRIMARY KEY,
name citext NOT NULL UNIQUE,
color char(7) NOT NULL DEFAULT '#5b8cff'
);
CREATE TABLE task_tags (
task_id bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
tag_id integer NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (task_id, tag_id)
);
CREATE TABLE comments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
task_id bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
author_id uuid NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
parent_id bigint REFERENCES comments(id) ON DELETE CASCADE, -- auto-referencia: hilos
body text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CHECK es un test que se ejecuta siempre
Una restricción en la base de datos no se puede olvidar, no depende de que el desarrollador use el ORM
correctamente y protege también frente a scripts manuales y a migraciones de datos. Las validaciones de
class-validator (capítulo 10) dan buenos mensajes de error al usuario; las restricciones del
esquema garantizan que los datos nunca queden en un estado imposible. Necesitas las dos: no son
redundantes, son capas distintas.
19.4.1 ON DELETE: la decisión que más se descuida
| Acción | Qué hace al borrar el padre | Cuándo usarla en TaskFlow |
|---|---|---|
CASCADE | Borra también los hijos | Composición real: al borrar una tarea, sus comentarios y sus etiquetas asociadas no tienen sentido |
SET NULL | Deja la columna a NULL | Agregación débil: al borrar un usuario, sus tareas quedan sin responsable, no se destruyen |
RESTRICT | Impide el borrado | Protección: no permitir borrar un usuario que tiene comentarios (integridad histórica) |
NO ACTION | Como RESTRICT, pero la comprobación puede diferirse al final de la transacción | Por defecto; útil con restricciones DEFERRABLE |
SET DEFAULT | Pone el valor por defecto | Raro; requiere que el valor por defecto exista en la tabla referenciada |
19.5 Tipos de datos: elegir bien de entrada
| Necesidad | Tipo recomendado | Por qué / qué evitar |
|---|---|---|
| Identificador secuencial | bigint GENERATED ALWAYS AS IDENTITY | Estándar SQL, sustituye a serial. Usa bigint desde el principio: migrar de int a bigint en una tabla de mil millones de filas es una noche muy larga |
| Identificador opaco o distribuido | uuid (v7 si puedes) | v4 es aleatorio y fragmenta el índice B-tree al insertar; v7 lleva marca de tiempo y es casi secuencial, así que se comporta como un entero |
| Dinero | numeric(12,2) o entero de céntimos | Nunca float/double: 0.1 + 0.2 <> 0.3 en coma flotante binaria |
| Texto | text | En PostgreSQL text y varchar rinden igual. Usa varchar(n) solo cuando el límite sea una regla de negocio real |
| Texto sin distinguir mayúsculas | citext | Alternativa: índice único sobre LOWER(col) |
| Instante en el tiempo | timestamptz | Guarda en UTC y convierte según la zona de la sesión. timestamp sin zona es una fuente inagotable de errores |
| Fecha sin hora | date | Un cumpleaños o un vencimiento no tienen zona horaria |
| Duración | interval o entero de minutos | interval es expresivo; el entero es más fácil de agregar |
| Conjunto cerrado de valores | enum nativo o text + CHECK | El enum es compacto y validado, pero añadir valores requiere ALTER TYPE y quitarlos es doloroso. text + CHECK es más flexible |
| Datos flexibles | jsonb | Nunca json (guarda el texto literal, sin indexar). Y no lo uses para huir del modelado |
| Lista corta de escalares | text[] | Cómodo, pero sin integridad referencial: si necesitas relacionar, usa una tabla |
CREATE TABLE invoices (
id serial PRIMARY KEY,
amount double precision NOT NULL, -- coma flotante para dinero
created timestamp NOT NULL -- sin zona horaria
);
SELECT 0.1::float8 + 0.2::float8 = 0.3::float8;
-- false ← y tu factura descuadra un céntimo cada mil líneas
CREATE TABLE invoices (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
amount numeric(12,2) NOT NULL,
currency char(3) NOT NULL DEFAULT 'EUR',
created_at timestamptz NOT NULL DEFAULT now()
);
SELECT 0.1::numeric + 0.2::numeric = 0.3::numeric;
-- true ← aritmética decimal exacta
19.6 Normalización paso a paso
Normalizar es eliminar redundancia para que cada hecho se almacene una sola vez. La redundancia no molesta por ocupar espacio, sino porque permite que dos copias del mismo hecho se contradigan.
19.6.1 El punto de partida: una tabla mal diseñada
CREATE TABLE pedidos_mal (
pedido_id int,
cliente_email text,
cliente_nombre text,
cliente_ciudad text,
productos text, -- 'teclado, ratón, monitor' ← varios valores en una celda
precios text, -- '25.00, 12.50, 199.00'
fecha date
);
Esta tabla sufre las tres anomalías clásicas:
- Anomalía de inserción: no puedes registrar un cliente que aún no ha hecho ningún pedido.
- Anomalía de actualización: si un cliente cambia de ciudad, tienes que actualizar todas sus filas; si fallas en una, el sistema pasa a tener dos verdades.
- Anomalía de borrado: al borrar el último pedido de un cliente pierdes también sus datos.
19.6.2 Las formas normales aplicadas
| Forma | Regla | Qué corregimos aquí |
|---|---|---|
| 1FN | Valores atómicos, sin grupos repetidos | productos y precios pasan a ser filas de una tabla lineas_pedido |
| 2FN | 1FN y ningún atributo depende de parte de una clave compuesta | En lineas_pedido(pedido_id, producto_id), el nombre del producto depende solo de producto_id: se va a productos |
| 3FN | 2FN y ningún atributo depende de otro no clave | cliente_nombre y cliente_ciudad dependen de cliente_email, no del pedido: se van a clientes |
| BCNF | Toda dependencia funcional parte de una superclave | Caso raro en la práctica; aparece con varias claves candidatas solapadas |
CREATE TABLE clientes (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email citext NOT NULL UNIQUE,
nombre text NOT NULL,
ciudad text
);
CREATE TABLE productos (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nombre text NOT NULL,
precio numeric(10,2) NOT NULL
);
CREATE TABLE pedidos (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
cliente_id bigint NOT NULL REFERENCES clientes(id),
fecha date NOT NULL DEFAULT current_date
);
CREATE TABLE lineas_pedido (
pedido_id bigint NOT NULL REFERENCES pedidos(id) ON DELETE CASCADE,
producto_id bigint NOT NULL REFERENCES productos(id),
cantidad integer NOT NULL CHECK (cantidad > 0),
-- Excepción deliberada: el precio se COPIA en la línea.
-- No es redundancia: es un hecho histórico. Si mañana sube el precio del
-- producto, la factura de ayer debe seguir diciendo lo que se cobró ayer.
precio_unitario numeric(10,2) NOT NULL,
PRIMARY KEY (pedido_id, producto_id)
);
Se desnormaliza después de medir, no antes. Los casos legítimos: contadores agregados
(projects.task_count) para evitar un COUNT caro en cada listado, campos
calculados que se consultan mucho, y copias históricas como el precio de la línea de pedido.
La condición innegociable es que exista un mecanismo que mantenga la copia sincronizada: un trigger, un suscriptor del ORM (capítulo 17) o un recálculo periódico. Un contador desnormalizado sin mantenimiento se convierte en un dato que miente, y un dato que miente es peor que no tener el dato.
19.7 Consultas: del SELECT a las funciones de ventana
19.7.1 El orden lógico de ejecución
Se escribe en un orden y se ejecuta en otro. Entender esto resuelve la mitad de las dudas de sintaxis.
ORDEN EN QUE SE ESCRIBE ORDEN EN QUE SE EVALÚA
─────────────────────── ──────────────────────
SELECT ... 1. FROM / JOIN ← se construye el conjunto de filas
FROM ... 2. WHERE ← se filtran filas individuales
WHERE ... 3. GROUP BY ← se agrupan
GROUP BY ... 4. HAVING ← se filtran GRUPOS
HAVING ... 5. SELECT ← se calculan las columnas y los alias
ORDER BY ... 6. DISTINCT
LIMIT ... 7. ORDER BY ← aquí YA existen los alias del SELECT
8. LIMIT / OFFSET
Consecuencias prácticas:
· No puedes usar un alias del SELECT en el WHERE (aún no existe).
· Sí puedes usarlo en el ORDER BY (ya existe).
· WHERE filtra filas; HAVING filtra grupos. No son intercambiables.
· WHERE antes de agrupar es más barato que HAVING después: filtra cuanto antes.
19.7.2 Joins
A = usuarios B = tareas asignadas
INNER JOIN LEFT JOIN LEFT JOIN ... WHERE b.id IS NULL
┌─────┬─────┐ ┌─────┬─────┐ ┌─────┬─────┐
│ A │██B██│ │█████│██B██│ │█████│ B │
│ │█████│ │█████│█████│ │█████│ │
└─────┴─────┘ └─────┴─────┘ └─────┴─────┘
solo coincidencias todo A + lo que solo A sin pareja
coincida de B ("anti-join")
FULL OUTER JOIN CROSS JOIN
┌─────┬─────┐ cada fila de A con cada fila de B
│█████│█████│ (producto cartesiano: |A| × |B| filas)
│█████│█████│ Útil para generar combinaciones; peligroso por accidente.
└─────┴─────┘
-- 1. INNER: solo tareas que tienen responsable
SELECT t.id, t.title, u.full_name
FROM tasks t
JOIN users u ON u.id = t.assignee_id;
-- 2. LEFT: todas las tareas, con responsable o sin él
SELECT t.id, t.title, COALESCE(u.full_name, 'Sin asignar') AS responsable
FROM tasks t
LEFT JOIN users u ON u.id = t.assignee_id;
-- 3. ANTI-JOIN: usuarios que no tienen ninguna tarea abierta
SELECT u.id, u.email
FROM users u
LEFT JOIN tasks t ON t.assignee_id = u.id AND t.status = 'open'
WHERE t.id IS NULL;
-- OJO: la condición sobre t va en el ON, no en el WHERE.
-- Si la pones en el WHERE, el LEFT JOIN se degrada a INNER JOIN.
-- 4. SELF JOIN: comentarios con su comentario padre
SELECT c.id, c.body, p.body AS respuesta_a
FROM comments c
LEFT JOIN comments p ON p.id = c.parent_id;
-- 5. LATERAL: las 3 tareas más recientes DE CADA proyecto.
-- LATERAL permite que la subconsulta referencie la fila exterior.
SELECT p.name, t.title, t.created_at
FROM projects p
CROSS JOIN LATERAL (
SELECT t.title, t.created_at
FROM tasks t
WHERE t.project_id = p.id
ORDER BY t.created_at DESC
LIMIT 3
) t;
LEFT JOIN convertido en INNER
Es probablemente el fallo de SQL más frecuente en producción. Al filtrar en el WHERE por una
columna de la tabla de la derecha, las filas sin pareja (que tienen NULL en esa columna) se
descartan, y con ellas desaparece todo el sentido del LEFT JOIN. La condición que restringe la
tabla derecha va en el ON; la que restringe la izquierda, en el WHERE.
19.7.3 Agregación
-- COUNT(*) cuenta filas; COUNT(col) ignora los NULL. No son lo mismo.
SELECT
count(*) AS total_tareas,
count(assignee_id) AS con_responsable,
count(*) - count(assignee_id) AS sin_responsable,
count(DISTINCT assignee_id) AS personas_implicadas,
-- FILTER es más legible que CASE WHEN dentro del agregado
count(*) FILTER (WHERE status = 'done') AS completadas,
count(*) FILTER (WHERE due_date < current_date
AND status <> 'done') AS vencidas,
round(avg(estimate_h), 2) AS estimacion_media,
-- avg() ignora los NULL: la media es sobre las tareas QUE TIENEN estimación
sum(estimate_h) AS horas_totales
FROM tasks
WHERE project_id = $1;
-- Resumen por proyecto, quedándonos solo con los proyectos grandes
SELECT p.name,
count(t.id) AS tareas,
count(*) FILTER (WHERE t.status = 'done') AS hechas
FROM projects p
LEFT JOIN tasks t ON t.project_id = p.id
GROUP BY p.id, p.name -- agrupa por la clave primaria: p.name va "de regalo"
HAVING count(t.id) >= 10 -- HAVING filtra GRUPOS
ORDER BY tareas DESC;
19.7.4 CTE y recursividad
WITH RECURSIVE hilo AS (
-- Caso base: comentarios raíz de la tarea
SELECT c.id, c.parent_id, c.body, c.author_id, 0 AS nivel,
ARRAY[c.created_at] AS camino
FROM comments c
WHERE c.task_id = $1 AND c.parent_id IS NULL
UNION ALL
-- Paso recursivo: hijos de lo ya encontrado
SELECT c.id, c.parent_id, c.body, c.author_id, h.nivel + 1,
h.camino || c.created_at
FROM comments c
JOIN hilo h ON c.parent_id = h.id
WHERE h.nivel < 10 -- cortafuegos: evita una recursión infinita si hay un ciclo
)
SELECT repeat(' ', nivel) || body AS comentario, nivel
FROM hilo
ORDER BY camino; -- ordena manteniendo la estructura del hilo
19.7.5 Funciones de ventana
Una función de ventana calcula un valor sobre un conjunto de filas relacionadas sin colapsarlas.
Es la diferencia esencial con GROUP BY: conservas todas las filas y añades el cálculo.
-- 1. La tarea más reciente de cada proyecto (patrón "top-N por grupo")
SELECT *
FROM (
SELECT t.*,
row_number() OVER (PARTITION BY t.project_id ORDER BY t.created_at DESC) AS rn
FROM tasks t
) x
WHERE rn = 1;
-- Alternativa específica de PostgreSQL, más corta y a menudo más rápida:
SELECT DISTINCT ON (project_id) *
FROM tasks
ORDER BY project_id, created_at DESC;
-- 2. Ranking de personas por tareas completadas, con desempate
SELECT u.full_name,
count(*) AS completadas,
rank() OVER (ORDER BY count(*) DESC) AS puesto,
dense_rank() OVER (ORDER BY count(*) DESC) AS puesto_sin_huecos
FROM tasks t
JOIN users u ON u.id = t.assignee_id
WHERE t.status = 'done'
GROUP BY u.id, u.full_name;
-- rank() deja huecos tras un empate (1,1,3); dense_rank() no (1,1,2)
-- 3. Acumulado y comparación con el periodo anterior
SELECT dia,
cerradas,
sum(cerradas) OVER (ORDER BY dia) AS acumulado,
lag(cerradas) OVER (ORDER BY dia) AS dia_anterior,
cerradas - lag(cerradas, 1, 0) OVER (ORDER BY dia) AS variacion,
round(avg(cerradas) OVER (ORDER BY dia
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 1) AS media_movil_7d
FROM (
SELECT date_trunc('day', completed_at)::date AS dia, count(*) AS cerradas
FROM tasks
WHERE completed_at >= current_date - interval '90 days'
GROUP BY 1
) d
ORDER BY dia;
19.7.6 Escritura: ON CONFLICT, RETURNING y actualizaciones con join
-- UPSERT: inserta o actualiza en una sola operación atómica.
-- Sin ON CONFLICT harían falta un SELECT y luego un INSERT o UPDATE,
-- con una condición de carrera entre ambos.
INSERT INTO tags (name, color)
VALUES ('urgente', '#ff5c7a')
ON CONFLICT (name) DO UPDATE
SET color = EXCLUDED.color -- EXCLUDED = la fila que se intentaba insertar
RETURNING id, name;
-- Ignorar duplicados sin error (inserción masiva idempotente)
INSERT INTO task_tags (task_id, tag_id)
SELECT $1, unnest($2::int[])
ON CONFLICT DO NOTHING;
-- UPDATE ... FROM: actualizar usando datos de otra tabla
UPDATE tasks t
SET status = 'cancelled', updated_at = now()
FROM projects p
WHERE p.id = t.project_id
AND p.archived_at IS NOT NULL
AND t.status IN ('open', 'in_progress')
RETURNING t.id; -- devuelve lo modificado: útil para auditar
-- DELETE con subconsulta correlacionada
DELETE FROM comments c
WHERE NOT EXISTS (SELECT 1 FROM tasks t WHERE t.id = c.task_id);
19.8 Índices
19.8.1 Cómo funciona un B-tree
Índice B-tree sobre tasks(due_date). Altura 3 para millones de filas:
bastan 3 o 4 lecturas de página para localizar cualquier valor.
┌──────────────────────┐
raíz │ 2026-03 │ 2026-09 │
└─────┬──────────┬─────┘
┌───────────┘ └───────────┐
┌─────▼─────┐ ┌─────▼─────┐
internas │ 01 │ 02 │…│ │ 10 │ 11 │…│
└──┬────────┘ └────────┬──┘
│ │
┌──────▼───────┐ ┌───────▼──────┐
hojas │fecha → ctid │◄──── enlazadas ───────►│fecha → ctid │
│2026-01-03→… │ entre sí │2026-10-02→… │
└──────────────┘ (permite recorrer └──────────────┘
rangos y ORDER BY
sin ordenar nada)
Por eso un B-tree sirve para: =, <, <=, >, >=, BETWEEN, IN,
ORDER BY, y LIKE 'prefijo%' (pero NO LIKE '%sufijo').
19.8.2 Tipos de índice y cuándo usarlos
| Tipo | Para qué | Ejemplo en TaskFlow |
|---|---|---|
| B-tree | Igualdad, rangos y ordenación. El 95 % de los casos | tasks(project_id, status) |
| GIN | Valores que contienen muchos elementos: jsonb, arrays, texto completo, trigramas | Búsqueda de texto en tasks.title |
| GiST | Datos geométricos, rangos, vecino más próximo | Solapamiento de rangos de fechas |
| BRIN | Tablas enormes con datos físicamente ordenados (series temporales). Índice diminuto | Tabla de auditoría por created_at |
| Hash | Solo igualdad. Rara vez mejor que un B-tree | Casi nunca |
19.8.3 Índices compuestos y la regla del prefijo izquierdo
CREATE INDEX idx_tasks_project_status_due
ON tasks (project_id, status, due_date);
-- Consultas que APROVECHAN el índice:
-- WHERE project_id = ? ← prefijo (1 columna)
-- WHERE project_id = ? AND status = ? ← prefijo (2 columnas)
-- WHERE project_id = ? AND status = ? AND due_date < ? ← completo
-- WHERE project_id = ? ORDER BY status, due_date ← orden servido por el índice
--
-- Consultas que NO lo aprovechan (o solo parcialmente):
-- WHERE status = ? ← salta la primera columna: no hay prefijo
-- WHERE due_date < ? ← idem
-- REGLA DE ORDENACIÓN DE COLUMNAS:
-- 1) columnas de igualdad (=) primero, de mayor a menor selectividad
-- 2) después una única columna de rango (<, >, BETWEEN)
-- 3) al final, columnas que solo se usan para ORDER BY
-- Tras una columna de rango, las siguientes ya no filtran en el índice.
-- ÍNDICE PARCIAL: mucho más pequeño y rápido si solo consultas un subconjunto
CREATE INDEX idx_tasks_activas
ON tasks (project_id, due_date)
WHERE status IN ('open', 'in_progress');
-- ÍNDICE SOBRE EXPRESIÓN: necesario si consultas con una función
CREATE INDEX idx_users_email_lower ON users (lower(email));
-- Ahora sí usa índice: WHERE lower(email) = lower($1)
-- ÍNDICE ÚNICO PARCIAL: la solución al soft delete (capítulo 17).
-- Permite reutilizar un email si la fila anterior está borrada lógicamente.
CREATE UNIQUE INDEX uq_users_email_vivos
ON users (email) WHERE deleted_at IS NULL;
-- INCLUDE: permite un "index only scan" sin ampliar la clave de búsqueda
CREATE INDEX idx_tasks_lista
ON tasks (project_id, created_at DESC) INCLUDE (title, status);
-- EN PRODUCCIÓN, siempre CONCURRENTLY: no bloquea las escrituras.
-- No puede ejecutarse dentro de una transacción (importante para las migraciones).
CREATE INDEX CONCURRENTLY idx_comments_task ON comments (task_id);
DELETE en la tabla padre tiene que recorrer la tabla hija
entera para comprobar la integridad, y que tus joins van por escaneo secuencial. Revisa que todas tus
columnas *_id tengan índice. Es la optimización con mejor relación entre esfuerzo y resultado
que existe.
19.8.4 Por qué el motor ignora tu índice
-- Función sobre la columna indexada
WHERE lower(email) = 'ana@x.com' -- sin índice de expresión
WHERE date(created_at) = '2026-05-01'
WHERE extract(year FROM due_date) = 2026
-- Comodín inicial
WHERE title LIKE '%informe%'
-- Tipos distintos: obliga a convertir cada fila
WHERE project_id = '123' -- uuid comparado con texto
-- Negación y OR sobre columnas distintas
WHERE status <> 'done'
WHERE assignee_id = $1 OR project_id = $2
-- Índice de expresión, o citext en la columna
WHERE email = 'ana@x.com'::citext
-- Rango en lugar de función sobre la columna
WHERE created_at >= '2026-05-01'
AND created_at < '2026-05-02'
WHERE due_date BETWEEN '2026-01-01' AND '2026-12-31'
-- Trigramas con índice GIN para el comodín inicial
CREATE INDEX ON tasks USING gin (title gin_trgm_ops);
-- Tipos coherentes (parámetro tipado en el driver)
WHERE project_id = $1::uuid
-- Lista positiva; y para el OR, UNION de dos consultas indexadas
WHERE status IN ('open', 'in_progress', 'cancelled')
19.9 Leer un plan de ejecución
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT t.id, t.title, u.full_name
FROM tasks t
LEFT JOIN users u ON u.id = t.assignee_id
WHERE t.project_id = '3f2a...'::uuid
AND t.status = 'open'
ORDER BY t.due_date
LIMIT 50;
ANTES (sin índice adecuado)
────────────────────────────────────────────────────────────────────────
Limit (cost=48210.5..48210.6 rows=50) (actual time=842.113..842.140 rows=50)
-> Sort (cost=48210.5..48284.2 rows=29480) (actual time=842.110..842.124)
Sort Key: t.due_date
Sort Method: top-N heapsort Memory: 41kB
-> Hash Left Join (actual time=12.4..790.2 rows=29480)
-> Seq Scan on tasks t (actual time=0.05..701.8 rows=29480)
Filter: (project_id = '3f2a...' AND status = 'open')
Rows Removed by Filter: 1970520 ← descarta 2 millones
Buffers: shared hit=1024 read=38112 ← 38k páginas de disco
Planning Time: 0.312 ms
Execution Time: 842.301 ms
DESPUÉS de: CREATE INDEX idx_tasks_activas ON tasks (project_id, status, due_date);
────────────────────────────────────────────────────────────────────────
Limit (cost=0.56..38.20 rows=50) (actual time=0.041..0.212 rows=50)
-> Nested Loop Left Join (actual time=0.039..0.198 rows=50)
-> Index Scan using idx_tasks_activas on tasks t
(actual time=0.028..0.061 rows=50)
Index Cond: (project_id = '3f2a...' AND status = 'open')
Buffers: shared hit=8 ← 8 páginas, todas en caché
-> Index Scan using users_pkey on users u (actual time=0.002..0.002)
Planning Time: 0.284 ms
Execution Time: 0.267 ms ← 3.150 veces más rápido
Nota: ya no aparece el nodo Sort. El índice entrega las filas ordenadas por
due_date, así que el LIMIT 50 puede parar en cuanto tiene 50 filas.
| Lo que ves | Qué significa | Qué hacer |
|---|---|---|
Seq Scan en tabla grande | Lee la tabla entera | Falta índice, o el filtro es poco selectivo |
Rows Removed by Filter enorme | Lee mucho y tira casi todo | Índice que incluya ese filtro |
rows estimadas muy lejos de las reales | Estadísticas obsoletas o correlación entre columnas | ANALYZE tabla; ajustar default_statistics_target |
Sort Method: external merge Disk: … | La ordenación no cupo en memoria | Subir work_mem o servir el orden con un índice |
Nested Loop con muchas filas | Bucle sobre un conjunto grande | Suele indicar mala estimación; revisa estadísticas |
read= alto frente a hit= | Va a disco en lugar de a caché | Menos datos leídos (índice, proyección) o más memoria |
Index Only Scan | Todo lo necesario está en el índice | Es el mejor caso; se logra con INCLUDE |
cost (son unidades arbitrarias del planificador) y mira actual time y
rows. Busca el nodo que consume la mayor parte del tiempo y pregúntate por qué toca tantas
filas. El 90 % de los problemas son un Seq Scan evitable o un Sort que un índice
podría haber servido gratis.
19.10 Transacciones, MVCC y concurrencia
19.10.1 ACID, sin recitarlo de memoria
| Propiedad | Qué garantiza | Ejemplo en TaskFlow |
|---|---|---|
| Atomicidad | Todo o nada | Crear un proyecto con sus tres miembros iniciales: o se crean los cuatro registros o ninguno |
| Consistencia | Las restricciones se cumplen al terminar | Ninguna tarea queda apuntando a un proyecto inexistente |
| Aislamiento | Las transacciones concurrentes no se estorban | Dos usuarios cerrando tareas a la vez no ven estados intermedios del otro |
| Durabilidad | Lo confirmado sobrevive a una caída | Tras el COMMIT, un corte de luz no pierde el dato (WAL) |
19.10.2 MVCC: por qué en PostgreSQL leer no bloquea
Control de concurrencia multiversión: cada UPDATE crea una VERSIÓN NUEVA
de la fila en lugar de sobrescribirla.
fila id=7 ┌──────────────────────────────────────────────┐
│ v1 xmin=100 xmax=140 status='open' │ ← versión antigua
│ v2 xmin=140 xmax=null status='done' │ ← versión vigente
└──────────────────────────────────────────────┘
▲ ▲
Tx 120 (empezó antes) Tx 150 (empezó después)
ve v1: 'open' ve v2: 'done'
Consecuencias:
· Los lectores NUNCA bloquean a los escritores, ni al revés.
· Las versiones muertas ocupan espacio: de ahí VACUUM y el "bloat".
· Una transacción abierta durante horas impide limpiar versiones muertas
de TODA la base de datos. Por eso las transacciones largas son dañinas
incluso si solo leen.
19.10.3 Niveles de aislamiento y anomalías
| Nivel | Lectura sucia | Lectura no repetible | Fantasmas | Anomalía de serialización |
|---|---|---|---|---|
| READ UNCOMMITTED | Posible (en PostgreSQL, no) | Posible | Posible | Posible |
| READ COMMITTED (por defecto) | No | Posible | Posible | Posible |
| REPEATABLE READ | No | No | No (en PostgreSQL) | Posible |
| SERIALIZABLE | No | No | No | No |
ACTUALIZACIÓN PERDIDA con READ COMMITTED (el caso que más daño hace)
Tiempo Transacción A Transacción B
────── ───────────────────────────── ─────────────────────────────
t1 BEGIN
t2 SELECT saldo FROM cuentas BEGIN
WHERE id=1; → 100
t3 SELECT saldo FROM cuentas
WHERE id=1; → 100
t4 UPDATE cuentas SET saldo=90
WHERE id=1;
t5 COMMIT
t6 UPDATE cuentas SET saldo=80
WHERE id=1; ← pisa el cambio de A
t7 COMMIT
Resultado: 80. Se han gastado 10 + 20 = 30, pero el saldo dice que solo 20.
El cargo de A se ha perdido y ninguna transacción ha fallado.
SOLUCIONES
1) Actualización atómica en la propia base de datos (la más simple):
UPDATE cuentas SET saldo = saldo - 10 WHERE id=1 AND saldo >= 10;
2) Bloqueo pesimista: SELECT ... FOR UPDATE (B espera a que A confirme)
3) Bloqueo optimista: columna version + WHERE version = ? (capítulo 17)
4) Aislamiento SERIALIZABLE + reintento al recibir error de serialización
BEGIN;
-- Bloquea las filas seleccionadas hasta el final de la transacción.
-- Otras transacciones que hagan FOR UPDATE sobre ellas esperarán.
SELECT * FROM tasks WHERE id = $1 FOR UPDATE;
UPDATE tasks SET status = 'in_progress' WHERE id = $1;
COMMIT;
-- Patrón de cola de trabajos dentro de la base de datos:
-- SKIP LOCKED hace que cada worker tome trabajos DISTINTOS sin esperar.
BEGIN;
WITH siguiente AS (
SELECT id FROM jobs
WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE jobs j SET status = 'processing', started_at = now()
FROM siguiente s WHERE j.id = s.id
RETURNING j.*;
COMMIT;
40P01. Prevención: bloquear
siempre los recursos en el mismo orden (por ejemplo, por identificador ascendente), mantener las
transacciones cortas y reintentar la operación cuando el error sea de interbloqueo o de serialización.
19.11 Rendimiento y operación
- Conexiones. Cada conexión de PostgreSQL es un proceso del sistema operativo con su propia memoria. Miles de conexiones no escalan. Con varias réplicas de la API, el número total de conexiones es réplicas × tamaño del pool: dimensiónalo y usa PgBouncer en modo transacción cuando crezca.
work_mem. Memoria por operación de ordenación o hash, no por consulta. Una consulta con tres nodosSortpuede usar tres veces ese valor, y cada conexión lo suyo.- Estadísticas. El planificador decide con muestras. Tras una carga masiva, ejecuta
ANALYZE; si una columna tiene una distribución muy sesgada, sube su objetivo de estadísticas. - Autovacuum. Limpia versiones muertas y actualiza estadísticas. Si una tabla recibe muchas actualizaciones, conviene afinar sus parámetros por tabla en lugar de dejar los globales.
- Particionado. Para tablas muy grandes con un criterio temporal claro (auditoría, eventos),
particionar por rango de fecha permite eliminar meses enteros con un
DROPinstantáneo y reduce el tamaño de los índices. - Réplicas de lectura. Descargan informes y lecturas pesadas, pero introducen retraso de replicación: no leas de la réplica algo que acabas de escribir en el primario.
19.12 Búsqueda de texto
-- Nivel 1: LIKE. Solo sirve con prefijo si quieres usar índice.
SELECT * FROM tasks WHERE title ILIKE 'informe%';
-- Nivel 2: trigramas. Tolera errores tipográficos y comodín inicial.
CREATE INDEX idx_tasks_title_trgm ON tasks USING gin (title gin_trgm_ops);
SELECT title, similarity(title, 'infrome mensal') AS parecido
FROM tasks
WHERE title % 'infrome mensal' -- operador de similitud
ORDER BY parecido DESC
LIMIT 10;
-- Nivel 3: búsqueda de texto completo con lematización en español.
ALTER TABLE tasks ADD COLUMN busqueda tsvector
GENERATED ALWAYS AS (
to_tsvector('spanish', coalesce(title,'') || ' ' || coalesce(description,''))
) STORED;
CREATE INDEX idx_tasks_busqueda ON tasks USING gin (busqueda);
SELECT id, title, ts_rank(busqueda, q) AS relevancia
FROM tasks, plainto_tsquery('spanish', 'informes mensuales') q
WHERE busqueda @@ q
ORDER BY relevancia DESC
LIMIT 20;
-- 'informes mensuales' encuentra 'informe mensual': la lematización
-- reduce cada palabra a su raíz.
19.13 Datos semiestructurados con jsonb
ALTER TABLE tasks ADD COLUMN metadata jsonb NOT NULL DEFAULT '{}'::jsonb;
-- Operadores esenciales
SELECT metadata -> 'origen' AS origen_json, -- devuelve jsonb
metadata ->> 'origen' AS origen_texto, -- devuelve text
metadata #>> '{cliente,id}' AS cliente_id -- ruta anidada
FROM tasks;
-- Índice GIN para consultas de contención
CREATE INDEX idx_tasks_metadata ON tasks USING gin (metadata);
SELECT * FROM tasks WHERE metadata @> '{"origen": "email"}';
-- Si SIEMPRE consultas por el mismo campo, un índice de expresión B-tree
-- es más pequeño y rápido que el GIN completo:
CREATE INDEX idx_tasks_origen ON tasks ((metadata ->> 'origen'));
jsonb es una trampa
Es excelente para datos genuinamente variables: la carga útil de un webhook, preferencias de usuario, un
formulario definido por el cliente. Es una mala idea cuando lo usas para no decidir el esquema: dentro de
un jsonb no hay claves foráneas, ni tipos, ni NOT NULL, ni restricciones, las
consultas son más lentas y las migraciones se convierten en scripts de transformación. La regla: si un
campo lo consultas o lo filtras a menudo, merece ser una columna.
19.14 Seguridad de la base de datos
- Usuario de aplicación con privilegios mínimos. La API no debe conectarse como superusuario ni
como propietario del esquema: solo necesita
SELECT,INSERT,UPDATEyDELETEsobre sus tablas. Las migraciones se ejecutan con otro usuario que sí puede alterar el esquema. - Inyección SQL. El ORM protege mientras uses parámetros. Se rompe en cuanto concatenas cadenas en
una consulta nativa o en un
ORDER BYdinámico. - Row Level Security. Aísla filas por inquilino a nivel del motor, de modo que una consulta mal escrita no pueda filtrar datos entre clientes. Es más robusto que un filtro de aplicación, aunque exige propagar el identificador de inquilino en la sesión.
- Copias de seguridad probadas. Una copia que nunca se ha restaurado no es una copia: es una suposición. Programa restauraciones de prueba periódicas y mide cuánto tardan.
- Datos personales. Cifrado en tránsito y en reposo, y anonimización al copiar producción a entornos de prueba.
// El parámetro llega del cliente y se concatena.
// ?orden=id; DROP TABLE tasks; --
const sql = `SELECT * FROM tasks ORDER BY ${orden}`;
await em.getConnection().execute(sql);
// Los identificadores no se pueden parametrizar:
// hay que validarlos contra una lista blanca.
const COLUMNAS = { titulo: 'title', creada: 'created_at' } as const;
const col = COLUMNAS[orden as keyof typeof COLUMNAS] ?? 'created_at';
const dir = dirParam === 'desc' ? 'DESC' : 'ASC';
await em.getConnection().execute(
`SELECT * FROM tasks WHERE project_id = ? ORDER BY ${col} ${dir}`,
[projectId], // los VALORES sí van parametrizados
);
19.14.1 Row Level Security para multi-tenancy
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY projects_por_equipo ON projects
USING (team_id = current_setting('app.team_id', true)::uuid);
-- En cada petición, la aplicación fija la variable de sesión
-- (con RequestContext de MikroORM, capítulo 14):
SET LOCAL app.team_id = '3f2a...';
-- A partir de aquí, cualquier consulta a projects solo ve las filas de ese equipo,
-- aunque el desarrollador olvide el WHERE.
19.15 Cargas masivas y migración de datos
-- COPY es un orden de magnitud más rápido que INSERT fila a fila
COPY tasks (project_id, title, status, created_at)
FROM '/tmp/tareas.csv' WITH (FORMAT csv, HEADER true);
-- Estrategia para una carga inicial grande:
-- 1. Crear la tabla SIN índices secundarios ni claves foráneas
-- 2. COPY de los datos
-- 3. Crear los índices (una sola pasada ordenada, mucho más rápido que
-- mantenerlos actualizados fila a fila)
-- 4. Añadir las claves foráneas
-- 5. ANALYZE para actualizar las estadísticas
-- Actualizaciones masivas: por lotes, no de golpe.
-- Un UPDATE de 10 millones de filas mantiene bloqueos y genera un WAL enorme.
UPDATE tasks SET status = 'cancelled'
WHERE id IN (SELECT id FROM tasks WHERE status = 'open'
AND created_at < '2020-01-01' LIMIT 10000);
-- Repetir hasta que no afecte a ninguna fila.
19.16 Correspondencia con MikroORM
| Concepto del ORM | Qué ocurre en la base de datos | Riesgo a vigilar |
|---|---|---|
em.find(Task, {}) | SELECT con WHERE | Sin limit, trae la tabla entera |
populate: ['tags'] | Segundo SELECT ... WHERE id IN (...) o un JOIN | Sin populate: N+1 |
flush() | BEGIN + inserts/updates agrupados + COMMIT | Orden de operaciones y bloqueos |
em.transactional() | BEGIN … COMMIT/ROLLBACK | Transacciones largas |
@Property({ version: true }) | UPDATE … WHERE version = ? | Hay que gestionar el 409 |
@Unique() | CREATE UNIQUE INDEX | Colisión con el soft delete |
findAndCount | Dos consultas: datos y COUNT(*) | El COUNT es caro en tablas enormes |
em.nativeUpdate | UPDATE directo | No pasa por el Unit of Work ni por los hooks |
@Filter (soft delete) | AND deleted_at IS NULL añadido | No se aplica al SQL nativo |
debug: true en la configuración de MikroORM) y
mira lo que ejecuta tu código. Descubrirás N+1, consultas sin LIMIT y joins
inesperados en la primera tarde. Es el hábito que separa a quien usa un ORM de quien lo domina.
19.17 Modelado de casos difíciles
Los esquemas de los tutoriales siempre modelan un blog con usuarios y artículos. Estos cuatro casos son los que aparecen de verdad en un producto y los que separan un modelo que aguanta de uno que hay que rehacer al año.
19.17.1 Jerarquías y árboles
| Técnica | Cómo funciona | Buena para | Coste |
|---|---|---|---|
| Lista de adyacencia | Columna parent_id | Escrituras y movimientos; es lo más simple | Leer el árbol completo exige recursión |
| Enumeración de caminos | Columna con la ruta: /1/7/23/ | Leer todos los descendientes con un LIKE 'ruta%' | Mover una rama obliga a reescribir todas sus rutas |
| Conjuntos anidados | Columnas izquierda y derecha | Lecturas muy rápidas de subárboles | Cualquier inserción reordena media tabla |
| Tabla de cierre | Tabla con todos los pares ancestro-descendiente | Consultas en cualquier dirección y a cualquier profundidad | Espacio y mantenimiento de la tabla auxiliar |
El consejo práctico: empieza siempre por la lista de adyacencia, que es lo que hace el hilo de comentarios de TaskFlow, y resuelve las lecturas con una CTE recursiva. Solo si mides que esas consultas son un problema real merece la pena pasar a una tabla de cierre, que es la alternativa más equilibrada de las tres restantes.
19.17.2 Historial y datos temporales
-- Pregunta previa que decide el diseño: ¿necesitas saber cómo estaba
-- un registro en una fecha pasada, o solo quién cambió qué y cuándo?
-- Opción A: bitácora de cambios (responde "quién y cuándo")
CREATE TABLE tasks_audit (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
task_id bigint NOT NULL,
operacion char(1) NOT NULL, -- I, U, D
cambios jsonb NOT NULL, -- solo lo que cambió
actor_id uuid,
ocurrido timestamptz NOT NULL DEFAULT now()
);
CREATE OR REPLACE FUNCTION auditar_tasks() RETURNS trigger AS $$
BEGIN
INSERT INTO tasks_audit (task_id, operacion, cambios, actor_id)
VALUES (
COALESCE(NEW.id, OLD.id),
left(TG_OP, 1),
CASE WHEN TG_OP = 'UPDATE'
-- solo los campos que realmente cambiaron
THEN to_jsonb(NEW) - (SELECT array_agg(key)
FROM jsonb_each(to_jsonb(NEW)) n
JOIN jsonb_each(to_jsonb(OLD)) o USING (key)
WHERE n.value = o.value)::text[]
ELSE to_jsonb(COALESCE(NEW, OLD)) END,
current_setting('app.user_id', true)::uuid
);
RETURN COALESCE(NEW, OLD);
END; $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_auditar_tasks
AFTER INSERT OR UPDATE OR DELETE ON tasks
FOR EACH ROW EXECUTE FUNCTION auditar_tasks();
-- Opción B: versionado temporal (responde "cómo estaba el 3 de marzo").
-- Cada fila tiene un intervalo de validez y solo una está vigente.
CREATE TABLE precios (
producto_id bigint NOT NULL,
importe numeric(10,2) NOT NULL,
validez tstzrange NOT NULL,
EXCLUDE USING gist (producto_id WITH =, validez WITH &&)
-- ↑ la restricción de exclusión impide que dos periodos se solapen
);
SELECT importe FROM precios
WHERE producto_id = 1 AND validez @> '2026-03-03'::timestamptz;
19.17.3 Multi-tenencia
Ya vimos Row Level Security como mecanismo de aislamiento. Lo que hay que decidir antes es el modelo, y la decisión es difícil de revertir:
- Base de datos compartida con columna de inquilino. Lo más barato y lo que casi todos eligen. El riesgo es que una consulta sin filtro filtra datos entre clientes, así que la columna debe estar en todas las tablas, formar parte de los índices y estar respaldada por Row Level Security, no solo por el ORM.
- Un esquema por inquilino. Mejor aislamiento y copias de seguridad por cliente, a cambio de que cada migración haya que aplicarla a N esquemas y de que el número de tablas crezca sin límite.
- Una base de datos por inquilino. Aislamiento máximo, personalización por cliente y ninguna posibilidad de fuga, pero un coste operativo que solo compensa con pocos clientes grandes.
19.17.4 Campos definidos por el usuario
(entidad_id, atributo, valor). Funciona, pero pierdes tipos, restricciones e integridad
referencial, y cualquier consulta que combine tres atributos se convierte en tres autojoins ilegibles y
lentos. Hoy la alternativa razonable en PostgreSQL es una columna jsonb con un índice GIN, más
una tabla que describa los campos permitidos por inquilino para poder validar en la aplicación. Mantiene la
flexibilidad sin destrozar el modelo relacional del resto del sistema.
19.18 Herramientas del día a día
-- 1. Las consultas que más tiempo total consumen (requiere pg_stat_statements).
-- Ordenar por tiempo TOTAL y no por tiempo medio: una consulta de 5 ms
-- ejecutada un millón de veces duele más que una de 2 s ejecutada una vez.
SELECT substr(query, 1, 80) AS consulta,
calls,
round(total_exec_time::numeric, 1) AS ms_total,
round(mean_exec_time::numeric, 2) AS ms_medio,
rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
-- 2. Índices que nunca se usan: coste puro en escrituras y espacio.
SELECT relname AS tabla, indexrelname AS indice,
pg_size_pretty(pg_relation_size(indexrelid)) AS tamano
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexrelid NOT IN (SELECT conindid FROM pg_constraint)
ORDER BY pg_relation_size(indexrelid) DESC;
-- 3. Tablas que necesitan índice: muchas lecturas secuenciales.
SELECT relname, seq_scan, seq_tup_read, idx_scan,
seq_tup_read / GREATEST(seq_scan, 1) AS filas_por_escaneo
FROM pg_stat_user_tables
WHERE seq_scan > 1000
ORDER BY seq_tup_read DESC;
-- 4. Transacciones abiertas demasiado tiempo: el enemigo silencioso
-- que impide limpiar versiones muertas en toda la base de datos.
SELECT pid, state, now() - xact_start AS duracion,
substr(query, 1, 60) AS consulta
FROM pg_stat_activity
WHERE state <> 'idle' AND xact_start < now() - interval '1 minute'
ORDER BY xact_start;
-- 5. Bloqueos: quién espera a quién.
SELECT bloqueada.pid AS espera, bloqueante.pid AS bloquea,
substr(bloqueada.query, 1, 50) AS consulta_en_espera
FROM pg_stat_activity bloqueada
JOIN pg_stat_activity bloqueante
ON bloqueante.pid = ANY(pg_blocking_pids(bloqueada.pid))
WHERE cardinality(pg_blocking_pids(bloqueada.pid)) > 0;
-- 6. Tamaño real de cada tabla, con sus índices.
SELECT relname,
pg_size_pretty(pg_total_relation_size(relid)) AS total,
pg_size_pretty(pg_relation_size(relid)) AS solo_datos,
n_live_tup AS filas_vivas, n_dead_tup AS filas_muertas
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 15;
pg_stat_statements el primer día del proyecto y míralo una vez al mes. En diez minutos
sabrás exactamente en qué se va el tiempo de tu base de datos, y casi siempre serán dos o tres consultas
que ni sospechabas, normalmente generadas por el ORM en un listado que alguien añadió sin pensar. Optimizar
a ciegas es perder el tiempo; esta tabla te dice dónde mirar.
19.19 Errores comunes y cómo solucionarlos
| Error | Causa | Solución |
|---|---|---|
| Listado lento que empeora con el tiempo | Falta índice; escaneo secuencial | EXPLAIN ANALYZE e índice compuesto acorde al WHERE y al ORDER BY |
LEFT JOIN que se comporta como INNER | Filtro de la tabla derecha en el WHERE | Mover la condición al ON |
| Resultados duplicados | JOIN con una relación 1:N | EXISTS si solo filtras; agregación si necesitas datos |
NOT IN que no devuelve nada | La subconsulta contiene NULL | NOT EXISTS, o excluir los nulos |
| Paginación que repite o salta filas | ORDER BY no determinista | Añadir un desempate único (, id) |
OFFSET 100000 tarda segundos | El motor genera y descarta 100.000 filas | Paginación por cursor (capítulo 16) |
| Descuadres de céntimos | Dinero en coma flotante | numeric o enteros de céntimos |
| Fechas desplazadas una hora | timestamp sin zona | timestamptz y almacenar en UTC |
could not obtain lock / interbloqueos | Orden de bloqueo incoherente | Ordenar los bloqueos y acortar las transacciones |
| La base de datos crece sin borrar nada | Bloat por versiones muertas y transacciones abiertas | Revisar pg_stat_activity, ajustar autovacuum |
too many connections | Pool × réplicas por encima del límite | Reducir el pool, PgBouncer |
| El índice existe pero no se usa | Función sobre la columna, tipos distintos o baja selectividad | Índice de expresión, tipos coherentes, o aceptar el escaneo |
19.20 Buenas y malas prácticas
Haz esto
- Restricciones en el esquema, no solo en la aplicación:
NOT NULL,CHECK,UNIQUEy claves foráneas. - Índice en toda columna de clave foránea. PostgreSQL no lo crea por ti.
timestamptzy UTC en todo lo que sea un instante.- Nombra las restricciones explícitamente: así el error dice algo útil y puedes mapearlo a un mensaje de negocio.
- Lee el plan de las cinco consultas más frecuentes de tu aplicación al menos una vez.
- Pagina siempre, con un orden estable y determinista.
- Transacciones cortas y sin llamadas de red dentro.
- Migraciones compatibles hacia atrás (expand/contract, capítulo 17).
Evita esto
SELECT *en producción: trae columnas que no usas e impide los index-only scans.- Concatenar entradas del usuario en SQL, ni siquiera en un
ORDER BY. - Coma flotante para dinero.
- Índices "por si acaso": cada uno ralentiza las escrituras y ocupa espacio.
- Lógica de negocio en triggers como norma: es invisible desde el código y difícil de depurar.
jsonbpara huir del modelado.- Borrar físicamente sin pensarlo cuando hay obligaciones de auditoría.
- Confiar en una copia de seguridad que nunca has restaurado.
19.21 Preguntas frecuentes
¿WHERE o HAVING?
WHERE filtra filas antes de agrupar; HAVING filtra grupos
después. Si la condición no involucra un agregado, va en el WHERE: filtrar antes es más barato
porque hay menos filas que agrupar. HAVING count(*) > 5 solo puede ir en el
HAVING.¿EXISTS, IN o JOIN?
EXISTS: no duplica
filas y el motor puede parar en la primera coincidencia. Usa JOIN cuando necesites
columnas de la otra tabla. IN con una lista literal corta está bien; NOT IN
con una subconsulta es peligroso porque devuelve conjunto vacío si aparece un NULL: usa
NOT EXISTS.¿Clave natural o subrogada?
bigint o un uuid sin significado) casi siempre: las
claves naturales cambian (el email de un usuario, el NIF de una empresa) y arrastran el cambio a todas las
tablas que las referencian. Eso no impide poner además una restricción UNIQUE sobre la clave
natural para garantizar la integridad. La excepción razonable son las tablas puente, donde la clave
compuesta de las dos claves foráneas es natural y estable.¿uuid o entero autoincremental?
¿Por qué mi consulta va rápida en desarrollo y lenta en producción?
¿Cuántos índices son demasiados?
INSERT,
UPDATE y DELETE que toque sus columnas, y ocupa espacio en disco y en caché. Una
tabla con más de seis u ocho índices merece revisión. Consulta pg_stat_user_indexes para
encontrar los que nunca se usan (idx_scan = 0) y elimínalos: son coste puro.¿Debo usar vistas?
REFRESH (preferiblemente
REFRESH MATERIALIZED VIEW CONCURRENTLY).¿Los triggers son buena idea?
psql. Para lógica de negocio, no: es código invisible desde la aplicación, difícil de testear,
de versionar y de depurar; alguien pasará una tarde entera buscando por qué un campo cambia solo.¿Qué es exactamente el bloat y por qué debería importarme?
VACUUM
recupera ese espacio para reutilizarlo. Si el autovacuum no da abasto (tablas con muchísimas
actualizaciones) o si una transacción lleva horas abierta impidiendo la limpieza, la tabla y sus índices
crecen con espacio inútil: se leen más páginas para los mismos datos y todo se ralentiza. Vigila las
transacciones largas antes que cualquier otra cosa.¿Merece la pena SERIALIZABLE?
READ COMMITTED más una actualización atómica o un bloqueo
optimista resuelve el problema con menos coste.¿Cómo cuento filas de forma eficiente en una tabla enorme?
COUNT(*) exacto obliga a recorrer todas las filas visibles: en tablas de
decenas de millones puede tardar segundos. Alternativas: una estimación con
reltuples de pg_class (suficiente para «unos 2,3 millones de resultados»), un
contador desnormalizado mantenido por trigger, o directamente no mostrar el total y usar paginación por
cursor con un «cargar más».¿Necesito saber esto si el ORM me lo hace todo?
19.22 Ejercicios
SQL-01 Lista el título y el nombre del responsable de todas las tareas abiertas del proyecto
$1, incluidas las que no tienen responsable, ordenadas por fecha de vencimiento con los nulos
al final. (Pista: NULLS LAST.)
SQL-02 Cuenta cuántas tareas hay en cada estado para un equipo, mostrando también los estados sin ninguna tarea.
SQL-03 Encuentra los usuarios que no han comentado nunca. Resuélvelo de dos formas distintas
(NOT EXISTS y anti-join) y compara los planes con EXPLAIN.
SQL-04 Muestra los cinco proyectos con más tareas vencidas (fecha de vencimiento pasada y estado
distinto de done).
SQL-05 Para cada proyecto, calcula el porcentaje de tareas completadas con un decimal, evitando la división por cero.
SQL-06 Obtén la última tarea creada de cada proyecto usando DISTINCT ON y también con
row_number(). ¿Cuál es más rápida sobre tu esquema y por qué?
SQL-07 Calcula, por día de los últimos 30, cuántas tareas se cerraron, el acumulado del mes y la
media móvil de 7 días. Incluye los días sin cierres (pista: generate_series).
SQL-08 Escribe una CTE recursiva que devuelva el hilo completo de comentarios de una tarea con su nivel de anidamiento y ordenado de forma que los hijos aparezcan bajo su padre.
SQL-09 Dada la consulta del listado principal de TaskFlow (filtro por proyecto y estado, orden por
fecha de vencimiento, límite 50), diseña el índice óptimo. Justifica el orden de las columnas y demuestra
con EXPLAIN ANALYZE que desaparece el nodo Sort.
SQL-10 Añade un contador task_count a projects y mantenlo con un
trigger. Escribe también la consulta de reconciliación que detectaría un contador desviado.
SQL-11 Implementa el email único compatible con el borrado lógico: dos usuarios borrados pueden compartir email, pero solo puede haber uno activo por dirección.
SQL-12 Simula una actualización perdida abriendo dos sesiones psql. Después
resuélvela de tres formas (actualización atómica, FOR UPDATE y columna de versión) y explica
cuál elegirías para un contador de visitas y cuál para un saldo bancario.
Soluciones comentadas (SQL-01, SQL-05, SQL-07, SQL-09 y SQL-11)
-- SQL-01
SELECT t.title,
COALESCE(u.full_name, '(sin asignar)') AS responsable,
t.due_date
FROM tasks t
LEFT JOIN users u ON u.id = t.assignee_id
WHERE t.project_id = $1
AND t.status IN ('open', 'in_progress')
ORDER BY t.due_date ASC NULLS LAST, t.id; -- el id desempata: orden determinista
-- SQL-05: NULLIF evita la división por cero devolviendo NULL,
-- y COALESCE lo convierte en 0 para presentarlo.
SELECT p.name,
count(t.id) AS total,
count(*) FILTER (WHERE t.status = 'done') AS hechas,
COALESCE(round(
100.0 * count(*) FILTER (WHERE t.status = 'done')
/ NULLIF(count(t.id), 0), 1), 0) AS porcentaje
FROM projects p
LEFT JOIN tasks t ON t.project_id = p.id
GROUP BY p.id, p.name
ORDER BY porcentaje DESC;
-- SQL-07: generate_series produce el calendario completo;
-- el LEFT JOIN rellena con ceros los días sin actividad.
WITH dias AS (
SELECT generate_series(current_date - 29, current_date, interval '1 day')::date AS dia
),
cierres AS (
SELECT completed_at::date AS dia, count(*) AS n
FROM tasks
WHERE completed_at >= current_date - 29
GROUP BY 1
)
SELECT d.dia,
COALESCE(c.n, 0) AS cerradas,
sum(COALESCE(c.n, 0)) OVER (ORDER BY d.dia) AS acumulado,
round(avg(COALESCE(c.n, 0)) OVER (ORDER BY d.dia
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 2) AS media_7d
FROM dias d
LEFT JOIN cierres c ON c.dia = d.dia
ORDER BY d.dia;
-- SQL-09: igualdad primero (project_id, status), y la columna de
-- ordenación al final para que el índice entregue las filas ya ordenadas.
CREATE INDEX idx_tasks_listado ON tasks (project_id, status, due_date);
-- Con LIMIT 50 el motor recorre solo las 50 primeras entradas del índice:
-- desaparece el nodo Sort y el tiempo pasa de cientos de ms a fracciones.
-- Si el listado excluye siempre las tareas cerradas, un índice parcial es aún mejor:
CREATE INDEX idx_tasks_listado_activas ON tasks (project_id, due_date)
WHERE status IN ('open', 'in_progress');
-- SQL-11: índice único PARCIAL. La restricción solo se aplica a las filas vivas.
DROP INDEX IF EXISTS users_email_key;
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key;
CREATE UNIQUE INDEX uq_users_email_activos
ON users (email) WHERE deleted_at IS NULL;
-- Alternativa si tu motor no admite índices parciales: una columna generada
-- que valga el id cuando está borrado y un valor fijo cuando está activo,
-- incluida en la clave única.
19.23 Resumen del capítulo
- El esquema es el contrato más duradero. Las restricciones (
NOT NULL,CHECK,UNIQUE, claves foráneas) son validaciones que nunca se olvidan. - Los tipos importan:
numericpara dinero,timestamptzpara instantes,bigintpara identificadores, y UUID v7 si necesitas identificadores opacos. - Normaliza hasta 3FN y desnormaliza solo después de medir, siempre con un mecanismo que mantenga la copia sincronizada.
- El orden lógico de ejecución explica casi todas las dudas de sintaxis:
WHEREfiltra filas,HAVINGfiltra grupos. - Un índice compuesto se usa por su prefijo izquierdo: igualdad primero, rango después, ordenación al final. Y las claves foráneas no se indexan solas.
EXPLAIN (ANALYZE, BUFFERS)convierte «esto va lento» en un diagnóstico concreto. Busca escaneos secuenciales evitables y ordenaciones que un índice podría servir gratis.- MVCC hace que leer no bloquee, a cambio de generar versiones muertas: las transacciones largas son el enemigo silencioso.
- La actualización perdida es real y no lanza ningún error: resuélvela con actualización atómica, bloqueo pesimista o bloqueo optimista.
- El ORM escribe el SQL, pero tú decides el modelo, los índices y las transacciones.
19.24 Recursos adicionales
- Documentación oficial de PostgreSQL — extensa y sorprendentemente legible; los capítulos de índices y de concurrencia son de lectura obligada.
- Use The Index, Luke! — el mejor material que existe sobre índices, disponible en español.
- explain.depesz.com — pega un plan de ejecución y te lo presenta resaltando dónde se va el tiempo.
- PostgreSQL Wiki · Don't Do This — catálogo oficioso de decisiones que lamentarás.
- PGTune — punto de partida razonable para configurar la memoria del servidor.