StackPractices
intermediate Por Mathias Paulenko

Desnormalización: espacio por velocidad de lectura

Guía práctica de desnormalización de bases de datos: cuándo cambiar almacenamiento por rendimiento de lectura y mantener datos consistentes.

Resumen

La desnormalización es la introducción intencional de redundancia en un esquema de base de datos para mejorar el rendimiento de lectura. La normalización elimina la duplicación para que cada dato viva en un solo lugar; la desnormalización copia deliberadamente ese dato a donde se lee, eliminando joins y precomputando trabajo que la consulta haría en cada petición. No es una excusa para un esquema desordenado — es una optimización que aplicas sobre una base normalizada, después de medir un cuello de botella real.

Esta guía recorre los cinco patrones de desnormalización que verás en producción, las estrategias de sincronización que mantienen honestos los datos derivados y un escenario completo con números reales. También cubre la parte que la mayoría de artículos omiten: lo que la desnormalización cuesta en la escritura y cómo detectar divergencias antes de que se conviertan en un bug.

El intercambio: escrituras y espacio por lecturas

Cada campo desnormalizado es una promesa que le haces a la base de datos: “actualízame cuando la fuente cambie”. Esa promesa tiene tres costes que se acumulan a medida que añades campos:

  • Amplificación de escritura. Un like solía ser un INSERT en post_likes. Con un contador en posts, es un INSERT más un UPDATE. Desnormaliza tres agregados y una sola acción de usuario puede tocar cuatro filas en dos tablas — y si alguna vive en otro servicio, ahora es una actualización distribuida.
  • Almacenamiento. Normalmente barato, pero contadores y documentos embebidos en filas calientes crecen más rápido de lo que los equipos esperan, y las filas anchas perjudican la eficiencia del buffer y de los índices.
  • Superficie de consistencia. En el momento en que la copia puede discrepar con la fuente, necesitas una respuesta a cómo se corrige: trigger síncrono, evento asíncrono, recálculo periódico o un job de reconciliación que encuentre la divergencia.

La desnormalización compensa cuando una lectura que ejecutas constantemente paga un impuesto de joins o agregación en cada ejecución — y puedes permitirte pagarlo una vez, en la escritura.

Cuándo usarla

Recurre a la desnormalización cuando la mayoría de estas condiciones se cumplen:

  • Cargas de trabajo de lectura intensiva donde los joins son el cuello de botella medido, no el sospechado
  • Agregados ejecutados frecuentemente sobre grandes volúmenes (dashboards, rankings, contadores)
  • La consulta necesita respuestas sub-segundo que los índices solos no pueden dar
  • Existe un mecanismo fiable para mantener los datos derivados sincronizados — o tolerancia al dato obsoleto
  • El almacenamiento es más barato que la computación que quemas por lectura

Cuándo NO usarla

Evita la desnormalización cuando el problema real es uno de estos:

  • Un índice que falta. La mayoría de las quejas de “consulta lenta” a escala son problemas de indexación. Revisa primero la indexación de bases de datos y el plan de ejecución.
  • Lecturas que podrían escalar horizontalmente. Si la consulta está bien pero la base de datos está saturada, una réplica de lectura distribuye la carga sin duplicar datos dentro del esquema.
  • Resultados que toleran obsolescencia y cambian constantemente. Si la respuesta no necesita almacenarse — solo ser rápida — un caché gana. Compara con las estrategias de caché con Redis antes de añadir columnas redundantes.
  • Equipos sin plan de sincronización. Si nadie puede responder “¿qué actualiza la copia cuando la fuente cambia?”, el campo desnormalizado divergirá en un mes.

Patrones habituales

PatrónDescripciónEjemplo
Columnas computadasAlmacenar valores derivados junto a los datos fuenteline_total = quantity * unit_price
ContadoresMantener conteos pre-agregadospost.like_count actualizado en cada like
Documentos embebidosAnidar datos relacionados en una sola fila/documentoPedido con items embebidos
Duplicación de lookupCopiar datos de referencia frecuentemente unidoscategory_name en la fila del producto
Vistas materializadasResultados de consulta precomputados, refrescados periódicamenteResumen diario de ingresos

