Saltar a contenido

Bases de datos (relacionales)

De un vistazo

Materia: Capa 2 · Dar forma al sistema · Tiempo de lectura: ~40 min · Requisitos previos: Capa 0 (estructuras de datos y complejidad: qué es un árbol balanceado, qué significa O(log n) frente a O(n)) y Capa 1.

En una frase: vas a entender por qué casi todo el software serio guarda sus datos en tablas con reglas estrictas, cómo un índice convierte una búsqueda de O(n) en O(log n) usando exactamente el mismo árbol balanceado que viste en Capa 0, qué te garantiza de verdad una transacción ACID, y cuándo NoSQL es la respuesta correcta en vez de la moda.

Por qué esto importa

Sabes hacer SELECT * FROM usuarios. Funciona. Entonces, ¿por qué molestarse en entender qué pasa por debajo?

Porque tarde o temprano aparecen problemas que no puedes resolver desde el ORM ni copiando un query de Stack Overflow, solo desde entender el motor:

  • ¿Por qué esta consulta que iba en 5 ms tarda ahora 8 segundos, si la tabla "solo" ha crecido a 3 millones de filas y el código es idéntico?
  • ¿Por qué mi listado de 50 pedidos lanza 451 consultas a la base de datos y tumba el servidor?
  • ¿Por qué dos usuarios comprando la última entrada a la vez acaban comprándola los dos, y el stock se queda en -1?
  • ¿Por qué a veces un UPDATE "desaparece" como si otro lo hubiera pisado?
  • ¿Debería usar MongoDB para esto o me estoy metiendo en un lío que pagaré en seis meses?

Todas tienen la misma raíz: cómo una base de datos organiza los datos en disco, cómo los encuentra rápido, y qué garantías te da (y cuáles no) cuando muchas cosas pasan a la vez. Sin este modelo mental, cada una es un misterio que "arreglas" a base de reintentos y superstición. Con él, son consecuencias predecibles. Ese es el salto de "sé escribir SQL" a "entiendo qué le estoy pidiendo a la máquina".

Intuición primero: el archivo de fichas y el índice del libro

Imagina un archivo de fichas de cartón enorme, como el de una biblioteca antigua. Cada ficha es una fila: un cliente, un pedido, una factura. Todas las fichas de clientes tienen los mismos campos rellenos (nombre, email, teléfono): esa es una tabla, y su plantilla es el esquema.

Ahora, dos operaciones que haces todo el rato con ese archivo:

  • "Dame la ficha del cliente número 4.012". Fácil si están ordenadas por número: vas casi directo.
  • "Dame la ficha del cliente cuyo email es ana@ejemplo.com". Aquí, si no hay más ayuda, te toca mirar ficha por ficha desde el principio hasta encontrarla. Con 200 fichas, un rato. Con 3 millones, te jubilas antes.

La solución que inventaron los bibliotecarios hace un siglo es la misma que usa una base de datos: un índice. Un cuadernillo aparte, ordenado alfabéticamente por email, donde cada entrada dice "email → está en la ficha número tal". Buscar en algo ordenado no es mirar uno a uno: es abrir por la mitad, ver si te has pasado o quedado corto, y repetir. Eso es una búsqueda binaria, y es exactamente el árbol balanceado de Capa 0: O(log n) en vez de O(n).

graph LR
  Q["Buscar por email<br/>ana@ejemplo.com"] --> D{"¿Hay índice<br/>por email?"}
  D -->|"No"| SCAN["Full scan:<br/>mirar las 3.000.000 fichas<br/>una a una · O(n)"]
  D -->|"Sí"| IDX["Buscar en el índice ordenado<br/>(árbol balanceado)<br/>~22 saltos · O(log n)"]
  IDX --> ROW["Ir directo a la ficha"]

Guarda esta imagen: tabla = archivo de fichas, índice = cuadernillo ordenado aparte. De ella cuelga casi toda la lección. El resto es entender qué reglas ponemos sobre las fichas (modelado), cómo garantizamos que no se corrompan cuando muchos escriben a la vez (transacciones), y qué pasa cuando decidimos tirar las reglas por la borda (NoSQL).

