Conception d'un système de vente de places de cinéma

1. Vue d’ensemble

Quand une blockbuster ouvre sa vente de places pour la première séance, des milliers de personnes cliquent simultanément sur un plan de salle avec seulement quelques centaines de places. Le système doit garantir que chaque place n’appartient qu’à une seule personne. Les places sont réservées temporairement pendant le paiement et doivent être automatiquement libérées si le client abandonne.

Cet article couvre l’intégralité de la conception : besoins, modèle de données, API, flux de traitement et points difficiles. Le code d’illustration utilise Java 21, Spring Boot 3.x, SQL et Redis. Après avoir lu cela, vous serez capable d’implémenter un mécanisme de réservation sûr sous de nombreuses requêtes concurrentes. Vous saurez également comment gérer l’expiration des réservations sans dépendre d’une tâche de nettoyage.

2. Besoins

2.1. Besoins fonctionnels

  • Le client voit le plan de salle d’une séance, avec des places vides, réservées ou vendues.
  • Le client sélectionne au maximum 8 places et les réserve pendant 10 minutes.
  • La réservation est tout ou rien : si une place ne peut pas être réservée, aucune place n’est réservée.
  • Si le client paie pendant la période de réservation, les places passent à l’état vendu.
  • Au bout de 10 minutes sans paiement, les places sont automatiquement libérées.

2.2. Échelle et contraintes

  • Environ 500 cinémas, 10 salles par cinéma, 100–300 places par salle.
  • Normalement quelques centaines de requêtes par seconde. Au lancement d’un film populaire, cela peut atteindre 50 000 requêtes par seconde, concentrées sur quelques séances.
  • Aucune place ne doit être vendue deux fois. C’est une contrainte inviolable.
  • Le ratio lecture/écriture est très asymétrique : chaque réservation s’accompagne de dizaines de visualisations du plan.
  • Le paiement passe par une passerelle externe. Le callback peut arriver en retard, deux fois, ou jamais.

3. Modèle de données

La table centrale est show_seat. Chaque ligne correspond à une place dans une séance spécifique. Nous ne créons pas de table « réservation » séparée, car l’état de la place et les informations de réservation doivent être mis à jour dans une seule instruction atomique.

CREATE TABLE show_seat (
    showtime_id     BIGINT       NOT NULL,
    seat_code       VARCHAR(8)   NOT NULL,
    status          VARCHAR(16)  NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE | HELD | BOOKED
    hold_id         UUID,
    customer_id     BIGINT,
    hold_expires_at TIMESTAMP,
    booking_id      BIGINT,
    PRIMARY KEY (showtime_id, seat_code)
);

CREATE INDEX idx_show_seat_hold ON show_seat (hold_id);
CREATE INDEX idx_show_seat_expired ON show_seat (status, hold_expires_at);

Quelques points à noter :

  • La clé primaire (showtime_id, seat_code) permet de mettre à jour rapidement un groupe de places d’une même séance.
  • hold_id regroupe les places d’une même réservation. L’étape de paiement confirmera l’ensemble selon ce hold_id.
  • L’index (status, hold_expires_at) sert à la tâche de nettoyage des réservations expirées à la section 7.

Les tables showtime, booking et payment sont des tables CRUD standard et ne seront pas détaillées ici.

4. API

GET    /showtimes/{showtimeId}/seats          -> plan de salle + états
POST   /showtimes/{showtimeId}/holds          -> réserver des places, body: {"seats":["F5","F6"]}
                                                 201 {"holdId":"...","expiresAt":"..."} | 409 si places occupées
POST   /holds/{holdId}/checkout               -> créer une session de paiement (en-tête Idempotency-Key)
DELETE /holds/{holdId}                        -> client annule, libère les places immédiatement
POST   /payments/callback                     -> la passerelle de paiement appelle

Retournez 409 Conflict si une place est déjà réservée par quelqu’un d’autre. Le client utilise ce code pour actualiser le plan et inviter le client à choisir d’autres places.

5. Flux de traitement

