Diseño de sistema de citas: slots y prevención de duplicados

1. Introducción

Programar una cita médica suena simple: el paciente elige al médico, elige la hora y hace clic en “Reservar”. La dificultad está en otro lado. Cientos de personas pueden hacer clic en el mismo slot a las 7:00 a.m. cuando la clínica abre las citas. El sistema debe garantizar que no hay dos personas que reciban el mismo horario.

Este artículo recorre todo el diseño: requisitos, modelo de datos, APIs, flujos de reserva y cancelación. Luego, el artículo se enfoca en la parte más importante: prevención de duplicados y manejo de picos de carga. Después de leerlo, podrás construir por tu cuenta el núcleo del sistema de citas usando Spring Boot y PostgreSQL. Este núcleo funcionará correctamente incluso con muchas solicitudes concurrentes.

2. Requisitos del problema

2.1. Requisitos funcionales

  • El médico (o administrador) crea un horario de trabajo. El sistema genera slots fijos, por ejemplo, cada slot de 15 minutos.
  • Cada slot tiene una capacidad. Para consultas especializadas es normalmente 1. Para vacunación puede ser 10 personas por franja horaria.
  • Los pacientes pueden ver los slots disponibles por médico y fecha, y luego reservar o cancelar.
  • Un paciente no puede tener dos citas válidas en el mismo slot.
  • Después de una reserva exitosa, el sistema envía un SMS o correo electrónico de confirmación.

2.2. Escala y restricciones

  • Hay 2.000 médicos. Cada médico tiene aproximadamente 40 slots por día. Los horarios se abren 14 días de anticipación, lo que significa aproximadamente 1,1 millones de slots activos.
  • Las lecturas superan mucho a las escrituras, con una proporción de aproximadamente 50:1.
  • Picos de tráfico: el horario de un médico popular se abre a las 7:00. En los primeros 30 segundos puede haber 5.000 solicitudes/segundo para unas pocas decenas de slots.
  • Consistencia fuerte para las operaciones de reserva. Nunca exceder la capacidad. La lista de slots disponibles puede atrasarse unos pocos segundos.
  • El cliente puede reintentar cuando la red falla. Por lo tanto, la operación de reserva debe ser idempotente.

3. Modelo de datos

Hay una decisión importante: crear slots como filas en lugar de calcular dinámicamente las horas disponibles a partir del horario de trabajo. Cuando cada slot es una fila, tenemos un punto específico para bloquear y verificar la capacidad. La contención se resuelve dentro del ámbito de una fila.

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,          -- BOOKED, CANCELLED
    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';

Cada restricción en el esquema tiene su propio rol:

  • chk_booked es la barrera final. Si el código tiene un error, la base de datos aún rechaza que booked_count exceda capacity.
  • uq_doctor_start evita crear slots duplicados si el trabajo de creación de horarios se ejecuta nuevamente.
  • uq_active_patient_slot es un índice único parcial de PostgreSQL. Un paciente puede tener como máximo una cita BOOKED en cada slot. Las citas canceladas no se cuentan, por lo que el paciente aún puede reservar nuevamente.
  • idempotency_key UNIQUE evita que reintentos del cliente creen una segunda cita.

La entidad JPA correspondiente para time_slot (con getters reducidos):

@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;
    }
    // getters...
}

4. Diseño de API

GET    /api/doctors/{doctorId}/slots?date=2026-10-12   -> lista de slots disponibles
POST   /api/appointments                               -> reservar cita
       Header: Idempotency-Key: 6f1c...
       Body:   { "slotId": 1001, "patientId": 77 }
DELETE /api/appointments/{id}                          -> cancelar cita

Códigos de respuesta para POST /api/appointments:

201 Created   -> reserva exitosa
200 OK        -> reintentar con la misma Idempotency-Key, devuelve la cita anterior
409 Conflict  -> el slot está lleno o el paciente ya tiene cita en este slot
429 Too Many Requests -> supera el límite de tasa

El cliente genera Idempotency-Key (UUID) por su cuenta para cada clic. Al reintentar, el cliente envía nuevamente la misma clave.

5. Prevención de duplicados

5.1. Por qué “verificar luego escribir” es incorrecto

La primera forma en que muchas personas lo piensan:

TimeSlot slot = slotRepository.findById(slotId).orElseThrow();
if (slot.getBookedCount() < slot.getCapacity()) {   // (1) leer
    slot.setBookedCount(slot.getBookedCount() + 1); // (2) escribir
    appointmentRepository.save(appointment);
}

