Sistema de venta de entradas: bloqueo temporal y expiración de reservas
1. Descripción general
Cuando se abre la venta de entradas para la primera función de una película de gran éxito, miles de personas hacen clic simultáneamente en un mapa de asientos con solo unos pocos cientos de lugares. El sistema debe garantizar que cada asiento pertenezca solo a una persona. Los asientos se retienen temporalmente mientras el cliente paga y deben liberarse automáticamente si el cliente se va.
Este artículo cubre el diseño completo: requisitos, modelo de datos, API, flujo de procesamiento y puntos difíciles. El código de ejemplo utiliza Java 21, Spring Boot 3.x, SQL y Redis. Después de leer, podrá implementar un mecanismo seguro de retención de asientos bajo múltiples solicitudes concurrentes. También aprenderá cómo manejar asientos expirados sin depender de un trabajo de limpieza.
2. Requisitos
2.1. Requisitos funcionales
- El cliente ve un mapa de asientos de una función, con estados vacío, retenido o vendido.
- El cliente selecciona hasta 8 asientos y los retiene durante 10 minutos.
- La retención de asientos es todo o nada: si un asiento no se puede retener, ninguno se retiene.
- Si el cliente paga durante el tiempo de retención, el asiento cambia a vendido.
- Si pasan 10 minutos sin pago, el asiento se libera automáticamente.
2.2. Escala y restricciones
- Aproximadamente 500 cines, cada uno con 10 salas, cada sala con 100–300 asientos.
- En condiciones normales, hay cientos de solicitudes por segundo. Cuando se abre la venta de películas populares, puede alcanzar 50.000 solicitudes por segundo, concentradas en algunos de los estrenos.
- No permitir la venta duplicada de asientos. Este es un requisito crítico.
- La proporción lectura-escritura es muy sesgada: una retención de asiento va acompañada de docenas de visualizaciones de mapas.
- El pago se procesa a través de una puerta de pago externa. El callback puede llegar tarde, llegar dos veces o nunca llegar.
3. Modelo de datos
La tabla central es show_seat. Cada fila corresponde a un asiento en una función específica. No separamos una tabla de ‘retención’ porque el estado del asiento e información de retención deben actualizarse en una sola declaración atómica.
CREATE TABLE show_seat (
showtime_id BIGINT NOT NULL,
seat_code VARCHAR(8) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE | HELD | BOOKED
hold_id UUID,
customer_id BIGINT,
hold_expires_at TIMESTAMP,
booking_id BIGINT,
PRIMARY KEY (showtime_id, seat_code)
);
CREATE INDEX idx_show_seat_hold ON show_seat (hold_id);
CREATE INDEX idx_show_seat_expired ON show_seat (status, hold_expires_at);
Algunos puntos a considerar:
- La clave principal
(showtime_id, seat_code)permite actualizar rápidamente un grupo de asientos de la misma función. hold_idagrupa asientos de la misma retención. El paso de pago confirmará todo el grupo según estehold_id.- El índice
(status, hold_expires_at)sirve para el trabajo de limpieza de asientos expirados en la sección 7.
Las tablas showtime, booking y payment son tablas CRUD estándar, por lo que no se presentan en detalle.
4. API
GET /showtimes/{showtimeId}/seats -> mapa de asientos + estado
POST /showtimes/{showtimeId}/holds -> retener asientos, body: {"seats":["F5","F6"]}
201 {"holdId":"...","expiresAt":"..."} | 409 si el asiento está ocupado
POST /holds/{holdId}/checkout -> crear sesión de pago (header Idempotency-Key)
DELETE /holds/{holdId} -> cliente cancela, libera asiento inmediatamente
POST /payments/callback -> gateway de pago llama
Devuelve 409 Conflict cuando otro cliente ha retenido el asiento. El cliente usa este código para actualizar el mapa y pedir al cliente que elija otro asiento.
5. Flujo de procesamiento
Cliente API BD / Redis Puerta de pago
| elige | | |
| asiento | | |
|------------>| UPDATE condicional | |
| |---------------------->| HELD, expira después 10' |
|<------------| 201 holdId | |
| proceder | | |
| al pago | | |
|------------>| crear pago (8') |------------------------->|
| | | callback ÉXITO |
| |<-------------------------------------------------|
| | HELD -> BOOKED | |
| | si aún no ha expirado | |
La sesión de pago se configura para expirar después de 8 minutos, más corta que el tiempo de retención. El búfer de 2 minutos ayuda a que la mayoría de devoluciones de llamada lleguen a tiempo antes de que se libere el asiento.
6. Bloqueo temporal de asientos
Dependencias requeridas:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
6.1. La declaración UPDATE condicional como fuente de verdad
El patrón incorrecto común es leer primero, escribir después: SELECT verifica que el asiento está libre, luego UPDATE. Entre las dos declaraciones, otra solicitud puede insertarse y tomar el asiento. El resultado es que dos personas creen que han retenido el asiento.
La forma correcta es incorporar la verificación en la condición WHERE, luego contar el número de filas afectadas:
@Service
public class SeatHoldService {
private static final Duration HOLD_TTL = Duration.ofMinutes(10);
private final NamedParameterJdbcTemplate jdbc;
private final Clock clock;
public SeatHoldService(NamedParameterJdbcTemplate jdbc, Clock clock) {
this.jdbc = jdbc;
this.clock = clock;
}
@Transactional
public SeatHold hold(long showtimeId, List<String> seatCodes, long customerId) {
UUID holdId = UUID.randomUUID();
Instant now = clock.instant();
Instant expiresAt = now.plus(HOLD_TTL);
var params = new MapSqlParameterSource()
.addValue("holdId", holdId)
.addValue("customerId", customerId)
.addValue("showtimeId", showtimeId)
.addValue("seatCodes", seatCodes.stream().sorted().toList())
.addValue("now", Timestamp.from(now))
.addValue("expiresAt", Timestamp.from(expiresAt));
int updated = jdbc.update("""
UPDATE show_seat
SET status = 'HELD', hold_id = :holdId,
customer_id = :customerId, hold_expires_at = :expiresAt
WHERE showtime_id = :showtimeId
AND seat_code IN (:seatCodes)
AND (status = 'AVAILABLE'
OR (status = 'HELD' AND hold_expires_at < :now))
""", params);
if (updated != seatCodes.size()) {
throw new SeatUnavailableException(showtimeId, seatCodes);
}
return new SeatHold(holdId, expiresAt);
}
}
public record SeatHold(UUID holdId, Instant expiresAt) {}
public class SeatUnavailableException extends RuntimeException {
public SeatUnavailableException(long showtimeId, List<String> seats) {
super("Los asientos " + seats + " del estreno " + showtimeId + " no están disponibles");
}
}
El código funciona de la siguiente manera:
- La declaración
UPDATEadquiere un bloqueo de fila en cada fila de asiento. Dos transacciones dirigidas al asientoF5deben hacer cola. La transacción posterior reevalúa la condiciónWHEREy vestatus = 'HELD', por lo que no puede actualizar esa fila. - La condición
status = 'HELD' AND hold_expires_at < :nowes lazy expiration. Un asiento cuya retención ha expirado se considera vacío, incluso si ningún trabajo lo ha limpiado. - Si el número de filas actualizadas es menor que el número de asientos solicitados, lanzamos una excepción.
@Transactionalrevierte toda la operación, por lo que obtenemos semántica all-or-nothing. Clockse inyecta en el servicio para que las pruebas puedan controlar el tiempo.
Supongamos que el cliente A retiene F5, F6, luego el cliente B retiene F6, F7. Entonces B recibe:
HTTP 409 Conflict
{"error":"Los asientos [F6, F7] del estreno 42 no están disponibles"}
El asiento F7 de B sigue vacío porque la transacción de B fue revertida.
Nota: Ordena
seatCodesantes de actualizar. Si A bloquea en el ordenF5 → F6y B bloquea en el ordenF6 → F5, dos transacciones pueden entrar en deadlock. Con PostgreSQL, el orden de bloqueo depende del orden de exploración de filas. Para estar seguro, puedes ejecutarSELECT ... ORDER BY seat_code FOR UPDATEantes de actualizar.
6.2. Redis como capa de bloqueo rápido
Cuando hay un pico de carga, 50.000 solicitudes de retención golpean simultáneamente a cientos de filas de una función. La base de datos tendrá que hacer cola con bloqueos muy largos. Colocamos Redis al frente para descartar rápidamente las solicitudes que seguramente fallarán. El script de Lua a continuación hace que las operaciones en múltiples claves sean atómicas:
@Component
public class RedisSeatGate {
private static final RedisScript<Long> TRY_HOLD = new DefaultRedisScript<>("""
for _, key in ipairs(KEYS) do
if redis.call('EXISTS', key) == 1 then return 0 end
end
for _, key in ipairs(KEYS) do
redis.call('SET', key, ARGV[1], 'PX', ARGV[2])
end
return 1
""", Long.class);
private final StringRedisTemplate redis;
public RedisSeatGate(StringRedisTemplate redis) {
this.redis = redis;
}
public boolean tryHold(long showtimeId, List<String> seats, UUID holdId, Duration ttl) {
List<String> keys = seats.stream()
.map(s -> "seat:{" + showtimeId + "}:" + s)
.toList();
Long ok = redis.execute(TRY_HOLD, keys, holdId.toString(),
String.valueOf(ttl.toMillis()));
return ok != null && ok == 1L;
}
}
Cómo usarlo:
- Redis ejecuta el script de Lua en un único subproceso. Ninguna solicitud puede insertarse entre la verificación
EXISTSySET. PXadjunta un TTL a la clave, por lo que Redis elimina automáticamente la clave cuando expira. Este es el mecanismo de expiración natural de la capa de caché.- La parte
{showtimeId}es un hash tag. En Redis Cluster, todas las claves de una función residen en el mismo slot. Si falta el hash tag, el script multi-clave reportará un errorCROSSSLOT.
El flujo completo es: llamar a tryHold en Redis primero. Si falla, devuelve 409 inmediatamente. Si tiene éxito, ejecuta la declaración UPDATE en la sección 6.1. Si UPDATE falla, elimina la clave de Redis que acaba de establecer.
Nota: Redis no es la fuente de verdad. Redis puede perder datos durante una conmutación por error, y la expiración de claves puede desviarse unos milisegundos respecto a la BD. La decisión final siempre es la declaración
UPDATEcondicional. Si su sistema no tiene picos de carga, elimine completamente la capa de Redis para reducir un componente que operar.
7. Expiración de la retención
La corrección de la expiración ya está garantizada por la condición hold_expires_at < :now en la sección 6.1. Sin embargo, el mapa de asientos todavía muestra HELD hasta que alguien lo sobrescribe. Por lo tanto, necesitamos un trabajo de limpieza adicional para que la interfaz muestre el estado correcto:
@Component
public class ExpiredHoldSweeper {
private final JdbcTemplate jdbc;
private final Clock clock;
public ExpiredHoldSweeper(JdbcTemplate jdbc, Clock clock) {
this.jdbc = jdbc;
this.clock = clock;
}
@Scheduled(fixedDelay = 15_000)
public void releaseExpired() {
int released = jdbc.update("""
UPDATE show_seat
SET status = 'AVAILABLE', hold_id = NULL,
customer_id = NULL, hold_expires_at = NULL
WHERE status = 'HELD' AND hold_expires_at < ?
""", Timestamp.from(clock.instant()));
if (released > 0) {
// publicar SeatsReleased para actualizar caché de mapa de asientos
}
}
}
Este trabajo se ejecuta cada 15 segundos. No hay problema si se ejecuta tarde o en múltiples instancias simultáneamente, ya que la declaración es idempotente. Gracias a lazy expiration, si el trabajo falla durante unos minutos, tampoco causará ventas duplicadas de asientos. La única consecuencia es que la interfaz mostrará información desactualizada durante ese período. Recuerda habilitar @EnableScheduling en la configuración.
El lado de confirmación de pago debe verificar la expiración en la misma declaración:
@Transactional
public boolean confirm(UUID holdId, long bookingId) {
int updated = jdbc.update("""
UPDATE show_seat
SET status = 'BOOKED', booking_id = :bookingId, hold_expires_at = NULL
WHERE hold_id = :holdId AND status = 'HELD'
AND hold_expires_at >= :now
""", new MapSqlParameterSource()
.addValue("holdId", holdId)
.addValue("bookingId", bookingId)
.addValue("now", Timestamp.from(clock.instant())));
return updated > 0;
}
Esta función devuelve false en dos casos: la retención ha expirado o el asiento ya ha sido retenido por otro. Ambos casos necesitan ser manejados en el paso de pago.
8. Consistencia con el pago
El callback de la puerta de pago es el lugar más propenso a errores. Hay tres escenarios que debe manejar:
- El callback llega dos veces. Almacena
payment_idcon restricciónUNIQUE. La segunda vez que vea que la transacción ya se ha procesado, devuelve200y no hace nada más. - El callback llega después de que el asiento expira.
confirmdevuelvefalse. En este punto, el sistema ha cobrado pero no puede entregar el asiento. Marcamos la reserva comoFAILEDe emitimos una orden de reembolso automático. No intentes ‘recuperar’ el asiento, porque es posible que otro cliente lo haya comprado. - El callback nunca llega. Un trabajo de reconciliación periódico consulta el estado de los pagos
PENDINGque han estado esperando más de 15 minutos desde la puerta, y luego aplica la lógica de los dos escenarios anteriores.
Después de que confirm tenga éxito, los pasos como enviar correo electrónico, generar entradas QR o acumular puntos de membresía no deben ejecutarse en la misma transacción. Escriba una fila BookingConfirmed en la tabla outbox en esa transacción. Un proceso separado empujará el evento a Kafka, con el tema booking-events y la clave siendo bookingId. Este enfoque garantiza que el evento solo se emita cuando la BD ha confirmado, y cuando confirma, el evento se emitirá con certeza.
9. Defensa contra picos de carga
La capa de Redis en la sección 6.2 solo aborda la parte de escritura. Cuando abre la venta de películas populares, necesita agregar las siguientes medidas:
- Sala de espera virtual. Cada función popular solo permite que N personas entren a la página de selección de asientos al mismo tiempo. El resto recibe un token de cola, almacenado en un conjunto ordenado de Redis con la puntuación siendo la hora de llegada. Unos pocos cientos de asientos no necesitan 50.000 personas seleccionando al mismo tiempo.
- Cache de mapa de asientos. El mapa se lee de Redis o CDN y se actualiza a través de eventos
SeatsHeld/SeatsReleased. Un mapa retrasado de 1-2 segundos sigue siendo aceptable, porque el paso de retención verificará de nuevo con precisión. - Límite de tasa por cliente. Por ejemplo, máximo 5 retenciones por minuto, para bloquear bots que rastrean asientos.
- Particionamiento por
showtime_id. Toda la contención está dentro de una sola función. Puede fragmentar la BD porshowtime_idsin necesidad de transacciones distribuidas.
10. Pruebas de concurrencia
El comportamiento más importante a verificar es: si múltiples clientes intentan retener el mismo asiento, solo uno debe ganar.
@SpringBootTest
class SeatHoldServiceTest {
@Autowired SeatHoldService service;
@Autowired JdbcTemplate jdbc;
@BeforeEach
void setUp() {
jdbc.update("DELETE FROM show_seat");
jdbc.update("INSERT INTO show_seat (showtime_id, seat_code) VALUES (42, 'F5'), (42, 'F6')");
}
@Test
void onlyOneCustomerCanHoldTheSameSeat() throws Exception {
int customers = 20;
var start = new CountDownLatch(1);
var successes = new AtomicInteger();
try (var pool = Executors.newFixedThreadPool(customers)) {
for (int i = 0; i < customers; i++) {
long customerId = i;
pool.submit(() -> {
start.await();
try {
service.hold(42, List.of("F5", "F6"), customerId);
successes.incrementAndGet();
} catch (SeatUnavailableException ignored) { }
return null;
});
}
start.countDown();
}
assertThat(successes.get()).isEqualTo(1);
assertThat(jdbc.queryForObject(
"SELECT COUNT(DISTINCT hold_id) FROM show_seat WHERE status = 'HELD'",
Integer.class)).isEqualTo(1);
}
}
Explicación de la prueba:
CountDownLatchmantiene 20 subprocesos en la línea de salida y los libera simultáneamente para maximizar la posibilidad de contención.try-with-resourcesenExecutorService(Java 21) espera a que se completen todas las tareas antes de ejecutar las aserciones.- La segunda aserción verifica que ambos asientos pertenecen al mismo
hold_id, es decir, no hay un caso de ‘un asiento por persona’.
La prueba se ejecuta en H2 con el archivo schema.sql tomado de la sección 3 (convertir el tipo UUID al UUID de H2, que ya es compatible). Necesitas declarar un bean Clock.systemUTC(). Para probar la expiración, reemplaza ese bean con un Clock simulado que puede avanzar el tiempo.
Nota: H2 no simula con precisión el comportamiento de bloqueo de PostgreSQL o MySQL. Para código relacionado con concurrencia, ejecute pruebas adicionales en la BD de producción correcta, por ejemplo, a través de Testcontainers.
11. Errores comunes
- Verificar primero, escribir después, es decir,
SELECTseguido deUPDATEsin bloqueo. Esta es la causa número uno de venta duplicada de asientos. - Depender completamente de un trabajo de limpieza para liberar asientos. Si el trabajo falla, los asientos quedan atrapados para siempre. Lazy expiration en
WHEREelimina este riesgo. - Usar Redis lock como fuente de verdad. El bloqueo puede perderse durante la conmutación por error, lo que resulta en dos personas reteniendo el mismo asiento.
- Usar
SELECT ... FOR UPDATEpara mantener bloqueo durante 10 minutos esperando el pago del cliente. El grupo de conexiones se agotará muy rápido. El bloqueo de BD debe existir solo por unos pocos milisegundos. El tiempo de retención largo debe representarse mediante datos, específicamente la columnahold_expires_at. - Obtener la hora de los relojes de múltiples máquinas diferentes. Unifica una fuente de hora: o
now()de la BD, oClockde la aplicación con NTP estrictamente sincronizado.
12. Conclusión
El corazón del sistema de venta de entradas es una declaración UPDATE condicional. Esta declaración tanto bloquea el asiento de forma atómica como trata la retención expirada como vacía, por lo que nunca vende duplicados y no necesita un trabajo de limpieza para ser correcto. Los componentes restantes se construyen alrededor de ese núcleo:
- Script de Redis Lua bloquea carga temprana.
- El trabajo de barrida ayuda a la interfaz a mostrar el estado correcto.
- Idempotencia y reconciliación manejan devoluciones de pago anómalas.
- Outbox combinado con Kafka emite eventos de confianza.
- La sala de espera virtual absorbe picos de carga.
Al implementar, comience con la parte central de SQL junto con las pruebas de concurrencia. Solo agregue Redis y la sala de espera virtual cuando las métricas de carga reales muestren que los necesita.