Saltar al contenido

043 — ACID: qué garantiza cada letra y quién la implementa

🗂️ parte 🎚️ nivel ⏱️ duración 📗 clase

Programa · Parte 08 · ← Anterior · Siguiente →

Parte 08 — Transacciones, concurrencia y recuperación · Intermedio · 3 horas estimadas · motores postgresql, sqlite, mysql · laboratorio labs/03-transactions · 3 fuentes.

Conceptos centrales: atomicidad · consistencia · aislamiento · durabilidad · unidad de recuperación

En este caso se comparan 7 motores: 5 lo resuelven (4 con el resultado comprobado por máquina) y 2 no, con el motivo escrito.

De qué trata esta clase

Qué garantiza cada letra de ACID y quién la implementa, con especial cuidado en la que más se malinterpreta: la consistencia es respetar las restricciones declaradas, así que lo que el motor no sabe no lo protege. Deja planteado que el aislamiento es la única letra que se vende por niveles.

flowchart LR
    C["🗄️ Clase 043"]
    C --> K1["atomicidad"]
    C --> K2["consistencia"]
    C --> K3["aislamiento"]
    C --> K4["durabilidad"]
    C --> K5["unidad de recuperación"]
    classDef raiz fill:#0b3d2e,stroke:#3fb950,color:#fff
    class C raiz

Antes de empezar

Esta clase supone que ya trabajaste lo siguiente. Si algo de la última columna no te suena, vuelve a esa clase antes de seguir: aquí se usa sin volver a explicarlo.

# Clase previa Lo que se da por sabido
005 Cambiar datos: INSERT, UPDATE, DELETE y el WHERE que salva UPDATE · DELETE · alcance del cambio · filas afectadas · transacción como red
023 Integridad: restricciones, claves foraneas y acciones referenciales integridad de entidad · integridad referencial · CHECK · ON DELETE · aplazamiento

Vocabulario de la clase

Los términos que siguen se usan más adelante con este significado exacto. La definición completa, con sus términos relacionados, está en el glosario del programa.

Término Qué significa Procedencia
atomicidad Todo o nada: la transacción se aplica entera o no deja rastro. No promete que sea correcta ni que sea rápida, solo que no habrá estados a medias visibles para nadie. se introduce aquí
consistencia La letra tramposa de ACID: significa que la transacción lleva la base de un estado válido a otro según las restricciones declaradas. Lo que el motor no sabe, no lo protege; la consistencia de negocio la pone quien declara las reglas, no el gestor. se introduce aquí
aislamiento Grado en que una transacción concurrente no ve los pasos intermedios de otra. Es la única letra de ACID que se vende por niveles, y el nivel por defecto de casi todos los motores no es el más fuerte. se introduce aquí
durabilidad Una vez confirmada la transacción, su efecto sobrevive a un corte de luz. Se consigue escribiendo el cambio en un registro secuencial y forzándolo al disco antes de responder «hecho». se introdujo en la 002
unidad de recuperación La transacción como frontera de lo que se rehace o se deshace tras una caída. Es lo que conecta ACID con el registro anticipado: sin transacción no hay nada que delimite qué debe sobrevivir. se introduce aquí

Propósito

Precisar qué garantiza cada letra de ACID, quién la implementa y qué queda fuera. «Usamos una base ACID» no significa que el sistema sea correcto: significa que existen unas garantías concretas si se usan bien.

Resultados de aprendizaje

Al terminar podrás:

  1. Definir con precisión atomicidad, consistencia, aislamiento y durabilidad.
  2. Explicar por qué la C es distinta de las otras tres.
  3. Identificar qué mecanismo del motor implementa cada letra.
  4. Reconocer las garantías que se pierden por configuración.
  5. Explicar por qué una transacción larga es un problema operativo.

Fundamentos

La transacción

Gray (1981) define la transacción como la unidad de trabajo que lleva la base de un estado consistente a otro, con la propiedad de que o se aplica entera o no se aplica nada. Es simultáneamente unidad de recuperación, de concurrencia y de consistencia.

