Design do carrinho de compras e fluxo de pedidos

1. Visão geral

Carrinho de compras e pedidos são as funções mais usadas em um e-commerce. Também são as mais propensas a gerar problemas: sobrevenda de estoque, débito duplo, pedidos “travados” durante flash sales.

Este artigo percorre um design completo em formato de estudo de caso. O design inclui requisitos, modelo de dados, API, fluxo de processamento e pontos críticos. Cada ponto crítico vem acompanhado de uma solução usando Spring Boot 3, PostgreSQL, Redis e Kafka. Ao terminar, você terá um framework de design para aplicar ou levar para uma entrevista de system design.

2. Requisitos

2.1. Requisitos funcionais

  • O cliente adiciona, edita e remove produtos (SKUs) do carrinho. O carrinho persiste entre múltiplas sessões de login.
  • No checkout, o sistema recalcula preços e verifica estoque, depois cria o pedido com status PENDING_PAYMENT.
  • Pedidos não pagos após 15 minutos são cancelados e o estoque é retornado.
  • Após pagamento bem-sucedido, o sistema emite eventos para logística, email e programa de pontos.

2.2. Escala assumida

  • 10 milhões de usuários ativos por mês.
  • Operações de carrinho: média de 2.000 req/s, pico de 20.000 req/s.
  • Pedidos: média de 200 pedidos/s. Durante flash sales pode chegar a 5.000 pedidos/s, concentrados em poucos SKUs.

2.3. Restrições

  • Nunca vender acima do estoque. Esta é uma restrição hard.
  • Não criar pedidos duplicados quando o cliente retira a requisição por timeout ou clica duas vezes.
  • Pedidos criados devem gerar eventos, mesmo se Kafka estiver temporariamente indisponível.
  • Carrinhos podem ter consistência “eventual”. Pedidos e estoque devem ter consistência forte.

A última restrição molda a arquitetura. Carrinhos têm muitas leituras/escritas e pouca importância, então vão no Redis. Pedidos e estoque precisam de transações, então vão no PostgreSQL.

3. Modelo de dados

O carrinho no Redis usa um Hash para cada cliente:

key:   cart:{customerId}
field: sku
value: quantity
TTL:   30 dias

O carrinho armazena apenas SKU e quantidade, não armazena preço. O preço pode mudar a qualquer momento, então o sistema sempre recalcula no checkout.

Os dados que precisam de transações ficam no PostgreSQL:

CREATE TABLE inventory (
    sku        VARCHAR(64) PRIMARY KEY,
    available  INT NOT NULL CHECK (available >= 0),
    reserved   INT NOT NULL DEFAULT 0
);

CREATE TABLE orders (
    id               BIGSERIAL PRIMARY KEY,
    customer_id      BIGINT       NOT NULL,
    idempotency_key  VARCHAR(64)  NOT NULL,
    status           VARCHAR(32)  NOT NULL,
    total_amount     BIGINT       NOT NULL,
    expires_at       TIMESTAMPTZ  NOT NULL,
    created_at       TIMESTAMPTZ  NOT NULL DEFAULT now(),
    UNIQUE (customer_id, idempotency_key)
);

CREATE TABLE order_items (
    order_id    BIGINT REFERENCES orders(id),
    sku         VARCHAR(64) NOT NULL,
    quantity    INT         NOT NULL,
    unit_price  BIGINT      NOT NULL,
    PRIMARY KEY (order_id, sku)
);

CREATE TABLE outbox_events (
    id           BIGSERIAL PRIMARY KEY,
    aggregate_id BIGINT      NOT NULL,
    event_type   VARCHAR(64) NOT NULL,
    payload      JSONB       NOT NULL,
    published    BOOLEAN     NOT NULL DEFAULT false
);

Alguns pontos notáveis neste schema:

  • CHECK (available >= 0) é a última camada de defesa. Se o código tiver um bug, o banco ainda bloqueia estoque negativo.
  • UNIQUE (customer_id, idempotency_key) bloqueia duplicação de pedidos no nível de dados.
  • order_items.unit_price é um snapshot do preço no momento do pedido. Pedidos antigos não são afetados por mudanças de preço.
  • Dinheiro é armazenado como BIGINT na menor unidade (centavos). Nunca use DOUBLE.

4. Design de API

