Projeto de sistema de reserva de hotel: inventário por noite e overbooking

1. Visão geral

O problema de reserva de hotel parece simples. Verifique se há quartos disponíveis e permita a reserva. Mas quando dois hóspedes clicam “Reservar” ao mesmo tempo para o último quarto da noite 31/12, o sistema facilmente vende um quarto para duas pessoas.

Este artigo passa por um projeto real: requisitos, modelo de inventário por noite, API, fluxo de retenção (hold) e confirmação, como evitar overbooking com PostgreSQL, e como lidar com picos de carga. Depois de ler, você poderá construir por conta própria a parte central de reserva com Spring Boot 3.x sem sofrer overbooking indesejado.

2. Requisitos

2.1. Requisitos funcionais

  • Encontrar quartos disponíveis por hotel, tipo de quarto, data de check-in e data de check-out.
  • Reter o quarto por 10 minutos para o hóspede fazer o pagamento.
  • Confirmar a reserva após o pagamento bem-sucedido.
  • Cancelar a reserva e devolver o inventário.

2.2. Escala e restrições

  • Aproximadamente 50.000 hotéis. Cada hotel tem em média 5 tipos de quarto. O sistema abre vendas 365 dias antes.
  • Volume de leitura muito maior que volume de escrita. Taxa de busca para reserva é cerca de 100:1.
  • Cerca de 50 reservas por segundo no horário de pico. Durante flash sales, esse número pode aumentar 20 vezes.
  • Restrição rígida: não vender mais do que o número de quartos configurado. Resultados de busca podem estar desatualizados por alguns segundos.

O número de linhas de inventário é cerca de 50.000 × 5 × 365 ≈ 91 milhões de linhas. Uma tabela PostgreSQL com bons índices ainda consegue lidar com esse volume.

3. Modelo de dados

A decisão mais importante deste artigo: o hóspede não reserva um quarto específico (número 302), mas sim um tipo de quarto por uma faixa de noites. O sistema só atribui o número do quarto no check-in. Portanto, o inventário é armazenado por chave (hotel, tipo_quarto, noite).

CREATE TABLE room_inventory (
    hotel_id      BIGINT  NOT NULL,
    room_type_id  BIGINT  NOT NULL,
    stay_date     DATE    NOT NULL,
    total_rooms   INT     NOT NULL,
    reserved      INT     NOT NULL DEFAULT 0,
    PRIMARY KEY (hotel_id, room_type_id, stay_date),
    CONSTRAINT chk_not_oversold CHECK (reserved >= 0 AND reserved <= total_rooms)
);

CREATE TABLE booking (
    id               BIGSERIAL PRIMARY KEY,
    idempotency_key  VARCHAR(64) NOT NULL UNIQUE,
    customer_id      BIGINT      NOT NULL,
    hotel_id         BIGINT      NOT NULL,
    room_type_id     BIGINT      NOT NULL,
    check_in         DATE        NOT NULL,
    check_out        DATE        NOT NULL,
    quantity         INT         NOT NULL,
    status           VARCHAR(16) NOT NULL, -- HOLD, CONFIRMED, CANCELLED, EXPIRED
    expires_at       TIMESTAMPTZ NOT NULL
);

CREATE INDEX idx_booking_expiry ON booking (expires_at) WHERE status = 'HOLD';

Explicação dos pontos principais:

  • Uma estadia de 10/12 a 13/12 ocupa 3 noites: 10, 11 e 12. A data de check-out não é contada. Todas as consultas usam o intervalo semi-aberto [check_in, check_out).
  • CHECK (reserved <= total_rooms) é a última linha de defesa. Se o código tiver um bug, o banco de dados rejeitará em vez de silenciosamente vender demais.
  • O índice parcial idx_booking_expiry contém apenas as reservas em status HOLD. O job de limpeza verifica rapidamente sem o índice inchar com histórico.

Nota: Muitos hotéis fazem overbooking intencional de cerca de 3–5% porque sabem que haverá cancelamentos. Implemente essa política definindo total_rooms maior que o número de quartos físicos. Não afrouxe a lógica de prevenção de overbooking.

4. API

GET  /hotels/{hotelId}/availability?checkIn=2026-12-10&checkOut=2026-12-13
POST /bookings                      (Header: Idempotency-Key)  -> 201, status=HOLD
POST /bookings/{id}/confirm         (chamado após pagamento bem-sucedido)
POST /bookings/{id}/cancel

Requisição de criar reserva:

public record HoldRequest(long customerId, long hotelId, long roomTypeId,
                          LocalDate checkIn, LocalDate checkOut, int quantity) {
    public HoldRequest {
        if (!checkOut.isAfter(checkIn)) throw new IllegalArgumentException("checkOut deve ser após checkIn");
        if (quantity < 1) throw new IllegalArgumentException("quantity >= 1");
    }

    public int nights() {
        return (int) ChronoUnit.DAYS.between(checkIn, checkOut);
    }
}

O header Idempotency-Key é gerado pelo cliente (UUID). Quando a rede é instável, o cliente pode fazer retry sem criar duas reservas.

5. Fluxo de processamento

Hóspede ──► POST /bookings ──► deduzir inventário todas as noites (1 transaction) ──► reserva HOLD, expira após 10'
        ──► pagamento ──► POST /confirm ──► HOLD → CONFIRMED
                        └► após 10' sem pagamento ──► job de limpeza: HOLD → EXPIRED, devolver inventário

O inventário é deduzido imediatamente ao criar um hold, não depois do pagamento. Isso garante que quando o hóspede entra na página de pagamento, há certeza de que o quarto está disponível. Em troca, o sistema deve ter um mecanismo para devolver o quarto quando o hold expira.

6. Desafio 1: evitar overbooking

6.1. Por que “ler depois escrever” não é seguro

A abordagem ingênua é usar SELECT para obter o número de quartos restantes, verificar em Java e depois UPDATE. Duas transações podem ler reserved = 9, total = 10 e ambas escrever reserved = 10. O resultado é que dois hóspedes retêm o mesmo quarto. Este é o clássico problema de lost update.

6.2. Bloquear em ordem e depois atualizar com condição

Thêm a dependência:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

Repository usa native query:

public interface InventoryRepository extends Repository<RoomInventory, InventoryId> {

    @Query(value = """
        SELECT stay_date FROM room_inventory
        WHERE hotel_id = :hotelId AND room_type_id = :roomTypeId
          AND stay_date >= :checkIn AND stay_date < :checkOut
        ORDER BY stay_date
        FOR UPDATE
        """, nativeQuery = true)
    List<LocalDate> lockNights(long hotelId, long roomTypeId, LocalDate checkIn, LocalDate checkOut);

    @Modifying
    @Query(value = """
        UPDATE room_inventory SET reserved = reserved + :qty
        WHERE hotel_id = :hotelId AND room_type_id = :roomTypeId
          AND stay_date >= :checkIn AND stay_date < :checkOut
          AND total_rooms - reserved >= :qty
        """, nativeQuery = true)
    int reserve(long hotelId, long roomTypeId, LocalDate checkIn, LocalDate checkOut, int qty);

    @Modifying
    @Query(value = """
        UPDATE room_inventory SET reserved = reserved - :qty
        WHERE hotel_id = :hotelId AND room_type_id = :roomTypeId
          AND stay_date >= :checkIn AND stay_date < :checkOut
        """, nativeQuery = true)
    int release(long hotelId, long roomTypeId, LocalDate checkIn, LocalDate checkOut, int qty);
}

Serviço para criar hold:

@Service
public class BookingService {

    private static final Duration HOLD_TTL = Duration.ofMinutes(10);
    private final InventoryRepository inventory;
    private final BookingRepository bookings;
    private final Clock clock;

    public BookingService(InventoryRepository inventory, BookingRepository bookings, Clock clock) {
        this.inventory = inventory;
        this.bookings = bookings;
        this.clock = clock;
    }

    @Transactional
    public Booking hold(HoldRequest req, String idempotencyKey) {
        Optional<Booking> existing = bookings.findByIdempotencyKey(idempotencyKey);
        if (existing.isPresent()) return existing.get();

        inventory.lockNights(req.hotelId(), req.roomTypeId(), req.checkIn(), req.checkOut());
        int updated = inventory.reserve(req.hotelId(), req.roomTypeId(),
                req.checkIn(), req.checkOut(), req.quantity());

        if (updated != req.nights()) {
            throw new RoomUnavailableException(req.hotelId(), req.checkIn(), req.checkOut());
        }
        return bookings.save(Booking.hold(req, idempotencyKey, Instant.now(clock).plus(HOLD_TTL)));
    }
}

