如何解决秒杀场景下的超卖现象(java)

由秒杀引发的一个问题

秒杀最大的一个问题就是解决超卖的问题。其中一种解决超卖如下方式:
update goods set num = num - 1 WHERE id = 1001 and num > 0
我们假设现在商品只剩下一件了,此时数据库中 num = 1;

但有100个线程同时读取到了这个 num = 1,所以100个线程都开始减库存了。

但你会最终会发觉,其实只有一个线程减库存成功,其他99个线程全部失败。

为何?

这就是MySQL中的排他锁起了作用。

排他锁又称为写锁,简称X锁,顾名思义,排他锁就是不能与其他所并存,如一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁,但是获取排他锁的事务是可以对数据就行读取和修改。

就是类似于我在执行update操作的时候,这一行是一个事务(默认加了排他锁)。这一行不能被任何其他线程修改和读写

第二种解决超卖的方式如下
select version from goods WHERE id= 1001;
update goods set num = num - 1, version = version + 1 WHERE id= 1001 AND num > 0 AND version = @version(上面查到的version);
update goods set num = num - 1, version = version + 1 WHERE id= 1001 AND version = @version(上面查到的version);
而且还应该在执行该sql语句前增加一个num数目是否大于0的业务逻辑判断。
在mysql中,这里实际上还是会加排它锁的,但是采用版本号也是解决超卖的一种方式,只不过用version的方式代替掉了数据库中num>0这语句的作用,将num>0的判断放置在了业务逻辑中进行。
实际上,这两种方式解决超卖的方式也有细微的一点区别。考虑两个线程,当库存数量为2时,如果是第一种方式,那么两个线程都能成功执行。如果为第二种方式,如果在第一个线程提交事务之前,第二个线程也执行了相同的sql拿到了version值(也就是线程1和线程2拿到了相同的version值),那么这两个线程之间将只有一个线程能够让库存数目减一成功执行。最终库存数目不为0,而为1。

这种方式采用了版本号的方式,其实也就是CAS的原理。

假设此时version = 100, num = 1; 100个线程进入到了这里,同时他们select出来版本号都是version = 100。

然后直接update的时候,只有其中一个先update了,同时更新了版本号。

那么其他99个在更新的时候,会发觉version并不等于上次select的version,就说明version被其他线程修改过了。那么我就放弃这次update

第三种解决超卖的方式如下
利用redis的单线程预减库存。

比如商品有100件。那么我在redis存储一个k,v。例如 <gs1001, 100>

每一个用户线程进来,key值就减1,等减到0的时候,全部拒绝剩下的请求。

那么也就是只有100个线程会进入到后续操作。所以一定不会出现超卖的现象。

SpringBoot + redis解决商品秒杀库存超卖,看这篇文章就够了 - 知乎 (zhihu.com)

在众多抢购活动中,在有限的商品数量的限制下如何保证抢购到商品的用户数不能大于商品数量,也就是不能出现超卖的问题;还有就是抢购时会出现大量用户的访问,如何提高用户体验效果也是一个问题,也就是要解决秒杀系统的性能问题。

本文主要介绍基于redis 实现商品秒杀功能。先来跟大家讲下大概思路。总体思路就是要减少对数据库的访问,尽可能将数据缓存到Redis缓存中,从缓存中获取数据。

在系统初始化时,将商品的库存数量加载到Redis缓存中,并不是需要先请求一次才能缓存
接收到秒杀请求时,在Redis中进行预减库存,当Redis中的库存不足时,直接返回秒杀失败,减少对数据库的访问。否则继续进行第3步;
将请求放入异步队列(RabbitMQ)中,立即给前端返回一个值,表示正在排队中。
服务端异步队列将请求出队,出队成功的请求可以然后进行秒杀逻辑,减库存–>下订单–>写入秒杀订单,成功了就返回成功。
当后台订单创建成功之后可以通过websocket向用户发送一个秒杀成功通知。前端以此来判断是否秒杀成功,秒杀成功则进入秒杀订单详情,否则秒杀失败。
系统初始化的时候将秒杀商品库存放入redis缓存
//首先我们需要实现InitializingBean接口,InitializingBean接口为bean提供了初始化方法的方式,它就包括afterPropertiesSet方法,凡是继承该接口的类,在初始化bean的时候会执行该方法。
@Component
public class WebListener implements InitializingBean{
  @Autowired
  private RedisTemplate redistemplate;
  
