Diseño avanzado de bases de datos

"Una base de datos bien diseñada es invisible: el sistema simplemente funciona, rápido y sin errores, y nadie nota que hay una BD ahí."

Qué vas a aprender en este capítulo

Este capítulo reúne técnicas avanzadas que transforman una base de datos funcional en una base de datos de producción: particionamiento para manejar tablas con decenas de millones de filas, vistas materializadas para reportes instantáneos, procedimientos almacenados para lógica de negocio centralizada, búsqueda de texto completo, y seguridad con roles.


5.1 Particionamiento declarativo

💡 Intuición

Una tabla de pedidos con 50 millones de filas es difícil de mantener: los índices son gigantes, el VACUUM tarda horas, y borrar datos viejos requiere DELETE masivos que bloquean la tabla.

El particionamiento divide la tabla en partes más pequeñas (particiones) que el motor maneja como una sola tabla desde la perspectiva de la aplicación. Borrás el dato del mes de enero eliminando una partición — operación instantánea.

📐 Fundamento

Particionamiento declarativo en PostgreSQL (v10+):

-- Tabla padre (no contiene datos directamente)
CREATE TABLE pedidos (
    id          BIGSERIAL,
    fecha       DATE NOT NULL,
    mesa_id     INTEGER,
    total       NUMERIC(10,2),
    estado      TEXT
) PARTITION BY RANGE (fecha);

-- Crear particiones por mes
CREATE TABLE pedidos_2026_01 PARTITION OF pedidos
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');

CREATE TABLE pedidos_2026_02 PARTITION OF pedidos
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');

-- ... etc.

-- Insertar va automáticamente a la partición correcta
INSERT INTO pedidos (fecha, mesa_id, total, estado)
VALUES ('2026-01-15', 3, 8.50, 'CERRADO');
-- → va a pedidos_2026_01

-- Consultar es transparente
SELECT * FROM pedidos WHERE fecha = '2026-01-15';
-- → PostgreSQL consulta solo pedidos_2026_01 (partition pruning)

Tipos de particionamiento:

Tipo Cuándo usar Ejemplo
RANGE Datos con orden natural (fecha, ID secuencial) PARTITION BY RANGE (fecha)
LIST Datos con valores discretos conocidos PARTITION BY LIST (pais)
HASH Distribución uniforme sin orden natural PARTITION BY HASH (cliente_id)

Índices en tablas particionadas:

-- Crear índice en todas las particiones a la vez
CREATE INDEX ON pedidos (fecha, mesa_id);
-- PostgreSQL crea automáticamente el índice en cada partición

-- Ver particiones existentes
SELECT tablename FROM pg_tables 
WHERE tablename LIKE 'pedidos_%';

Archivado y purga de datos:

-- Eliminar un mes de datos de forma INSTANTÁNEA (vs DELETE que tarda horas)
DROP TABLE pedidos_2024_01;  -- millones de filas eliminadas en segundos

-- O mover a almacenamiento de archivo (tablespace más barato)
ALTER TABLE pedidos_2024_01 SET TABLESPACE archivo_frio;

Partition pruning: El planificador de PostgreSQL automáticamente omite las particiones que no pueden contener filas que satisfagan la condición WHERE. Una query WHERE fecha = '2026-01-15' solo escanea pedidos_2026_01, no las otras 23 particiones del año.


5.2 Vistas materializadas

💡 Intuición

Una vista normal es una consulta guardada — cada vez que la consultás, ejecuta el SELECT de nuevo. Útil, pero si el SELECT tarda 30 segundos, la vista también tarda 30 segundos.

Una vista materializada guarda el resultado de la consulta en disco. Consultarla es instantáneo. El trade-off: los datos pueden estar desactualizados hasta que se refresque manualmente (o con un job programado).

📐 Fundamento

-- Crear vista materializada: reporte de ventas por platillo
CREATE MATERIALIZED VIEW ventas_por_platillo AS
SELECT 
    p.id,
    p.nombre,
    p.categoria,
    COUNT(ip.id)                          AS total_pedidos,
    SUM(ip.cantidad)                      AS unidades_vendidas,
    SUM(ip.precio_unit * ip.cantidad)     AS ingresos_total,
    AVG(ip.precio_unit * ip.cantidad)     AS ticket_promedio
FROM items_pedido ip
JOIN platillos p ON ip.platillo_id = p.id
JOIN pedidos ped ON ip.pedido_id = ped.id
WHERE ped.estado = 'CERRADO'
GROUP BY p.id, p.nombre, p.categoria
ORDER BY ingresos_total DESC;

-- Crear índice sobre la vista materializada
CREATE INDEX ON ventas_por_platillo (categoria);

-- Consultar (instantáneo, sin re-ejecutar el SELECT pesado)
SELECT * FROM ventas_por_platillo WHERE categoria = 'pupusa';