Funciona passo a passo da seguinte forma:

  1. lockNights bloqueia as linhas de inventário em ordem de stay_date. Suponha que o hóspede A reserva 10–13 e o hóspede B reserva 11–14. Se o bloqueio não for em uma ordem fixa, A retém a noite 10 esperando a noite 11, B retém a noite 12 esperando a noite 11, e ambos podem deadlock. Quando todas as transações bloqueiam na mesma ordem, essa situação não ocorre.
  2. reserve apenas incrementa reserved nas noites que ainda têm espaço disponível. Retorna o número de linhas atualizadas.
  3. Se o número de linhas for menor que o número de noites, pode ser porque uma noite já está cheia ou não foi aberta para venda (sem linha). O serviço lança RoomUnavailableException. É uma RuntimeException, então @Transactional faz rollback completo, inclusive das noites já incrementadas.

Resultado com o último quarto quando duas requisições chegam ao mesmo tempo:

T1: lockNights (mantém bloqueio) → reserve = 3 linhas → INSERT booking → COMMIT
T2: lockNights (aguarda T1)  → reserve = 0 linhas → RoomUnavailableException → ROLLBACK → HTTP 409

Aqui você não precisa elevar o isolamento para SERIALIZABLE. READ COMMITTED junto com bloqueio de linha é suficiente.

6.3. Idempotência ao fazer retry simultâneo

Duas requisições com a mesma Idempotency-Key podem passar pela verificação findByIdempotencyKey. A restrição UNIQUE bloqueará a segunda requisição com DataIntegrityViolationException. Você captura essa exceção na camada de controller, lê novamente a reserva pela chave e retorna o resultado anterior. A transação perdedora já fez rollback, então a dedução de inventário que ela fez também é revertida.

7. Desafio 2: hold expirado e confirmação em conflito

Job de limpeza é executado a cada 30 segundos:

@Component
public class HoldExpiryJob {

    private final BookingRepository bookings;
    private final InventoryRepository inventory;

    public HoldExpiryJob(BookingRepository bookings, InventoryRepository inventory) {
        this.bookings = bookings;
        this.inventory = inventory;
    }

    @Scheduled(fixedDelay = 30_000)
    @Transactional
    public void expireHolds() {
        // SELECT ... WHERE status='HOLD' AND expires_at < now() LIMIT 500 FOR UPDATE SKIP LOCKED
        for (Booking b : bookings.lockExpiredHolds(500)) {
            inventory.release(b.getHotelId(), b.getRoomTypeId(), b.getCheckIn(), b.getCheckOut(), b.getQuantity());
            b.markExpired();
        }
    }
}

FOR UPDATE SKIP LOCKED permite que você execute múltiplas instâncias sem processar uma reserva em duplicata. Cada lote pega apenas 500 linhas para manter a transação curta.

Lembre-se de ativar @EnableScheduling em uma classe @Configuration. Ao precisar liberar inventário, também chame release na mesma ordem de noites como ao bloquear para evitar deadlock com a thread de hold.

O conflito mais perigoso é: o hóspede conclui o pagamento exatamente quando o job está expirando o hold. Você resolve isso com um UPDATE condicional ao status:

UPDATE booking SET status = 'CONFIRMED'
WHERE id = :id AND status = 'HOLD' AND expires_at > now();

Se a instrução retornar 0 linhas, o hold expirou e o quarto pode ter sido atribuído a outro hóspede. Neste caso, o serviço deve reembolsar ou tentar fazer hold novamente antes de confirmar. Nunca mude de EXPIRED para CONFIRMED sem devolver o inventário.

8. Desafio 3: picos de carga

O volume de leitura é 100 vezes maior que o de escrita, então as buscas não devem tocar em linhas bloqueadas. Você pode cachear o status do quarto no Redis com um TTL curto:

@Service
public class AvailabilityCache {

    private final StringRedisTemplate redis;

    public AvailabilityCache(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public Optional<Integer> get(long hotelId, long roomTypeId, LocalDate night) {
        String v = redis.opsForValue().get(key(hotelId, roomTypeId, night));
        return Optional.ofNullable(v).map(Integer::valueOf);
    }

    public void put(long hotelId, long roomTypeId, LocalDate night, int available) {
        redis.opsForValue().set(key(hotelId, roomTypeId, night), String.valueOf(available), Duration.ofSeconds(30));
    }

    private String key(long h, long rt, LocalDate d) {
        return "avail:%d:%d:%s".formatted(h, rt, d);
    }
}

Princípios a manter:

  • Redis serve apenas para leitura. A fonte de verdade é PostgreSQL. Se o resultado da busca mostrar “1 quarto disponível” mas a etapa de hold falhar, o hóspede recebe HTTP 409 com mensagem “acabou de sair do estoque”. Nenhuma reserva incorreta é criada.
  • Após cada hold, release ou cancel, você escreve um evento em uma tabela outbox na mesma transação. Um relay envia esse evento para o tópico Kafka inventory-changed. Um consumer deleta a chave Redis ou atualiza o índice de busca. O outbox garante que eventos não sejam perdidos se o app cair logo após o commit.
  • Durante flash sales, os bloqueios se concentram em alguns hotéis “quentes”. Você deve implementar rate limiting por hotelId no API gateway e definir lock_timeout de cerca de 2 segundos para a transação de reserva. Assim, requisições falham rapidamente em vez de ocupar toda a pool de conexões.

9. Testando overbooking

O teste mais importante é quando múltiplas threads competem pelo último quarto. Você precisa de um PostgreSQL real, pois H2 não simula corretamente o comportamento de bloqueio. Adicione a dependência:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class BookingServiceConcurrencyTest {

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

    @Autowired BookingService bookingService;
    @Autowired JdbcTemplate jdbc;

    @Test
    void onlyOneGuestGetsTheLastRoom() throws Exception {
        for (int d = 10; d <= 12; d++) {
            jdbc.update("INSERT INTO room_inventory VALUES (1, 1, ?, 1, 0)", LocalDate.of(2026, 12, d));
        }
        var req = new HoldRequest(42L, 1L, 1L, LocalDate.of(2026, 12, 10), LocalDate.of(2026, 12, 13), 1);

        AtomicInteger success = new AtomicInteger();
        try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<?>> futures = IntStream.range(0, 20)
                .mapToObj(i -> pool.submit(() -> {
                    try {
                        bookingService.hold(req, "key-" + i);
                        success.incrementAndGet();
                    } catch (RoomUnavailableException ignored) { }
                }))
                .toList();
            for (Future<?> f : futures) f.get();
        }

        assertThat(success.get()).isEqualTo(1);
        assertThat(jdbc.queryForList("SELECT reserved FROM room_inventory", Integer.class))
            .containsOnly(1);
    }
}

Vinte virtual threads chamam hold com chaves diferentes. O teste afirma que exatamente uma thread com sucesso, e todas as três noites têm reserved = 1. Nenhuma noite é incrementada duas vezes por rollback seletivo. A tabela é criada pelo script schema.sql da seção 3, ou por migração Flyway.

10. Erros comuns

  • Contar a data de check-out como uma noite. Este bug faz o hóspede ser cobrado por uma noite a mais. Use o intervalo [check_in, check_out) em todos os lugares.
  • Verificar inventário em Java após SELECT sem bloqueio. Isso causa lost update. A condição deve estar na cláusula UPDATE ou após FOR UPDATE.
  • Não comparar o número de linhas atualizadas com o número de noites. Se uma noite não tiver uma linha de inventário, o UPDATE ainda “sucederá” para as outras noites.
  • Chamar a API de pagamento dentro da transação que retém bloqueios. Os bloqueios são retidos por segundos e todo o hotel é atrasado. Você deve separar em etapas: hold (commit), pagamento, depois confirm.
  • Usar Redis DECR como fonte de verdade. Quando o Redis faz failover e perde dados, você perde o inventário. Isso só é recomendável quando a carga excede muito a capacidade do banco de dados, e mesmo assim você ainda precisa fazer reconciliação com o DB.

Nota: O modelo de contagem por tipo de quarto não é adequado quando o hóspede precisa escolher um quarto específico, como quarto 302, por exemplo em homestay ou casa de temporada. Nesse caso, você precisa de uma tabela room_night(room_id, stay_date) com restrição UNIQUE, ou uma exclusion constraint em daterange do PostgreSQL.

11. Conclusão

O núcleo do sistema de reserva de hotel é uma tabela de inventário por noite. Acima dela está uma transação curta: bloquear noites em ordem, atualizar condicionalmente e verificar o número de linhas afetadas. A restrição CHECK é a última linha de defesa. O hold com tempo limite, junto com um job de limpeza usando SKIP LOCKED, resolve o problema do hóspede abandonar o pagamento. O UPDATE condicional ao status resolve o conflito entre confirm e expire.

A parte de leitura é separada em Redis e sincronizada via outbox com Kafka, então as buscas conseguem lidar com grande volume sem comprometer a consistência na escrita. Você pode começar com este projeto e apenas fazer sharding por hotel_id quando um node PostgreSQL realmente não tiver mais capacidade.

system-designspring-bootpostgresqlconcurrencyredis