电商购物车与订单设计

1. 概述

购物车和订单是电商平台上客户最常用的两项功能。它们也最容易出现故障:库存超卖、重复扣款、秒杀时订单“卡壳”。

本文通过案例研究呈现一个完整的设计。设计包括需求分析、数据模型、API、处理流程和难点解决方案。每个难点都附带使用 Spring Boot 3、PostgreSQL、Redis 和 Kafka 的解决方案。读完后,你将拥有一份可直接应用或用于 system design 面试的完整框架。

2. 需求分析

2.1. 功能需求

  • 客户可在购物车中添加、编辑、删除商品(SKU)。购物车跨登录会话保留。
  • 结账时,系统重新计算价格和检查库存,然后创建处于 PENDING_PAYMENT 状态的订单。
  • 15 分钟内未支付的订单将被取消,商品退回库存。
  • 支付成功后,系统向仓储、邮件和积分系统发送事件。

2.2. 预期规模

  • 月活用户 1000 万。
  • 购物车操作:平均 2000 req/s,峰值 20000 req/s。
  • 下单:平均 200 单/s。秒杀时可达 5000 单/s,集中在几十个 SKU。

2.3. 约束条件

  • 不能超卖。这是硬性约束。
  • 不能创建重复订单。客户端由于超时或误双击而重试请求时也不行。
  • 订单创建后,事件必须被发送,即使 Kafka 暂时中断。
  • 购物车可以是“最终一致”的。订单和库存必须强一致。

最后这个约束决定了架构。购物车频繁读写且不够重要,放在 Redis 上。订单和库存需要事务,放在 PostgreSQL 上。

3. 数据模型

Redis 中的购物车为每个客户使用一个 Hash:

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

购物车只存储 SKU 和数量,不存储价格。价格随时可能变化,所以系统在结账时总是重新计算价格。

需要事务的数据放在 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
);

这个 schema 中有几个值得注意的点:

  • CHECK (available >= 0) 是最后一道防线。即使代码有 bug,数据库也能阻止负库存。
  • UNIQUE (customer_id, idempotency_key) 在数据层阻止创建重复订单。
  • order_items.unit_price 是下单时刻的价格快照。旧订单不会因商品价格变化而受影响。
  • 金额以最小单位(分)存储为 BIGINT。不要用 DOUBLE。

4. API 设计

GET    /api/cart                       -> 查看购物车(含当前价格)
PUT    /api/cart/items/{sku}           -> body {"quantity": 2},设置数量
DELETE /api/cart/items/{sku}           -> 从购物车删除 SKU
POST   /api/orders                     -> header Idempotency-Key,从购物车创建订单
GET    /api/orders/{id}                -> 查看订单状态

用绝对数量的 PUT 是幂等的。重复调用多次结果相同。若改成 POST /add 加 1 的设计,客户端每次重试都会多加一件。

POST /api/orders 本质上不是幂等的,所以客户端必须为每次“下单”生成一个 Idempotency-Key(UUID)。重试时,客户端发送相同的 key。

5. Redis 上的购物车

添加依赖:

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

Service 购物车:

@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("购物车已满 " + MAX_ITEMS + " 件商品");
        }
        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())));
    }
}

说明:

  • ops.put(key, sku, ...) 以 O(1) 的时间复杂度写入 Hash 中的一个字段。两个请求同时修改不同 SKU 不会相互覆盖。如果你把整个购物车存为一个 JSON 字符串,就会被覆盖。
  • expire 在每次写入时刷新。购物车在 30 天无操作后才会过期。
  • MAX_ITEMS 限制购物车大小,防止 bot 滥用导致 key 过大。

hasKey 和 size 两个命令不是原子的。两个并发请求可能突破 1-2 件的限制。对购物车来说,这种小偏差可以接受。但不要对库存应用这种“近似”做法。

注意: Redis 不是绝对持久的数据源。启用 AOF(appendfsync everysec)以限制丢失在约一秒钟以内。丢失一秒钟的购物车数据可以接受,但丢失订单不行。

6. 订单流程

POST /api/orders 的流程如下:

1. 从 Redis 读取购物车
2. 从 Catalog 获取当前价格
3. BEGIN transaction(PostgreSQL)
   3a. 用 idempotency_key 插入订单 -> 若冲突则返回旧订单
   3b. 逐个 SKU 扣减库存(conditional update)-> 缺货则 ROLLBACK
   3c. 插入 order_items(价格快照)
   3d. 插入 outbox_events "OrderCreated"
4. COMMIT
5. 删除购物车(尽力而为)
6. Relay 读取 outbox -> 发布到 Kafka

6.1. 条件更新防止超卖

常见的错误做法是读库存,在 Java 中比较,再写回。读和写之间,另一个线程可能已经买走货物。正确做法是把检查和扣减合并为一条 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;
    }
}

数据库在 UPDATE 期间锁定该行。第二个请求必须等待第一个请求完成,然后在新值上重新评估 available >= :qty 条件。如果缺货,updated == 0,方法返回 false。你不需要 SELECT ... FOR UPDATE 或版本列。

6.2. 幂等性和事务

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

