Skip to content

Como Prevenir Reserva Dupla (aplicação de reservas e capacidade)

Os sistemas de reserva têm dois modos distintos de falha que devem ser tratados separadamente:

  1. Reserva duplicada — o mesmo usuário tenta reservar o mesmo horário duas vezes
  2. Excesso de capacidade — o número de reservas excederia o limite do horário

Ambos resultam em um INSERT rejeitado, mas requerem respostas de erro diferentes. Este guia mostra como distingui-los e proteger contra conflitos concorrentes.


1. Schema: restrição UNIQUE + coluna de capacidade

sql
CREATE TABLE slots (
    id       INTEGER PRIMARY KEY AUTOINCREMENT,
    date     TEXT    NOT NULL,
    time     TEXT    NOT NULL,
    capacity INTEGER NOT NULL DEFAULT 1,
    UNIQUE(date, time)
);

CREATE TABLE reservations (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    slot_id    INTEGER NOT NULL REFERENCES slots(id),
    user_id    TEXT    NOT NULL,
    created_at TEXT    NOT NULL,
    UNIQUE(slot_id, user_id)  -- guarda de último recurso contra reservas duplicadas
);
CREATE INDEX idx_reservations_slot ON reservations (slot_id);

A restrição UNIQUE(slot_id, user_id) é a rede de segurança — ela previne reservas duplicadas mesmo que a lógica da aplicação tenha um bug. Mas não pode dizer por que o INSERT falhou.


2. Distinguir duplicata de excesso de capacidade com uma verificação explícita

DatabaseConstraintException não carrega informações no nível de coluna sobre qual restrição foi acionada. Para retornar respostas 409 diferentes, verifique cada condição antes do INSERT:

php
public function reserve(int $slotId, string $userId): ?Reservation
{
    // 1. Verificar reserva duplicada primeiro
    $existing = $this->db->fetchOne(
        'SELECT id FROM reservations WHERE slot_id = ? AND user_id = ?',
        [$slotId, $userId],
    );
    if ($existing !== null) {
        throw new AlreadyReservedException('User already has a reservation.');
    }

    // 2. Verificar capacidade restante
    $slot = $this->findSlot($slotId);
    if ($slot === null || $slot->available() === 0) {
        return null; // chamador mapeia null → 409 slot-full
    }

    // 3. INSERT — restrição UNIQUE é a guarda final
    $id = $this->db->insert(
        'INSERT INTO reservations (slot_id, user_id, created_at) VALUES (?, ?, ?)',
        [$slotId, $userId, $now],
    );

    return new Reservation((int) $id, $slotId, $userId, $now);
}

Use uma exceção de domínio (AlreadyReservedException) para a regra de negócio voltada ao usuário, não DatabaseConstraintException — que sinaliza um evento na camada do banco, não uma condição de negócio.


3. Handler: mapear para respostas 409 distintas

php
try {
    $reservation = $this->repo->reserve($slotId, $userId);
} catch (AlreadyReservedException) {
    return $this->problems->create(
        $request, 'already-reserved', 'Already Reserved', 409,
        'You already have a reservation for this slot.',
    );
}

if ($reservation === null) {
    return $this->problems->create(
        $request, 'slot-full', 'Slot Full', 409,
        'No capacity remaining for this slot.',
    );
}

4. Calcular disponibilidade no SQL (evitar N+1)

Contar reservas na mesma query que a busca do horário:

sql
SELECT s.*, COUNT(r.id) AS reserved
FROM slots s
LEFT JOIN reservations r ON r.slot_id = s.id
WHERE s.id = ?
GROUP BY s.id

Então available = capacity - reserved. Nunca buscar todas as reservas para contá-las no PHP.


5. Concorrência: TOCTOU e quando importa

O padrão de verificar-depois-inserir tem uma janela TOCTOU (Time-of-Check / Time-of-Use): duas requisições concorrentes podem ambas passar pela verificação de capacidade e então ambas tentar inserir.

Banco de DadosComportamento
SQLiteSerialização de escrita por banco: apenas uma escrita roda por vez. O segundo INSERT atinge a restrição UNIQUE e lança DatabaseConstraintException. Seguro.
PostgreSQL sob alta concorrênciaDuas requisições com user_id diferentes podem ambas passar pela verificação available > 0 e ambas fazer INSERT, excedendo brevemente a capacidade em 1. A restrição UNIQUE não é acionada (usuários diferentes).

Correção para PostgreSQL: envolva a verificação e o INSERT em uma transação SERIALIZABLE, ou use SELECT ... FOR UPDATE na linha do horário para bloqueá-la antes de ler:

php
$this->txManager->transactional(function (DatabaseQueryExecutorInterface $tx) use ($slotId, $userId): ?Reservation {
    $db = new SqliteBookingRepository($tx);
    // Todas as queries neste closure compartilham o mesmo snapshot serializável
    return $db->reserveWithinTransaction($slotId, $userId);
});

Para SQLite, a restrição UNIQUE sozinha é proteção suficiente.


6. Testar cenários de estilo concorrente

Testes sequenciais não podem reproduzir verdadeira concorrência, mas podem verificar a intenção:

php
public function testLostUpdateSimulation(): void
{
    $slotId = $this->decode($this->createSlot(capacity: 1))['id'];

    $alice = $this->reserve($slotId, 'alice');
    $bob   = $this->reserve($slotId, 'bob');  // chega "simultaneamente"

    self::assertSame(201, $alice->getStatusCode());
    self::assertSame(409, $bob->getStatusCode());  // horário cheio

    $slot = $this->decode($this->req('GET', '/slots/' . $slotId));
    self::assertSame(0, $slot['available']);
}

public function testCancelFreesCapacity(): void
{
    $slotId = $this->decode($this->createSlot(capacity: 1))['id'];
    $this->reserve($slotId, 'alice');

    $this->req('DELETE', '/slots/' . $slotId . '/reservations/alice');

    // Após cancelar, bob pode reservar
    self::assertSame(201, $this->reserve($slotId, 'bob')->getStatusCode());
}

Notas

  • A restrição UNIQUE é uma guarda de último recurso — ela pega bugs na lógica da aplicação. Nunca dependa dela como principal aplicador de capacidade, porque não consegue distinguir usuário-duplicado de excesso-de-capacidade.
  • Cancelar e reservar novamente: quando um usuário cancela, exclua de reservations. A contagem de capacidade decrementa automaticamente via query COUNT(r.id). Não é necessária atualização explícita de "liberar horário".
  • Cancelamento idempotente: DELETE WHERE slot_id = ? AND user_id = ? retorna 0 linhas se a reserva não existir — mapeie isso para 404, não 500.

Publicado sob a licença MIT.