El dato como activo duradero
El código se despliega y se sustituye; los datos permanecen. Un esquema mal pensado se paga durante años en consultas lentas, informes contradictorios y migraciones arriesgadas. Por eso el trabajo con bases de datos empieza antes de elegir motor: se parte de qué hechos hay que guardar, qué garantías necesitan y qué preguntas se harán sobre ellos.
Un criterio práctico para ordenar las decisiones:
- Corrección: los datos no pueden quedar en un estado imposible (integridad).
- Disponibilidad y recuperación: qué pasa cuando algo falla, y cuánto se tarda en volver.
- Rendimiento: las consultas reales responden en el tiempo que necesita el producto.
- Evolución: el esquema puede cambiar sin parar el servicio.
Modelado relacional y normalización
El modelo relacional organiza los datos en tablas (relaciones) formadas por filas y columnas, enlazadas mediante claves. La normalización es el proceso de eliminar redundancias para que cada hecho se guarde una sola vez.
Cliente y producto se repiten en cada fila: anomalías de actualización.
Las primeras formas normales, con un ejemplo:
| Forma normal | Regla | Ejemplo de violación |
|---|---|---|
| 1FN | Valores atómicos, sin listas ni grupos repetidos en una celda | Una columna telefonos con varios números separados por comas |
| 2FN | Cada atributo depende de toda la clave, no de una parte | En una línea de pedido con clave (pedido, producto), guardar el nombre del producto |
| 3FN | Los atributos no clave dependen solo de la clave, no de otros atributos no clave | Guardar ciudad y codigo_postal en clientes cuando uno determina al otro |
| BCNF | Todo determinante es una clave candidata | Casos con varias claves candidatas solapadas |
Un esquema mínimo normalizado con sus restricciones:
CREATE TABLE clientes (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
nombre text NOT NULL,
creado_en timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE pedidos (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
cliente_id bigint NOT NULL REFERENCES clientes (id),
estado text NOT NULL CHECK (estado IN ('nuevo', 'pagado', 'enviado', 'cancelado')),
total_cent integer NOT NULL CHECK (total_cent >= 0),
creado_en timestamptz NOT NULL DEFAULT now()
);Las reglas importantes (unicidad, no nulo, clave foránea, CHECK) viven en la base de datos, no solo en la aplicación: la aplicación cambia y puede haber varios clientes que escriban; la base de datos es el último guardián.
Claves: naturales y sustitutas
- La clave natural viene del dominio (un email, un NIF). Puede cambiar, repetirse con matices o tener formatos distintos.
- La clave sustituta (identificador autogenerado o UUID) es estable y no significa nada. Se suele usar como clave primaria y se protege la clave natural con una restricción
UNIQUE. - Dinero: se guarda en enteros de la unidad mínima o en
numeric, nunca en coma flotante. - Fechas:
timestamptz(con zona horaria, almacenado en UTC) en lugar de texto.
Cuándo desnormalizar
Desnormalizar es duplicar datos a propósito para leer más rápido. Solo tiene sentido cuando una medición muestra el problema y existe un mecanismo que mantiene la copia coherente (un trigger, una vista materializada, un proceso que recalcula). Sin ese mecanismo, la copia termina divergiendo.
Índices: qué son y qué tipos hay
Un índice es una estructura auxiliar que permite encontrar filas sin recorrer toda la tabla. Acelera lecturas y encarece escrituras y espacio: cada INSERT, UPDATE o DELETE debe mantenerlos.
| Tipo | Sirve para | Notas |
|---|---|---|
| B-tree | Igualdad, rangos y ordenación | El tipo por defecto; cubre la mayoría de los casos |
| Hash | Solo igualdad exacta | Menos versátil; rara vez compensa frente a B-tree |
| GIN | Valores con varios elementos: arrays, jsonb, texto completo | Muy bueno en lectura; más lento de actualizar |
| GiST / SP-GiST | Datos geométricos, rangos, vecindad | Base de la búsqueda espacial |
| BRIN | Tablas enormes con datos físicamente ordenados (por ejemplo, por fecha) | Ocupa muy poco; es poco preciso |
| Compuesto | Consultas que filtran por varias columnas a la vez | El orden de las columnas importa |
| Parcial | Solo un subconjunto de filas | Más pequeño y más rápido para ese caso |
| Sobre expresión | Consultas por una función de la columna | Por ejemplo, lower(email) |
| Cubriente | Responder la consulta solo desde el índice | Se añaden columnas con INCLUDE |
-- Compuesto: sirve para filtrar por cliente y ordenar por fecha
CREATE INDEX idx_pedidos_cliente_fecha ON pedidos (cliente_id, creado_en DESC);
-- Parcial: solo los pedidos pendientes, que son pocos y los que se consultan
CREATE INDEX idx_pedidos_pendientes ON pedidos (creado_en) WHERE estado = 'nuevo';
-- Sobre expresión: búsqueda de email sin distinguir mayúsculas
CREATE UNIQUE INDEX idx_clientes_email_lower ON clientes (lower(email));
-- GIN sobre jsonb
CREATE INDEX idx_eventos_datos ON eventos USING gin (datos);La regla del prefijo más a la izquierda: un índice sobre (cliente_id, creado_en) sirve para filtrar por cliente_id o por ambos, pero no ayuda a buscar solo por creado_en.
Leer un plan de ejecución
El optimizador decide cómo ejecutar una consulta a partir de estadísticas de la tabla. No hay que adivinar: se pide el plan y se lee.
EXPLAIN lo muestra; EXPLAIN ANALYZE lo ejecuta y compara estimado con real.
Nodos típicos
EXPLAIN (ANALYZE, BUFFERS)
SELECT p.id, p.total_cent
FROM pedidos p
WHERE p.cliente_id = 42
ORDER BY p.creado_en DESC
LIMIT 20;Un resultado representativo (los valores concretos varían):
Limit (cost=0.43..8.20 rows=20 width=16) (actual time=0.03..0.05 rows=20 loops=1)
-> Index Scan using idx_pedidos_cliente_fecha on pedidos p
Index Cond: (cliente_id = 42)
Planning Time: 0.1 ms
Execution Time: 0.1 msQué mirar, por orden:
- Tipo de lectura: un
Seq Scansobre una tabla grande con un filtro muy selectivo suele indicar que falta un índice (o que no se puede usar). - Estimado frente a real: si
rowsestimado y real difieren en órdenes de magnitud, las estadísticas están desfasadas (ANALYZE) o la consulta correlaciona columnas que el optimizador trata como independientes. - Orden y combinación: un
Sortcostoso o unNested Loopcon miles de iteraciones sobre una tabla grande son sospechosos. - Bucles (
loops): el coste mostrado es por iteración; hay que multiplicarlo.
Causas habituales de que un índice no se use: aplicar una función a la columna (por ejemplo, date(creado_en) en el filtro), comparar tipos distintos, usar LIKE con comodín al principio, o que la tabla sea tan pequeña que leerla entera salga más barato.
Transacciones, ACID y aislamiento
Una transacción agrupa operaciones para que se comporten como una sola unidad.
Sí: COMMIT, los cambios quedan guardados. No: ROLLBACK, se deshace todo.
- Atomicidad: o se aplican todas las operaciones o ninguna.
- Consistencia: se respetan las restricciones definidas; la transacción lleva la base de un estado válido a otro válido.
- Aislamiento: las transacciones concurrentes no se interfieren de forma observable.
- Durabilidad: lo confirmado sobrevive a un fallo (se escribe antes en el registro de transacciones).
BEGIN;
UPDATE cuentas SET saldo_cent = saldo_cent - 5000 WHERE id = 1;
UPDATE cuentas SET saldo_cent = saldo_cent + 5000 WHERE id = 2;
COMMIT; -- si algo falla antes, ROLLBACK deja todo como estabaAnomalías de concurrencia
| Anomalía | Qué ocurre |
|---|---|
| Lectura sucia | Se lee un cambio de otra transacción que aún no se ha confirmado (y quizá se deshaga) |
| Lectura no repetible | Se lee la misma fila dos veces y el valor es distinto porque otra transacción lo confirmó entre medias |
| Lectura fantasma | Se repite una consulta por rango y aparecen o desaparecen filas por otra transacción |
| Escritura sesgada (write skew) | Dos transacciones leen el mismo estado, deciden por separado y escriben filas distintas que juntas rompen una regla |
| Actualización perdida | Dos transacciones leen, modifican y escriben el mismo valor y una pisa a la otra |
Niveles de aislamiento del estándar SQL
Más permisivo
Más estricto
| Nivel | Lectura sucia | No repetible | Fantasma |
|---|---|---|---|
| Read uncommitted | Posible | Posible | Posible |
| Read committed | No | Posible | Posible |
| Repeatable read | No | No | Posible según el motor |
| Serializable | No | No | No |
El estándar define lo mínimo; cada motor lo implementa a su manera (algunos ofrecen más garantías de las que exige el nivel). Hay que consultar la documentación del motor concreto. Serializable obliga a reintentar las transacciones que el motor aborta por conflicto: el código cliente debe contemplarlo.
Bloqueos y MVCC
- Bloqueo pesimista: se reserva la fila antes de modificarla.
SELECT ... FOR UPDATEbloquea las filas leídas hasta el final de la transacción. - Control optimista: se lee una versión y se comprueba al escribir que no ha cambiado (columna
versiono marca de tiempo). Si cambió, se reintenta. - MVCC (control de concurrencia multiversión): el motor conserva varias versiones de cada fila y cada transacción ve una instantánea coherente. Los lectores no bloquean a los escritores.
-- Reservar stock sin que dos transacciones lo vendan a la vez
BEGIN;
SELECT stock FROM productos WHERE id = 7 FOR UPDATE;
UPDATE productos SET stock = stock - 1 WHERE id = 7 AND stock > 0;
COMMIT;
-- Cola de trabajos: cada worker toma uno distinto sin esperar
SELECT id FROM trabajos WHERE estado = 'pendiente'
ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED;Un interbloqueo (deadlock) ocurre cuando dos transacciones se esperan mutuamente. El motor aborta una de ellas. Se previene accediendo siempre a los recursos en el mismo orden y manteniendo las transacciones cortas.
ACID frente a BASE y el teorema CAP
En sistemas distribuidos hay un compromiso formulado por el teorema CAP: ante una partición de red, hay que elegir entre consistencia (todas las lecturas ven la última escritura) y disponibilidad (todos los nodos responden). No se pueden tener ambas durante la partición.
| Enfoque | Prioridad | Característica |
|---|---|---|
| ACID | Consistencia fuerte | Transacciones con garantías estrictas; típico de sistemas relacionales |
| BASE | Disponibilidad | Basically Available, Soft state, Eventually consistent: las réplicas convergen con el tiempo |
La consistencia eventual exige diseñar para ella: lecturas que pueden devolver datos algo antiguos, operaciones idempotentes y resolución de conflictos. Elegir BASE no es «gratis»: traslada complejidad a la aplicación.
Replicación, particionado y sharding
Replicación
Mantiene copias de los datos en varios nodos para disponibilidad y escalar lecturas.
Envía el registro de cambios (WAL) a las réplicas.
- Asíncrona: el primario confirma sin esperar a las réplicas. Es rápida, pero un fallo puede perder las últimas escrituras y las réplicas leen con retraso.
- Síncrona: el primario espera la confirmación de al menos una réplica. Más seguro, más latencia.
- Failover: promover una réplica cuando cae el primario. Exige evitar el split-brain (dos nodos creyéndose primarios).
- Leer de una réplica justo después de escribir puede no mostrar el cambio: si importa, esa lectura se hace en el primario.
Particionado y sharding
Un servidor, varias particiones: la poda evita leer las que no hacen falta.
Varios servidores: el enrutado depende de la clave de reparto.
-- Particionado declarativo por rango de fechas
CREATE TABLE eventos (
id bigint NOT NULL,
creado_en timestamptz NOT NULL,
datos jsonb
) PARTITION BY RANGE (creado_en);
CREATE TABLE eventos_2026_01 PARTITION OF eventos
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');El particionado facilita borrar datos antiguos (se elimina una partición entera) y acelera consultas que filtran por la clave de partición. El sharding reparte entre servidores; la clave de reparto es la decisión crítica: una mala clave crea nodos saturados («puntos calientes») y consultas que deben visitar todos los shards. Antes de hacer sharding conviene agotar índices, caché, réplicas de lectura y servidores mayores.
Conexiones: el recurso escaso
Abrir una conexión es caro y el servidor soporta un número limitado. Una aplicación que abre una por petición se cae bajo carga.
- Un pool de conexiones reutiliza un conjunto reducido y reparte las consultas entre ellas.
- Con muchas instancias de la aplicación (sobre todo en entornos sin estado), un pooler externo a nivel de servidor evita agotar las conexiones.
- Dimensionar el pool por la capacidad de la base de datos, no por el número de peticiones concurrentes: más conexiones no son más rendimiento.
- Poner tiempos máximos (de consulta, de espera de bloqueo, de inactividad en transacción) para que una consulta colgada no arrastre al resto.
Migraciones de esquema sin corte
El esquema se versiona como el código, con migraciones ordenadas y reversibles cuando es posible. Los cambios destructivos se hacen en fases con el patrón expandir y contraer: cada paso es compatible con la versión anterior y la siguiente de la aplicación.
-- 1. Expandir: añadir la columna como nula (rápido, sin reescribir la tabla)
ALTER TABLE clientes ADD COLUMN nombre_completo text;
-- 3. Rellenar por lotes pequeños para no bloquear
UPDATE clientes SET nombre_completo = nombre || ' ' || apellidos
WHERE id BETWEEN 1 AND 10000 AND nombre_completo IS NULL;
-- Índice sin bloquear las escrituras
CREATE INDEX CONCURRENTLY idx_clientes_nombre_completo ON clientes (nombre_completo);
-- 5. Contraer, cuando ninguna versión de la app usa las columnas viejas
ALTER TABLE clientes DROP COLUMN nombre, DROP COLUMN apellidos;Operaciones que suelen bloquear la tabla (según el motor y la versión): añadir una columna con valor por defecto en motores antiguos, cambiar el tipo de una columna, crear un índice sin la opción concurrente, añadir una restricción validando todas las filas. Hay que conocer el comportamiento del motor y probar la migración con un volumen realista.
Copias de seguridad y recuperación
Dos objetivos definen la estrategia:
- RPO (Recovery Point Objective): cuántos datos se pueden perder, medido en tiempo.
- RTO (Recovery Time Objective): cuánto tiempo puede estar el servicio caído.
Una copia que no se ha restaurado nunca es una hipótesis.
Objetivos
| Mecanismo | Protege de | No protege de |
|---|---|---|
| Réplica | Fallo de un nodo | Borrado o corrupción lógica (se replica igual) |
| Copia completa periódica | Pérdida total | Cambios posteriores a la copia |
| Copia + archivo continuo del registro | Pérdida con punto de restauración arbitrario (PITR) | Fallos si la copia está en el mismo sitio |
| Copia en otra ubicación | Desastre del entorno | Nada que no se haya probado antes |
Lo que separa un plan real de uno aparente: copias fuera del entorno principal, cifradas, con control de acceso distinto, y restauraciones probadas con regularidad midiendo cuánto se tarda de verdad.
Familias NoSQL y cuándo elegirlas
Datos operativos
Datos especializados
| Familia | Modelo | Encaja en | Cuidado con |
|---|---|---|---|
| Documental | Documentos JSON con esquema flexible | Catálogos, contenido con forma variable, agregados leídos enteros | Las relaciones y las transacciones entre documentos |
| Clave-valor | Valor opaco por clave | Sesiones, caché, contadores, límites de uso, colas ligeras | Solo se consulta por clave |
| Columnar (familia de columnas) | Filas dispersas agrupadas por clave | Volúmenes de escritura muy altos con acceso por clave | Modelar según las consultas, no según las entidades |
| Columnar analítico | Almacenamiento por columnas | Agregaciones sobre muchas filas, informes | Mal para escrituras fila a fila |
| Grafo | Nodos y relaciones | Recomendaciones, redes, rutas, dependencias | Agregados masivos |
| Series temporales | Medidas con marca de tiempo | Métricas, sensores, registros | Uso muy especializado |
| Búsqueda | Índice invertido de texto | Búsqueda de texto completo, filtros y facetas | No es fuente de verdad: se alimenta desde otra base |
| Vectorial | Vectores de embeddings | Similitud semántica, recuperación para RAG | Aproximada; no sirve para exactitud |
Regla de partida: una base relacional bien modelada cubre la mayoría de los casos, incluido JSON (jsonb) y búsqueda de texto básica. Se añade otro motor cuando hay una necesidad medida que el relacional no cubre, asumiendo el coste de operar y sincronizar dos sistemas.
Índices vectoriales
Un embedding es un vector numérico que representa el significado de un texto. Buscar los más parecidos de forma exacta exige comparar con todos; los índices aproximados lo evitan.
- HNSW: grafo jerárquico de vecinos. Muy buena velocidad y precisión; consume más memoria y tarda más en construirse.
- IVF: divide el espacio en grupos (celdas) y busca solo en los más cercanos a la consulta. Más ligero; requiere entrenar los centros y ajustar cuántas celdas se visitan.
- Métricas de distancia: coseno, producto escalar, euclídea; debe coincidir con la que usa el modelo de embeddings.
- Se combina con filtros por metadatos (usuario, fecha) y con búsqueda textual para mejores resultados.
-- Con una extensión vectorial en PostgreSQL
CREATE TABLE fragmentos (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
texto text NOT NULL,
embedding vector(768)
);
CREATE INDEX ON fragmentos USING hnsw (embedding vector_cosine_ops);
SELECT id, texto FROM fragmentos
ORDER BY embedding <=> $1 -- distancia coseno al vector de la consulta
LIMIT 5;Seguridad de los datos
- Mínimo privilegio: la aplicación se conecta con un rol que solo puede hacer lo que necesita; las migraciones usan otro rol distinto; nadie usa el superusuario en producción.
- Consultas parametrizadas siempre. Nunca se construye SQL concatenando entradas.
- Cifrado en tránsito (TLS) y en reposo (disco o columnas sensibles).
- Secretos de conexión fuera del código, rotables.
- Seguridad a nivel de fila cuando varios inquilinos comparten tablas.
- Auditoría de accesos privilegiados y cambios de esquema.
// Vulnerable: la entrada se convierte en parte de la consulta
db.query("SELECT * FROM clientes WHERE email = '" + email + "'");
// Correcto: el valor viaja aparte, el motor nunca lo interpreta como SQL
db.query("SELECT * FROM clientes WHERE email = $1", [email]);-- Rol de aplicación con permisos mínimos
CREATE ROLE app_web LOGIN;
GRANT SELECT, INSERT, UPDATE ON clientes, pedidos TO app_web;
-- sin DELETE, sin DDL, sin acceso a tablas internas
-- Seguridad a nivel de fila: cada inquilino ve lo suyo
ALTER TABLE pedidos ENABLE ROW LEVEL SECURITY;
CREATE POLICY por_inquilino ON pedidos
USING (inquilino_id = current_setting('app.inquilino')::bigint);Problemas habituales y cómo detectarlos
| Problema | Síntoma | Remedio |
|---|---|---|
| N+1 | Una consulta por fila de un listado | Una sola consulta con JOIN o carga por lotes |
| Falta de índice | Seq Scan en tabla grande con filtro selectivo | Índice sobre las columnas del filtro |
SELECT * | Transferencia de columnas inútiles, planes peores | Pedir solo las columnas necesarias |
Paginación con OFFSET alto | Cada página es más lenta que la anterior | Paginación por clave (keyset) |
| Transacciones largas | Bloqueos, versiones sin limpiar | Mantenerlas cortas; sin llamadas de red dentro |
| Estadísticas desfasadas | Planes malos tras una carga masiva | ANALYZE y autovacuum bien configurado |
| Copias sin probar | Descubrir el fallo el día del incidente | Simulacros de restauración periódicos |
-- Paginación por clave: coste constante en cualquier página
SELECT id, creado_en FROM pedidos
WHERE (creado_en, id) < ($1, $2)
ORDER BY creado_en DESC, id DESC
LIMIT 20;Lista de comprobación
- Cada tabla tiene clave primaria y las relaciones tienen clave foránea.
- Las reglas de negocio críticas están como restricciones en la base de datos.
- Hay índices para las consultas frecuentes y se han revisado con
EXPLAIN. - Las transacciones son cortas y se conoce el nivel de aislamiento que se usa.
- Las migraciones son versionadas, probadas con datos realistas y compatibles con la versión anterior.
- Existen pool de conexiones y tiempos máximos configurados.
- Las copias tienen RPO y RTO definidos, están cifradas, fuera del entorno principal y se restauran de forma periódica.
- La aplicación usa un rol de mínimo privilegio y consultas parametrizadas.
- Se monitorizan consultas lentas, bloqueos, retraso de réplicas y espacio en disco.
Antipatrones
- Guardar listas separadas por comas o JSON donde hay relaciones que se consultan y se unen.
- Usar la base de datos como cola sin
SKIP LOCKEDni control de reintentos. - Una tabla genérica clave-valor (EAV) para evitar diseñar el esquema.
- Confiar en que la aplicación valide la integridad y no declarar restricciones.
- Elegir NoSQL por moda y reimplementar a mano uniones y transacciones.
- Replicar y creer que eso ya es una copia de seguridad.