Saltar al contenido principal
Jordi Izquierdo Montalbán
Lo que practico

Fundamentos

Bases de datos

Elijo el motor según el dato: relacional, vectorial, grafo o clave-valor.

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:

  1. Corrección: los datos no pueden quedar en un estado imposible (integridad).
  2. Disponibilidad y recuperación: qué pasa cuando algo falla, y cuánto se tarda en volver.
  3. Rendimiento: las consultas reales responden en el tiempo que necesita el producto.
  4. 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.

Normalizar separa cada hecho en su tabla y lo une con claves.

Las primeras formas normales, con un ejemplo:

Forma normalReglaEjemplo de violación
1FNValores atómicos, sin listas ni grupos repetidos en una celdaUna columna telefonos con varios números separados por comas
2FNCada atributo depende de toda la clave, no de una parteEn una línea de pedido con clave (pedido, producto), guardar el nombre del producto
3FNLos atributos no clave dependen solo de la clave, no de otros atributos no claveGuardar ciudad y codigo_postal en clientes cuando uno determina al otro
BCNFTodo determinante es una clave candidataCasos 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.

TipoSirve paraNotas
B-treeIgualdad, rangos y ordenaciónEl tipo por defecto; cubre la mayoría de los casos
HashSolo igualdad exactaMenos versátil; rara vez compensa frente a B-tree
GINValores con varios elementos: arrays, jsonb, texto completoMuy bueno en lectura; más lento de actualizar
GiST / SP-GiSTDatos geométricos, rangos, vecindadBase de la búsqueda espacial
BRINTablas enormes con datos físicamente ordenados (por ejemplo, por fecha)Ocupa muy poco; es poco preciso
CompuestoConsultas que filtran por varias columnas a la vezEl orden de las columnas importa
ParcialSolo un subconjunto de filasMás pequeño y más rápido para ese caso
Sobre expresiónConsultas por una función de la columnaPor ejemplo, lower(email)
CubrienteResponder la consulta solo desde el índiceSe 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.

El plan se lee de dentro hacia fuera: primero cómo se leen los datos, luego cómo se combinan.
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 ms

Qué mirar, por orden:

  • Tipo de lectura: un Seq Scan sobre 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 rows estimado 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 Sort costoso o un Nested Loop con 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.

Varias operaciones que se aplican todas o ninguna.
  • 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 estaba

Anomalías de concurrencia

AnomalíaQué ocurre
Lectura suciaSe lee un cambio de otra transacción que aún no se ha confirmado (y quizá se deshaga)
Lectura no repetibleSe lee la misma fila dos veces y el valor es distinto porque otra transacción lo confirmó entre medias
Lectura fantasmaSe 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 perdidaDos transacciones leen, modifican y escriben el mismo valor y una pisa a la otra

Niveles de aislamiento del estándar SQL

Cuanto más estricto el nivel, menos anomalías y más coste de coordinación.
NivelLectura suciaNo repetibleFantasma
Read uncommittedPosiblePosiblePosible
Read committedNoPosiblePosible
Repeatable readNoNoPosible según el motor
SerializableNoNoNo

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 UPDATE bloquea 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 version o 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.
Cada transacción ve una instantánea coherente; lectores y escritores no se bloquean entre sí.
-- 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.

EnfoquePrioridadCaracterística
ACIDConsistencia fuerteTransacciones con garantías estrictas; típico de sistemas relacionales
BASEDisponibilidadBasically 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.

Las escrituras van al primario; las lecturas pueden repartirse entre réplicas con algo de retraso.
  • 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

Particionar divide una tabla dentro de un servidor; el sharding reparte los datos entre servidores.
-- 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.

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.
El RPO fija cuántos datos se pueden perder; el RTO, cuánto se puede tardar en volver.
MecanismoProtege deNo protege de
RéplicaFallo de un nodoBorrado o corrupción lógica (se replica igual)
Copia completa periódicaPérdida totalCambios posteriores a la copia
Copia + archivo continuo del registroPérdida con punto de restauración arbitrario (PITR)Fallos si la copia está en el mismo sitio
Copia en otra ubicaciónDesastre del entornoNada 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

Cada familia organiza los datos de una forma distinta y responde bien a un tipo de pregunta.
FamiliaModeloEncaja enCuidado con
DocumentalDocumentos JSON con esquema flexibleCatálogos, contenido con forma variable, agregados leídos enterosLas relaciones y las transacciones entre documentos
Clave-valorValor opaco por claveSesiones, caché, contadores, límites de uso, colas ligerasSolo se consulta por clave
Columnar (familia de columnas)Filas dispersas agrupadas por claveVolúmenes de escritura muy altos con acceso por claveModelar según las consultas, no según las entidades
Columnar analíticoAlmacenamiento por columnasAgregaciones sobre muchas filas, informesMal para escrituras fila a fila
GrafoNodos y relacionesRecomendaciones, redes, rutas, dependenciasAgregados masivos
Series temporalesMedidas con marca de tiempoMétricas, sensores, registrosUso muy especializado
BúsquedaÍndice invertido de textoBúsqueda de texto completo, filtros y facetasNo es fuente de verdad: se alimenta desde otra base
VectorialVectores de embeddingsSimilitud semántica, recuperación para RAGAproximada; 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.

Un índice aproximado cambia algo de exactitud por mucha velocidad.
  • 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

ProblemaSíntomaRemedio
N+1Una consulta por fila de un listadoUna sola consulta con JOIN o carga por lotes
Falta de índiceSeq Scan en tabla grande con filtro selectivoÍndice sobre las columnas del filtro
SELECT *Transferencia de columnas inútiles, planes peoresPedir solo las columnas necesarias
Paginación con OFFSET altoCada página es más lenta que la anteriorPaginación por clave (keyset)
Transacciones largasBloqueos, versiones sin limpiarMantenerlas cortas; sin llamadas de red dentro
Estadísticas desfasadasPlanes malos tras una carga masivaANALYZE y autovacuum bien configurado
Copias sin probarDescubrir el fallo el día del incidenteSimulacros 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 LOCKED ni 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.