GET    /api/cart                       -> ver carrinho (com preços atuais)
PUT    /api/cart/items/{sku}           -> body {"quantity": 2}, define quantidade
DELETE /api/cart/items/{sku}           -> remove SKU do carrinho
POST   /api/orders                     -> header Idempotency-Key, cria pedido
GET    /api/orders/{id}                -> vê status do pedido

PUT com quantidade absoluta é idempotente. Chamar múltiplas vezes produz o mesmo resultado. Se você usar POST /add com +1, cada retry do cliente adicionará outro produto.

POST /api/orders não é idempotente por natureza, então o cliente deve gerar uma Idempotency-Key (UUID) para cada clique em “Finalizar”. Ao retentear, o cliente envia a mesma chave.

5. Carrinho no Redis

Adicione a dependência:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Serviço de carrinho:

@Service
public class CartService {

    private static final Duration CART_TTL = Duration.ofDays(30);
    private static final int MAX_ITEMS = 100;

    private final StringRedisTemplate redis;

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

    public void setQuantity(long customerId, String sku, int quantity) {
        String key = "cart:" + customerId;
        HashOperations<String, String, String> ops = redis.opsForHash();
        if (quantity <= 0) {
            ops.delete(key, sku);
            return;
        }
        if (!ops.hasKey(key, sku) && ops.size(key) >= MAX_ITEMS) {
            throw new IllegalStateException("Carrinho já tem " + MAX_ITEMS + " produtos");
        }
        ops.put(key, sku, String.valueOf(quantity));
        redis.expire(key, CART_TTL);
    }

    public Map<String, Integer> getItems(long customerId) {
        Map<String, String> raw = redis.<String, String>opsForHash().entries("cart:" + customerId);
        return raw.entrySet().stream()
                .collect(Collectors.toMap(Map.Entry::getKey, e -> Integer.parseInt(e.getValue())));
    }
}

Explicação:

  • ops.put(key, sku, ...) escreve um field num Hash com complexidade O(1). Dois clientes editando SKUs diferentes não se sobrescrevem. Se você armazenasse o carrinho como uma string JSON, seria sobrescrito.
  • expire é renovado a cada escrita. O carrinho expira apenas após 30 dias sem operações.
  • MAX_ITEMS limita o tamanho do carrinho para evitar keys muito grandes causadas por bots.

As operações hasKey e depois size não são atômicas. Dois requests em paralelo podem ultrapassar o limite por 1 ou 2 itens. Para carrinhos, uma pequena discrepância é aceitável. Você não deve aplicar este nível de “aproximação” para estoque.

Nota: Redis não é uma fonte de dados absolutamente durável. Ative AOF (appendfsync everysec) para limitar perda de dados a cerca de um segundo. Perder um segundo de dados de carrinho é aceitável, mas perder pedidos não é.

6. Fluxo de pedidos

O fluxo POST /api/orders passa pelos seguintes passos:

1. Lê carrinho do Redis
2. Obtém preços atuais do Catálogo
3. BEGIN transação (PostgreSQL)
   3a. Insert orders com idempotency_key  -> se duplicado, retorna pedido anterior
   3b. Deduz estoque de cada SKU (conditional update) -> sem estoque, ROLLBACK
   3c. Insert order_items (snapshot de preço)
   3d. Insert outbox_events "OrderCreated"
4. COMMIT
5. Deleta carrinho (best-effort)
6. Relay lê outbox -> publica no Kafka

6.1. Proteção contra sobrevenda com conditional update

Um erro comum é ler o estoque, comparar em Java e depois escrever. Entre a leitura e a escrita, outra thread pode ter comprado o item. A forma correta é combinar a verificação e a dedução em uma única instrução SQL:

@Repository
public class InventoryRepository {

    private final JdbcClient jdbc;

    public InventoryRepository(JdbcClient jdbc) {
        this.jdbc = jdbc;
    }

    public boolean reserve(String sku, int quantity) {
        int updated = jdbc.sql("""
                UPDATE inventory
                   SET available = available - :qty,
                       reserved  = reserved  + :qty
                 WHERE sku = :sku AND available >= :qty
                """)
                .param("qty", quantity)
                .param("sku", sku)
                .update();
        return updated == 1;
    }
}