Client          API                 DB / Redis              Passerelle de paiement
  | sélectionner places |                      |                          |
  |------------>| UPDATE conditionnel  |                          |
  |             |---------------------->| HELD, expiration après 10'|
  |<------------| 201 holdId           |                          |
  | paiement    |                      |                          |
  |------------>| créer paiement (8')  |------------------------->|
  |             |                      |        callback SUCCESS  |
  |             |<-------------------------------------------------|
  |             | HELD -> BOOKED si    |                          |
  |             | pas encore expiré    |                          |

La session de paiement est configurée pour expirer après 8 minutes, moins que le délai de réservation. Le délai d’attente de 2 minutes permet à la plupart des callbacks d’arriver à temps avant que les places ne soient libérées.

6. Verrouillage temporaire des places

Dépendances nécessaires :

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>

6.1. L’UPDATE conditionnel est la source de vérité

L’erreur courante est de lire d’abord puis d’écrire : SELECT pour vérifier si la place est libre, puis UPDATE. Entre les deux instructions, une autre requête peut s’intercaler et réserver la place. Il en résulte que deux personnes pensent avoir réservé la même place.

La bonne façon est de fusionner la vérification dans la clause WHERE et de compter les lignes affectées :

@Service
public class SeatHoldService {

    private static final Duration HOLD_TTL = Duration.ofMinutes(10);

    private final NamedParameterJdbcTemplate jdbc;
    private final Clock clock;

    public SeatHoldService(NamedParameterJdbcTemplate jdbc, Clock clock) {
        this.jdbc = jdbc;
        this.clock = clock;
    }

    @Transactional
    public SeatHold hold(long showtimeId, List<String> seatCodes, long customerId) {
        UUID holdId = UUID.randomUUID();
        Instant now = clock.instant();
        Instant expiresAt = now.plus(HOLD_TTL);

        var params = new MapSqlParameterSource()
                .addValue("holdId", holdId)
                .addValue("customerId", customerId)
                .addValue("showtimeId", showtimeId)
                .addValue("seatCodes", seatCodes.stream().sorted().toList())
                .addValue("now", Timestamp.from(now))
                .addValue("expiresAt", Timestamp.from(expiresAt));

        int updated = jdbc.update("""
                UPDATE show_seat
                   SET status = 'HELD', hold_id = :holdId,
                       customer_id = :customerId, hold_expires_at = :expiresAt
                 WHERE showtime_id = :showtimeId
                   AND seat_code IN (:seatCodes)
                   AND (status = 'AVAILABLE'
                        OR (status = 'HELD' AND hold_expires_at < :now))
                """, params);

        if (updated != seatCodes.size()) {
            throw new SeatUnavailableException(showtimeId, seatCodes);
        }
        return new SeatHold(holdId, expiresAt);
    }
}

public record SeatHold(UUID holdId, Instant expiresAt) {}

public class SeatUnavailableException extends RuntimeException {
    public SeatUnavailableException(long showtimeId, List<String> seats) {
        super("Les places " + seats + " de la séance " + showtimeId + " ne sont pas disponibles");
    }
}

Le code fonctionne comme suit :

  • L’instruction UPDATE acquiert un verrou de ligne sur chaque enregistrement de place. Deux transactions ciblant la même place F5 doivent attendre leur tour. La transaction qui arrive en second révalue la clause WHERE, voit status = 'HELD' et ne peut pas mettre à jour cette ligne.
  • La condition status = 'HELD' AND hold_expires_at < :now est l’expiration paresseuse. Une place dont la réservation a expiré est traitée comme libre, même si aucune tâche de nettoyage n’a été exécutée.
  • Si le nombre de lignes mises à jour est inférieur au nombre de places demandées, une exception est levée. @Transactional restaure tout, nous obtenons donc le comportement tout-ou-rien.
  • Clock est injecté dans le service pour permettre aux tests de contrôler le temps.

Supposons que le client A réserve F5, F6, puis le client B réserve F6, F7. B reçoit alors :

HTTP 409 Conflict
{"error":"Les places [F6, F7] de la séance 42 ne sont pas disponibles"}

La place F7 de B est restée libre car la transaction de B a été restaurée.

Remarque : Triez seatCodes avant la mise à jour. Si A verrouille dans l’ordre F5 → F6 et B dans l’ordre F6 → F5, les deux transactions peuvent se bloquer mutuellement. Avec PostgreSQL, l’ordre de verrouillage dépend de l’ordre de balayage des lignes. Pour être sûr, vous pouvez exécuter SELECT ... ORDER BY seat_code FOR UPDATE avant la mise à jour.

6.2. Redis comme couche de blocage rapide

Sous une charge soudaine, 50 000 requêtes de réservation frappent simultanément quelques centaines de lignes d’une séance. La base de données doit gérer une très longue file d’attente de verrous. Nous mettons Redis en avant pour rejeter rapidement les requêtes qui perdront certainement. Le script Lua ci-dessous rend les opérations sur plusieurs clés atomiques :

@Component
public class RedisSeatGate {

    private static final RedisScript<Long> TRY_HOLD = new DefaultRedisScript<>("""
            for _, key in ipairs(KEYS) do
              if redis.call('EXISTS', key) == 1 then return 0 end
            end
            for _, key in ipairs(KEYS) do
              redis.call('SET', key, ARGV[1], 'PX', ARGV[2])
            end
            return 1
            """, Long.class);

    private final StringRedisTemplate redis;

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

    public boolean tryHold(long showtimeId, List<String> seats, UUID holdId, Duration ttl) {
        List<String> keys = seats.stream()
                .map(s -> "seat:{" + showtimeId + "}:" + s)
                .toList();
        Long ok = redis.execute(TRY_HOLD, keys, holdId.toString(),
                String.valueOf(ttl.toMillis()));
        return ok != null && ok == 1L;
    }
}

Voici comment l’utiliser :

  • Redis exécute le script Lua sur un seul thread. Aucune requête ne peut s’intercaler entre le contrôle EXISTS et le SET.
  • PX définit un TTL sur la clé, donc Redis supprime la clé automatiquement à l’expiration. C’est le mécanisme d’expiration naturel du cache.
  • La partie {showtimeId} est une hash tag. Sur un cluster Redis, tous les keys d’une séance résident dans le même slot. Sans la hash tag, un script multi-clés signalera une erreur CROSSSLOT.

Le flux complet est : appelez d’abord tryHold sur Redis. Si cela échoue, retournez 409 immédiatement. Si cela réussit, exécutez l’UPDATE de la section 6.1. Si l’UPDATE échoue, supprimez la clé Redis que vous venez de définir.

Remarque : Redis n’est pas la source de vérité. Redis peut perdre des données lors d’un basculement, et les clés peuvent expirer décalées de quelques millisecondes par rapport à la base de données. La décision finale est toujours l’UPDATE conditionnel. Si votre système n’a pas de pics de charge, supprimez entièrement la couche Redis pour réduire un composant à maintenir.

7. Expiration des réservations

La correction de l’expiration est garantie par la condition hold_expires_at < :now de la section 6.1. Cependant, le plan de salle affiche toujours HELD jusqu’à ce que quelqu’un d’autre l’écrase. Nous avons donc besoin d’une tâche de nettoyage supplémentaire pour que l’interface affiche l’état correct :

@Component
public class ExpiredHoldSweeper {

    private final JdbcTemplate jdbc;
    private final Clock clock;

    public ExpiredHoldSweeper(JdbcTemplate jdbc, Clock clock) {
        this.jdbc = jdbc;
        this.clock = clock;
    }

    @Scheduled(fixedDelay = 15_000)
    public void releaseExpired() {
        int released = jdbc.update("""
                UPDATE show_seat
                   SET status = 'AVAILABLE', hold_id = NULL,
                       customer_id = NULL, hold_expires_at = NULL
                 WHERE status = 'HELD' AND hold_expires_at < ?
                """, Timestamp.from(clock.instant()));
        if (released > 0) {
            // publier SeatsReleased pour mettre à jour le cache du plan de salle
        }
    }
}

Cette tâche s’exécute toutes les 15 secondes. Peu importe si elle s’exécute en retard ou sur plusieurs instances simultanément, car l’instruction est idempotente. Grâce à l’expiration paresseuse, si la tâche meurt pendant quelques minutes, cela ne provoquera pas de vente double. La seule conséquence est que l’interface affiche des données obsolètes pendant cette période. N’oubliez pas d’activer @EnableScheduling dans la configuration.

La partie confirmation de paiement doit vérifier l’expiration dans la même instruction :

@Transactional
public boolean confirm(UUID holdId, long bookingId) {
    int updated = jdbc.update("""
            UPDATE show_seat
               SET status = 'BOOKED', booking_id = :bookingId, hold_expires_at = NULL
             WHERE hold_id = :holdId AND status = 'HELD'
               AND hold_expires_at >= :now
            """, new MapSqlParameterSource()
                    .addValue("holdId", holdId)
                    .addValue("bookingId", bookingId)
                    .addValue("now", Timestamp.from(clock.instant())));
    return updated > 0;
}

Cette fonction retourne false dans deux cas : la réservation a expiré, ou la place a été reprise par quelqu’un d’autre. Les deux cas doivent être gérés à l’étape de paiement.

8. Cohérence avec le paiement

Le callback de la passerelle de paiement est l’endroit le plus facile pour faire une erreur. Il y a trois situations que vous devez gérer :

  1. Callback arrive deux fois. Enregistrez payment_id avec une contrainte UNIQUE. La deuxième fois, voyant que la transaction a déjà été traitée, retournez 200 et ne faites rien d’autre.
  2. Callback arrive après l’expiration de la réservation. confirm retourne false. À ce stade, le système a prélevé l’argent mais ne peut pas livrer la place. Marquez la réservation comme FAILED et lancez un remboursement automatique. N’essayez pas de « reprendre » la place, car quelqu’un d’autre l’a peut-être achetée.
  3. Le callback n’arrive jamais. Une tâche de rapprochement périodique interroge l’état des paiements PENDING de plus de 15 minutes auprès de la passerelle, puis applique la logique des deux cas ci-dessus.

Après la confirm réussie, les étapes comme envoyer un email, générer un billet QR ou créditer des points de fidélité ne doivent pas s’exécuter dans la même transaction. Écrivez une ligne BookingConfirmed dans une table outbox lors de cette transaction. Un processus séparé poussera l’événement vers Kafka avec le topic booking-events et la clé bookingId. Cela garantit que l’événement n’est émis que lorsque la base de données a validé, et une fois validé, l’événement est garanti d’être émis.

9. Protection contre les pics de charge

La couche Redis de la section 6.2 n’adresse que la partie écriture. Lors du lancement d’un film populaire, vous avez besoin de mesures supplémentaires :

  • Salle d’attente virtuelle (virtual waiting room). Chaque séance populaire n’admet que N personnes sur la page de sélection des places à la fois. Les autres reçoivent un jeton de file d’attente, stocké dans un Redis sorted set avec le score du moment d’arrivée. Quelques centaines de places n’ont pas besoin de 50 000 personnes sélectionnant simultanément.
  • Plan de salle en cache. Le plan est lu depuis Redis ou un CDN et actualisé via des événements SeatsHeld/SeatsReleased. Un délai de 1–2 secondes est acceptable, car l’étape de réservation vérifiera de nouveau exactement.
  • Limite de débit par client. Par exemple, maximum 5 réservations par minute, pour bloquer les bots de scraping.
  • Partitionnement par showtime_id. Tous les conflits se limitent à une seule séance. Vous pouvez partitionner la base de données par showtime_id sans avoir besoin de transactions distribuées.

10. Test de concurrence

Le comportement le plus important à vérifier est : si plusieurs clients réservent la même place, un seul gagne.

@SpringBootTest
class SeatHoldServiceTest {

    @Autowired SeatHoldService service;
    @Autowired JdbcTemplate jdbc;

    @BeforeEach
    void setUp() {
        jdbc.update("DELETE FROM show_seat");
        jdbc.update("INSERT INTO show_seat (showtime_id, seat_code) VALUES (42, 'F5'), (42, 'F6')");
    }

    @Test
    void onlyOneCustomerCanHoldTheSameSeat() throws Exception {
        int customers = 20;
        var start = new CountDownLatch(1);
        var successes = new AtomicInteger();

        try (var pool = Executors.newFixedThreadPool(customers)) {
            for (int i = 0; i < customers; i++) {
                long customerId = i;
                pool.submit(() -> {
                    start.await();
                    try {
                        service.hold(42, List.of("F5", "F6"), customerId);
                        successes.incrementAndGet();
                    } catch (SeatUnavailableException ignored) { }
                    return null;
                });
            }
            start.countDown();
        }

        assertThat(successes.get()).isEqualTo(1);
        assertThat(jdbc.queryForObject(
                "SELECT COUNT(DISTINCT hold_id) FROM show_seat WHERE status = 'HELD'",
                Integer.class)).isEqualTo(1);
    }
}

Explication du test :

  • CountDownLatch garde les 20 threads à la ligne de départ puis les libère ensemble, pour maximiser les chances de conflit.
  • try-with-resources sur ExecutorService (Java 21) attend que toutes les tâches se terminent avant d’exécuter les assertions.
  • La deuxième assertion vérifie que les deux places appartiennent au même hold_id, c’est-à-dire qu’il n’y a pas de cas « chacun une place ».

Le test s’exécute sur H2 avec un fichier schema.sql de la section 3 (convertissez le type UUID en celui de H2, qui est déjà supporté). Vous avez besoin de déclarer un bean Clock.systemUTC(). Pour tester l’expiration, remplacez ce bean par une Clock factice qui peut avancer le temps.

Remarque : H2 ne simule pas exactement le comportement de verrouillage de PostgreSQL ou MySQL. Pour le code lié à la concurrence, exécutez des tests supplémentaires sur la vraie base de données de production, par exemple via Testcontainers.

11. Erreurs courantes

  • Vérifier puis écrire, c’est-à-dire SELECT suivi d’UPDATE sans verrou. C’est la cause numéro un de la vente double de places.
  • Dépendre entièrement d’une tâche de nettoyage pour libérer les places. Si la tâche meurt, les places restent bloquées. L’expiration paresseuse dans WHERE élimine ce risque.
  • Utiliser un verrou Redis comme source de vérité. Le verrou peut être perdu lors d’un basculement, conduisant à deux personnes ayant la même place.
  • Utiliser SELECT ... FOR UPDATE pour tenir un verrou pendant 10 minutes en attente de paiement du client. Le pool de connexions s’épuisera rapidement. Un verrou DB ne devrait exister que quelques millisecondes. Un délai de réservation long doit être représenté par des données, spécifiquement la colonne hold_expires_at.
  • Obtenir l’heure de plusieurs horloges machines différentes. Unifiez une source de temps : soit now() de la base de données, soit Clock de l’app avec une synchronisation NTP stricte.

12. Conclusion

Le cœur du système de vente de places est une instruction UPDATE conditionnelle. Cette instruction verrouille atomiquement les places tout en traitant les réservations expirées comme libres, de sorte qu’il n’y a jamais de vente double et qu’aucune tâche de nettoyage n’est nécessaire. Les autres composants sont construits autour de ce noyau :

  • Un script Lua Redis bloque la charge précocement.
  • Une tâche sweeper aide l’interface à afficher l’état correct.
  • L’idempotence et le rapprochement gèrent les callbacks de paiement inattendus.
  • La table outbox combinée avec Kafka émet des événements de manière fiable.
  • Une salle d’attente virtuelle absorbe les pics de charge.

Lors de la mise en œuvre, commencez par la partie noyau SQL avec un test de concurrence. N’ajoutez Redis et la salle d’attente virtuelle que lorsque les données de charge réelles montrent que vous les avez besoin.

system-designspring-bootredisconcurrencycase-study