关键点:

  • ON CONFLICT ... DO NOTHING RETURNING id 只在插入成功时返回 id。若 key 已存在,结果为空。此时服务读取旧订单并返回,客户端重试仍会获得相同订单。
  • OutOfStockException 是 RuntimeException,所以 @Transactional 会回滚全部:订单、之前扣减的 SKU 和 outbox。没有“半途”状态。
  • SKU 列表在扣减前被排序。两个订单若都包含 SKU A 和 B,都会先锁 A 再锁 B,从而避免死锁。
  • 若首个请求回滚,key 也一起回滚。客户端用相同 key 重试会从头开始,这正是所需行为。

6.3. 事务性 Outbox

若在事务内调用 kafkaTemplate.send(),会遇到两种错误之一。Kafka 发送成功但 DB 回滚产生“幽灵”事件。DB 提交但 Kafka 失败则事件丢失。Outbox 通过在同一事务内写入事件和订单,然后由 relay 推送事件到 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 仅在应用启用 @EnableScheduling 时运行。

  • FOR UPDATE SKIP LOCKED 允许多个 relay 实例并行运行而不会处理同一行。
  • 消息的 key 是 aggregateId。同一订单的所有事件进入同一分区并保持顺序。
  • .get() 等待 Kafka 确认后才标记 published。若 relay 中途崩溃,事件可能被重新发送。Outbox 保证 at-least-once,所以消费者必须幂等,比如保存已处理的 orderId。

6.4. 支付超时处理

定期任务取消超期订单并将商品退回库存:

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;

status = 'PENDING_PAYMENT' 条件解决与支付回调的竞争。回调也必须用 UPDATE ... WHERE status = 'PENDING_PAYMENT' 更改状态。谁先更新谁赢,另一方更新 0 行。若支付在订单被取消后到达,系统退款而非“复活”订单。

上述语句假设每个 SKU 在每个订单中只出现一次,这由主键 (order_id, sku) 保证。若一批取消有多个订单包含同一 SKU,PostgreSQL 对每一行 inventory 只应用一个 join。此时需要先用 GROUP BY sku 和 SUM(quantity) 聚合。

7. 流量激增:秒杀

当 5000 req/s 都争夺同一个 SKU 时,所有请求排队等待 inventory 中一行的锁。数据库瓶颈,延迟飙升并拖累其他 SKU。解决方案是在请求到达 DB 前用 Redis 上运行 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 原子性地执行 Lua 脚本。GET 和 DECRBY 之间没有命令插入。1000 个配额和 50000 个请求时,只有约 1000 个请求通过网关进入 OrderService。剩余 49000 个请求立即获得 HTTP 409”配额已满”,无需任何 DB 查询。

流程如下:

tryAcquire (Redis)  --false-->  409 已售罄
       |true
placeOrder(PostgreSQL,条件更新不变)
       |exception
release (Redis)

Redis 只是过滤器。真实源仍是 PostgreSQL 中的条件更新。若 Redis 数字偏差,如重启或 release 未运行,最坏结果是少卖几件而不是超卖。

其他辅助措施:

  • 按客户限速阻止 bot 灌水。
  • 队列: 将通过网关的请求推入 Kafka,消费者以 DB 承受的速率创建订单,客户端轮询 GET /api/orders/{id}。用延迟换稳定性。
  • 隔离热 SKU 到独立表或实例,避免影响普通订单。

8. 库存竞争测试

最关键的测试是证明系统在多线程并发购买时不会超卖。

@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) 禁用 @JdbcTest 的默认事务。否则 setUp 中插入的数据未提交,其他线程看不到。
  • 测试用 Java 21 的虚拟线程创建 20 个并发请求。
  • @JdbcTest 的默认嵌入数据库(H2)足够此测试。要测试 ON CONFLICT 和 SKIP LOCKED,用 Testcontainers 的真实 PostgreSQL。

预期结果:

success = 5, available = 0

若用“SELECT 后在 Java 中比较”的方式替换 UPDATE,此测试会随机失败,success 有时会达到 7 或 8。

9. 常见错误与何时不应使用

  • 在购物车中保存价格,结账时直接使用。 恶意用户可能保留旧价格或修改 payload。总是从服务器获取价格。
  • 添加到购物车时立即扣减库存。 用户经常放弃购物车,库存会被无用地“冻结”。只在结账时保留商品,且始终有时间限制。
  • 在 @Transactional 内调用 Kafka 或支付服务。 长事务会长期持有锁,同时仍无法保证一致性。使用 outbox。
  • 将 Redis 视为库存的真实源。 Redis 应仅作为过滤层。
  • 在服务间使用分布式事务(2PC/XA)。 这种方法复杂且缓慢。使用基于事件的 saga 和补偿步骤(取消订单、退货)。

注意: 若你的平台每分钟只有几十个订单,单个数据库加条件更新和幂等性 key 就够用了。Redis 网关、Kafka 队列和隔离热 SKU 只有在实测数据显示 DB 真正成为瓶颈时才值得添加。

10. 结论

本文按层次设计了购物车和订单流程。购物车在 Redis Hash 上因需要快速且接受最终一致性。订单和库存在 PostgreSQL 上因需要强一致性。

三个核心技术解决难点:

  • 条件更新防止超卖。
  • 幂等性 key 加 UNIQUE 约束阻止重复订单。
  • 事务性 outbox 保证事件不丢失。

秒杀时,Redis 网关运行 Lua 脚本在到达数据库前限流,数据库仍保持真实源的角色。你可以从最简单的版本开始,随着实际负载需求逐步添加各层。

system-designspring-bootrediskafkacase-study