Design de sistema de venda de ingressos para cinema: reserva com expiração

1. Visão Geral

Quando os ingressos de uma sessão de um blockbuster são colocados à venda, milhares de pessoas clicam simultaneamente em um mapa de assentos que tem apenas algumas centenas de lugares. O sistema deve garantir que cada assento pertence a apenas uma pessoa. Os assentos são reservados temporariamente durante o pagamento e devem ser automaticamente liberados se o cliente desistir.

Este artigo aborda o design completo: requisitos, modelo de dados, API, fluxo de processamento e pontos desafiadores. O código usa Java 21, Spring Boot 3.x, SQL e Redis. Depois de ler, você será capaz de implementar um mecanismo seguro de reserva de assentos sob alta concorrência. Você também saberá como lidar com assentos expirados sem depender de um job de limpeza.

2. Requisitos

2.1. Requisitos Funcionais

  • O cliente visualiza o mapa de assentos de uma sessão, com status disponível, reservado ou vendido.
  • O cliente seleciona no máximo 8 assentos e os reserva por 10 minutos.
  • A reserva é tudo ou nada: se um assento não puder ser reservado, nenhum será.
  • Se o cliente pagar durante o período de reserva, o assento é marcado como vendido.
  • Após 10 minutos sem pagamento, o assento é automaticamente liberado.

2.2. Escala e Restrições

  • Cerca de 500 cinemas, 10 salas por cinema, 100–300 assentos por sala.
  • Em operação normal, centenas de requisições por segundo. Quando um filme popular é colocado à venda, pode chegar a 50.000 requisições/segundo, concentradas em poucas sessões.
  • Nunca vender o mesmo assento duas vezes. Esta é uma restrição crítica.
  • A razão leitura/escrita é muito desigual: uma reserva é acompanhada por dezenas de visualizações do mapa.
  • O pagamento passa por um gateway externo. O callback pode atrasar, chegar duas vezes ou não chegar nunca.

3. Modelo de Dados

A tabela central é show_seat. Cada linha representa um assento em uma sessão específica. Não separamos uma tabela “reserva” porque o status do assento e as informações de reserva devem ser atualizados em uma única instrução atômica.

CREATE TABLE show_seat (
    showtime_id     BIGINT       NOT NULL,
    seat_code       VARCHAR(8)   NOT NULL,
    status          VARCHAR(16)  NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE | HELD | BOOKED
    hold_id         UUID,
    customer_id     BIGINT,
    hold_expires_at TIMESTAMP,
    booking_id      BIGINT,
    PRIMARY KEY (showtime_id, seat_code)
);

CREATE INDEX idx_show_seat_hold ON show_seat (hold_id);
CREATE INDEX idx_show_seat_expired ON show_seat (status, hold_expires_at);

Alguns pontos importantes:

  • A chave primária (showtime_id, seat_code) permite atualizar rapidamente um grupo de assentos da mesma sessão.
  • hold_id agrupa os assentos da mesma reserva. O passo de pagamento confirmará todo o grupo usando esse hold_id.
  • O índice (status, hold_expires_at) serve para a limpeza de assentos expirados na seção 7.

As tabelas showtime, booking e payment são CRUD padrão e não serão detalhadas neste artigo.

4. API

GET    /showtimes/{showtimeId}/seats          -> mapa de assentos + status
POST   /showtimes/{showtimeId}/holds          -> reservar assentos, body: {"seats":["F5","F6"]}
                                                 201 {"holdId":"...","expiresAt":"..."} | 409 se assento ocupado
POST   /holds/{holdId}/checkout               -> criar sessão de pagamento (header Idempotency-Key)
DELETE /holds/{holdId}                        -> cliente cancela, liberar assento imediatamente
POST   /payments/callback                     -> gateway de pagamento chama de volta

Retorna 409 Conflict quando o assento já foi reservado por outro cliente. O cliente usa esse código para atualizar o mapa e pedir ao usuário escolher outros assentos.

5. Fluxo de Processamento

