1

Duas pessoas reservaram o mesmo horário: o problema que um CRUD simples não resolve

Um sistema de agendamento pode passar por todos os testes manuais e ainda confirmar duas reservas para o mesmo profissional, no mesmo horário.

Não precisa ter milhões de usuários. Duas requisições executadas no momento certo já são suficientes.

Vamos usar um cenário didático: um profissional atende uma pessoa por vez, e cada atendimento ocupa um horário previamente definido. Ana e Bruno tentam reservar a mesma vaga das 14h.

O fluxo parece correto

Uma primeira implementação costuma seguir esta sequência:

consultar se a vaga está disponível
se estiver ocupada:
    informar conflito
se estiver livre:
    criar reserva
    confirmar para o cliente

Sozinho, esse fluxo parece funcionar. Ana reserva; quando Bruno tenta, o sistema encontra a reserva e recusa a segunda solicitação.

O problema aparece quando as duas operações se sobrepõem:

MomentoRequisição de AnaRequisição de Bruno
1Consulta: vaga livre
2Consulta: vaga livre
3Grava a reserva
4Grava a reserva
5Recebe confirmaçãoRecebe confirmação

Cada requisição tomou uma decisão com base em uma informação que deixou de ser suficiente antes da gravação. Essa é uma condição de corrida.

A regra precisa ser mais precisa

“Verificar disponibilidade” descreve uma ação. Não define, sozinha, a garantia que o sistema precisa oferecer.

Uma regra melhor para este exemplo é:

Para cada vaga previamente definida, pode existir no máximo uma reserva ativa.

Essa frase muda a implementação. Agora temos algo que precisa continuar verdadeiro mesmo quando duas operações acontecem juntas.

Também precisamos explicitar o contexto: são vagas fixas ou intervalos livres? Um cancelamento libera a vaga? Há capacidade para mais de uma pessoa? O profissional atende em mais de uma unidade?

As respostas fazem parte do desenho do sistema.

Uma solução para vagas fixas

Se cada vaga já existe e tem um identificador próprio, podemos fazer o banco impedir que duas reservas ativas usem a mesma vaga.

Exemplo didático em PostgreSQL:

CREATE TABLE reservas (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    vaga_id bigint NOT NULL,
    cliente_id bigint NOT NULL,
    cancelada_em timestamptz
);

CREATE UNIQUE INDEX uma_reserva_ativa_por_vaga
    ON reservas (vaga_id)
    WHERE cancelada_em IS NULL;

O índice parcial aplica a unicidade somente às reservas ativas. A consulta prévia pode continuar existindo para orientar o usuário, mas a gravação passa a ter uma proteção independente dela.

O esquema foi reduzido para mostrar a regra; um sistema completo também precisa das referências para vagas e clientes, permissões e demais validações. A sintaxe é ilustrativa e não foi executada durante a preparação deste artigo.

Na aplicação, o conflito dessa restrição deve virar uma resposta compreensível: “Essa vaga acabou de ser reservada. Escolha outra.” Em uma API HTTP, eu usaria 409 Conflict para esse caso de negócio. Outros erros de banco devem ser tratados conforme sua causa; não basta transformar qualquer exceção em “horário ocupado”.

O cliente só recebe confirmação depois que a transação confirma a reserva.

E quando os horários têm duração variável?

Aqui o modelo anterior já não basta. Duas reservas podem começar em horários diferentes e ainda se sobrepor: uma das 14h às 15h e outra das 14h30 às 15h30.

No PostgreSQL, tipos de intervalo e restrições de exclusão permitem representar e impedir sobreposições para um mesmo recurso. Essa é outra modelagem, que precisa considerar o profissional ou recurso reservado, cancelamentos e limites do intervalo. A documentação oficial mostra exemplos dessa abordagem.

Minha escolha seria começar pela regra de capacidade e duração, antes de selecionar o mecanismo de controle.

“Coloquei em uma transação” não encerra a discussão

Transações e controle de concorrência precisam ser avaliados em conjunto. No isolamento padrão Read Committed do PostgreSQL, duas consultas podem observar a ausência de uma reserva antes de qualquer uma das gravações ser confirmada.

Uma transação não cria automaticamente a restrição que o domínio exige. Índices, restrições, bloqueios ou isolamento mais forte podem fazer parte da solução, conforme o modelo. Usar Serializable, por exemplo, exige tratar falhas de serialização com repetição da transação completa.

Como eu verificaria a garantia

O teste importante não é apenas reservar e tentar novamente. É coordenar duas requisições para disputarem a mesma vaga.

Eu verificaria:

  • Somente uma reserva ativa persiste para a vaga.
  • A requisição vencedora recebe confirmação depois do commit.
  • A outra recebe um conflito de negócio compreensível.
  • Reservas de vagas diferentes continuam funcionando.
  • Cancelar uma reserva libera a vaga conforme a regra estabelecida.
  • A proteção continua válida com mais de uma instância da aplicação.

Também separaria esse problema da idempotência. Repetir uma solicitação após uma falha de rede não deve criar uma reserva extra. Isso exige uma política própria para reconhecer a repetição; não substitui a regra de exclusividade da vaga.

A decisão arquitetural está no comportamento

O ponto não é adicionar ferramentas ao projeto. É definir qual estado nunca pode existir e escolher onde essa garantia será sustentada.

A mesma pergunta aparece na última unidade de um estoque, no limite de inscrições de um evento e na capacidade de uma sala.

