Arquitetura de sistema de agendamento de consultas

1. Visão geral

Agendar uma consulta médica parece simples: o paciente escolhe o médico, escolhe o horário e clica em “Agendar”. A dificuldade está em outro lugar. Centenas de pessoas podem clicar no mesmo slot às 7 da manhã, quando a clínica abre os agendamentos. O sistema deve garantir que nenhum slot seja oferecido a duas pessoas.

Este artigo percorre todo o design: requisitos, modelo de dados, API, fluxo de agendamento e cancelamento. Depois, focamos na parte mais crítica: prevenção de duplicação e tratamento de picos de carga. Ao terminar, você será capaz de implementar o núcleo do sistema de agendamento com Spring Boot e PostgreSQL. Este núcleo funcionará corretamente mesmo sob muitas requisições simultâneas.

2. Requisitos do problema

2.1. Requisitos funcionais

  • Médicos (ou administradores) criam agendas de trabalho. O sistema gera slots fixos, por exemplo, cada slot com duração de 15 minutos.
  • Cada slot tem uma capacidade. Uma consulta especializada geralmente é 1. Uma sessão de vacinação pode ser 10 pessoas por intervalo de tempo.
  • Pacientes visualizam slots disponíveis por médico e data, depois agendám ou cancelam.
  • Um paciente não pode ter dois agendamentos válidos no mesmo slot.
  • Após agendamento bem-sucedido, o sistema envia SMS ou e-mail de confirmação.

2.2. Escala e restrições

  • Há 2.000 médicos. Cada médico tem aproximadamente 40 slots por dia. O agendamento abre 14 dias antes, ou seja, cerca de 1,1 milhão de slots ativos.
  • Leituras são muito mais frequentes que escritas, razão aproximada de 50:1.
  • Picos de carga: a agenda de um médico popular abre às 7h. Nos primeiros 30 segundos, pode haver 5.000 requisições/segundo em poucas dezenas de slots.
  • Consistência forte para operações de agendamento. Nunca deve exceder a capacidade. A lista de slots disponíveis pode estar atrasada alguns segundos.
  • O cliente pode fazer retry quando há problemas de rede. Portanto, a operação de agendamento deve ser idempotente.

3. Modelo de dados

Há uma decisão importante: pré-criar slots como linhas em vez de calcular dinamicamente ‘horas livres’ a partir da agenda de trabalho. Quando cada slot é uma linha, temos um ponto específico para bloquear e verificar a capacidade. Conflitos são resolvidos no escopo de uma única linha.

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 restrição no schema tem um papel específico:

  • chk_booked é a última linha de defesa. Se houver um bug no código, o banco de dados ainda recusará booked_count exceder capacity.
  • uq_doctor_start evita duplicação de slots quando o job de criação de agenda é executado novamente.
  • uq_active_patient_slot é um índice único parcial do PostgreSQL. Um paciente pode ter no máximo um agendamento BOOKED por slot. Agendamentos cancelados não são contados, então o paciente ainda pode reagendar.
  • idempotency_key UNIQUE garante que tentativas do cliente não criem um segundo agendamento.

Entidade JPA correspondente para time_slot (getters omitidos):

@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. Design de API

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

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

201 Created   -> agendamento bem-sucedido
200 OK        -> retry com mesma Idempotency-Key, retorna agendamento anterior
409 Conflict  -> slot cheio ou paciente já tem agendamento neste slot
429 Too Many Requests -> limite de taxa excedido

O cliente gera seu próprio Idempotency-Key (UUID) para cada clique de botão. Em caso de retry, o cliente reenvia a mesma chave.

5. Prevenção de duplicação

5.1. Por que “verificar depois escrever” é incorreto

A primeira abordagem que vem à mente de muitos:

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

