Conception d'un système de location : calendrier, dépôt et annulation
1. Aperçu
La location de voiture en libre-service semble simple : le client choisit une voiture, une date, verse un dépôt et retire la voiture. Le plus difficile est de garantir qu’une voiture ne soit pas réservée par deux personnes pour le même horaire. Le système doit aussi gérer correctement les appels webhook répétés de la passerelle de paiement, et supporter les pics de trafic avant les vacances du Têt ou le 30 avril.
Cet article couvre l’ensemble de la conception : exigences, modèle de données, API, flux de réservation, dépôt et annulation. Chaque point difficile s’accompagne d’exemples de code en Java 21, Spring Boot 3.x, PostgreSQL, Redis et Kafka. À la fin, vous disposerez d’une architecture applicable à tout système de réservation par plage horaire, qu’il s’agisse de voitures, de chambres, de terrains de sport ou de salles de réunion.
2. Exigences
2.1. Exigences fonctionnelles
- Le client cherche une voiture par ville et plage horaire, par exemple
2026-01-25 08:00→2026-01-28 20:00. - Le client réserve (hold) une voiture pendant 15 minutes pour effectuer le paiement du dépôt.
- Une fois le dépôt payé, la réservation passe à
CONFIRMED. Si elle n’est pas payée après 15 minutes, elle passe àEXPIREDet la voiture est libérée. - Le client qui annule a droit à un remboursement selon la politique : 100 % si annulé plus de 72 heures avant, 50 % si plus de 24 heures avant, 0 % sinon.
- Si le propriétaire annule la réservation, le client est remboursé de la totalité du dépôt.
- Envoyer des notifications (SMS, push) quand le statut de la réservation change.
2.2. Échelle et contraintes
| Métrique | Normal | Pic (Têt) |
|---|---|---|
| Nombre de voitures | 20.000 | 20.000 |
| Requêtes de recherche | ~200 req/s | ~3.000 req/s |
| Réservations (hold) | ~10 req/s | ~300 req/s |
| Ratio lecture/écriture | ~20:1 | ~10:1, concentré sur quelques centaines de voitures « chaudes » |
La contrainte la plus importante est jamais de surréservation. Le système est autorisé à retourner une erreur « la voiture vient d’être réservée », mais ne doit jamais laisser deux contrats pointer vers une même voiture pour le même créneau horaire. C’est pourquoi les données de réservation doivent être fortement cohérentes. La page de recherche peut afficher des données de quelques dizaines de secondes anciennes.
3. Modèle de données
PostgreSQL est le choix approprié car il dispose du type tstzrange et des exclusion constraints. Avec ces deux outils, la base de données elle-même bloquera les deux plages horaires qui se chevauchent sur une même voiture.
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE car (
id BIGSERIAL PRIMARY KEY,
plate VARCHAR(20) NOT NULL UNIQUE,
model VARCHAR(100) NOT NULL,
city VARCHAR(50) NOT NULL,
daily_price BIGINT NOT NULL
);
CREATE TABLE booking (
id BIGSERIAL PRIMARY KEY,
car_id BIGINT NOT NULL REFERENCES car(id),
customer_id BIGINT NOT NULL,
period TSTZRANGE NOT NULL,
status VARCHAR(20) NOT NULL,
deposit_amount BIGINT NOT NULL,
hold_expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT no_overlap EXCLUDE USING gist (
car_id WITH =,
period WITH &&
) WHERE (status IN ('HOLD', 'CONFIRMED', 'ON_TRIP'))
);
CREATE TABLE payment (
id BIGSERIAL PRIMARY KEY,
booking_id BIGINT NOT NULL REFERENCES booking(id),
provider_txn_id VARCHAR(64) NOT NULL UNIQUE,
type VARCHAR(10) NOT NULL, -- DEPOSIT | REFUND
amount BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
topic VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
Quelques points importants :
no_overlapsignifie qu’il ne peut y avoir deux lignes avec le mêmecar_idet desperiodqui se chevauchent (&&). La clauseWHEREexclut les réservationsCANCELLED,EXPIRED,COMPLETEDde la contrainte, donc une réservation annulée ne bloque plus le calendrier.- L’extension
btree_gistest nécessaire pour combiner l’opération=surBIGINTavec l’opération&&sur les ranges dans un même index GiST. provider_txn_id UNIQUEest la base de l’idempotence lors du traitement des webhooks de paiement.- La table
outboxassure l’envoi des événements vers Kafka sans perte (voir section 7).
Remarque : Une voiture a besoin de temps de nettoyage et de vérification entre deux trajets. Vous devez stocker
periodavec un buffer ajouté, par exemple 2 heures après l’heure de retour. Ne pas ajouter le buffer dans le code de l’application, car tôt ou tard quelqu’un oubliera de l’ajouter.
4. Conception de l’API
| Méthode | Point de terminaison | Description |
|---|---|---|
GET |
/cars?city=HN&from=…&to=… |
Trouver des voitures disponibles |
POST |
/bookings |
Réserver, retourner bookingId et le lien de paiement |
POST |
/payments/webhook |
La passerelle de paiement rapporte le résultat |
POST |
/bookings/{id}/cancel |
Annuler |
POST /bookings reçoit l’en-tête Idempotency-Key. Si l’appli mobile réessaie à cause d’un problème réseau, la requête répétée ne créera pas de nouvelle réservation. Le serveur retourne 409 Conflict si la voiture a déjà été réservée, et 201 Created avec le corps suivant lors d’une réservation réussie :
{ "bookingId": 98123, "status": "HOLD", "holdExpiresAt": "2026-01-10T09:15:00+07:00",
"paymentUrl": "https://pay.example/checkout/98123" }
5. Flux de réservation
5.1. Prévention des surréservations avec constraint
L’approche familière mais erronée est « SELECT pour vérifier la disponibilité, puis INSERT ». Deux requêtes arrivant au même moment voient toutes deux la voiture libre, et les deux INSERT réussissent. À la place, insérez simplement et laissez l’exclusion constraint décider quelle requête l’emporte.
Voici un exemple utilisant JdbcClient avec les dépendances suivantes :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
@Service
public class BookingService {
private final JdbcClient jdbc;
public BookingService(JdbcClient jdbc) {
this.jdbc = jdbc;
}
public long hold(long carId, long customerId,
OffsetDateTime from, OffsetDateTime to, long deposit) {
try {
return jdbc.sql("""
INSERT INTO booking(car_id, customer_id, period, status,
deposit_amount, hold_expires_at)
VALUES (:carId, :customerId,
tstzrange(CAST(:from AS timestamptz), CAST(:to AS timestamptz), '[)'),
'HOLD', :deposit, now() + interval '15 minutes')
RETURNING id
""")
.param("carId", carId)
.param("customerId", customerId)
.param("from", from)
.param("to", to)
.param("deposit", deposit)
.query(Long.class)
.single();
} catch (DataIntegrityViolationException e) {
throw new CarUnavailableException(carId);
}
}
}
public class CarUnavailableException extends RuntimeException {
public CarUnavailableException(long carId) {
super("La voiture " + carId + " est déjà réservée pour cette période");
}
}
La plage '[)' comprend le point de départ mais pas le point final. Grâce à cela, un trajet se terminant à 20:00 et un trajet commençant à 20:00 ne sont pas considérés comme se chevauchant. Lorsque la contrainte est violée, PostgreSQL retourne SQLState 23P01, et Spring traduit cette erreur en DataIntegrityViolationException. Nous la convertissons en CarUnavailableException pour que le contrôleur la mappe à 409.
Quand deux clients réservent la même voiture 42 pour le même créneau horaire, le journal ressemblera à :
customer=1001 -> 201 Created bookingId=98123 status=HOLD
customer=1002 -> 409 Conflict La voiture 42 est déjà réservée pour cette période
5.2. Expiration des réservations
Un job périodique convertit les réservations HOLD expirées en EXPIRED. Les réservations EXPIRED ne correspondent pas à la clause WHERE de la contrainte, donc la voiture est libérée immédiatement.
@Component
public class HoldExpiryJob {
private final JdbcClient jdbc;
public HoldExpiryJob(JdbcClient jdbc) {
this.jdbc = jdbc;
}
@Scheduled(fixedDelay = 30_000)
public void expireHolds() {
int expired = jdbc.sql("""
UPDATE booking SET status = 'EXPIRED'
WHERE status = 'HOLD' AND hold_expires_at < now()
""").update();
if (expired > 0) {
System.out.println("Réservations expirées : " + expired);
}
}
}
N’oubliez pas d’activer @EnableScheduling sur une classe de configuration. Ce job peut s’exécuter en parallèle sur plusieurs instances de façon sûre, car la requête UPDATE ... WHERE status = 'HOLD' est déjà idempotente.
6. Dépôt : webhook idempotent et race avec expiration
La passerelle de paiement appelle généralement le webhook au moins une fois, ce qui signifie qu’elle peut l’appeler deux ou trois fois. En outre, le webhook peut arriver après l’expiration de la réservation, par exemple si le client paie à la 59ᵉ seconde mais que la banque traite lentement. Les deux scénarios doivent être gérés.
@Service
public class PaymentService {
private final JdbcClient jdbc;
private final OutboxWriter outbox;
public PaymentService(JdbcClient jdbc, OutboxWriter outbox) {
this.jdbc = jdbc;
this.outbox = outbox;
}
@Transactional
public void onDepositPaid(long bookingId, String providerTxnId, long amount) {
int inserted = jdbc.sql("""
INSERT INTO payment(booking_id, provider_txn_id, type, amount)
VALUES (:bookingId, :txn, 'DEPOSIT', :amount)
ON CONFLICT (provider_txn_id) DO NOTHING
""")
.param("bookingId", bookingId).param("txn", providerTxnId).param("amount", amount)
.update();
if (inserted == 0) {
return; // webhook dupliqué, déjà traité
}
int confirmed = jdbc.sql("""
UPDATE booking SET status = 'CONFIRMED', hold_expires_at = NULL
WHERE id = :id AND status = 'HOLD'
""").param("id", bookingId).update();
if (confirmed == 1) {
outbox.write("booking.confirmed", Map.of("bookingId", bookingId));
} else {
outbox.write("refund.requested", Map.of("bookingId", bookingId, "amount", amount));
}
}
}
Le traitement se déroule comme suit :
- Insérer dans
paymentavecON CONFLICT DO NOTHING. Siinserted == 0, c’est un webhook dupliqué, nous le ignorons. - Changer le statut avec une mise à jour conditionnelle (
WHERE status = 'HOLD'). Cette instruction agit comme un compare-and-set, donc aucun verrou explicite n’est nécessaire. - Si la mise à jour ne touche aucune ligne, le hold a déjà
EXPIREDet la voiture appartient peut-être à quelqu’un d’autre. Nous émettons alors automatiquement un remboursement, sans essayer de « ressusciter » la réservation.
Toutes les opérations se déroulent au sein d’une transaction. Soit le paiement, le statut de la réservation et l’événement outbox sont tous écrits, soit rien n’est écrit.
7. Annulation et remboursement
La politique de remboursement est une logique pure, nous la séparons donc dans une classe dédiée pour la tester facilement :
public final class CancellationPolicy {
private CancellationPolicy() {}
public static long refundAmount(long deposit, OffsetDateTime pickupAt,
OffsetDateTime cancelledAt, boolean cancelledByHost) {
if (cancelledByHost) {
return deposit;
}
long hoursBefore = Duration.between(cancelledAt, pickupAt).toHours();
if (hoursBefore >= 72) return deposit;
if (hoursBefore >= 24) return deposit / 2;
return 0;
}
}
Test avec JUnit 5 et AssertJ :
class CancellationPolicyTest {
private final OffsetDateTime pickup = OffsetDateTime.parse("2026-01-25T08:00:00+07:00");
@Test
void refundsFullWhenCancelledThreeDaysBefore() {
assertThat(CancellationPolicy.refundAmount(2_000_000, pickup, pickup.minusHours(72), false))
.isEqualTo(2_000_000);
}
@Test
void refundsHalfWhenCancelledOneDayBefore() {
assertThat(CancellationPolicy.refundAmount(2_000_000, pickup, pickup.minusHours(30), false))
.isEqualTo(1_000_000);
}
@Test
void refundsNothingWhenCancelledLastMinute() {
assertThat(CancellationPolicy.refundAmount(2_000_000, pickup, pickup.minusHours(2), false))
.isZero();
}
@Test
void refundsFullWhenHostCancels() {
assertThat(CancellationPolicy.refundAmount(2_000_000, pickup, pickup.minusHours(1), true))
.isEqualTo(2_000_000);
}
}
Au niveau du service, l’annulation utilise aussi une mise à jour conditionnelle et RETURNING pour récupérer les données de calcul du remboursement dans la même instruction :
UPDATE booking SET status = 'CANCELLED'
WHERE id = :id AND status = 'CONFIRMED'
RETURNING lower(period) AS pickup_at, deposit_amount;
Si l’instruction ne retourne aucune ligne, la réservation a été annulée précédemment ou le client a récupéré la voiture (ON_TRIP). L’API retourne alors 409. S’il y a une ligne retournée, nous calculons refundAmount puis écrivons l’événement refund.requested dans la boîte sortante, dans la même transaction.
Remarque : Utilisez toujours le serveur (
Clockinjecté) pourcancelledAt, ne prenez pas le temps du client. Quelques minutes de différence autour de la marque 24 heures peuvent représenter des millions de dongs.
8. Pics de charge et événements fiables
8.1. Séparation lecture/écriture
En haute saison, la plupart du trafic provient des opérations de recherche. Les résultats de recherche peuvent être cachés dans Redis avec la clé avail:{city}:{date} et un TTL de 30–60 secondes. Les données en cache peuvent être anciennes, et c’est acceptable car la contrainte PostgreSQL reste la source de vérité définitive. Au pire, le client voit la voiture « disponible » puis reçoit 409 lors de la réservation.
8.2. Prévention de l’accaparement
Avant le Têt, certains clients (ou bots) réservent en masse sans payer. Vous pouvez limiter chaque client à un maximum de 3 réservations par 15 minutes avec Redis :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
@Component
public class HoldRateLimiter {
private final StringRedisTemplate redis;
public HoldRateLimiter(StringRedisTemplate redis) {
this.redis = redis;
}
public boolean tryAcquire(long customerId) {
String key = "hold-limit:" + customerId;
Long count = redis.opsForValue().increment(key);
if (count != null && count == 1) {
redis.expire(key, Duration.ofMinutes(15));
}
return count != null && count <= 3;
}
}
increment est une opération atomique, donc plusieurs instances l’appelant simultanément comptent toujours correctement. La 4ᵉ tentative dans la fenêtre de 15 minutes recevra false, et l’API retournera 429 Too Many Requests.
8.3. Outbox vers Kafka
Envoyer vers Kafka immédiatement dans une transaction est très risqué. Si le commit DB réussit mais que Kafka échoue, l’événement est perdu. Si l’ordre inverse, nous envoions un événement « fantôme » pour une modification jamais enregistrée. Le pattern transactional outbox résout ce problème : écrire l’événement dans la table outbox au sein de la même transaction, puis un relais lit cette table et l’envoie vers Kafka.
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
@Component
public class OutboxRelay {
private final JdbcClient jdbc;
private final KafkaTemplate<String, String> kafka;
public OutboxRelay(JdbcClient jdbc, KafkaTemplate<String, String> kafka) {
this.jdbc = jdbc;
this.kafka = kafka;
}
@Scheduled(fixedDelay = 1_000)
@Transactional
public void publish() {
var rows = jdbc.sql("""
SELECT id, topic, payload::text AS payload FROM outbox
WHERE published_at IS NULL ORDER BY id LIMIT 100
FOR UPDATE SKIP LOCKED
""").query().listOfRows();
for (var row : rows) {
kafka.send((String) row.get("topic"), (String) row.get("payload")).join();
jdbc.sql("UPDATE outbox SET published_at = now() WHERE id = :id")
.param("id", row.get("id")).update();
}
}
}
FOR UPDATE SKIP LOCKED permet à plusieurs instances de relais de s’exécuter en parallèle sans acquérir les mêmes lignes. Le relais peut toujours envoyer un événement en double, par exemple en cas de crash après send mais avant UPDATE. C’est pourquoi le consommateur (service de notification, service de remboursement) doit être idempotent sur bookingId.
9. Erreurs courantes et quand ne pas appliquer
- Vérifier la disponibilité avec SELECT puis INSERT. Cette approche a toujours une condition de course. Laissez la contrainte faire son travail.
- Verrouiller avec Redis au lieu d’une contrainte. Le verrou peut expirer à mi-parcours, par exemple lors d’une pause GC. Redis devrait seulement être une couche de protection auxiliaire, pas la source de vérité.
- Stocker le temps sans fuseau horaire. Utilisez
TIMESTAMPTZetOffsetDateTime, surtout si vous envisagez de vous étendre à plusieurs pays par la suite. - Rembourser de manière synchrone dans la requête d’annulation. Une passerelle de paiement lente ralentira l’API d’annulation. Poussez le remboursement via la boîte sortante.
- Ne pas utiliser l’exclusion constraint quand le calendrier est fragmenté sur plusieurs bases de données selon un critère autre que
car_id. La contrainte n’est valide que dans une seule base de données. Si vous devez fragmenter, fragmentez parcar_idpour que tous les bookings d’une voiture se trouvent au même endroit.
10. Conclusion
Le problème de la location de voiture en libre-service tourne autour de trois points clés. Premièrement, laisser la base de données bloquer la surréservation avec tstzrange et l’exclusion constraint. Deuxièmement, changer le statut avec une mise à jour conditionnelle combinée à une clé unique pour gérer les webhooks dupliqués et les races avec les holds expirés. Troisièmement, séparer les tâches auxiliaires comme les notifications et les remboursements du flux principal avec une boîte sortante transactionnelle et Kafka. Redis prend en charge la charge : mise en cache des recherches et limitation des réservations. La source de vérité reste dans PostgreSQL.
Cette architecture s’applique à la plupart des systèmes de réservation par plage horaire. Il vous suffit de remplacer car par une chambre d’hôtel, un terrain de sport ou un calendrier de consultation médicale.