Buscar un concierto y comprar un asiento exigen garantías distintas. La búsqueda puede mostrar información ligeramente atrasada y seguir siendo útil. La compra necesita una decisión inequívoca sobre quién obtiene el asiento.

Un sistema de boletaje como el que modelamos al pensar en Ticketmaster debe separar esas operaciones. El diseño prioriza disponibilidad para descubrir y consultar eventos, y consistencia para reservar inventario y confirmar ventas. Estas prioridades se concretan en el comportamiento de cada operación durante un fallo.

Requisitos

El usuario debe poder buscar eventos por texto, ubicación, categoría y fecha. También necesita consultar la sede, los artistas, las condiciones de venta y el mapa de asientos.

La compra tiene dos momentos: reserva temporal y confirmación del pago. La reserva concede un plazo para completar la operación; si vence, el inventario puede volver a asignarse. El usuario debe poder consultar el estado de su compra aunque cierre la página o pierda la conexión.

La regla central es que un asiento de un evento solo puede tener una venta confirmada vigente. El sistema también debe limitar cuánto inventario puede retener un usuario y cuánto tiempo puede hacerlo. Para entradas sin asiento asignado, el equivalente es impedir que las ventas y reservas vigentes superen el aforo disponible.

Entidades e interfaz

El catálogo describe qué se vende; el inventario y las órdenes registran quién puede comprarlo y qué ocurrió con esa compra.

EntidadResponsabilidad
EventoFecha, categoría, sede, artistas y condiciones de venta.
Sede y asientoDistribución física, secciones y asientos identificables.
ArtistaInformación del artista y su relación con los eventos.
InventarioEstado de cada asiento por evento, propietario de la reserva y expiración.
ReservaUsuario, asientos retenidos, identificador y plazo de validez.
Orden y pagoPrecio calculado por el servidor, estado de compra y referencia del proveedor.
BoletoDerecho de acceso emitido al confirmar la venta.

Una interfaz REST puede organizarse así:

EndpointAcción
GET /eventsBuscar con filtros de texto, ubicación, categoría y fecha.
GET /events/{eventId}Consultar detalles y una proyección del mapa de asientos.
POST /holdsSolicitar una reserva temporal de los asientos seleccionados.
POST /ordersIniciar la compra de una reserva propia y vigente.
GET /orders/{orderId}Consultar el resultado de la compra.

La identidad del usuario puede viajar en Authorization: Bearer <JWT>. El servidor verifica la firma y los claims relevantes, como emisor, audiencia y expiración, y comprueba que el usuario tenga acceso a la reserva u orden solicitada. El precio y la pertenencia de una reserva se validan en el servidor.

Las operaciones de reserva y compra aceptan una clave de idempotencia para que un reintento del mismo usuario y operación devuelva el resultado previo. Una restricción de unicidad asocia cada reserva a una sola orden de compra, incluso si llegan solicitudes con claves distintas; esa orden puede registrar varios intentos de pago. Los intentos se coordinan de forma atómica por orden: antes de abrir otro se resuelve o cancela el anterior, y cualquier cobro redundante se registra y compensa. El proveedor de pagos tiene su propio canal de notificaciones autenticadas; un mensaje del navegador no basta para dar una orden por pagada.

Diseño de alto nivel

El catálogo y la búsqueda atienden las consultas frecuentes. El servicio de reservas controla el inventario y las órdenes en Postgres. La cola virtual regula cuántos compradores entran al flujo de reserva durante una venta de alta demanda.

---
config:
  look: handDrawn
---
flowchart TD
    client["Usuario"] -->|"Buscar y consultar"| catalog["Catálogo de eventos"]
    catalog --> search[("Elasticsearch<br/>Índice de búsqueda")]
    client -->|"Entrar a la venta"| queue["Cola virtual<br/>Admisión con Redis"]
    queue --> booking["Reservas y órdenes"]
    booking --> database[("Postgres<br/>Inventario y reservas<br/>Órdenes y outbox")]
    booking <-->|"Pago y resultado verificado"| payment["Proveedor de pagos"]
    database -.->|"Cambios confirmados"| relay["Publicación de eventos"]
    relay -.->|"Actualizar proyección"| search
    relay --> realtime["SSE o long polling<br/>Mapa de asientos"]
    realtime --> client

Postgres conserva el estado autoritativo de la venta. Elasticsearch y el mapa son proyecciones que pueden retrasarse; la reserva vuelve a comprobar la disponibilidad en la base transaccional.

