电商购物车与订单设计
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 脚本在到达数据库前限流,数据库仍保持真实源的角色。你可以从最简单的版本开始,随着实际负载需求逐步添加各层。