Flash sale design: selling 10,000 products in 1 second without overselling

1. Overview

A flash sale is a classic system design problem. Hundreds of thousands of people click “Buy” at exactly the same moment. Inventory is only a few thousand units. The system must handle sudden traffic spikes and absolutely never oversell.

This article walks through a concrete case study: selling 10,000 products within 1 second. After reading, you’ll know how to specify requirements, design your data model and API, keep stock atomic using Redis Lua, write orders asynchronously via Kafka, and recover inventory when customers don’t pay.

2. Requirements

2.1. Functional requirements

  • Each sale has one or more products; each product has a fixed quantity and sale price.
  • A logged-in user clicks “Buy” and receives a result: reservation successful, sold out, or already purchased.
  • Each user can buy only 1 product per sale.
  • A reservation must be paid within 10 minutes. After that, the item returns to inventory so others can buy it.

2.2. Scale

  • Inventory: 10,000 products.
  • Participants: roughly 500,000 people, most clicking in the first 1–2 seconds.
  • Estimated peak load: 200,000–500,000 requests/second to the purchase endpoint.

Only 10,000 of those will succeed. Over 98% will get “sold out”. This is the critical detail: the system must reject requests cheaply and quickly.

2.3. Constraints

  • No overselling: the number of valid orders never exceeds 10,000.
  • Don’t leave too much unrecovered: reserved inventory that isn’t paid must be returned to stock.
  • The normal sales system should not be affected while flash sale runs.

3. Overall architecture

A single relational database can handle a few thousand UPDATE statements per second on the same row. That’s nowhere near hundreds of thousands. So we split the flow into multiple layers, each filtering load for the next:

Client ──► CDN (static sale page, countdown)
  │
  ▼
API Gateway (rate limit by user/IP, block bots)
  │
  ▼
Flash Sale Service ──► Redis (Lua: check + decrement stock atomically)
  │                         │ succeeds (~10,000 times)
  │                         ▼
  │                    Kafka topic "flash-sale-orders"
  │                         │
  ▼                         ▼
202 Accepted          Order Consumer ──► PostgreSQL (source of truth)

Main ideas:

  1. Redis is the gatekeeper. Stock decrement runs in memory, single-threaded, so it’s atomic and fast.
  2. Kafka is the buffer. Only ~10,000 successful messages enter the queue. The consumer writes to DB at the database’s speed.
  3. PostgreSQL is the safety net. Constraints in the database guarantee no overselling, even if Redis fails.

4. Data model

In PostgreSQL, we need two tables:

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)
);

Two important constraints:

  • chk_not_oversold prevents sold from exceeding quantity at the database layer.
  • uq_item_user both enforces the “one product per user” rule and makes the consumer idempotent when Kafka redelivers a message.

In Redis, each product uses two keys:

{item:1001}:stock    -> "10000"          (String, remaining quantity)
{item:1001}:buyers   -> {userId, ...}    (Set, users who reserved)

The {item:1001} part is a hash tag. Thanks to it, both keys always land on the same slot in a Redis Cluster. A Lua script can only run when all its keys are on the same slot.

Before the sale opens, a job preloads the inventory into 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"}

The purchase endpoint returns 202 rather than 201, because the order isn’t written to the database immediately. The client keeps the orderId and polls the GET endpoint until the order is ready for payment. The userId comes from the token, not the request body, so a user cannot buy on behalf of someone else.

6. Atomic reservation with Redis Lua

Add the dependency:

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

The most common mistake is writing GET and then DECR as two separate commands from Java. Between the two, hundreds of other threads read the same value (say, 1) and decrement it. The result is negative inventory. Lua script solves this because Redis executes the entire script as one atomic block—no other commands can run in between.

File 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

The script returns -2 if the user already purchased, -1 if sold out, otherwise the remaining quantity after decrement. Check and decrement happen in one step, so there’s no race condition.

The release script src/main/resources/lua/release.lua:

-- Only increment stock if user actually holds a reservation
if redis.call('SREM', KEYS[2], ARGV[1]) == 1 then
  return redis.call('INCR', KEYS[1])
end
return -1

The condition SREM == 1 prevents returning the same item twice for one user. This is a common bug when the expiry job and cancellation thread run in parallel.

The service calls the 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 sends the script via EVALSHA and automatically falls back to EVAL if Redis hasn’t cached the script yet. You don’t need to manage the SHA yourself.

7. Asynchronous order writing via Kafka

Add the dependency:

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

7.1. Controller: reserve then publish event

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"));
            }
        };
    }
}