El detalle

1. Por qué existe el modelo relacional y qué problema resolvió

Antes de 1970, los datos se guardaban en estructuras jerárquicas o de red: para leer un dato tenías que saber el camino físico hasta él (recorre esta lista, salta a este puntero). El código de la aplicación estaba casado con la forma en que los bytes estaban colocados en el disco. Cambiar el almacenamiento rompía los programas.

Edgar Codd, en IBM, propuso en 1970 una idea que suena obvia hoy y fue revolucionaria entonces: separar el "qué quiero" del "cómo se guarda". Los datos son relaciones (tablas) con filas y columnas, y tú pides lo que quieres de forma declarativa —describes el resultado, no el camino— y el motor decide cómo obtenerlo:

SELECT nombre, email FROM clientes WHERE ciudad = 'Madrid';

No dices "abre este fichero, salta 4.000 bytes, sigue este puntero". Dices qué. El motor tiene un componente, el planificador de consultas (query planner), que decide el cómo: si usar un índice o escanear la tabla, en qué orden juntar las tablas, etc. Esta separación es la razón por la que puedes añadir un índice y acelerar una consulta sin tocar una línea de código de aplicación. El motor cambia el plan; tú no te enteras.

El modelo se apoya en tres patas:

  • Tablas (relaciones): conjuntos de filas con las mismas columnas.
  • Álgebra relacional: una base matemática (uniones, proyecciones, selecciones) que garantiza que las operaciones se pueden combinar y optimizar de forma predecible.
  • SQL: el lenguaje declarativo que puso todo esto al alcance de la gente.

Por qué ganó y sigue ganando 50 años después

El modelo relacional no es el más rápido para todo, ni el más flexible. Ganó porque acertó con el compromiso que la mayoría del software necesita: datos con estructura clara, consultables de mil formas distintas que no conoces de antemano, con garantías fuertes de integridad. Cuando no sabes qué preguntas harás mañana, poder consultar por cualquier columna sin haberlo previsto es oro. Guárdate esto para la sección de NoSQL.

2. Modelado: tablas, claves, relaciones y normalización

Modelar es decidir qué tablas hay y cómo se conectan. Las piezas:

  • Clave primaria (PK): la columna (o combinación) que identifica unívocamente cada fila. clientes.id. No se repite, no es nula. Es el "número de ficha".
  • Clave foránea (FK): una columna que apunta a la PK de otra tabla. pedidos.cliente_id guarda el id de un cliente. Es cómo se conectan las fichas entre sí, y el motor vigila que no apuntes a un cliente que no existe (integridad referencial).

Con esas dos piezas modelas los tres tipos de relación:

