Módulo 2 · Bases de datos

Lección 10 — Transacciones

ACID, niveles de aislamiento, bloqueos y concurrencia optimista/pesimista en la reserva de asientos.

Publicada
En esta lección
  1. Objetivos
  2. 1. ACID en una transacción real
  3. 2. Niveles de aislamiento: qué anomalia elimina cada uno
  4. 3. Bloqueos: el SELECT FOR UPDATE
  5. 4. La transacción de reserva definitiva
  6. Autoevaluación

Stack: PostgreSQL · Proyecto: TicketFlow Estado: Publicada — la lección más importante del módulo de datos Prerrequisito: Lección 09 — Índices


Objetivos

  1. Explicar ACID con ejemplos de TicketFlow, no con definiciones de manual.
  2. Elegir nivel de aislamiento sabiendo qué anomalia evita cada uno.
  3. Usar select_for_update y transacciones atómicas en Django para reservas concurrentes.
  4. Escribir la transacción de reserva definitiva (la que sobrevive al pico de venta).

1. ACID en una transacción real

La transacción de reserva agrupa: INSERT reservation + INSERT items + (luego) UPDATE payment. Sin transacción:

  • Atomicidad: falla el INSERT del ítem → queda la reserva huérfana. Con transacción: rollback total o nada.
  • Consistencia: las constraints (I1) se comprueban al commit — la transacción nunca deja la BD en estado imposible.
  • Aislamiento: dos reservas concurrentes del mismo asiento no se ven a medias (el corazón de esta lección).
  • Durabilidad: el COMMIT confirmado sobrevive al reinicio (WAL de PostgreSQL).

En Django: from django.db import transaction + with transaction.atomic(): — el bloque entero o nada. Las excepciones hacen rollback; el return, commit.

2. Niveles de aislamiento: qué anomalia elimina cada uno

NivelFenómenos que SÍ permiteEvita
READ COMMITTED (default PG)lecturas no repetibles, fantasmalecturas sucias
REPEATABLE READfantasma (en PG: snapshot)no repetibles
SERIALIZABLE—todo (con reintentos)

La lectura no repetible en TicketFlow: lees el saldo de la tarjeta regalo (100), otro proceso gasta 80, tú gastas 70 con el saldo viejo → -50. No es problema del nivel por sí solo: es check-then-act sin bloqueo (Lección 03). Los niveles controlan lecturas; el concurrent-write se controla con bloqueos.

Decisión práctica: quedas en READ COMMITTED + bloqueos explícitos (SELECT... FOR UPDATE) en el flujo crítico. SERIALIZABLE solo si el dominio lo exige y asumes reintentos por serialization_failure.

3. Bloqueos: el SELECT FOR UPDATE

python
with transaction.atomic():
    seat = Seat.objects.select_for_update().get(pk=seat_id)   # fila bloqueada
    if seat.esta_libre():
        item = ReservationItem.objects.create(...)             # nadie más toca esta fila aquí

FOR UPDATE bloquea la fila elegida hasta el commit/rollback: el segundo request espera en su SELECT y, al despertar, ve el estado ya confirmado. Variantes que debes conocer:

  • nowait=True: no espera, lanza error (para responder 409 al instante).
  • skip_locked=True: salta filas bloqueadas (patrón de workers de cola: cada worker toma reservas distintas — Lección 29).
  • Deadlock: dos transacciones que bloquean asientos en orden distinto — la regla de orden global de la 03 reaparece: bloquea siempre ordenado (.order_by("id") en el select_for_update del queryset).

4. La transacción de reserva definitiva

python
from django.db import transaction, IntegrityError

@transaction.atomic
def reservar(event_id, seat_ids, user):
    # 1. Bloquea asientos en orden determinista (anti-deadlock)
    seats = list(
        Seat.objects.select_for_update()
        .filter(pk__in=seat_ids, event_id=event_id)
        .order_by("id")
    )
    if len(seats) != len(seat_ids):
        raise Conflict("asiento inexistente")

    # 2. Reglas de negocio con las filas ya bloqueadas
    if any(s.esta_reservado() for s in seats):
        raise Conflict("asiento ocupado")
    evento_debe_estar_abierto(seats[0].event)      # I4

    # 3. Escrituras atómicas
    r = Reservation.objects.create(
        user=user, event=seats[0].event,
        status=ReservationState.PENDING_PAYMENT,
        expires_at=now() + timedelta(minutes=10),  # I2
    )
    ReservationItem.objects.bulk_create([
        ReservationItem(reservation=r, seat=s, price_at_purchase=s.precio()) for s in seats
    ])
    return r

Por qué funciona sin raza: el bloqueo convierte check-then-act en check-then-act con exclusión mutua. La constraint UNIQUE de la 00b queda como red de seguridad (defense in depth): si un camino se salta el bloqueo, la BD rechaza con IntegrityError → 409.

Regla de oro de alcance: dentro de atomic() no hay HTTP externo, ni sleep, ni llamadas lentas (pasarela). La transacción sostiene bloqueos: corta y libera. Lo externo va después (outbox/cola: Lección 30/32).


Autoevaluación

  1. ¿Qué garantía de ACID falla si el proceso muere entre el INSERT de reserva y el de ítems sin transacción? ¿Y con transacción?
  2. ¿Por qué "subir a SERIALIZABLE" no arregla el saldo de la tarjeta regalo por sí solo? ¿Qué es la respuesta correcta?
  3. ¿Qué hace el segundo request exactamente cuando el primero tiene FOR UPDATE sobre la fila? ¿Y con nowait? ¿Y skip_locked?
  4. ¿Por qué el select_for_update().order_by("id") evita deadlocks en reservas de varios asientos?
  5. ¿Qué operaciones JAMÁS van dentro de transaction.atomic() y por qué?

Continúa con los ejercicios. Las soluciones solo tras intentarlo.