谁说秒杀系统简单的?说说秒杀系统设计思路

秒杀系统

​ 什么是秒杀,我就不多说了,懂得都懂,不懂的多逛逛淘宝。对于秒杀功能,从专业的角度上总结一句话:在高并发下访问同一资源。所以一个优秀的秒杀系统,就需要能够 在保证功能正确的情况下,顶住这样的一个冲击

​ 什么是功能正确?就是避免超卖,也要避免少卖。就是我有多少库存,我就要刚刚好卖出多少(当然如果没人来买,你也不能自我消化掉)

​ 接下来就是怎么顶住了……对于高并发下,普通硬扛肯定不行。可以出肉加强下系统的承受能力(集群部署负载均衡熔断降级等),还需要有奶妈(缓存等),或对敌方进行各种削弱(前端按钮置灰限流后端消息队列限流接口动态化或隐藏等)。

​ 多说无益,来点解决方案吧。学得少,针对几个简单切入,大伙看看多多指教。

​ 首先明确一下,秒杀接口(seckill方法)功能简化为下面三个主要功能:

  • 校验库存
  • 更新库存(-1为例)
  • 创建订单

1、超卖问题

​ 大头,秒杀系统的核心,无论你系统承受的QPS多牛,功能需求不满足屁用没有,库存都没了你还卖,库存变负数,你的工资可能也要变负数了

为什么会出现超卖现象

​ 本机人工测试,postman调用一下抢购接口,诶成功,库存-1,减到0时返回抢购失败,诶完美,实现了,代码提交报告任务完成去吃饭了……大佬来了,JMeter测一下,来个高一点的并发,哦吼库存负数,他看了你,你看了他,挠了挠头,有点尴尬……

​ 为什么?很简单,共享数据没能正确同步。假设库存剩下1,高并发下,有两个用户同时进行库存检验,判断都是1,诶美滋滋,然后都去减库存、创建订单,诶都买到了哈。很显然,没有做其他判断的话,数据库就会-2,库存也就负数了。

怎么解决

​ 从最粗暴的思路来,为啥本机人工测试没问题?串行化访问呗,单线程,干啥都不虚。那既然这样,我就直接粗暴加上synchronized不就行了。
当然了,也不是说随便加到哪里都可以,要知道对于秒杀方法(或者说是涉及到修改的方法),在service层中(一般也都是加在service层的,符合分层各层的职责),是需要加上事务的(@Transactional),同样也有加锁行为。如果sync加在service层秒杀方法的内部,那么事务的锁范围比sync的大,那么就有可能事务提交出现同时提交多个用户秒杀的结果,同样也会有超卖问题。所以,需要保证sync锁的范围比事务的范围大,也就是说需要先获取到锁,才能进入事务。那么就只能将sync提到controller层了,包围调用service层的这个秒杀方法即可

​ 避免超卖了吗?避免了。结束了吗?早着呢,要知道我们是高并发下的,而synchronized恰恰和高并发是死对头,加上了重量级互斥锁,大量的请求被阻塞,并发性低,显然不是我们所想要的。

​ 怎么优化?上面是悲观锁的方式,加上了锁导致的并发性能低。那么我就不加锁不就行了!那就可以采取在数据库中实现乐观锁,也就是添加版本号。库存检验没问题后,更新库存的时候还需要再比较获取到的version,只有版本号相同才可以更新库存(同时版本号+1)。

update t_stock set sale=sale+1, version=version+1 where id=#{id} and version=#{version}

注意1:这种方式解决超卖问题,结果并不是先到的人先得!!而是碰运气的,如果一个人先成功抢购了,在他更新库存前就获取到库存信息的请求,都会抢购失败。

注意2:sale、version的修改,不可在Java代码中进行+1后再直接update这个值,因为这样在高并发的情况下,多个用户取到的sale可能是相同的,最终导致多个用户购买了,sale只是+1而已,导致超卖。需要 通过SQL语句一起判断后同步增加。

​ 其实也是可以不用到version这种方式的,比如可以直接减库存,最后的where条件,通过库存量大于0的条件来替代version的比较(感觉这样子相对公平些,没必要太关注版本问题,只要库存足够,即使被同时获取到相同库存的其他用户先减去库存了,只要轮到自己时还是>0的库存,同样可以抢购成功)

2、少卖问题

​ 在下面缓存的代码部分,有对少卖进行处理。主要就是边界问题导致少卖,处理好缓存和数据库真实库存量保持一致即可。

3、缓存优化

​ 为什么要使用缓存,应该不需要我说明了吧。

初步:Redis分布式缓存优化