A few points to note:

  • A “sold out” request costs just one Redis call and then returns. This is the path taken by 98% of requests.
  • If sending to Kafka fails, we call release to return the item to inventory. Without this compensation, inventory gets stuck “in limbo” forever.
  • The message key is the itemId. All orders for the same product go to the same partition, so they’re processed in order.

The producer should enable acks=all and idempotence to avoid losing messages:

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. Consumer: database is the safety net

@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; // message redelivered, already handled

        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("DB stock exhausted for item " + event.itemId());
        }
    }
}

ON CONFLICT DO NOTHING makes the consumer idempotent. Kafka guarantees at-least-once delivery, so the same message can arrive twice. The second time, no row is inserted and the handler exits early.

The UPDATE ... WHERE sold < quantity clause is an atomic condition at the database layer. If Redis gets out of sync (for example, if someone manually loads the wrong number), the statement returns 0 rows. The transaction rolls back and the order is not created. You should configure an error handler to push bad messages to a dead-letter topic and return the inventory in Redis, instead of retrying forever.

Since the consumer only receives ~10,000 messages and writes sequentially per partition, the database handles a few thousand transactions per second. This load is completely normal.

8. Payment expiry and stock recovery

A scheduled job scans for expired orders:

@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 changes the status and retrieves the list in one statement. If two instances run the job simultaneously, each row is only updated by one instance, because the condition status = 'PENDING' no longer holds after the first update.

Note the order: releasing to Redis happens inside the transaction but isn’t part of it. If the transaction rolls back after release, Redis ends up with an extra unit. In practice, you should separate release to run after commit, for example using TransactionSynchronization.afterCommit. Alternatively, you could emit an “OrderExpired” event via Kafka. This shortened version is just for illustration.

9. Testing: never oversell

The most important behavior to test is: 1,000 people compete for 100 products, and exactly 100 succeed. We use Testcontainers to run a real Redis instance.

<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);
    }
}

The try-with-resources block with virtual thread executor waits for all tasks to finish before proceeding. The expected results are:

successes = 100
stock     = "0"

If you replace the Lua script with separate GET and DECR commands, this test will fail inconsistently, and stock often goes negative.

10. Hard points and common pitfalls

Hot key on Redis. Every request hits the same key {item:1001}:stock. One Redis node handles ~100,000 commands/second. At higher loads, there are two approaches:

  • Shard the inventory: split 10,000 into 10 keys like {item:1001:0} … {item:1001:9}, each with 1,000 units, landing on different nodes. A request picks a shard at random and tries others if sold out. In this case, the “one product per user” rule needs to either route users to a fixed shard by userId % 10 or check at the database layer.
  • Local sold-out flag: when Redis returns SOLD_OUT, each instance sets an AtomicBoolean in memory and refuses further requests without calling Redis.

Load spike before Redis. Rate limiting at the gateway, CAPTCHA, or “virtual queueing” cuts most bots. The sale page should be static on a CDN. The “Buy” button should only activate when the sale time arrives, and the purchase URL should only be returned at that time to prevent early clicking via scripts.

Redis data loss. If Redis fails over and the replica hasn’t received the final DECR command, inventory on Redis might be higher than actual. Then the sold < quantity condition in the database kicks in. Never skip this safety net.

Eventual consistency. The user gets 202 but the order isn’t in the database yet. The UI must show “processing” and poll, not report “purchase successful” immediately.

Note: Don’t use SELECT ... FOR UPDATE on the inventory row as the main flash sale mechanism. It’s correct for consistency, but every transaction queues waiting on the same row lock. Throughput drops to a few hundred or thousand requests/second, the connection pool drains, and the normal sales system crashes too.

Note: The Redis + Kafka architecture is only worth it under truly high load. If your sale is just hundreds of requests/second, a single UPDATE ... WHERE sold < quantity in PostgreSQL is enough. It’s much simpler and has no synchronization issues between two datastores.

11. Conclusion

Flash sale design revolves around one principle: filter load at each layer and keep a single source of truth with tight constraints. In this article, you’ve learned:

  • Redis Lua script checks and decrements stock atomically, rejecting “sold out” in just one round-trip.
  • Kafka buffers so the database only receives the exact successful orders.
  • PostgreSQL with UNIQUE, CHECK, and UPDATE ... WHERE sold < quantity acts as a safety net against overselling and handles duplicate messages.
  • Compensation mechanisms when Kafka send fails or an order expires, with guards against double returns.

When applying this in production, start with the contention test from section 9. Then run a load test with numbers close to your real peak load before the first flash sale goes live.

system-designrediskafkaspring-bootconcurrency