Cliente        API                 BD / Redis              Gateway de pagamento
  | seleciona  |                       |                          |
  | assentos   |                       |                          |
  |------------>| UPDATE condicional   |                          |
  |             |---------------------->| HELD, expira em 10'     |
  |<------------| 201 holdId            |                          |
  | checkout    |                       |                          |
  |------------>| criar pagamento (8')  |------------------------->|
  |             |                       |        callback SUCCESS  |
  |             |<-------------------------------------------------|
  |             | HELD -> VENDIDO se    |                          |
  |             | ainda não expirou     |                          |

A sessão de pagamento é configurada para expirar após 8 minutos, menos que o tempo de reserva. O buffer de 2 minutos garante que a maioria dos callbacks cheguem antes de o assento ser liberado.

6. Reservar Assentos Temporariamente

Dependências necessárias:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>

6.1. UPDATE Condicional como Fonte da Verdade

O erro comum é ler primeiro e depois escrever: SELECT verifica se o assento está vazio, depois UPDATE. Entre os dois comandos, outro request pode intervir e reservar o assento. Resultado: dois clientes acham que conseguiram reservar.

A forma correta é incorporar a verificação na cláusula WHERE e contar as linhas afetadas:

@Service
public class SeatHoldService {

    private static final Duration HOLD_TTL = Duration.ofMinutes(10);

    private final NamedParameterJdbcTemplate jdbc;
    private final Clock clock;

    public SeatHoldService(NamedParameterJdbcTemplate jdbc, Clock clock) {
        this.jdbc = jdbc;
        this.clock = clock;
    }

    @Transactional
    public SeatHold hold(long showtimeId, List<String> seatCodes, long customerId) {
        UUID holdId = UUID.randomUUID();
        Instant now = clock.instant();
        Instant expiresAt = now.plus(HOLD_TTL);

        var params = new MapSqlParameterSource()
                .addValue("holdId", holdId)
                .addValue("customerId", customerId)
                .addValue("showtimeId", showtimeId)
                .addValue("seatCodes", seatCodes.stream().sorted().toList())
                .addValue("now", Timestamp.from(now))
                .addValue("expiresAt", Timestamp.from(expiresAt));

        int updated = jdbc.update("""
                UPDATE show_seat
                   SET status = 'HELD', hold_id = :holdId,
                       customer_id = :customerId, hold_expires_at = :expiresAt
                 WHERE showtime_id = :showtimeId
                   AND seat_code IN (:seatCodes)
                   AND (status = 'AVAILABLE'
                        OR (status = 'HELD' AND hold_expires_at < :now))
                """, params);

        if (updated != seatCodes.size()) {
            throw new SeatUnavailableException(showtimeId, seatCodes);
        }
        return new SeatHold(holdId, expiresAt);
    }
}

public record SeatHold(UUID holdId, Instant expiresAt) {}

public class SeatUnavailableException extends RuntimeException {
    public SeatUnavailableException(long showtimeId, List<String> seats) {
        super("Seats " + seats + " of showtime " + showtimeId + " are not available");
    }
}

Como este código funciona:

  • O UPDATE adquire um lock de linha em cada assento. Duas transações visando o assento F5 têm de se enfileirar. A transação posterior reavalia a cláusula WHERE e vê status = 'HELD', então não consegue atualizar a linha.
  • A condição status = 'HELD' AND hold_expires_at < :now é expiração preguiçosa. Um assento com reserva expirada é considerado vazio, mesmo se nenhum job o tenha limpado ainda.
  • Se o número de linhas atualizadas for menor que o número de assentos solicitados, lançamos uma exceção. @Transactional faz rollback completo, garantindo atomicidade.
  • Clock é injetado no serviço para que os testes possam controlar o tempo.

Suponha que o cliente A reserva F5, F6, depois o cliente B tenta F6, F7. B recebe:

HTTP 409 Conflict
{"error":"Seats [F6, F7] of showtime 42 are not available"}

O assento F7 de B permanece vazio porque a transação de B foi revertida.

Nota: Ordene seatCodes antes do UPDATE. Se A fizer lock na ordem F5 → F6 e B na ordem F6 → F5, as duas transações podem causar deadlock. Com PostgreSQL, a ordem de lock depende da ordem de varredura. Para ter certeza, você pode executar SELECT ... ORDER BY seat_code FOR UPDATE antes do UPDATE.

6.2. Redis como Camada de Bloqueio Rápido

Sob picos de tráfego, 50.000 requisições de reserva chegam a centenas de linhas de uma única sessão. O banco de dados teria filas de lock muito longas. Colocamos Redis à frente para rejeitar rapidamente requests perdedoras. O script Lua abaixo torna operações em múltiplas chaves atômicas:

@Component
public class RedisSeatGate {

    private static final RedisScript<Long> TRY_HOLD = new DefaultRedisScript<>("""
            for _, key in ipairs(KEYS) do
              if redis.call('EXISTS', key) == 1 then return 0 end
            end
            for _, key in ipairs(KEYS) do
              redis.call('SET', key, ARGV[1], 'PX', ARGV[2])
            end
            return 1
            """, Long.class);

    private final StringRedisTemplate redis;

    public RedisSeatGate(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public boolean tryHold(long showtimeId, List<String> seats, UUID holdId, Duration ttl) {
        List<String> keys = seats.stream()
                .map(s -> "seat:{" + showtimeId + "}:" + s)
                .toList();
        Long ok = redis.execute(TRY_HOLD, keys, holdId.toString(),
                String.valueOf(ttl.toMillis()));
        return ok != null && ok == 1L;
    }
}

Como usar:

  • Redis executa o script Lua em uma única thread. Nenhuma requisição pode intervir entre a verificação EXISTS e o SET.
  • PX associa um TTL à chave, então Redis a deleta automaticamente ao expirar. Este é o mecanismo natural de expiração da camada de cache.
  • A parte {showtimeId} é uma hash tag. No Redis Cluster, todas as chaves de uma sessão ficarão no mesmo slot. Sem a hash tag, um script multi-chave lançará um erro CROSSSLOT.

O fluxo completo é: primeiro tente tryHold no Redis. Se falhar, retorne 409 imediatamente. Se sucessar, execute o UPDATE da seção 6.1. Se o UPDATE falhar, delete a chave Redis que foi definida.

Nota: Redis não é a fonte de verdade. Redis pode perder dados em failovers, e chaves podem expirar com alguns milissegundos de diferença do BD. A decisão final é sempre o UPDATE condicional. Se seu sistema não enfrentar picos de tráfego, remova completamente a camada Redis para reduzir a complexidade operacional.

7. Expiração de Reservas

A correção da expiração é garantida pela condição hold_expires_at < :now na seção 6.1. Porém, o mapa de assentos ainda mostrará HELD até que alguém o sobrescreva. Por isso precisamos de um job de limpeza para o mapa exibir corretamente:

@Component
public class ExpiredHoldSweeper {

    private final JdbcTemplate jdbc;
    private final Clock clock;

    public ExpiredHoldSweeper(JdbcTemplate jdbc, Clock clock) {
        this.jdbc = jdbc;
        this.clock = clock;
    }

    @Scheduled(fixedDelay = 15_000)
    public void releaseExpired() {
        int released = jdbc.update("""
                UPDATE show_seat
                   SET status = 'AVAILABLE', hold_id = NULL,
                       customer_id = NULL, hold_expires_at = NULL
                 WHERE status = 'HELD' AND hold_expires_at < ?
                """, Timestamp.from(clock.instant()));
        if (released > 0) {
            // publicar SeatsReleased para atualizar o cache do mapa de assentos
        }
    }
}

O job roda a cada 15 segundos. Se atrasar ou rodar em várias instâncias simultaneamente, não há problema porque o comando é idempotente. Graças à expiração preguiçosa, mesmo que o job morra por alguns minutos, nunca haverá venda duplicada. A única consequência é que o mapa ficará desatualizado por um tempo. Lembre-se de ativar @EnableScheduling na configuração.

O lado de confirmação de pagamento deve verificar o prazo no mesmo comando:

@Transactional
public boolean confirm(UUID holdId, long bookingId) {
    int updated = jdbc.update("""
            UPDATE show_seat
               SET status = 'BOOKED', booking_id = :bookingId, hold_expires_at = NULL
             WHERE hold_id = :holdId AND status = 'HELD'
               AND hold_expires_at >= :now
            """, new MapSqlParameterSource()
                    .addValue("holdId", holdId)
                    .addValue("bookingId", bookingId)
                    .addValue("now", Timestamp.from(clock.instant())));
    return updated > 0;
}

Esta função retorna false em dois casos: a reserva expirou, ou o assento foi reservado por outro cliente. Ambos os casos precisam ser tratados no passo de pagamento.

8. Consistência com Pagamento

Callbacks do gateway de pagamento são o lugar mais fácil de errar. Há três cenários que você precisa lidar:

  1. Callback chega duas vezes. Salve payment_id com restrição UNIQUE. Na segunda vez, veja que a transação já foi processada, retorne 200 e não faça nada.
  2. Callback chega depois que o assento expirou. confirm retorna false. Neste ponto, o sistema cobrou mas não consegue entregar o assento. Marque a reserva como FAILED e emita um reembolso automático. Não tente “recuperar” o assento, porque outro cliente pode já tê-lo comprado.
  3. Callback nunca chega. Um job de reconciliação periódica verifica o status de pagamentos PENDING com mais de 15 minutos junto ao gateway, depois aplica a lógica dos dois cenários acima.

Após confirm ter sucesso, etapas como enviar email, gerar voucher QR ou adicionar pontos de fidelidade não devem rodar na mesma transação. Escreva uma linha BookingConfirmed na tabela outbox dentro dessa transação. Um processo separado empurrará o evento para Kafka, no tópico booking-events com chave bookingId. Isso garante que o evento só é emitido após o commit do BD, e se commitar, o evento certamente será enviado.

9. Resistindo a Picos de Tráfego

A camada Redis da seção 6.2 só endereça a parte de escrita. Quando colocando um filme popular à venda, você precisa de medidas adicionais:

  • Fila de espera virtual. Cada sessão popular permite que apenas N pessoas acessem a página de seleção de assentos por vez. Os outros recebem um token de fila, armazenado em um Redis sorted set com score sendo o timestamp de chegada. Alguns centenas de assentos não precisam de 50.000 pessoas competindo simultaneamente.
  • Cache de mapa de assentos. O mapa é lido de Redis ou CDN e atualizado via eventos SeatsHeld/SeatsReleased. Um atraso de 1–2 segundos é aceitável porque a reserva verificará novamente com precisão.
  • Rate limiting por cliente. Por exemplo, máximo 5 reservas por minuto, para bloquear bots de scanning.
  • Particionamento por showtime_id. Toda contenção está dentro de uma sessão. Você pode fazer sharding do BD por showtime_id sem necessidade de transações distribuídas.

10. Testando Concorrência

O comportamento mais crítico a verificar: se múltiplos clientes tentam reservar o mesmo assento, apenas um consegue.

@SpringBootTest
class SeatHoldServiceTest {

    @Autowired SeatHoldService service;
    @Autowired JdbcTemplate jdbc;

    @BeforeEach
    void setUp() {
        jdbc.update("DELETE FROM show_seat");
        jdbc.update("INSERT INTO show_seat (showtime_id, seat_code) VALUES (42, 'F5'), (42, 'F6')");
    }

    @Test
    void onlyOneCustomerCanHoldTheSameSeat() throws Exception {
        int customers = 20;
        var start = new CountDownLatch(1);
        var successes = new AtomicInteger();

        try (var pool = Executors.newFixedThreadPool(customers)) {
            for (int i = 0; i < customers; i++) {
                long customerId = i;
                pool.submit(() -> {
                    start.await();
                    try {
                        service.hold(42, List.of("F5", "F6"), customerId);
                        successes.incrementAndGet();
                    } catch (SeatUnavailableException ignored) { }
                    return null;
                });
            }
            start.countDown();
        }

        assertThat(successes.get()).isEqualTo(1);
        assertThat(jdbc.queryForObject(
                "SELECT COUNT(DISTINCT hold_id) FROM show_seat WHERE status = 'HELD'",
                Integer.class)).isEqualTo(1);
    }
}

Explicação do teste:

  • CountDownLatch segura 20 threads na linha de partida e os libera simultaneamente, maximizando a chance de contenção.
  • try-with-resources no ExecutorService (Java 21) aguarda todas as tarefas terminarem antes de executar as assertions.
  • A segunda assertion verifica que ambos os assentos pertencem ao mesmo hold_id, ou seja, não há “um assento para cada pessoa”.

O teste roda no H2 com arquivo schema.sql da seção 3 (adaptando o tipo UUID para o suportado pelo H2). Você precisa declarar um bean Clock.systemUTC(). Para testar expiração, substitua esse bean por um Clock mockado que possa avanço no tempo.

Nota: H2 não emula perfeitamente o comportamento de lock do PostgreSQL ou MySQL. Para código relacionado a concorrência, execute testes adicionais no banco de dados de produção, por exemplo via Testcontainers.

11. Erros Comuns

  • Check-then-act, ou seja, SELECT depois UPDATE sem lock. Esta é a causa número um de venda duplicada de assentos.
  • Depender completamente de um job de limpeza para liberar assentos. Se o job morrer, os assentos ficarão presos para sempre. Expiração preguiçosa no WHERE elimina esse risco.
  • Usar Redis lock como fonte de verdade. Locks podem ser perdidas em failovers, resultando em dois clientes reservando o mesmo assento.
  • Usar SELECT ... FOR UPDATE mantendo o lock por 10 minutos esperando o cliente pagar. O pool de conexões se esgotará rapidamente. O lock no BD deve durar apenas alguns milissegundos. O tempo de reserva longo deve ser representado por dados, especificamente a coluna hold_expires_at.
  • Usar o relógio de máquinas diferentes. Unificar a fonte de tempo: use now() do BD ou Clock da app com NTP sincronizado.

12. Conclusão

O coração do sistema de vendas de ingressos é um UPDATE condicional. Este comando bloqueia assentos atomicamente e trata reservas expiradas como vazias, então nunca há venda duplicada e nenhum job de limpeza é estritamente necessário. Os outros componentes se baseiam neste núcleo:

  • Script Redis Lua rejeita carga no início.
  • Job de varredura ajuda o mapa a exibir o estado correto.
  • Idempotência e reconciliação lidam com callbacks de pagamento anormais.
  • Outbox com Kafka entrega eventos de forma confiável.
  • Fila de espera virtual absorve picos de tráfego.

Ao implementar, comece com o núcleo SQL e o teste de concorrência. Adicione Redis e fila de espera apenas quando os dados de tráfego real mostrarem que você precisa deles.

system-designspring-bootredisconcurrencycase-study