Las cuatro letras, una por una

A — Atomicidad. Todo o nada. Si la transacción falla o el proceso muere, no queda ningún efecto parcial. Lo implementa el registro de deshacer (undo): el motor guarda cómo revertir cada cambio antes de aplicarlo.

C — Consistencia. Aquí está la trampa pedagógica. Las otras tres letras son propiedades del motor; la C es una propiedad de la aplicación y del esquema. El motor solo garantiza las restricciones que alguien declaró (clase 013). Si nadie declaró que el saldo no puede ser negativo, ninguna transacción ACID lo impedirá.

Formulación honesta: la atomicidad y el aislamiento permiten que la aplicación mantenga sus invariantes; la C es la afirmación de que las mantiene.

I — Aislamiento. Las transacciones concurrentes producen un resultado equivalente a alguna ejecución en serie. Esa es la definición de serializabilidad, y es el nivel más fuerte. En la práctica casi todos los sistemas funcionan en niveles más débiles que permiten anomalías, y por defecto (clase 034). Lo implementan el bloqueo en dos fases o el control multiversión (clase 035).

D — Durabilidad. Una vez confirmada, la transacción sobrevive a fallos. Lo implementa el registro anticipado (clase 036). El matiz importante: durabilidad frente a qué fallo. Sobrevivir a la caída del proceso, a la de la máquina, a la pérdida del disco y a la del centro de datos son cuatro garantías distintas con cuatro costos distintos.

Fallo Mecanismo necesario
Caída del proceso WAL escrito al sistema operativo
Caída de la máquina fsync del WAL al disco
Pérdida del disco Réplica o copia externa
Pérdida del centro de datos Réplica geográficamente distante
Error humano (DROP TABLE) Copia con recuperación a un punto en el tiempo

Ninguna configuración de un solo nodo protege de las tres últimas.

Garantías que se pierden por configuración

Configuración Qué se pierde
PostgreSQL synchronous_commit = off Durabilidad: hasta ~0,2 s de transacciones confirmadas
PostgreSQL fsync = off Durabilidad y posiblemente la integridad del archivo
MySQL innodb_flush_log_at_trx_commit = 2 Durabilidad ante caída de la máquina
MySQL con tablas MyISAM Todo: no hay transacciones
Réplica asíncrona con conmutación Las transacciones aún no replicadas
SQLite synchronous = OFF Durabilidad e integridad ante corte de energía

La primera y la tercera son decisiones legítimas para datos reconstruibles, y hay que declararlas, no heredarlas de un archivo de configuración copiado.

flowchart TD
    T["BEGIN"] --> W["Escrituras"]
    W --> A{"¿COMMIT o fallo?"}
    A -- "Fallo" --> U["Registro de deshacer<br/>→ ATOMICIDAD"]
    A -- "COMMIT" --> V{"¿Se violó alguna<br/>restricción declarada?"}
    V -- "Sí" --> U
    V -- "No" --> L["WAL + fsync<br/>→ DURABILIDAD"]
    L --> OK["Confirmado"]
    W -.->|"2PL o MVCC"| I["AISLAMIENTO"]
    V -.-> C["CONSISTENCIA<br/>(esquema + aplicación)"]

Ejemplo trabajado

Transferencia entre cuentas, el caso canónico:

BEGIN;
UPDATE cuentas SET saldo = saldo - 300 WHERE id = 1;
UPDATE cuentas SET saldo = saldo + 300 WHERE id = 2;
COMMIT;

Qué garantiza cada letra aquí:

BEGIN;
UPDATE cuentas SET saldo = saldo - 100000 WHERE id = 1;  -- saldo queda en -99 700
COMMIT;   -- ACID perfecto, negocio roto

La transacción es atómica, aislada y duradera, y deja un saldo imposible. La C exige escribirla:

ALTER TABLE cuentas ADD CONSTRAINT saldo_no_negativo CHECK (saldo >= 0);