No seu projeto, qual regra parece correta quando as operações são executadas uma de cada vez, mas ainda não foi verificada com concorrência?


Sobre o autor: sou Marcos Chaves, autor de Arquitetura de Software para Iniciantes — Como pensar, decidir e evoluir sistemas sem complicar. No livro, uso o projeto Agenda Fácil para explorar decisões sobre dados, APIs, segurança e evolução. Este artigo é um conteúdo complementar; o exemplo SQL acima foi elaborado para esta publicação.

Também escrevi Construa sua Primeira API Profissional com Java 21, que acompanha a construção do Nexus CRM. Os dois títulos estão disponíveis na Amazon:

Referências técnicas

Carregando publicação patrocinada...
1

Sobre o "não basta transformar qualquer exceção em horário ocupado": dá pra deixar isso objetivo olhando o SQLSTATE e o nome da restrição, nunca a mensagem:

  • 23505 (unique_violation) vindo de uma_reserva_ativa_por_vaga vira 409 (no índice parcial, o constraint_name do erro traz o nome do próprio índice)
  • 23P01 (exclusion_violation) vindo da restrição de sobreposição vira 409
  • o resto continua 500 (fora o 40P01 abaixo)

Um detalhe do EXCLUDE que pouca gente conhece: diferente do índice único, ele grava a entrada no índice e só depois varre procurando conflito. Se duas transações inserirem quase juntas, pode acontecer de cada uma enxergar a linha não commitada da outra: as duas ficam esperando e o detector de deadlock aborta uma com 40P01, não 23P01. Vale tratar com retry da transação inteira (ela já foi abortada): na nova tentativa, a reexecução espera a outra terminar e cai no 23P01 normal (ou passa, se a outra deu rollback).

Pra testar sem sleep fixo: duas conexões, faz o insert na A sem commitar e dispara o da B em outra thread, que fica bloqueada esperando a A terminar. Faz polling até pg_blocking_pids(pid_da_b) conter o pid da A, commita a A e confere que a B recebeu 23505 e que existe exatamente uma reserva ativa. Repetindo com ROLLBACK na A, a B tem que passar.

1

Sobre o "não basta transformar qualquer exceção em horário ocupado": dá pra deixar isso objetivo olhando o SQLSTATE e o nome da restrição, nunca a mensagem:

  • 23505 (unique_violation) vindo de uma_reserva_ativa_por_vaga vira 409 (no índice parcial, o constraint_name do erro traz o nome do próprio índice)
  • 23P01 (exclusion_violation) vindo da restrição de sobreposição vira 409
  • o resto continua 500 (fora o 40P01 abaixo)

Um detalhe do EXCLUDE que pouca gente conhece: diferente do índice único, ele grava a entrada no índice e só depois varre procurando conflito. Se duas transações inserirem quase juntas, pode acontecer de cada uma enxergar a linha não commitada da outra: as duas ficam esperando e o detector de deadlock aborta uma com 40P01, não 23P01. Vale tratar com retry da transação inteira (ela já foi abortada): na nova tentativa, a reexecução espera a outra terminar e cai no 23P01 normal (ou passa, se a outra deu rollback).

Pra testar sem sleep fixo: duas conexões, faz o insert na A sem commitar e dispara o da B em outra thread, que fica bloqueada esperando a A terminar. Faz polling até pg_blocking_pids(pid_da_b) conter o pid da A, commita a A e confere que a B recebeu 23505 e que existe exatamente uma reserva ativa. Repetindo com ROLLBACK na A, a B tem que passar.

1

Sobre o "não basta transformar qualquer exceção em horário ocupado": dá pra deixar isso objetivo olhando o SQLSTATE e o nome da restrição, nunca a mensagem:

  • 23505 (unique_violation) vindo de uma_reserva_ativa_por_vaga vira 409 (no índice parcial, o constraint_name do erro traz o nome do próprio índice)
  • 23P01 (exclusion_violation) vindo da restrição de sobreposição vira 409
  • o resto continua 500 (fora o 40P01 abaixo)

Um detalhe do EXCLUDE que pouca gente conhece: diferente do índice único, ele grava a entrada no índice e só depois varre procurando conflito. Se duas transações inserirem quase juntas, pode acontecer de cada uma enxergar a linha não commitada da outra: as duas ficam esperando e o detector de deadlock aborta uma com 40P01, não 23P01. Vale tratar com retry da transação inteira (ela já foi abortada): na nova tentativa, a reexecução espera a outra terminar e cai no 23P01 normal (ou passa, se a outra deu rollback).

Pra testar sem sleep fixo: duas conexões, faz o insert na A sem commitar e dispara o da B em outra thread, que fica bloqueada esperando a A terminar. Faz polling até pg_blocking_pids(pid_da_b) conter o pid da A, commita a A e confere que a B recebeu 23505 e que existe exatamente uma reserva ativa. Repetindo com ROLLBACK na A, a B tem que passar.

1

Pra quem for seguir pro caso de duração variável, dois detalhes que costumam travar na primeira tentativa. A restrição de exclusão com o profissional junto (profissional_id WITH =, periodo WITH &&) precisa da extensão btree_gist, senão o Postgres reclama que bigint não tem operator class pra gist. E vale usar o intervalo no formato [), que já é o padrão do tstzrange, assim uma reserva das 14h às 15h não conflita com a das 15h às 16h.

O EXCLUDE também aceita WHERE (cancelada_em IS NULL), igual ao índice parcial, então a regra de cancelamento do primeiro exemplo continua valendo.