高并发下如何防止商品超卖

284 阅读8分钟

面向:后端工程师、架构师、产品

在电商促销、秒杀、抢购场景中,超卖(oversell) 是最常见也最致命的问题之一:库存在同一时刻被多个请求同时扣减,导致实际销售量超过了真实库存,引发退款、差评、资金损失与品牌伤害。本文从原因、本质、常见解决方案、权衡与实践注意事项全面讲解,并给出代码示例与架构图方案,帮助你在 0 到 1 建立可靠的防超卖能力。


目录

  1. 问题概述与典型场景

  2. 超卖的根本原因与本质

  3. 常见解决方案(优缺点对比)

  4. 推荐架构(多种模式)

  5. 关键实现:代码样例

    • 数据库乐观扣减(SQL)
    • Redis Lua 原子扣减
    • 分布式锁(Redis)示例
    • 队列+异步扣减(幂等与补偿)示例
    • 幂等处理示例
  6. 实战注意事项与风险

  7. 测试方案与容量规划

  8. 总结与建议清单


1. 问题概述与典型场景

  • 秒杀/抢购活动:短时间内大量并发请求同时下单。
  • 库存 stock 以整数计量(售罄时为 0)。
  • 目标:保证库存不出现负值,且避免重复发货或超卖。

示例:100 件库存,1 秒内 10 万并发请求同时扣库存。如何确保最终成功订单 ≤ 100?


2. 超卖的根本原因与本质

本质:多个并发请求对共享资源(stock)的竞态访问导致状态更新冲突,而系统采用的更新手段不是原子的或缺乏全局序。

常见根因

  • 读取-判断-写入(read-modify-write)流程没有原子性;
  • 缓存与数据库之间未做一致性控制;
  • 多副本/分片环境下缺乏全局一致性;
  • 并发量远超数据库承载,出现超时与重试,重复扣减。

3. 常见解决方案(优缺点对比)

  1. 数据库事务 + 行级锁(悲观锁 SELECT ... FOR UPDATE)

    • 优点:简单、强一致性,数据库保证串行化。\
    • 缺点:吞吐受限、锁等待导致延迟高;在海量并发下会成为瓶颈。
  2. 数据库乐观锁(version / CAS / WHERE stock > 0)

    • 优点:无需长时间锁,适合读多写少。\
    • 缺点:冲突导致重试,重试风暴下性能下降。
  3. Redis 原子操作(DECR/ Lua 脚本)

    • 优点:高性能、延迟低,原子性好。\
    • 缺点:需要解决持久化与最终一致性(DB 写入);单点/主从故障考虑。
  4. 分布式锁(Redis/Etcd/Zookeeper/Redisson)

    • 优点:可做全局互斥,跨服务保证串行访问。\
    • 缺点:加锁限流导致吞吐下降;需谨慎处理锁失效与重入。
  5. 令牌/漏桶/信号量(限流)

    • 优点:在进入下单流程前对并发量做削峰,稳定后端系统。\
    • 缺点:需要合适容量配置;会拒绝请求。
  6. 队列/异步扣减(单线程消费或分片消费者)

    • 优点:将并发转换为可控的序列化写入,便于保证顺序与一致性。\
    • 缺点:增加延迟;需处理幂等与失败补偿。
  7. 预占库存(秒杀令牌)

    • 优点:请求先获取购买资格(令牌),减少实际数据库压力。\
    • 缺点:需要回收令牌逻辑,存在超卖风险需做好原子性。

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 幂等设计要点

  1. 每次请求带 idempotencyKey(客户端生成或网关生成)。
  2. 在订单落库处(或预写日志表)先 INSERT IGNORE 或使用唯一索引 UNIQUE(request_id)
  3. 消费者重试时,通过该唯一键判断是否已处理。

示例 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. 实战注意事项与风险

  1. 缓存一致性:如果库存同时放在 Redis 和 DB,需保证更新顺序(先 Redis,再 MQ -> DB),并处理异步失败的补偿(rollback/回写)。
  2. 失败补偿:生产环境中必须有补偿或重试流程(未写 DB 的 Redis 扣减需回补)。
  3. 重复请求与幂等:网络重试、超时重试会导致重复请求,幂等设计是必须的。
  4. 网络分区:分布式锁可能在网络抖动时导致误判(锁在不同节点不可见)。
  5. 容量与退避:高冲突场景要合理退避,避免数据库或 Redis 被重试风暴压垮。
  6. 安全超卖容忍度:明确业务是否可容忍微量超卖(例如 1%),或绝对不能超卖,不同策略选择不同权衡。
  7. 慢消费者/消息堆积:队列写入快速但消费者处理慢,会产生延迟与数据积压,需要监控并扩容消费者。

7. 测试方案与容量规划

  • 压测:用工具(wrk、JMeter、Locust)模拟峰值并发,验证系统 QPS、P99、错误率。
  • 场景测试:并发扣减、网络抖动、消费者宕机、DB 写失败并重启的恢复流程。
  • 容量计算:根据历史峰值、期望成功率、单次处理耗时估算所需 Redis/DB TPS 与消费者数量。

8. 总结与实践建议清单

  1. 秒杀类场景首选:Redis 原子扣减 + MQ 异步持久化 + 消费者幂等写 DB
  2. 需要强一致性且能接受延迟:队列串行化消费
  3. 并发不太高或希望简单实现:DB 乐观锁(UPDATE ... WHERE stock > 0)
  4. 不要把分布式锁当为万能解:锁会成为吞吐瓶颈。使用成熟客户端库(Redisson)并实现锁拥有者校验。
  5. 实施前做足够压测,设计失败补偿与回滚,监控库存、队列长度、消费者滞后、失败率。