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_expirycontém apenas as reservas em statusHOLD. 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_roomsmaior 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:
lockNightsbloqueia as linhas de inventário em ordem destay_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.reserveapenas incrementareservednas noites que ainda têm espaço disponível. Retorna o número de linhas atualizadas.- 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. É umaRuntimeException, então@Transactionalfaz 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
holdfalhar, o hóspede recebe HTTP 409 com mensagem “acabou de sair do estoque”. Nenhuma reserva incorreta é criada. - Após cada
hold,releaseoucancel, você escreve um evento em uma tabela outbox na mesma transação. Um relay envia esse evento para o tópico Kafkainventory-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
hotelIdno API gateway e definirlock_timeoutde 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
SELECTsem bloqueio. Isso causa lost update. A condição deve estar na cláusulaUPDATEou apósFOR 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
UPDATEainda “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
DECRcomo 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çãoUNIQUE, ou uma exclusion constraint emdaterangedo 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.