Design de sistema de leilão online: bids concorrentes e encerramento

1. Visão geral

Um sistema de leilão parece simples: usuários oferecem preços, o maior preço vence. A dificuldade está em dois momentos críticos. Primeiro, centenas de bids podem chegar à mesma sessão em poucos segundos. Segundo, o limite de “sessão encerrada” deve ser absolutamente claro. Perder um bid ou escolher o vencedor errado são erros financeiros.

Este artigo aborda todo o design: requisitos, modelo de dados, API, como processar bids concorrentes com PostgreSQL e Redis, mecanismo anti-sniping e como encerrar a sessão com segurança quando há múltiplas instâncias. Após ler isto, você poderá construir o núcleo de um sistema de leilão correto com Spring Boot 3.

2. Requisitos do problema

2.1. Requisitos funcionais

  • O vendedor cria uma sessão de leilão com preço inicial, incremento mínimo e hora de encerramento.
  • Compradores oferecem bids. Um bid é válido quando a sessão está aberta e o preço não é inferior a preço atual + incremento.
  • Se houver um bid válido nos últimos 2 minutos, a sessão é estendida por mais 2 minutos.
  • Quando o tempo expira, o sistema encerra a sessão, determina o vencedor e emite um evento para etapas de pagamento e notificação.
  • Observadores recebem preços atualizados em tempo real.

2.2. Escala e restrições

  • Aproximadamente 100.000 sessões abertas simultaneamente. A maioria das sessões é muito “fria”, apenas alguns bids por hora.
  • Algumas sessões “quentes” (marcas premium, flash sales) recebem até 2.000 bids/segundo no último minuto.
  • Consistência forte por sessão: não perder bids válidos, não aceitar bids após o fechamento, apenas um vencedor.
  • Bids devem ser idempotentes: tentativas de cliente por timeout não criam dois bids.
  • Latência do bid p99 abaixo de 200 ms.

Em resumo, precisamos de consistência por sessão. Entre sessões diferentes não é necessária. Este é o ponto-chave para escalar: cada sessão é uma unidade de contenção independente.

3. Modelo de dados

Usamos PostgreSQL como fonte de verdade. A tabela auction mantém o estado atual. A tabela bid é um log de apenas escrita (append-only).

CREATE TABLE auction (
    id             BIGSERIAL PRIMARY KEY,
    seller_id      BIGINT        NOT NULL,
    title          VARCHAR(200)  NOT NULL,
    current_price  NUMERIC(14,2) NOT NULL,
    min_increment  NUMERIC(14,2) NOT NULL,
    leader_id      BIGINT,
    status         VARCHAR(10)   NOT NULL DEFAULT 'OPEN',  -- OPEN | CLOSED
    end_time       TIMESTAMPTZ   NOT NULL
);
CREATE INDEX idx_auction_open_end ON auction (end_time) WHERE status = 'OPEN';

CREATE TABLE bid (
    id          BIGSERIAL PRIMARY KEY,
    auction_id  BIGINT        NOT NULL REFERENCES auction(id),
    bidder_id   BIGINT        NOT NULL,
    amount      NUMERIC(14,2) NOT NULL,
    request_id  UUID          NOT NULL,
    created_at  TIMESTAMPTZ   NOT NULL DEFAULT clock_timestamp(),
    UNIQUE (auction_id, request_id)
);

Alguns pontos a notar:

  • current_price e leader_id são desnormalizados em auction. Assim, verificar um bid requer apenas ler uma linha, em vez de SELECT MAX(amount) na tabela bid.
  • Índice parcial idx_auction_open_end contém apenas sessões abertas. O job de encerramento verificará este índice muito rapidamente.
  • UNIQUE (auction_id, request_id) é o mecanismo de idempotência no nível do banco de dados.

4. API

POST /auctions                     criar sessão
GET  /auctions/{id}                visualizar estado da sessão
POST /auctions/{id}/bids           colocar bid (header Idempotency-Key: <uuid>)
WS   /ws/auctions/{id}             receber novos preços em tempo real

Requisição de bid:

{ "bidderId": 42, "amount": 1250000 }

Há três tipos de resposta. 201 Created quando o bid é aceito. 409 Conflict quando o preço é muito baixo ou a sessão já foi encerrada. 200 OK com o resultado anterior quando a requisição tem um Idempotency-Key coincidente.

5. Colocando bids concorrentes

5.1. Erro clássico: leitura e depois escrita

A maneira mais natural é a forma errada:

@Transactional
public void placeBidNaive(long auctionId, long bidderId, BigDecimal amount) {
    Auction a = auctionRepository.findById(auctionId).orElseThrow();
    if (amount.compareTo(a.getCurrentPrice().add(a.getMinIncrement())) < 0) {
        throw new BidRejectedException("Preço muito baixo");
    }
    a.setCurrentPrice(amount);
    a.setLeaderId(bidderId);
}

Suponha que o preço atual seja 1.000. Duas threads leem o preço 1.000. Thread A coloca 1.100, thread B coloca 1.050. Ambas passam pela verificação. Se B fizer commit depois, o preço final é 1.050 e o líder é B. O bid mais alto de A é sobrescrito. Este é o erro de lost update.

5.2. Conditional update atômico

A solução mais concisa é combinar a verificação e a escrita em um único UPDATE. A condição está no WHERE:

public interface AuctionRepository extends JpaRepository<Auction, Long> {

    @Modifying
    @Query(value = """
        UPDATE auction
           SET current_price = :amount,
               leader_id     = :bidderId,
               end_time      = CASE
                   WHEN end_time - clock_timestamp() < INTERVAL '2 minutes'
                   THEN clock_timestamp() + INTERVAL '2 minutes'
                   ELSE end_time END
         WHERE id = :id
           AND status = 'OPEN'
           AND end_time > clock_timestamp()
           AND :amount >= current_price + min_increment
        """, nativeQuery = true)
    int tryPlaceBid(@Param("id") long id,
                    @Param("bidderId") long bidderId,
                    @Param("amount") BigDecimal amount);
}

O mecanismo funciona da seguinte forma:

  • PostgreSQL bloqueia a linha auction durante UPDATE. A segunda thread deve esperar a primeira thread fazer commit.
  • No nível READ COMMITTED, depois de liberar o bloqueio, PostgreSQL reavalia WHERE na versão de linha mais recente. Um bid de 1.050 encontra o novo preço de 1.100, a condição é falsa, então o comando retorna 0.
  • A lógica anti-sniping (bloco CASE) está sempre dentro desta instrução. Assim, a extensão nunca fica fora de sincronização com o bid que a causou.
  • O tempo vem de clock_timestamp() do banco de dados, não do relógio de cada instância de aplicação. O banco de dados é a única fonte de tempo.

Nota: Não use now() aqui. No PostgreSQL, now() retorna o momento do início da transação. Uma transação aberta antes do fechamento mas que roda lentamente ainda pode “escapar” um bid após o fechamento.

O serviço registra o bid no mesmo log da transação:

@Service
@RequiredArgsConstructor
public class BidService {

    private final AuctionRepository auctionRepository;
    private final BidRepository bidRepository;

    @Transactional
    public Bid placeBid(long auctionId, long bidderId, BigDecimal amount, UUID requestId) {
        var existing = bidRepository.findByAuctionIdAndRequestId(auctionId, requestId);
        if (existing.isPresent()) {
            return existing.get(); // retry do cliente: retorna resultado anterior
        }
        int updated = auctionRepository.tryPlaceBid(auctionId, bidderId, amount);
        if (updated == 0) {
            throw new BidRejectedException("Sessão encerrada ou preço não é alto o suficiente");
        }
        return bidRepository.save(new Bid(auctionId, bidderId, amount, requestId));
    }
}

Se duas tentativas com requestId coincidente rodarem em paralelo, ambas podem passar pela etapa findBy.... Nesse caso, a restrição UNIQUE lançará DataIntegrityViolationException para a segunda transação. Esta transação sofre rollback completo, incluindo a parte UPDATE de preço. O controlador captura essa exceção e retorna o bid que já existe.

5.3. Testando bids concorrentes

O teste abaixo envia 200 bids em paralelo usando virtual threads para a mesma sessão, depois verifica invariantes. Dependências a adicionar:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class BidServiceConcurrencyTest {

    @Container @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");

    @Autowired BidService bidService;
    @Autowired AuctionRepository auctionRepository;
    @Autowired BidRepository bidRepository;

    @Test
    void concurrentBids_keepHighestAcceptedBidAsLeader() throws Exception {
        long id = auctionRepository.save(Auction.open("Relógio antigo",
                new BigDecimal("1000"), new BigDecimal("10"),
                Instant.now().plus(Duration.ofHours(1)))).getId();

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 1; i <= 200; i++) {
                long bidder = i;
                var amount = new BigDecimal(1000 + i * 10);
                executor.submit(() -> {
                    try {
                        bidService.placeBid(id, bidder, amount, UUID.randomUUID());
                    } catch (BidRejectedException ignored) { }
                });
            }
        }

        Auction result = auctionRepository.findById(id).orElseThrow();
        BigDecimal maxAccepted = bidRepository.findMaxAmount(id);

        assertThat(result.getCurrentPrice()).isEqualByComparingTo("3000");
        assertThat(result.getLeaderId()).isEqualTo(200L);
        assertThat(maxAccepted).isEqualByComparingTo(result.getCurrentPrice());
    }
}