  @Override
  public void afterPropertiesSet() throws Exception{
    List<GoodsVo> goodsList = goodsService.listGoodsVo();
        if(goodsList == null) {
            return;
        }
        for(GoodsVo goods : goodsList) {
            redistemplate.set(GoodsKey.getMiaoshaGoodsStock, ""+goods.getId(), goods.getStockCount());
            localOverMap.put(goods.getId(), false);//先初始化 每个商品都是false 就是还有库存
        }
  }
}
//这就实现了我们系统启动就把所有缓存加载完毕,然后我们通过操作redis来实现预减库存
预减库存 请求放到异步队列
//然后当我们的并发量够大,redis的压力页很大,然后我们可以通过map集合标记缓存,减少redis服务器的压力
 
// 1、生成一个map,并在初始化的时候,将所有商品的id为键,标记false 存入map中。
// 2、在预减库存之前,从map中取标记,若标记为false,说明库存,还有,
// 3、预减库存,当遇到库存不足的时候,将该商品的标记置为true,表示该商品的库存不足。
这样,下面的所有请求,将被拦截,无需访问redis进行预减库存。
 
//系统启动时会对其初始化,将所有秒杀商品id存入map,库存为0是为true
private Map<Long,Boolean> localOverMap = new HashMap<Long,Boolean>();
 
 
//====================================================================================
 
    @RequestMapping(value="/{path}/do_miaosha", method=RequestMethod.POST)
    @ResponseBody
    public Result<Integer> miaosha(HttpServletRequest request, HttpServletResponse response,
                                   Model model,MiaoshaUser user,
                                   @RequestParam("goodsId")long goodsId,
                                   @PathVariable("path") String path) {
        model.addAttribute("user", user);
        //如果用户为空,则返回至登录页面
        if(user == null) {
            return Result.error(CodeMsg.SESSION_ERROR);
        }
        //验证path
        boolean check = miaoshaService.checkPath(user, goodsId, path);
        if(!check){
            return Result.error(CodeMsg.REQUEST_ILLEGAL);
        }
        //内存标记,从map取值判断,减少redis访问
        boolean over = localOverMap.get(goodsId);
        if(over) {
            return Result.error(CodeMsg.MIAO_SHA_OVER);
        }
        //预减库存 这里的减库存是原子性的操作
        long stock = redisService.decr(GoodsKey.getMiaoshaGoodsStock, ""+goodsId);//10
        if(stock < 0) {
            localOverMap.put(goodsId, true);
            return Result.error(CodeMsg.MIAO_SHA_OVER);
        }
        //判断是否已经秒杀到了
        MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
        if(order != null) {
            return Result.error(CodeMsg.REPEATE_MIAOSHA);
        }
        //入队
        MiaoshaMessage mm = new MiaoshaMessage();
        mm.setUser(user);
        mm.setGoodsId(goodsId);
        sender.sendMiaoshaMessage(mm);
        //返回0代表排队中
        return Result.success(0);
    }
// redis给数据库减轻压力,利用map标记库存给redis减轻压力
还有一种写法:

这里使用到了redis api中的decrement操作,预减用户抢购的数量,同时判断redis中的库存是否大于用户抢购数量,如果小于0,直接提示用户秒杀失败,否则秒杀成功,进入redis消息队列执行数据库建库存操作。

由于是通过异步队列写入数据库中,可能存在数据不一致

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Java秒杀系统中,如果不进行合理的控制,可能会导致Redis超卖的问题。这是因为秒杀活动会引起大量用户的并发请求,造成高压力的并发场景。由于秒杀商品数量有限,如果不进行合理的控制,可能会导致商品超卖,即一个商品被多个用户秒杀成功。 为了解决这个问题,可以采取以下措施: 1. 使用Redis作为缓存:可以将秒杀商品的库存信息存储在Redis中,并使用Redis的原子操作来实现商品的减库存操作。这样可以避免在高并发场景下出现数据不一致的问题。 2. 限制用户的请求频率:可以设置一个合理的请求频率限制,比如每个用户每秒只能发送一次请求。这样可以避免同一个用户发送大量的请求,减轻服务器的压力。 3. 使用分布式锁:可以使用分布式锁来控制对商品库存的访问,确保在某个时间点只有一个用户可以进行秒杀操作。常用的分布式锁有Redis的setnx或RedLock等。这样可以保证商品不会被多个用户同时秒杀成功。 4. 预减库存和回滚机制:可以在Redis中预先设置秒杀商品的库存数量,每次有新的秒杀请求来时,先对Redis中的库存进行预减操作,如果库存为负数,则表示该商品已被秒杀完,需要进行回滚操作。 综上所述,通过合理使用Redis缓存、限制用户的请求频率、使用分布式锁以及预减库存和回滚机制等措施,可以有效解决Java秒杀系统中Redis超卖的问题,保证秒杀活动的顺利进行。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值