HOME> 历届世界杯决赛> 亿级秒杀,高并发秒杀下单,超卖问题+少卖问题,如何解决?

亿级秒杀,高并发秒杀下单,超卖问题+少卖问题,如何解决?

2025-09-08 02:11:10

一:超卖少买问题描述

在电商系统中买商品过程,先加入购物车,然后选中商品,点击结算,即会进入待支付状态,后续支付。

一般电子商务网站都会遇到如团购、秒杀、特价之类的活动,而这样的活动有一个共同的特点就是访问量激增、上千甚至上万人抢购一个商品。

场景一:买家需要购买数量可以多件 场景二:秒杀活动,到时间点只能购买一件

然而,作为活动商品,库存肯定是很有限的,如何控制库存不让出现超卖 ,以防止造成不必要的损失是众多电子商务网站程序员头疼的问题,这同时也是最基本的问题。

在秒杀系统设计中,超卖是一个经典、常见的问题,任何商品都会有数量上限,如何避免成功下订单买到商品的人数不超过商品数量的上限,这是每个抢购活动都要面临的难点。

在多个用户同时发起对同一个商品的下单请求时,先查询商品库存,再修改商品库存,会出现资源竞争问题,导致库存的最终结果出现异常。

问题:

当商品A一共有库存15件,用户甲先下单10件,用户乙下单8件,这时候库存只能满足一个人下单成功,如果两个人同时提交,就出现了超卖的问题。

二:高并发秒杀下单 + 交付 流程的 4个阶段

秒杀下单 的四个阶段:

第一阶段 扣库预扣

用户选中秒杀商品点击“抢购”, 后端收到下单请求, 使用redis lua 脚本进行redis 库存的预扣 ,保障库存预扣的原子性,下一个用户看到库存量已经被减少1个,

扣减库存的操作,使用redis 分布式锁保障幂等性,防止 用户同一秒触发重复“抢购”,

下单请求,发送消息队列,进入第二阶段

第二阶段 库存扣减

使用redis 分布式锁 保障 订单操作的幂等性,防止 同一个请求重复下单,

使用本地消息表分布式事务,解决订单和库存的数据之间最终一致性方案

完成下单后,订单状态 变成了待支付

下单的主体流程,至此基本结束

第三阶段支付回调

后续在页面再触发支付流程,支持完成后会回调订单服务,修改订单状态

第四阶段库存补偿

使用延迟消息/定时任务,对超时未支付订单进行关闭,并且对库存进行补偿

三:异步模式下的两阶段 下单

下单的主体流程就是在第一阶段 扣库预扣,第二阶段 库存扣减 。

四:两阶段 下单 方案3大缺点:

第一阶段 扣库预扣,申请成功之后,进入消息队列;

用户选中秒杀商品点击“抢购”, 后端收到下单请求, 使用redis lua 脚本进行redis 库存的预扣 ,保障库存预扣的原子性。

这里,将存库扣减前移,从MySQL前移到Redis中,所有的预减库存的操作放到内存中。

扣减库存的操作,使用redis 分布式锁保障幂等性,防止 用户同一秒触发重复“抢购”。

由于Redis的写性能和读性能都远高于MySQL,这就解决了高并发下的性能问题,提升了并发量10倍以上。

下单请求,发送消息队列,进入第二阶段

第二阶段 库存扣减 ,从消息队列消费 下单请求,然后完成下单操作。 查库存 -> 创建订单 -> 扣减库存。通过分布式锁 、分布式事务机制保障解决多个provider实例并发下单产生的超卖问题。

第二阶段 , 然后通过队列等异步手段,将变化的数据异步写入到DB中。‘

幂等性保证:使用redis 分布式锁 保障 订单操作的幂等性,防止 同一个请求重复下单,

一致性保证:使用本地消息表分布式事务,解决订单和库存的数据之间最终一致性方案

下单的主体流程,至此基本结束

完成下单后,订单状态 变成了待支付

不一致的问题:

由于异步写入DB,可能存在数据不一致,存在某一时刻DB和Redis中数据不一致的风险。

少买 问题

可能存在少买,也就是如果拿到号的人不真正下订单,可能库存减为0,但是订单数并没有达到库存阀值,有效 订单少了。

可能存在超卖 ,也就是 库存减为0, 订单数 超过了库存阀值,有效 订单多了

五:如何解决超卖 问题

这里,将存库扣减前移,从MySQL前移到Redis中,所有的预减库存的操作放到内存中。

超卖 问题

在第一阶段,用分布式锁,是为了防刷、防止同一个用户同一秒里面把购物车里的商品进行多次结算,防止前端代码出问题触发两次,就会解决超卖问题。

用户选中秒杀商品点击“抢购”, 后端收到下单请求, 进行redis 库存的预扣 。

扣减库存的操作,使用redis 分布式锁保障幂等性,防止 用户同一秒触发重复“抢购”,解决超卖问题。

以下是一个使用 Java 和 Redis 分布式锁解决秒杀超卖问题的示例代码。 示例代码如下:

import redis.clients.jedis.Jedis;

public class SeckillDemo {

private static final String LOCK_SUCCESS = "OK";

private static final String SET_IF_NOT_EXIST = "NX";

private static final String SET_WITH_EXPIRE_TIME = "PX";

private static final int LOCK_EXPIRE_TIME = 1000; // 锁的过期时间,单位为毫秒

public static void main(String[] args) {

Jedis jedis = new Jedis("localhost", 6379);

String productId = "123456"; // 商品ID

String userId = "user123"; // 用户ID

boolean result = seckill(jedis, productId, userId);

if (result) {

System.out.println("用户 " + userId + " 秒杀商品 " + productId + " 成功!");

} else {

System.out.println("用户 " + userId + " 秒杀商品 " + productId + " 失败!");

}

jedis.close();

}

public static boolean seckill(Jedis jedis, String productId, String userId) {

String lockKey = "seckill_lock:" + productId;

String stockKey = "seckill_stock:" + productId;

String uniqueValue = System.currentTimeMillis() + userId; // 唯一值,用于防止误删锁

// 尝试获取锁

String result = jedis.set(lockKey, uniqueValue, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, LOCK_EXPIRE_TIME);

if (LOCK_SUCCESS.equals(result)) {

try {

// 获取商品库存

String stockStr = jedis.get(stockKey);

int stock = stockStr == null? 0 : Integer.parseInt(stockStr);

if (stock > 0) {

// 扣减库存

jedis.decr(stockKey);

System.out.println("库存扣减成功,当前库存: " + (stock - 1));

return true;

} else {

System.out.println("库存不足,秒杀失败");

return false;

}

} finally {

// 释放锁,使用 Lua 脚本来确保原子性

String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";

jedis.eval(script, 1, lockKey, uniqueValue);

}

} else {

System.out.println("获取锁失败,正在排队或秒杀已结束");

return false;

}

}

}

5.1 解决超卖 的代码解释:

首先,根据商品 ID 生成一个唯一的锁键 lockKey 和库存键 stockKey。

第二步,抢锁。 尝试使用 jedis.set 方法获取锁,该方法的参数包括锁键、唯一值、NX 和 PX 以及锁的过期时间。

如果抢锁成功(返回 OK),进入秒杀逻辑。

第三步,预扣库存。 获取商品库存,若库存大于 0,则使用 jedis.decr 方法扣减库存,同时打印当前库存。

第四步,释放锁。 无论秒杀成功与否,最终都要释放锁。这里 使用 Lua 脚本来释放锁,确保只有加锁的客户端才能释放锁,避免误删锁的情况。Lua 脚本首先检查锁的值是否与之前存储的唯一值相等,如果相等,则删除锁,否则不做任何操作。

5.2 使用分布式锁要解决的问题

分布式锁要解决的问题很多,比如下面的三个大问题:

六:如何解决少卖 问题?

锁失效的问题

锁的自动续期问题

Redis分段锁问题

其他的高可用、高并发问题?

在秒杀场景中,可能会出现少卖问题,即实际卖出的商品数量少于实际库存。

如果要解决少卖,方便后面的人下单, 需要恢复库存数量。

恢复库存数量,有很多方案可以解决:

1 使用RocketMQ 延迟消息

2 使用xxl-job 的定时任务

6.1. 使用RocketMQ 延迟消息解决少卖 问题?

通过使用延迟消息,可以在订单提交后设置一个延迟时间,让系统在延迟时间后再次检查订单状态,确保库存正确扣减。

RocketMQ默认支持18个延时级别,分别为:

1s, 5s, 10s, 30s, 1m, 2m, 3m, 4m, 5m, 6m, 7m, 8m, 9m, 10m, 20m, 30m, 1h, 2h

如果需要更多的延时级别,可以在broker.conf文件中进行配置。

例如,增加2天的延时级别:

messageDelayLevel=1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h 2d

在用户提交订单后,生产者发送一条延迟消息到RocketMQ 去做 订单的检查,库存补偿。

假设我们希望在订单提交后10m 检查订单状态,可以设置延迟级别为14(10分钟)。

生产者发送延迟消息的代码

import org.apache.rocketmq.client.producer.DefaultMQProducer;

import org.apache.rocketmq.client.producer.SendResult;

import org.apache.rocketmq.common.message.Message;

public class ProducerDelay {

public static void main(String[] args) throws Exception {

DefaultMQProducer producer = new DefaultMQProducer("producerGroup");

producer.setNamesrvAddr("localhost:9876");

producer.start();

Message msg = new Message("SECKILL_CHECK_TOPIC", "订单001".getBytes());

msg.setDelayTimeLevel(14); // 设置延迟10分钟

SendResult sendResult = producer.send(msg);

System.out.println("发送结果: " + sendResult);

producer.shutdown();

}

}

消费者处理延迟消息

消费者在10分钟后接收到延迟消息,检查订单状态。

