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_countexcedercapacity.uq_doctor_startevita 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 agendamentoBOOKEDpor slot. Agendamentos cancelados não são contados, então o paciente ainda pode reagendar.idempotency_key UNIQUEgarante 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
tryReservesuceder, massaveAndFlushviolaruq_active_patient_slot(paciente já tem agendamento), Spring lançaDataIntegrityViolationException. A transação é revertida completamente, inclusive o comando de incrementobooked_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 retorna200.
@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
409para 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 deuq_active_patient_slotretorna409. Violação do unique deidempotency_keyrelê o agendamento pela chave e retorna200.
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,
DECRum 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
HELDcom colunaexpires_at.tryReserveainda incrementabooked_count. Um job periódico converte agendamentosHELDexpirados paraEXPIREDe chamarelease. Não deixe slots reservados indefinidamente. - Impedir que o paciente agende dois médicos na mesma hora: índice único por
slot_idnão é suficiente. Armazene umtstzrange(start_at, end_at)adicional emappointment, então use uma exclusion constraint do PostgreSQL (EXCLUDE USING gist, requer extensãobtree_gist) no parpatient_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
SERIALIZABLEem 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
capacityebooked_count. - Use conditional update para verificar e escrever em uma etapa atômica.
- Use
CHECKconstraint e índice único parcial como última linha de defesa. - Use
Idempotency-Keypara 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.