1. 确认需求和技术方案
一般在电商系统和=或者秒杀系统中都有出现一种商品超卖的问题存在,原因就是再大量并发请求的时候导致了数据库的脏读和不可重复读,从而造成了商品的下单数量大于了商品的库存数量。
一般来说常用的解决超卖的方案有两种:
- 方案一:悲观锁(不推荐)
- 方案二:乐观锁
2. 使用两种方案来解决问题
方案一:悲观锁
对于方案一的解决方法有很多,比如在对要加锁的方法上加入synchronized同步字段,使接口被访问时至多同时只能被一个线程访问,其他的请求都会处于阻塞状态,直至上一个请求处理结束,线程被释放,下一个请求再进来,使每一次进来的请求都需要排队,一次一个。
这种方案可以有效的解决超卖问题,每一次请求都可以保证数据库数据的一致性,但是也有缺点,用户的体验就不是不好了,后面的的用户在等待很久之后可能还是会提示请求失败,商品已经买完了。
@PutMapping("")
public Object doorder(@ApiParam(name = "DTO" @RequestBody DTO dto){
try {
//悲观锁
synchronized (this){
//业务层的减库存方法和下单方法
...
return;
}
}catch (Exception e){
//这里也可以使用自定义的方式捕获异常并返回想要异常
e.printStackTrace();
return ...;
}
}
但需要强调的一点是synchronized不能和@Transactional一起使用,也不要在Service层的方法上加synchronized,因为Service层的业务方法大多加有事务控制,和悲观锁联合使用的时候,悲观锁解锁的时间比事务提交的时间早,可能会导致少量请求在上一次事务未完全提交就进来,最终还是会导致少量的超卖出现。所以尽量在接口中加上同步代码块来控制业务的访问。
方案二:乐观锁
对于方案二来说,并发数较大的情况下就需要使用消息队列来保证数据的一致性。常用的有Redis消息队列来防止超卖,下面给出的是用Redis的消息队列来进行商品超卖的控制。
- 一:获取商品库存。
- 二:判断库存是否充足,如果充足则继续执行,否则返回错误信息。
- 三:使用Redis作为消息队列,将购买请求放入队列中。
- 四:开启一个线程来消费队列中的购买请求,对于每一个请求,按照以下步骤处理。
- a:获取商品库存。
- b:判断库存是否充足,如果充足则继续执行,否则返回错误信息。
- c:将购买请求的处理结果返回给用户。
下面是完整的Java代码实现。
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.0.0</version>
</dependency>
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import redis.clients.jedis.Transaction;
public class RedisDemo {
public static void main(String[] args) {
// 创建redis连接池
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxTotal(8);
jedisPoolConfig.setMaxIdle(8);
JedisPool jedisPool = new JedisPool(jedisPoolConfig, "localhost", 6379, 3000, null);
// 乐观锁实现防止超卖
for (int i = 0; i < 100; i++) {
new Thread(() -> {
String watchKey = "goods:001";
String userKey = "user:001";
String requestId = "request:001";
int buyNum = 1;
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
// 监视watchKey
jedis.watch(watchKey);
// 获取商品库存
int stock = Integer.parseInt(jedis.get(watchKey));
if (stock < buyNum) {
jedis.unwatch();
System.out.println("库存不足!");
return;
}
// 开始事务
Transaction transaction = jedis.multi();
// 减少库存
transaction.decrBy(watchKey, buyNum);
// 成功的订单数
int successCount = Integer.parseInt(jedis.get(userKey));
// 提交事务
transaction.exec();
// 增加成功的订单数
successCount++;
jedis.set(userKey, String.valueOf(successCount));
System.out.println(Thread.currentThread().getName() + " 抢购成功!");
} finally {
if (jedis != null) {
jedis.close();
}
}
}, "用户" + (i + 1)).start();
}
// 消费消息队列,处理购买请求
new Thread(() -> {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
while (true) {
// 从消息队列中获取购买请求
String request = jedis.rpop(requestId);
if (request == null) {
continue;
}
// 开始事务
Transaction transaction = jedis.multi();
// 获取商品库存
int stock = Integer.parseInt(jedis.get(watchKey));
if (stock < buyNum) {
continue;
}
// 减少库存
transaction.decrBy(watchKey, buyNum);
// 提交事务
transaction.exec();
// 返回结果给用户
jedis.set(request, "success");
}
} finally {
if (jedis != null) {
jedis.close();
}
}
}, "消费者").start();
}
}
使用Redis防止超卖的一种很常见的做法,其主要是通过Redis的原子操作incr或decr实现。其特点有如下几点:
-
快速高效:Redis是内存级别的缓存,读写速度非常快,可以满足高并发场景下的业务需求,提升系统性能。
-
可靠性高:Redis支持事务和CAS命令,防止并发操作导致数据不一致的问题,保证数据的一致性和可靠性。
-
简单易用:Redis有丰富的数据结构和详细的API文章,易上手和维护。
-
运维成本低:Redis有非常好的集群和监控方案,也支持自动化运维,能减少运维成本和复杂度。
-
灵活可扩展:Redis支持主从复制和分片,可以根据业务需求快速扩展集群,以应对高并发场景的需求。
3.总结
悲观锁和乐观锁的方式相比较,悲观锁是在对库存表进行操作时预先加锁,确保同一时刻只有一个线程能够访问和修改库存数据。但是因为加锁的原因,就会导致其他线程需要等待锁释放才能进行操作,影响并发性能,并且给用户的体验感非常差。
而使用Redis,基于Redis的原子操作能够更好地保证数据的安全性,并且性能更高,在大并发的场景下会有很好的性能表现。