Ahora sí: el UPDATE falla, la transacción se revierte y la atomicidad garantiza que no queda rastro.

La invariante que un CHECK no cubre. «La suma de todos los saldos es constante»: relaciona filas distintas y no hay CHECK estándar que lo exprese. Solo el aislamiento serializable garantiza que dos transferencias concurrentes no la rompan; en niveles más débiles hay que auditarla:

SELECT SUM(saldo) FROM cuentas;   -- debe coincidir con el total esperado

Transacciones largas: el problema operativo.

BEGIN;
SELECT * FROM enrollments;          -- el informe tarda 40 minutos
-- ... la sesión queda abierta ...
COMMIT;

Durante esos 40 minutos, en un motor MVCC:

Medición típica: una transacción abierta 40 minutos en una base con 2 000 actualizaciones por segundo impide recuperar ~4,8 millones de versiones de fila. La consecuencia se ve como «la base creció 30 GB de la nada».

Regla operativa: una transacción abarca las escrituras que deben ser atómicas y nada más. Nunca debe contener llamadas de red a servicios externos, esperas de usuario ni informes largos.

Comparación

Propiedad La garantiza Se pierde si
Atomicidad Registro de deshacer Casi nunca; el motor la sostiene
Consistencia Esquema + aplicación No se declaran las restricciones
Aislamiento 2PL o MVCC Nivel por defecto más débil que serializable
Durabilidad WAL + fsync Configuración de rendimiento; réplica asíncrona

Errores frecuentes

  1. Creer que la C la pone el motor. La pone el esquema que se escribió.
  2. Suponer serializable por defecto. Ningún motor de servidor lo hace por omisión.
  3. Transacciones que envuelven llamadas HTTP. Un servicio lento hincha la base.
  4. Confiar en la durabilidad con réplica asíncrona y conmutación automática. Se pierden transacciones confirmadas.
  5. Usar MyISAM y hablar de transacciones. No las hay.
  6. No probar la restauración. La durabilidad sin restauración probada es una suposición (clase 048).

De la clase a la operación

«Somos ACID» es una respuesta incompleta a «¿pueden perder mi pedido?». La respuesta completa enumera: el nivel de aislamiento efectivo, la configuración de fsync, el modo de replicación y el resultado de la última prueba de restauración.

Reto de transferencia

  1. Determina el nivel de aislamiento y la configuración de durabilidad reales de tu base.
  2. Escribe la invariante de negocio más importante y verifica si el esquema la garantiza.
  3. Provoca un fallo a mitad de una transacción y demuestra la atomicidad.
  4. Localiza la transacción más larga de tu sistema y estima su efecto sobre las versiones de fila.

Preguntas de evaluación

  1. ¿Por qué la C es de naturaleza distinta a A, I y D?
  2. Da una invariante de tu dominio que ningún CHECK pueda expresar y di quién la vigila.
  3. Enumera los cuatro fallos frente a los que puede exigirse durabilidad y el mecanismo de cada uno.
  4. Explica el efecto de una transacción abierta durante una hora en un motor MVCC.

🌐 El mismo problema en cada motor

Caso: Una transferencia que se completa y otra que se deshace sin dejar rastro

ACID son cuatro promesas distintas y conviene no confundirlas. Atomicidad: o todo o nada. Consistencia: las reglas declaradas siguen siendo ciertas al terminar. Islamiento: las transacciones concurrentes no se ven a medias. Durabilidad: lo confirmado sobrevive a un corte de luz.

El caso ejercita las dos primeras. Una transferencia de 30 de A a B se confirma; otra de 500 se deshace, y con ella la mitad que ya se había escrito. Entre las dos sentencias de la transferencia hay un instante en el que el dinero está duplicado: la atomicidad es la garantía de que ese instante no existe para nadie más, y que al deshacer no queda rastro.

Al final, A tiene 70 y B tiene 80: la suma sigue siendo 150, que es el invariante que ninguna transferencia debe romper.

