Inicio / TypeScript / Conceptos de Backend / Transacciones y ACID

Transacciones y ACID

Propiedades ACID, niveles de aislamiento, bloqueos pesimistas y optimistas.

Avanzado
🔒 Solo lectura
📖

Estás en modo lectura

Puedes leer toda la lección, pero para marcar progreso, hacer ejercicios y ganar XP necesitas una cuenta Pro.

Desbloquear por $9/mes

Transacciones y ACID

Una transacción es un conjunto de operaciones que se ejecutan como una unidad atómica: o se completan todas, o no se aplica ninguna.


Las propiedades ACID

Atomicidad (Atomicity)

Una transacción es todo o nada. Si alguna operación falla, todas las anteriores se revierten.

-- Transferencia bancaria: deben ejecutarse AMBAS o NINGUNA
BEGIN;

    UPDATE accounts SET balance_cents = balance_cents - 10000
    WHERE id = 1 AND balance_cents >= 10000;

    -- Si la fila actualizada fue 0 (sin fondos), hacemos ROLLBACK
    -- desde la aplicación

    UPDATE accounts SET balance_cents = balance_cents + 10000
    WHERE id = 2;

COMMIT;  -- o ROLLBACK si algo fue mal
// En TypeScript con pg (node-postgres)
import { Pool } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