-- Refrescar los datos (esto sí ejecuta el SELECT completo)
REFRESH MATERIALIZED VIEW ventas_por_platillo;

-- Refrescar sin bloquear lecturas (PostgreSQL 9.4+)
REFRESH MATERIALIZED VIEW CONCURRENTLY ventas_por_platillo;
-- (requiere un UNIQUE INDEX en la vista)

Estrategias de refresco:

-- Opción 1: Refresco manual desde la aplicación
-- Llamar REFRESH al final de cada jornada (ej: a las 23:00)

-- Opción 2: pg_cron (extensión para jobs programados)
SELECT cron.schedule('refresco-ventas', '0 23 * * *', 
  'REFRESH MATERIALIZED VIEW CONCURRENTLY ventas_por_platillo');

-- Opción 3: Trigger que invalida y marca como "dirty" la vista
-- (requiere lógica adicional en la aplicación)

Vista vs Vista Materializada vs Tabla desnormalizada:

Vista Vista Materializada Tabla desnormalizada
Siempre actualizada Solo al refrescar Solo al refrescar
Velocidad de lectura Igual que la query Muy rápida Muy rápida
Espacio en disco 0 Ocupa espacio Ocupa espacio
Complejidad de actualización Ninguna REFRESH Triggers o app

5.3 Procedimientos almacenados y triggers

💡 Intuición

Un procedimiento almacenado es código que vive en la base de datos — no en la aplicación. Útil para lógica de negocio que siempre debe ejecutarse consistentemente, sin importar qué aplicación accede a la BD.

Un trigger es código que se ejecuta automáticamente cuando ocurre un evento (INSERT, UPDATE, DELETE). Es la única forma de garantizar que algo siempre suceda cuando cambian los datos.

📐 Fundamento

Función / Procedimiento almacenado en PL/pgSQL:

-- Función que registra un pedido con verificación de inventario
CREATE OR REPLACE FUNCTION registrar_pedido(
    p_mesa_id   INTEGER,
    p_mozo_id   INTEGER,
    p_items     JSONB  -- [{platillo_id: 1, cantidad: 2}, ...]
) RETURNS INTEGER AS $$
DECLARE
    v_pedido_id INTEGER;
    v_item      JSONB;
    v_stock     INTEGER;
BEGIN
    -- Crear el pedido
    INSERT INTO pedidos (mesa_id, mozo_id, estado, fecha)
    VALUES (p_mesa_id, p_mozo_id, 'ABIERTO', NOW())
    RETURNING id INTO v_pedido_id;
    
    -- Procesar cada ítem
    FOR v_item IN SELECT * FROM jsonb_array_elements(p_items) LOOP
        -- Verificar stock
        SELECT cantidad INTO v_stock
        FROM inventario
        WHERE platillo_id = (v_item->>'platillo_id')::INTEGER
        FOR UPDATE;  -- lock para evitar race condition
        
        IF v_stock < (v_item->>'cantidad')::INTEGER THEN
            RAISE EXCEPTION 'Stock insuficiente para platillo %', 
                            v_item->>'platillo_id';
        END IF;
        
        -- Insertar ítem
        INSERT INTO items_pedido (pedido_id, platillo_id, cantidad, precio_unit)
        SELECT v_pedido_id, 
               (v_item->>'platillo_id')::INTEGER, 
               (v_item->>'cantidad')::INTEGER,
               precio
        FROM platillos WHERE id = (v_item->>'platillo_id')::INTEGER;
        
        -- Descontar inventario
        UPDATE inventario 
        SET cantidad = cantidad - (v_item->>'cantidad')::INTEGER
        WHERE platillo_id = (v_item->>'platillo_id')::INTEGER;
    END LOOP;
    
    RETURN v_pedido_id;
    
EXCEPTION
    WHEN OTHERS THEN
        ROLLBACK;
        RAISE;
END;
$$ LANGUAGE plpgsql;

-- Llamar la función
SELECT registrar_pedido(3, 7, '[{"platillo_id": 1, "cantidad": 2}]'::jsonb);

Trigger — auditoría automática:

-- Tabla de auditoría
CREATE TABLE auditoria_pedidos (
    id          BIGSERIAL PRIMARY KEY,
    pedido_id   INTEGER,
    campo       TEXT,
    valor_antes TEXT,
    valor_despues TEXT,
    usuario     TEXT,
    fecha       TIMESTAMP DEFAULT NOW()
);

-- Función del trigger
CREATE OR REPLACE FUNCTION auditar_cambio_pedido()
RETURNS TRIGGER AS $$
BEGIN
    IF OLD.estado IS DISTINCT FROM NEW.estado THEN
        INSERT INTO auditoria_pedidos 
            (pedido_id, campo, valor_antes, valor_despues, usuario)
        VALUES 
            (NEW.id, 'estado', OLD.estado, NEW.estado, current_user);
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Asociar el trigger a la tabla
CREATE TRIGGER tg_auditar_pedido
AFTER UPDATE ON pedidos
FOR EACH ROW
EXECUTE FUNCTION auditar_cambio_pedido();

