面向:后端工程师、架构师、产品
在电商促销、秒杀、抢购场景中,超卖(oversell) 是最常见也最致命的问题之一:库存在同一时刻被多个请求同时扣减,导致实际销售量超过了真实库存,引发退款、差评、资金损失与品牌伤害。本文从原因、本质、常见解决方案、权衡与实践注意事项全面讲解,并给出代码示例与架构图方案,帮助你在 0 到 1 建立可靠的防超卖能力。
目录
-
问题概述与典型场景
-
超卖的根本原因与本质
-
常见解决方案(优缺点对比)
-
推荐架构(多种模式)
-
关键实现:代码样例
- 数据库乐观扣减(SQL)
- Redis Lua 原子扣减
- 分布式锁(Redis)示例
- 队列+异步扣减(幂等与补偿)示例
- 幂等处理示例
-
实战注意事项与风险
-
测试方案与容量规划
-
总结与建议清单
1. 问题概述与典型场景
- 秒杀/抢购活动:短时间内大量并发请求同时下单。
- 库存
stock以整数计量(售罄时为 0)。 - 目标:保证库存不出现负值,且避免重复发货或超卖。
示例:100 件库存,1 秒内 10 万并发请求同时扣库存。如何确保最终成功订单 ≤ 100?
2. 超卖的根本原因与本质
本质:多个并发请求对共享资源(stock)的竞态访问导致状态更新冲突,而系统采用的更新手段不是原子的或缺乏全局序。
常见根因:
- 读取-判断-写入(read-modify-write)流程没有原子性;
- 缓存与数据库之间未做一致性控制;
- 多副本/分片环境下缺乏全局一致性;
- 并发量远超数据库承载,出现超时与重试,重复扣减。
3. 常见解决方案(优缺点对比)
-
数据库事务 + 行级锁(悲观锁 SELECT ... FOR UPDATE)
- 优点:简单、强一致性,数据库保证串行化。\
- 缺点:吞吐受限、锁等待导致延迟高;在海量并发下会成为瓶颈。
-
数据库乐观锁(version / CAS / WHERE stock > 0)
- 优点:无需长时间锁,适合读多写少。\
- 缺点:冲突导致重试,重试风暴下性能下降。
-
Redis 原子操作(DECR/ Lua 脚本)
- 优点:高性能、延迟低,原子性好。\
- 缺点:需要解决持久化与最终一致性(DB 写入);单点/主从故障考虑。
-
分布式锁(Redis/Etcd/Zookeeper/Redisson)
- 优点:可做全局互斥,跨服务保证串行访问。\
- 缺点:加锁限流导致吞吐下降;需谨慎处理锁失效与重入。
-
令牌/漏桶/信号量(限流)
- 优点:在进入下单流程前对并发量做削峰,稳定后端系统。\
- 缺点:需要合适容量配置;会拒绝请求。
-
队列/异步扣减(单线程消费或分片消费者)
- 优点:将并发转换为可控的序列化写入,便于保证顺序与一致性。\
- 缺点:增加延迟;需处理幂等与失败补偿。
-
预占库存(秒杀令牌)
- 优点:请求先获取购买资格(令牌),减少实际数据库压力。\
- 缺点:需要回收令牌逻辑,存在超卖风险需做好原子性。
4. 推荐架构(多种模式)
下面给出三种常见可行架构,分别适用于不同规模与 SLA 要求:
架构 A:Redis 原子库存 + 异步持久化(高 TPS、低延迟)
客户端 --> API 网关 --> 业务服务(检查限流) --> Redis(库存做 DECR / Lua) --> 下单消息写入队列 --> 订单服务消费队列写 DB--> 若 Redis 库存不足,直接返回售罄
说明:Redis 负责实时库存扣减(原子),消息队列用于把成功的下单请求落盘到 DB。DB 写入由消费者按序执行并做幂等检查。
适用场景:超高并发秒杀,允许最终一致性(允许短时延迟写入 DB)。
架构 B:队列串行化 + 单点扣减(强一致性,能完全避免超卖)
客户端 --> API 网关 --> 入队(Kafka / RocketMQ) --> 订单消费者(单线程或并发分片,各自串行处理) --> DB 原子扣减(事务)
说明:通过队列把并发请求转化为可控的串行消费(可分片到多个商品或 hash 分区),消费者每条消息做幂等与事务扣减写入。避免并发冲突。
适用场景:需要强一致性、可以接受排队延迟的场景。
架构 C:数据库乐观 + 本地回退(简单、成本低)
客户端 --> API --> 读取库存
--> 尝试 SQL: UPDATE product SET stock = stock - 1 WHERE id = ? AND stock > 0; (返回 affectedRows)
if affectedRows == 1 -> 成功并写订单
else -> 售罄
说明:最简单方案,适合并发量中等的业务。
5. 关键实现:代码样例
下面给出若干关键代码片段,帮助快速落地。
5.1 数据库乐观扣减(SQL + Java JDBC 示例)
-- 假设有 product(id, stock, version)
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = ? AND stock > 0 AND version = ?;
// Java 示例(JDBC)
int tryDecrStock(Connection conn, long productId, int expectedVersion) throws SQLException {
String sql = "UPDATE product SET stock = stock - 1, version = version + 1 WHERE id = ? AND stock > 0 AND version = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, productId);
ps.setInt(2, expectedVersion);
return ps.executeUpdate(); // 返回受影响行数,1 表示成功
}
}
要点:读取 version 后重试机制、限重试次数、适当退避(exponential backoff)。
5.2 Redis Lua 原子扣减(最推荐的高并发场景)
优点:原子、延迟低、并发友好。建议把库存在 Redis 中维护为
key = stock:productId。
-- stock_decr.lua
local key = KEYS[1]
local num = tonumber(ARGV[1])
local cur = tonumber(redis.call('GET', key) or '-1')
if cur == -1 then
return -2 -- not exist
end
if cur < num then
return -1 -- insufficient
end
cur = cur - num
redis.call('SET', key, cur)
return cur
Java 调用示例(Jedis):
String script = // 上面 lua 内容
Object res = jedis.eval(script, Collections.singletonList("stock:" + productId), Collections.singletonList("1"));
long newStock = (Long) res; // -1 表示库存不足
后续:若 Lua 返回成功,应把下单消息写入 MQ,消费者最终写 DB 并做幂等校验。
5.3 Redis 分布式锁(SET NX + 过期 + 验证)
仅在无法使用 Redis 原子扣减或需要复杂临界区操作时使用。不推荐把锁作为主方法处理所有秒杀请求(会降低吞吐)。
// 使用 Jedis 简单示例(伪代码)
String lockKey = "lock:product:" + productId;
String token = UUID.randomUUID().toString();
String ok = jedis.set(lockKey, token, "NX", "PX", 3000); // 3s
if ("OK".equals(ok)) {
try {
// 读取库存、扣减、写DB
} finally {
// 释放锁:需要保证只由持有者释放
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(token));
}
} else {
// 获取锁失败 -> 返回限流或重试
}
注意:分布式锁需考虑锁超时(延长)、客户端宕机、可靠性(建议使用成熟库如 Redisson)。
5.4 队列 + 异步扣减(Kafka / RocketMQ / RabbitMQ)
入队(API 层) :
- 先做速率限制与简单校验。若校验通过,入队并返回“已排队”或“抢购成功(先占位)”。
- 入队时可先在 Redis 做乐观预占(可选)。
消费者(单线程 or 分片)伪代码:
// 消费者伪代码
while (true) {
Message msg = mq.poll();
if (msg == null) continue;
if (alreadyProcessed(msg.getRequestId())) continue; // 幂等
boolean ok = txDoOrder(msg); // DB 事务:检查库存、写订单、扣库存
if (!ok) {
// 写回失败日志或补偿队列
}
}
幂等:消费者端必须实现基于 requestId 的幂等检查(在 DB 写订单前判断该 requestId 是否已处理)。
5.5 幂等设计要点
- 每次请求带
idempotencyKey(客户端生成或网关生成)。 - 在订单落库处(或预写日志表)先
INSERT IGNORE或使用唯一索引UNIQUE(request_id)。 - 消费者重试时,通过该唯一键判断是否已处理。
示例 SQL:
CREATE TABLE order_request_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
request_id VARCHAR(64) NOT NULL UNIQUE,
product_id BIGINT,
status TINYINT,
created_at DATETIME
);
-- 插入作为幂等锁
INSERT INTO order_request_log(request_id, product_id, status, created_at) VALUES(?, ?, 0, NOW());
-- 若插入失败(duplicate key),说明已处理或正在处理
6. 实战注意事项与风险
- 缓存一致性:如果库存同时放在 Redis 和 DB,需保证更新顺序(先 Redis,再 MQ -> DB),并处理异步失败的补偿(rollback/回写)。
- 失败补偿:生产环境中必须有补偿或重试流程(未写 DB 的 Redis 扣减需回补)。
- 重复请求与幂等:网络重试、超时重试会导致重复请求,幂等设计是必须的。
- 网络分区:分布式锁可能在网络抖动时导致误判(锁在不同节点不可见)。
- 容量与退避:高冲突场景要合理退避,避免数据库或 Redis 被重试风暴压垮。
- 安全超卖容忍度:明确业务是否可容忍微量超卖(例如 1%),或绝对不能超卖,不同策略选择不同权衡。
- 慢消费者/消息堆积:队列写入快速但消费者处理慢,会产生延迟与数据积压,需要监控并扩容消费者。
7. 测试方案与容量规划
- 压测:用工具(wrk、JMeter、Locust)模拟峰值并发,验证系统 QPS、P99、错误率。
- 场景测试:并发扣减、网络抖动、消费者宕机、DB 写失败并重启的恢复流程。
- 容量计算:根据历史峰值、期望成功率、单次处理耗时估算所需 Redis/DB TPS 与消费者数量。
8. 总结与实践建议清单
- 秒杀类场景首选:Redis 原子扣减 + MQ 异步持久化 + 消费者幂等写 DB。
- 需要强一致性且能接受延迟:队列串行化消费。
- 并发不太高或希望简单实现:DB 乐观锁(UPDATE ... WHERE stock > 0) 。
- 不要把分布式锁当为万能解:锁会成为吞吐瓶颈。使用成熟客户端库(Redisson)并实现锁拥有者校验。
- 实施前做足够压测,设计失败补偿与回滚,监控库存、队列长度、消费者滞后、失败率。