Suponha que duas threads A e B leiam na etapa (1) e vejam booked_count = 0, capacity = 1. Ambas passam na condição e ambas escrevem. O resultado é dois agendamentos para o mesmo slot. Com o nível de isolamento padrão READ COMMITTED do PostgreSQL, este é o erro lost update (atualização perdida).

5.2. Conditional update: verificação e escrita em um comando

A correção é combinar verificação e escrita em um UPDATE condicional. O banco de dados bloqueia a linha durante a atualização. A segunda thread aguarda e depois reavalia a condição WHERE com o novo 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 retorna o número de linhas afetadas. Se o resultado for 1, você conquistou o slot. Se for 0, o slot está cheio, passou da hora, ou não existe. Não há necessidade de SELECT ... FOR UPDATE, nem de otimistic locking com coluna version. O bloqueio é mantido por um período muito curto.

Serviço de agendamento:

@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: retorna resultado anterior
        }
        if (slotRepository.tryReserve(slotId, clock.instant()) == 0) {
            throw new SlotFullException(slotId);
        }
        // saveAndFlush para constraint violation disparar imediatamente nesta transaction
        return appointmentRepository.saveAndFlush(
                Appointment.booked(slotId, patientId, idempotencyKey));
    }
}

Clock deve ser declarado como um bean, por exemplo Clock.systemUTC(), para que os testes possam substituí-lo por um horário fixo. O código funciona assim:

  • A verificação de idempotência ocorre primeiro. Se o cliente fizer retry após a primeira solicitação ser confirmada, ele recebe o mesmo agendamento anterior.
  • Se tryReserve suceder, mas saveAndFlush violar uq_active_patient_slot (paciente já tem agendamento), Spring lança DataIntegrityViolationException. A transação é revertida completamente, inclusive o comando de incremento booked_count. O espaço é devolvido automaticamente.
  • Duas requisições de retry podem chegar quase simultaneamente e ambas não ver a chave. Nesse caso, uma solicitação violará idempotency_key UNIQUE. Na camada de controlador, você captura este erro, relê pelo key, depois retorna 200.
@ExceptionHandler(DataIntegrityViolationException.class)
ResponseEntity<ProblemDetail> onConflict(DataIntegrityViolationException ex) {
    return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
                    "Você já tem um agendamento neste horário"));
}

Nota: O exemplo acima retorna 409 para todas as violações de restrição para manter o código breve. Em um sistema real, você deve distinguir entre os dois casos pelo nome da restrição. Violação de uq_active_patient_slot retorna 409. Violação do unique de idempotency_key relê o agendamento pela chave e retorna 200.

5.3. Cancelamento também deve ter condição

Cancelamento também sofre com race conditions. Se o paciente clicar em ‘Cancelar’ duas vezes, booked_count pode ser decrementado duas vezes. A correção é a mesma: decremente apenas quando a mudança de status realmente ocorre.

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

markCancelled é uma consulta @Modifying: update Appointment a set a.status = 'CANCELLED' where a.id = :id and a.status = 'BOOKED'. A segunda solicitação de cancelamento recebe changed = 0 e não faz mais nada.

5.4. Teste com múltiplas threads simultâneas

O teste abaixo simula 20 pacientes competindo por um slot com capacidade 1. Você deve executar o teste com um PostgreSQL real via Testcontainers, pois H2 não suporta índices parciais.

@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 20 threads simultaneamente
        }                        // close() aguarda todas as tasks terminarem

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

CountDownLatch garante que todas as threads começam simultaneamente, tornando race conditions mais prováveis. Resultado esperado:

success      = 1
booked_count = 1
19 threads restantes recebem SlotFullException

Se você substituir tryReserve pela abordagem ‘ler depois escrever’ da seção 5.1, este teste geralmente falhará com success maior que 1.

6. Tratamento de picos na abertura de agendamentos

Conditional update garante correção, mas não garante desempenho sob carga. 5.000 requisições/segundo convergindo para poucas linhas criarão uma fila de bloqueio no banco de dados. O pool de conexões se esgota e outras APIs sofrem degradação. Existem três camadas de tratamento.