​ 为了避免每次判断库存量都直接打到数据库上,可以先将商品的库存量缓存到Redis中,每次抢购时,在Redis进行原子减操作,再判断库存量是否<0,如果是的话直接返回抢购失败,否则才继续减库存、新增订单等对数据库的操作。

​ 注意redis的库存判断,只是一个初步判断,判断库存量保证没有超卖后,还需要在数据库中修改真正的库存量。

//在项目启动的时候就调用此方法,将所有秒杀的商品和库存量存入Redis
//缓存预热思路,当然实际上的预热操作,不是这么low的做法
@PostConstruct
public void init(){
    List<Product> products = productService.listProducts();
    for(Product p: products){
        //key和商品Id有关联,自己定
        stringRedisTemplate.opsForValue().set(key, p.getStock());
    }
}
//抢购方法(简单描述逻辑而已)
@Postmapping("/{productId}")
public ReturnMessage seckill(@PathVariable("productId")Long productId){
    //根据商品Id在Redis中减库存
    Long stock = stringRedisTemplate.opsForValue().decrement(key);
    if(stock < 0){  //注意不是 <= 0
        //防止少卖,同样也是为了避免数据库和redis缓存不一致的问题
        stringRedisTemplate.opsForValue().increment(key);
        return ReturnMessage.error("商品已售完");
    }
    
    try{
        //减库存+创建订单
        orderService.seckill(productId);
    } catch(Exception e){
        //防止订单创建失败后,数据库和redis中的库存量不一致,redis重新加+1
        stringRedisTemplate.opsForValue().increment(key);
        return ReturnMessage.error("创建订单失败");
    }
}
优化:在使用Redis分布式缓存的基础上,加上本地缓存

​ 上面的方式,每次检查库存,都需要去访问Redis。虽然说Redis速度快,但是毕竟是要进行网络连接,相比直接本地JVM缓存,还是慢上不少的。

​ 所以在使用redis的基础上,在JVM层面新增一个标志字段,判断是否已售完,如果已售完,则连redis都不需要去查了

问题:既然本地缓存快,为什么不直接使用本地缓存?

答案:本地缓存快,但是本地缓存小,而且受限于单机模式,扩展难,掉电就没了。所以本地缓存一般缓存访问频率最高的部分热点数据,其他热点数据放在Redis分布式缓存中,这就是多级缓存。

//本地缓存有多种,这里使用map来演示(售罄的商品加入到map中)
//也可以用Set直接contains判断也可以,不过记得使用线程安全的
public static ConcurrentHashMap<Long, Boolean> productSoldOutMap = new ConcurrentHashMap<>();

@Postmapping("/{productId}")
public ReturnMessage seckill(@PathVariable("productId")Long productId){
    //先在本地缓存中,判断是否售罄
    if(productSoldOutMap.get(productId) != null){
        return ReturnMessage.error("商品已售完");
    }
    
     //根据商品Id在Redis中减库存
    Long stock = stringRedisTemplate.opsForValue().decrement(key);
    if(stock < 0){  //注意不是 <= 0
        //已售完,添加标记
        productSoldOutMap.put(proddctId, true);
        //防止少卖,同样也是为了避免数据库和redis缓存不一致的问题
        stringRedisTemplate.opsForValue().increment(key);
        return ReturnMessage.error("商品已售完");
    }
    
    try{
        orderService.seckill(productId);
    } catch(Exception e){
        //防止订单创建失败后,数据库和redis中的库存量不一致,redis重新加+1
        stringRedisTemplate.opsForValue().increment(key);
        //防止订单失败后,被误标志为已售完,如果本地缓存有,则移除
        if(productSoldOutMap.get(productId) != null){
            productSoldOutMap.remove(productId);
        }
        return ReturnMessage.error("创建订单失败");
    }
}
再优化:在上面的基础上,加上zookeeper实现分布式同步

上面的优化,只适合单体结构的系统,对于分布式系统,JVM不同,不能保证各自的JVM缓存可以同步修改。

所以需要可以针对分布式系统的同步机制。可以利用zookeeper,在上面代码的基础上,在获取redis的库存后,判断为售完的逻辑中,向zookeeper添加一个售罄的标志为true。之后使用watcher机制监听这个flag。

如果出现flag修改为false(订单创建失败的逻辑内,需要将售罄的标志修改为false),那么就会执行对应的watcher方法,在这个watcher方法中,有对JVM中的map标记进行相应的恢复(这样子就可以保证所有JVM中的售罄标志得到同步修改)

也就是说并不是将售罄的标志提交到zookeeper,而是在每一个JVM中也有着一个标志,通过zookeeper的标志,来同步每一个JVM中的标志。

