闪购系统设计: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(最终数据源)
核心思想:
- Redis是闸门。库存扣减在内存中完成,单线程,所以原子且快速。
- Kafka是缓冲。只有约10000条成功消息进入队列。消费者按数据库速度写入。
- 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节的竞争测试开始。然后在接近真实流量的负载测试中运行,在第一次闪购前验证。