erDiagram
    CLIENTE ||--o{ PEDIDO : "hace"
    PEDIDO ||--|{ LINEA_PEDIDO : "contiene"
    PRODUCTO ||--o{ LINEA_PEDIDO : "aparece en"
    CLIENTE {
        int id PK
        string email
        string nombre
    }
    PEDIDO {
        int id PK
        int cliente_id FK
        datetime fecha
    }
    PRODUCTO {
        int id PK
        string nombre
        decimal precio
    }
    LINEA_PEDIDO {
        int pedido_id FK
        int producto_id FK
        int cantidad
    }
  • Uno a muchos (1:N): un cliente tiene muchos pedidos; cada pedido, un cliente. La FK vive en el lado "muchos" (pedidos.cliente_id).
  • Muchos a muchos (N:M): un pedido tiene muchos productos y un producto está en muchos pedidos. No se puede con una sola FK: hace falta una tabla intermedia (linea_pedido) que enlaza los dos.
  • Uno a uno (1:1): menos común; suele indicar que dos tablas podrían ser una, o que separas datos por seguridad/rendimiento.

Normalización: no repitas datos

La normalización es un conjunto de reglas para organizar las tablas de modo que cada dato viva en un solo sitio. La regla práctica que cubre el 90 % de los casos: si te ves copiando el mismo valor en muchas filas, sácalo a su propia tabla y apunta con una FK.

El mal ejemplo clásico —una sola tabla gorda—:

-- ❌ Sin normalizar: el nombre y email del cliente se repiten en CADA pedido
CREATE TABLE pedidos (
    id            INT PRIMARY KEY,
    cliente_nombre VARCHAR(100),   -- 'Ana López' repetido en sus 40 pedidos
    cliente_email  VARCHAR(100),   -- si Ana cambia de email... ¿actualizas 40 filas?
    fecha         DATETIME,
    total         DECIMAL(10,2)
);

El problema no es el espacio (que también). Es la incoherencia: si Ana cambia de email, o lo actualizas en las 40 filas sin fallar en ninguna, o tienes a Ana con dos emails distintos según el pedido. Normalizado, el email de Ana vive en un solo lugar:

-- ✅ Normalizado: el dato del cliente vive una vez, los pedidos apuntan a él
CREATE TABLE clientes (
    id     INT PRIMARY KEY,
    nombre VARCHAR(100),
    email  VARCHAR(100)
);
CREATE TABLE pedidos (
    id         INT PRIMARY KEY,
    cliente_id INT REFERENCES clientes(id),  -- FK: apunta al cliente
    fecha      DATETIME,
    total      DECIMAL(10,2)
);

Cuándo desnormalizar (a propósito)

Normalizar tiene un coste: para juntar la info de nuevo necesitas JOINs, y un JOIN cuesta. En sistemas con muchísimas lecturas y pocas escrituras, a veces duplicar datos a propósito —desnormalizar— es la decisión correcta: guardas el cliente_nombre en el pedido para no hacer el JOIN en cada listado.

Desnormaliza tarde y con los ojos abiertos

La regla: normaliza por defecto, desnormaliza solo cuando midas un problema real de rendimiento y aceptes conscientemente el precio (mantener la copia sincronizada). Desnormalizar "por si acaso" al principio es cambiar un problema que no tienes (velocidad) por uno que sí tendrás garantizado (datos incoherentes). Empezar normalizado y relajar donde duela es fácil; empezar hecho un desastre y normalizar después es una migración de pesadilla.

3. Índices: el árbol balanceado que convierte O(n) en O(log n)

Volvamos al cuadernillo ordenado de la intuición. Un índice es una estructura de datos, separada de la tabla, que mantiene los valores de una columna ordenados y, junto a cada uno, un puntero a la fila real. Sin índice, buscar por una columna es un full scan: mirar las N filas una a una, O(n). Con índice, es una búsqueda en una estructura ordenada, O(log n).

¿Qué estructura? Casi siempre un B-tree (árbol B, o su variante B+tree). Y aquí conectamos directamente con Capa 0: es un árbol balanceado. Recuerda la propiedad clave de un árbol balanceado: su altura crece logarítmicamente con el número de elementos. Buscar es bajar desde la raíz hasta una hoja, y la altura es cuántos saltos das.

graph TB
  R["Raíz<br/>[50 | 100]"]
  A["[10 | 30]"]
  B["[60 | 80]"]
  C["[120 | 200]"]
  R -->|"< 50"| A
  R -->|"50–100"| B
  R -->|"> 100"| C
  A --> A1["hojas: 5,10,20,30..."]
  B --> B1["hojas: 55,60,70,80..."]
  C --> C1["hojas: 110,120,150,200..."]

Los números concretos son lo que convence: un B-tree es "ancho" (cada nodo tiene cientos de hijos, no dos como un árbol binario), así que con 3 o 4 niveles direccionas millones de filas. Buscar una fila entre 3 millones son ~3-4 lecturas de disco en vez de 3 millones de comparaciones. Ese es el trato: O(log n) frente a O(n). Y por eso la consulta que iba en 5 ms y ahora tarda 8 segundos casi siempre es lo mismo: la tabla creció, alguien busca por una columna sin índice, y el full scan que antes no dolía ahora sí.

-- La cura de "de repente va lento": un índice en la columna por la que filtras
CREATE INDEX idx_clientes_email ON clientes(email);
-- Ahora WHERE email = '...' usa el B-tree: O(log n) en vez de escanear la tabla

En Doctrine, esto se declara en el mapping de la entidad:

#[ORM\Entity]
#[ORM\Table(name: 'clientes')]
#[ORM\Index(name: 'idx_clientes_email', columns: ['email'])]
class Cliente
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private int $id;

    #[ORM\Column(length: 100)]
    private string $email;
}

El coste: los índices no son gratis

Si los índices son tan buenos, ¿por qué no indexar todas las columnas? Porque un índice es una segunda estructura que hay que mantener. Cada INSERT, UPDATE o DELETE en la tabla obliga a actualizar también todos sus índices —reordenar el B-tree para que siga ordenado y balanceado. Es el mismo compromiso de Capa 0: acelerar la lectura a costa de la escritura, y de espacio en disco.

Consecuencias prácticas:

  • Indexa las columnas por las que filtras (WHERE), juntas (JOIN) u ordenas (ORDER BY) a menudo. Ahí el índice se paga solo.
  • No indexes columnas que casi nunca consultas, ni tablas con escritura masiva y lectura rara: pagarías el coste de mantenimiento sin cobrar el beneficio.
  • Un índice sobre una columna de baja cardinalidad (pocos valores distintos, como un booleano activo) casi no ayuda: si la mitad de las filas cumplen la condición, el motor prefiere escanear. El índice brilla cuando selecciona pocas filas de muchas.

La PK ya viene indexada; las FK, ojo

La clave primaria tiene índice automático (el motor lo necesita para garantizar unicidad). Las claves foráneas no siempre, y es una causa clásica de lentitud: haces JOINs por cliente_id millones de veces sin índice en esa columna. Revísalo.

4. Transacciones y ACID: todo o nada, y aislados

Imagina una transferencia: restar 100 € de una cuenta y sumarlos a otra. Son dos operaciones (UPDATE), pero moralmente son una sola cosa. Si el servidor se cae justo entre las dos, has restado 100 € que no aparecen en ningún sitio. Dinero evaporado.

Una transacción es un grupo de operaciones que el motor trata como una unidad indivisible. Se demarca con BEGINCOMMIT (confirma todo) o ROLLBACK (deshaz todo):

BEGIN;
UPDATE cuentas SET saldo = saldo - 100 WHERE id = 1;
UPDATE cuentas SET saldo = saldo + 100 WHERE id = 2;
COMMIT;   -- o si algo falla antes: ROLLBACK, y es como si nada hubiera pasado

Las garantías de una transacción se resumen en el acrónimo ACID:

  • A — Atomicidad: todo o nada. O se aplican las dos operaciones, o ninguna. No existe "media transferencia". Si algo peta a mitad, ROLLBACK automático.
  • C — Consistencia: una transacción lleva la base de datos de un estado válido a otro válido. Se respetan las reglas (FKs, restricciones CHECK, unicidad). Nunca dejas la base en un estado que viole sus propias reglas.
  • I — Aislamiento: transacciones concurrentes no se pisan. Cada una se comporta como si estuviera sola, aunque por dentro se solapen. (Este es el más sutil y el que más problemas causa; le dedicamos la sección siguiente.)
  • D — Durabilidad: una vez que el motor te dice COMMIT OK, el dato está a salvo aunque se vaya la luz en el instante siguiente. Está escrito en disco de forma permanente (vía un registro de transacciones, el write-ahead log).
graph LR
  B["BEGIN"] --> O1["UPDATE resta 100"]
  O1 --> O2["UPDATE suma 100"]
  O2 --> Q{"¿Todo bien?"}
  Q -->|"Sí"| C["COMMIT<br/>cambios permanentes (durable)"]
  Q -->|"Fallo / crash"| R["ROLLBACK<br/>como si nada pasó (atómico)"]

En Doctrine, la unidad de trabajo (EntityManager) envuelve tus cambios en una transacción al hacer flush(). Para agrupar varias operaciones con control explícito:

$em->getConnection()->beginTransaction();
try {
    $origen->retirar(100);
    $destino->ingresar(100);
    $em->flush();
    $em->getConnection()->commit();
} catch (\Throwable $e) {
    $em->getConnection()->rollBack();   // atomicidad: o las dos, o ninguna
    throw $e;
}

5. Niveles de aislamiento y las anomalías que evitan

La A, C y D de ACID son casi absolutas. La I (aislamiento) es un dial que ajustas, porque el aislamiento perfecto es caro: cuanto más aíslas, más bloqueas, y menos concurrencia toleras. El estándar SQL define cuatro niveles, y cada uno permite o previene ciertas anomalías. Las anomalías son las cosas raras que pasan cuando dos transacciones se solapan:

  • Lectura sucia (dirty read): lees un dato que otra transacción ha escrito pero aún no ha confirmado (COMMIT). Si esa otra hace ROLLBACK, has leído algo que nunca existió. Basas una decisión en un dato fantasma.
  • Lectura no repetible (non-repeatable read): lees una fila, y al leerla otra vez en la misma transacción tiene otro valor, porque alguien la modificó y confirmó en medio. Dos lecturas del mismo dato, dos respuestas.
  • Lectura fantasma (phantom read): ejecutas SELECT ... WHERE precio < 50 y salen 10 filas; lo repites y salen 11, porque otra transacción insertó una fila nueva que cumple la condición. No cambió una fila que leíste: apareció una que no estaba.

Los cuatro niveles, de menos a más estricto, y qué anomalía frenan:

Nivel Lectura sucia Lectura no repetible Fantasma Coste
Read Uncommitted ⚠️ posible ⚠️ posible ⚠️ posible mínimo
Read Committed ✅ evitada ⚠️ posible ⚠️ posible bajo
Repeatable Read ✅ evitada ✅ evitada ⚠️ posible* medio
Serializable ✅ evitada ✅ evitada ✅ evitada alto

El nivel por defecto que de verdad usas

PostgreSQL y muchos otros usan Read Committed por defecto; MySQL/InnoDB usa Repeatable Read. La mayoría del software vive bien ahí. Serializable te da la ilusión perfecta de "estoy solo en la base de datos", pero a cambio de más bloqueos y de que algunas transacciones fallen y haya que reintentarlas. Súbelo solo donde la corrección lo exija (contabilidad, reservas de stock), no por defecto. (Nota: en Repeatable Read de PostgreSQL, gracias a su modelo de versiones (MVCC), los fantasmas también se evitan en la práctica; el estándar los permitiría.)

6. Bloqueos y su coste

¿Cómo consigue el motor el aislamiento? En buena parte, con bloqueos (locks). Cuando una transacción va a modificar una fila, pone un candado sobre ella: otras transacciones que quieran tocarla esperan a que la primera termine (COMMIT/ROLLBACK). Así se serializa el acceso al dato en disputa.

El coste es doble. Primero, la espera: si muchas transacciones pelean por las mismas filas ("puntos calientes", como el contador de stock de un producto muy vendido), se forma cola y el rendimiento se desploma aunque tengas CPU de sobra. Segundo, el deadlock (interbloqueo): dos transacciones se bloquean mutuamente en cruz.

graph LR
  T1["Transacción A<br/>tiene candado de la fila 1<br/>quiere la fila 2"]
  T2["Transacción B<br/>tiene candado de la fila 2<br/>quiere la fila 1"]
  T1 -->|"espera a"| T2
  T2 -->|"espera a"| T1

Ninguna suelta lo suyo hasta conseguir lo que quiere, y lo que quieren lo tiene la otra: esperarían para siempre. El motor detecta el ciclo, mata una de las dos (la víctima recibe un error de deadlock) y la otra continúa. Tu código debe estar preparado para reintentar la transacción abortada.

Cómo reduces bloqueos y deadlocks

  • Transacciones cortas: abre tarde, confirma pronto. Un BEGIN con trabajo pesado (o peor, una llamada HTTP) en medio mantiene candados puestos una eternidad.
  • Accede a las filas siempre en el mismo orden en todas las transacciones. Los deadlocks nacen de órdenes cruzados; un orden global consistente los previene.
  • Para el caso "última entrada": un bloqueo explícito (SELECT ... FOR UPDATE) sobre la fila del stock serializa a los compradores y evita el stock en -1, a costa de que compren en fila.

7. El problema N+1: el clásico de los ORMs

Aquí está el bug de rendimiento que todo el mundo comete con un ORM al menos una vez. Tienes un listado de pedidos y, para cada uno, muestras el nombre de su cliente:

$pedidos = $repo->findAll();          // 1 consulta: SELECT * FROM pedidos
foreach ($pedidos as $pedido) {
    echo $pedido->getCliente()->getNombre();
    // ⚠️ getCliente() dispara OTRA consulta la primera vez: SELECT * FROM clientes WHERE id = ?
}

Con 50 pedidos, esto lanza 1 consulta para los pedidos + 50 para los clientes = 51 consultas. De ahí el nombre: N+1. Con 450 pedidos, 451 consultas. Cada una es un ida y vuelta a la base de datos, con su latencia; el listado que "debería" ir instantáneo tarda segundos y satura la conexión. Lo insidioso es que el ORM lo esconde: getCliente() parece un simple acceso a un atributo, no una consulta a la base de datos. El lazy loading (carga perezosa) que tanto ayuda es también quien te tiende la trampa.

La cura es traer los datos relacionados de una vez con un JOIN, en lugar de gotear consultas. En SQL crudo lo harías con un JOIN; en Doctrine, con un fetch join en el DQL:

// ✅ Una sola consulta: pedidos y sus clientes juntos
$pedidos = $em->createQuery('
    SELECT p, c
    FROM App\Entity\Pedido p
    JOIN p.cliente c
')->getResult();

foreach ($pedidos as $pedido) {
    echo $pedido->getCliente()->getNombre();  // ya está cargado: 0 consultas extra
}

Pasas de N+1 consultas a 1. La regla general: cuando vayas a recorrer una colección tocando una relación de cada elemento, cárgala por adelantado (fetch join, o EAGER donde tenga sentido). Y mide: el profiler de Symfony te canta el número de consultas por petición; si ves 200, casi seguro tienes un N+1 escondido.

El N+1 no lo arregla un índice

Es tentador pensar que la lentitud se cura indexando. No: cada una de esas 51 consultas puede ser rapidísima gracias a los índices, y aun así 51 idas y vueltas a la red te matan por acumulación de latencia. El problema es el número de consultas, no la velocidad de cada una. Se cura reduciendo consultas (JOIN), no acelerándolas.

8. SQL vs NoSQL: qué gana y qué pierde cada uno

"NoSQL" no es una cosa; es un paraguas para bases de datos que renuncian a algo del modelo relacional a cambio de otra cosa. Las familias principales: documentales (MongoDB), clave-valor (Redis), columnares (Cassandra), de grafos (Neo4j). Lo que casi todas comparten es el compromiso de fondo, así que centrémonos en él con el caso más común, las documentales.

Una base documental guarda documentos (piensa en JSON) sin esquema fijo: cada documento puede tener campos distintos. Lo que ganas y lo que pierdes:

Relacional (SQL) Documental (NoSQL)
Esquema Fijo y validado por el motor Flexible; cada documento a su aire
Consultas ad hoc Cualquier columna, JOINs arbitrarios Fáciles por la clave; caras/limitadas fuera del patrón previsto
Integridad FKs, transacciones ACID fuertes Más débil por defecto; a menudo sin JOINs
Escalado horizontal Más difícil (JOINs y ACID no reparten bien) Diseñado para repartirse en muchas máquinas
Modelo mental Normaliza, junta al leer Desnormaliza, guarda junto lo que lees junto

La clave es esta: SQL brilla cuando no sabes qué preguntas harás mañana y necesitas integridad fuerte. Puedes consultar por cualquier columna, cruzar tablas de mil formas, y el motor te protege de datos incoherentes. NoSQL brilla cuando conoces tus patrones de acceso de antemano, tienes muchísimo volumen que repartir entre máquinas, y tus datos encajan naturalmente en "documentos" autocontenidos (un perfil, un carrito, un evento) que lees y escribes enteros.

La recomendación honesta (y por qué)

Empieza en relacional salvo que tengas una razón concreta y medida para no hacerlo. Motivos: la mayoría de las apps no tienen el volumen que justifica NoSQL, sí necesitan integridad (¿de verdad quieres pedidos sin garantías ACID?), y sobre todo no conoces tus patrones de acceso el primer día —justo cuando NoSQL te obliga a comprometerte con ellos. El fallo caro clásico es elegir MongoDB por moda o por "escala" que nunca llega, y a los seis meses estar reimplementando JOINs y transacciones a mano en el código de aplicación: lo peor de los dos mundos. NoSQL es una herramienta excelente para el problema que resuelve; asegúrate de tener ese problema. (Matiz de honestidad: PostgreSQL moderno tiene tipo JSONB y da mucha de la flexibilidad documental sin renunciar a lo relacional; suele ser el punto medio correcto.)

Cómo se ve en la práctica

Un caso real, el de la "última entrada" del principio. Un evento con 1 entrada libre, dos usuarios pulsan "Comprar" en el mismo instante.

// ❌ Race condition: los dos leen stock=1, los dos creen que pueden
$evento = $repo->find($id);
if ($evento->getStock() > 0) {           // ambas transacciones ven stock = 1
    $evento->setStock($evento->getStock() - 1);
    $em->flush();                        // ambas escriben stock = 0 → ¡2 entradas vendidas!
}

Las dos transacciones leen stock = 1 antes de que ninguna escriba, las dos pasan el if, las dos restan, y acabas con dos entradas vendidas y un cliente furioso. Es exactamente una anomalía de aislamiento. La cura es bloquear la fila al leer, para que la segunda transacción espere a ver el resultado de la primera:

// ✅ Bloqueo pesimista: la segunda transacción espera y ve stock ya en 0
$em->getConnection()->beginTransaction();
try {
    // SELECT ... FOR UPDATE: candado sobre la fila del evento
    $evento = $em->find(Evento::class, $id, LockMode::PESSIMISTIC_WRITE);
    if ($evento->getStock() > 0) {
        $evento->setStock($evento->getStock() - 1);
        $em->flush();
        $em->getConnection()->commit();
    } else {
        $em->getConnection()->rollBack();   // la segunda llega aquí: stock ya es 0
    }
} catch (\Throwable $e) {
    $em->getConnection()->rollBack();
    throw $e;
}

Las dos cosas que "hacen lo mismo" a ojos del código divergen por completo en el modelo de ejecución concurrente: la primera deja que dos lecturas se solapen; la segunda serializa el acceso a la fila caliente. Sin el modelo mental de transacciones y bloqueos, la solución (FOR UPDATE) es un conjuro memorizado. Con él, es la consecuencia obvia de cómo funciona el aislamiento.

Lo que sacrificas / errores comunes

  • Indexar todo "por si acaso": cada índice ralentiza las escrituras y ocupa disco. Un índice sin consultas que lo usen es coste puro. Indexa lo que filtras, mide, y borra los que no se usan.
  • Confundir "la consulta es lenta" con "falta un índice": a veces el problema es un N+1 (demasiadas consultas rápidas), no una consulta lenta. Mira el número de consultas antes de optimizar cada una.
  • Creer que ACID te protege de las carreras sin más: el nivel de aislamiento por defecto permite anomalías (lecturas no repetibles, fantasmas). Si tu lógica depende de que nadie toque el dato entre tu lectura y tu escritura, tienes que pedirlo explícitamente (bloqueo o nivel serializable).
  • Elegir NoSQL por moda o por "escala" futura imaginaria: acabas reimplementando JOINs y transacciones en el código de aplicación, peor y con más bugs. Ten el problema antes de comprar la solución.
  • Desnormalizar pronto: cambias un problema de rendimiento que aún no tienes por uno de incoherencia de datos que tendrás seguro. Normaliza primero, relaja donde midas dolor.
  • Transacciones largas: mantener candados mientras haces trabajo pesado o llamadas externas dentro del BEGIN/COMMIT estrangula la concurrencia y multiplica los deadlocks.

Resumen

  1. El modelo relacional separó el qué quiero del cómo se guarda: pides datos de forma declarativa (SQL) y el motor decide el plan. Por eso puedes acelerar una consulta con un índice sin tocar el código.
  2. Modelar es decidir tablas y conexiones con claves primarias y foráneas. Normaliza para que cada dato viva en un solo sitio; desnormaliza solo cuando midas un problema real y aceptes el coste de la incoherencia.
  3. Un índice es un árbol balanceado (B-tree) que convierte una búsqueda de O(n) (full scan) en O(log n), igual que en Capa 0. El precio: cada escritura mantiene el índice, así que indexas lo que consultas, no todo.
  4. Una transacción te da ACID: atomicidad (todo o nada), consistencia (reglas siempre respetadas), aislamiento (como si estuvieras solo) y durabilidad (confirmado = a salvo). El aislamiento es un dial: niveles más bajos permiten anomalías (lectura sucia, no repetible, fantasma) a cambio de más concurrencia.
  5. El N+1 (1 consulta + N por relación) se cura con JOINs / fetch join, no con índices; y la elección SQL vs NoSQL es un compromiso: SQL gana con integridad y consultas imprevisibles, NoSQL con volumen y patrones de acceso conocidos de antemano.

Ejercicios socráticos

No busques la respuesta fuera. Razónala con el modelo de esta lección. Si te atascas, la pista está en el texto.

  1. Una tabla de 10 millones de filas. La consulta WHERE email = ? tarda 6 segundos; añades un índice en email y baja a 3 ms. Explica en términos de árbol balanceado y saltos por qué la mejora es tan brutal, y estima cuántos "saltos" hacía cada versión.
  2. Tienes un listado que muestra 100 facturas, cada una con el nombre de su cliente, y ves 101 consultas en el profiler. ¿Qué está pasando exactamente y por qué añadir un índice a clientes no lo arreglará? ¿Qué sí lo arregla y por qué reduce las consultas a 1?
  3. Dos cajeros retiran a la vez de la misma cuenta con saldo 100 €: uno saca 80, otro saca 70. Con el nivel de aislamiento por defecto (Read Committed) y sin bloqueo explícito, ¿puede quedar la cuenta en negativo? Razona qué lee y escribe cada transacción y en qué orden.
  4. ¿Por qué crees que indexar todas las columnas de una tabla que recibe millones de INSERT al día sería un desastre, aunque acelerara todas las lecturas? Piensa en qué tiene que hacer el motor en cada INSERT.
  5. Un compañero propone migrar el sistema de pedidos (con clientes, productos, facturas y muchos informes cruzados distintos cada trimestre) de PostgreSQL a MongoDB "para que escale". Dale la objeción más fuerte que se te ocurra, apoyándote en qué gana y qué pierde cada modelo.

Repaso espaciado

Pasa estas a Anki (repasos.md). Formato: pregunta que obligue a razonar, no a reconocer.

  • [ ] ¿Qué estructura de datos usa un índice típico y por qué convierte O(n) en O(log n)? Conéctalo con el árbol balanceado de Capa 0.
  • [ ] Enumera ACID y explica cada letra con una frase que capture su garantía. ¿Cuál de las cuatro es un "dial" que ajustas y por qué?
  • [ ] Distingue lectura sucia, no repetible y fantasma: ¿qué ve cada una y qué nivel de aislamiento frena cada una?
  • [ ] ¿Qué es el problema N+1, por qué lo esconde un ORM, y por qué NO se cura con un índice? ¿Con qué se cura?
  • [ ] ¿Cuándo elegirías NoSQL sobre relacional y cuándo sería un error? Da el criterio, no un ejemplo suelto.

Para seguir tirando del hilo

  • Designing Data-Intensive Applications (Martin Kleppmann) — la referencia moderna. Los capítulos de índices, transacciones y replicación profundizan en todo esto con rigor y sin humo.
  • Use The Index, Luke! (Markus Winand, gratis online) — todo sobre cómo funcionan los índices B-tree y cómo escribir consultas que los aprovechen. Práctico y directo.
  • Documentación de Doctrine sobre Best Practices y el capítulo de Improving Performance — cómo detectar y matar N+1, y cuándo usar EAGER vs LAZY.
  • Experimento: crea una tabla con un millón de filas, mide una consulta con EXPLAIN sin índice, añade el índice, vuelve a medir con EXPLAIN y mira cómo cambia el plan de Seq Scan a Index Scan. Verlo con tus ojos asienta esto más que releerlo.