Salida esperada, idéntica en todos los motores que lo resuelven:

cuenta saldo
A 70
B 80

El contrato vive en motores.yaml y lo comprueba python scripts/verificar_equivalencia.py --clase 043: 4 de las 5 implementaciones se ejecutan de verdad y su resultado se compara con esa tabla; el resto se declara como material revisado, no ejecutado.

Motor ¿Resuelve el caso? Nivel de prueba Código Fuente
SQLite núcleo código doc oficial
DuckDB núcleo código doc oficial
PostgreSQL servicio código doc oficial
MySQL servicio código doc oficial
MongoDB declarado código doc oficial
Redis no doc oficial
Apache Cassandra no doc oficial

Los que resuelven el caso

SQLite · implementaciones/sqlite/consulta.sql

verificado — se ejecuta en CI sin servicios

-- motor: sqlite
-- doc: https://sqlite.org/transactional.html
-- nota: el CHECK (saldo >= 0) es la «C» de ACID: la regla que sigue siendo
--       cierta al terminar. El ROLLBACK es la «A». Son dos garantias distintas
--       y aqui se ven las dos en el mismo archivo.

-- === preparacion ===
CREATE TABLE cuentas (
    id    TEXT PRIMARY KEY,
    saldo INTEGER NOT NULL CHECK (saldo >= 0)
);
INSERT INTO cuentas (id, saldo) VALUES ('A', 100), ('B', 50);

-- Transferencia valida: las dos escrituras son UNA operacion.
BEGIN;
UPDATE cuentas SET saldo = saldo - 30 WHERE id = 'A';
UPDATE cuentas SET saldo = saldo + 30 WHERE id = 'B';
COMMIT;

-- Transferencia imposible: A no tiene 500. El abono a B ya se ha escrito
-- cuando la aplicacion comprueba el origen y decide deshacer. Entre las dos
-- sentencias existe un instante en el que el dinero esta duplicado; la
-- atomicidad es la garantia de que ese instante no existe para nadie mas y de
-- que al deshacer no queda rastro de el.
BEGIN;
UPDATE cuentas SET saldo = saldo + 500 WHERE id = 'B';
-- El cargo a A ni siquiera se intenta: violaria el CHECK (saldo >= 0) y, sin
-- transaccion, el abono a B se habria quedado escrito para siempre.
ROLLBACK;

-- === consulta ===
SELECT id, saldo FROM cuentas ORDER BY id;

DuckDB · implementaciones/duckdb/consulta.sql

verificado — se ejecuta en CI sin servicios

-- motor: duckdb
-- doc: https://duckdb.org/docs/stable/sql/statements/transactions.html

-- === preparacion ===
CREATE TABLE cuentas (
    id    VARCHAR PRIMARY KEY,
    saldo INTEGER NOT NULL CHECK (saldo >= 0)
);
INSERT INTO cuentas (id, saldo) VALUES ('A', 100), ('B', 50);

-- Transferencia valida: las dos escrituras son UNA operacion.
BEGIN;
UPDATE cuentas SET saldo = saldo - 30 WHERE id = 'A';
UPDATE cuentas SET saldo = saldo + 30 WHERE id = 'B';
COMMIT;

-- Transferencia imposible: A no tiene 500. El abono a B ya se ha escrito
-- cuando la aplicacion comprueba el origen y decide deshacer. Entre las dos
-- sentencias existe un instante en el que el dinero esta duplicado; la
-- atomicidad es la garantia de que ese instante no existe para nadie mas y de
-- que al deshacer no queda rastro de el.
BEGIN;
UPDATE cuentas SET saldo = saldo + 500 WHERE id = 'B';
-- El cargo a A ni siquiera se intenta: violaria el CHECK (saldo >= 0) y, sin
-- transaccion, el abono a B se habria quedado escrito para siempre.
ROLLBACK;

-- === consulta ===
SELECT id, saldo FROM cuentas ORDER BY id;

PostgreSQL · implementaciones/postgresql/consulta.sql

