闪购系统设计:1秒售出10000件不超卖

1. 概览

闪购是经典的系统设计问题。成百上千用户在同一时刻点击“购买”。库存只有几千件商品。系统必须承受突发流量,且绝对不能超卖。

本文介绍一个具体的案例:1秒内售出10000件。读完后,你会知道如何提出需求、设计数据模型和API、用Redis Lua实现原子预留、通过Kafka异步落单、以及在客户未付款时自动还货。

2. 需求

2.1. 功能需求

  • 每次闪购可包含一件或多件商品,每件商品有固定库存和活动价格。
  • 已登录用户点击“购买”后,获得结果:预留成功、已售罄或已购买过。
  • 每个用户每次闪购只能购买1件商品。
  • 预留订单需在10分钟内完成支付。超时则自动还货回库,供他人购买。

2.2. 规模

  • 库存:10000件商品。
  • 参与用户:约50万人,大多在开卖后1-2秒内下单。
  • 峰值流量估算:20万-50万 请求/秒。

其中只有10000个请求成功。超过98%的请求会返回“已售罄”。这是关键点:系统必须快速、廉价地拒绝请求。

2.3. 约束

  • 不能超卖:有效订单数永不超过10000。
  • 不能亏货:预留但未支付的商品必须能退回。
  • 不影响日常业务:闪购进行时,平时的销售系统不能受影响。

3. 整体架构

单个关系数据库能承受每秒几千次对同一行的 UPDATE 操作。这远不足够几十万的并发。因此我们分成多层,每层都为下一层过滤掉部分流量:

客户端 ──► CDN(静态销售页、倒计时)
  │
  ▼
API网关(按用户/IP限流、拦截机器人)
  │
  ▼
闪购服务 ──► Redis(Lua脚本:检查+扣库原子操作)
  │                    │ 仅成功约10000次
  │                    ▼
  │               Kafka主题"flash-sale-orders"
  │                    │
  ▼                    ▼
202 Accepted    订单消费者 ──► PostgreSQL(最终数据源)

核心思想:

  1. Redis是闸门。库存扣减在内存中完成,单线程,所以原子且快速。
  2. Kafka是缓冲。只有约10000条成功消息进入队列。消费者按数据库速度写入。
  3. PostgreSQL是防线。约束条件保证即使Redis出故障,也不会超卖。

4. 数据模型

PostgreSQL中需要两个表:

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

两个关键约束:

  • chk_not_oversold 防止 sold 超过 quantity。
  • uq_item_user 既实现了“每人1件”规则,也让消费者在Kafka重复推送消息时保持幂等。

Redis中,每件闪购商品用两个键:

{item:1001}:stock    -> "10000"          (String,剩余库存)
{item:1001}:buyers   -> {userId, ...}    (Set,已预留人员)

{item:1001} 部分是哈希标签。多亏有它,两个键总是落在Redis Cluster的同一slot。Lua脚本只在所有键位于同一slot时才能执行。

开卖前,一个任务预加载库存到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"}

购买端点返回 202 而非 201,因为订单还未立即写入数据库。客户端保留 orderId,轮询 GET 端点直到订单可支付。userId 从token中提取,不从请求体中读取,防止用户代他人购买。

6. 用Redis Lua实现原子预留

添加依赖:

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

最常见的错误是分开执行 GET 和 DECR 两条语句。在这两条语句之间,数百个线程已经读到相同的值“1”并同时扣减。结果库存变成负数。Lua脚本解决了这个问题,因为Redis以原子方式执行整个脚本,不会有其他命令插入其中。

文件 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

脚本返回 -2 如果用户已购买,-1 如果已售罄,否则返回扣减后的库存。检查和扣减在同一步内完成,所以没有竞态条件。

还货脚本 src/main/resources/lua/release.lua:

-- 仅在用户确实在预留状态时才增加库存
if redis.call('SREM', KEYS[2], ARGV[1]) == 1 then
  return redis.call('INCR', KEYS[1])
end
return -1

条件 SREM == 1 防止对同一人重复还货。这是并发环境下的常见bug,当过期任务和取消线程同时执行时会发生。

Service调用脚本:

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用 EVALSHA 发送脚本,若Redis尚未缓存则自动回退到 EVAL。你不需手动管理SHA。

7. 通过Kafka异步写单

添加依赖:

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

7.1. Controller:预留后发布事件

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