Supongamos que dos threads A y B leen el paso (1) y ven booked_count = 0, capacity = 1. Ambos pasan la condición y ambos escriben. El resultado es dos citas para el mismo slot. Con el nivel de aislamiento predeterminado READ COMMITTED de PostgreSQL, esto es exactamente un error de pérdida de actualización.

5.2. Actualización condicional: verificar y escribir en una sola declaración

La solución es combinar la verificación y la escritura en una única declaración UPDATE condicional. La base de datos bloquea la fila durante la actualización. El segundo thread debe esperar y luego reevaluar la condición WHERE con el nuevo valor.

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 devuelve el número de filas afectadas. Si el resultado es 1, aseguraste el lugar. Si es 0, el slot está lleno, ya pasó la hora o no existe. No necesitas SELECT ... FOR UPDATE, tampoco locking optimista con una columna version. El bloqueo se mantiene solo durante un tiempo muy corto.

Servicio de reserva:

@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();                       // reintentar: devuelve resultado anterior
        }
        if (slotRepository.tryReserve(slotId, clock.instant()) == 0) {
            throw new SlotFullException(slotId);
        }
        // saveAndFlush para que los errores de unique constraint fallen inmediatamente en esta transacción
        return appointmentRepository.saveAndFlush(
                Appointment.booked(slotId, patientId, idempotencyKey));
    }
}

Clock debe ser declarado como un bean, por ejemplo Clock.systemUTC(), para que el test pueda reemplazarlo con una hora fija. El código funciona así:

  • El paso de verificación de idempotencia se ejecuta primero. Si el cliente reintenta después de que la solicitud inicial se confirmó, el cliente recibe exactamente la cita anterior.
  • Si tryReserve tiene éxito pero saveAndFlush viola uq_active_patient_slot (el paciente ya tiene una cita), Spring lanza DataIntegrityViolationException. La transacción se revierte completamente, incluyendo el incremento de booked_count. El lugar se devuelve automáticamente.
  • Dos solicitudes de reintentos pueden llegar casi al mismo tiempo y ambas no ven la clave. Entonces una solicitud violará idempotency_key UNIQUE. En la capa del controlador, captas este error, vuelves a leer por clave y devuelves 200.
@ExceptionHandler(DataIntegrityViolationException.class)
ResponseEntity<ProblemDetail> onConflict(DataIntegrityViolationException ex) {
    return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
                    "Usted ya tiene una cita en este horario"));
}

Nota: El ejemplo anterior devuelve 409 para todas las violaciones de restricción para mantener el código breve. En un sistema real, deberías diferenciar entre los dos casos por nombre de restricción. Una violación de uq_active_patient_slot devuelve 409. Una violación única de idempotency_key lee de nuevo la cita por clave y devuelve 200.

5.3. Cancelar también debe ser condicional

Cancelar también tiene race conditions. Si el paciente hace clic en “Cancelar” dos veces, booked_count puede restar dos veces. La solución es la misma: solo restar cuando el cambio de estado realmente ocurre.

@Transactional
public void cancel(long appointmentId) {
    Appointment appt = appointmentRepository.findById(appointmentId).orElseThrow();
    int changed = appointmentRepository.markCancelled(appointmentId); // donde status = 'BOOKED'
    if (changed == 1) {
        slotRepository.release(appt.getSlotId());
    }
}

markCancelled es una query @Modifying: update Appointment a set a.status = 'CANCELLED' where a.id = :id and a.status = 'BOOKED'. La segunda solicitud de cancelación recibe changed = 0 y no hace nada más.

5.4. Pruebas con múltiples threads concurrentes

La prueba a continuación tiene 20 pacientes compitiendo por un slot con capacidad 1. Deberías ejecutar la prueba con PostgreSQL real a través de Testcontainers, porque H2 no admite índices parciales.

@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();   // libera los 20 threads al mismo tiempo
        }                        // close() espera a que todas las tareas terminen

        assertThat(success.get()).isEqualTo(1);
        assertThat(slotRepository.findById(slot.getId()).orElseThrow().getBookedCount())
                .isEqualTo(1);
    }
}

CountDownLatch mantiene todos los threads comenzando al mismo tiempo, por lo que las race conditions son más propensas a ocurrir. Resultado esperado:

success      = 1
booked_count = 1
19 threads restantes reciben SlotFullException

Si reemplazas tryReserve con el método de “leer luego escribir” de la sección 5.1, esta prueba usualmente falla con success mayor que 1.