verificado — se ejecuta contra el motor real levantado con docker compose

-- motor: postgresql
-- doc: https://www.postgresql.org/docs/current/tutorial-transactions.html
-- nota: aqui el DDL tambien es transaccional. Esto es legal y se deshace entero:
--         BEGIN; ALTER TABLE cuentas ADD COLUMN divisa text; ROLLBACK;
--       En MySQL y en Oracle, ese ALTER confirma la transaccion y no hay vuelta
--       atras. Es la razon por la que las migraciones de esquema son mucho mas
--       seguras en PostgreSQL.

-- === preparacion ===
DROP TABLE IF EXISTS cuentas;

CREATE TABLE cuentas (
    id    text PRIMARY KEY,
    saldo integer NOT NULL CHECK (saldo >= 0)
);
INSERT INTO cuentas (id, saldo) VALUES ('A', 100), ('B', 50);

-- Transferencia valida: las dos escrituras son UNA operacion.
BEGIN;
UPDATE cuentas SET saldo = saldo - 30 WHERE id = 'A';
UPDATE cuentas SET saldo = saldo + 30 WHERE id = 'B';
COMMIT;

-- Transferencia imposible: A no tiene 500. El abono a B ya se ha escrito
-- cuando la aplicacion comprueba el origen y decide deshacer. Entre las dos
-- sentencias existe un instante en el que el dinero esta duplicado; la
-- atomicidad es la garantia de que ese instante no existe para nadie mas y de
-- que al deshacer no queda rastro de el.
BEGIN;
UPDATE cuentas SET saldo = saldo + 500 WHERE id = 'B';
-- El cargo a A ni siquiera se intenta: violaria el CHECK (saldo >= 0) y, sin
-- transaccion, el abono a B se habria quedado escrito para siempre.
ROLLBACK;

-- === consulta ===
SELECT id, saldo FROM cuentas ORDER BY id;

MySQL · implementaciones/mysql/consulta.sql

verificado — se ejecuta contra el motor real levantado con docker compose

-- motor: mysql
-- doc: https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-model.html
-- nota: dos avisos que no estan en el codigo y hay que saber igual:
--       1) El DDL NO es transaccional: un ALTER TABLE dentro de una transaccion
--          la confirma implicitamente.
--       2) innodb_flush_log_at_trx_commit distinto de 1 significa perder hasta
--          un segundo de transacciones YA CONFIRMADAS tras una caida.

-- === preparacion ===
DROP TABLE IF EXISTS cuentas;

CREATE TABLE cuentas (
    id    VARCHAR(10) PRIMARY KEY,
    saldo INT NOT NULL CHECK (saldo >= 0)
);
INSERT INTO cuentas (id, saldo) VALUES ('A', 100), ('B', 50);

-- Transferencia valida: las dos escrituras son UNA operacion.
BEGIN;
UPDATE cuentas SET saldo = saldo - 30 WHERE id = 'A';
UPDATE cuentas SET saldo = saldo + 30 WHERE id = 'B';
COMMIT;

-- Transferencia imposible: A no tiene 500. El abono a B ya se ha escrito
-- cuando la aplicacion comprueba el origen y decide deshacer. Entre las dos
-- sentencias existe un instante en el que el dinero esta duplicado; la
-- atomicidad es la garantia de que ese instante no existe para nadie mas y de
-- que al deshacer no queda rastro de el.
BEGIN;
UPDATE cuentas SET saldo = saldo + 500 WHERE id = 'B';
-- El cargo a A ni siquiera se intenta: violaria el CHECK (saldo >= 0) y, sin
-- transaccion, el abono a B se habria quedado escrito para siempre.
ROLLBACK;

-- === consulta ===
SELECT id, saldo FROM cuentas ORDER BY id;

MongoDB · implementaciones/mongodb/consulta.js

declarado — se revisa a mano contra la documentación citada; la máquina no lo ejecuta

