Design de flash sale: vender 10.000 produtos em 1 segundo
1. Visão Geral
Flash sale é um problema clássico de design de sistemas. Centenas de milhares de pessoas clicam em “Comprar” no mesmo momento. O estoque tem apenas alguns milhares de produtos. O sistema precisa lidar com picos de carga e absolutamente não pode vender mais do que tem.
Este artigo passa por um estudo de caso específico: vender 10.000 produtos em cerca de 1 segundo. Ao terminar de ler, você saberá como especificar requisitos, projetar o modelo de dados e API, fazer reservas atômicas com Redis Lua, gravar pedidos assincronamente via Kafka e reembolsar o estoque quando o cliente não paga.
2. Requisitos
2.1. Requisitos Funcionais
- Cada rodada de venda tem um ou mais produtos, cada produto tem quantidade fixa e preço de venda.
- Um usuário logado clica em “Comprar” e recebe um resultado: reserva bem-sucedida, fora de estoque ou já comprado.
- Cada usuário pode comprar apenas 1 produto por rodada.
- A reserva deve ser paga em 10 minutos. Se expirar, o produto volta para o estoque para outros comprarem.
2.2. Escala
- Estoque: 10.000 produtos.
- Participantes: cerca de 500.000 pessoas, a maioria clicando nos primeiros 1–2 segundos.
- Pico de carga estimado: 200.000–500.000 requisições/segundo para o endpoint de compra.
Apenas 10.000 delas terão sucesso. Mais de 98% das requisições serão “fora de estoque”. Este é o detalhe crítico: o sistema deve rejeitar barato e rápido.
2.3. Restrições
- Nunca vender abaixo do estoque: o número de pedidos válidos nunca deve exceder 10.000.
- Não perder demais na sobra: produtos reservados mas não pagos devem ser devolvidos.
- O sistema de vendas normal não deve ser afetado durante a execução da flash sale.
3. Arquitetura Geral
Um banco de dados relacional simples consegue lidar com alguns milhares de UPDATEs por segundo na mesma linha. Isso está bem longe de várias centenas de milhares. Então, dividimos o fluxo em várias camadas, cada camada filtrando a carga da próxima:
Client ──► CDN (página de venda estática, contagem regressiva)
│
▼
API Gateway (rate limit por usuário/IP, bloqueia bot)
│
▼
Flash Sale Service ──► Redis (Lua: verifica + decrementa estoque atomicamente)
│ │ sucesso (~10.000 vezes)
│ ▼
│ Kafka topic "flash-sale-orders"
│ │
▼ ▼
202 Accepted Order Consumer ──► PostgreSQL (fonte de verdade final)
Ideias principais:
- Redis é a porta da guarda. A operação de decremento de estoque roda em memória, single-threaded, então é atômica e rápida.
- Kafka é o buffer. Apenas cerca de 10.000 mensagens bem-sucedidas entram na fila. O consumidor escreve no DB na velocidade do DB.
- PostgreSQL é a rede de segurança. Restrições no DB garantem que não vendemos abaixo do estoque, mesmo se Redis falhar.
4. Modelo de Dados
No PostgreSQL, precisamos de duas tabelas:
CREATE TABLE flash_sale_item (
id BIGINT PRIMARY KEY,
sale_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
quantity INT NOT NULL,
sold INT NOT NULL DEFAULT 0,
CONSTRAINT chk_not_oversold CHECK (sold <= quantity)
);
CREATE TABLE flash_sale_order (
id UUID PRIMARY KEY,
item_id BIGINT NOT NULL REFERENCES flash_sale_item(id),
user_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL, -- PENDING, PAID, EXPIRED
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_item_user UNIQUE (item_id, user_id)
);
Duas restrições importantes:
chk_not_oversoldimpede quesoldultrapassequantityna camada do DB.uq_item_usertanto aplica a regra “um produto por pessoa” quanto torna o consumidor idempotente quando Kafka reentrega mensagens.
No Redis, cada produto de venda usa duas chaves:
{item:1001}:stock -> "10000" (String, quantidade restante)
{item:1001}:buyers -> {userId, ...} (Set, pessoas que já fizeram reserva)
A parte {item:1001} é a hash tag. Graças a ela, as duas chaves sempre ficam no mesmo slot no Redis Cluster. Script Lua só funciona quando todas as chaves estão no mesmo slot.
Antes da hora de abertura, um job carrega o estoque em Redis:
redis-cli SET "{item:1001}:stock" 10000
5. API
POST /api/flash-sales/items/{itemId}/purchase
Authorization: Bearer <token>
202 Accepted {"orderId": "c0a8...", "status": "PENDING"}
409 Conflict {"error": "ALREADY_PURCHASED"}
410 Gone {"error": "SOLD_OUT"}
GET /api/flash-sales/orders/{orderId}
200 OK {"orderId": "c0a8...", "status": "PENDING"}
O endpoint de compra retorna 202 e não 201, porque o pedido ainda não foi gravado no DB. O cliente mantém o orderId e faz poll do endpoint GET até que o pedido esteja pronto para pagamento. O userId é extraído do token, não do corpo, para que o usuário não possa comprar por outro.
6. Reserva Atômica com Redis Lua
Adicione a dependência:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
O erro mais comum é escrever GET e depois DECR como dois comandos separados do Java. Entre os dois comandos, centenas de outras threads conseguem ler o mesmo valor 1 e decrementar. O resultado é estoque negativo. Script Lua resolve isso porque Redis executa todo o script como um bloco, sem intercalar outros comandos.
Arquivo src/main/resources/lua/reserve.lua:
-- KEYS[1] = stock, KEYS[2] = buyers, ARGV[1] = userId
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
return -2
end
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock <= 0 then
return -1
end
redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[1])
return stock - 1
O script retorna -2 se o usuário já comprou, -1 se esgotou, caso contrário é a quantidade restante após decremento. Verificação e decremento ocorrem em uma única etapa, então não há race condition.
Script de devolução de estoque src/main/resources/lua/release.lua:
-- Cumpre devolver o estoque apenas se o usuário realmente está com a reserva
if redis.call('SREM', KEYS[2], ARGV[1]) == 1 then
return redis.call('INCR', KEYS[1])
end
return -1
A condição SREM == 1 previne devolver o estoque duas vezes para a mesma pessoa. Este é um erro comum quando o job de expiração e o thread de cancelamento rodam em paralelo.
Serviço que chama o script:
public enum ReserveResult { SUCCESS, SOLD_OUT, ALREADY_PURCHASED }
@Service
public class StockReservationService {
private static final RedisScript<Long> RESERVE =
RedisScript.of(new ClassPathResource("lua/reserve.lua"), Long.class);
private static final RedisScript<Long> RELEASE =
RedisScript.of(new ClassPathResource("lua/release.lua"), Long.class);
private final StringRedisTemplate redis;
public StockReservationService(StringRedisTemplate redis) {
this.redis = redis;
}
public ReserveResult reserve(long itemId, long userId) {
Long result = redis.execute(RESERVE, keys(itemId), String.valueOf(userId));
if (result == null) throw new IllegalStateException("Redis returned null");
if (result == -2) return ReserveResult.ALREADY_PURCHASED;
if (result == -1) return ReserveResult.SOLD_OUT;
return ReserveResult.SUCCESS;
}
public void release(long itemId, long userId) {
redis.execute(RELEASE, keys(itemId), String.valueOf(userId));
}
private static List<String> keys(long itemId) {
String tag = "{item:" + itemId + "}";
return List.of(tag + ":stock", tag + ":buyers");
}
}
Spring Data Redis envia o script com EVALSHA e automaticamente volta para EVAL se Redis ainda não tiver armazenado em cache o script. Você não precisa gerenciar SHA manualmente.
7. Escrita Assíncrona de Pedidos via Kafka
Adicione a dependência:
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
7.1. Controller: Reservar e depois publicar evento
public record OrderPlaced(UUID orderId, long itemId, long userId) {}
@RestController
@RequestMapping("/api/flash-sales")
public class PurchaseController {
private final StockReservationService reservation;
private final KafkaTemplate<String, OrderPlaced> kafka;
public PurchaseController(StockReservationService reservation,
KafkaTemplate<String, OrderPlaced> kafka) {
this.reservation = reservation;
this.kafka = kafka;
}
@PostMapping("/items/{itemId}/purchase")
public ResponseEntity<Map<String, Object>> purchase(@PathVariable long itemId,
@AuthenticationPrincipal Jwt jwt) {
long userId = Long.parseLong(jwt.getSubject());
return switch (reservation.reserve(itemId, userId)) {
case SOLD_OUT -> ResponseEntity.status(410).body(Map.of("error", "SOLD_OUT"));
case ALREADY_PURCHASED -> ResponseEntity.status(409).body(Map.of("error", "ALREADY_PURCHASED"));
case SUCCESS -> {
var event = new OrderPlaced(UUID.randomUUID(), itemId, userId);
kafka.send("flash-sale-orders", String.valueOf(itemId), event)
.whenComplete((ok, ex) -> {
if (ex != null) reservation.release(itemId, userId);
});
yield ResponseEntity.accepted()
.body(Map.of("orderId", event.orderId(), "status", "PENDING"));
}
};
}
}
Alguns pontos a observar:
- Uma requisição “fora de estoque” custa apenas uma chamada a Redis e retorna. Este é o caminho de 98% das requisições.
- Se enviar para Kafka falhar, chamamos
releasepara devolver o produto ao estoque. Sem este passo de compensação, o produto ficaria “reservado para sempre”. - A chave da mensagem é
itemId. Todos os pedidos do mesmo produto entram na mesma partição e são processados em ordem.
O produtor deve habilitar acks=all e idempotência para não perder mensagens:
spring.kafka.producer.acks=all
spring.kafka.producer.properties.enable.idempotence=true
spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer
7.2. Consumidor: DB como Rede de Segurança
@Component
public class OrderConsumer {
private final JdbcTemplate jdbc;
public OrderConsumer(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
@KafkaListener(topics = "flash-sale-orders", groupId = "order-writer")
public void handle(OrderPlaced event) {
int inserted = jdbc.update("""
INSERT INTO flash_sale_order (id, item_id, user_id, status)
VALUES (?, ?, ?, 'PENDING')
ON CONFLICT DO NOTHING
""", event.orderId(), event.itemId(), event.userId());
if (inserted == 0) return; // mensagem reentregue, já foi processada
int updated = jdbc.update("""
UPDATE flash_sale_item SET sold = sold + 1
WHERE id = ? AND sold < quantity
""", event.itemId());
if (updated == 0) {
throw new IllegalStateException("Estoque de DB esgotado para item " + event.itemId());
}
}
}
ON CONFLICT DO NOTHING torna o consumidor idempotente. Kafka garante entrega pelo menos uma vez, então a mesma mensagem pode chegar duas vezes. Na segunda vez, nenhuma linha é inserida e a execução encerra cedo.
O UPDATE ... WHERE sold < quantity é uma condição atômica na camada do DB. Se Redis ficar fora de sincronia (por exemplo, alguém carrega manualmente um número errado), o comando retorna 0. Transação faz rollback, pedido não é criado. Você deve configurar um manipulador de erro que envia a mensagem para um dead-letter topic e devolve estoque em Redis, em vez de fazer retry infinito.
Como o consumidor recebe apenas cerca de 10.000 mensagens e escreve sequencialmente por partição, o DB lida com alguns milhares de transações por segundo. Este nível é completamente normal.
8. Expiração de Pagamento e Devolução de Estoque
Um job periódico verifica pedidos expirados:
@Component
public class ExpiredOrderJob {
private final JdbcTemplate jdbc;
private final StockReservationService reservation;
public ExpiredOrderJob(JdbcTemplate jdbc, StockReservationService reservation) {
this.jdbc = jdbc;
this.reservation = reservation;
}
@Scheduled(fixedDelay = 30_000)
@Transactional
public void expire() {
List<long[]> expired = jdbc.query("""
UPDATE flash_sale_order SET status = 'EXPIRED'
WHERE status = 'PENDING' AND created_at < now() - interval '10 minutes'
RETURNING item_id, user_id
""", (rs, i) -> new long[]{rs.getLong(1), rs.getLong(2)});
for (long[] row : expired) {
jdbc.update("UPDATE flash_sale_item SET sold = sold - 1 WHERE id = ?", row[0]);
reservation.release(row[0], row[1]);
}
}
}
UPDATE ... RETURNING muda o status e obtém a lista de pedidos em uma única instrução. Se duas instâncias rodam o job ao mesmo tempo, cada linha é atualizada por apenas uma instância, porque a condição status = 'PENDING' deixa de ser verdadeira após a primeira atualização.
Observe a ordem: o release do Redis está dentro da transação do DB mas não é parte dela. Se a transação fizer rollback após o release, Redis terá um crédito extra. Na prática, você deve separar o release para após commit, por exemplo com TransactionSynchronization.afterCommit. Você também pode emitir um evento “OrderExpired” via Kafka. A versão simplificada aqui é apenas para ilustração.
9. Testes: Nunca Vender Abaixo do Estoque
O comportamento mais crítico a testar é: 1.000 pessoas disputando 100 produtos resultará em exatamente 100 com sucesso. Usamos Testcontainers para rodar Redis real.
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
@Testcontainers
class StockReservationServiceTest {
@Container
static GenericContainer<?> redisContainer =
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@Test
void whenThousandUsersCompeteForHundredItems_thenExactlyHundredSucceed() {
var factory = new LettuceConnectionFactory(
redisContainer.getHost(), redisContainer.getMappedPort(6379));
factory.afterPropertiesSet();
var template = new StringRedisTemplate(factory);
var service = new StockReservationService(template);
template.opsForValue().set("{item:1}:stock", "100");
var successes = new AtomicInteger();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (long userId = 0; userId < 1_000; userId++) {
long id = userId;
executor.submit(() -> {
if (service.reserve(1, id) == ReserveResult.SUCCESS) {
successes.incrementAndGet();
}
});
}
}
assertThat(successes.get()).isEqualTo(100);
assertThat(template.opsForValue().get("{item:1}:stock")).isEqualTo("0");
assertThat(service.reserve(1, 0)).isEqualTo(ReserveResult.ALREADY_PURCHASED);
}
}
O bloco try-with-resources com virtual thread executor aguarda todas as tarefas terminarem. Resultado esperado:
successes = 100
stock = "0"
Se você substituir o script Lua por GET e depois DECR separados, este teste falhará de forma instável e o estoque frequentemente fica negativo.
10. Pontos Difíceis e Erros Comuns
Hot key no Redis. Toda requisição bate na mesma chave {item:1001}:stock. Um nó Redis processa cerca de 100.000 comandos/segundo. Em cargas maiores, há duas abordagens:
-
Dividir estoque (sharding de estoque): dividir 10.000 em 10 chaves
{item:1001:0}…{item:1001:9}, cada chave com 1.000, em diferentes nós. Requisição escolhe um shard aleatoriamente, se esgotar tenta outro. Neste caso, a regra “um produto por pessoa” precisa rotear o usuário para um shard fixo poruserId % 10, ou verificar na camada DB. -
Flag de esgotado local: quando Redis reporta
SOLD_OUT, cada instância liga umAtomicBooleanna memória e rejeita imediatamente sem mais chamar Redis.
Pico de carga antes até do Redis. Rate limit no gateway, captcha ou “fila virtual” ajuda a cortar a maioria dos bots. A página de venda deve ser uma página estática no CDN. O botão “Comprar” só é ativado na hora certa, e a URL de compra é retornada apenas então para reduzir cliques antecipados via script.
Redis perde dados. Se Redis faz failover e a réplica não conseguiu receber o último DECR, o estoque no Redis pode estar mais alto do que o real. Neste momento, a condição sold < quantity no DB entra em ação. Nunca abandone esta rede de segurança.
Consistência eventual. O usuário recebe 202 mas o pedido ainda não está no DB imediatamente. A UI deve exibir status “processando” e fazer poll, não relatar “compra bem-sucedida” imediatamente.
Observação: Não use
SELECT ... FOR UPDATEna linha de estoque como mecanismo principal para flash sale. Está correto em termos de consistência, mas toda transação fica enfileirada esperando o mesmo row lock. O throughput desmorona para centenas até milhares de requisições/segundo, o pool de conexão se esgota e derruba todo o sistema de vendas normal.
Observação: A arquitetura Redis + Kafka só vale a pena para cargas realmente altas. Se sua venda tem apenas centenas de requisições/segundo, uma simples instrução
UPDATE ... WHERE sold < quantityno PostgreSQL é suficiente. É muito mais simples e não tem problemas de sincronização entre dois armazéns de dados.
11. Conclusão
O design de flash sale gira em torno de um princípio: filtrar carga em cada camada e manter uma fonte de verdade com restrições firmes. Neste artigo, você viu:
- Script Lua do Redis verifica e decrementa estoque atomicamente, rejeita “fora de estoque” em um único round-trip.
- Kafka como buffer para que DB receba apenas o número certo de pedidos bem-sucedidos.
- PostgreSQL com
UNIQUE,CHECKeUPDATE ... WHERE sold < quantitycomo rede de segurança contra vendas abaixo do estoque e tratamento de mensagens duplicadas. - Mecanismo de compensação quando Kafka falha ou pedido expira, com bloqueio contra devolver estoque duas vezes.
Ao aplicar na prática, comece com testes de concorrência como na seção 9. Depois rode testes de carga com números próximos ao pico real, antes da primeira venda abrir.