Design de reserva aérea: hold, pagamento idempotente e prevenção de overbooking

1. Visão geral

Vender passagens aéreas parece simples: cliente escolhe assento, paga, recebe passagem. A dificuldade é quando milhares de pessoas competem pelos mesmos assentos limitados. E o pagamento passa por terceiros, que podem ser lentos ou devolver o resultado várias vezes.

Neste artigo, você vai projetar um sistema de reserva desde os requisitos até o código. O sistema inclui reserva com prazo de validade, pagamento idempotente, prevenção de venda duplicada de assentos e suporte a picos de carga. Ao final, você terá um framework de design para aplicar em problemas similares como venda de ingressos para shows, reserva de hotéis ou flash sale.

2. Requisitos do Problema

2.1. Requisitos Funcionais

  • Encontrar voos e visualizar o mapa de assentos disponíveis.
  • Reservar (hold) um ou mais assentos por 10 minutos.
  • Pagamento. Se bem-sucedido, emitir passagem; se falhar ou expirar, liberar o assento.
  • Cancelar passagem e reembolsar conforme política.

2.2. Escala e Restrições

  • Aproximadamente 5.000 voos/dia, 180 assentos cada.
  • Volume de leitura (busca, visualizar assentos) é cerca de 100x o volume de escrita.
  • Ao abrir venda de passagens com preço 0, pode haver 50.000 req/s direcionados para alguns poucos voos.
  • Restrições críticas: um assento nunca pode ser vendido para duas pessoas. O número de passagens vendidas por classe não pode exceder o limite permitido pela companhia.

Nota: Companhias aéreas propositalmente fazem overbooking em proporção, ex., vendendo 183 passagens para 180 assentos em classe econômica. A razão é que sempre há clientes que não comparecem. O que combatemos é overbooking não intencional por race condition. O nível de overbooking intencional deve ser um parâmetro controlado.

Os dados de inventário de assentos precisam de consistência forte. A parte de busca pode aceitar eventual consistency e ser cacheada agressivamente.

3. Modelo de Dados

Usamos PostgreSQL como fonte de verdade para inventário:

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
);

O design tem dois níveis de inventário:

  • fare_inventory conta por classe de tarifa. A restrição CHECK é a última rede de segurança: mesmo com bug no código, o banco de dados rejeita overbooking acima do limite.
  • seat gerencia cada assento individualmente. A chave primária (flight_id, seat_no) garante que cada assento tem apenas uma linha de estado.

4. API

GET  /flights?from=SGN&to=HAN&date=2026-12-20     -> lista de voos (em cache)
GET  /flights/{id}/seats                           -> mapa de assentos (cache 2-5 seg)
POST /bookings                                     -> reservar, retorna bookingId + expiresAt
POST /bookings/{id}/payments                       -> header Idempotency-Key obrigatório
POST /payments/webhook                             -> gateway de pagamento faz callback
DELETE /bookings/{id}                              -> cancelar

Request de reserva:

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

O mapa de assentos pode estar desatualizado alguns segundos. O usuário pode ver um assento “disponível” e depois ser rejeitado ao tentar reservar. Esse é um trade-off aceitável, desde que a etapa de reserva sempre revalide no banco de dados.

5. Reserva: Prevenção de Venda Duplicada de Assentos

A forma errada mais comum é ‘ler depois escrever’: SELECT status, vê AVAILABLE então UPDATE. Duas transações podem ver o assento vazio e ambas escrever. Resultado: um assento vendido para duas pessoas.

A solução é conditional update: consolidar a verificação na cláusula UPDATE e contar as linhas afetadas.

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 bloqueia a linha durante UPDATE. A segunda transação aguarda, depois reavalia a cláusula WHERE com os novos dados. Desta vez a condição é falsa, então o comando retorna 0 linhas. A condição held_until < now() permite ‘roubar’ de volta um assento com prazo expirado sem esperar pelo job de limpeza.

O serviço combina os dois passos em uma transação:

@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) {
            // Rollback tanto da reserva quanto dos assentos já reservados
            throw new SeatUnavailableException(req.seats());
        }

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

SeatUnavailableException é uma RuntimeException, então o Spring faz rollback de toda a transação. O cliente consegue todos os assentos escolhidos ou nenhum. Não há caso em que ele pega 12A mas perde 12B.

Nota: Para evitar deadlock quando dois clientes escolhem {12A, 12B} e {12B, 12A}, ordene seatNos antes de enviar. Postgres não garante a ordem de bloqueio pela lista IN. Para grandes grupos de assentos, use SELECT … ORDER BY seat_no FOR UPDATE antes de fazer o update.

Se um assento recém-expirado foi ‘roubado’ por outro, o contador held da reserva antiga ainda existe. O job de limpeza na seção 7 vai subtrair.

6. Pagamento Idempotente

O gateway de pagamento pode chamar o webhook várias vezes. O usuário pode clicar ‘Pagar’ duas vezes. Toda operação financeira deve 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());   // chamada repetida: retorna resultado anterior
        }

        // HELD -> CONFIRMED, só sucede se a reserva não expirou
        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;
    }
}

O comando transition é apenas outro conditional update:

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

