Diseño del sistema de reserva de vuelos: retención,pago, prevención

1. Descripción general

Vender vuelos suena simple: el cliente elige un asiento, paga y recibe su billete. Lo difícil es cuando miles de personas compiten por un número limitado de asientos. Además, el pago va a través de terceros que puede ser lento o devolver resultados múltiples veces.

En este artículo, diseñarás un sistema de reserva de vuelos desde los requisitos hasta el código. El sistema incluye retención de asientos con límite de tiempo, pago idempotente, prevención de sobreventa y soporte de cargas repentinas. Cuando termines, tendrás un marco de diseño que puedes aplicar a problemas similares como venta de entradas de concierto, reservas de hotel o flash sales.

2. Requisitos del problema

2.1. Requisitos funcionales

  • Buscar vuelos y ver el diagrama de asientos disponibles.
  • Retener (hold) uno o más asientos durante 10 minutos.
  • Pago. Si es exitoso, emitir billete; si falla o expira, liberar los asientos.
  • Cancelar boleto y reembolsar según la política.

2.2. Escala y restricciones

  • Alrededor de 5.000 vuelos/día, cada vuelo con 180 asientos.
  • El volumen de lectura (búsqueda, consulta de asientos) es aproximadamente 100 veces mayor que el de escritura.
  • Al abrir la venta de vuelos de bajo costo, puede haber 50.000 solicitudes/segundo concentradas en unas pocas decenas de vuelos.
  • Restricción dura: un asiento nunca debe venderse a dos personas. El número de billetes vendidos por clase no debe exceder el límite permitido por la aerolínea.

Nota: Las aerolíneas deliberadamente hacen overbooking en proporción, por ejemplo vendiendo 183 billetes para 180 asientos en clase económica. La razón es que siempre hay clientes que no se presentan. Lo que prevenimos es overbooking involuntario debido a condiciones de carrera. El nivel de overbooking intencional debe ser un parámetro controlado.

Estas dos restricciones determinan la elección arquitectónica. Los datos de inventario de asientos necesitan consistencia fuerte. La búsqueda puede aceptar consistencia eventual y ser agresivamente cacheada.

3. Modelo de datos

Usaremos PostgreSQL como fuente de verdad (source of truth) para el inventario:

CREATE TABLE flight (
    id              BIGINT PRIMARY KEY,
    code            VARCHAR(10) NOT NULL,
    departure_at    TIMESTAMPTZ NOT NULL
);

CREATE TABLE fare_inventory (
    flight_id       BIGINT NOT NULL REFERENCES flight(id),
    fare_class      VARCHAR(10) NOT NULL,        -- ECONOMY, BUSINESS
    capacity        INT NOT NULL,
    overbook_limit  INT NOT NULL DEFAULT 0,
    sold            INT NOT NULL DEFAULT 0,
    held            INT NOT NULL DEFAULT 0,
    PRIMARY KEY (flight_id, fare_class),
    CHECK (sold + held <= capacity + overbook_limit)
);

CREATE TABLE seat (
    flight_id   BIGINT NOT NULL REFERENCES flight(id),
    seat_no     VARCHAR(4) NOT NULL,
    status      VARCHAR(10) NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE, HELD, SOLD
    booking_id  BIGINT,
    held_until  TIMESTAMPTZ,
    PRIMARY KEY (flight_id, seat_no)
);

CREATE TABLE booking (
    id           BIGINT PRIMARY KEY,
    flight_id    BIGINT NOT NULL,
    customer_id  BIGINT NOT NULL,
    status       VARCHAR(12) NOT NULL,   -- HELD, CONFIRMED, EXPIRED, CANCELLED
    amount       NUMERIC(12,2) NOT NULL,
    expires_at   TIMESTAMPTZ NOT NULL
);

CREATE TABLE payment (
    id               BIGINT PRIMARY KEY,
    booking_id       BIGINT NOT NULL,
    idempotency_key  VARCHAR(64) NOT NULL UNIQUE,
    status           VARCHAR(10) NOT NULL
);

El diseño tiene dos niveles de inventario:

  • fare_inventory cuenta por clase de tarifa. La restricción CHECK es la red de seguridad final: incluso si el código tiene un bug, la base de datos rechaza overbooking que exceda el límite.
  • seat gestiona cada asiento específico. La clave primaria (flight_id, seat_no) asegura que cada asiento tenga una única fila de estado.

4. API