Dos detalles que conviene interiorizar. Las columnas computadas declaradas como GENERATED ALWAYS AS ... STORED las mantiene el motor — sin lógica de sincronización que pueda fallar, pero solo pueden referenciar su propia fila. Los contadores y la duplicación de lookup cruzan fronteras de filas y tablas, así que esos son los que necesitan una estrategia de sincronización de verdad. Los documentos embebidos intercambian actualizaciones atómicas multi-entidad por una sola lectura; es la postura por defecto en almacenes de documentos y la excepción en los relacionales.

Ejemplos prácticos

Tabla de contadores

En lugar de contar likes en cada lectura:

SELECT COUNT(*) FROM post_likes WHERE post_id = 123;

Desnormaliza a un contador y actualízalo dentro de la misma transacción que la escritura fuente — la atomicidad es lo que mantiene honesto al contador:

CREATE TABLE posts (
    post_id SERIAL PRIMARY KEY,
    title TEXT NOT NULL,
    like_count INT NOT NULL DEFAULT 0
);

BEGIN;
    INSERT INTO post_likes (post_id, user_id) VALUES (123, 456);
    UPDATE posts SET like_count = like_count + 1 WHERE post_id = 123;
COMMIT;

Ambas sentencias se confirman o ninguna lo hace. Separarlas en transacciones distintas es como los contadores se vuelven negativos o cuentan doble sin que nadie lo note.

Columna computada

CREATE TABLE order_items (
    item_id SERIAL PRIMARY KEY,
    order_id INT NOT NULL REFERENCES orders(order_id),
    product_id INT NOT NULL,
    quantity INT NOT NULL,
    unit_price DECIMAL(10,2) NOT NULL,
    line_total DECIMAL(10,2) GENERATED ALWAYS AS (quantity * unit_price) STORED
);

SELECT item_id, product_id, line_total FROM order_items WHERE order_id = 100;

La palabra clave STORED materializa el valor al escribir, así que las lecturas nunca lo recalculan. Indexa la columna y se comporta como cualquier campo indexado — útil cuando filtras u ordenas por el valor derivado.

Vista materializada para agregados

CREATE MATERIALIZED VIEW daily_revenue AS
SELECT
    DATE(created_at) AS day,
    SUM(total) AS revenue,
    COUNT(*) AS order_count
FROM orders
GROUP BY DATE(created_at);

REFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue;
CREATE INDEX idx_daily_revenue_day ON daily_revenue(day);

CONCURRENTLY refresca sin bloquear a los lectores, a costa de exigir un índice único en la vista. Una vista materializada es desnormalización con la sincronización delegada a un horario de refresco — los datos pueden tener minutos u horas de retraso, aceptable para dashboards y equivocado para flujos de checkout.

Mantener consistentes los datos derivados

EstrategiaVentajasDesventajas
Actualizaciones a nivel aplicaciónSimple, explícita, testeableSe rompe cuando un segundo escritor la ignora
Triggers de base de datosGarantizada en cada escritura, invisible para la appDifícil de depurar, añade latencia a la escritura
CDC (Change Data Capture)Desacoplada, asíncrona, escala entre serviciosConsistencia eventual, overhead de infraestructura
Refresco de vistas materializadasAutomático para agregadosDatos obsoletos entre refrescos; el refresco mismo tiene coste

A nivel aplicación

El ejemplo transaccional de arriba es sincronización a nivel aplicación: la ruta de escritura conoce el campo desnormalizado y lo actualiza en la misma transacción. Es la estrategia más barata de operar y la primera en romperse — el día que un segundo servicio escriba en post_likes sin actualizar posts, el contador empieza a mentir. Si vas por esta vía, canaliza todas las escrituras por un único repositorio o frontera de servicio, no por cinco rutas de código.

Triggers

CREATE OR REPLACE FUNCTION update_post_like_count()
RETURNS TRIGGER AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        UPDATE posts SET like_count = like_count + 1 WHERE post_id = NEW.post_id;
    ELSIF TG_OP = 'DELETE' THEN
        UPDATE posts SET like_count = like_count - 1 WHERE post_id = OLD.post_id;
    END IF;
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER post_likes_trigger
AFTER INSERT OR DELETE ON post_likes
FOR EACH ROW EXECUTE FUNCTION update_post_like_count();