6. Manejo de picos de tráfico al abrir horarios

La actualización condicional garantiza corrección, pero no garantiza rendimiento bajo carga. 5.000 solicitudes/segundo convergiendo en unas pocas filas crearán una cola de bloqueos en la base de datos. El pool de conexiones se agota gradualmente y otras APIs se ralentizan. Hay tres capas de manejo.

6.1. Cachear la lista de slots disponibles

La mayoría del tráfico es GET slots. Puedes cachear el resultado por doctorId + date en Redis con un TTL corto, por ejemplo 3-5 segundos. Cuando se reserva o cancela exitosamente, elimina la clave correspondiente. La lista puede ser un poco antigua, pero está bien: la decisión final sigue siendo tomada por tryReserve en la base de datos.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

6.2. Limitación de tasa y bloqueo temprano de slots llenos

@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;          // máx. 5 intentos / 10 seg
    }

    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));
    }
}

El controlador llama a allowPatient primero. Si el resultado es false, el controlador devuelve 429. Cuando tryReserve devuelve 0, llama a markFull. Las solicitudes posteriores al mismo slot serán rechazadas inmediatamente en Redis y no llegarán a la base de datos. El TTL de 30 segundos permite que el slot se “reabra” automáticamente cuando alguien cancela.

Nota: No uses Redis como la fuente de verdad para la capacidad, por ejemplo, hacer DECR en un contador y considerarlo como completado. Cuando Redis y la base de datos se desincronizaron (reinicio de Redis, rollback de transacción), terminarás vendiendo en exceso o perdiendo lugares. Redis debe ser solo un filtro que se coloca frente a la base de datos.

6.3. Enviar notificaciones de forma asincrónica mediante outbox

No llames al servicio de SMS dentro de la transacción de reserva. Escribe una fila adicional en la tabla outbox_event dentro de la misma transacción. Un worker (o Debezium) enviará el evento AppointmentBooked a Kafka, y el servicio de notificación consumirá ese evento. La transacción de reserva es así más corta y no depende de servicios externos. Tampoco nunca enviarás un SMS para una cita que fue revocada.

7. Cuándo expandir el diseño

  • Retener lugar cuando se requiere pago por adelantado: agrega un estado HELD junto con una columna expires_at. tryReserve sigue incrementando booked_count. Un trabajo periódico cambia las citas HELD que han expirado a EXPIRED y llama a release. No dejes que los lugares se retengan indefinidamente.
  • Evitar que un paciente reserve dos médicos en la misma hora: un índice único en slot_id no es suficiente para esto. Almacena además tstzrange(start_at, end_at) en appointment, luego usa la restricción de exclusión de PostgreSQL (EXCLUDE USING gist, requiere la extensión btree_gist) en el par patient_id, rango de tiempo.
  • Slots de duración flexible (el médico puede tomar una cita de 15, 30 o 45 minutos): el modelo de slot fijo ya no es adecuado. Necesitas una restricción de exclusión por rango de tiempo para cada médico en lugar de contar la capacidad.
  • No uses SERIALIZABLE para todo el sistema solo para evitar duplicados. Es correcto, pero causa muchos errores de serialización bajo carga alta. La actualización condicional junto con las restricciones resuelve el problema con un costo mucho menor.
  • No uses distributed lock (Redisson, Zookeeper) cuando solo necesitas bloquear una fila. La base de datos ya te proporciona el bloqueo, y cada componente que agregues es un punto más que puede fallar.

8. Conclusión

El diseño de un sistema de reservas gira en torno a un principio: llevar la prevención de duplicados al lugar con consistencia fuerte, es decir, la base de datos. Específicamente:

  • Modela cada slot como una fila con capacity y booked_count.
  • Usa la actualización condicional para verificar y escribir en un paso atómico.
  • Usa las restricciones CHECK e índices únicos parciales como barreras finales.
  • Usa Idempotency-Key para reintentos seguros.

Las capas superiores (caché Redis, limitación de tasa, marcar slots llenos, outbox con Kafka) ayudan al sistema a soportar picos de tráfico cuando se abren los horarios. Estas capas no afectan la corrección de la reserva. Finalmente, siempre ten una prueba multihilo como la de la sección 5.4 ejecutándose en una base de datos real. Esa es la forma más confiable de demostrar que el sistema nunca permite que dos personas compartan el mismo slot.

system-designspring-bootpostgresqlredisconcurrency