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_expiry ne contient que les réservations au statut HOLD. 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_rooms plus 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 :

  1. lockNights verrouille les lignes d’inventaire dans l’ordre de stay_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.
  2. reserve n’incrémente reserved que pour les nuits ayant assez de place. Il retourne le nombre de lignes affectées.
  3. 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 une RuntimeException, donc @Transactional annule 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, release ou cancel, vous écrivez un événement dans une table outbox dans la même transaction. Un relais pousse cet événement sur un topic Kafka inventory-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éfinir lock_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 SELECT sans verrouillage. Cela cause une perte de mise à jour. La condition doit être dans la clause UPDATE ou après FOR 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 DECR comme 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 contrainte UNIQUE, ou d’une exclusion constraint sur un daterange en 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.

system-designspring-bootpostgresqlconcurrencyredis