Conception d'un système de réservation de billets d'avion
1. Aperçu général
La vente de billets d’avion semble simple : le client choisit un siège, paie, reçoit son billet. La difficulté, c’est que des milliers de personnes se battent pour un nombre limité de sièges. Le paiement transite par un tiers, qui peut être lent ou renvoyer le résultat plusieurs fois.
Dans cet article, vous allez concevoir un système de réservation de bout en bout. Le système comprend la retenue avec délai d’expiration, les paiements idempotents, la prévention du surbooking accidentel et la gestion des pics de charge. Après la lecture, vous aurez un cadre de conception à appliquer à des problèmes similaires comme la vente de billets de concert, la réservation d’hôtel ou les ventes flash.
2. Spécifications
2.1. Exigences fonctionnelles
- Chercher des vols et consulter le schéma des sièges disponibles.
- Retenir (hold) un ou plusieurs sièges pendant 10 minutes.
- Payer. En cas de succès, émettre le billet ; en cas d’échec ou d’expiration, libérer le siège.
- Annuler un billet et rembourser selon la politique en vigueur.
2.2. Échelle et contraintes
- Environ 5 000 vols par jour, 180 sièges par vol.
- Les lectures (recherche, consultation des sièges) sont 100 fois plus fréquentes que les écritures.
- Au lancement de ventes de billets à bas prix, il peut y avoir 50 000 requêtes par seconde sur quelques dizaines de vols.
- Contrainte critique : un siège ne peut jamais être vendu à deux personnes. Le nombre de billets vendus par classe de tarif ne peut pas dépasser la limite fixée par la compagnie.
Remarque : Les compagnies aériennes intentionnellement surbookent selon un ratio, par exemple vendent 183 billets pour 180 sièges en classe économique. C’est parce que certains clients ne se présentent pas. Ce que nous combattons, c’est le surbooking accidentel dû aux conditions de concurrence. Le niveau de surbooking intentionnel doit être un paramètre contrôlé.
Ces deux contraintes dictent le choix de l’architecture. L’inventaire des sièges doit avoir une cohérence forte. La recherche peut accepter la cohérence finale et être fortement cachée.
3. Modèle de données
Nous utilisons PostgreSQL comme source de vérité pour l’inventaire :
CREATE TABLE flight (
id BIGINT PRIMARY KEY,
code VARCHAR(10) NOT NULL,
departure_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE fare_inventory (
flight_id BIGINT NOT NULL REFERENCES flight(id),
fare_class VARCHAR(10) NOT NULL, -- ECONOMY, BUSINESS
capacity INT NOT NULL,
overbook_limit INT NOT NULL DEFAULT 0,
sold INT NOT NULL DEFAULT 0,
held INT NOT NULL DEFAULT 0,
PRIMARY KEY (flight_id, fare_class),
CHECK (sold + held <= capacity + overbook_limit)
);
CREATE TABLE seat (
flight_id BIGINT NOT NULL REFERENCES flight(id),
seat_no VARCHAR(4) NOT NULL,
status VARCHAR(10) NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE, HELD, SOLD
booking_id BIGINT,
held_until TIMESTAMPTZ,
PRIMARY KEY (flight_id, seat_no)
);
CREATE TABLE booking (
id BIGINT PRIMARY KEY,
flight_id BIGINT NOT NULL,
customer_id BIGINT NOT NULL,
status VARCHAR(12) NOT NULL, -- HELD, CONFIRMED, EXPIRED, CANCELLED
amount NUMERIC(12,2) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE payment (
id BIGINT PRIMARY KEY,
booking_id BIGINT NOT NULL,
idempotency_key VARCHAR(64) NOT NULL UNIQUE,
status VARCHAR(10) NOT NULL
);
La conception dispose de deux niveaux d’inventaire :
fare_inventorycompte par classe de tarif. La contrainteCHECKest le filet de sécurité final : même si le code a un bug, la base de données refusera un surbooking au-delà de la limite.seatgère chaque siège en particulier. La clé primaire(flight_id, seat_no)garantit qu’il n’y a qu’une seule ligne d’état par siège.
4. API
GET /flights?from=SGN&to=HAN&date=2026-12-20 -> liste des vols (mise en cache)
GET /flights/{id}/seats -> schéma des sièges (mise en cache 2-5 sec)
POST /bookings -> retenir, retourner bookingId + expiresAt
POST /bookings/{id}/payments -> en-tête Idempotency-Key obligatoire
POST /payments/webhook -> passerelle de paiement rappelle ici
DELETE /bookings/{id} -> annuler
Requête de retenue :
{ "flightId": 1001, "customerId": 42, "fareClass": "ECONOMY", "seats": ["12A", "12B"] }
Le schéma des sièges peut être vieux de quelques secondes. L’utilisateur peut voir un siège « libre » puis être rejeté lors de la retenue. C’est un compromis acceptable, du moment que l’étape de retenue revérifie toujours la base de données.
5. Retenue : prévention de la vente de sièges en double
L’erreur la plus courante est le « lire puis écrire » : SELECT status, voir AVAILABLE, puis UPDATE. Deux transactions peuvent voir le même siège libre puis l’écrire. Le résultat : un siège vendu à deux personnes.
La solution est la mise à jour conditionnelle : intégrer la condition de vérification dans la clause UPDATE et compter les lignes affectées.
public interface SeatRepository extends JpaRepository<Seat, SeatId> {
@Modifying
@Query(value = """
UPDATE seat
SET status = 'HELD', booking_id = :bookingId, held_until = :until
WHERE flight_id = :flightId
AND seat_no IN (:seatNos)
AND (status = 'AVAILABLE' OR (status = 'HELD' AND held_until < now()))
""", nativeQuery = true)
int holdSeats(Long flightId, List<String> seatNos, Long bookingId, Instant until);
@Modifying
@Query(value = """
UPDATE seat SET status = 'SOLD', held_until = NULL
WHERE booking_id = :bookingId AND status = 'HELD'
""", nativeQuery = true)
int markSold(Long bookingId);
}
public interface FareInventoryRepository extends JpaRepository<FareInventory, FareInventoryId> {
@Modifying
@Query(value = """
UPDATE fare_inventory SET held = held + :n
WHERE flight_id = :flightId AND fare_class = :fareClass
AND sold + held + :n <= capacity + overbook_limit
""", nativeQuery = true)
int reserve(Long flightId, String fareClass, int n);
}
PostgreSQL verrouille la ligne lors de l’exécution de UPDATE. La deuxième transaction doit attendre, puis réévalue la condition WHERE sur les nouvelles données. À ce moment, la condition échoue, donc la déclaration retourne 0 lignes. La condition held_until < now() permet de « reprendre » les sièges qui ont dépassé leur délai de retenue sans attendre un job de nettoyage.
Le service combine les deux étapes dans une seule transaction :
@Service
@RequiredArgsConstructor
public class BookingService {
private static final Duration HOLD_TTL = Duration.ofMinutes(10);
private final SeatRepository seatRepository;
private final FareInventoryRepository inventoryRepository;
private final BookingRepository bookingRepository;
private final IdGenerator idGenerator;
@Transactional
public Booking hold(HoldRequest req) {
int n = req.seats().size();
if (inventoryRepository.reserve(req.flightId(), req.fareClass(), n) != 1) {
throw new SoldOutException(req.flightId(), req.fareClass());
}
Instant until = Instant.now().plus(HOLD_TTL);
long bookingId = idGenerator.next();
int held = seatRepository.holdSeats(req.flightId(), req.seats(), bookingId, until);
if (held != n) {
// Annulation de la réservation et des sièges déjà retenus
throw new SeatUnavailableException(req.seats());
}
return bookingRepository.save(new Booking(bookingId, req.flightId(),
req.customerId(), BookingStatus.HELD, req.amount(), until));
}
}
SeatUnavailableException est une RuntimeException, donc Spring annule toute la transaction. Le client obtient tous les sièges choisis ou aucun. Il n’y a pas de cas où 12A est retenu mais 12B est perdu.
Remarque : Pour éviter un blocage quand deux clients choisissent {12A, 12B} et {12B, 12A}, triez
seatNosavant l’envoi vers la base. Postgres ne garantit pas l’ordre de verrouillage avec une clauseIN. Pour de grands groupes de sièges, utilisezSELECT … ORDER BY seat_no FOR UPDATEavant la mise à jour.
Si un siège que vous veniez de retenir est repris par quelqu’un d’autre, le compteur held de la vieille réservation reste en place. Le job de nettoyage à la section 7 le corrigera.
6. Paiement idempotent
La passerelle de paiement peut appeler le webhook plusieurs fois. L’utilisateur peut cliquer deux fois sur « Payer ». Chaque opération financière doit être idempotente.
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentRepository paymentRepository;
private final BookingRepository bookingRepository;
private final SeatRepository seatRepository;
private final FareInventoryRepository inventoryRepository;
private final OutboxRepository outboxRepository;
@Transactional
public PaymentResult confirm(long bookingId, String idempotencyKey) {
Optional<Payment> existing = paymentRepository.findByIdempotencyKey(idempotencyKey);
if (existing.isPresent()) {
return PaymentResult.of(existing.get()); // appel répété : retourner ancien résultat
}
// HELD -> CONFIRMED, succès seulement si la réservation n'a pas expiré
int updated = bookingRepository.transition(bookingId, "HELD", "CONFIRMED");
if (updated == 0) {
paymentRepository.save(Payment.refundRequired(bookingId, idempotencyKey));
return PaymentResult.REFUND;
}
Booking booking = bookingRepository.findById(bookingId).orElseThrow();
int seats = seatRepository.markSold(bookingId);
inventoryRepository.moveHeldToSold(booking.getFlightId(), booking.getFareClass(), seats);
paymentRepository.save(Payment.success(bookingId, idempotencyKey));
outboxRepository.save(OutboxEvent.bookingConfirmed(bookingId));
return PaymentResult.SUCCESS;
}
}
La méthode transition n’est qu’une autre mise à jour conditionnelle :
UPDATE booking SET status = :to WHERE id = :id AND status = :from
Le point critique est la course entre « l’argent arrive » et « la retenue expire ». Le flux de paiement et le job d’expiration changent tous les deux le statut à partir de HELD. Un seul gagne. Si l’argent arrive en retard, le client est remboursé automatiquement au lieu de recevoir un siège déjà vendu à quelqu’un d’autre.
La colonne idempotency_key UNIQUE empêche deux requêtes avec la même clé de s’exécuter en parallèle. Les deux ne verront pas l’ancienne transaction, mais la deuxième lèvera une DataIntegrityViolationException lors de l’insertion, ce qui entraînera l’annulation de sa transaction.
6.1. Outbox et Kafka
L’émission du billet, l’envoi d’e-mail et la synchronisation vers le système d’enregistrement s’exécutent de manière asynchrone. N’envoyez pas directement Kafka dans la transaction. Si la base valide mais que l’envoi échoue (ou vice versa), les données divergeront. Écrivez plutôt dans une table outbox dans la même transaction, puis un relais pousse vers Kafka :
@Component
@RequiredArgsConstructor
public class OutboxRelay {
private final OutboxRepository outboxRepository;
private final KafkaTemplate<String, String> kafka;
@Scheduled(fixedDelay = 500)
@Transactional
public void publish() {
for (OutboxEvent e : outboxRepository.findTop100ByPublishedFalseOrderById()) {
kafka.send("booking-events", String.valueOf(e.getBookingId()), e.getPayload()).join();
e.markPublished();
}
}
}
Le relais garantit at-least-once, donc le consommateur côté émission de billet doit aussi être idempotent, par exemple en conservant un enregistrement de bookingId déjà traité. Utiliser bookingId comme clé garantit que tous les événements d’une même réservation vont dans la même partition et conservent l’ordre.
7. Nettoyage des retenues expirées
@Scheduled(fixedDelay = 30_000)
@Transactional
public void expireHolds() {
for (Booking b : bookingRepository.findTop500ByStatusAndExpiresAtBefore(
BookingStatus.HELD, Instant.now())) {
if (bookingRepository.transition(b.getId(), "HELD", "EXPIRED") == 1) {
int released = seatRepository.releaseByBooking(b.getId()); // HELD -> AVAILABLE
inventoryRepository.release(b.getFlightId(), b.getFareClass(), b.getSeatCount());
}
}
}
Le job soustrait held selon seatCount de la réservation, pas selon le nombre de sièges libérés. C’est parce que certains sièges ont peut-être déjà été repris par quelqu’un d’autre, mais le compteur compte toujours pour l’ancienne réservation. En cas d’exécution multi-instance, utilisez SELECT … FOR UPDATE SKIP LOCKED ou ShedLock pour éviter que les nœuds traitent les mêmes données.
8. Gestion des pics de charge
Quand la vente de billets bon marché s’ouvre, 50 000 requêtes/seconde peuvent frapper quelques lignes fare_inventory. C’est une hot row. Chaque transaction fait la queue pour le verrou de la même ligne. La base de données ne meurt pas faute de CPU, mais d’épuisement du pool de connexions.
La stratégie est de filtrer avant la base de données :
- Salle d’attente virtuelle (waiting room) : ne laisser que N personnes par seconde accéder à la page de réservation ; les autres reçoivent un token d’attente.
- Filtre rapide sur Redis : verrouiller les sièges avec
SET NXpour rejeter tôt les requêtes qui perdront certainement. - La base de données reste l’arbitre final.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
@Component
@RequiredArgsConstructor
public class SeatGate {
private final StringRedisTemplate redis;
public boolean tryEnter(long flightId, String seatNo, long customerId) {
String key = "seat:%d:%s".formatted(flightId, seatNo);
Boolean ok = redis.opsForValue()
.setIfAbsent(key, String.valueOf(customerId), Duration.ofSeconds(15));
return Boolean.TRUE.equals(ok);
}
}
Si tryEnter retourne false, l’API retourne immédiatement 409 Conflict sans ouvrir aucune transaction. La TTL de 15 secondes suffit pour l’étape de retenue sur la base, et la clé disparaît d’elle-même si l’instance plante.
Remarque : Ne faites pas de Redis la source de vérité pour l’inventaire. Lors d’un basculement Redis, vous pourriez perdre les clés et laisser deux personnes passer le portail. C’est acceptable, car la mise à jour conditionnelle sur la base bloquera quand même. Inversement, si vous faites confiance uniquement à Redis, vous vendrez des sièges en double.
Pour une hot row avec comptage par classe de tarif, vous pouvez diviser fare_inventory en plusieurs « buckets » (par exemple, 10 lignes de 18 sièges chacune) et en choisir un aléatoirement pour réduire la contention de verrous.
9. Tests anti-surbooking
Le test le plus important : 50 threads tentent de retenir le même siège, un seul gagne. Exécutez contre une vraie PostgreSQL via Testcontainers, car le comportement de verrouillage de H2 diffère de Postgres.
@SpringBootTest
@Testcontainers
class BookingServiceConcurrencyTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired BookingService bookingService;
@Test
void onlyOneCustomerCanHoldTheSameSeat() throws Exception {
int threads = 50;
AtomicInteger success = new AtomicInteger();
CountDownLatch start = new CountDownLatch(1);
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < threads; i++) {
long customerId = i;
pool.submit(() -> {
start.await();
try {
bookingService.hold(new HoldRequest(1001L, customerId, "ECONOMY",
List.of("12A"), new BigDecimal("1500000")));
success.incrementAndGet();
} catch (SeatUnavailableException | SoldOutException ignored) { }
return null;
});
}
start.countDown();
}
assertThat(success.get()).isEqualTo(1);
}
}
Le test suppose des données d’exemple (vol 1001, siège 12A disponible) chargées via Flyway ou data.sql. Le CountDownLatch libère tous les threads simultanément pour maximiser la contention. Le bloc try-with-resources de ExecutorService (Java 21) attend que toutes les tâches se terminent avant de fermer. Le résultat attendu : exactement une réussite, 49 fois une SeatUnavailableException.
10. Erreurs courantes
- Utiliser
synchronizedou un lock dans la JVM : fonctionne uniquement avec une instance. Passer à deux pods casse tout. - Optimistic locking avec
@Versionpour les hot rows : logiquement correct, mais en pic de charge, le taux de retry est très élevé. La mise à jour conditionnelle est plus efficace car elle ne nécessite pas de lecture préalable. - Garder la transaction ouverte lors d’appels à la passerelle de paiement : le verrou de ligne est maintenu pendant des secondes. Retenez, validez, appelez le paiement, puis confirmez dans une nouvelle transaction.
- Oublier de rembourser après expiration de la retenue : c’est la source de plainte la plus importante. Ayez toujours une branche de remboursement automatique.
- Mettre en cache le schéma des sièges trop longtemps : les utilisateurs choisissent constamment des sièges perdus. 2–5 secondes suffisent.
11. Conclusion
Un système de réservation de billets d’avion tourne autour d’un principe : séparer ce qui exige une cohérence forte (inventaire des sièges, état de la réservation) de ce qui tolère de la latence (recherche, notifications, émission de billet). Les mises à jour conditionnelles sur la base de données résolvent la concurrence sans besoin d’un verrou distribué. Une machine d’état HELD → CONFIRMED/EXPIRED avec transition conditionnelle gère la course entre le paiement et l’expiration. Les clés d’idempotence associées à l’outbox garantissent que l’argent et les événements ne sont pas dupliqués. Redis et la salle d’attente virtuelle protègent la base contre les pics de trafic.
Vous pouvez appliquer ce cadre à tout problème de « ressource rare, beaucoup de concurrents » : billets de concert, chambres d’hôtel, créneaux de consultation médicale ou ventes flash.