Tres lecciones enteras defendiendo que cada hecho debe estar almacenado exactamente una vez, y ahora una lección explicando cuándo conviene romper esa regla. Puede parecer una contradicción, y no lo es: es la diferencia entre saber una regla y saber usarla.
Al final de la lección anterior nos encontramos cuatro veces con la misma situación. duracion_min en eventos, calculada a partir de inicio y fin. plazas_ocupadas en inscripciones, calculada a partir de acompanantes. El importe de una multa, que no puede recalcularse si la ordenanza cambia. La tarifa_hora de una cesión de sala, que debe quedar congelada con el valor del día de la factura. Las cuatro son redundancias. Las cuatro violan la letra de la tercera forma normal. Y las cuatro son correctas.
Desnormalizar es introducir redundancia en un esquema a propósito, para conseguir algo concreto que el esquema normalizado no daba. Las tres palabras que importan son "a propósito", "concreto" y "no daba". Sin las tres, no es desnormalización: es no haber hecho el trabajo.
Esta lección cierra el módulo con la decisión inversa, tomada con el mismo rigor con que hemos tomado las anteriores. Qué se gana y qué se paga exactamente. Cuándo está justificado y cuándo no. Las técnicas una a una, con SQL sobre BiblioRed. Cómo se mantiene la coherencia de lo que se ha duplicado adrede. Y una guía de decisión con las preguntas que hay que responder antes de tocar nada —y las señales de que hay que dar marcha atrás.
Contenido
- Qué es desnormalizar y la regla de oro
- Lo primero que hay que probar: los índices, no la desnormalización
- La balanza: qué se gana y qué se paga
- Cuándo está justificado: los tres casos
- Técnica 1: la columna redundante o calculada
- Técnica 2: columnas generadas frente a columnas mantenidas a mano
- Técnica 3: duplicar un atributo para evitar un JOIN
- Técnica 4: tablas de resumen preagregadas
- Técnica 5: vistas materializadas
- Técnica 6: tablas de historial planas
- Técnica 7: el esquema en estrella de los almacenes de datos
- Cómo se mantiene la coherencia de lo desnormalizado
- El puente con NoSQL: el modelado documental es desnormalización elevada a método
- Guía de decisión: las preguntas antes y las señales para deshacerlo
- Qué es desnormalizar y la regla de oro
Definición. Desnormalizar es modificar deliberadamente un esquema normalizado para introducir redundancia —datos duplicados o derivados— con el objetivo de mejorar el rendimiento de lectura o de preservar un valor histórico, aceptando a cambio el coste de mantener esa redundancia coherente.
Fíjate en tres cosas de esa definición.
Se parte de un esquema normalizado. No se puede desnormalizar lo que nunca estuvo normalizado. Un esquema que nació con redundancia porque nadie analizó las dependencias no está desnormalizado: está mal diseñado. La diferencia no es semántica: en un esquema desnormalizado sabes exactamente qué dato está duplicado, por qué, y quién es responsable de mantenerlo; en un esquema mal diseñado, no.
El objetivo es concreto. "Por si acaso" y "para que vaya más rápido" no son objetivos. "El informe mensual de dirección tarda cuarenta segundos y tiene que tardar menos de dos" sí lo es. "El importe de la multa no debe cambiar si la ordenanza sube" también.
Se acepta un coste, y hay que nombrarlo. Toda desnormalización tiene una factura, y la factura la paga alguien: el que escribe, el que mantiene el disparador, o el usuario que un día ve dos cifras distintas para lo mismo.
La regla de oro
Primero normaliza. Después desnormaliza a propósito, midiendo. Nunca al revés.
Es la regla que ordena toda la lección y merece la pena desglosarla:
"Primero normaliza" significa que el punto de partida es siempre el esquema en 3FN o FNBC. Es el que expresa correctamente el dominio, el que no admite contradicciones, y el que cualquier profesional entenderá. Es también, y esto se olvida, la única referencia contra la que se puede medir si la desnormalización ha servido de algo.
"A propósito" significa documentado. Cada columna redundante debe tener escrito al lado: qué duplica, por qué, quién la mantiene y qué pasa si se desincroniza. Sin eso, dentro de dos años alguien la verá, pensará que es un error, y la "arreglará" o —peor— la dejará pudrirse.
"Midiendo" significa con números antes y después. Si no puedes decir "esta consulta tardaba 4,2 segundos y ahora tarda 80 milisegundos", no sabes si la desnormalización ha servido. Y muy a menudo no sirve: el problema estaba en otra parte.
"Nunca al revés" es lo importante. Empezar con un esquema redundante "porque así será más rápido" y normalizarlo cuando dé problemas es el orden equivocado, porque la desnormalización prematura toma decisiones sobre un patrón de uso que todavía no conoces, y deshacerla después es infinitamente más caro que hacerla ahora: hay datos, hay código y hay informes que dependen de ella.
- Lo primero que hay que probar: los índices, no la desnormalización
Antes de seguir, una advertencia que evita la mayor parte de las desnormalizaciones innecesarias que se ven en producción.
Cuando una consulta va lenta, la desnormalización no es lo primero que hay que probar. Es lo último.
El orden correcto de intervenciones, de menos invasiva a más, es este:
| Orden | Intervención | Reversible | Riesgo para los datos |
|---|---|---|---|
| 1 | Crear un índice adecuado | Sí, DROP INDEX |
Ninguno |
| 2 | Reescribir la consulta (evitar subconsultas correlacionadas, SELECT *, funciones sobre columnas indexadas) |
Sí | Ninguno |
| 3 | Actualizar las estadísticas del planificador (ANALYZE) |
Sí | Ninguno |
| 4 | Ajustar la configuración del servidor (memoria de trabajo, caché) | Sí | Ninguno |
| 5 | Desnormalizar | Difícilmente | Sí: inconsistencia |
Los cuatro primeros no tocan los datos, no introducen la posibilidad de que la base de datos se contradiga, y se deshacen en un minuto. El quinto es permanente en la práctica.
Y la experiencia es contundente: la inmensa mayoría de las consultas lentas en un esquema normalizado se arreglan con un índice. Un JOIN de cinco tablas con las claves ajenas indexadas sobre unos cientos de miles de filas es una operación de milisegundos en PostgreSQL. Si tarda segundos, casi siempre falta un índice, la consulta pide columnas que no necesita, o el planificador está trabajando con estadísticas viejas.
Los índices, el plan de ejecución, EXPLAIN ANALYZE y cómo se lee, y la optimización de consultas en general son el contenido de la lección 06-03. Es literalmente la lección siguiente a este módulo, y el orden no es casual: primero se aprende a hacer que el esquema normalizado vaya rápido, y solo entonces se plantea cambiarlo.
Regla operativa: no desnormalices ninguna consulta sin haber ejecutado antes su
EXPLAIN ANALYZEy haber comprobado que no hay ningún índice que la arregle. Si no sabes leer un plan de ejecución todavía, no estás en condiciones de decidir una desnormalización.
- La balanza: qué se gana y qué se paga
Toda desnormalización es un intercambio. Estos son los dos platos de la balanza, y conviene tenerlos escritos para poder comparar en cada caso concreto.
Lo que se gana
| Beneficio | En qué consiste | Cuánto puede valer |
|---|---|---|
| Menos JOIN | Los datos que se leen juntos están en la misma tabla | Notable con muchos JOIN o tablas grandes; irrelevante con dos tablas pequeñas bien indexadas |
| Lecturas más rápidas | Menos páginas de disco que leer, menos trabajo del planificador | De un 10 % a varios órdenes de magnitud, según el caso |
| Agregados precalculados | Un informe que sumaba diez millones de filas lee una tabla de mil | Aquí es donde la desnormalización gana de verdad: de minutos a milisegundos |
| Consultas más simples | Menos código SQL que escribir y mantener en la aplicación | Real, aunque casi nunca es motivo suficiente por sí solo |
| Estabilidad histórica | Un valor queda congelado y no cambia aunque cambie su origen | No es rendimiento: es corrección. Es el caso más fuerte de todos |
Lo que se paga
| Coste | En qué consiste | Gravedad |
|---|---|---|
| Redundancia | El mismo hecho en dos sitios | Es la puerta de entrada a todo lo demás |
| Riesgo de inconsistencia | Los dos sitios pueden discrepar, y con el tiempo discrepan | Alta. Es exactamente lo que la normalización existía para impedir |
| Escrituras más caras | Cada INSERT/UPDATE/DELETE toca más filas y más tablas |
Proporcional al desequilibrio lectura/escritura |
| Escrituras más complejas | La lógica de mantenimiento hay que escribirla, probarla y mantenerla | Media-alta. Es código nuevo que puede fallar |
| Más espacio | Datos duplicados ocupan más | Baja. Casi nunca decide nada hoy |
| Riesgo de contención | Un contador en una fila única se convierte en un cuello de botella de concurrencia | Alta y poco anticipada. Ver la nota de abajo |
| Esquema más difícil de entender | La siguiente persona no sabrá si esa columna es fuente o copia | Media. Se mitiga documentando |
La nota sobre la contención merece detenerse, porque es el coste que menos se anticipa. Si añades total_prestamos a la tabla socios y lo actualizas en cada préstamo, cada operación de mostrador tiene que bloquear la fila del socio. Con un socio prestando de uno en uno no pasa nada. Pero si mañana añades total_prestamos a sucursales, todas las operaciones de la sucursal Norte compiten por la misma fila, y en hora punta el mostrador se serializa. Los mecanismos de bloqueo y los niveles de aislamiento son la lección 06-02; por ahora quédate con que un contador global es una desnormalización con un coste de concurrencia que puede ser mucho peor que el JOIN que evitaba.
- Cuándo está justificado: los tres casos
De todos los motivos que se alegan para desnormalizar, solo tres resisten el examen.
Caso 1: relación lectura/escritura muy desequilibrada
La desnormalización cambia coste de escritura por velocidad de lectura. Solo compensa si se lee muchísimo más de lo que se escribe.
En BiblioRed, la ficha pública de un material —título, autor, disponibilidad por sucursal— se consulta desde el catálogo web unas 40.000 veces al día. Los datos que muestra cambian, como mucho, cuando entra un ejemplar nuevo: dos o tres veces por semana. La relación es de decenas de miles de lecturas por escritura, y ahí una copia bien mantenida se paga sola.
En el lado contrario, la tabla prestamos se escribe constantemente durante el horario del mostrador y se lee sobre todo por socio. Desnormalizarla para acelerar un informe que se ejecuta una vez al mes es un mal negocio.
El criterio numérico: si la relación lecturas/escrituras no llega a 10:1, casi nunca compensa. Por encima de 100:1, empieza a ser interesante. Y ese ratio hay que medirlo, no estimarlo.
Caso 2: agregados costosos sobre muchas filas
Este es el caso donde la desnormalización gana por goleada y ninguna otra técnica se le acerca.
Dirección quiere un cuadro de mando con los préstamos por sucursal y mes de los últimos ocho años. Sobre el esquema normalizado, eso es un GROUP BY sobre 84.000 filas de prestamos con tres JOIN. Hoy tarda unos segundos. Cuando BiblioRed lleve veinte años y tenga millones de préstamos, tardará minutos, y el cuadro de mando se abrirá una vez cada mañana... para cada uno de los quince responsables.
La clave está en que los datos de meses cerrados no cambian nunca. Recalcular en cada consulta el total de marzo de 2019 es tirar trabajo. Precalcularlo una vez y guardarlo es la decisión evidente. Es la técnica 4 de la sección 8.
Caso 3: datos históricos que deben quedar congelados
Y este es el caso más importante de los tres, porque no es una cuestión de rendimiento sino de corrección. Aquí la desnormalización no es un compromiso: es la única respuesta correcta.
Considera la multa 900 de BiblioRed: emitida el 12 de marzo de 2026 al socio 14 por un retraso de 35 días, importe 3,50 €, tarifa vigente 0,10 €/día. En abril, el ayuntamiento sube la tarifa a 0,15 €/día.
¿Cuánto debe Marta Alsina por aquella multa? 3,50 €. Se le comunicó por escrito, consta en el recibo, y no puede cambiar. Si multas no guardara el importe y lo calculara con un JOIN a la tabla de tarifas, en abril esa multa pasaría a valer 5,25 € retroactivamente. Eso no es un problema de diseño: es un error de facturación.
El mismo razonamiento vale para:
- El nombre del socio en el momento del pago. Si un recibo dice "Recibido de Marta Alsina" y ella cambia de apellido, el recibo emitido no cambia. El nombre en el recibo es un dato histórico, no una referencia viva a
socios. - El precio de adquisición de un ejemplar. Se pagó lo que se pagó.
- La dirección de envío de un pedido, en un comercio electrónico. El pedido se envió allí, aunque el cliente se haya mudado.
Este es exactamente el patrón que en la lección 03-03 llamamos duplicación histórica congelada al hablar del modelado documental, y clasificamos como "categoría B": un campo duplicado que es correcto por semántica, no una copia que haya que propagar. La disciplina que exige es distinta de la de una copia viva: no se actualiza nunca, y precisamente por eso no tiene riesgo de inconsistencia.
La prueba para distinguirlo: pregúntate "si el valor original cambia mañana, ¿este debe cambiar también?". Si la respuesta es no, no es una desnormalización de rendimiento: es un dato distinto que casualmente coincidió con el original en el momento de crearlo, y guardarlo es lo correcto.
- Técnica 1: la columna redundante o calculada
La más común y la más fácil de hacer mal. Consiste en guardar en una tabla un valor que se puede obtener contando o sumando filas de otra.
El caso: la ficha del socio en la aplicación de mostrador muestra cuántos préstamos tiene en total y cuántos están abiertos. Normalizado:
SELECT s.socio_id, s.nombre, s.apellidos,
COUNT(*) AS total_prestamos,
COUNT(*) FILTER (WHERE p.fecha_devolucion IS NULL) AS prestamos_abiertos
FROM socios s
LEFT JOIN prestamos p ON p.socio_id = s.socio_id
WHERE s.socio_id = 14
GROUP BY s.socio_id, s.nombre, s.apellidos;Con un índice sobre prestamos(socio_id) esto es instantáneo para un socio. Aquí no hay nada que desnormalizar, y es importante decirlo: la tentación de añadir el contador aparece antes de haber comprobado si hace falta.
Donde sí aparece el problema es en el listado de los 12.000 socios de la sucursal Norte con su número de préstamos, que la aplicación pagina de 50 en 50. Ahí el agregado se calcula sobre toda la tabla en cada página.
La desnormalización:
ALTER TABLE socios
ADD COLUMN total_prestamos INTEGER NOT NULL DEFAULT 0,
ADD COLUMN prestamos_abiertos INTEGER NOT NULL DEFAULT 0;
-- Carga inicial desde la fuente de verdad
UPDATE socios s
SET total_prestamos = c.total,
prestamos_abiertos = c.abiertos
FROM (
SELECT socio_id,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE fecha_devolucion IS NULL) AS abiertos
FROM prestamos GROUP BY socio_id
) c
WHERE c.socio_id = s.socio_id;La consulta pasa a ser un SELECT directo sobre socios, sin JOIN ni agregación.
Lo que hay que documentar y no olvidar:
| Pregunta | Respuesta para este caso |
|---|---|
| ¿Qué duplica? | Un agregado de prestamos |
| ¿Cuál es la fuente de verdad? | prestamos, siempre. Si discrepan, prestamos tiene razón |
| ¿Quién lo mantiene? | Ver sección 12: aplicación, disparador o lote |
| ¿Qué pasa si se desincroniza? | La ficha muestra un número equivocado. Impacto bajo, pero visible |
| ¿Cómo se detecta? | Consulta de auditoría periódica |
| ¿Cómo se recalcula? | El UPDATE ... FROM de arriba |
Esa última fila es la más importante y la que más se olvida: toda columna desnormalizada necesita un procedimiento documentado para recalcularla desde cero. Es la red de seguridad, y algún día se usará.
La consulta de auditoría:
-- Detectar socios cuyo contador no cuadra con la realidad
SELECT s.socio_id, s.total_prestamos AS guardado, COUNT(p.prestamo_id) AS real_
FROM socios s
LEFT JOIN prestamos p ON p.socio_id = s.socio_id
GROUP BY s.socio_id, s.total_prestamos
HAVING s.total_prestamos <> COUNT(p.prestamo_id);Si esa consulta devuelve filas, el mecanismo de mantenimiento tiene un agujero. Programarla como comprobación nocturna cuesta cinco minutos y ahorra meses de desconcierto.
- Técnica 2: columnas generadas frente a columnas mantenidas a mano
No toda columna derivada tiene el mismo riesgo. Hay una diferencia enorme entre las que mantiene el SGBD y las que mantiene tu código, y elegir bien es la decisión más rentable de esta lección.
Columnas generadas: el SGBD garantiza la coherencia
Ya las conocemos del módulo 4 y las examinamos en 05-03:
-- En eventos
duracion_min INTEGER GENERATED ALWAYS AS
(EXTRACT(EPOCH FROM (fin - inicio)) / 60) STORED
-- En inscripciones
plazas_ocupadas SMALLINT GENERATED ALWAYS AS (1 + acompanantes) STOREDLa palabra ALWAYS es la garantía: PostgreSQL recalcula el valor en cada INSERT y cada UPDATE, y rechaza cualquier intento de escribirlo a mano:
ERROR: no se puede insertar un valor no-DEFAULT en la columna «plazas_ocupadas» DETALLE: La columna «plazas_ocupadas» es una columna generada.
Es una desnormalización sin riesgo de inconsistencia. Viola la 3FN en la letra, y no en el espíritu, porque el peligro que la 3FN previene está eliminado por otro mecanismo.
Su limitación es importante: una columna generada solo puede depender de columnas de su propia fila y usar funciones deterministas. No puede contar filas de otra tabla, ni consultar sucursales, ni usar now(). Por eso total_prestamos en socios no puede ser una columna generada: depende de otra tabla.
Columnas mantenidas a mano: el riesgo es tuyo
Cuando la columna generada no llega, el mantenimiento pasa a ser responsabilidad de alguien, y ahí empiezan los problemas de la sección 12.
La tabla de decisión
| Situación | Solución | Riesgo |
|---|---|---|
| Deriva de columnas de la misma fila, función determinista | Columna generada ALWAYS ... STORED |
Ninguno |
| Deriva de columnas de la misma fila, pero debe congelarse en el tiempo | Columna normal + valor calculado al insertar | Bajo: no se toca nunca más |
| Deriva de otra tabla, tolera segundos de retraso | Columna normal + disparador | Medio |
| Deriva de otra tabla, tolera horas de retraso | Columna normal + proceso por lotes | Medio, pero controlado |
| Agregado sobre millones de filas | Tabla de resumen o vista materializada | Ver secciones 8 y 9 |
Y el detalle que ya señalamos en 05-03 y que conviene fijar: un importe histórico no puede ser una columna generada, aunque lo parezca. importe = tarifa × dias es una fórmula, sí, pero si tarifa cambia, una columna generada recalcularía el importe de las multas antiguas. Tiene que ser una columna normal, calculada una vez al emitir la multa y nunca más. La diferencia entre duracion_min —derivada de inicio y fin, que son de la propia fila y no cambian— y un importe derivado de un dato externo mutable es exactamente esta.
- Técnica 3: duplicar un atributo para evitar un JOIN
La técnica más simple: copiar una columna de la tabla A a la tabla B para no tener que unirlas.
El caso: la lista de préstamos activos en el mostrador muestra el título del material. Normalizado hacen falta tres JOIN, porque libros es una vista sobre materiales + materiales_libro desde la jerarquía de 04-03:
SELECT p.prestamo_id, p.fecha_prestamo, m.titulo
FROM prestamos p
JOIN ejemplares e ON e.ejemplar_id = p.ejemplar_id
JOIN materiales m ON m.material_id = e.material_id
WHERE p.fecha_devolucion IS NULL AND e.sucursal_id = 2;La desnormalización:
ALTER TABLE prestamos ADD COLUMN titulo_material VARCHAR(200);
UPDATE prestamos p
SET titulo_material = m.titulo
FROM ejemplares e
JOIN materiales m ON m.material_id = e.material_id
WHERE e.ejemplar_id = p.ejemplar_id;La consulta se queda en un SELECT sobre prestamos con un solo JOIN a ejemplares para el filtro de sucursal.
Y ahora la parte honesta: en este caso concreto, casi seguro que no vale la pena. Con índices en las claves ajenas, tres JOIN sobre unas decenas de miles de filas son milisegundos. Se ha introducido una copia viva —si alguien corrige un título mal catalogado, hay que propagarlo a todos los préstamos— a cambio de un beneficio que probablemente no se nota. Es el ejemplo perfecto de desnormalización que parece razonable y no lo es.
¿Cuándo sí valdría la pena? Cuando el atributo duplicado cumple al menos una de estas dos condiciones:
- Es inmutable. El ISBN de una edición no cambia nunca. Copiarlo es gratis: no hay nada que propagar. Es la "categoría A" de 03-03.
- Debe congelarse. El título del material en el momento del préstamo, para un recibo o un histórico. Es la categoría B: se copia una vez y nunca se toca.
Si el atributo es vivo —puede cambiar y la copia debe seguirlo— la duplicación exige propagación, y entonces hay que preguntarse si el JOIN que se evita compensa el mecanismo que se añade. La mayor parte de las veces, no.
- Técnica 4: tablas de resumen preagregadas
Aquí es donde la desnormalización deja de ser un compromiso discutible y pasa a ser la solución obvia.
El caso: el cuadro de mando de dirección con los préstamos por sucursal y mes desde 2018.
CREATE TABLE resumen_prestamos_mes (
anio SMALLINT NOT NULL,
mes SMALLINT NOT NULL,
sucursal_id INTEGER NOT NULL,
total_prestamos INTEGER NOT NULL,
socios_distintos INTEGER NOT NULL,
dias_prestamo_medios NUMERIC(5,2),
prestamos_con_retraso INTEGER NOT NULL,
cerrado BOOLEAN NOT NULL DEFAULT FALSE,
actualizado_en TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT pk_resumen_prestamos_mes PRIMARY KEY (anio, mes, sucursal_id),
CONSTRAINT chk_resumen_mes CHECK (mes BETWEEN 1 AND 12),
CONSTRAINT fk_resumen_sucursal FOREIGN KEY (sucursal_id)
REFERENCES sucursales (sucursal_id) ON UPDATE CASCADE
);La carga, que es una única consulta agregada:
INSERT INTO resumen_prestamos_mes
(anio, mes, sucursal_id, total_prestamos, socios_distintos,
dias_prestamo_medios, prestamos_con_retraso, cerrado)
SELECT EXTRACT(YEAR FROM p.fecha_prestamo)::SMALLINT,
EXTRACT(MONTH FROM p.fecha_prestamo)::SMALLINT,
e.sucursal_id,
COUNT(*),
COUNT(DISTINCT p.socio_id),
AVG(p.fecha_devolucion - p.fecha_prestamo),
COUNT(*) FILTER (WHERE p.fecha_devolucion > p.fecha_devolucion_prevista),
TRUE
FROM prestamos p
JOIN ejemplares e ON e.ejemplar_id = p.ejemplar_id
WHERE p.fecha_prestamo < date_trunc('month', CURRENT_DATE) -- solo meses cerrados
GROUP BY 1, 2, 3
ON CONFLICT (anio, mes, sucursal_id) DO UPDATE
SET total_prestamos = EXCLUDED.total_prestamos,
socios_distintos = EXCLUDED.socios_distintos,
dias_prestamo_medios = EXCLUDED.dias_prestamo_medios,
prestamos_con_retraso = EXCLUDED.prestamos_con_retraso,
actualizado_en = now();El cuadro de mando pasa de agregar 84.000 filas (y creciendo) a leer una tabla de unos 400 registros —8 años × 12 meses × 4 sucursales—. La diferencia de rendimiento no es de un 20 %: es de tres órdenes de magnitud, y no se puede conseguir de ninguna otra forma.
Tres detalles de diseño que hacen que esta técnica funcione bien:
La columna cerrado. Distingue los meses terminados —que no cambiarán nunca— del mes en curso, que sí. Los cerrados se calculan una vez y se olvidan; solo el mes actual necesita refresco. Es lo que hace que el coste de mantenimiento sea casi cero.
La columna actualizado_en. Cualquiera que mire la tabla sabe de cuándo son los datos. Sin ella, nadie puede juzgar si una cifra es fiable.
El ON CONFLICT ... DO UPDATE. Permite ejecutar la carga las veces que haga falta sin duplicar nada. Un proceso que se puede repetir sin efectos secundarios es infinitamente más fácil de operar que uno que hay que ejecutar exactamente una vez.
Y la regla que no se negocia: la tabla de resumen es derivada, nunca fuente de verdad. Si resumen_prestamos_mes y prestamos discrepan, prestamos tiene razón y el resumen se regenera. El día que alguien empiece a corregir cifras directamente en el resumen, la desnormalización se ha convertido en un segundo sistema de datos incoherente con el primero.
- Técnica 5: vistas materializadas
Una vista materializada es una tabla de resumen que el SGBD gestiona por ti: se define con una consulta, PostgreSQL guarda el resultado en disco, y se refresca cuando se lo pides.
CREATE MATERIALIZED VIEW mv_disponibilidad_material AS
SELECT m.material_id,
m.titulo,
e.sucursal_id,
su.nombre AS sucursal_nombre,
COUNT(*) AS ejemplares_totales,
COUNT(*) FILTER (WHERE e.estado = 'disponible') AS disponibles,
COUNT(*) FILTER (WHERE e.estado = 'prestado') AS prestados
FROM materiales m
JOIN ejemplares e ON e.material_id = m.material_id
JOIN sucursales su ON su.sucursal_id = e.sucursal_id
GROUP BY m.material_id, m.titulo, e.sucursal_id, su.nombre;
-- Un índice único es obligatorio para poder refrescar sin bloquear (ver abajo)
CREATE UNIQUE INDEX uq_mv_disponibilidad
ON mv_disponibilidad_material (material_id, sucursal_id);Se consulta como cualquier tabla:
SELECT sucursal_nombre, disponibles
FROM mv_disponibilidad_material
WHERE material_id = 4021 AND disponibles > 0;El refresco
Es el punto crítico, y la diferencia entre las dos formas es grande:
-- Bloquea la vista: nadie puede leerla mientras dura
REFRESH MATERIALIZED VIEW mv_disponibilidad_material;
-- No bloquea: los lectores siguen viendo la versión anterior hasta que termina.
-- Requiere el índice UNIQUE de arriba. Es más lento, pero es el que se usa en producción.
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_disponibilidad_material;Cuándo se refresca
| Estrategia | Cómo | Cuándo usarla |
|---|---|---|
| Programado | Tarea de sistema cada N minutos u horas | Lo habitual. Datos que toleran retraso: catálogo, informes |
| Tras un lote | Al final del proceso nocturno de importación | Cuando la fuente cambia en momentos conocidos |
| Bajo demanda | El usuario pulsa "actualizar informe" | Informes pesados que se piden pocas veces |
| Por disparador | Un TRIGGER sobre la tabla base lanza el refresco |
Casi nunca. Refrescar la vista entera en cada escritura anula el beneficio |
Para el catálogo web de BiblioRed, cada 10 minutos es más que suficiente: que la web diga "2 disponibles" cuando hace tres minutos que quedó 1 es aceptable, porque el socio va a comprobarlo en el mostrador de todos modos.
Vista materializada frente a tabla de resumen
| Vista materializada | Tabla de resumen | |
|---|---|---|
| Definición | Una sentencia CREATE MATERIALIZED VIEW |
CREATE TABLE + proceso de carga |
| Refresco | REFRESH, siempre completo (en PostgreSQL) |
A medida: solo el mes en curso, solo lo que cambió |
| Riesgo de divergencia de la lógica | Ninguno: la consulta está en la definición | Existe: el INSERT puede desviarse de lo que se pretendía |
| Escalabilidad | Limitada: refrescar el todo cuesta cada vez más | Buena: se refresca solo lo necesario |
| Esfuerzo | Mínimo | Medio |
Regla práctica: empieza siempre por una vista materializada. Si el refresco completo tarda demasiado —y con años de historial acabará tardando— migra a una tabla de resumen con refresco incremental. Es el orden que minimiza el trabajo.
(Nota: SQLite no tiene vistas materializadas. El equivalente es una tabla normal poblada por la aplicación. Es una de las diferencias que hay que tener presentes al elegir entre PostgreSQL y SQLite, como vimos en 01-02.)
- Técnica 6: tablas de historial planas para informes
Una variante de la tabla de resumen que no agrega, sino que aplana: guarda una fila por hecho, pero con todas las columnas que hacen falta ya resueltas, sin JOIN.
CREATE TABLE historial_prestamos_plano (
prestamo_id INTEGER NOT NULL,
fecha_prestamo DATE NOT NULL,
fecha_devolucion DATE,
dias_prestado INTEGER,
-- Datos del socio EN EL MOMENTO del préstamo
socio_id INTEGER NOT NULL,
socio_nombre VARCHAR(140) NOT NULL,
socio_sucursal VARCHAR(60) NOT NULL,
-- Datos del material EN EL MOMENTO del préstamo
material_id INTEGER NOT NULL,
material_titulo VARCHAR(200) NOT NULL,
material_tipo VARCHAR(20) NOT NULL,
autor_nombre VARCHAR(140),
-- Datos del ejemplar
ejemplar_codigo VARCHAR(10) NOT NULL,
sucursal_prestamo VARCHAR(60) NOT NULL,
CONSTRAINT pk_historial_prestamos_plano PRIMARY KEY (prestamo_id)
);Cada fila lleva doce columnas que en el esquema normalizado exigirían cinco JOIN. Cualquier informe —préstamos por autor y año, por tipo de material y sucursal, por franja de edad del socio— se resuelve con un GROUP BY sobre una sola tabla.
Lo importante de esta técnica es la frase "en el momento del préstamo". Los valores se copian cuando el préstamo se cierra y no se actualizan nunca más. Si Marta Alsina se traslada a la sucursal Sur en 2027, los préstamos que hizo en 2026 siguen diciendo "Norte", que es la verdad histórica. Un informe sobre la actividad de la sucursal Norte en 2026 debe contarlos.
Aquí está la diferencia clave con las técnicas anteriores: esto no es una copia que haya que mantener sincronizada. Es un registro de lo que era cierto entonces. No hay riesgo de inconsistencia porque no hay nada que propagar, y por eso esta es una de las desnormalizaciones más seguras que existen.
-- Se puebla cuando el préstamo se cierra, con los valores vigentes en ese momento
INSERT INTO historial_prestamos_plano
SELECT p.prestamo_id, p.fecha_prestamo, p.fecha_devolucion,
p.fecha_devolucion - p.fecha_prestamo,
s.socio_id, s.nombre || ' ' || s.apellidos, ss.nombre,
m.material_id, m.titulo, m.tipo,
a.nombre || ' ' || a.apellidos,
e.codigo, es.nombre
FROM prestamos p
JOIN socios s ON s.socio_id = p.socio_id
JOIN sucursales ss ON ss.sucursal_id = s.sucursal_id
JOIN ejemplares e ON e.ejemplar_id = p.ejemplar_id
JOIN sucursales es ON es.sucursal_id = e.sucursal_id
JOIN materiales m ON m.material_id = e.material_id
LEFT JOIN autores a ON a.autor_id = m.autor_id
WHERE p.prestamo_id = 5001;
- Técnica 7: el esquema en estrella de los almacenes de datos
Todas las técnicas anteriores son desnormalizaciones puntuales sobre un esquema normalizado. El esquema en estrella es otra cosa: es un modelo de datos completo diseñado desde el principio para desnormalizar, de forma sistemática y por norma.
Retomamos aquí la distinción OLTP frente a OLAP de la lección 01-02:
| OLTP (BiblioRed operativo) | OLAP (almacén analítico) | |
|---|---|---|
| Para qué | Registrar préstamos, dar de alta socios | Analizar diez años de actividad |
| Operaciones | Muchas, pequeñas, concurrentes | Pocas, enormes, secuenciales |
| Escrituras | Constantes | Solo la carga periódica |
| Prioridad | Integridad, latencia baja | Rendimiento de lectura masiva |
| Diseño | Normalizado (3FN) | Desnormalizado (estrella) |
Hechos y dimensiones
El esquema en estrella organiza los datos en dos tipos de tabla:
- Tabla de hechos (fact table): una fila por acontecimiento medible, con las métricas numéricas que se van a agregar y claves ajenas a las dimensiones. Es enorme —millones o miles de millones de filas— y muy estrecha.
- Tablas de dimensión: el contexto por el que se quiere filtrar y agrupar. Son pequeñas, anchas y deliberadamente desnormalizadas: una dimensión no se descompone aunque tenga dependencias transitivas.
-- DIMENSIÓN: el material. Nótese que autor, editorial y tipo están
-- APLANADOS aquí, en vez de en tablas separadas. Es intencionado.
CREATE TABLE dim_material (
material_key INTEGER PRIMARY KEY,
material_id INTEGER NOT NULL,
titulo VARCHAR(200) NOT NULL,
tipo VARCHAR(20) NOT NULL,
autor_nombre VARCHAR(140),
autor_nacionalidad VARCHAR(40), -- transitiva vía autor: aceptado
editorial VARCHAR(80),
anio_publicacion SMALLINT,
idioma VARCHAR(20)
);
-- DIMENSIÓN: la sucursal, con su geografía aplanada
CREATE TABLE dim_sucursal (
sucursal_key INTEGER PRIMARY KEY,
sucursal_id INTEGER NOT NULL,
nombre VARCHAR(60) NOT NULL,
ciudad VARCHAR(60) NOT NULL, -- transitiva vía CP: aceptado
codigo_postal VARCHAR(5) NOT NULL,
comarca VARCHAR(60)
);
-- DIMENSIÓN: el tiempo. Todas las formas de mirar una fecha, precalculadas
CREATE TABLE dim_fecha (
fecha_key INTEGER PRIMARY KEY, -- 20260409
fecha DATE NOT NULL,
anio SMALLINT NOT NULL,
trimestre SMALLINT NOT NULL,
mes SMALLINT NOT NULL,
mes_nombre VARCHAR(12) NOT NULL,
dia_semana SMALLINT NOT NULL,
es_festivo BOOLEAN NOT NULL DEFAULT FALSE
);
-- HECHO: un préstamo. Estrecha, larguísima, solo claves y métricas
CREATE TABLE hechos_prestamo (
prestamo_key BIGINT PRIMARY KEY,
fecha_key INTEGER NOT NULL REFERENCES dim_fecha (fecha_key),
socio_key INTEGER NOT NULL REFERENCES dim_socio (socio_key),
material_key INTEGER NOT NULL REFERENCES dim_material (material_key),
sucursal_key INTEGER NOT NULL REFERENCES dim_sucursal (sucursal_key),
dias_prestado SMALLINT,
dias_retraso SMALLINT NOT NULL DEFAULT 0,
importe_recargo NUMERIC(6,2) NOT NULL DEFAULT 0,
num_prestamos SMALLINT NOT NULL DEFAULT 1
);Se llama "en estrella" porque el diagrama tiene la tabla de hechos en el centro y las dimensiones alrededor:
flowchart TD
DF["dim_fecha"] --> H
DS["dim_socio"] --> H
DM["dim_material"] --> H
DSU["dim_sucursal"] --> H
H["<b>hechos_prestamo</b><br/>métricas + claves"]
Y las consultas analíticas se vuelven triviales de escribir y rapidísimas de ejecutar:
-- Préstamos y retrasos por nacionalidad del autor y trimestre, 2025
SELECT f.anio, f.trimestre, m.autor_nacionalidad,
SUM(h.num_prestamos) AS prestamos,
AVG(h.dias_retraso) AS retraso_medio
FROM hechos_prestamo h
JOIN dim_fecha f ON f.fecha_key = h.fecha_key
JOIN dim_material m ON m.material_key = h.material_key
WHERE f.anio = 2025
GROUP BY f.anio, f.trimestre, m.autor_nacionalidad
ORDER BY prestamos DESC;Un solo nivel de JOIN, sin cadenas. En el esquema normalizado, llegar de un préstamo a la nacionalidad del autor exige recorrer prestamos → ejemplares → materiales → autores.
Por qué en analítica se desnormaliza por norma
Cuatro razones, y las cuatro son sólidas:
- No hay escrituras concurrentes. El almacén se carga por lotes desde el sistema operativo. El principal coste de la desnormalización —mantener la coherencia ante escrituras— sencillamente no existe.
- La fuente de verdad está en otro sitio. Si el almacén se corrompe, se vuelve a cargar desde el OLTP. La redundancia no puede producir una pérdida irrecuperable.
- Los datos son históricos e inmutables. Un préstamo de 2019 no cambia. Y cuando el contexto cambia —un material se recataloga— lo correcto es conservar el valor antiguo para los hechos antiguos, que es justo lo que la desnormalización da.
- El patrón de consulta es conocido y estable. Se sabe de antemano por qué se va a agrupar, y el modelo se diseña para eso.
En resumen: en OLAP se dan a la vez todas las condiciones que justifican desnormalizar, y ninguna de las que lo desaconsejan. Por eso allí es la norma y no la excepción.
(Existe una variante llamada copo de nieve que sí normaliza las dimensiones —sacando autor de dim_material a su propia tabla, por ejemplo—. Ahorra espacio y complica las consultas. El criterio mayoritario en la industria es estrella salvo que las dimensiones sean gigantescas. El diseño de almacenes de datos es una disciplina propia; aquí solo interesa reconocerlo como desnormalización sistemática y entender por qué está justificada.)
- Cómo se mantiene la coherencia de lo desnormalizado
Toda desnormalización que no sea histórica congelada crea una obligación: mantener la copia sincronizada con la fuente. Hay tres formas de cumplirla y hay que elegir conscientemente.
Opción A: en la aplicación
El código que escribe el préstamo actualiza también el contador:
BEGIN;
INSERT INTO prestamos (socio_id, ejemplar_id, fecha_prestamo, fecha_devolucion_prevista)
VALUES (14, 3081, CURRENT_DATE, CURRENT_DATE + 21);
UPDATE socios
SET total_prestamos = total_prestamos + 1,
prestamos_abiertos = prestamos_abiertos + 1
WHERE socio_id = 14;
COMMIT;A favor: la lógica está donde el equipo la ve, es fácil de depurar y de probar.
En contra, y es un problema serio: basta que un solo camino de escritura se olvide para que la copia empiece a divergir. Y los caminos de escritura son más de los que parece: la aplicación web, la aplicación de mostrador, el proceso de importación nocturno, el script de corrección que alguien ejecutó a mano un martes por la tarde. Cada uno tiene que acordarse.
Opción B: con un disparador (TRIGGER)
Un disparador es una función que el SGBD ejecuta automáticamente cuando ocurre un evento sobre una tabla. La ventaja decisiva: da igual quién escriba y desde dónde.
-- La función que hace el trabajo
CREATE OR REPLACE FUNCTION fn_actualizar_contador_socio()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE socios
SET total_prestamos = total_prestamos + 1,
prestamos_abiertos = prestamos_abiertos
+ CASE WHEN NEW.fecha_devolucion IS NULL THEN 1 ELSE 0 END
WHERE socio_id = NEW.socio_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE socios
SET total_prestamos = total_prestamos - 1,
prestamos_abiertos = prestamos_abiertos
- CASE WHEN OLD.fecha_devolucion IS NULL THEN 1 ELSE 0 END
WHERE socio_id = OLD.socio_id;
ELSIF TG_OP = 'UPDATE' THEN
-- Devolución: pasa de abierto a cerrado
IF OLD.fecha_devolucion IS NULL AND NEW.fecha_devolucion IS NOT NULL THEN
UPDATE socios SET prestamos_abiertos = prestamos_abiertos - 1
WHERE socio_id = NEW.socio_id;
END IF;
END IF;
RETURN NULL; -- AFTER trigger: el valor devuelto se ignora
END;
$$ LANGUAGE plpgsql;
-- El disparador que la invoca
CREATE TRIGGER trg_contador_socio
AFTER INSERT OR UPDATE OR DELETE ON prestamos
FOR EACH ROW
EXECUTE FUNCTION fn_actualizar_contador_socio();Lectura del código, línea a línea, porque es la primera vez que aparece un disparador en el curso:
RETURNS TRIGGERmarca la función como apta para ser invocada por un disparador.TG_OPes una variable especial que contiene la operación:'INSERT','UPDATE'o'DELETE'.NEWes la fila nueva (existe enINSERTyUPDATE);OLDes la anterior (existe enUPDATEyDELETE).AFTER ... FOR EACH ROWsignifica que se ejecuta una vez por fila afectada, después de aplicar el cambio. Si unUPDATEtoca 500 filas, el disparador se ejecuta 500 veces.- Todo ocurre dentro de la misma transacción que la operación original: si el
UPDATEdel contador falla, elINSERTdel préstamo se deshace también. Esa atomicidad es precisamente lo que hace fiable esta opción, y es materia de la lección 06-01.
A favor: imposible de saltarse. Coherencia garantizada por el SGBD.
En contra: es lógica de negocio escondida en la base de datos, invisible para quien lee el código de la aplicación; complica la depuración; y cuesta en cada escritura. Un UPDATE masivo de 100.000 filas ejecuta el disparador 100.000 veces.
Opción C: por proceso por lotes
Un trabajo programado recalcula la copia periódicamente:
-- Cada noche a las 3:00
UPDATE socios s
SET total_prestamos = COALESCE(c.total, 0),
prestamos_abiertos = COALESCE(c.abiertos, 0)
FROM (
SELECT socio_id, COUNT(*) AS total,
COUNT(*) FILTER (WHERE fecha_devolucion IS NULL) AS abiertos
FROM prestamos GROUP BY socio_id
) c
WHERE c.socio_id = s.socio_id;A favor: coste cero en las escrituras, código sencillo, y —esto es valioso— corrige por sí solo cualquier divergencia, venga de donde venga.
En contra: los datos están desfasados entre ejecuciones. Hay que decidir si eso es aceptable, y decirlo en la interfaz ("datos a fecha de ayer").
La comparativa
| Aplicación | Disparador | Lote | |
|---|---|---|---|
| Garantía de coherencia | Baja: depende de que todos los caminos lo hagan | Alta: el SGBD la impone | Media: exacta después de cada ejecución |
| Latencia | Inmediata | Inmediata | Hasta el siguiente ciclo |
| Coste en escritura | Medio | Medio-alto | Nulo |
| Coste en operación | Bajo | Bajo | Medio: hay un proceso que vigilar |
| Visibilidad para el equipo | Alta | Baja: hay que ir a buscarlo | Media |
| Se salta si escribes por otro lado | Sí | No | No aplica: se corrige solo |
| Se autocorrige | No | No | Sí |
| Recomendado para | Copias poco críticas con un solo camino de escritura | Copias críticas que deben ser exactas siempre | Agregados e informes que toleran retraso |
La combinación que mejor funciona en la práctica: elige una de las tres para el mantenimiento y añade siempre la de lote como auditoría. Aunque uses un disparador, programa la consulta de comprobación nocturna. Si algún día devuelve filas, sabrás que hay un agujero antes de que lo descubra un usuario.
- El puente con NoSQL: el modelado documental es desnormalización elevada a método
Si al leer la sección 7 has pensado "esto se parece mucho a lo de embeber documentos", has visto exactamente lo que hay que ver.
El modelado documental de la lección 03-03 —embeber en lugar de referenciar, duplicar campos a propósito, diseñar el agregado alrededor de la consulta que lo va a leer— es desnormalización. No es una técnica parecida: es la misma decisión, tomada por los mismos motivos, con las mismas consecuencias.
Compáralo punto por punto:
| En el mundo relacional (esta lección) | En el mundo documental (03-03) |
|---|---|
| Duplicar un atributo para evitar un JOIN | Embeber un subdocumento para evitar una segunda consulta |
| Tabla de resumen preagregada | Campos de recuento dentro del documento agregado |
| Columna redundante mantenida por disparador | Campo duplicado con propagación al actualizar el original |
Dato histórico congelado (importe de la multa) |
Campo duplicado de categoría B: histórico congelado |
| Atributo inmutable copiado (el ISBN) | Campo duplicado de categoría A: inmutable, gratis |
| Fuente de verdad frente a copia derivada | La colección canónica frente al agregado de lectura |
La diferencia no está en la técnica sino en el punto de partida por defecto. En un sistema relacional, lo normal es normalizar y desnormalizar en casos concretos y justificados. En un sistema documental, lo normal es agregar —desnormalizar— y normalizar (referenciar) en casos concretos y justificados. El eje es el mismo; lo que cambia es dónde está el punto neutro.
Y la disciplina que exige es idéntica. En 03-03 establecimos que cada campo duplicado debe estar clasificado —inmutable, histórico congelado, o vivo con propagación— y que si no se escribe, en seis meses nadie sabrá si hay que actualizarlo. Es exactamente la documentación que la sección 5 de esta lección exige para cada columna redundante. La regla es la misma en los dos mundos: duplica lo que se muestra, nunca lo que se usa para decidir; y escribe siempre quién es la fuente de verdad.
Esto explica también algo que en el módulo 3 podía sonar contradictorio. Cuando dijimos que MongoDB "no necesita JOIN" no estábamos diciendo que hubiera desaparecido el problema que el JOIN resuelve: estábamos diciendo que se paga por adelantado, en la escritura, en forma de duplicación mantenida. Es el mismo intercambio de la sección 3 de esta lección, con los mismos platos en la balanza.
- Guía de decisión: las preguntas antes y las señales para deshacerlo
Las siete preguntas antes de desnormalizar
Respóndelas por escrito. Si alguna no tiene respuesta, no desnormalices todavía.
1. ¿He medido el problema? ¿Cuánto tarda ahora la consulta, con datos de producción y volumen real? Si no tienes el número, no tienes un problema: tienes una sospecha.
2. ¿He probado con un índice? EXPLAIN ANALYZE de la consulta, revisión de los índices existentes, ANALYZE de las tablas. Esto es la lección 06-03 y es obligatorio antes de seguir.
3. ¿Cuál es el objetivo concreto? "Este informe debe abrirse en menos de dos segundos" es un objetivo. "Ir más rápido" no lo es, porque no se puede saber si se ha cumplido.
4. ¿Cuál es la relación lecturas/escrituras? Medida, no estimada. Por debajo de 10:1, la desnormalización rara vez compensa.
5. ¿La copia es inmutable, histórica congelada o viva? Es la pregunta que más ahorra. Las dos primeras son casi gratis. Solo la tercera exige un mecanismo de propagación, y solo entonces hay que responder las dos siguientes.
6. ¿Quién mantiene la copia y qué pasa si falla? Aplicación, disparador o lote (sección 12). Y el escenario del fallo: ¿un número mal en una pantalla, o un importe mal en una factura? La gravedad decide el mecanismo.
7. ¿Cómo se detecta y se repara la divergencia? La consulta de auditoría y el procedimiento de recálculo, escritos y programados. Si no los tienes, la desnormalización no está terminada.
El árbol de decisión
flowchart TD
A["Consulta lenta<br/>o dato que debe congelarse"] --> B{"¿Es un dato histórico<br/>que debe congelarse?"}
B -->|Sí| C["Guárdalo. No es<br/>desnormalización opcional:<br/>es lo correcto"]
B -->|No| D{"¿Has medido<br/>con EXPLAIN ANALYZE?"}
D -->|No| E["Mídelo primero<br/>→ 06-03"]
D -->|Sí| F{"¿Lo arregla<br/>un índice?"}
F -->|Sí| G["Crea el índice.<br/>Fin del problema"]
F -->|No| H{"¿Es un agregado<br/>sobre muchas filas?"}
H -->|Sí| I["Vista materializada<br/>o tabla de resumen"]
H -->|No| J{"¿Ratio lecturas/escrituras<br/>mayor que 10:1?"}
J -->|No| K["No desnormalices.<br/>Revisa la consulta"]
J -->|Sí| L{"¿El dato copiado<br/>es inmutable?"}
L -->|Sí| M["Duplica. Coste casi nulo"]
L -->|No| N["Duplica + mecanismo de<br/>propagación + auditoría.<br/>Documéntalo"]
Las señales de que hay que deshacerlo
Una desnormalización no es para siempre. Estas seis señales indican que hay que revisarla, y probablemente revertirla:
1. Las consultas de auditoría devuelven filas con regularidad. El mecanismo de mantenimiento tiene un agujero que no se ha cerrado. Cada divergencia detectada es un dato que alguien vio mal antes de que la auditoría lo pillara.
2. Nadie recuerda por qué está esa columna. Si la documentación no existe o nadie la encuentra, la desnormalización ya no es deliberada: es deuda.
3. La copia se ha convertido en fuente de verdad. El síntoma es que alguien corrige un valor en la copia en lugar de en el original. A partir de ahí hay dos sistemas de datos que se contradicen y no hay forma de decidir cuál manda.
4. Las escrituras se han vuelto el cuello de botella. El sistema optimizó las lecturas y ahora el mostrador espera. Mide otra vez: puede que el equilibrio haya cambiado.
5. El motivo original ha desaparecido. El informe que justificaba la tabla de resumen ya no lo usa nadie. La versión nueva de PostgreSQL ejecuta aquel JOIN cien veces más rápido. Se añadió un índice que resuelve el caso. Revisa las desnormalizaciones al menos una vez al año: algunas caducan.
6. La lógica de mantenimiento se ha vuelto más compleja que el JOIN que evitaba. Si el disparador tiene cuarenta líneas y tres casos especiales para ahorrar un JOIN de dos tablas, el intercambio ha dejado de tener sentido.
Cómo se deshace
Con la misma disciplina que se hizo, y en orden inverso al de 05-03: se cambian primero las lecturas para que usen el esquema normalizado, se comprueba que dan los mismos resultados, se retira el mecanismo de mantenimiento, y solo al final se elimina la columna o la tabla. Guardando una copia antes, siempre.
Errores Comunes y Consejos
Desnormalizar sin haber medido. Es el error número uno y la causa de la mayor parte de la redundancia innecesaria que hay en producción. "Esto va a ir lento cuando crezca" es una predicción, no una medición, y las predicciones sobre rendimiento fallan constantemente: el cuello de botella casi nunca está donde se esperaba.
Desnormalizar antes de probar un índice. Es la sección 2 entera. Un CREATE INDEX es reversible, gratuito en riesgo, e instantáneo; una desnormalización es permanente en la práctica. Empieza por 06-03.
Empezar desnormalizado "por si acaso". Rompe la regla de oro. No sabes todavía qué consultas van a dominar, ni con qué volumen, ni con qué patrón de escritura. Y deshacerlo después es mucho más caro que hacerlo ahora.
No documentar la fuente de verdad. Cada dato duplicado tiene que tener escrito cuál de las dos copias manda. Sin eso, el día que discrepen —y discreparán— nadie sabrá cuál corregir, y alguien elegirá mal.
Tratar la copia como fuente de verdad. El síntoma es un UPDATE directo sobre la tabla de resumen para "cuadrar" una cifra. Ese UPDATE no arregla nada: crea una divergencia permanente que el siguiente refresco borrará, o peor, no borrará.
Poner un disparador que refresque una vista materializada entera en cada escritura. Anula por completo el beneficio y convierte cada INSERT en un recálculo global. Las vistas materializadas se refrescan de forma programada.
Olvidar el procedimiento de recálculo. Toda desnormalización necesita un UPDATE/INSERT documentado que la reconstruya desde cero. Algún día habrá que ejecutarlo, con prisa, y no será el momento de escribirlo.
Confundir "dato congelado" con "dato desnormalizado". No son lo mismo y confundirlos lleva a dos errores opuestos: propagar un valor que debía quedarse quieto (y falsear un histórico), o dejar sin propagar una copia viva (y mostrar datos obsoletos). La pregunta que los separa está en la sección 4: si el original cambia mañana, ¿este debe cambiar también?
No revisar nunca. Las desnormalizaciones caducan. Una revisión anual de todas las que hay en el esquema, con sus mediciones repetidas, suele encontrar al menos una que ya no hace falta.
Ejercicios
Ejercicio 1: Decidir si desnormalizar
Para cada uno de estos cuatro casos de BiblioRed, decide si desnormalizarías o no. Justifica con las preguntas de la sección 14 e indica, si desnormalizas, qué técnica usarías y qué mecanismo de mantenimiento.
a) La página de detalle de un evento muestra el nombre de la sala y su aforo. Se consulta unas 300 veces al día. La consulta normalizada tarda 4 milisegundos.
b) El informe anual de dirección cruza los 84.000 préstamos con socios, materiales, autores y sucursales, agrupando por autor y año. Tarda 38 segundos y lo abren 15 personas cada mañana durante enero.
c) El recibo que se imprime al cobrar una multa muestra el nombre del socio, el importe y el motivo.
d) El catálogo web muestra, para cada material, cuántos ejemplares hay disponibles en cada sucursal. Se consulta 40.000 veces al día y los datos cambian con cada préstamo y cada devolución.
Ejercicio 2: Detectar y reparar una divergencia
BiblioRed añadió hace seis meses la columna socios.total_prestamos, mantenida por la aplicación de mostrador (opción A de la sección 12). Hoy, la responsable de la sucursal Norte dice que la ficha de un socio muestra 12 préstamos y su historial solo tiene 9.
Se pide:
- a) Escribir la consulta de auditoría que encuentra todos los socios con el contador descuadrado, mostrando la diferencia.
- b) Escribir el
UPDATEque repara el contador de todos ellos. - c) Proponer tres causas plausibles de la divergencia, teniendo en cuenta que el mantenimiento está en la aplicación.
- d) Proponer el cambio de mecanismo que evitaría que vuelva a ocurrir, y decir qué se gana y qué se paga.
Ejercicio 3: Diseñar una tabla de resumen
Dirección de BiblioRed pide un cuadro de mando de la actividad de eventos con estas cifras, por sucursal y mes: número de eventos celebrados, número total de inscripciones confirmadas, plazas ofertadas, ocupación media en porcentaje, y valoración media de los informes de evento.
Se pide:
- a) Escribir el
CREATE TABLEde la tabla de resumen, con clave primaria y las columnas de control que recomienda la sección 8. - b) Escribir el
INSERT ... SELECTque la carga a partir deeventos,salas,inscripcioneseinformes_evento, contemplando solo los meses cerrados. - c) Decidir el mecanismo de refresco y justificarlo.
- d) Escribir la consulta de auditoría que comprueba que una fila del resumen cuadra con los datos operativos.
Soluciones
Solución 1
a) No desnormalizar. La pregunta 1 ya lo resuelve: 4 milisegundos no es un problema. Con 300 consultas diarias, el tiempo total de CPU dedicado a ese JOIN es de poco más de un segundo al día. Introducir una copia de sala_nombre y sala_aforo en eventos significaría además duplicar un dato vivo —el aforo cambia si la sala se reforma, y de hecho es el ejemplo de anomalía de actualización que usamos en 04-01—, así que exigiría un mecanismo de propagación completo. Coste alto, beneficio nulo.
b) Sí desnormalizar: tabla de resumen o vista materializada. Es el caso 2 de la sección 4 en estado puro. 38 segundos × 15 personas = casi diez minutos diarios de espera acumulada, sobre datos que no cambian: los préstamos de años cerrados son inmutables. Además, la pregunta 2 no va a salvarlo: ningún índice acelera significativamente un GROUP BY que recorre las 84.000 filas de todos modos.
La técnica adecuada es una tabla de resumen con granularidad autor-año y una columna cerrado, refrescada una vez al mes por proceso por lotes. Los años pasados se calculan una vez en la vida. El coste de mantenimiento tiende a cero y la consulta pasa de 38 segundos a unos pocos milisegundos.
c) Sí, pero no es una desnormalización opcional: es corrección. Es el caso 3 de la sección 4. El recibo emitido el 12 de marzo dice lo que dice y no puede cambiar: ni si el socio se cambia el nombre, ni si la ordenanza sube la tarifa, ni si la multa se recalifica. Los tres valores deben copiarse en la tabla pagos (o en una tabla recibos) en el momento de emitir el recibo, y no volver a tocarse nunca.
No hace falta mecanismo de mantenimiento —precisamente porque no se propaga nada— y no hay riesgo de inconsistencia. Es la desnormalización más segura y la única de las cuatro que sería un error no hacer.
d) Sí desnormalizar: vista materializada. Es el caso 1 de la sección 4. La relación lecturas/escrituras es abrumadora: 40.000 consultas diarias frente a unos pocos cientos de préstamos y devoluciones. Y la consulta normalizada es un COUNT agrupado sobre ejemplares para cada material, que en la página del catálogo se ejecuta muchas veces.
La técnica es la mv_disponibilidad_material de la sección 9, con refresco programado cada 5 o 10 minutos. La clave está en aceptar el desfase: que el catálogo web diga "2 disponibles" cuando quedan 1 es tolerable, porque el socio comprobará la disponibilidad real al pedirlo. Lo que no sería tolerable es usar esa vista para decidir si se concede un préstamo: para eso hay que consultar ejemplares, que es la fuente de verdad. Es la aplicación exacta de la regla de 03-03: duplica lo que se muestra, nunca lo que se usa para decidir.
Solución 2
a) Consulta de auditoría:
SELECT s.socio_id,
s.nombre || ' ' || s.apellidos AS socio,
s.total_prestamos AS guardado,
COUNT(p.prestamo_id) AS real_,
s.total_prestamos - COUNT(p.prestamo_id) AS diferencia
FROM socios s
LEFT JOIN prestamos p ON p.socio_id = s.socio_id
GROUP BY s.socio_id, s.nombre, s.apellidos, s.total_prestamos
HAVING s.total_prestamos <> COUNT(p.prestamo_id)
ORDER BY abs(s.total_prestamos - COUNT(p.prestamo_id)) DESC;El LEFT JOIN es imprescindible: con un JOIN interno, los socios sin ningún préstamo desaparecerían del resultado, y son precisamente los que pueden tener un contador positivo erróneo.
b) Reparación:
BEGIN;
UPDATE socios s
SET total_prestamos = COALESCE(c.total, 0)
FROM (
SELECT s2.socio_id, COUNT(p.prestamo_id) AS total
FROM socios s2
LEFT JOIN prestamos p ON p.socio_id = s2.socio_id
GROUP BY s2.socio_id
) c
WHERE c.socio_id = s.socio_id
AND s.total_prestamos <> COALESCE(c.total, 0);
-- Comprobar antes de confirmar: debe devolver 0 filas
SELECT COUNT(*) FROM (
SELECT s.socio_id FROM socios s
LEFT JOIN prestamos p ON p.socio_id = s.socio_id
GROUP BY s.socio_id, s.total_prestamos
HAVING s.total_prestamos <> COUNT(p.prestamo_id)
) x;
COMMIT;El COALESCE cubre a los socios sin préstamos, cuyo COUNT sobre el LEFT JOIN da 0 pero cuyo subconjunto podría no aparecer. Y la comprobación dentro de la transacción, antes del COMMIT, es la práctica correcta: si el número no es cero, se hace ROLLBACK.
c) Tres causas plausibles, todas características del mantenimiento en la aplicación:
- Un camino de escritura que no actualiza el contador. El proceso nocturno de importación de préstamos de la biblioteca vecina, o el script de corrección que alguien ejecutó a mano, insertaron en
prestamossin tocarsocios. Es la causa más frecuente. - Un
DELETEno contemplado. Cuando Iván Pereda pidió que se borrara su historial, se borraron las filas deprestamospero el código solo restaba el contador en el caso de la devolución, no en el del borrado. - Una transacción parcialmente confirmada. Si el
INSERTy elUPDATEno estaban dentro de la misma transacción, un fallo entre los dos deja el préstamo insertado y el contador sin incrementar. Es un error sutil y produce divergencias de una unidad, muy difíciles de rastrear después.
d) Cambio de mecanismo: pasar a disparador (opción B), y añadir la auditoría por lotes.
El disparador de la sección 12 se ejecuta venga la escritura de donde venga: la aplicación web, el mostrador, el proceso de importación o el psql de un martes por la tarde. Elimina de raíz las causas 1 y 2. Y como se ejecuta dentro de la misma transacción que la operación original, elimina también la 3.
Lo que se gana: coherencia garantizada por el SGBD, no por la disciplina de todos los equipos que escriben.
Lo que se paga: un coste añadido en cada escritura sobre prestamos —notable si alguna vez se hace un UPDATE masivo—; lógica de negocio que vive en la base de datos y no se ve leyendo el código de la aplicación; y una función plpgsql más que probar y mantener.
Y en cualquier caso, la consulta del apartado a) se programa como comprobación nocturna igualmente. Incluso con disparador: si el disparador se desactiva alguna vez para una carga masiva y alguien se olvida de reactivarlo, la auditoría lo detectará esa misma noche.
Solución 3
a) La tabla de resumen:
CREATE TABLE resumen_eventos_mes (
anio SMALLINT NOT NULL,
mes SMALLINT NOT NULL,
sucursal_id INTEGER NOT NULL,
eventos_celebrados INTEGER NOT NULL DEFAULT 0,
inscripciones_conf INTEGER NOT NULL DEFAULT 0,
plazas_ofertadas INTEGER NOT NULL DEFAULT 0,
ocupacion_media_pct NUMERIC(5,2),
valoracion_media NUMERIC(3,2),
cerrado BOOLEAN NOT NULL DEFAULT FALSE,
actualizado_en TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT pk_resumen_eventos_mes PRIMARY KEY (anio, mes, sucursal_id),
CONSTRAINT chk_resumen_ev_mes CHECK (mes BETWEEN 1 AND 12),
CONSTRAINT chk_resumen_ev_ocupacion
CHECK (ocupacion_media_pct IS NULL OR ocupacion_media_pct BETWEEN 0 AND 100),
CONSTRAINT chk_resumen_ev_valoracion
CHECK (valoracion_media IS NULL OR valoracion_media BETWEEN 0 AND 5),
CONSTRAINT fk_resumen_ev_sucursal FOREIGN KEY (sucursal_id)
REFERENCES sucursales (sucursal_id) ON UPDATE CASCADE
);Las columnas cerrado y actualizado_en son las de control que pedía el enunciado. Los CHECK no son adorno: una tabla derivada con un porcentaje de ocupación del 340 % delata un error en la consulta de carga, y es mejor que lo detecte el INSERT que un directivo en una reunión.
b) La carga:
INSERT INTO resumen_eventos_mes
(anio, mes, sucursal_id, eventos_celebrados, inscripciones_conf,
plazas_ofertadas, ocupacion_media_pct, valoracion_media, cerrado)
SELECT EXTRACT(YEAR FROM e.inicio)::SMALLINT AS anio,
EXTRACT(MONTH FROM e.inicio)::SMALLINT AS mes,
s.sucursal_id,
COUNT(DISTINCT e.evento_id),
COALESCE(SUM(i.confirmadas), 0),
SUM(e.plazas_ofertadas),
CASE WHEN SUM(e.plazas_ofertadas) > 0
THEN 100.0 * COALESCE(SUM(i.confirmadas), 0) / SUM(e.plazas_ofertadas)
ELSE NULL END,
AVG(inf.valoracion_media),
TRUE
FROM eventos e
JOIN salas s ON s.sala_id = e.sala_id
LEFT JOIN LATERAL (
SELECT SUM(ins.plazas_ocupadas) AS confirmadas
FROM inscripciones ins
WHERE ins.evento_id = e.evento_id
AND ins.estado IN ('confirmada','asistida')
) i ON TRUE
LEFT JOIN informes_evento inf ON inf.evento_id = e.evento_id
WHERE e.estado = 'celebrado'
AND e.inicio < date_trunc('month', CURRENT_DATE) -- solo meses cerrados
GROUP BY 1, 2, s.sucursal_id
ON CONFLICT (anio, mes, sucursal_id) DO UPDATE
SET eventos_celebrados = EXCLUDED.eventos_celebrados,
inscripciones_conf = EXCLUDED.inscripciones_conf,
plazas_ofertadas = EXCLUDED.plazas_ofertadas,
ocupacion_media_pct = EXCLUDED.ocupacion_media_pct,
valoracion_media = EXCLUDED.valoracion_media,
actualizado_en = now();Tres decisiones que merecen comentario:
- La subconsulta
LATERALpara las inscripciones evita el error clásico de multiplicar filas al unir dos tablas de detalle (inscripciones e informes) contra la misma tabla de eventos. Sin ella, un evento con 20 inscripciones y un informe produciría 20 filas y la valoración media se contaría 20 veces. Es exactamente el problema de "inventar filas al reunir" de 05-03, en su versión de agregación. e.estado = 'celebrado'excluye los cancelados y los programados, que no deben contar como actividad.plazas_ocupadasen lugar de contar inscripciones: la columna generada deinscripcionesya incluye los acompañantes, que es lo que ocupa aforo de verdad.
c) Mecanismo de refresco: proceso por lotes, mensual, el día 1 de cada mes.
La justificación está en la naturaleza del dato. Los meses cerrados no cambian nunca: un evento celebrado en marzo con sus inscripciones y su informe es un hecho consumado. Refrescar más a menudo sería trabajo inútil. Y como el cuadro de mando es una herramienta de dirección que se mira mensualmente, un dato "a cierre del mes pasado" es exactamente lo que se necesita.
El ON CONFLICT ... DO UPDATE permite además reejecutar la carga sin riesgo si un informe de evento se rellena con retraso.
Un disparador aquí sería un error grave: recalcular agregados mensuales en cada inscripción es un coste permanente para un beneficio que se consume una vez al mes.
d) Consulta de auditoría para una fila concreta (marzo de 2026, sucursal Norte):
WITH operativo AS (
SELECT COUNT(DISTINCT e.evento_id) AS eventos,
SUM(e.plazas_ofertadas) AS plazas
FROM eventos e
JOIN salas s ON s.sala_id = e.sala_id
WHERE s.sucursal_id = 2
AND e.estado = 'celebrado'
AND e.inicio >= '2026-03-01' AND e.inicio < '2026-04-01'
),
resumen AS (
SELECT eventos_celebrados AS eventos, plazas_ofertadas AS plazas
FROM resumen_eventos_mes
WHERE anio = 2026 AND mes = 3 AND sucursal_id = 2
)
SELECT o.eventos AS eventos_operativo, r.eventos AS eventos_resumen,
o.plazas AS plazas_operativo, r.plazas AS plazas_resumen,
(o.eventos = r.eventos AND o.plazas = r.plazas) AS cuadra
FROM operativo o CROSS JOIN resumen r; eventos_operativo | eventos_resumen | plazas_operativo | plazas_resumen | cuadra
-------------------+-----------------+------------------+----------------+--------
14 | 14 | 420 | 420 | tSi cuadra es f, la tabla de resumen está desviada y hay que reejecutar la carga de ese mes. Programar esta comprobación para el mes anterior, ejecutada semanalmente, es suficiente: los datos de meses cerrados no deberían moverse, y si se mueven es que alguien está corrigiendo datos históricos, que es algo que conviene saber.
Conclusión
Este módulo empezó con una promesa del módulo 4: someter el esquema de BiblioRed a un examen formal que hasta entonces habíamos evitado. Ya está hecho, y conviene mirar el recorrido entero.
En 05-01 construimos el instrumental. Las tres anomalías —de inserción, de actualización y de borrado— dejaron de ser una nota al pie para convertirse en tres fallos que provocamos con SQL sobre la hoja de préstamos. Aprendimos a escribir su causa como dependencia funcional X → Y, a distinguir las totales de las parciales y las transitivas, a deducir con los axiomas de Armstrong, y a calcular el cierre X⁺ para encontrar claves candidatas con un algoritmo en lugar de con intuición.
En 05-02 recorrimos el catálogo. Primera forma normal y la atomicidad que depende del uso; segunda y las dependencias parciales; tercera y las transitivas; Boyce-Codd con su definición de una línea y su letra pequeña sobre la conservación de dependencias; cuarta y las multivaluadas independientes; quinta y la honestidad de decir que casi nunca aparece. Y el criterio que ordena todo: hasta 3FN/FNBC siempre, más allá solo si el caso lo pide.
En 05-03 hicimos el trabajo. De una hoja de cálculo de trece columnas a nueve tablas en FNBC, paso a paso, con los datos delante y el SQL de migración. Aprendimos que INSERT ... SELECT DISTINCT es la forma canónica de migrar, que un error de clave duplicada es el esquema nuevo haciendo su trabajo, que la condición de Heath es lo que separa una descomposición correcta de una que inventa filas, y que en producción se normaliza por fases y no de golpe. Y encontramos un fallo real en el esquema del módulo 4: multas violaba la 3FN por prestamo_id → socio_id, lo que permitía cobrarle a un socio la multa de otro.
Y en esta lección hemos cerrado el círculo con la decisión inversa. Desnormalizar no es lo contrario de normalizar: es lo que se hace después de normalizar, sobre un esquema que ya es correcto, para conseguir algo concreto que ese esquema no daba. Hemos visto las siete técnicas —columna redundante, columna generada, atributo duplicado, tabla de resumen, vista materializada, historial plano, esquema en estrella—, las tres formas de mantener la coherencia con sus garantías y sus costes, y las siete preguntas que hay que responder por escrito antes de tocar nada. Hemos visto también que el caso más fuerte para desnormalizar no es el rendimiento sino la corrección: el importe de una multa, el nombre en un recibo y la tarifa de una factura son datos históricos congelados, y guardarlos no es una concesión, es la única respuesta correcta. Y hemos reconocido que el modelado documental de 03-03 es esta misma disciplina llevada al centro del método, con las mismas categorías y las mismas obligaciones.
La regla de oro resume el módulo entero: primero normaliza, después desnormaliza a propósito, midiendo, y nunca al revés. Un esquema normalizado del que se ha retrocedido en dos puntos concretos, documentados, medidos y auditados, es un buen esquema. Un esquema redundante que nunca pasó por la normalización no es un esquema desnormalizado: es un esquema sin diseñar.
Con esto se cierra el módulo 5, Normalización. El esquema de BiblioRed ha pasado el examen, con un fallo detectado y corregido y cuatro desnormalizaciones ahora justificadas y escritas. Sabemos que la estructura es correcta y que los datos no pueden contradecirse. Lo que todavía no sabemos es qué ocurre cuando dos personas del mostrador registran un préstamo del mismo ejemplar en el mismo instante, ni qué pasa si el servidor se apaga a mitad de una operación, ni cuánto tarda de verdad una consulta cuando la tabla tiene diez millones de filas, ni quién puede leer los teléfonos de los socios. En el módulo 6, Transacciones, Rendimiento y Seguridad, dejamos de mirar el esquema y empezamos a mirar el sistema en funcionamiento: las transacciones y las propiedades ACID que garantizan que una operación ocurre entera o no ocurre (06-01); la concurrencia y los niveles de aislamiento que deciden qué ve cada usuario mientras otro escribe (06-02); los índices y los planes de ejecución, que son —recuérdalo— lo primero que hay que probar antes de desnormalizar (06-03); y la seguridad, los permisos y las copias de seguridad, que es lo que separa una base de datos de un accidente esperando a ocurrir (06-04).
Fundamentos de Bases de Datos
Módulo 1: Introducción a las Bases de Datos
- Conceptos Básicos de Bases de Datos
- Tipos de Bases de Datos
- Historia y Evolución de las Bases de Datos
- Sistemas Gestores de Bases de Datos y Arquitectura
Módulo 2: Bases de Datos Relacionales
- Modelo Relacional
- Lenguaje SQL
- Operaciones Básicas en SQL
- Consultas Multitabla: JOIN y Subconsultas
- Agregación y Agrupación de Datos
- Integridad Referencial
Módulo 3: Bases de Datos No Relacionales
- Introducción a NoSQL
- Tipos de Bases de Datos NoSQL
- Modelado de Datos en NoSQL
- Comparación entre Bases de Datos Relacionales y No Relacionales
Módulo 4: Diseño de Esquemas
- Principios de Diseño de Esquemas
- Diagramas Entidad-Relación (ER)
- Transformación de Diagramas ER a Esquemas Relacionales
- Tipos de Datos y Restricciones
Módulo 5: Normalización
Módulo 6: Transacciones, Rendimiento y Seguridad
- Transacciones y Propiedades ACID
- Concurrencia y Niveles de Aislamiento
- Índices y Optimización de Consultas
- Seguridad, Permisos y Copias de Seguridad
Módulo 7: Ejercicios Prácticos
- Ejercicios de SQL
- Ejercicios de Diseño de Esquemas
- Ejercicios de Normalización
- Ejercicios de Consultas Avanzadas y Transacciones
Módulo 8: Casos de Estudio
- Caso de Estudio: Base de Datos Relacional
- Caso de Estudio: Base de Datos No Relacional
- Caso de Estudio: Persistencia Políglota
