Diseño del carrito y flujo de pedidos para e-commerce

1. Descripción general

El carrito de compras y el proceso de pedidos son dos funcionalidades que los clientes utilizan más en una plataforma de comercio electrónico. También son las que tienden a causar más problemas: sobreventa de inventario, cobros duplicados, pedidos “colgados” durante flash sales.

Este artículo recorre un diseño completo en forma de caso de estudio. El diseño incluye requisitos, modelo de datos, API, flujos de procesamiento y puntos críticos. Cada punto crítico viene con una solución implementada en Spring Boot 3, PostgreSQL, Redis y Kafka. Después de leerlo, tendrás un marco de diseño que puedes aplicar o llevar a una entrevista de diseño de sistemas.

2. Especificación de requisitos

2.1. Requisitos funcionales

  • El cliente puede agregar, editar y eliminar productos (SKU) en el carrito. El carrito se persiste entre múltiples sesiones de inicio de sesión.
  • Durante el checkout, el sistema recalcula el precio y verifica el inventario, luego crea un pedido en estado PENDING_PAYMENT.
  • Los pedidos sin pagar se cancelan después de 15 minutos y el inventario se devuelve al almacén.
  • Después de un pago exitoso, el sistema emite eventos para el almacén, email y puntos de recompensa.

2.2. Escala asumida

  • 10 millones de usuarios activos mensualmente.
  • Operaciones del carrito: promedio 2.000 req/s, pico de 20.000 req/s.
  • Pedidos: promedio 200 pedidos/s. Durante flash sales puede alcanzar 5.000 pedidos/s concentrados en unos pocos SKU.

2.3. Restricciones

  • Sin sobreventa de inventario. Esta es una restricción dura.
  • No crear pedidos duplicados cuando el cliente reenvía la solicitud por timeout o el usuario hace clic dos veces.
  • Una vez creado el pedido, los eventos deben publicarse aunque Kafka esté temporalmente interrumpido.
  • El carrito puede tener consistencia “eventual”. Los pedidos e inventario deben tener consistencia fuerte.

La última restricción determina la arquitectura. El carrito tiene lecturas/escrituras frecuentes y es menos crítico, así que se coloca en Redis. Los pedidos e inventario necesitan transacciones, por lo que se colocan en PostgreSQL.

3. Modelo de datos

El carrito en Redis usa un Hash para cada cliente:

key:   cart:{customerId}
field: sku
value: quantity
TTL:   30 días

El carrito solo almacena SKU y cantidad, no el precio. Los precios pueden cambiar en cualquier momento, por lo que el sistema siempre recalcula el precio en el checkout.

La parte de datos que requiere transacciones se coloca en 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
);

Algunos puntos notables en este esquema:

  • CHECK (available >= 0) es una capa de protección final. Si el código tiene un error, la base de datos aún previene inventario negativo.
  • UNIQUE (customer_id, idempotency_key) previene la creación de pedidos duplicados a nivel de base de datos.
  • order_items.unit_price es una captura del precio en el momento del pedido. Los pedidos anteriores no se ven afectados cuando cambia el precio del producto.
  • El dinero se almacena como BIGINT en la unidad más pequeña (céntimos). No uses DOUBLE.

4. Diseño de API

GET    /api/cart                       -> ver carrito (con precios actuales)
PUT    /api/cart/items/{sku}           -> body {"quantity": 2}, establecer cantidad
DELETE /api/cart/items/{sku}           -> eliminar SKU del carrito
POST   /api/orders                     -> header Idempotency-Key, crear pedido del carrito
GET    /api/orders/{id}                -> ver estado del pedido

PUT con cantidad absoluta es idempotente. Llamarla múltiples veces produce el mismo resultado. Si diseñaras como POST /add con +1, cada reintento del cliente agregaría un producto más.

POST /api/orders no es idempotente por naturaleza, así que el cliente debe generar una Idempotency-Key (UUID) cada vez que hace clic en “Realizar pedido”. Al reintentar, el cliente envía la misma clave.

5. Carrito en Redis

Agrega dependencia:

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

Servicio de carrito:

@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("El carrito ya tiene " + MAX_ITEMS + " productos");
        }
        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())));
    }
}

Explicación:

  • ops.put(key, sku, ...) escribe un campo en el Hash con complejidad O(1). Dos clientes editando diferentes SKU no se sobrescriben mutuamente. Si guardas todo el carrito como una cadena JSON, se sobrescribirá.
  • expire se extiende en cada escritura. El carrito solo expira después de 30 días sin actividad.
  • MAX_ITEMS limita el tamaño del carrito para evitar claves demasiado grandes de bots.

