Denormalización Intencional
Copias optimizadas para lectura con una estrategia de actualización documentada intercambian el costo de las uniones (joins) por velocidad de consulta; solo después de que el diseño normalizado se demuestre insuficiente.
Receta
Tarjeta de receta de referencia rápida, lista para copiar y pegar.
-- Fuente de verdad normalizada
CREATE TABLE orders (
order_id bigint PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers (customer_id),
status text NOT NULL
);
CREATE TABLE customers (
customer_id bigint PRIMARY KEY,
email text NOT NULL
);
-- Copia de lectura desnormalizada en orders (documentar el invariante)
ALTER TABLE orders ADD COLUMN customer_email text;
CREATE OR REPLACE FUNCTION sync_order_customer_email()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
SELECT email INTO NEW.customer_email FROM customers WHERE customer_id = NEW.customer_id;
RETURN NEW;
END;
$$;
CREATE TRIGGER orders_customer_email_trg
BEFORE INSERT OR UPDATE OF customer_id ON orders
FOR EACH ROW EXECUTE FUNCTION sync_order_customer_email();Cuándo recurrir a esto:
pg_stat_statementsmuestra que una unión (join) crítica está bloqueando los SLOs después de que existen los índices adecuados.- La ruta de lectura necesita columnas de instantánea (snapshot) estables (precio en el momento de la compra).
- La réplica de informes sigue siendo demasiado lenta para una consulta de panel específica.
Ejemplo de Trabajo
BEGIN;
CREATE TABLE products (
product_id bigint PRIMARY KEY,
sku text NOT NULL,
name text NOT NULL,
price numeric(12, 2) NOT NULL
);
CREATE TABLE orders (
order_id bigint PRIMARY KEY,
placed_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders (order_id),
product_id bigint NOT NULL REFERENCES products (product_id),
quantity integer NOT NULL CHECK (quantity > 0),
-- Denorm de instantánea: el precio del catálogo puede cambiar más tarde
unit_price numeric(12, 2) NOT NULL,
product_name text NOT NULL,
PRIMARY KEY (order_id, product_id)
);
-- Rellenar columnas de denorm en la inserción mediante trigger
CREATE OR REPLACE FUNCTION order_items_snapshot_product()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
p products%ROWTYPE;
BEGIN
SELECT * INTO p FROM products WHERE product_id = NEW.product_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'product % not found', NEW.product_id;
END IF;
NEW.unit_price := COALESCE(NEW.unit_price, p.price);
NEW.product_name := COALESCE(NEW.product_name, p.name);
RETURN NEW;
END;
$$;
CREATE TRIGGER order_items_snapshot_trg
BEFORE INSERT ON order_items
FOR EACH ROW EXECUTE FUNCTION order_items_snapshot_product();
INSERT INTO products (product_id, sku, name, price)
VALUES (1, 'A1', 'Alpha', 19.99);
INSERT INTO orders (order_id) VALUES (100);
INSERT INTO order_items (order_id, product_id, quantity, unit_price, product_name)
VALUES (100, 1, 2, 19.99, 'Alpha');
-- El cambio de nombre del catálogo no reescribe los elementos de línea históricos
UPDATE products SET name = 'Alpha Pro' WHERE product_id = 1;
SELECT product_name, unit_price FROM order_items WHERE order_id = 100;
COMMIT;Lo que esto demuestra:
unit_priceyproduct_nameson instantáneas intencionales en la fila del hecho.- El trigger rellena las instantáneas en la inserción para que las aplicaciones no olviden los campos desnormalizados.
- Los informes históricos permanecen correctos cuando cambian los catálogos.
Inmersión Profunda
Cómo Funciona
- La desnormalización duplica datos que podrían obtenerse mediante una unión (join) desde una tabla de dimensión.
- Los invariantes deben ser escritos: "
order_items.product_namees el nombre en el momento de la inserción, no el catálogo en vivo." - Estrategias de actualización: trigger en escritura, trabajo por lotes o actualización de vista materializada.
- Las consultas de detección de deriva comparan las columnas desnormalizadas con las tablas de origen.
Estrategias de Desnormalización
| Estrategia | Frescura | Complejidad |
|---|---|---|
| Trigger en escritura | Inmediata | Media |
| Doble escritura en la aplicación | Inmediata | Alta (rutas de código omitidas) |
| UPDATE programado por lotes | Retraso aceptable | Baja |
| Vista materializada | Intervalo de actualización | Media |
Notas SQL
-- Detección de deriva: el email desnormalizado debe coincidir con el cliente, a menos que las reglas de instantánea digan lo contrario
SELECT o.order_id, o.customer_email, c.email
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
WHERE o.customer_email IS DISTINCT FROM c.email;Trampas
- Desnormalizar antes de indexar uniones - columnas redundantes sin medir. Solución: indexar FKs,
EXPLAIN, luego desnormalizar. - Columnas de espejo en vivo - se espera que
customer_emailenorderssiga los cambios de email sin actualización. Solución: documentar instantánea vs. espejo; añadir trabajo de actualización. - Deriva del trigger - un
UPDATEmanual omite los triggers de la aplicación en SQL de administración. Solución: trabajo de reconciliación periódico. - Filas anchas por conveniencia - 40 columnas desnormalizadas anulables. Solución: vista materializada o instantánea JSONB con versión de esquema.
- Sin historia de reversión - no se puede recalcular la desnormalización desde el origen. Solución: mantener el origen normalizado como autoridad; la desnormalización es derivada.
Alternativas
| Alternativa | Usar Cuando | No Usar Cuando |
|---|---|---|
| Uniones normalizadas indexadas | Los datos caben en la caché del búfer | Unión probada de miles de millones de filas |
| Vista materializada | Muchos lectores, misma proyección | Necesita frescura a nivel de fila en la ruta OLTP |
| Réplica de lectura + índice de cobertura | La división lectura/escritura es suficiente | El retraso de la réplica es inaceptable |
| Caché de aplicación (Redis) | Datos de visualización efímeros | Instantáneas financieras que requieren historial de auditoría |
Preguntas Frecuentes
¿Cuándo se justifica la desnormalización?
Cuando tenga evidencia: pg_stat_statements, EXPLAIN (ANALYZE, BUFFERS), e intentos fallidos con índices de cobertura en el esquema normalizado.
¿Deberían desnormalizarse los precios históricos?
Sí - unit_price en order_items es el modelado estándar de comercio, no un truco de rendimiento.
¿Cómo documento los invariantes?
COMMENT ON COLUMN order_items.product_name IS
'SNAPSHOT: nombre del producto en el momento de la inserción de la línea; no rastrea cambios de nombre del catálogo';¿Triggers vs. doble escritura en la aplicación?
Los triggers capturan todas las rutas de inserción, incluido el SQL ad-hoc. Las aplicaciones son más fáciles de probar pero más fáciles de omitir de forma inconsistente.
¿Pueden las columnas generadas desnormalizar?
Sí, cuando la derivación es SQL puro:
ALTER TABLE order_items ADD COLUMN line_total numeric(12,2)
GENERATED ALWAYS AS (quantity * unit_price) STORED;¿Con qué frecuencia deben ejecutarse las comprobaciones de deriva?
Diariamente para columnas de espejo; nunca para instantáneas intencionales a menos que las reglas de negocio cambien retroactivamente.
¿La desnormalización rompe la 3FN?
Sí, por definición. Acéptelo conscientemente para obtener ganancias de lectura medidas o instantáneas requeridas.
¿Qué pasa con la desnormalización JSONB?
Almacene un blob de instantánea versionado en la inserción para auditoría (product_snapshot jsonb). Indexe solo los campos sobre los que filtra.
¿Cómo elimino la desnormalización más tarde?
Demuestre el rendimiento de las uniones con índices, migre los lectores, elimine la columna en la fase de migración de contrato.
¿Es el almacenamiento en caché lo mismo que la desnormalización?
Conceptualmente similar: duplica datos para obtener velocidad. La desnormalización de la base de datos sobrevive a la expulsión de la caché y admite informes SQL.
Relacionado
- Conceptos Básicos de Normalización - por qué los duplicados perjudican
- Vistas Materializadas - proyecciones desnormalizadas por lotes
- Mejores Prácticas de Desnormalización - lista de verificación de invariantes
- Diseño de Índices - pruebe primero los índices
Versiones de Stack: Esta página fue escrita para PostgreSQL 18.4 (estable 18, mantenimiento 17), pgvector 0.8+, PgBouncer 1.x, Patroni 3.x, y PostGIS 3.5+.