Sobreventa de 312 boletas: lecturas sin bloqueo y reservas de 10 minutos
Una ticketera vende 5 000 boletas para un concierto. El flujo actual de reserva es: SELECT cantidad FROM inventario WHERE evento_id=?, la aplicación compara en memoria y luego ejecuta UPDATE inventario SET cantidad=?. PostgreSQL, nivel de aislamiento READ COMMITTED. En la preventa corrieron 200 hilos concurrentes sobre el mismo evento y se vendieron 5 312 boletas: 312 de sobreventa, 97 reembolsos y una multa contractual. Además, el pago por PSE puede tardar hasta 8 minutos, pero la reserva debe expirar a los 10 minutos para no bloquear el inventario. El equipo propone dos soluciones: «subir el aislamiento a SERIALIZABLE» y «poner synchronized en el método Java». Hay 3 instancias de la aplicación detrás de un balanceador de carga.
1. Explique la causa raíz de la sobreventa y evalúe las dos propuestas del equipo.
Read the worked answer
Causa raíz. Es un lost update clásico sobre una operación leer-modificar-escribir. En READ COMMITTED, el SELECT no toma bloqueo: dos transacciones leen 100 unidades, ambas calculan 100 − 2 y ambas escriben 98. La segunda escritura pisa a la primera y se contabilizan dos ventas con una sola unidad descontada. Con 200 hilos sobre el mismo registro, el resultado era inevitable, no mala suerte.
Propuesta 1: synchronized en el método Java. No funciona. synchronized protege solo dentro de una JVM; con 3 instancias detrás del balanceador, tres hilos pueden entrar simultáneamente, uno por instancia. Es una exclusión mutua local aplicada a un recurso compartido distribuido.
Propuesta 2: SERIALIZABLE. Sí previene la anomalía, pero con 200 hilos sobre la misma fila se producen fallos de serialización en cascada: el motor aborta transacciones y la aplicación debe reintentarlas. Si no hay lógica de reintento, el sistema devuelve errores al usuario; si la hay, el throughput se degrada y aparece el riesgo de tormenta de reintentos. Es una solución correcta pero costosa e incompleta sin manejo de errores.
Conclusión. Ninguna de las dos ataca el diseño: el problema es que la decisión de disponibilidad se toma en memoria de la aplicación en lugar de en el motor de base de datos, que sí conoce el estado real.
2. Compare bloqueo optimista, bloqueo pesimista y restricción en base de datos para este caso. Recomiende una y justifique con criterios de contención y concurrencia.
Read the worked answer
Contexto. Alta contención sobre pocas filas (un concierto caliente), 3 instancias, picos de 200 hilos.
Bloqueo optimista con versión. SELECT cantidad, version y luego UPDATE ... SET cantidad=?, version=version+1 WHERE evento_id=? AND version=?; si afecta 0 filas, se reintenta. Funciona bien con contención baja o media; con 200 hilos sobre la misma fila la tasa de colisión es alta y el reintento se convierte en el cuello de botella.
Bloqueo pesimista. SELECT ... FOR UPDATE serializa el acceso a la fila: correcto y predecible, pero las transacciones esperan y se acumulan; con 200 hilos hay riesgo de timeouts y de bloqueos largos, especialmente si el bloqueo se mantiene mientras se habla con PSE.
Actualización condicional atómica (recomendada). UPDATE inventario SET disponible = disponible - :n WHERE evento_id = :id AND disponible >= :n y verificar rowcount = 1. La decisión de disponibilidad ocurre en el motor, en una sola sentencia, sin leer antes y sin mantener bloqueos durante la llamada al pago. Si rowcount = 0, se responde «agotado» sin excepción ni reintento.
Defensa en profundidad. Añadir CHECK (disponible >= 0) como última barrera: un bug futuro no puede producir sobreventa, fallará la transacción.
Sobre el pago de 8 minutos. Nunca mantener una transacción de base de datos abierta durante la llamada a PSE. La reserva se modela como fila propia (reserva con estado y expira_en) y el inventario se descuenta al confirmar la reserva, no al iniciar el pago.
3. Diseñe la expiración de reservas a los 10 minutos sin liberar inventario dos veces ni liberar reservas ya pagadas.
Read the worked answer
Modelo. Tabla reserva(id, evento_id, cantidad, estado, creada_en, expira_en), con estados PENDIENTE_PAGO, CONFIRMADA, EXPIRADA, CANCELADA. El inventario se descuenta al crear la reserva con la actualización condicional atómica; liberar significa devolver la cantidad.
Transición segura (idempotente por construcción).
UPDATE reserva SET estado='EXPIRADA' WHERE id=:id AND estado='PENDIENTE_PAGO' AND expira_en < now().
Si afecta 1 fila, se libera el inventario en la misma transacción. Si afecta 0, la reserva ya fue confirmada o expirada: no se hace nada. La condición de estado es lo que impide liberar dos veces y lo que impide liberar una reserva pagada.
Disparador. Un job programado cada 30–60 segundos que procesa por lotes (SELECT ... FOR UPDATE SKIP LOCKED sobre reservas vencidas) escala mejor que 5 000 temporizadores en memoria, que se pierden si la instancia se reinicia. Un mensaje retardado por reserva es válido, pero debe llevar la misma transición condicional como salvaguarda ante entregas duplicadas (at-least-once).
Carrera crítica: el pago llega después de expirar. El webhook de PSE debe ejecutar UPDATE reserva SET estado='CONFIRMADA' WHERE id=:id AND estado='PENDIENTE_PAGO'. Si afecta 0 filas y el estado es EXPIRADA, no se reactiva la reserva: se registra el pago como «pago sin cupo» y se compensa con reembolso automático, notificando al cliente. Nunca se debe reasignar inventario silenciosamente ni dejar el dinero retenido.
Métrica de control. Comparar boletas confirmadas contra inventario disponible cada hora; cualquier diferencia negativa es incidente con alerta.