Système d'agenda : slots et prévention des doublons
1. Aperçu général
Prendre un rendez-vous médical semble simple : le patient choisit un médecin, choisit une heure et clique sur « Réserver ». La difficulté se situe ailleurs. Des centaines de personnes peuvent cliquer sur le même slot à 7 heures du matin, quand la clinique ouvre les réservations. Le système doit garantir que deux personnes ne peuvent pas accaparer la même place.
Cet article passe en revue la conception complète : les exigences, le modèle de données, l’API, le flux de réservation et d’annulation. L’article se concentre ensuite sur la partie la plus importante : prévenir les doublons et gérer les pics de charge. Après la lecture, vous pourrez construire vous-même le cœur du système de réservation avec Spring Boot et PostgreSQL. Ce cœur du système est correct même avec plusieurs requêtes concurrentes.
2. Exigences du problème
2.1. Exigences fonctionnelles
- Le médecin (ou l’admin) crée un planning de travail. Le système génère des slots fixes, par exemple chaque slot dure 15 minutes.
- Chaque slot a une capacité. Une consultation spécialisée a généralement une capacité de 1. Une séance de vaccination peut avoir une capacité de 10 pour chaque créneau.
- Le patient consulte les slots disponibles par médecin et par jour, puis réserve ou annule un rendez-vous.
- Un patient ne peut pas avoir deux rendez-vous valides dans le même slot.
- Après une réservation réussie, le système envoie un SMS ou un email de confirmation.
2.2. Échelle et contraintes
- Il y a 2 000 médecins. Chaque médecin a environ 40 slots par jour. Le planning ouvre 14 jours à l’avance, soit environ 1,1 million de slots actifs.
- Les lectures sont beaucoup plus nombreuses que les écritures, avec un ratio d’environ 50:1.
- Pics de charge : le planning d’un médecin réputé ouvre à 7:00. Dans les 30 premières secondes, il peut y avoir 5 000 requêtes/seconde sur quelques dizaines de slots.
- Cohérence forte pour l’opération de réservation. On ne doit jamais dépasser la capacité. La liste des slots disponibles peut être légèrement à jour avec quelques secondes de retard.
- Le client peut réessayer en cas de problème réseau. Par conséquent, l’opération de réservation doit être idempotente.
3. Modèle de données
Il y a une décision importante : créer chaque slot en tant que ligne de la base de données au lieu de calculer dynamiquement les heures libres à partir du planning de travail. Quand chaque slot est une ligne, on a un point spécifique pour verrouiller et vérifier la capacité. Les contentions sont résolues au niveau d’une seule ligne.
CREATE TABLE time_slot (
id BIGSERIAL PRIMARY KEY,
doctor_id BIGINT NOT NULL,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
capacity INT NOT NULL CHECK (capacity > 0),
booked_count INT NOT NULL DEFAULT 0,
CONSTRAINT chk_booked CHECK (booked_count BETWEEN 0 AND capacity),
CONSTRAINT uq_doctor_start UNIQUE (doctor_id, start_at)
);
CREATE TABLE appointment (
id BIGSERIAL PRIMARY KEY,
slot_id BIGINT NOT NULL REFERENCES time_slot(id),
patient_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL, -- RÉSERVÉ, ANNULÉ
idempotency_key VARCHAR(64) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX uq_active_patient_slot
ON appointment (slot_id, patient_id)
WHERE status = 'BOOKED';
Chaque contrainte dans le schéma joue un rôle particulier :
chk_bookedest la dernière ligne de défense. Si le code a un bogue, la base de données rejette toujours un dépassement debooked_countpar rapport àcapacity.uq_doctor_startempêche la création de slots dupliqués si le job de création de planning est relancé.uq_active_patient_slotest un index unique partiel de PostgreSQL. Un patient ne peut avoir au maximum qu’un rendez-vousBOOKEDpar slot. Les rendez-vous annulés ne sont pas comptabilisés, donc le patient peut réserver à nouveau.idempotency_key UNIQUEfait que la tentative du client ne crée pas un second rendez-vous.
Entité JPA correspondante pour time_slot (getters abrégés) :
@Entity
@Table(name = "time_slot")
public class TimeSlot {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long doctorId;
private Instant startAt;
private Instant endAt;
private int capacity;
private int bookedCount;
protected TimeSlot() {}
public TimeSlot(Long doctorId, Instant startAt, int capacity) {
this.doctorId = doctorId;
this.startAt = startAt;
this.endAt = startAt.plus(15, ChronoUnit.MINUTES);
this.capacity = capacity;
this.bookedCount = 0;
}
// getters...
}
4. Conception de l’API
GET /api/doctors/{doctorId}/slots?date=2026-10-12 -> liste des slots disponibles
POST /api/appointments -> réserver
Header: Idempotency-Key: 6f1c...
Body: { "slotId": 1001, "patientId": 77 }
DELETE /api/appointments/{id} -> annuler
Codes de réponse pour POST /api/appointments :
201 Created -> réservation réussie
200 OK -> nouvelle tentative avec la même Idempotency-Key, retour du rendez-vous précédent
409 Conflict -> le slot est complet ou le patient a déjà réservé ce slot
429 Too Many Requests -> dépassement de la limite de taux
Le client génère lui-même l’Idempotency-Key (UUID) pour chaque clic sur le bouton. Lors d’une nouvelle tentative, le client envoie à nouveau la même clé.
5. Prévention des doublons
5.1. Pourquoi « vérifier puis écrire » est incorrect
Le premier approche que beaucoup de gens envisagent :
TimeSlot slot = slotRepository.findById(slotId).orElseThrow();
if (slot.getBookedCount() < slot.getCapacity()) { // (1) lecture
slot.setBookedCount(slot.getBookedCount() + 1); // (2) écriture
appointmentRepository.save(appointment);
}
Supposons que deux threads A et B lisent ensemble à l’étape (1) et voient booked_count = 0, capacity = 1. Les deux passent la condition et les deux écrivent. Le résultat est deux rendez-vous pour la même place. Avec le niveau d’isolation par défaut READ COMMITTED de PostgreSQL, c’est l’erreur lost update.
5.2. Conditional update : vérifier et écrire en une seule instruction
La solution est de combiner la vérification et l’écriture dans une seule instruction UPDATE conditionnelle. La base de données verrouille la ligne pendant la mise à jour. Le deuxième thread doit attendre, puis révaluer la condition WHERE sur la nouvelle valeur.
public interface TimeSlotRepository extends JpaRepository<TimeSlot, Long> {
@Modifying
@Query("""
update TimeSlot s
set s.bookedCount = s.bookedCount + 1
where s.id = :id
and s.bookedCount < s.capacity
and s.startAt > :now
""")
int tryReserve(@Param("id") long id, @Param("now") Instant now);
@Modifying
@Query("update TimeSlot s set s.bookedCount = s.bookedCount - 1 where s.id = :id and s.bookedCount > 0")
int release(@Param("id") long id);
}
tryReserve retourne le nombre de lignes affectées. Si le résultat est 1, vous avez obtenu la place. Si c’est 0, le slot est complet, le délai a expiré ou il n’existe pas. Pas besoin de SELECT ... FOR UPDATE, et pas besoin de locking optimiste avec une colonne version. Le verrou n’est maintenu que pendant une très courte période.
Service de réservation :
@Service
public class BookingService {
private final TimeSlotRepository slotRepository;
private final AppointmentRepository appointmentRepository;
private final Clock clock;
public BookingService(TimeSlotRepository slotRepository,
AppointmentRepository appointmentRepository,
Clock clock) {
this.slotRepository = slotRepository;
this.appointmentRepository = appointmentRepository;
this.clock = clock;
}
@Transactional
public Appointment book(long slotId, long patientId, String idempotencyKey) {
var existing = appointmentRepository.findByIdempotencyKey(idempotencyKey);
if (existing.isPresent()) {
return existing.get(); // retry : retourner le résultat précédent
}
if (slotRepository.tryReserve(slotId, clock.instant()) == 0) {
throw new SlotFullException(slotId);
}
// saveAndFlush pour que les erreurs d'unique constraint éclosent immédiatement dans cette transaction
return appointmentRepository.saveAndFlush(
Appointment.booked(slotId, patientId, idempotencyKey));
}
}
Clock doit être déclaré en tant que bean, par exemple Clock.systemUTC(), pour que les tests puissent le remplacer par une heure fixe. Le code fonctionne comme suit :
- La vérification d’idempotence s’exécute d’abord. Si le client réessaye après que la première requête a été validée, le client reçoit le rendez-vous d’origine.
- Si
tryReserveréussit mais quesaveAndFlushviole la contrainteuq_active_patient_slot(le patient a déjà un rendez-vous), Spring lèveDataIntegrityViolationException. La transaction est entièrement annulée, y compris l’incrément debooked_count. La place est automatiquement retournée. - Deux requêtes de nouvelle tentative peuvent arriver pratiquement en même temps et aucune n’a vu la clé. Dans ce cas, une requête violera la contrainte
idempotency_key UNIQUE. Au niveau du contrôleur, vous capturez cette erreur, relisez selon la clé, puis retournez200.
@ExceptionHandler(DataIntegrityViolationException.class)
ResponseEntity<ProblemDetail> onConflict(DataIntegrityViolationException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"Vous avez déjà un rendez-vous à ce créneau"));
}
Remarque : l’exemple ci-dessus retourne
409pour toute violation de contrainte pour garder le code court. Dans un système réel, vous devez distinguer les deux cas selon le nom de la contrainte. La violation deuq_active_patient_slotretourne409. La violation d’unique suridempotency_keyrelit le rendez-vous par clé et retourne200.
5.3. L’annulation doit également être conditionnelle
L’annulation subit aussi des conditions de concurrence. Si le patient clique sur « Annuler » deux fois, booked_count peut être décrémenté deux fois. La solution est toujours la même : ne décrémenter que lorsque la transition d’état réussit réellement.
@Transactional
public void cancel(long appointmentId) {
Appointment appt = appointmentRepository.findById(appointmentId).orElseThrow();
int changed = appointmentRepository.markCancelled(appointmentId); // où status = 'BOOKED'
if (changed == 1) {
slotRepository.release(appt.getSlotId());
}
}
Explication : markCancelled est une requête @Modifying : update Appointment a set a.status = 'CANCELLED' where a.id = :id and a.status = 'BOOKED'. La deuxième requête d’annulation reçoit changed = 0 et ne fait rien de plus.
5.4. Tester avec plusieurs threads concurrents
Le test ci-dessous oppose 20 patients pour un seul slot de capacité 1. Vous devez exécuter le test avec PostgreSQL réel via Testcontainers, car H2 ne supporte pas les index partiels.
@SpringBootTest
class BookingServiceConcurrencyTest {
@Autowired BookingService bookingService;
@Autowired TimeSlotRepository slotRepository;
@Test
void onlyOnePatientGetsSingleCapacitySlot() throws Exception {
TimeSlot slot = slotRepository.save(
new TimeSlot(42L, Instant.now().plus(1, ChronoUnit.DAYS), 1));
int patients = 20;
var start = new CountDownLatch(1);
var success = new AtomicInteger();
try (ExecutorService pool = Executors.newFixedThreadPool(patients)) {
for (int i = 0; i < patients; i++) {
long patientId = i;
pool.submit(() -> {
start.await();
try {
bookingService.book(slot.getId(), patientId, "key-" + patientId);
success.incrementAndGet();
} catch (SlotFullException ignored) {
}
return null;
});
}
start.countDown(); // relâcher 20 threads au même moment
} // close() attend la fin de toutes les tâches
}
}
Le CountDownLatch garde tous les threads pour démarrer en même temps, ce qui rend les conditions de concurrence plus susceptibles de se produire. Résultat attendu :
success = 1
booked_count = 1
19 autres threads reçoivent SlotFullException
Si vous remplacez tryReserve par la façon « lire puis écrire » de la section 5.1, ce test échoue généralement avec success supérieur à 1.
6. Gérer les pics de charge à l’ouverture
Le conditional update garantit la justesse, mais pas la performance sous charge. 5 000 requêtes/seconde convergeant sur quelques lignes créeront une file d’attente de verrous dans la base de données. Le pool de connexions s’épuisera progressivement et les autres API ralentiront. Il y a trois couches de gestion.
6.1. Mettre en cache la liste des slots disponibles
La plupart du trafic est des GET slots. Vous pouvez mettre en cache le résultat par doctorId + date dans Redis avec une TTL courte, par exemple 3–5 secondes. Quand une réservation ou une annulation réussit, supprimez la clé correspondante. La liste peut être légèrement obsolète, mais c’est correct : la décision finale revient toujours à tryReserve sur la base de données.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
6.2. Limitation de débit et blocage précoce des slots complets
@Component
public class BookingGate {
private final StringRedisTemplate redis;
public BookingGate(StringRedisTemplate redis) { this.redis = redis; }
public boolean allowPatient(long patientId) {
String key = "rl:booking:" + patientId;
Long count = redis.opsForValue().increment(key);
if (count != null && count == 1) {
redis.expire(key, Duration.ofSeconds(10));
}
return count != null && count <= 5; // au maximum 5 réservations / 10 sec
}
public boolean isKnownFull(long slotId) {
return Boolean.TRUE.equals(redis.hasKey("slot:full:" + slotId));
}
public void markFull(long slotId) {
redis.opsForValue().set("slot:full:" + slotId, "1", Duration.ofSeconds(30));
}
}
Explication : le contrôleur appelle d’abord allowPatient. Si le résultat est false, le contrôleur retourne 429. Quand tryReserve retourne 0, appelez markFull. Les requêtes ultérieures pour le même slot seront rejetées directement dans Redis sans atteindre la base de données. Une TTL de 30 secondes permet au slot de se « réouvrir » lorsque quelqu’un annule un rendez-vous.
Remarque : n’utilisez pas Redis comme source de vérité pour la capacité, par exemple en faisant un
DECRsur un compteur et en supposant que la réservation est terminée. Lorsque Redis et la base de données se désynchronisent (redémarrage de Redis, rollback de transaction), vous vendrez au-delà ou perdrez des places. Redis ne doit être qu’un filtre placé devant la base de données.
6.3. Envoyer les notifications de manière asynchrone via outbox
N’appelez pas le service SMS à l’intérieur de la transaction de réservation. Enregistrez plutôt une ligne dans la table outbox_event dans la même transaction. Un worker (ou Debezium) poussera l’événement AppointmentBooked vers Kafka, et le service de notification l’utilisera. La transaction de réservation est ainsi courte et indépendante du service externe. Vous n’enverrez jamais de SMS pour un rendez-vous qui a été annulé.
7. Quand faut-il évoluer la conception
- Réserver quand le paiement est requis en premier : ajouter un statut
HELDavec une colonneexpires_at.tryReserveincrémente toujoursbooked_count. Un job périodique convertit les rendez-vousHELDexpirés enEXPIREDet appellerelease. Ne laissez pas les places retenues indéfiniment. - Empêcher un patient de réserver deux médecins à la même heure : un index unique sur
slot_idne suffit pas pour ce cas. Ajoutez une colonnetstzrange(start_at, end_at)àappointment, puis utilisez la contrainte d’exclusion de PostgreSQL (EXCLUDE USING gist, nécessitant l’extensionbtree_gist) sur la pairepatient_id, plage horaire. - Slots de durée flexible (le médecin prend des créneaux de 15, 30 ou 45 minutes) : le modèle de slots fixes n’est plus approprié. Vous avez besoin d’une contrainte d’exclusion par plage horaire pour chaque médecin au lieu de compter la capacité.
- N’utilisez pas
SERIALIZABLEpour tout le système juste pour prévenir les doublons. Cette approche est correcte, mais provoque de nombreuses erreurs de sérialisation sous une charge élevée. Conditional update avec les contraintes résout le problème à un coût bien inférieur. - N’utilisez pas de verrous distribués (Redisson, Zookeeper) quand vous ne devez verrouiller qu’une seule ligne. La base de données verrouille déjà pour vous, et chaque composant supplémentaire est un point de défaillance potentiel.
8. Conclusion
La conception du système de prise de rendez-vous tourne autour d’un principe : confier la prévention des doublons à l’endroit qui offre la cohérence forte, c’est-à-dire la base de données. Plus précisément :
- Modélisez chaque slot en tant que ligne avec
capacityetbooked_count. - Utilisez conditional update pour vérifier et écrire en une étape atomique.
- Utilisez les contraintes
CHECKet les index uniques partiels comme dernière ligne de défense. - Utilisez
Idempotency-Keypour les tentatives en toute sécurité.
La couche supérieure (cache Redis, limitation de taux, marquage des slots complets, outbox avec Kafka) aide le système à supporter les pics de charge lors de l’ouverture des réservations. Cette couche ne nuit pas à la justesse de la réservation. Enfin, assurez-vous toujours d’avoir un test multithreadé comme celui de la section 5.4 exécuté sur une vraie base de données. C’est le moyen le plus fiable de prouver que le système n’attribue jamais la même place à deux personnes.