Los triggers trasladan la garantía de sincronización al motor — ningún escritor puede saltárselos, incluidos los apaños con psql y las migraciones que olvidaste. Los inconvenientes: FOR EACH ROW se dispara por fila, así que un borrado masivo de 50.000 likes ejecuta el trigger 50.000 veces; y un bug en el trigger se lleva la escritura fuente por delante, que es el peor modo de fallo que tiene la desnormalización.

CDC y eventos

El change data capture lee el write-ahead log y publica los cambios de filas como eventos — Debezium alimentando Kafka es el stack estándar. Un consumidor actualiza después el almacén desnormalizado, que puede vivir en otra base de datos por completo (Elasticsearch, un esquema de reporting, un caché). Así se alimentan las sagas y los read models en sistemas orientados a eventos, y combina con el patrón CDC y el patrón outbox cuando el evento debe garantizarse contra el commit local. El precio es real: un pipeline de streaming que operar, un diseño de ordenación y lecturas consistentes a la larga — no transaccionalmente.

Cómo elegir entre ellas

Usa triggers cuando la sincronización deba ser inmediata y el volumen de escrituras sea modesto. Usa actualizaciones a nivel aplicación cuando exista una única ruta de escritura bien definida. Usa CDC cuando la copia desnormalizada viva en otro sistema o necesites desacoplar la sincronización de la escritura. Usa refresco programado para agregados que toleran obsolescencia. La regla que aguanta en la práctica: triggers para corrección que no puedes permitirte perder, CDC para escala, refresco para dashboards.

flowchart diagram: Escritura de la aplicación

Sea cual sea la estrategia, ejecuta un control de consistencia periódico que recalcule una muestra de valores desnormalizados desde la fuente y alerte sobre divergencias. Las estrategias de sincronización fallan en silencio; el control es lo que convierte la divergencia silenciosa en una página.

Poblar la copia inicial (backfill)

Añadir un campo desnormalizado a una tabla en producción obliga a responder una pregunta más: ¿cómo se rellenan los datos existentes? Para tablas pequeñas, un UPDATE único con join a la fuente basta. Para las grandes, rellena en lotes por rangos de clave primaria — un solo UPDATE de 50M filas bloquea la tabla durante minutos. Con CDC, el camino limpio es snapshot-then-stream: toma una foto consistente, arranca el conector desde ese punto y deja que los eventos arrastren el delta. El backfill y el mecanismo de sincronización se solapan durante ese período, así que ordena las escrituras o la última en llegar puede ser un valor obsoleto.

Un escenario completo: el dashboard de ventas

Un equipo tiene un dashboard de ventas en tiempo real sobre PostgreSQL: 10M pedidos, 50M items de pedido. La consulta de resumen diario tarda 8,2 segundos y quieren bajar de los 200ms.

La consulta normalizada paga el impuesto cada vez que se ejecuta:

SELECT
    DATE(o.created_at) AS day,
    c.name AS category_name,
    COUNT(*) AS order_count,
    SUM(oi.quantity * oi.unit_price) AS revenue
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
JOIN categories c ON c.id = p.category_id
WHERE o.created_at >= NOW() - INTERVAL '30 days'
GROUP BY day, c.name
ORDER BY day DESC, revenue DESC;
-- 8,2s: 3 joins + agregación sobre 50M filas

Paso 1 — desnormalizar el lookup. El join a category_name existe solo para buscar una etiqueta que cambia una vez por trimestre. Cópiala a order_items y mantenla con un trigger en la inserción:

ALTER TABLE order_items ADD COLUMN category_name VARCHAR(100);

UPDATE order_items oi SET category_name = c.name
FROM products p JOIN categories c ON c.id = p.category_id
WHERE p.id = oi.product_id;

CREATE FUNCTION sync_category_name() RETURNS TRIGGER AS $$
BEGIN
    SELECT c.name INTO NEW.category_name
    FROM products p JOIN categories c ON c.id = p.category_id
    WHERE p.id = NEW.product_id;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER order_items_category
BEFORE INSERT OR UPDATE OF product_id ON order_items
FOR EACH ROW EXECUTE FUNCTION sync_category_name();

