Diseño de flash sale: vender 10.000 productos en 1 segundo sin sobrestock
1. Descripción general
Flash sale es un problema clásico de diseño de sistemas. Cientos de miles de personas hacen clic en “Comprar” exactamente al mismo tiempo. El inventario tiene solo unos miles de productos. El sistema debe soportar picos de carga y jamás vender más de lo disponible.
Este artículo analiza un caso de estudio concreto: vender 10.000 productos en alrededor de 1 segundo. Al terminar, sabrás cómo especificar requisitos, diseñar el modelo de datos y las APIs, realizar reservas atómicas con Redis Lua, escribir pedidos de forma asincrónica mediante Kafka y devolver el inventario cuando el cliente no paga.
2. Requisitos
2.1. Requisitos funcionales
- Cada lote de venta tiene uno o más productos; cada producto tiene una cantidad fija y un precio de venta.
- El usuario autenticado hace clic en “Comprar” y recibe uno de estos resultados: reserva exitosa, agotado o ya comprado.
- Cada usuario solo puede comprar 1 producto por lote.
- El pedido de reserva debe pagarse en 10 minutos. Si vence, se devuelve al inventario para que otros lo compren.
2.2. Escala
- Inventario: 10.000 productos.
- Participantes: alrededor de 500.000 personas, la mayoría hace clic en los primeros 1–2 segundos.
- Pico de carga estimado: 200.000–500.000 solicitudes por segundo al endpoint de compra.
Solo 10.000 de ellos tienen éxito. Más del 98% de las solicitudes serán “agotado”. Este es el detalle clave: el sistema debe rechazar rápido y barato.
2.3. Restricciones
- Sin sobrestock: el número de pedidos válidos nunca excede 10.000.
- Sin mucho inventario retenido: los productos reservados pero no pagados deben devolverse.
- El sistema de ventas regular no se ve afectado cuando se ejecuta el flash sale.
3. Arquitectura general
Una base de datos relacional única puede manejar miles de UPDATE por segundo en la misma fila. Ese número es mucho menor que cientos de miles. Por lo tanto, dividimos el flujo en múltiples capas, cada una filtrando carga para la siguiente:
Cliente ──► CDN (página de venta estática, cuenta regresiva)
│
▼
API Gateway (límite de velocidad por usuario/IP, bloquea bots)
│
▼
Servicio Flash Sale ──► Redis (Lua: verificar + decrementar stock atómicamente)
│ │ exitosas (~10.000 veces)
│ ▼
│ tema de Kafka "flash-sale-orders"
│ │
▼ ▼
202 Aceptado Consumidor de Pedidos ──► PostgreSQL (fuente única de verdad)
Ideas clave:
- Redis es la puerta de contención. La operación de decremento de inventario se ejecuta en memoria, de un solo hilo, por lo que es atómica y rápida.
- Kafka es el búfer. Solo alrededor de 10.000 mensajes exitosos entran en la cola. El consumidor escribe en la BD al ritmo de la BD.
- PostgreSQL es la red de seguridad. Las restricciones en la BD garantizan que no habrá sobrestock, incluso si Redis falla.
4. Modelo de datos
En PostgreSQL, necesitamos dos tablas:
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)
);
Dos restricciones importantes:
chk_not_oversoldimpide quesoldexcedaquantityen la capa BD.uq_item_usertanto aplica la regla “un producto por persona”, como hace que el consumidor sea idempotente cuando Kafka vuelve a entregar mensajes.
En Redis, cada producto de venta utiliza dos claves:
{item:1001}:stock -> "10000" (String, cantidad restante)
{item:1001}:buyers -> {userId, ...} (Set, usuarios que han reservado)
La parte {item:1001} es una etiqueta hash. Gracias a ella, ambas claves siempre están en el mismo slot en Redis Cluster. El script Lua solo puede ejecutarse cuando todas las claves están en el mismo slot.
Antes de abrir la venta, un trabajo precarga el inventario en 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"}
El endpoint de compra devuelve 202 en lugar de 201, porque el pedido no se escribe en la BD inmediatamente. El cliente retiene orderId y consulta el endpoint GET hasta que el pedido está listo para pagar. userId se obtiene del token, no del cuerpo de la solicitud, para que el usuario no pueda comprar por otra persona.
6. Reserva atómica con Redis Lua
Agregar dependencia:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
El error más común es escribir GET y luego DECR como dos comandos separados desde Java. Entre los dos comandos, cientos de otros hilos han tenido tiempo de leer el mismo valor 1 y decrementarlo. El resultado es un inventario negativo. El script Lua resuelve esto porque Redis ejecuta todo el script como un bloque, sin que otros comandos se intercalen.
Archivo src/main/resources/lua/reserve.lua:
-- KEYS[1] = inventario, KEYS[2] = compradores, 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
El script devuelve -2 si el usuario ya compró, -1 si está agotado; de lo contrario, la cantidad restante después del decremento. La verificación y decremento del inventario ocurren en un solo paso, por lo que no hay condición de carrera.
Script de devolución de inventario src/main/resources/lua/release.lua:
-- Incremente el inventario solo si el usuario realmente tiene una reserva
if redis.call('SREM', KEYS[2], ARGV[1]) == 1 then
return redis.call('INCR', KEYS[1])
end
return -1
La condición SREM == 1 evita devolver inventario dos veces para la misma persona. Este es un error común cuando el trabajo de vencimiento y el hilo de cancelación se ejecutan en paralelo.
El servicio llama al 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 devolvió 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 envía el script usando EVALSHA y automáticamente recurre a EVAL si Redis aún no tiene el script en caché. No necesitas gestionar el SHA manualmente.
7. Escritura asincrónica de pedidos mediante Kafka
Agregar dependencia:
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
7.1. Controlador: reservar y luego 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"));
}
};
}
}
Varios puntos a tener en cuenta:
- Una solicitud “agotado” solo cuesta una llamada a Redis y luego retorna. Este es el camino tomado por el 98% de las solicitudes.
- Si enviar a Kafka falla, llamamos a
releasepara devolver el artículo al inventario. Sin este paso de compensación, el inventario quedaría “retenido indefinidamente”. - La clave del mensaje es
itemId. Todos los pedidos del mismo producto van a la misma partición, por lo que se procesan en orden.
El productor debe habilitar acks=all e idempotencia para no perder mensajes:
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: la BD es la red de seguridad
@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; // mensaje repetido, ya procesado
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("Inventario de BD agotado para el artículo " + event.itemId());
}
}
}
ON CONFLICT DO NOTHING hace que el consumidor sea idempotente. Kafka garantiza entrega al menos una vez, por lo que el mismo mensaje puede llegar dos veces. La segunda vez no inserta ninguna fila y termina temprano.
La cláusula UPDATE ... WHERE sold < quantity es una condición atómica en la capa BD. Si Redis está desincronizado (por ejemplo, alguien cargó un número incorrecto manualmente), la instrucción devuelve 0. La transacción se revierte, el pedido no se crea. Debes configurar un controlador de errores que envíe mensajes con errores a un tema de letra muerta y devuelva inventario a Redis, en lugar de reintentar indefinidamente.
Como el consumidor solo recibe alrededor de 10.000 mensajes y escribe secuencialmente por partición, la BD maneja algunos miles de transacciones por segundo. Este nivel es completamente manejable.
8. Vencimiento de pago y devolución de inventario
Un trabajo periódico escanea pedidos vencidos:
@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 cambia el estado y obtiene la lista de pedidos en una sola declaración. Si dos instancias ejecutan el trabajo simultáneamente, cada fila se actualiza solo por una instancia, porque la condición status = 'PENDING' ya no es verdadera después de la primera vez.
Nota el orden: la liberación de Redis está dentro de la transacción BD pero no es parte de ella. Si la transacción se revierte después de la liberación, Redis tendrá un exceso de una unidad. En la práctica, debes separar la liberación después del commit, por ejemplo usando TransactionSynchronization.afterCommit. También podrías publicar un evento “OrderExpired” a través de Kafka. La versión simplificada aquí es solo para ilustrar.
9. Prueba: nunca vender con sobrestock
El comportamiento más importante para probar es: cuando 1.000 personas compiten por 100 productos, exactamente 100 tienen éxito. Usamos Testcontainers para ejecutar 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);
}
}
El bloque try-with-resources con el ejecutor de hilos virtuales espera a que todas las tareas terminen antes de continuar. Resultado esperado:
successes = 100
stock = "0"
Si reemplazas el script Lua con GET y luego DECR por separado, esta prueba fallaría de manera inestable, y el stock a menudo caería en negativo.
10. Puntos difíciles y errores comunes
Clave activa en Redis. Cada solicitud golpea la misma clave {item:1001}:stock. Un nodo Redis maneja alrededor de 100.000 comandos por segundo. Con cargas más altas, hay dos enfoques:
- Sharding de inventario: divide 10.000 en 10 claves
{item:1001:0}…{item:1001:9}, cada clave con 1.000, ubicadas en nodos diferentes. La solicitud elige una fragmentación aleatoria; si está agotada, prueba otra fragmentación. La regla “un producto por persona” entonces requiere enrutar al usuario a un fragmento fijo segúnuserId % 10, o verificar en la capa BD. - Indicador de agotado local: cuando Redis reporta
SOLD_OUT, cada instancia activa unAtomicBooleanen memoria y rechaza inmediatamente sin llamar a Redis nuevamente.
Picos de carga antes incluso de Redis. El límite de velocidad en la puerta, captcha o “cola virtual” ayuda a filtrar la mayoría de los bots. La página de venta debe ser una página estática en CDN. El botón “Comprar” solo se activa cuando llega la hora, y la URL de compra solo se devuelve entonces para reducir los clics anticipados por script.
Pérdida de datos en Redis. Si Redis realiza conmutación por error y la réplica no ha recibido el comando DECR final, el inventario en Redis puede ser más alto de lo real. En este punto, la condición sold < quantity en la BD hace su trabajo. Nunca omitas esta red de seguridad.
Consistencia eventual. El usuario recibe 202 pero el pedido aún no está en la BD inmediatamente. La interfaz de usuario debe mostrar el estado “procesando” y hacer polling, no reportar “compra exitosa” inmediatamente.
Nota: No uses
SELECT ... FOR UPDATEen la fila de inventario como mecanismo principal para flash sale. Es correcto desde el punto de vista de la consistencia, pero cada transacción se encola esperando el mismo bloqueo de fila. El rendimiento cae a algunos cientos a algunos miles de solicitudes por segundo, el grupo de conexiones se agota y cae todo el sistema de ventas regular.
Nota: La arquitectura Redis + Kafka solo vale la pena cuando la carga es realmente grande. Si tu venta solo tiene algunos cientos de solicitudes por segundo, una sola declaración
UPDATE ... WHERE sold < quantityen PostgreSQL es suficiente. Es mucho más simple y no hay problemas de sincronización entre dos almacenes de datos.
11. Conclusión
El diseño de flash sale gira en torno a un principio: filtrar la carga en cada capa y mantener una fuente única de verdad con restricciones estrictas. En este artículo, has visto:
- Los scripts Redis Lua verifican y decrementan el inventario atómicamente, rechazando “agotado” en un solo viaje redondo.
- Kafka actúa como búfer para que la BD reciba solo el número exacto de pedidos exitosos.
- PostgreSQL con
UNIQUE,CHECKyUPDATE ... WHERE sold < quantityactúa como una red de seguridad contra sobrestock y maneja mensajes duplicados. - Mecanismo de compensación cuando el envío a Kafka falla o el pedido vence en el pago, con protección contra devoluciones dobles.
Al implementar en la práctica, comienza con pruebas de contención como en la sección 9. Luego ejecuta pruebas de carga con números cercanos al pico real, antes de que se abra la primera venta.