如果订单未支付,可以进行相应的处理,如取消订单、释放库存等。

import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;

import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyContext;

import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;

import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyListener;

import org.apache.rocketmq.common.message.MessageExt;

public class ConsumerDelay {

public static void main(String[] args) throws Exception {

DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("consumerGroup");

consumer.setNamesrvAddr("localhost:9876");

consumer.subscribe("SECKILL_TOPIC", "*");

consumer.registerMessageListener(new ConsumeConcurrentlyListener() {

@Override

public ConsumeConcurrentlyStatus consumeMessage(List msgs, ConsumeConcurrentlyContext context) {

for (MessageExt msg : msgs) {

System.out.println("接收到延迟消息: " + new String(msg.getBody()));

// 检查订单状态

String orderId = new String(msg.getBody());

if (isOrderUnpaid(orderId)) {

// 订单未支付,取消订单,释放库存

cancelOrder(orderId);

}

}

return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;

}

});

consumer.start();

System.out.println("消费者启动成功");

}

private static boolean isOrderUnpaid(String orderId) {

// 模拟检查订单状态

return true; // 假设订单未支付

}

private static void cancelOrder(String orderId) {

// 模拟取消订单,释放库存

// DB中的库存 +1

// Redis 中的库存 +1

System.out.println("取消订单: " + orderId);

}

}

以上步骤,使用RocketMQ的延迟消息可以有效解决秒杀场景中的少卖问题,确保订单处理的准确性和及时性。

6.2. 使用 xxl-job 定时任务解决少卖 问题?

使用xxl-job 定时任务,进行未支付订单的检查,库存补偿。

一般来说,订单信息存储在数据库中,包含订单状态(如已支付、未支付、已取消等)、订单创建时间、商品信息等。

当用户下单后,订单初始状态为未支付,并记录订单创建时间。

xxl-job 定时任务执行逻辑

定时任务周期性地检查未支付订单,将超时的未支付订单标记为已取消。

对于已取消的订单,将其占用的库存进行补偿,恢复库存数量。

xxl-job 定时任务代码:

import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.jdbc.core.JdbcTemplate;

import org.springframework.stereotype.Component;

@Component

public class OrderTimeoutJobHandler {

@Autowired

private JdbcTemplate jdbcTemplate;

// 订单超时时间,单位为秒,例如 600 秒即 10分钟

private static final int ORDER_TIMEOUT_SECONDS = 600;

public void handleOrderTimeout() {

// 计算超时时间

long timeoutTimestamp = System.currentTimeMillis() - (ORDER_TIMEOUT_SECONDS * 1000);

// 查询超时未支付订单

String sql = "SELECT order_id, product_id, quantity FROM orders WHERE status = 'UNPAID' AND create_time < FROM_UNIXTIME(?)";

List> timeoutOrders = jdbcTemplate.queryForList(sql, timeoutTimestamp / 1000);

for (Map order : timeoutOrders) {

String orderId = (String) order.get("order_id");

String productId = (String) order.get("product_id");

int quantity = (int) order.get("quantity");

// 取消订单

cancelOrder(orderId);

// 补偿库存

compensateStock(productId, quantity);

}

}

private void cancelOrder(String orderId) {

String sql = "UPDATE orders SET status = 'CANCELLED' WHERE order_id =?";

jdbcTemplate.update(sql, orderId);

}

private void compensateStock(String productId, int quantity) {

//恢复 DB中的库存

String sql = "UPDATE product_stock SET stock = stock +? WHERE product_id =?";

jdbcTemplate.update(sql, quantity, productId);

//恢复 redis 中的库存

}

}

handleOrderTimeout 方法:

该方法由 xxl-job 定时触发,首先计算超时时间戳。

然后查询订单表中状态为 UNPAID 且创建时间早于超时时间戳的订单。

对于这些超时未支付订单,将其状态更新为 CANCELLED,并对相应的商品库存进行补偿。

xxl-job 配置:

在 xxl-job-admin 管理界面中,添加一个定时任务,设置任务的调度规则,例如每 5 分钟执行一次,根据实际业务需求调整。

将 OrderTimeoutJobHandler 类注册到 xxl-job 中,并将 handleOrderTimeout 方法作为任务的执行方法。

xxl-job 定时任务解决少卖的注意事项:

调度周期的问题:

根据业务需求调整定时任务的调度周期,避免过于频繁或过于稀疏。

数据一致性问题:

在实际应用中,对于订单的创建、库存扣减、订单取消和库存补偿,可能需要使用数据库事务保证数据一致性,避免出现数据不一致的情况。

数据库索引的性能问题:

可以添加索引,提高查询效率,例如在订单表的 status 和 create_time 列上添加联合索引。

总之:

通过 xxl-job 定时任务, 可以定期检查未支付订单并进行库存补偿,确保业务的正常运行和库存的合理管理。

玩率土,赢金钞 《率土之滨》新赛季时代战场来袭
四座敞篷车大全推荐,可以敞篷的车有哪些