几点需注意:

  • “已售罄”请求仅执行一次Redis调用后返回。这是98%请求的代码路径。
  • 若Kafka发送失败,我们调用 release 还货。没有这个补偿,货会被“锁定”永远。
  • 消息的key是 itemId。同一商品的所有订单进入同一分区,按顺序处理。

生产者应启用 acks=all 和幂等性以避免丢失消息:

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. 消费者:DB作为防线

@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; // 消息重复,已处理过

        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 让消费者幂等。Kafka保证至少投递一次,所以同一消息可能到达两次。第二次时无法插入任何行并提前返回。

UPDATE ... WHERE sold < quantity 是数据库层的原子条件。若Redis数据有偏差(比如有人手工改了库存),这条语句返回 0。事务回滚,订单不被创建。你应配置一个错误处理器,把有问题的消息推送到死信主题并还货到Redis,而不是无限重试。

由于消费者只处理约10000条消息,且按分区顺序写入,数据库承受每秒数千笔事务。这是正常水平。

8. 支付超期和还货

一个定期任务扫描过期订单:

@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 在一条语句内改变状态并获取列表。若两个实例同时运行任务,每行仅被一个实例更新,因为条件 status = 'PENDING' 在第一次后不再成立。

注意顺序:还货到Redis位于DB事务内但不属于该事务。若事务提交后再还货前发生回滚,Redis会多出一单位。实际项目中,应在事务提交后再还货,比如用 TransactionSynchronization.afterCommit。或者发布“OrderExpired”事件通过Kafka。本文的简化版本只为演示。

9. 测试:永不超卖

最重要的行为测试:1000人争抢100件商品时,恰好100人成功。我们用Testcontainers运行真实的Redis。

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

带virtual thread的executor的try-with-resources块等待所有任务完成后才继续。预期结果:

successes = 100
stock     = "0"

若改用 GET 后 DECR 两条独立语句,这个测试会不稳定失败,库存常常变成负数。

10. 难点和常见错误

Redis上的热键。每个请求都打到同一个键 {item:1001}:stock。一个Redis节点可处理约10万命令/秒。在更高流量下,有两种应对方式:

  • 库存分片:将10000分成10个键 {item:1001:0} … {item:1001:9},各1000件,落在不同节点。请求随机选择分片,没货再试其他分片。此时“每人1件”规则需按 userId % 10 将用户固定到一个分片,或在DB层检查。
  • 本地售罄标记:当Redis报 SOLD_OUT,每个实例在内存中置一个 AtomicBoolean,之后直接拒绝而不再调用Redis。

网关之前的流量风暴。限流、验证码或“虚拟排队”帮助切断大部分机器人。销售页应是CDN上的静态页面。“购买”按钮仅在时间到时启用,URL仅在那时返回,减少脚本预点击。

Redis数据丢失。Redis故障转移时,若副本尚未收到最后的 DECR,Redis的库存可能高于实际。此时数据库的 sold < quantity 条件起作用。永远不要去掉这道防线。

最终一致性。用户收到 202 但订单还未在数据库中。UI应显示“处理中”状态并轮询,不能立即报“购买成功”。

注意:不要用 SELECT ... FOR UPDATE 锁定库存行作为闪购的主要机制。这虽然从一致性角度正确,但所有事务会排队等待同一个行锁。吞吐量跌至每秒几百到几千请求,连接池耗尽,甚至拖累平时的销售系统崩溃。

注意:Redis + Kafka架构仅在流量确实很大时才值得用。若你的闪购仅有每秒几百请求,PostgreSQL中一条 UPDATE ... WHERE sold < quantity 就足够了。它简单得多,没有双份数据源的同步问题。

11. 总结

闪购系统设计围绕一个原则:按层过滤流量,维护一个带紧约束的单一源。本文展示了:

  • Redis Lua脚本原子地检查和扣库,“已售罄”请求在一个往返内被拒。
  • Kafka作为缓冲,DB仅收到恰好成功的订单数。
  • PostgreSQL通过 UNIQUE、CHECK 和 UPDATE ... WHERE sold < quantity 作为最后防线,防超卖并处理消息重复。
  • Kafka失败或订单超期支付时的补偿机制,包括防止重复还货的检查。

实际应用时,从第9节的竞争测试开始。然后在接近真实流量的负载测试中运行,在第一次闪购前验证。

system-designrediskafkaspring-bootconcurrency