6.1. Cache de slots disponíveis

A maioria do tráfego é GET slots. Você pode cachear o resultado por doctorId + date no Redis com um TTL curto, por exemplo 3–5 segundos. Quando um agendamento ou cancelamento for bem-sucedido, remova a chave correspondente. A lista pode estar um pouco desatualizada, mas não importa: a decisão final ainda é feita por tryReserve no banco de dados.

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

6.2. Rate limit e bloqueio preventivo de slots cheios

@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áximo 5 agendamentos / 10 segundos
    }

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

O controlador chama allowPatient primeiro. Se o resultado for false, o controlador retorna 429. Quando tryReserve retorna 0, chame markFull. Requisições subsequentes para o mesmo slot são rejeitadas no Redis sem acessar o banco de dados. O TTL de 30 segundos permite que o slot seja ‘reaberto’ automaticamente quando alguém cancelar.

Nota: Não use Redis como fonte de verdade para capacidade, por exemplo, DECR um contador e considere como agendado. Quando Redis e banco de dados divergem (reinicialização do Redis, rollback de transação), você venderá em excesso ou perderá vagas. Redis deve ser apenas um filtro na frente do banco de dados.

6.3. Envio assíncrono de notificações via outbox

Não chame o serviço de SMS dentro da transação de agendamento. Em vez disso, insira uma linha na tabela outbox_event dentro da mesma transação. Um worker (ou Debezium) enviará o evento AppointmentBooked para o Kafka, e o serviço de notificação o consumirá. A transação de agendamento é assim curta e independente de serviços externos. Você também nunca enviará SMS para um agendamento que foi revertido.

7. Quando expandir o design

  • Reservar slot ao exigir pagamento anterior: adicione estado HELD com coluna expires_at. tryReserve ainda incrementa booked_count. Um job periódico converte agendamentos HELD expirados para EXPIRED e chama release. Não deixe slots reservados indefinidamente.
  • Impedir que o paciente agende dois médicos na mesma hora: índice único por slot_id não é suficiente. Armazene um tstzrange(start_at, end_at) adicional em appointment, então use uma exclusion constraint do PostgreSQL (EXCLUDE USING gist, requer extensão btree_gist) no par patient_id, intervalo de tempo.
  • Slots com duração flexível (médico recebe turnos de 15, 30 ou 45 minutos): modelo de slot fixo não é mais apropriado. Você precisa de uma exclusion constraint por intervalo de tempo para cada médico em vez de contar capacidade.
  • Não use SERIALIZABLE em todo o sistema apenas para prevenção de duplicação. Esta abordagem é correta, mas causa muitos erros de serialização sob alta carga. Conditional update com restrições resolve o problema com custo muito menor.
  • Não use distributed lock (Redisson, Zookeeper) quando só precisa bloquear uma linha. O banco de dados já faz bloqueio para você, e cada componente adicional é um ponto de falha potencial.

8. Conclusão

O design do sistema de agendamento gira em torno de um princípio: coloque a prevenção de duplicação onde há consistência forte, ou seja, no banco de dados. Especificamente:

  • Modele cada slot como uma linha com capacity e booked_count.
  • Use conditional update para verificar e escrever em uma etapa atômica.
  • Use CHECK constraint e índice único parcial como última linha de defesa.
  • Use Idempotency-Key para retry seguro.

A camada superior (cache Redis, rate limit, marcação de slots cheios, outbox com Kafka) permite que o sistema suporte picos de carga na abertura. Esta camada não afeta a correção do agendamento. Finalmente, sempre tenha um teste multithread como na seção 5.4 executado em um banco de dados real. Essa é a forma mais confiável de provar que o sistema nunca permite que duas pessoas reservem o mesmo slot.

system-designspring-bootpostgresqlredisconcurrency