Los comandos hasKey y size no son atómicos. Dos solicitudes paralelas podrían exceder el límite en 1–2 artículos. Para un carrito, esta pequeña discrepancia es aceptable. No debes aplicar este nivel de “eventual” al inventario.

Nota: Redis no es una fuente de datos completamente duradera. Habilita AOF (appendfsync everysec) para limitar la pérdida a aproximadamente un segundo. Perder un segundo de datos del carrito es aceptable, pero perder un pedido no lo es.

6. Flujo de pedidos

El flujo de POST /api/orders pasa por los siguientes pasos:

1. Leer carrito de Redis
2. Obtener precios actuales del Catálogo
3. BEGIN transaction (PostgreSQL)
   3a. Insertar pedido con idempotency_key  -> si existe, devolver pedido anterior
   3b. Restar inventario por cada SKU (conditional update) -> si falta stock, ROLLBACK
   3c. Insertar order_items (captura de precios)
   3d. Insertar outbox_events "OrderCreated"
4. COMMIT
5. Eliminar carrito (best-effort)
6. Relay lee outbox -> publica Kafka

6.1. Prevención de sobreventa mediante actualización condicional

El error común es leer el inventario, comparar en Java y luego escribir. Entre la lectura y la escritura, otro thread podría haber comprado el producto. El enfoque correcto es combinar la verificación y la resta en una única sentencia 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;
    }
}

La base de datos bloquea la fila mientras se ejecuta UPDATE. La segunda solicitud debe esperar a que termine la primera y luego reevalúa la condición available >= :qty con el nuevo valor. Si no hay suficiente stock, updated == 0 y el método devuelve false. No necesitas SELECT ... FOR UPDATE ni columna de versión.

6.2. Idempotencia y transacción

@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("Sin stock: " + sku); }
}

Puntos clave:

  • ON CONFLICT ... DO NOTHING RETURNING id solo devuelve el id cuando la inserción es exitosa. Si la clave ya existe, el resultado está vacío. Luego el servicio lee el pedido anterior y lo devuelve, por lo que el reintento del cliente recibe exactamente un pedido.
  • OutOfStockException es una RuntimeException, por lo que @Transactional revierte todo: el pedido, los SKU deducidos anteriormente y el outbox. No hay estado “parcial”.
  • La lista de SKU se ordena antes de restar del inventario. Dos pedidos que contengan los SKU A y B siempre bloquearán A primero y luego B, evitando así deadlocks.
  • Si la primera solicitud se revierte, la clave también se revierte. El reintento del cliente con la misma clave se ejecutará desde el principio, que es exactamente el comportamiento deseado.

6.3. Outbox transaccional

Si llamas kafkaTemplate.send() dentro de una transacción, encontrarás uno de dos problemas. Si Kafka envía exitosamente pero la DB se revierte, se crea un evento “fantasma”. Si la DB se compromete pero Kafka falla, el evento se pierde. Outbox lo resuelve escribiendo el evento en la misma transacción que el pedido, y luego un relé lo publica a 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 solo se ejecuta si la aplicación ha habilitado @EnableScheduling.

  • FOR UPDATE SKIP LOCKED permite ejecutar múltiples instancias de relé en paralelo sin procesar la misma fila dos veces.
  • La clave del mensaje es aggregateId. Todos los eventos de un mismo pedido caen en la misma partición y mantienen el orden correcto.
  • .get() espera la confirmación de Kafka antes de marcar published. Si el relé muere a mitad de camino, el evento puede ser reenviado. Outbox garantiza at-least-once, por lo que el consumidor debe ser idempotente, por ejemplo, almacenando los orderId ya procesados.

6.4. Expiración del pago

Un job programado cancela los pedidos expirados y devuelve el inventario:

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;

La condición status = 'PENDING_PAYMENT' resuelve la carrera con el callback de pago. El callback también debe cambiar el estado con UPDATE ... WHERE status = 'PENDING_PAYMENT'. Quienquiera que actualice primero gana, el otro actualiza 0 filas. Si el pago llega después de que el pedido fue cancelado, el sistema reembolsa en lugar de “resucitar” el pedido.

La sentencia anterior asume que cada SKU aparece solo una vez en cada pedido, garantizado por la clave primaria (order_id, sku). Si un lote de cancelación contiene muchos pedidos con el mismo SKU, PostgreSQL aplica un join de una sola fila por cada fila de inventory. En ese caso, necesitas agrupar primero con GROUP BY sku y SUM(quantity).