async function transferFunds(
  fromId: number,
  toId:   number,
  amount: number
): Promise<void> {
  const client = await pool.connect();

  try {
    await client.query('BEGIN');

    const debit = await client.query(
      `UPDATE accounts
       SET balance_cents = balance_cents - $1
       WHERE id = $2 AND balance_cents >= $1
       RETURNING id`,
      [amount, fromId]
    );

    if (debit.rowCount === 0) {
      throw new Error('Fondos insuficientes');
    }

    await client.query(
      `UPDATE accounts
       SET balance_cents = balance_cents + $1
       WHERE id = $2`,
      [amount, toId]
    );

    await client.query('COMMIT');
    console.log(`Transferencia de ${amount} completada`);

  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

Consistencia (Consistency)

La transacción lleva la base de datos de un estado válido a otro válido, respetando todas las restricciones (FK, CHECK, UNIQUE...).

-- Esta transacción fallará si viola la constraint
BEGIN;
    INSERT INTO orders (user_id, total_cents, status)
    VALUES (999, 5000, 'pending');
    -- ERROR: foreign key violation (user 999 no existe)
ROLLBACK;  -- automático por el error

Aislamiento (Isolation)

Las transacciones concurrentes se ejecutan como si fueran secuenciales. Cada transacción ve un snapshot consistente.

Durabilidad (Durability)

Una vez confirmada (COMMIT), la transacción persiste aunque el sistema falle inmediatamente después (gracias al Write-Ahead Log).


Niveles de aislamiento

Los problemas de concurrencia que cada nivel previene:

Nivel Dirty Read Non-repeatable Read Phantom Read
READ UNCOMMITTED ❌ posible ❌ posible ❌ posible
READ COMMITTED ✅ evitado ❌ posible ❌ posible
REPEATABLE READ ✅ evitado ✅ evitado ❌ posible
SERIALIZABLE ✅ evitado ✅ evitado ✅ evitado

PostgreSQL usa READ COMMITTED por defecto.

-- Cambiar nivel de aislamiento para una transacción
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
    -- ...
COMMIT;

Fenómenos de concurrencia explicados

Dirty Read: Transacción A lee datos no confirmados de B
  T1: UPDATE balance = 0 (sin COMMIT)
  T2: SELECT balance → ve 0  ← INCORRECTO (T1 puede hacer ROLLBACK)

Non-repeatable Read: misma fila, distinto valor en la misma transacción
  T1: SELECT balance → 1000
  T2: UPDATE balance = 500; COMMIT
  T1: SELECT balance → 500  ← CAMBIÓ dentro de la misma tx

Phantom Read: misma consulta, distinto conjunto de filas
  T1: SELECT COUNT(*) FROM orders WHERE status='pending' → 5
  T2: INSERT INTO orders ... (pending); COMMIT
  T1: SELECT COUNT(*) ... → 6  ← APARECIO nueva fila

Bloqueos (Locks)

Bloqueo pesimista (Pessimistic Locking)

Bloquea la fila en el momento de leerla para evitar conflictos.

-- FOR UPDATE: bloquea las filas seleccionadas
BEGIN;
    SELECT id, balance_cents
    FROM   accounts
    WHERE  id = 1
    FOR    UPDATE;              -- nadie más puede leer/modificar esta fila

    UPDATE accounts SET balance_cents = balance_cents - 10000 WHERE id = 1;
COMMIT;

-- FOR SHARE: permite otras lecturas FOR SHARE, bloquea writes
SELECT * FROM products WHERE id = 5 FOR SHARE;

-- NOWAIT: falla si no puede obtener el lock inmediatamente
SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;

-- SKIP LOCKED: útil para colas de trabajo (job queues)
SELECT * FROM job_queue WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;

Bloqueo optimista (Optimistic Locking)

Detecta conflictos al escribir en lugar de bloquear al leer. Usando un campo version.

interface Product {
  id:          number;
  name:        string;
  stock:       number;
  version:     number;  // campo de versión
}

async function updateStock(
  client: any,
  productId: number,
  delta: number
): Promise<void> {
  // Paso 1: leer con versión actual
  const { rows } = await client.query(
    'SELECT id, stock, version FROM products WHERE id = $1',
    [productId]
  );
  const product = rows[0] as Product;

  const newStock = product.stock + delta;
  if (newStock < 0) throw new Error('Stock insuficiente');

  // Paso 2: actualizar SOLO si la versión no cambió
  const result = await client.query(
    `UPDATE products
     SET stock = $1, version = version + 1
     WHERE id = $2 AND version = $3`,
    [newStock, productId, product.version]
  );

  if (result.rowCount === 0) {
    // Otro proceso modificó el producto entre nuestra lectura y escritura
    throw new Error('Conflicto de concurrencia, reintenta la operación');
  }
}

Deadlocks

Dos transacciones se bloquean mutuamente esperando recursos que la otra tiene.

T1: LOCK A → espera B
T2: LOCK B → espera A
→ DEADLOCK
-- Prevención: siempre bloquear recursos en el MISMO ORDEN
-- T1 y T2 siempre hacen: cuenta con id menor primero

-- ❌ T1: UPDATE cuenta 1, luego cuenta 2
-- ❌ T2: UPDATE cuenta 2, luego cuenta 1  → DEADLOCK

-- ✅ Ambas: UPDATE MIN(id) primero, luego MAX(id)
BEGIN;
    SELECT id FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
    -- ahora ambas transacciones bloquean en el mismo orden
    UPDATE accounts SET balance_cents = balance_cents - 10000 WHERE id = 1;
    UPDATE accounts SET balance_cents = balance_cents + 10000 WHERE id = 2;
COMMIT;

PostgreSQL detecta deadlocks automáticamente y aborta una de las transacciones con error ERROR: deadlock detected. La aplicación debe capturar este error y reintentar.


SAVEPOINT: Rollback parcial

BEGIN;
    INSERT INTO users (email, name) VALUES ('a@test.com', 'Ana');

    SAVEPOINT sp1;

    INSERT INTO users (email, name) VALUES ('a@test.com', 'Duplicado');
    -- ERROR: duplicate key

    ROLLBACK TO SAVEPOINT sp1;  -- solo deshace desde sp1

    -- La inserción de Ana sigue en pie
    INSERT INTO users (email, name) VALUES ('b@test.com', 'Bob');

COMMIT;  -- Ana y Bob insertados, el duplicado nunca ocurrió

Resumen

Concepto Descripción
BEGIN / COMMIT / ROLLBACK Control de transacción explícito
ACID Garantías que ofrece una transacción
READ COMMITTED Nivel de aislamiento más común, evita dirty reads
SERIALIZABLE Máximo aislamiento, máxima consistencia, menor rendimiento
Pessimistic Lock FOR UPDATE — bloquea al leer, más seguro con alta contención
Optimistic Lock Campo version — detecta conflictos al escribir, mejor rendimiento con baja contención
DEADLOCK Bloquea al ordenar recursos consistentemente; captura y reintenta en la app
🔒

Ejercicio práctico disponible

Sistema de Transferencias con Rollback

Desbloquear ejercicios
// Sistema de Transferencias con Rollback
// Desbloquea Pro para acceder a este ejercicio
// y ganar +50 XP al completarlo

function ejemplo() {
    // Tu código aquí...
}

¿Te gustó esta lección?

Con Pro puedes marcar progreso, hacer ejercicios, tomar quizzes, ganar XP y obtener tu constancia.

Ver planes desde $9/mes