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 | Sí | 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:
- Nunca conectar como superusuario desde la aplicación.
- Usar SSL/TLS para todas las conexiones a la BD en producción.
- Rotar contraseñas periódicamente o usar autenticación certificada.
- Auditar accesos — registrar quién accede a qué datos sensibles.
- Nunca concatenar SQL — siempre usar prepared statements/parámetros.
- 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
pedidospor 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 BIGSERIALtipo TEXT(valores: 'LOGIN', 'PEDIDO', 'PAGO', 'ERROR')fecha TIMESTAMPusuario_id INTEGERmetadata 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.
Solución
Estrategia: RANGE por fecha (mensual)
CREATE TABLE eventos_sistema (
id BIGSERIAL,
tipo TEXT,
fecha TIMESTAMP NOT NULL,
usuario_id INTEGER,
metadata JSONB
) PARTITION BY RANGE (fecha);
-- Partición por mes
CREATE TABLE eventos_2026_05 PARTITION OF eventos_sistema
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
Para eventos de ERROR frecuentes: Crear un índice parcial en cada partición:
CREATE INDEX ON eventos_2026_05 (fecha) WHERE tipo = 'ERROR';
Archivado de 6 meses: Job mensual que:
- Mueve la partición vieja a un tablespace de almacenamiento barato.
- O la exporta a S3/archivo y hace DROP TABLE.
-- Mover a tablespace de archivo
ALTER TABLE eventos_2025_11 SET TABLESPACE tablespace_frio;
-- La tabla sigue accesible pero en almacenamiento más barato
Justificación de RANGE sobre LIST(tipo):
- LIST(tipo) crearía 4 particiones que nunca crecen ni se archivan.
- RANGE(fecha) permite archivado eficiente y consultas por rango que son las más comunes.
✏️ Ejercicio 5.2 — Trigger de auditoría
Escribí un trigger en PL/pgSQL que:
- Se ejecute AFTER INSERT OR UPDATE OR DELETE en la tabla
facturas. - 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). - En caso de DELETE, registre el monto de la factura eliminada.
Solución
CREATE TABLE auditoria_facturas (
id BIGSERIAL PRIMARY KEY,
operacion TEXT NOT NULL,
factura_id INTEGER,
monto_antes NUMERIC(10,2),
monto_despues NUMERIC(10,2),
usuario TEXT NOT NULL DEFAULT current_user,
fecha TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE OR REPLACE FUNCTION fn_auditar_factura()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO auditoria_facturas (operacion, factura_id, monto_antes, monto_despues)
VALUES ('INSERT', NEW.id, NULL, NEW.total_con_iva);
ELSIF TG_OP = 'UPDATE' THEN
INSERT INTO auditoria_facturas (operacion, factura_id, monto_antes, monto_despues)
VALUES ('UPDATE', NEW.id, OLD.total_con_iva, NEW.total_con_iva);
ELSIF TG_OP = 'DELETE' THEN
INSERT INTO auditoria_facturas (operacion, factura_id, monto_antes, monto_despues)
VALUES ('DELETE', OLD.id, OLD.total_con_iva, NULL);
END IF;
-- Para DELETE retornar OLD, para INSERT/UPDATE retornar NEW
IF TG_OP = 'DELETE' THEN
RETURN OLD;
ELSE
RETURN NEW;
END IF;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER tg_auditar_factura
AFTER INSERT OR UPDATE OR DELETE ON facturas
FOR EACH ROW EXECUTE FUNCTION fn_auditar_factura();
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:
- Transacciones y ACID — garantizar integridad aunque el sistema falle.
- Índices y optimización — hacer que las consultas sean rápidas a escala.
- Replicación y sharding — alta disponibilidad y escalabilidad horizontal.
- NoSQL — elegir la herramienta correcta para cada tipo de dato.
- 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
- Kleppmann, Designing Data-Intensive Applications — el libro más importante sobre sistemas de datos modernos.
- PostgreSQL docs — documentación oficial completa con ejemplos.
- pgexercises.com — ejercicios de SQL interactivos.
- Sistemas distribuidos — próximo libro de la carrera (ciclo 6).
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:
- Carga inicial con
pg_bulkoCOPYdesde CSV (cap. 2 — performance). - 3 índices estratégicos justificados con
EXPLAIN ANALYZEantes/después (cap. 2). - 1 partición declarativa sobre la tabla más grande (cap. 5).
- 1 vista materializada para un dashboard de KPIs (cap. 5).
- Backup full + WAL de PostgreSQL y prueba de restauración a un punto en el tiempo (cap. 1 + cap. 5).
- Mini-test de aislamiento: dos sesiones con
SERIALIZABLEsimulando 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.