O banco de dados bloqueia a linha enquanto UPDATE executa. O segundo request deve esperar o primeiro terminar e depois reavaliar a condição available >= :qty com o novo valor. Se não há estoque suficiente, updated == 0 e o método retorna false. Você não precisa de SELECT ... FOR UPDATE ou version column.

6.2. Idempotência e transação

@Service
public class OrderService {

    private final JdbcClient jdbc;
    private final InventoryRepository inventory;

    public OrderService(JdbcClient jdbc, InventoryRepository inventory) {
        this.jdbc = jdbc;
        this.inventory = inventory;
    }

    @Transactional
    public long placeOrder(long customerId, String idempotencyKey, List<OrderLine> lines) {
        Optional<Long> existing = jdbc.sql("""
                INSERT INTO orders (customer_id, idempotency_key, status, total_amount, expires_at)
                VALUES (:c, :k, 'PENDING_PAYMENT', :total, now() + interval '15 minutes')
                ON CONFLICT (customer_id, idempotency_key) DO NOTHING
                RETURNING id
                """)
                .param("c", customerId).param("k", idempotencyKey)
                .param("total", lines.stream().mapToLong(OrderLine::subtotal).sum())
                .query(Long.class).optional();

        if (existing.isEmpty()) {
            return jdbc.sql("SELECT id FROM orders WHERE customer_id = :c AND idempotency_key = :k")
                    .param("c", customerId).param("k", idempotencyKey)
                    .query(Long.class).single();
        }
        long orderId = existing.get();

        for (OrderLine line : lines.stream().sorted(Comparator.comparing(OrderLine::sku)).toList()) {
            if (!inventory.reserve(line.sku(), line.quantity())) {
                throw new OutOfStockException(line.sku());
            }
            jdbc.sql("INSERT INTO order_items VALUES (:o, :s, :q, :p)")
                    .param("o", orderId).param("s", line.sku())
                    .param("q", line.quantity()).param("p", line.unitPrice())
                    .update();
        }

        jdbc.sql("""
                INSERT INTO outbox_events (aggregate_id, event_type, payload)
                VALUES (:id, 'OrderCreated', CAST(:payload AS jsonb))
                """)
                .param("id", orderId)
                .param("payload", "{\"orderId\":" + orderId + ",\"customerId\":" + customerId + "}")
                .update();
        return orderId;
    }
}

public record OrderLine(String sku, int quantity, long unitPrice) {
    long subtotal() { return quantity * unitPrice; }
}

public class OutOfStockException extends RuntimeException {
    public OutOfStockException(String sku) { super("Fora de estoque: " + sku); }
}

Pontos críticos:

  • ON CONFLICT ... DO NOTHING RETURNING id retorna id apenas quando o insert é bem-sucedido. Se a chave já existe, o resultado está vazio. O serviço então lê o pedido anterior e retorna, então retries do cliente ainda recebem o mesmo pedido.
  • OutOfStockException é uma RuntimeException, então @Transactional faz rollback de tudo: pedido, SKUs já deduzidos anteriormente e outbox. Sem estado “meio a meio”.
  • A lista de SKUs é ordenada antes de deduzir. Dois pedidos contendo SKU A e B sempre bloquearão A antes de B, evitando deadlock.
  • Se o primeiro request sofrer rollback, a chave também sofre. Um retry com a mesma chave reexecuta do começo, que é exatamente o comportamento esperado.

6.3. Transactional outbox

Se você chamar kafkaTemplate.send() dentro de uma transação, enfrenta um dos dois problemas. Kafka envia com sucesso mas DB faz rollback, criando um evento “fantasma”. DB faz commit mas Kafka falha, perdendo o evento. Outbox resolve escrevendo eventos na mesma transação do pedido, depois um relay empurra eventos para Kafka.

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
@Component
public class OutboxRelay {

    private final JdbcClient jdbc;
    private final KafkaTemplate<String, String> kafka;

    public OutboxRelay(JdbcClient jdbc, KafkaTemplate<String, String> kafka) {
        this.jdbc = jdbc;
        this.kafka = kafka;
    }