GET  /flights?from=SGN&to=HAN&date=2026-12-20     -> lista de vuelos (cache)
GET  /flights/{id}/seats                           -> diagrama de asientos (cache 2-5 segundos)
POST /bookings                                     -> retener, devolver bookingId + expiresAt
POST /bookings/{id}/payments                       -> header Idempotency-Key obligatorio
POST /payments/webhook                             -> puerta de pago llama de vuelta
DELETE /bookings/{id}                              -> cancelar

Solicitud de retención:

{ "flightId": 1001, "customerId": 42, "fareClass": "ECONOMY", "seats": ["12A", "12B"] }

El diagrama de asientos puede estar obsoleto unos segundos. El usuario puede ver un asiento “disponible” y luego ser rechazado al retenerlo. Este es un compromiso aceptable, siempre que el paso de retención siempre se revalide contra la base de datos.

5. Retención: prevención de sobreventa

El error más común es ‘leer luego escribir’: SELECT status, ver AVAILABLE y luego UPDATE. Dos transacciones pueden leer y ver disponible, luego ambas escribir. El resultado es que un asiento se vende a dos personas.

La solución es actualización condicional: combinar la condición de verificación en el UPDATE y contar el número de filas afectadas.

public interface SeatRepository extends JpaRepository<Seat, SeatId> {

    @Modifying
    @Query(value = """
        UPDATE seat
           SET status = 'HELD', booking_id = :bookingId, held_until = :until
         WHERE flight_id = :flightId
           AND seat_no IN (:seatNos)
           AND (status = 'AVAILABLE' OR (status = 'HELD' AND held_until < now()))
        """, nativeQuery = true)
    int holdSeats(Long flightId, List<String> seatNos, Long bookingId, Instant until);

    @Modifying
    @Query(value = """
        UPDATE seat SET status = 'SOLD', held_until = NULL
         WHERE booking_id = :bookingId AND status = 'HELD'
        """, nativeQuery = true)
    int markSold(Long bookingId);
}

public interface FareInventoryRepository extends JpaRepository<FareInventory, FareInventoryId> {

    @Modifying
    @Query(value = """
        UPDATE fare_inventory SET held = held + :n
         WHERE flight_id = :flightId AND fare_class = :fareClass
           AND sold + held + :n <= capacity + overbook_limit
        """, nativeQuery = true)
    int reserve(Long flightId, String fareClass, int n);
}

PostgreSQL bloquea la fila cuando ejecuta UPDATE. La segunda transacción debe esperar, luego reevalúa la condición WHERE contra los datos nuevos. En ese punto la condición es falsa, por lo que la declaración devuelve 0 filas. La condición held_until < now() permite ‘reclamar’ asientos cuya retención ha expirado sin necesidad de esperar a un trabajo de limpieza.

El servicio combina dos pasos en una transacción:

@Service
@RequiredArgsConstructor
public class BookingService {

    private static final Duration HOLD_TTL = Duration.ofMinutes(10);

    private final SeatRepository seatRepository;
    private final FareInventoryRepository inventoryRepository;
    private final BookingRepository bookingRepository;
    private final IdGenerator idGenerator;

    @Transactional
    public Booking hold(HoldRequest req) {
        int n = req.seats().size();
        if (inventoryRepository.reserve(req.flightId(), req.fareClass(), n) != 1) {
            throw new SoldOutException(req.flightId(), req.fareClass());
        }

        Instant until = Instant.now().plus(HOLD_TTL);
        long bookingId = idGenerator.next();
        int held = seatRepository.holdSeats(req.flightId(), req.seats(), bookingId, until);
        if (held != n) {
            // Reversión tanto de reserve como de los asientos ya retenidos
            throw new SeatUnavailableException(req.seats());
        }

        return bookingRepository.save(new Booking(bookingId, req.flightId(),
                req.customerId(), BookingStatus.HELD, req.amount(), until));
    }
}

SeatUnavailableException es una RuntimeException, por lo que Spring revierte toda la transacción. El cliente retiene todos los asientos que eligió, o no retiene ninguno. No hay caso de retener 12A pero perder 12B.

Nota: Para evitar deadlock cuando dos clientes eligen {12A, 12B} y {12B, 12A}, ordena seatNos antes de enviar. Postgres no garantiza el orden de bloqueo según la lista IN. Para grupos grandes de asientos, debes usar SELECT … ORDER BY seat_no FOR UPDATE antes de actualizar.

Si un asiento cuya retención acaba de expirar es reclamado por otro, el contador held de la reserva anterior permanece. El trabajo de limpieza en la sección 7 lo reducirá.

6. Pago idempotente

La puerta de pago puede llamar al webhook varias veces. El usuario puede hacer clic en ‘Pagar’ dos veces. Toda operación de dinero debe ser idempotente.