7. Carga repentina: flash sale

Cuando 5.000 solicitudes/s compiten por un SKU, todas están en cola esperando el bloqueo de una fila en inventory. La base de datos se congestiona, la latencia se dispara y afecta otros SKU. La solución es limitar el tráfico antes de tocar la DB, usando un contador de inventario en Redis ejecutando un 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 ejecuta el script Lua de forma atómica. Ningún comando se interpone entre GET y DECRBY. Con 1.000 cupos y 50.000 solicitudes, solo alrededor de 1.000 solicitudes pasan la puerta a OrderService. Las otras 49.000 reciben inmediatamente HTTP 409 “Sin cupos” sin costear ni una consulta a la DB.

El flujo integrado es así:

tryAcquire (Redis)  --false-->  409 Agotado
       |true
placeOrder (PostgreSQL, conditional update aún mantiene su efecto)
       |exception
release (Redis)

Redis es solo un filtro. La fuente de verdad sigue siendo el conditional update en PostgreSQL. Si Redis está desfasado, por ejemplo por un reinicio o porque release no se ejecuta, el peor resultado es vender menos de lo previsto, no sobrevender.

Algunas medidas complementarias adicionales:

  • Límite de velocidad por cliente para bloquear spam de bots.
  • Colas: empuja las solicitudes que pasan la puerta a Kafka, el consumidor crea pedidos a la velocidad que la DB puede manejar, el cliente consulta GET /api/orders/{id}. Este enfoque intercambia latencia por estabilidad.
  • Aisla SKU calientes en una tabla o instancia separada para no afectar los pedidos regulares.

8. Pruebas de concurrencia del inventario

La prueba más importante es demostrar que el sistema no sobrevende cuando múltiples threads compran simultáneamente.

@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) desactiva la transacción predeterminada de @JdbcTest. Si no la desactivas, los datos insertados en setUp no se confirman y otros threads no los ven.
  • La prueba usa virtual threads de Java 21 para crear 20 solicitudes simultáneas.
  • La base de datos embebida predeterminada de @JdbcTest (H2) es suficiente para esta prueba. Para probar ON CONFLICT y SKIP LOCKED, usa PostgreSQL real a través de Testcontainers.

Resultado esperado:

success = 5, available = 0

Si reemplazas la sentencia UPDATE por el patrón “SELECT y comparar en Java”, esta prueba falla aleatoriamente, con success llegando a veces a 7 u 8.

9. Errores comunes y cuándo no usar esto

  • Guardar el precio en el carrito y usarlo en el checkout. Los atacantes pueden retener el precio antiguo o modificar el payload. Siempre obtén el precio del servidor.
  • Restar del inventario al agregar al carrito. Los usuarios abandonan carritos frecuentemente, por lo que el inventario quedará “atrapado” sin propósito. Solo reserva al checkout y siempre con una expiración.
  • Llamar Kafka o servicio de pago dentro de @Transactional. Las transacciones largas mantienen bloqueos más tiempo y aún no garantizan consistencia. Usa outbox.
  • Considerar Redis como la fuente de verdad para el inventario. Redis solo debe actuar como una capa de filtrado.
  • Usar transacciones distribuidas (2PC/XA) entre servicios. Esto es complejo y lento. Usa sagas basadas en eventos con pasos de compensación (cancelación, devolución).

Nota: Si tu plataforma solo tiene decenas de pedidos por minuto, una base de datos única con conditional update e idempotency key es suficiente. La puerta de Redis, colas de Kafka y aislamiento de SKU calientes solo valen la pena agregar cuando las métricas reales muestran que la DB está realmente congestionada.

10. Conclusión

El artículo ha diseñado el carrito y el flujo de pedidos capa por capa. El carrito reside en Redis Hash porque necesita ser rápido y puede tolerar consistencia eventual. Los pedidos e inventario residen en PostgreSQL porque requieren consistencia fuerte.

Tres técnicas clave resuelven los puntos críticos:

  • Actualización condicional previene la sobreventa.
  • Idempotency key junto con la restricción UNIQUE previene la creación de pedidos duplicados.
  • Outbox transaccional garantiza que no se pierdan eventos.

Durante un flash sale, una puerta de Redis ejecuta un script Lua para limitar el tráfico antes de tocar la base de datos, mientras que la base de datos sigue siendo la fuente de verdad. Puedes comenzar con la versión más simple e ir agregando capas según lo requiera la carga real.

system-designspring-bootrediskafkacase-study