Conception d'un système de réservation d'hôtel : inventaire par nuit et surréservation
1. Aperçu général
Le problème de la réservation d’hôtel semble simple en apparence. Vérifier les chambres disponibles et laisser le client réserver. Mais quand deux clients cliquent sur “Réserver” pour la dernière chambre de la nuit du 31 décembre, le système a vite fait de vendre la même chambre à deux personnes.
Cet article parcourt une conception réelle : les exigences, le modèle d’inventaire par nuit, les API, le flux de réservation temporaire (hold) puis de confirmation, comment prévenir la surréservation avec PostgreSQL, et comment gérer les pics de charge. Après cette lecture, vous serez capable de construire le cœur d’un système de réservation avec Spring Boot 3.x sans surréservation involontaire.
2. Exigences
2.1. Exigences fonctionnelles
- Chercher les chambres disponibles par hôtel, type de chambre, date d’arrivée (check-in) et date de départ (check-out).
- Réserver une chambre pour 10 minutes afin que le client procède au paiement.
- Confirmer la réservation après un paiement réussi.
- Annuler la réservation et récupérer l’inventaire.
2.2. Échelle et contraintes
- Environ 50 000 hôtels. Chaque hôtel dispose en moyenne de 5 types de chambres. Le système ouvre les réservations 365 jours à l’avance.
- Le volume de lectures est bien plus important que celui des écritures. Le ratio recherche/réservation est environ 100:1.
- Environ 50 réservations par seconde aux heures de pointe. Lors d’une vente flash, ce chiffre peut être multiplié par 20.
- Contrainte stricte : ne pas vendre plus que le nombre de chambres configurées. Les résultats de recherche peuvent diverger de quelques secondes.
Le nombre de lignes d’inventaire est environ 50 000 × 5 × 365 ≈ 91 millions de lignes. Une table PostgreSQL avec de bons index peut gérer ce volume.
3. Modèle de données
La décision la plus importante de cet article : le client ne réserve pas une chambre spécifique (numéro 302), mais un type de chambre pour une plage de nuits. Le système n’assigne le numéro de chambre qu’au moment du check-in. Par conséquent, l’inventaire est stocké avec la clé composite (hôtel, type_chambre, nuit).
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';
Explications des points clés :
- Un séjour du 10/12 au 13/12 occupe 3 nuits : 10, 11 et 12. La date de départ ne compte pas. Toutes les requêtes utilisent un intervalle semi-ouvert
[check_in, check_out). CHECK (reserved <= total_rooms)est le filet de sécurité final. Si le code contient un bug, la base de données refusera plutôt que de silencieusement surréserver.- L’index partial
idx_booking_expiryne contient que les réservations au statutHOLD. Un job de nettoyage le scrute rapidement sans que l’index ne grossisse avec l’historique.
Remarque : Beaucoup d’hôtels intentionnellement surréservent de 3–5% car ils savent que certains clients annuleront. Représentez cette politique en définissant
total_roomsplus grand que le nombre physique de chambres. N’affaiblissez pas la logique de prévention de la surréservation.
4. API
GET /hotels/{hotelId}/availability?checkIn=2026-12-10&checkOut=2026-12-13
POST /bookings (En-tête : Idempotency-Key) -> 201, status=HOLD
POST /bookings/{id}/confirm (appelé après paiement réussi)
POST /bookings/{id}/cancel
Requête de création de réservation :
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 doit être après checkIn");
if (quantity < 1) throw new IllegalArgumentException("quantity >= 1");
}
public int nights() {
return (int) ChronoUnit.DAYS.between(checkIn, checkOut);
}
}
L’en-tête Idempotency-Key est généré par le client (UUID). Si le réseau est instable, le client peut réessayer sans crainte de créer deux réservations.
5. Flux de traitement
Client ──► POST /bookings ──► réduire l'inventaire toutes les nuits (1 transaction) ──► réservation HOLD, expire après 10'
──► paiement ──► POST /confirm ──► HOLD → CONFIRMED
└► pas de paiement dans les 10' ──► job nettoyage : HOLD → EXPIRED, augmenter inventaire
L’inventaire est réduit immédiatement lors de la création du hold, pas en attente du paiement. Cela garantit que si le client accède à la page de paiement, il y a toujours une chambre. En contrepartie, le système doit restaurer l’inventaire quand le hold expire.
6. Défi 1 : prévenir la surréservation
6.1. Pourquoi “lire puis écrire” n’est pas sûr
L’approche naïve est de faire un SELECT pour obtenir le nombre de chambres restantes, vérifier en Java, puis UPDATE. Deux transactions peuvent lire reserved = 9, total = 10 et écrire reserved = 10. Deux clients se retrouvent avec une réservation pour la même chambre. C’est la perte de mise à jour classique.
6.2. Verrouiller dans l’ordre puis mettre à jour de manière conditionnelle
Ajoutez la dépendance :
<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 avec requête native :
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);
}
Service de création du 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)));
}
}
Le processus fonctionne ainsi, étape par étape :
lockNightsverrouille les lignes d’inventaire dans l’ordre destay_date. Supposons que le client A réserve 10–13 et le client B réserve 11–14. Si le verrouillage n’est pas dans l’ordre fixe, A attend la nuit 11 en maintenant la nuit 10, B attend la nuit 11 en maintenant la nuit 12, et un deadlock peut survenir. Quand toutes les transactions verrouillent dans le même ordre, cela ne se produit pas.reserven’incrémentereservedque pour les nuits ayant assez de place. Il retourne le nombre de lignes affectées.- Si le nombre de lignes est inférieur au nombre de nuits, cela peut être dû à une nuit complète ou pas encore mise en vente (pas de ligne). Le service lève
RoomUnavailableException. C’est uneRuntimeException, donc@Transactionalannule tout, y compris les nuits déjà incrémentées.
Le résultat avec la dernière chambre quand deux requêtes arrivent simultanément :
T1 : lockNights (obtient verrou) → reserve = 3 lignes → INSERT booking → COMMIT
T2 : lockNights (attend T1) → reserve = 0 lignes → RoomUnavailableException → ROLLBACK → HTTP 409
Ici, vous n’avez pas besoin d’élever l’isolation à SERIALIZABLE. READ COMMITTED avec verrouillage de lignes suffit.
6.3. Idempotence lors de tentatives simultanées
Deux requêtes portant la même Idempotency-Key peuvent passer le filtre findByIdempotencyKey. La contrainte UNIQUE bloquera la deuxième requête avec DataIntegrityViolationException. Vous devez attraper cette exception au niveau du contrôleur, relire la réservation par sa clé, puis retourner le résultat ancien. La transaction perdante a annulé, donc l’inventaire qu’elle vient de réduire est également restauré.
7. Défi 2 : hold expiré et confirmation en conflit
Le job de nettoyage s’exécute toutes les 30 secondes :
@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 vous permet d’exécuter plusieurs instances sans traiter la même réservation en double. Chaque lot ne prend que 500 lignes pour garder la transaction courte.
N’oubliez pas d’activer @EnableScheduling sur une classe @Configuration. Quand vous restaurez l’inventaire, appelez aussi release dans le même ordre de nuits que lors du verrouillage pour éviter le deadlock avec le thread de hold.
Le conflit le plus dangereux est : le client paie pile au moment où le job fait expirer le hold. Vous le résolvez avec une UPDATE conditionnelle sur l’état :
UPDATE booking SET status = 'CONFIRMED'
WHERE id = :id AND status = 'HOLD' AND expires_at > now();
Si la requête retourne 0 lignes, le hold a expiré et la chambre peut déjà appartenir à quelqu’un d’autre. À ce moment, le service doit rembourser ou réessayer le hold puis confirmer. Ne transformez jamais EXPIRED en CONFIRMED sans restaurer l’inventaire.
8. Défi 3 : pics de charge
Le volume de lectures est 100 fois celui des écritures, donc les recherches ne doivent pas acquérir les verrous des lignes. Vous pouvez mettre en cache l’état des chambres sur Redis avec un TTL court :
@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);
}
}
Les principes à maintenir sont :
- Redis ne sert que pour la lecture. PostgreSQL est la source de vérité. Si le résultat de recherche affiche “il reste 1 chambre” mais le
holdéchoue, le client reçoit uniquement HTTP 409 avec le message “chambre complète à l’instant”. Aucune réservation incorrecte n’est créée. - Après chaque
hold,releaseoucancel, vous écrivez un événement dans une table outbox dans la même transaction. Un relais pousse cet événement sur un topic Kafkainventory-changed. Un consumer supprime la clé Redis ou met à jour l’index de recherche. L’outbox assure qu’aucun événement n’est perdu si l’app s’écrase juste après le commit. - Lors d’une vente flash, le verrouillage se concentre sur quelques hôtels “chauds”. Vous devez limiter le débit par
hotelIdà la passerelle API et définirlock_timeoutà environ 2 secondes pour la transaction de réservation. De cette façon, une requête échoue rapidement au lieu d’occuper tout le pool de connexions.
9. Tests de surréservation
Le test le plus important est plusieurs threads se battant pour la dernière chambre. Vous avez besoin d’une vraie PostgreSQL, car H2 ne simule pas correctement le comportement de verrouillage. Ajoutez les dépendances :
<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);
}
}
Vingt virtual threads appellent hold avec des clés différentes. Le test confirme qu’un seul thread réussit et que les trois nuits ont reserved = 1. Aucune nuit n’est surchargée à cause d’un rollback incomplet. Les tables sont créées via un script schema.sql à la section 3, ou via les migrations Flyway.
10. Erreurs fréquentes
- Compter la date de départ comme une nuit. Ce bug fait que le client se voit retirer un soir supplémentaire d’inventaire. Utilisez l’intervalle
[check_in, check_out)partout. - Vérifier l’inventaire en Java après un
SELECTsans verrouillage. Cela cause une perte de mise à jour. La condition doit être dans la clauseUPDATEou aprèsFOR UPDATE. - Ne pas comparer le nombre de lignes mises à jour avec le nombre de nuits. Si une nuit n’a pas de ligne d’inventaire, l’
UPDATE“réussit” quand même pour les autres nuits. - Appeler l’API de paiement dans une transaction qui tient un verrou. Le verrou est maintenu pendant des secondes, et tout l’hôtel est bloqué. Vous devez séparer les étapes : hold (commit), paiement, puis confirm.
- Utiliser Redis
DECRcomme source de vérité. Si Redis bascule et perd les données, vous perdez l’inventaire. Cette approche n’est à considérer que si la charge dépasse largement la capacité de la base de données, et même dans ce cas, vous avez besoin d’une rapprochement avec la DB.
Remarque : Le modèle de comptage par type de chambre ne convient pas quand le client doit choisir une chambre exacte (numéro 302), par exemple pour un gîte ou une villa privée. Dans ce cas, vous avez besoin d’une table
room_night(room_id, stay_date)avec contrainteUNIQUE, ou d’une exclusion constraint sur undaterangeen PostgreSQL.
11. Conclusion
Le cœur d’un système de réservation d’hôtel est une table d’inventaire par nuit. Au-dessus, il y a une transaction brève : verrouiller les nuits dans l’ordre, mettre à jour de manière conditionnelle et vérifier le nombre de lignes affectées. La contrainte CHECK forme le filet de sécurité final. Les holds limités dans le temps, couplés à un job de nettoyage utilisant SKIP LOCKED, résolvent le problème du client qui abandonne le paiement. Une UPDATE conditionnelle sur le statut résout les conflits entre confirm et expire.
La partie lecture est séparée sur Redis et synchronisée via outbox avec Kafka, de sorte que les recherches supportent les grandes charges sans affaiblir la cohérence des écritures. Vous pouvez partir de cette conception, puis fragmenter uniquement par hotel_id quand un seul nœud PostgreSQL n’a réellement plus assez de puissance.