Venta de entradas de conciertos: cola virtual para millones
1. Descripción general
Un gran concierto abre las ventas a las 10:00. Unos segundos después, millones de personas presionan “Comprar entrada” para competir por alrededor de 50 000 asientos. Si dejas que todas estas solicitudes lleguen a la base de datos, el sistema colapsará. Si lo bloqueas incorrectamente, dos personas podrían comprar el mismo asiento.
Este artículo recorre cada paso del diseño: requisitos, modelo de datos, API y flujos de procesamiento. El enfoque se centra en tres desafíos: picos de carga, contención de asientos y consistencia en pagos. Al final, podrás construir una cola virtual con Redis, un mecanismo para mantener asientos sin venderlos dos veces y un flujo de confirmación de pedidos seguro ante llamadas repetidas.
2. Requisitos del problema
2.1. Requisitos funcionales
- El usuario ingresa a la página del evento y entra en una cola virtual antes de que abra la venta.
- El sistema deja entrar a los usuarios en la sección de selección de asientos por tandas, en orden de llegada.
- El usuario selecciona hasta 4 asientos. Los asientos se mantienen durante 5 minutos para el pago.
- Si el pago es exitoso, el asiento pasa al estado vendido y se emite el ticket.
- Si expira sin pago, el asiento se devuelve al inventario.
2.2. Escala y restricciones
| Métrica | Valor estimado |
|---|---|
| Usuarios concurrentes en apertura | 2 000 000 |
| Número de asientos | 50 000 |
| RPS pico en la página del evento | ~500 000 RPS |
| Usuarios en la sección de selección simultáneamente | 5 000 |
La restricción más importante es nunca vender el mismo asiento dos veces. La consistencia aquí debe ser strong consistency. Por el contrario, la posición en la cola solo necesita ser aproximada: mostrar “hay aproximadamente 12 000 personas delante” es suficiente.
De estos dos números se puede extraer una conclusión fundamental: solo el 2,5% de los usuarios obtendrán entrada. Por lo tanto, el sistema debe rechazar o retrasar la mayoría de las solicitudes en la capa más económica posible, antes de que lleguen a la base de datos.
3. Arquitectura y flujo de procesamiento
Client ──> CDN (página estática, página de espera)
│
├─> Queue Service ──> Redis (ZSET cola, token de acceso)
│ ▲
│ └── Admitter (scheduled, libera N usuarios/segundo)
│
└─> Booking Service (requiere token de acceso)
├─> PostgreSQL (asiento, pedido, outbox)
└─> Kafka (ticket-issued) ──> Ticket/Email Service
El flujo principal ocurre así:
- El cliente llama a
POST /events/{id}/queuey recibequeueId. - El cliente consulta
GET /events/{id}/queue/{queueId}cada pocos segundos para ver su posición. Cuando llega su turno, recibeaccessToken. - El cliente hace una llamada a
POST /events/{id}/holdscon el token, indicando los asientos. - El cliente paga. El proveedor de pagos llama al webhook, el sistema invoca
POST /orders/{id}/confirmcon una clave de idempotencia. - Booking Service escribe el pedido y el evento outbox en la misma transacción. Luego el evento se envía a Kafka para emitir el ticket.
La página de espera se sirve estáticamente desde CDN. Solo las APIs de join y poll llegan al backend, y ambas solo acceden a Redis.
4. Modelo de datos
CREATE TABLE seat (
event_id BIGINT NOT NULL,
seat_id VARCHAR(16) NOT NULL,
section VARCHAR(16) NOT NULL,
price NUMERIC(12,2) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE', -- DISPONIBLE | RETENIDO | VENDIDO
held_by BIGINT,
hold_expires_at TIMESTAMP,
PRIMARY KEY (event_id, seat_id)
);
CREATE TABLE ticket_order (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
event_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL, -- PENDIENTE | PAGADO | VENCIDO
idempotency_key VARCHAR(64) NOT NULL UNIQUE
);
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
topic VARCHAR(64) NOT NULL,
payload TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
El estado del asiento se almacena en la tabla seat. Por lo tanto, mantener un asiento requiere solo una sentencia UPDATE condicional. La restricción UNIQUE en idempotency_key es la última defensa contra duplicación de pedidos.
5. Cola virtual con Redis
Agrega la dependencia:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
5.1. Entrar en la cola
La cola es un Redis Sorted Set. El score es un número de secuencia que aumenta, obtenido de INCR:
@Service
public class QueueService {
private final StringRedisTemplate redis;
public QueueService(StringRedisTemplate redis) {
this.redis = redis;
}
public long join(long eventId, long customerId) {
String queueKey = "queue:" + eventId;
String member = String.valueOf(customerId);
Long existing = redis.opsForZSet().rank(queueKey, member);
if (existing != null) {
return existing;
}
Long seq = redis.opsForValue().increment("queue:seq:" + eventId);
redis.opsForZSet().addIfAbsent(queueKey, member, seq);
return redis.opsForZSet().rank(queueKey, member);
}
public Long position(long eventId, long customerId) {
return redis.opsForZSet().rank("queue:" + eventId, String.valueOf(customerId));
}
}
Explicación:
incrementdevuelve un número de secuencia global. No usas timestamp como score porque muchas solicitudes podrían coincidir en el mismo milisegundo.addIfAbsentcorresponde aZADD NX. Aunque el usuario presione F5 constantemente, no será empujado al final de la cola.rank(ZRANK) tiene complejidad O(log N). Con 2 millones de elementos, este comando solo cuesta algunos microsegundos.
Tres personas que entran a la cola uno tras otro reciben los siguientes resultados:
join(1, 101) -> 0
join(1, 202) -> 1
join(1, 101) -> 0 // vuelve a entrar, mantiene su posición
Nota: Si necesitas bloquear bots, autentica al usuario (login, captcha) antes de permitirle entrar a la cola. Una vez en el ZSET, los bots ocupan un lugar en la cola igual que los usuarios reales.
5.2. Admitter: liberar usuarios por lotes
Un job que se ejecuta cada segundo. Este job verifica cuántos espacios quedan en la sección de selección de asientos y luego saca exactamente esa cantidad de personas del frente de la cola:
@Component
public class Admitter {
private static final int MAX_ACTIVE = 5_000;
private static final Duration TOKEN_TTL = Duration.ofMinutes(10);
private final StringRedisTemplate redis;
public Admitter(StringRedisTemplate redis) {
this.redis = redis;
}
@Scheduled(fixedRate = 1000)
public void admit() {
long eventId = 1L;
String activeKey = "active:" + eventId;
long now = System.currentTimeMillis();
redis.opsForZSet().removeRangeByScore(activeKey, 0, now);
long active = Optional.ofNullable(redis.opsForZSet().zCard(activeKey)).orElse(0L);
long slots = MAX_ACTIVE - active;
if (slots <= 0) return;
var batch = redis.opsForZSet().popMin("queue:" + eventId, slots);
if (batch == null) return;
for (var tuple : batch) {
String customerId = tuple.getValue();
String token = UUID.randomUUID().toString();
redis.opsForValue().set("access:" + token, customerId, TOKEN_TTL);
redis.opsForValue().set("token-of:" + eventId + ":" + customerId, token, TOKEN_TTL);
redis.opsForZSet().add(activeKey, customerId, now + TOKEN_TTL.toMillis());
}
}
}
Explicación:
active:{eventId}es un ZSET con score igual a la hora de vencimiento.removeRangeByScorelimpia a los usuarios cuya sesión ha expirado, y luegozCardindica cuántos usuarios están en la sección de selección.popMin(ZPOPMIN) extrae y elimina los primeros N elementos de la cola.- Cuando se consulta (poll), el cliente lee
token-of:.... Si la clave existe, significa que ha llegado su turno y el cliente recibe el token.
Necesitas habilitar @EnableScheduling y ejecutar este job en exactamente una instancia, por ejemplo usando ShedLock o leader election. Si dos instancias se ejecutan simultáneamente, la sección de selección podría exceder MAX_ACTIVE.
Tampoco necesitas ejecutar múltiples Admitters para manejar 2 millones de personas. La velocidad de liberación de usuarios está limitada por la capacidad del Booking Service, no por Redis.
5.3. Bloquear solicitudes sin token
@Component
public class AccessTokenFilter extends OncePerRequestFilter {
private final StringRedisTemplate redis;
public AccessTokenFilter(StringRedisTemplate redis) {
this.redis = redis;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().contains("/holds");
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String token = req.getHeader("X-Queue-Token");
String customerId = token == null ? null : redis.opsForValue().get("access:" + token);
if (customerId == null) {
res.sendError(HttpServletResponse.SC_FORBIDDEN, "No admitido");
return;
}
req.setAttribute("customerId", Long.valueOf(customerId));
chain.doFilter(req, res);
}
}
Este filtro usa el token como clave de búsqueda y no confía en el customerId enviado por el cliente. Los usuarios no pueden colarse llamando directamente a la API /holds.
6. Mantener asientos: manejo de contención
Múltiples personas pueden seleccionar el mismo asiento A-12. La forma más simple y correcta es dejar que la base de datos lo decida con una sentencia UPDATE condicional:
@Service
public class SeatHoldService {
private static final Duration HOLD_TTL = Duration.ofMinutes(5);
private final JdbcTemplate jdbc;
public SeatHoldService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public void hold(long eventId, long customerId, List<String> seatIds) {
if (seatIds.size() > 4) throw new IllegalArgumentException("Máximo 4 asientos");
Timestamp now = Timestamp.from(Instant.now());
Timestamp expires = Timestamp.from(Instant.now().plus(HOLD_TTL));
for (String seatId : seatIds.stream().sorted().toList()) {
int updated = jdbc.update("""
UPDATE seat SET status = 'HELD', held_by = ?, hold_expires_at = ?
WHERE event_id = ? AND seat_id = ?
AND (status = 'AVAILABLE' OR (status = 'HELD' AND hold_expires_at < ?))
""", customerId, expires, eventId, seatId, now);
if (updated == 0) {
throw new SeatUnavailableException(seatId);
}
}
}
}
class SeatUnavailableException extends RuntimeException {
SeatUnavailableException(String seatId) { super("Asiento ocupado: " + seatId); }
}
Explicación:
- La cláusula
WHEREverifica y actualiza en un comando atómico. La base de datos bloquea esa fila. Las solicitudes posteriores esperarán, luego reevaluarán la condición y veránstatus = 'HELD', recibiendoupdated = 0. - La condición
hold_expires_at < ?permite recuperar asientos que ya han vencido sin esperar un job de limpieza. - Si un asiento en el grupo falla, la excepción hace rollback de toda la transacción. El usuario nunca termina con un grupo “parcial” de asientos.
- Ordenar
seatIdsantes de actualizar ayuda a que cada transacción bloquee en el mismo orden, evitando deadlock cuando dos personas seleccionan cruzadamente, por ejemplo {A-1, A-2} y {A-2, A-1}.
Nota: No sigas el patrón de ‘SELECT para verificar estado y luego UPDATE’ sin lock. Ese es el race condition clásico que lleva a vender el mismo asiento dos veces.
6.1. Test del comportamiento contra venta duplicada
Hacemos que 50 threads compitan por el mismo asiento. El resultado esperado es que solo exactamente un thread tenga éxito.
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
@JdbcTest
@Import(SeatHoldService.class)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class SeatHoldServiceTest {
@Autowired JdbcTemplate jdbc;
@Autowired SeatHoldService service;
@Test
void onlyOneCustomerCanHoldTheSameSeat() throws Exception {
jdbc.update("DELETE FROM seat");
jdbc.update("INSERT INTO seat(event_id, seat_id, section, price) VALUES (1, 'A-12', 'VIP', 3000000)");
AtomicInteger success = new AtomicInteger();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (long customer = 1; customer <= 50; customer++) {
long id = customer;
executor.submit(() -> {
try {
service.hold(1L, id, List.of("A-12"));
success.incrementAndGet();
} catch (SeatUnavailableException ignored) { }
});
}
}
assertThat(success.get()).isEqualTo(1);
assertThat(jdbc.queryForObject(
"SELECT status FROM seat WHERE seat_id = 'A-12'", String.class)).isEqualTo("HELD");
}
}
La tabla seat se crea desde src/test/resources/schema.sql. Usa la sentencia CREATE TABLE seat de la sección 4, reemplazando BIGSERIAL con BIGINT AUTO_INCREMENT si creas las otras tablas. @Transactional(NOT_SUPPORTED) desactiva la transacción que envuelve el test, permitiendo que los datos se comprometan para que otros threads los vean.
7. Pago y consistencia
El webhook del proveedor de pagos puede ser invocado múltiples veces. Por lo tanto, el paso de confirmación de pedido debe ser idempotente:
@Transactional
public void confirm(long orderId, long customerId, long eventId, String idempotencyKey) {
int inserted = jdbc.update("""
INSERT INTO ticket_order(id, customer_id, event_id, status, idempotency_key)
VALUES (?, ?, ?, 'PAID', ?) ON CONFLICT (idempotency_key) DO NOTHING
""", orderId, customerId, eventId, idempotencyKey);
if (inserted == 0) return; // ya se procesó antes
int sold = jdbc.update("""
UPDATE seat SET status = 'SOLD'
WHERE event_id = ? AND held_by = ? AND status = 'HELD'
""", eventId, customerId);
if (sold == 0) throw new IllegalStateException("La retención expiró, se requiere reembolso");
jdbc.update("INSERT INTO outbox(topic, payload) VALUES ('ticket-issued', ?)",
"{\"orderId\":" + orderId + "}");
}
Este código maneja tres cosas:
ON CONFLICT DO NOTHING(sintaxis de PostgreSQL) asegura que webhooks repetidos no crean dos pedidos.- Los asientos solo se cambian a
SOLDsi aún están siendo mantenidos por este cliente. Si el hold ha expirado y el asiento pertenece a otro, la transacción hace rollback. Entonces el pedido debe enviarse al flujo de reembolso. - Transactional outbox: el evento se escribe en la misma transacción que el pedido. Un proceso separado (o Debezium) lee la tabla
outboxy lo envía a Kafka. Esto garantiza que no hay casos donde el pedido se haya guardado pero el ticket no se emita, o viceversa.
Nota: Establece el TTL del hold un poco más largo que el timeout de la página de pago, por ejemplo hold 5 minutos y página de pago 4 minutos. Este búfer reduce los casos donde el cliente ha pagado pero el hold acaba de expirar.
8. Puntos difíciles y errores comunes
- Picos de carga en la capa inicial: la página de espera debe ser una página estática en CDN. La API de consulta debe devolver un header sugiriendo el intervalo de consulta, por ejemplo 5–15 segundos según la posición. Si el cliente consulta cada segundo, 2 millones de personas generarían 2 millones de RPS a Redis.
- Redis es un punto de fallo único: usa Redis Cluster o Sentinel. Si solo hay un evento grande, el ZSET completo está en una clave, por lo que estará en un shard. Un nodo Redis puede manejar cientos de miles de ops/s, pero debes medir primero.
- Mostrar ‘asientos disponibles’ incorrectamente: el número de asientos vacíos en la interfaz puede leerse de un cache y aceptar un retraso de unos pocos segundos. Solo el UPDATE en la sección 6 es la fuente de verdad.
- Holds basura: el usuario mantiene asientos y cierra la pestaña. La condición de vencimiento en el UPDATE ya maneja la recuperación. Solo necesitas agregar un job periódico para devolver asientos vencidos a
AVAILABLEpara que la interfaz muestre correctamente. - Cuándo NO necesitas cola virtual: para eventos con cientos de asientos y tráfico moderado, rate limiting más UPDATE condicional es suficiente. La cola virtual añade complejidad operativa y ralentiza la experiencia. Solo úsala cuando el volumen de compradores supere significativamente la capacidad de la capa de reserva.
9. Conclusión
El sistema de venta de entradas maneja bien la carga al separar dos preocupaciones. La cola virtual en Redis absorbe los picos de tráfico y mantiene la equidad. La base de datos solo procesa a los pocos usuarios que han sido liberados y garantiza que no haya ventas duplicadas mediante UPDATE condicional.
Has visto cómo usar ZADD NX y ZPOPMIN para encolar y liberar usuarios por lotes, cómo mantener asientos de manera atómica y evitar deadlocks, y cómo confirmar pedidos de manera idempotente combinado con transactional outbox para enviar eventos a Kafka. A partir de este marco, puedes expandir con protección contra bots, colas prioritarias para miembros, o múltiples eventos ejecutándose en paralelo.