Fíjate en que el trigger cubre UPDATE OF product_id, no solo inserts — el día que un producto se recategorice, las filas históricas conservan la etiqueta antigua, que suele ser lo que quiere un informe de ingresos de todas formas.

Paso 2 — construir la tabla resumen. Pre-agrega lo que el dashboard pide:

CREATE TABLE daily_category_summary (
    day DATE NOT NULL,
    category_name VARCHAR(100) NOT NULL,
    order_count INT NOT NULL DEFAULT 0,
    revenue DECIMAL(12,2) NOT NULL DEFAULT 0,
    avg_order_value DECIMAL(10,2) NOT NULL DEFAULT 0,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (day, category_name)
);

CREATE INDEX idx_summary_day ON daily_category_summary(day DESC);

Paso 3 — mantenerla fresca con una vista materializada. Un refresco por hora recalcula desde la fuente; la tabla resumen también puede mantenerse fila a fila con la misma maquinaria de triggers si importa una frescura de minutos:

CREATE MATERIALIZED VIEW mv_daily_category AS
SELECT
    DATE(o.created_at) AS day,
    oi.category_name,
    COUNT(DISTINCT o.id) AS order_count,
    SUM(oi.quantity * oi.unit_price) AS revenue,
    AVG(oi.quantity * oi.unit_price) AS avg_order_value
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.created_at >= NOW() - INTERVAL '90 days'
GROUP BY day, oi.category_name;

REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_category;

Paso 4 — la consulta del dashboard se vuelve trivial:

SELECT day, category_name, order_count, revenue, avg_order_value
FROM daily_category_summary
WHERE day >= CURRENT_DATE - INTERVAL '30 days'
ORDER BY day DESC, revenue DESC;
-- 45ms: sin joins, índice en day
MétricaAntes (normalizado)Después (desnormalizado)
Tiempo de consulta8,2s45ms
Joins necesarios30
Filas escaneadas~50M~90
Almacenamiento extra0~200MB
MantenimientoNingunoTrigger + refresco por hora

Paso 5 — el control de divergencias. Un job nocturno compara el resumen desnormalizado con un cálculo fresco y alerta sobre divergencias:

SELECT a.day, a.category_name,
       a.revenue AS denormalized, b.revenue AS fresh,
       ABS(a.revenue - b.revenue) AS drift
FROM daily_category_summary a
JOIN mv_daily_category b
  ON a.day = b.day AND a.category_name = b.category_name
WHERE ABS(a.revenue - b.revenue) > 0.01;

Ese control no es herramienta opcional — es la diferencia entre una estrategia de desnormalización y un incidente de calidad de datos esperando su primer hilo enfurecido en Slack.

Desnormalización frente a las alternativas

Si necesitas…EligeEn lugar de
Agregados frecuentes sobre tablas grandesTabla desnormalizada o vista materializadaCaché (los datos cambian demasiado rápido para cachear bien)
Lecturas tolerantes a obsolescencia que cambian constantementeCaché (Redis)Desnormalización (sincronizarías para nada)
Más capacidad de lectura, mismas consultasRéplica de lecturaDesnormalización (no arregla un primario saturado de escrituras)
Una consulta de reporting puntualVista materializadaCambios de esquema (no desnormalices por un solo dashboard)
Eliminación de joins en un document storeDocumento embebidoModelo normalizado (default equivocado para el motor)

La pregunta útil no es “¿debería desnormalizar?” — es “¿es la copia más barata de mantener que el join de ejecutar?” Cuando la copia vive en tu propio esquema y el motor puede mantenerla honesta con un trigger, normalmente sí. Cuando necesita un pipeline, un caché o una réplica puede ser la respuesta más barata.

SQL frente a NoSQL

Las bases de datos de documentos desnormalizan por defecto: un pedido embebe sus items porque el agregado es la unidad de lectura y no hay join que pagar. Si modelas un document store como un esquema SQL normalizado, pelearás con el motor en cada consulta.

Las bases relacionales tienen el default opuesto — desnormaliza de forma deliberada y con moderación, porque cada campo redundante es un contrato de sincronización que estás firmando. La regla práctica: en NoSQL, justifica la normalización; en SQL, justifica la desnormalización. En cualquiera de los dos casos, escribe por qué existe cada campo redundante.