Reserva temporal y concurrencia

Una fila de inventario identificada por (event_id, seat_id) representa el estado de ese asiento en ese evento. La transición a reservado incluye un hold_id, un propietario, una expiración y, si hace falta, una versión que permita detectar operaciones antiguas.

La transacción de reserva debe comprobar la disponibilidad y modificarla de forma atómica. Puede usar una actualización condicional o un bloqueo de fila. Si dos compradores compiten, solo uno obtiene la transición; el otro recibe un conflicto y vuelve a elegir. Las restricciones de unicidad protegen la identidad del inventario y evitan duplicar la venta vigente.

ACID proporciona las herramientas para esa coordinación, pero las condiciones de la transición y las restricciones deben expresar la regla de negocio. Una lectura seguida de una escritura sin coordinación deja abierta la posibilidad de que dos solicitudes crean haber obtenido el mismo asiento.

Al reservar varios asientos, todos se adquieren en una transacción o la operación se revierte. Adquirir los bloqueos en un orden estable ayuda a evitar deadlocks. Esa transacción es corta: el usuario completa el pago después, sin mantener bloqueos de base de datos durante todo el checkout.

Expiración de reservas y Redis

Redis puede representar reservas activas mediante claves con TTL y acelerar consultas del mapa. También puede coordinar selecciones provisionales entre instancias del servicio, evitando repetir escrituras de base de datos por cada cambio de selección. Esto reduce lecturas y contención; no elimina la transacción que concede una reserva firme.

La base conserva hold_id, propietario y expires_at. Una nueva reserva puede recuperar un asiento cuya reserva anterior venció mediante una transición condicional, evaluando el plazo con una referencia temporal del servidor. La expiración lógica se comprueba al operar sobre el inventario: no depende de que un cron limpie la fila exactamente a tiempo. Redis deja de devolver una clave vencida, pero su eliminación física y las notificaciones de expiración no son un reloj preciso para confirmar ventas. Redis con TTL y columnas de expiración resuelven necesidades operativas distintas; ninguno sustituye las condiciones transaccionales.

Ejemplo: una reserva de diez minutos

Para un asiento, el flujo puede ser el siguiente:

  1. El servidor autentica al comprador, crea un hold_id y registra un owner vinculado a esa identidad. Un ticket_id identifica el inventario de un asiento en un evento, no solo el número de asiento de la sede. El servidor define un único plazo de diez minutos para la operación.
  2. Una adquisición provisional puede usar SET hold:<ticket_id> <hold_id:owner> NX EX 600. Redis crea la clave y su TTL de forma atómica solo si no existe; todas las instancias usan la misma clave compartida. Esta exclusión temporal, o lease, coordina solicitudes concurrentes en ese primario de Redis, pero todavía no concede una reserva firme ni garantiza propiedad ante cualquier fallo.
  3. La transacción de Postgres comprueba la disponibilidad y registra el mismo hold_id, propietario y expires_at. Si hay conflicto, se rechaza la solicitud. Haber ganado la clave de Redis no permite saltarse esta validación.
  4. Solo después del commit se publica la reserva firme en las proyecciones del mapa. El TTL de Redis se calcula con el tiempo restante hasta el mismo expires_at; no se reinician otros diez minutos al actualizar la caché. Si la transacción falla, se libera la adquisición provisional únicamente si sigue perteneciendo a ese intento.
  5. Al confirmar el pago, se vuelve a validar en Postgres que el mismo hold_id pertenece al comprador, sigue vigente y puede pasar a vendido. Una lectura de Redis no basta para confirmar la compra.

Para liberar o renovar una clave hay que comparar su token y modificarla en una sola operación atómica, por ejemplo con Lua. Un GET seguido de DEL deja una carrera: el lease puede vencer, otro comprador adquirir la clave y el proceso anterior borrar la reserva nueva. Compare-and-delete evita ese borrado ajeno; no impide por sí solo que un proceso atrasado intente actuar sobre el inventario, que sigue protegido por la identidad y las condiciones de Postgres.

Cerrar el navegador no libera automáticamente la clave. Una cancelación anticipada requiere una petición autenticada al servidor, que cancela la reserva propia en la base y libera su clave condicionalmente. Si esa petición no llega, el plazo vence; un worker puede limpiar después los registros expirados sin determinar cuándo dejaron de ser válidos.

Un timeout de Redis puede dejar incierto si la adquisición se ejecutó, y un failover puede perder una clave. La recuperación consulta el estado por hold_id y usa reintentos idempotentes; una clave ausente no concede derechos sobre un asiento que Postgres todavía reserva. Si se permite continuar sin Redis, se limita la admisión para que el fallback no sature la base.