@Service
@RequiredArgsConstructor
public class PaymentService {

    private final PaymentRepository paymentRepository;
    private final BookingRepository bookingRepository;
    private final SeatRepository seatRepository;
    private final FareInventoryRepository inventoryRepository;
    private final OutboxRepository outboxRepository;

    @Transactional
    public PaymentResult confirm(long bookingId, String idempotencyKey) {
        Optional<Payment> existing = paymentRepository.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) {
            return PaymentResult.of(existing.get());   // llamada repetida: devolver resultado anterior
        }

        // HELD -> CONFIRMED, solo gana si la reserva aún no ha expirado
        int updated = bookingRepository.transition(bookingId, "HELD", "CONFIRMED");
        if (updated == 0) {
            paymentRepository.save(Payment.refundRequired(bookingId, idempotencyKey));
            return PaymentResult.REFUND;
        }

        Booking booking = bookingRepository.findById(bookingId).orElseThrow();
        int seats = seatRepository.markSold(bookingId);
        inventoryRepository.moveHeldToSold(booking.getFlightId(), booking.getFareClass(), seats);

        paymentRepository.save(Payment.success(bookingId, idempotencyKey));
        outboxRepository.save(OutboxEvent.bookingConfirmed(bookingId));
        return PaymentResult.SUCCESS;
    }
}

La declaración transition es solo otra actualización condicional:

UPDATE booking SET status = :to WHERE id = :id AND status = :from

El punto crítico es la carrera entre ‘dinero llega’ y ‘retención expira’. Tanto el flujo de pago como el trabajo de expiración transicionan el estado desde HELD. Solo un lado gana. Si el dinero llega tarde, el cliente recibe un reembolso automático en lugar de recibir un asiento que fue vendido a otra persona.

La columna idempotency_key UNIQUE evita que dos solicitudes con la misma clave se ejecuten en paralelo. Ambas no ven el registro anterior, pero la segunda solicitud lanza DataIntegrityViolationException al insertar, y su transacción se revierte.

6.1. Outbox y Kafka

Emitir billete, enviar correo electrónico y sincronizar con el sistema de check-in se ejecutan de forma asincrónica. No envíes a Kafka directamente en la transacción. Si la BD hace commit pero el envío falla (o viceversa), los datos divergen. Escribe en la tabla outbox en la misma transacción, luego un relay lo envía a Kafka:

@Component
@RequiredArgsConstructor
public class OutboxRelay {

    private final OutboxRepository outboxRepository;
    private final KafkaTemplate<String, String> kafka;

    @Scheduled(fixedDelay = 500)
    @Transactional
    public void publish() {
        for (OutboxEvent e : outboxRepository.findTop100ByPublishedFalseOrderById()) {
            kafka.send("booking-events", String.valueOf(e.getBookingId()), e.getPayload()).join();
            e.markPublished();
        }
    }
}

El relay garantiza at-least-once, por lo que el consumidor del lado de emisión del billete también debe ser idempotente, por ejemplo guardando bookingId ya procesado. Usar bookingId como clave ayuda a que los eventos de la misma reserva vayan a la misma partición y mantengan el orden correcto.

7. Limpieza de retenciones expiradas

@Scheduled(fixedDelay = 30_000)
@Transactional
public void expireHolds() {
    for (Booking b : bookingRepository.findTop500ByStatusAndExpiresAtBefore(
            BookingStatus.HELD, Instant.now())) {
        if (bookingRepository.transition(b.getId(), "HELD", "EXPIRED") == 1) {
            int released = seatRepository.releaseByBooking(b.getId()); // HELD -> AVAILABLE
            inventoryRepository.release(b.getFlightId(), b.getFareClass(), b.getSeatCount());
        }
    }
}

El trabajo reduce held según seatCount de la reserva, no según la cantidad de asientos recién liberados. La razón es que algunos asientos pueden haber sido reclamados por otros antes, pero el contador aún se cuenta para la reserva anterior. Cuando ejecutes múltiples instancias, usa SELECT … FOR UPDATE SKIP LOCKED o ShedLock para que los nodos no procesen duplicados.

8. Soporte de picos de carga

Cuando abres la venta de vuelos económicos, 50.000 solicitudes/segundo golpean unas pocas filas de fare_inventory. Eso es un hot row. Cada transacción se pone en cola esperando el mismo bloqueo de fila. La base de datos no muere por CPU, sino porque el pool de conexiones se agota.