-- Ahora cualquier UPDATE en pedidos queda registrado
UPDATE pedidos SET estado = 'CERRADO' WHERE id = 123;
-- → auditoria_pedidos recibe automáticamente una fila

5.4 Búsqueda full-text

💡 Intuición

WHERE nombre LIKE '%pupusa%' funciona, pero no entiende el idioma: no encuentra "pupusas" si buscás "pupusa", no entiende sinónimos, y es lento sin índice.

La búsqueda full-text de PostgreSQL tokeniza el texto, aplica stemming (reduce palabras a su raíz), y usa índices GIN para búsquedas ultrarrápidas en texto libre.

📐 Fundamento

-- Preparar la tabla para búsqueda full-text
ALTER TABLE platillos ADD COLUMN ts_busqueda TSVECTOR;

-- Generar el vector de búsqueda (en español)
UPDATE platillos 
SET ts_busqueda = to_tsvector('spanish', nombre || ' ' || COALESCE(descripcion, ''));

-- Crear índice GIN para búsqueda rápida
CREATE INDEX idx_platillos_fts ON platillos USING GIN (ts_busqueda);

-- Mantener actualizado con trigger
CREATE OR REPLACE FUNCTION actualizar_ts_platillo()
RETURNS TRIGGER AS $$
BEGIN
    NEW.ts_busqueda := to_tsvector('spanish', 
        NEW.nombre || ' ' || COALESCE(NEW.descripcion, ''));
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER tg_ts_platillo
BEFORE INSERT OR UPDATE ON platillos
FOR EACH ROW EXECUTE FUNCTION actualizar_ts_platillo();

-- Buscar
SELECT nombre, precio,
       ts_rank(ts_busqueda, query) AS relevancia
FROM platillos, 
     to_tsquery('spanish', 'pupusa & queso') AS query
WHERE ts_busqueda @@ query
ORDER BY relevancia DESC;

-- Busca: documentos que contienen "pupusa" Y "queso"
-- Maneja automáticamente: "pupusas", "quesos", acentos

Operadores en to_tsquery:

to_tsquery('pupusa & queso')    -- Y
to_tsquery('pupusa | loroco')   -- O
to_tsquery('!picante')          -- NOT
to_tsquery('pupusa:*')          -- prefijo (pupusas, pupusería...)

5.5 Seguridad: roles y permisos

📐 Fundamento

El principio de mínimo privilegio: cada usuario y aplicación debe tener solo los permisos necesarios para su función.

-- Crear roles
CREATE ROLE app_lectura;
CREATE ROLE app_escritura;
CREATE ROLE app_admin;

-- Asignar permisos
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_lectura;

GRANT SELECT, INSERT, UPDATE ON pedidos, items_pedido, facturas 
TO app_escritura;

GRANT app_lectura TO app_escritura;  -- escritura hereda lectura

GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO app_admin;

-- Crear usuarios y asignar roles
CREATE USER mozo_app WITH PASSWORD 'password_seguro';
GRANT app_escritura TO mozo_app;

CREATE USER reporte_app WITH PASSWORD 'otro_password';
GRANT app_lectura TO reporte_app;

-- Row Level Security (RLS) — cada local solo ve sus propios datos
ALTER TABLE pedidos ENABLE ROW LEVEL SECURITY;

CREATE POLICY local_policy ON pedidos
    USING (local_id = current_setting('app.local_id')::INTEGER);

-- Cuando la app conecta:
SET app.local_id = '2';
-- Ahora SELECT * FROM pedidos solo devuelve pedidos del local 2

Mejores prácticas de seguridad en BD:

  1. Nunca conectar como superusuario desde la aplicación.
  2. Usar SSL/TLS para todas las conexiones a la BD en producción.
  3. Rotar contraseñas periódicamente o usar autenticación certificada.
  4. Auditar accesos — registrar quién accede a qué datos sensibles.
  5. Nunca concatenar SQL — siempre usar prepared statements/parámetros.
  6. Cifrar datos sensibles (números de tarjeta, DUIs) con pgcrypto.
-- Cifrar datos sensibles
CREATE EXTENSION IF NOT EXISTS pgcrypto;

INSERT INTO clientes (nombre, dui_cifrado)
VALUES ('Ana', pgp_sym_encrypt('01234567-8', 'clave_secreta'));

-- Descifrar (solo si se tiene la clave)
SELECT nombre, pgp_sym_decrypt(dui_cifrado::bytea, 'clave_secreta')
FROM clientes WHERE id = 1;