El mapa puede combinar inventario de la base con la proyección de reservas en Redis, sin consultar la base por cada actualización del navegador. Esa vista puede retrasarse y no constituye un snapshot atómico entre ambos sistemas: una clave ausente no debe anular una venta o reserva vigente en Postgres. El usuario puede recibir un conflicto aunque el asiento pareciera disponible.

Confirmación del pago

Reservar y pagar son dos etapas del producto. Postgres y el proveedor de pagos no comparten una sola transacción ACID, por lo que el diseño necesita estados explícitos, reintentos seguros y compensación.

El servidor crea una orden asociada a la reserva y registra el intento de pago. Después solicita el pago fuera de la transacción de inventario. Al verificar el resultado, una transacción corta comprueba que la orden sigue siendo confirmable y que los asientos siguen reservados para ese mismo hold_id, todavía vigente. Entonces marca el inventario como vendido y registra los boletos emitidos.

Si el pago llega después de que la reserva venza o el asiento se reasigne, el flujo debe anular o reembolsar el cobro según el estado del proveedor. Una confirmación antigua nunca puede apropiarse de la reserva nueva. La compensación también queda registrada y se reintenta si es necesario.

Las notificaciones pueden duplicarse o llegar fuera de orden. El consumidor valida su autenticidad, deduplica por identificador y aplica únicamente transiciones válidas. Una reconciliación consulta los pagos que quedaron en estado incierto, por ejemplo cuando hubo un timeout después de enviar el cobro. Si el proveedor admite claves de idempotencia, el reintento usa la misma clave dentro de la ventana y las condiciones que garantiza su contrato. Fuera de esa garantía, hay que resolver el estado del intento anterior antes de iniciar otro cobro.

Búsqueda y actualizaciones del mapa

Elasticsearch permite combinar búsqueda de texto, filtros y consultas geográficas. Postgres mantiene los datos del catálogo y el inventario; el índice de búsqueda se actualiza de forma asíncrona. Sus resultados pueden mostrar un evento cuyo último asiento acaba de venderse, por lo que la disponibilidad se valida de nuevo al reservar.

Para publicar cambios sin perderlos entre el commit y el envío, la transacción escribe también un registro en una outbox. Un proceso publica esos registros hacia las proyecciones. La entrega puede repetirse: cada consumidor debe deduplicar o aplicar versiones de manera idempotente.

El mapa puede actualizarse mediante SSE, que mantiene un flujo de eventos del servidor al navegador, o long polling, que conserva una petición hasta que hay cambios o vence su plazo. Cada actualización lleva una versión o un identificador. Tras una desconexión, el cliente recupera los eventos disponibles o solicita un nuevo snapshot si perdió parte del historial.

Un mapa reciente ayuda a elegir, pero la confirmación de la reserva sigue siendo la decisión que concede el asiento. El cliente debe manejar un conflicto aunque el asiento pareciera libre al hacer clic.

Cola virtual y ventas masivas

Cuando un evento concentra demanda, escalar el catálogo no elimina la competencia sobre su inventario. La cola virtual mantiene una espera controlada y admite compradores al ritmo que soportan el checkout, la base de datos y el proveedor de pagos.

Redis puede mantener la posición y las ventanas de admisión. La política debe definir el orden, los límites por identidad, el tratamiento de reconexiones y qué ocurre si falla la cola. Cada comprador admitido recibe un permiso verificable y de duración acotada. Los endpoints de reserva comprueban ese permiso para que acceder directamente a la API no permita saltarse la espera.

La admisión habilita el acceso al flujo de compra; el asiento se obtiene con la transacción de reserva. Se pueden particionar datos por evento, pero una venta popular sigue creando un punto de concentración que debe gestionarse con control de concurrencia y límites de admisión.

Qué verificar

Las pruebas centrales hacen competir a varios compradores por el mismo asiento, vencen una reserva durante el pago y repiten solicitudes o notificaciones. También deben cubrir pagos tardíos, caídas de Redis, retraso de la outbox y reconexión al mapa.

Las señales útiles son conflictos de reserva, tiempo en cola, duración del checkout, pagos sin orden confirmada, compensaciones pendientes y retraso de las proyecciones. El sistema funciona cuando puede explicar quién obtuvo cada asiento, qué ocurrió con el dinero y cómo se recupera una operación incompleta.