La estrategia es filtrar antes de llegar a BD:

  1. Sala de espera virtual (waiting room): solo permite que N personas/segundo entren a la página de reserva, el resto recibe un token de fila.
  2. Filtro rápido en Redis: bloquea asientos con SET NX para rechazar temprano las solicitudes que seguramente pierden.
  3. BD sigue siendo el árbitro final.
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
@Component
@RequiredArgsConstructor
public class SeatGate {

    private final StringRedisTemplate redis;

    public boolean tryEnter(long flightId, String seatNo, long customerId) {
        String key = "seat:%d:%s".formatted(flightId, seatNo);
        Boolean ok = redis.opsForValue()
                .setIfAbsent(key, String.valueOf(customerId), Duration.ofSeconds(15));
        return Boolean.TRUE.equals(ok);
    }
}

Si tryEnter devuelve false, la API devuelve inmediatamente 409 Conflict sin abrir ninguna transacción. El TTL de 15 segundos es suficiente para el paso de retención en BD, y la clave desaparece si la instancia falla.

Nota: No hagas de Redis la fuente de verdad para el inventario. Si Redis se recupera, puedes perder claves y dejar pasar a dos personas. Eso está bien, porque la actualización condicional en BD aún lo evita. Por el contrario, si solo confías en Redis, venderás asientos duplicados.

Para hot rows que cuentan por clase de tarifa, puedes dividir fare_inventory en múltiples ‘buckets’ (por ejemplo, 10 filas, cada una con 18 asientos) y elegir uno al azar para reducir la contención de bloqueos.

9. Pruebas contra sobreventa

La prueba más importante: 50 threads retienen un asiento, solo uno gana. Ejecuta con PostgreSQL real a través de Testcontainers, porque el comportamiento de bloqueo de H2 difiere de Postgres.

@SpringBootTest
@Testcontainers
class BookingServiceConcurrencyTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");

    @Autowired BookingService bookingService;

    @Test
    void onlyOneCustomerCanHoldTheSameSeat() throws Exception {
        int threads = 50;
        AtomicInteger success = new AtomicInteger();
        CountDownLatch start = new CountDownLatch(1);

        try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < threads; i++) {
                long customerId = i;
                pool.submit(() -> {
                    start.await();
                    try {
                        bookingService.hold(new HoldRequest(1001L, customerId, "ECONOMY",
                                List.of("12A"), new BigDecimal("1500000")));
                        success.incrementAndGet();
                    } catch (SeatUnavailableException | SoldOutException ignored) { }
                    return null;
                });
            }
            start.countDown();
        }

        assertThat(success.get()).isEqualTo(1);
    }
}

La prueba asume datos de muestra (vuelo 1001, asiento 12A disponible) cargados a través de Flyway o data.sql. CountDownLatch libera todos los threads a la vez para maximizar la contención. El bloque try-with-resources de ExecutorService (Java 21) espera a que todas las tareas terminen antes de cerrar. Resultado esperado: exactamente un éxito, 49 veces SeatUnavailableException.

10. Errores comunes

  • Usar synchronized o lock en JVM: correcto solo cuando se ejecuta una instancia. Escalar a dos pods se rompe.
  • Optimistic locking con @Version para hot rows: lógicamente correcto, pero bajo carga repentina, la tasa de reintento es muy alta. La actualización condicional es más eficiente porque no requiere lectura previa.
  • Mantener la transacción abierta mientras llamas a la puerta de pago: el bloqueo de fila se mantiene durante segundos. Retén, haz commit, llama a pago, luego confirma en una transacción nueva.
  • Olvidar manejar el dinero después de que expire la retención: esta es la fuente de quejas más grande. Siempre ten una rama de reembolso automático.
  • Cachear el diagrama de asientos demasiado tiempo: los usuarios seleccionan constantemente asientos que ya se fueron. 2–5 segundos es suficiente.

11. Conclusión

El sistema de reserva de vuelos gira alrededor de un principio: separar la parte que necesita consistencia fuerte (inventario de asientos, estado de reserva) de la que acepta demora (búsqueda, notificaciones, emisión de billete). La actualización condicional en la BD resuelve la contención sin necesidad de bloqueos distribuidos. La máquina de estados HELD → CONFIRMED/EXPIRED con transiciones condicionales maneja la carrera entre pago y expiración. La clave de idempotencia junto con outbox evita que dinero y eventos se dupliquen. Redis y la sala de espera virtual protegen la BD cuando la carga sube repentinamente.

Puedes aplicar este marco a cualquier problema de ‘recurso escaso, múltiples competidores’: entradas de concierto, habitaciones de hotel, citas de consulta o flash sales.

system-designspring-bootconcurrencyrediskafka