// motor: mongodb
// doc: https://www.mongodb.com/docs/manual/core/transactions/
// nota: implementacion DECLARADA, y el motivo es parte de la leccion: las
//       transacciones de varios documentos exigen un CONJUNTO DE REPLICAS. En
//       el servidor suelto que levanta este repositorio, este guion falla con
//       «Transaction numbers are only allowed on a replica set member or
//       mongos». No se ejecuta porque no se puede, y decirlo vale mas que
//       fingir que si.
//
//       Con una sola cuenta por documento no haria falta nada de esto: la
//       escritura de un documento ya es atomica. La transaccion aparece justo
//       cuando el agregado se reparte en dos documentos, que es la senal de que
//       el modelo documental se esta usando como si fuera relacional.

// === preparacion ===
db.cuentas.drop();
db.cuentas.insertMany([
  { _id: "A", saldo: 100 },
  { _id: "B", saldo: 50 },
]);

const sesion = db.getMongo().startSession();
const cuentas = sesion.getDatabase(db.getName()).cuentas;

// Transferencia valida.
sesion.startTransaction();
cuentas.updateOne({ _id: "A" }, { $inc: { saldo: -30 } });
cuentas.updateOne({ _id: "B" }, { $inc: { saldo: 30 } });
sesion.commitTransaction();

// Transferencia imposible: se comprueba DENTRO de la transaccion y se aborta.
sesion.startTransaction();
cuentas.updateOne({ _id: "B" }, { $inc: { saldo: 500 } });
const origen = cuentas.findOne({ _id: "A" });
if (origen.saldo < 500) {
  sesion.abortTransaction();
} else {
  cuentas.updateOne({ _id: "A" }, { $inc: { saldo: -500 } });
  sesion.commitTransaction();
}
sesion.endSession();

// === consulta ===
db.cuentas
  .find()
  .sort({ _id: 1 })
  .forEach((d) => print(d._id + "|" + d.saldo));

Los que no resuelven este caso — y qué se hace en su lugar

Descartar un motor con un argumento es tan formativo como usarlo. Ninguna de estas filas dice que el motor sea peor: dice que este problema no es el suyo.

Motor Por qué no Qué se hace en su lugar Fuente
Redis MULTI/EXEC no es una transacción ACID: garantiza que las órdenes se ejecutan seguidas y sin intercalación, pero no hay reversión. Si una orden falla en medio, las anteriores ya se aplicaron y las siguientes se aplican igual. Su documentación lo dice sin rodeos. Un script Lua, que sí es atómico y aislado, comprobando las condiciones antes de escribir nada: la reversión se sustituye por no llegar a empezar. doc
Apache Cassandra No hay transacciones entre particiones ni reversión. BATCH solo es atómico y aislado dentro de una partición, y su uso entre particiones lo desaconseja la propia documentación por el costo del registro de lotes. Diseñar para que la operación quepa en una partición, o implementar una saga con pasos compensatorios en la aplicación, asumiendo que hay estados intermedios visibles. doc

Laboratorio

python scripts/validate_repository.py
python labs/03-transactions/run_transactions_lab.py

Guarda como evidencia la salida completa, la versión del motor y la semilla o los parámetros usados. Una captura sin comando no es evidencia: no se puede repetir.

Evaluación

Criterio Peso Qué se comprueba
Comprensión conceptual 25 % Explica el mecanismo, no solo el resultado
Ejecución reproducible 25 % Otra persona obtiene lo mismo con las instrucciones dadas
Interpretación basada en evidencia 25 % Cada conclusión se apoya en una salida o una medición
Límites y riesgos declarados 25 % Dice qué no demuestra el ejercicio y qué faltaría en producción

La clase se da por superada cuando la respuesta explica el mecanismo, muestra la salida que la respalda y declara al menos un límite del ejercicio.

Fuentes de esta clase

Todo lo afirmado arriba procede de estas obras. Los identificadores viven en catalog/sources.json y el estado de los enlaces se comprueba con python scripts/check_external_links.py.


Programa · Parte 08 · ← Anterior · Siguiente →