    @Scheduled(fixedDelay = 500)
    @Transactional
    public void publish() throws Exception {
        List<OutboxEvent> events = jdbc.sql("""
                SELECT id, aggregate_id, event_type, payload::text AS payload
                  FROM outbox_events WHERE published = false
                 ORDER BY id LIMIT 100
                 FOR UPDATE SKIP LOCKED
                """).query(OutboxEvent.class).list();

        for (OutboxEvent e : events) {
            kafka.send("orders", String.valueOf(e.aggregateId()), e.payload()).get();
            jdbc.sql("UPDATE outbox_events SET published = true WHERE id = :id")
                    .param("id", e.id()).update();
        }
    }
}

record OutboxEvent(long id, long aggregateId, String eventType, String payload) {}

@Scheduled roda apenas se a aplicação tiver @EnableScheduling ligado.

  • FOR UPDATE SKIP LOCKED permite múltiplas instâncias de relay rodarem em paralelo sem processar a mesma linha.
  • A chave da mensagem é aggregateId. Todos os eventos do mesmo pedido caem na mesma partition e mantêm ordem.
  • .get() aguarda confirmação do Kafka antes de marcar published. Se o relay cair no meio, o evento pode ser reenviado novamente. Outbox garante at-least-once, então consumers devem ser idempotentes, por exemplo armazenando orderId já processado.

6.4. Expiração do pagamento

Um job periódico cancela pedidos vencidos e retorna o estoque:

WITH expired AS (
    UPDATE orders SET status = 'CANCELLED'
     WHERE status = 'PENDING_PAYMENT' AND expires_at < now()
    RETURNING id
)
UPDATE inventory i
   SET available = i.available + oi.quantity,
       reserved  = i.reserved  - oi.quantity
  FROM order_items oi
 WHERE oi.order_id IN (SELECT id FROM expired) AND oi.sku = i.sku;

A condição status = 'PENDING_PAYMENT' resolve corrida com callback de pagamento. O callback também deve atualizar usando UPDATE ... WHERE status = 'PENDING_PAYMENT'. Quem atualiza primeiro vence, o outro atualiza 0 linhas. Se o pagamento chegar após o pedido ser cancelado, o sistema reembolsa em vez de “ressuscitar” o pedido.

A consulta assume cada SKU aparece uma vez em cada pedido, o que a chave primária (order_id, sku) garante. Se um lote de cancelamento tiver múltiplos pedidos com o mesmo SKU, PostgreSQL aplica apenas um join de linha para cada linha de inventory. Você precisaria agrupar primeiro com GROUP BY sku e SUM(quantity).

7. Carga de pico: flash sales

Quando 5.000 requisições/s disputam um SKU, todas enfileiram esperando a trava de uma linha em inventory. O banco fica sobrecarregado, latência dispara e arrasta outros SKUs. A solução é bloquear fluxo antes de tocar o banco, usando um contador de estoque no Redis com script Lua:

@Component
public class FlashSaleGate {

    private static final RedisScript<Long> TRY_ACQUIRE = RedisScript.of("""
            local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
            local qty = tonumber(ARGV[1])
            if stock >= qty then
                redis.call('DECRBY', KEYS[1], qty)
                return 1
            end
            return 0
            """, Long.class);

    private final StringRedisTemplate redis;

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

    public boolean tryAcquire(String sku, int quantity) {
        Long ok = redis.execute(TRY_ACQUIRE, List.of("flash:stock:" + sku), String.valueOf(quantity));
        return ok != null && ok == 1L;
    }

    public void release(String sku, int quantity) {
        redis.opsForValue().increment("flash:stock:" + sku, quantity);
    }
}

Redis executa script Lua de forma atômica. Nenhuma instrução se insere entre GET e DECRBY. Com 1.000 unidades e 50.000 requisições, apenas 1.000 passam pelo portão e entram em OrderService. 49.000 recebem imediatamente HTTP 409 “Fora de estoque” sem uma única query ao banco.

O fluxo se integra assim:

tryAcquire (Redis)  --false-->  409 Sold out
       |true
placeOrder (PostgreSQL, conditional update ainda em vigor)
       |exception
release (Redis)

Redis é apenas um filtro. A fonte de verdade permanece o conditional update em PostgreSQL. Se Redis desincronizar, por exemplo por reinicialização ou se release não rodar, o pior caso é vender alguns itens a menos, nunca acima da cota.