Errores habituales

  • Desnormalizar prematuramente. Normaliza primero; desnormaliza solo cuando un cuello de botella medido lo justifique. La redundancia sobre un esquema roto solo hace que las partes rotas sean consistentes entre sí.
  • Sin estrategia de sincronización. “Lo actualizaremos en la app” sin responder dónde, en qué transacción y qué pasa cuando falla — así es como los contadores divergen.
  • Sobre-desnormalizar. Cada columna copiada es un contrato de mantenimiento. Si no puedes nombrar la lectura que la necesita, el campo no se gana su coste de escritura.
  • Ignorar la amplificación de escritura. Los contadores en filas calientes serializan las actualizaciones sobre la fila padre — 500 likes por segundo en un post son 500 bloqueos de fila por segundo. Los contadores de alta frecuencia quizá pertenezcan a Redis con un volcado periódico.
  • Desnormalizar datos de referencia mutables. Copiar category_name es seguro porque rara vez cambia. Copiar price o status es criar bugs — los campos mutables pertenecen al join o a un snapshot versionado.
  • Sin detección de divergencias. Si nada compara la copia con la fuente, la divergencia aparece como un bug de datos reportado por un usuario meses después.

Solución de problemas

  • El contador diverge tras un despliegue o un incidente. Un escritor se saltó la ruta de sincronización o una transacción hizo rollback a medias. Encuéntralo recalculando desde la fuente (SELECT COUNT(*) frente al valor almacenado) y arregla el escritor, no solo el número.
  • El trigger hace lentas las escrituras. Los triggers FOR EACH ROW en operaciones masivas multiplican el trabajo por fila. Agrupa el mantenimiento (FOR EACH STATEMENT con tabla de transición) o mueve la sincronización a un job diferido.
  • El refresco de la vista materializada bloquea o crece. Los refrescos largos sobre vistas grandes retienen recursos; refresca CONCURRENTLY cuando importan los lectores, o particiona la vista y refresca partes en horarios escalonados.
  • Las lecturas obsoletas confunden a los usuarios. Si la obsolescencia es un problema de producto y no técnico — muestra “actualizado hace N minutos” en el dashboard, o acorta el intervalo de sincronización para los campos que los usuarios realmente miran.
  • Dos mecanismos de sincronización peleándose. Actualizaciones de la aplicación más un trigger sobre el mismo campo incrementan dos veces. Elige un único dueño para cada campo desnormalizado y documéntalo.

See Also

Preguntas frecuentes

¿Debo desnormalizar en SQL o en NoSQL?

Los document stores fomentan la desnormalización por diseño — embeber es la elección de modelado por defecto. En SQL, desnormaliza con moderación y documenta la razón de cada campo redundante. Las bases de datos difieren en el default, no en el trade-off subyacente.

¿La desnormalización viola la integridad de los datos?

No si se gestiona correctamente. Mantén una única fuente de verdad en las tablas normalizadas y trata los campos desnormalizados como datos derivados con un mecanismo de sincronización explícito — igual que un caché es derivado, solo que almacenado en el esquema.

¿Cómo detecto divergencias en datos desnormalizados?

Ejecuta un control de consistencia periódico que recalcule el valor desnormalizado desde las tablas fuente y lo compare con el almacenado. Alerta sobre divergencias que superen un umbral. Muestrea para abaratar si el recálculo completo es demasiado caro.

¿Cuál es la diferencia entre desnormalización y caché?

Un caché vive fuera del esquema y sirve lecturas que toleran obsolescencia; la desnormalización vive dentro del esquema y sobrevive a la invalidación del caché. Cachea cuando los datos cambian rápido y la obsolescencia es aceptable; desnormaliza cuando la respuesta necesita ser consultable, indexable y razonablemente consistente.

¿Cuánta redundancia es demasiada?

Cuando la ruta de escritura no da abasto, cuando la lógica de mantenimiento es más compleja que la consulta que reemplazó, o cuando nadie puede listar qué campos son derivados y cómo se sincroniza cada uno. Si pasa lo último, ya tienes demasiada.