O ponto crítico está na race entre ‘dinheiro chega’ e ‘prazo de reserva expira’. Tanto o fluxo de pagamento quanto o job de expiração mudam o status de HELD. Apenas um lado vence. Se o dinheiro chegar tarde, o cliente é reembolsado automaticamente em vez de receber um assento já vendido para outra pessoa.

A coluna idempotency_key UNIQUE impede o caso de duas requisições com a mesma chave rodando em paralelo. Ambas não veem o registro antigo, mas a segunda será lançada DataIntegrityViolationException ao inserir, e sua transação sofre rollback.

6.1. Outbox e Kafka

Emissão de passagem, envio de e-mail e sincronização para o sistema de check-in rodam assincronamente. Não envie para Kafka diretamente na transação. Se o DB fizer commit mas o envio falhar (ou vice-versa), os dados ficarão inconsistentes. Escreva na tabela outbox na mesma transação, depois um relay envia para o 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();
        }
    }
}

O relay garante at-least-once, então o consumer na emissão de passagens também precisa ser idempotente, ex., salvando bookingId já processados. Usar bookingId como chave coloca eventos do mesmo booking na mesma partição e preserva a ordem.

7. Limpeza de Reservas 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());
        }
    }
}

O job subtrai held pelo seatCount da reserva, não pelo número de assentos recém-liberados. Porque alguns assentos podem ter sido ‘roubados’ por outros antes, mas o contador ainda estava na reserva antiga. Ao rodar múltiplas instâncias, use SELECT … FOR UPDATE SKIP LOCKED ou ShedLock para evitar processamento duplicado entre nós.

8. Suportando Picos de Carga

Ao abrir vendas de passagens baratas, 50.000 req/s batem no mesmo tempo em poucas linhas de fare_inventory. Isso é hot row. Cada transação enfileira esperando o mesmo row lock. O banco não morre por CPU, morre porque o pool de conexões esgota.

A estratégia é filtrar antes de chegar ao banco:

  1. Sala de espera virtual (waiting room): deixa apenas N pessoas/seg entrar na página de reserva, o resto recebe token de fila.
  2. Filtro rápido em Redis: bloqueia assento via SET NX para rejeitar rapidamente requests que definitivamente perderão.
  3. O banco ainda é o á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);
    }
}

Se tryEnter retorna false, a API retorna imediatamente 409 Conflict sem abrir nenhuma transação. TTL de 15 segundos é suficiente para a etapa de reserva no banco, e a chave desaparece automaticamente se a instância travar.

Nota: Não transforme Redis na fonte de verdade para inventário. Se Redis falhar, você pode perder chaves e deixar duas pessoas passarem. Tudo bem assim, porque o conditional update no banco ainda bloqueia. Por outro lado, se confiar apenas em Redis, você venderá assentos duplicados.

Para hot row contando por classe de tarifa, você pode dividir fare_inventory em múltiplos ‘buckets’ (ex., 10 linhas, cada uma com 18 assentos) e escolher um bucket aleatoriamente para reduzir contenção de lock.

9. Testando contra Venda Duplicada

O teste mais importante: 50 threads tentam reservar o mesmo assento, apenas um ganha. Execute com PostgreSQL real via Testcontainers, porque H2 tem comportamento de lock diferente.

@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);
    }
}

O teste assume dados de exemplo (voo 1001, assento 12A vazio) carregados via Flyway ou data.sql. CountDownLatch libera todas threads ao mesmo tempo para maximizar contenção. O bloco try-with-resources de ExecutorService (Java 21) aguarda todas as tasks antes de fechar. Resultado esperado: exatamente um sucesso, 49 vezes SeatUnavailableException.

10. Erros Comuns

  • Usar synchronized ou lock na JVM: só funciona com uma instância. Escale para dois pods e quebra.
  • Optimistic locking com @Version em hot row: logicamente correto, mas sob picos de carga, taxa de retry é muito alta. Conditional update é mais eficiente porque não precisa ler antes.
  • Deixar transação aberta enquanto chama o gateway de pagamento: row lock é mantido por segundos. Faça reserva, commit, chame pagamento, depois confirme em uma nova transação.
  • Esquecer de processar dinheiro após expiração de reserva: maior fonte de reclamações. Sempre tenha ramo de reembolso automático.
  • Cachear mapa de assentos por muito tempo: usuários escolhem assentos que já se foram. 2–5 segundos é suficiente.

11. Conclusão

O sistema de reserva de passagens aéreas gira em torno de um princípio: separar o que precisa de consistência forte (inventário de assentos, status de reserva) do que tolera latência (busca, notificações, emissão de passagem). Conditional update no banco resolve contenção sem precisar de lock distribuído. Máquina de estado HELD → CONFIRMED/EXPIRED com transição condicional trata race entre pagamento e expiração. Chave de idempotência e outbox mantêm dinheiro e eventos sem duplicação. Redis e sala de espera virtual protegem o banco quando carga dispara.

Você pode aplicar esse framework a qualquer problema ‘recurso escasso, muita contenda’: ingressos de show, reserva de hotel, slots de consulta ou flash sale.

design-sistemaspring-bootconcorrênciarediskafka