Um bid de 3.000 (bidder 200) é sempre válido porque não há bid mais alto. Portanto, não importa a ordem de execução, deve ser o líder. Se você substituir tryPlaceBid pela versão ingênua da seção 5.1, este teste falhará intermitentemente. Esse é o sinal de uma race condition.

6. Sessão “quente”: movendo a contenção para Redis

Conditional update serializa todos os bids em uma única linha. Para uma sessão, PostgreSQL geralmente pode processar algumas centenas a cerca de mil UPDATEs por segundo. Uma sessão que recebe 2.000 bids/segundo criará uma fila de bloqueio longa e empurrará p99 além do limite.

Para as sessões marcadas como “quentes”, movemos a decisão para Redis. Redis executa scripts Lua em um único thread e atomicamente. Dependências a adicionar:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
@Service
@RequiredArgsConstructor
public class HotBidService {

    private static final RedisScript<Long> PLACE_BID = RedisScript.of("""
        local a = redis.call('HMGET', KEYS[1], 'price', 'inc', 'end', 'status')
        local now = tonumber(ARGV[3])
        if a[4] ~= 'OPEN' or now >= tonumber(a[3]) then return -1 end
        if tonumber(ARGV[1]) < tonumber(a[1]) + tonumber(a[2]) then return 0 end
        local newEnd = tonumber(a[3])
        if newEnd - now < 120000 then newEnd = now + 120000 end
        redis.call('HSET', KEYS[1], 'price', ARGV[1], 'leader', ARGV[2], 'end', newEnd)
        return 1
        """, Long.class);

    private final StringRedisTemplate redis;
    private final KafkaTemplate<String, BidAccepted> kafka;

    public boolean placeBid(long auctionId, long bidderId, long amount, UUID requestId) {
        long now = Long.parseLong(redis.execute(
                (RedisCallback<Long>) c -> c.serverCommands().time()).toString());
        Long r = redis.execute(PLACE_BID, List.of("auction:" + auctionId),
                String.valueOf(amount), String.valueOf(bidderId), String.valueOf(now));
        if (r == null || r != 1L) return false;
        kafka.send("bid-accepted", String.valueOf(auctionId),
                new BidAccepted(auctionId, bidderId, amount, requestId, now));
        return true;
    }
}

Explicação breve:

  • O script lê preço, incremento, hora de encerramento e status, verifica e escreve, tudo em uma única etapa atômica. Valor de retorno: 1 é aceito, 0 é preço baixo, -1 é sessão encerrada.
  • O tempo vem do comando TIME do Redis para que todas as instâncias compartilhem um relógio. Na prática, você pode chamar redis.call('TIME') diretamente no script. Para fazer isso, você precisa habilitar o modo de replicação de efeitos de script, que é o padrão desde Redis 5.
  • Os bids aceitos serão enviados para Kafka com a chave auctionId. Todos os bids de uma sessão caem na mesma partição e mantêm a ordem correta. O consumidor os escreve em PostgreSQL em lotes.

Nota: Neste ponto, Redis se torna a fonte de verdade durante toda a sessão. Você precisa habilitar AOF com appendfsync everysec ou always, executar replicas e lidar com o caso onde kafka.send falha depois que Redis já escreveu (retry ou reconciliação periódica entre Redis e PostgreSQL). Não aplique este modelo a todas as sessões. A maioria das sessões frias roda bem com conditional update e é muito mais simples.

7. Encerrando a sessão

7.1. Encerrando sessão com um único comando atômico

Não precisamos de um scheduler preciso até milissegundos para cada sessão. O limite real já está na condição end_time > clock_timestamp() da instrução UPDATE de bid. Após o fechamento, nenhum bid passa, mesmo quando o status ainda é OPEN. O job de encerramento apenas precisa “fechar os livros” em alguns segundos:

@Component
@RequiredArgsConstructor
public class AuctionCloser {

    private final JdbcTemplate jdbc;

    @Scheduled(fixedDelay = 1000)
    @Transactional
    public void closeExpired() {
        var closed = jdbc.query("""
            UPDATE auction SET status = 'CLOSED'
             WHERE id IN (SELECT id FROM auction
                           WHERE status = 'OPEN' AND end_time <= clock_timestamp()
                           ORDER BY end_time LIMIT 500
                           FOR UPDATE SKIP LOCKED)
            RETURNING id, leader_id, current_price
            """, (rs, i) -> new AuctionClosed(rs.getLong("id"),
                    (Long) rs.getObject("leader_id"), rs.getBigDecimal("current_price")));

        for (var e : closed) {
            jdbc.update("INSERT INTO outbox(topic, aggregate_id, payload) VALUES (?, ?, ?::jsonb)",
                    "auction-closed", e.auctionId(), e.toJson());
        }
    }
}