当然也可以仅仅通过zookeeper来,不过和上面redis同理,可以避免多次不必要的访问zookeeper

再再优化:吐了吐了,优化不动了

​ 比如处理Redis的缓存问题:缓存穿透、缓存击穿、缓存雪崩……处理思路也很简单,可以看我的Redis学习笔记。

4、限流

​ 对于并发量很大的秒杀场景,通常是需要对请求进行限制的,不会让所有的请求同时打到缓存或者数据库上。要知道Redis单机最多也就几万QPS,MySQL几千,tomcat几百?如果几十万上百万都打到Redis中,也还是撑不住的,对服务器等的压力很大。

​ 所以需要限流:对某一时间窗口内的请求数量进行限制,保持系统的可用性和稳定性达到限制速率,则可以拒绝服务、等待、排队等处理。 (比如1秒内同时有2000个并发请求,那么可以限制1秒内处理500个请求,2000个请求就分在4秒内执行)

常用的限流算法
  • 漏斗(漏桶)算法

    • 思路简单粗暴,少用,类似阻塞队列。请求先进到“桶”中,无论请求量多少多快,桶始终以恒定的速率流出被服务器处理。但请求量超过桶的容量,则丢弃,拒绝此服务。(强制限制请求速率)
  • 令牌桶算法

    • 设立一个 容量固定的令牌桶,有一个方法以恒定的速率不断往令牌桶加入令牌,令牌桶满则溢出。每一个请求过来,都需要先往令牌桶中拿到令牌只有拿到令牌的请求才可以被处理。否则,可以一直等待直到拿到令牌;也可以重试,超过重试时间还没有拿到令牌,则就拒绝服务。
    • 令牌桶是漏斗算法的改进,体现在令牌桶可以一定程度上处理突发请求

借鉴:https://www.cnblogs.com/xrq730/p/11025029.html

假设我们想要的速率是1000QPS,那么往桶中放令牌的速度就是1000个/s,假设第1秒只有800个请求,那意味着第2秒可以容许1200个请求,这就是一定程度突发流量的意思,反之我们看漏桶算法,第一秒只有800个请求,那么全部放过,第二秒这1200个请求将会被打回200个。

注意上面多次提到一定程度这四个字,这也是我认为令牌桶算法最需要注意的一个点。假设还是1000QPS的速率,那么5秒钟放5000个令牌,第1秒钟800个请求过来,第2~4秒没有请求,那么按照令牌桶算法,第5秒钟可以接受4200个请求,但是实际上这已经远远超出了系统的承载能力,因此使用令牌桶算法特别注意设置桶中令牌的上限即可。

​ Google开源项目Guava中的RateLimiter就是令牌桶算法的一个实现

  • 引入依赖
<!--google开源工具类RateLimiter令牌桶实现-->
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>28.2-jre</version>
</dependency>
  • 基本用法
//创建令牌桶实例(每秒往令牌桶生产10个令牌,即每100ms加入一个令牌)
private RateLimiter rateLimiter = RateLimiter.create(10);

@GetMapping("/sale")
public String sale(Integer id){
    //1、没有获取到令牌(token)的请求,一直等待直到拿到令牌
    //log.info("等待时间:{}", rateLimiter.acquire());

    //2、设置等待时间,超过等待时间则抛弃(3秒)
    if(!rateLimiter.tryAcquire(3, TimeUnit.SECONDS)){
        System.out.println("被限流了,被抛弃了");
    }
    else{
        System.out.println("处理业务………………");
    }
    return "测试令牌桶算法";
}

5、其他操作

当然了,一个秒杀系统,不仅仅上面就可以支撑,还需要考虑很多方面的问题,比如:

  • 前端限流:按钮置灰避免连续多次点击提交
  • 为了防刷,可以对URL进行隐藏或者动态化、单用户限制访问频率、验证码等
  • Redis实现限时抢购
  • 服务器设置为集群,Nginx进行负载均衡
  • 除了限流,对于过于火爆的抢购活动,必要时采取降级、熔断或隔离
  • 采取了分布式架构,还需要考虑分布式事务问题
  • 采用了Redis缓存,还需要考虑缓存三大问题:穿透、击穿、雪崩(可以看看我的Redis学习笔记)
  • ……

​ 这些都需要考虑,我就不一一详细介绍了(一是菜,二是懒),留给你们自己去了解。一个优秀的秒杀系统,不是一个简单的解决方案就可以处理的。

​ 所以虽然说有人先嫌弃管理系统,再嫌弃电商系统,又嫌弃秒杀系统,但是我个人认为,该学的东西还是挺多了,一步一步学吧,不眼高手低,人云亦云,也不要固步自封。不会就学,学会就进阶。

  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值