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_priceeleader_idsão desnormalizados emauction. Assim, verificar um bid requer apenas ler uma linha, em vez deSELECT MAX(amount)na tabelabid.- Índice parcial
idx_auction_open_endconté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
auctiondurante UPDATE. A segunda thread deve esperar a primeira thread fazer commit. - No nível
READ COMMITTED, depois de liberar o bloqueio, PostgreSQL reavaliaWHEREna 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 retorna0. - 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 everysecoualways, executar replicas e lidar com o caso ondekafka.sendfalha 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 LOCKEDpermite 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 LOCKEDpula essa linha. A próxima execução verificará novamente, quandoend_timepode ter sido estendida. - A transição de status
OPEN → CLOSEDocorre 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úmerasOptimisticLockExceptione loops de retry. Conditional update não precisa de retry porqueWHEREé reavaliado automaticamente. - Usar
SELECT ... FOR UPDATEe 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 ... WHEREatô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.