🛠️ En la práctica

La Esquina en producción: lista de verificación final

Al llevar La Esquina a producción, esta es la checklist de la base de datos:

Rendimiento:

  • [ ] Índices en todas las columnas usadas en WHERE, JOIN y ORDER BY frecuentes
  • [ ] EXPLAIN ANALYZE en todas las queries críticas (< 100ms)
  • [ ] Vistas materializadas para reportes de fin del día
  • [ ] Particionamiento de pedidos por mes (para archivado)

Alta disponibilidad:

  • [ ] Réplica síncrona para pedidos y pagos
  • [ ] Réplica asíncrona para reportes
  • [ ] Backup automático diario con prueba de restauración mensual
  • [ ] Failover automático configurado (Patroni o pgBouncer)

Seguridad:

  • [ ] Roles separados: app_lectura, app_escritura, app_admin
  • [ ] SSL/TLS habilitado
  • [ ] RLS por local
  • [ ] Auditoría de cambios en facturas y pagos
  • [ ] DUIs y datos sensibles cifrados

Monitoreo:

  • [ ] Alertas en replication lag > 30s
  • [ ] Alertas en queries lentas > 1s
  • [ ] Dashboard de métricas (pg_stat_statements, pgBadger)
  • [ ] VACUUM configurado correctamente (no solo autovacuum default)

5.6 Ejercicios

✏️ Ejercicio 5.1 — Particionamiento

Diseñá el esquema de particionamiento para la tabla eventos_sistema que registra:

  • id BIGSERIAL
  • tipo TEXT (valores: 'LOGIN', 'PEDIDO', 'PAGO', 'ERROR')
  • fecha TIMESTAMP
  • usuario_id INTEGER
  • metadata JSONB

Requisitos:

  • Los eventos se consultan casi siempre por rango de fechas.
  • Los eventos de ERROR se consultan con mucha frecuencia independientemente de la fecha.
  • Los eventos más viejos de 6 meses se archivan y raramente se consultan.

Elegí la estrategia de particionamiento e indicá cómo manejarías el archivado.

✏️ Ejercicio 5.2 — Trigger de auditoría

Escribí un trigger en PL/pgSQL que:

  1. Se ejecute AFTER INSERT OR UPDATE OR DELETE en la tabla facturas.
  2. Registre en auditoria_facturas: el tipo de operación (INSERT/UPDATE/DELETE), el ID de la factura, el usuario de la BD, la fecha/hora, y el monto anterior y nuevo (en el caso de UPDATE).
  3. En caso de DELETE, registre el monto de la factura eliminada.

5.7 Cierre del libro

Este libro cubrió los temas de Bases de Datos II que transforman el conocimiento de SQL básico en habilidades de diseño para sistemas de producción reales:

  1. Transacciones y ACID — garantizar integridad aunque el sistema falle.
  2. Índices y optimización — hacer que las consultas sean rápidas a escala.
  3. Replicación y sharding — alta disponibilidad y escalabilidad horizontal.
  4. NoSQL — elegir la herramienta correcta para cada tipo de dato.
  5. Diseño avanzado — particionamiento, vistas materializadas, procedimientos, seguridad.

La Esquina pasó de ser una pupusería con una base de datos frágil en un solo servidor a tener una arquitectura que puede manejar tres locales, miles de pedidos diarios, y nunca perder un centavo — ni cuando el servidor principal falla.


5.8 Para profundizar

5.X Mini-proyecto integrador

🏗️ Proyecto final — Tu base de datos bajo carga real

Alcance: tomá el esquema que diseñaste en BD I (catálogo de pupusería, biblioteca, lo que sea), poblálo con al menos 1M de filas, y exprimilo aplicando todo lo del libro.

Entregables:

  1. Carga inicial con pg_bulk o COPY desde CSV (cap. 2 — performance).
  2. 3 índices estratégicos justificados con EXPLAIN ANALYZE antes/después (cap. 2).
  3. 1 partición declarativa sobre la tabla más grande (cap. 5).
  4. 1 vista materializada para un dashboard de KPIs (cap. 5).
  5. Backup full + WAL de PostgreSQL y prueba de restauración a un punto en el tiempo (cap. 1 + cap. 5).
  6. Mini-test de aislamiento: dos sesiones con SERIALIZABLE simulando una compra concurrente; documentá qué pasa (cap. 1).

Criterio de éxito: podés explicar, viendo solo EXPLAIN, por qué tus consultas tardan lo que tardan.

Tiempo estimado: 2 semanas, 5-8 hs/semana.


Definiciones nuevas: particionamiento declarativo, partition pruning, vista materializada, REFRESH MATERIALIZED VIEW, PL/pgSQL, procedimiento almacenado, trigger, TSVECTOR, TSQUERY, GIN, stemming, Row Level Security, pgcrypto, tablespace.