Algumas medidas complementares:

  • Rate limit por cliente para bloquear spam de bots.
  • Fila: empurre requests bem-sucedidos para Kafka, consumer cria pedidos na velocidade que o banco aguenta, cliente faz poll GET /api/orders/{id}. Isto troca latência por estabilidade.
  • Isolar SKUs quentes em tabela ou instância separada para não afetar pedidos normais.

8. Testes de concorrência para estoque

O teste mais importante prova que o sistema nunca sobrevende quando múltiplas threads compram.

@JdbcTest
@Import(InventoryRepository.class)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class InventoryRepositoryTest {

    @Autowired JdbcClient jdbc;
    @Autowired InventoryRepository inventory;

    @BeforeEach
    void setUp() {
        jdbc.sql("""
                CREATE TABLE IF NOT EXISTS inventory (
                  sku VARCHAR(64) PRIMARY KEY,
                  available INT NOT NULL CHECK (available >= 0),
                  reserved INT NOT NULL DEFAULT 0)
                """).update();
        jdbc.sql("DELETE FROM inventory").update();
        jdbc.sql("INSERT INTO inventory (sku, available) VALUES ('IPHONE-16', 5)").update();
    }

    @Test
    void givenStockOf5_when20ConcurrentBuyers_thenExactly5Succeed() throws Exception {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<Boolean>> results = IntStream.range(0, 20)
                    .mapToObj(i -> executor.submit(() -> inventory.reserve("IPHONE-16", 1)))
                    .toList();

            long success = 0;
            for (Future<Boolean> f : results) {
                if (f.get()) success++;
            }
            assertThat(success).isEqualTo(5);
        }

        Integer left = jdbc.sql("SELECT available FROM inventory WHERE sku = 'IPHONE-16'")
                .query(Integer.class).single();
        assertThat(left).isZero();
    }
}
  • @Transactional(propagation = NOT_SUPPORTED) desativa transação padrão de @JdbcTest. Sem isto, dados inseridos em setUp não são commitados e threads não conseguem ver.
  • Teste usa virtual threads do Java 21 para criar 20 requisições simultâneas.
  • Banco embutido padrão de @JdbcTest (H2) é suficiente para isto. Para testar ON CONFLICT e SKIP LOCKED, use PostgreSQL real via Testcontainers.

Resultado esperado:

success = 5, available = 0

Se você substituir UPDATE pela abordagem “SELECT e compare em Java”, este teste falhará aleatoriamente, às vezes success chega a 7 ou 8.

9. Armadilhas comuns e quando não usar

  • Armazenar preço no carrinho e usar no checkout. Um atacante pode manter preço antigo ou editar payload. Sempre obtenha preço do servidor.
  • Deduzir estoque ao adicionar ao carrinho. Usuários abandonam carrinhos com frequência, então estoque fica “preso” inutilmente. Deduz apenas no checkout e sempre com expiração.
  • Chamar Kafka ou serviços de pagamento dentro de @Transactional. Transações longas prendem travas, além de não garantirem consistência. Use outbox.
  • Tratar Redis como fonte de verdade para estoque. Redis deve ser apenas camada de filtro.
  • Usar transações distribuídas (2PC/XA) entre serviços. Complexo e lento. Use sagas baseadas em eventos com compensações (cancelar pedido, retornar estoque).

Nota: Se seu e-commerce processa apenas dezenas de pedidos por minuto, um banco único com conditional update e idempotency key é suficiente. Portão Redis, fila Kafka e isolamento de SKUs quentes devem ser adicionados apenas se medições reais mostrarem que o banco está sobrecarregado.

10. Conclusão

Este artigo projetou carrinho e fluxo de pedidos camada por camada. Carrinhos ficam em Redis Hash porque precisam ser rápidos e aceitam consistência eventual. Pedidos e estoque ficam em PostgreSQL porque exigem consistência forte.

Três técnicas lidam com os pontos críticos:

  • Conditional update previne sobrevenda de estoque.
  • Idempotency key com restrição UNIQUE previne pedidos duplicados.
  • Transactional outbox garante que eventos não se perdem.

Durante flash sales, um portão Redis com script Lua filtra fluxo antes de tocar o banco, enquanto o banco permanece fonte de verdade. Você pode começar com a versão mais simples e adicionar camadas conforme a carga real exigir.

system-designspring-bootrediskafkacase-study