Este comando é seguro quando executado em múltiplas instâncias porque:

  • FOR UPDATE SKIP LOCKED permite que as instâncias compartilhem as sessões. Duas instâncias nunca fecham a mesma sessão.
  • Se um bid está segurando um bloqueio de linha, SKIP LOCKED pula essa linha. A próxima execução verificará novamente, quando end_time pode ter sido estendida.
  • A transição de status OPEN → CLOSED ocorre apenas uma vez. Executar o job novamente não causa efeitos colaterais, ou seja, o job é idempotente.

7.2. Emitindo eventos com Outbox

O evento auction-closed dispara o pagamento e envia um e-mail para o vencedor. Se você chamar kafka.send diretamente na transação, pode encontrar dois cenários ruins. Um é ter enviado o evento mas a transação fazer rollback. Dois é ter feito commit mas o envio falhar. A tabela outbox é escrita na mesma transação que o encerramento da sessão. Posteriormente, um relay (polling ou Debezium CDC) empurra as linhas do outbox para Kafka. O lado do consumidor deve ser idempotente por auctionId, porque o relay garante at-least-once.

8. Picos de carga e fan-out em tempo real

O último minuto de uma sessão quente não tem apenas muitos bids, mas também dezenas de milhares de espectadores. Algumas técnicas de suporte:

  • Rate limit por usuário, por exemplo, máximo 5 bids/segundo com token bucket em Redis. Esta técnica bloqueia bots de spam antes de tocarem na camada de contenção.
  • Rejeição antecipada: cache o preço atual na memória (algumas centenas de milissegundos) e rejeite imediatamente bids claramente muito baixos antes de chamar o DB ou Redis. Cache é usado apenas para rejeitar, nunca para aceitar.
  • Fan-out via pub/sub: quando um bid é aceito, publicar no canal Redis auction:{id}. Cada nó WebSocket se inscreve e empurra para seus clientes. Se há muitos bids, agrupe as atualizações a cada 100-200 ms. Os espectadores apenas precisam ver o preço mais recente, não cada bid individual.
  • Separar leitura e escrita: GET /auctions/{id} pode ler de uma réplica ou cache. Apenas o caminho de novos bids precisa ir para o primário.

9. Erros comuns e quando não usar

  • Usar o relógio de aplicação para comparar com end_time. As instâncias desviarem de alguns segundos é normal. Sempre deixe o banco de dados ou Redis decidir a hora.
  • Usar @Version (optimistic locking) para sessões quentes. Ainda está correto, mas 2.000 bids/segundo gerarão inúmeras OptimisticLockException e loops de retry. Conditional update não precisa de retry porque WHERE é reavaliado automaticamente.
  • Usar SELECT ... FOR UPDATE e depois verificar em Java. Isso está correto, mas mantém o bloqueio mais tempo, porque precisa esperar uma volta completa pela aplicação. Use apenas quando a lógica de verificação é muito complexa para escrever em SQL.
  • Distributed lock (Redisson) em torno de toda a função de bid. Isso adiciona um ponto de falha e risco de bloqueio expirar no meio, enquanto o banco de dados já tem bloqueio de linha.
  • Ignorar idempotência. Cliente móvel tentar novamente quando a rede falha é rotina. Sem request_id, a mesma pessoa pode “fazer licitação contra si mesma”.

10. Conclusão

A chave de um sistema de leilão é considerar cada sessão como uma unidade de consistência independente. Este artigo lidou com cada ponto difícil da seguinte forma:

  • Bids concorrentes: combinar verificação e escrita em um único UPDATE ... WHERE atômico, com lógica anti-sniping na mesma instrução.
  • Hora de encerramento: obter a hora do banco de dados (clock_timestamp()), não da aplicação.
  • Sessão quente: mover a contenção para Redis Lua e escrever durável via Kafka, ao custo de operação mais complexa.
  • Encerramento da sessão: usar FOR UPDATE SKIP LOCKED, transição de estado idempotente e emitir eventos via Outbox.

Você deve começar com a versão pura do PostgreSQL. É suficiente para a maioria dos casos e fácil de verificar com testes concorrentes como na seção 5.3. Apenas quando as métricas reais mostram que uma linha auction se torna um gargalo, mova essas sessões para Redis.

system-designconcurrencyspring-bootpostgresqlredis