JavaWeb电商 秒杀业务分析

商品秒杀功能的高并发解决方案

一。业务逻辑分析

  • ​ 所谓秒杀: 从业务角度看,是短时间内多个用户“争抢”资源,这里的资源在大部分秒杀场景里是商品;将业务抽象,技术角度看,秒杀就是多个线程对资源进行操作,所以实现秒杀,就必须控制线程对资源的争抢,既要保证高效并发,也要保证操作的正确

1.秒杀业务的大概运行流程

  • 提交秒杀商品申请(审核通过),录入秒杀商品数据,主要有:商品标题,商品原价,秒杀价格,商品图片,介绍等信息
  • 运营商审核秒杀申请
  • 秒杀频道首页列出秒杀商品,点击秒杀商品图片可以跳转到秒杀商品详细页面
  • 商品详细页面显示秒杀商品信息,点击立即抢购实现秒杀下单,下单时扣减库存,当库存为0或者不存在活动时间范围内时无法秒杀
  • 秒杀下单成功,直接跳转到支付页面(扫码),支付成功,跳转到成功页面,填写收货、电话、收件人等信息,完成订单。
  • 当用户秒杀下单5分钟内未支付,取消预订单,调用支付的关闭订单接口,恢复库存。
 A:查询模块(查询库存)并发查询,库存数存在缓存中,(商品信息和图片信息等)静态化处理(生成静态页)和库存剩余数量缓存化处理。
 B: 下订单模块(秒杀关键部分),队列控制异步化处理,首先判断这个队列是否已满, 如果没满就将请求放入队列中排队,队列满以后的所有请求直接返回秒杀失败。
 C: 支付模块,异步付款,等待付款成功结果。(付款成功更新库存,也可下单的时候扣库存)。

JavaWeb电商 秒杀业务分析_第1张图片

2.秒杀/抢购技术特点

  • ​ 读多写少 (一趟火车其实只有2000张票,200w个人来买,最多2000个人下单成功,其他人都是查询库存)

    • 使用(Redis)缓存解决
  • ​ 高并发

    • 限流 (限制同一时间访问的流量)

    • 负载均衡 (单体tomcat并发200完美胜任,突破五,六百就力不从心)

    • 缓存 (预先把秒杀商品加载进内存)

    • 异步 (将同步的并发请求转换为异步,多线程处理)

    • 队列 (使用redis队列,因为pop操作是原子的,即使有很多用户同时到达,也是依次执行)

JavaWeb电商 秒杀业务分析_第2张图片

3.秒杀架构思想

JavaWeb电商 秒杀业务分析_第3张图片

(1).设计思路

  • 超买超卖问题的解决。

  • **将请求拦截在系统上游,降低下游压力: ** 秒杀系统特点是并发量极大,但实际秒杀成功的请求数量却很少,所以如果不在前端拦截很可能造成数据库读写锁冲突,甚至导致死锁,最终请求超时

  • **充分利用缓存: **利用缓存可极大提高系统读写速度。

  • 消息队列: 消息队列可以削峰,将拦截大量并发请求,这也是一个异步处理过程,后台业务根据自己的处理能力,从消息队列中主动的拉取请求消息进行业务处理。

二。秒杀中的问题解决方案

1.秒杀页面(恶意刷新页面)

  • 秒杀活动开始前,其实就有很多用户访问该页面了。如果这个页面的一些资源,比如 CSS、JS、图片、商品详情等,都访问后端服务器,甚至 DB 的话,服务肯定会出现不可用的情况。

  • 解决方案: 所以要创建静态页面让这个页面整体进行静态化,并将页面静态化之后的页面分发到 CDN(类似于资源服务器) 边缘节点上,起到压力分散的作用

  • 生成的静态页面会遇到的问题: 由于我们以后开发的系统肯定不是给自己用的,用户可能处于不同的时区,他们的当前系统时间也是不同的,所以我们写一个通用的时间规范:就是当前服务器的时间;
    在这里插入图片描述

2.防止提前下单

  • ​ 在之前我们做的后端项目中,跳转到某个详情页一般都是:根据ID查询该详情数据,然后将页面跳转到详情页并将数据直接渲染到页面上。但是秒杀系统不同,它也不能就这样简单的定义;
  • 解决方案:
  • 首先要保证该商品处于秒杀状态。也就是1.秒杀开始时间要<当前时间;2.秒杀截止时间要>当前时间。
  • 要保证一个用户只能抢购到一件该商品,应做到商品秒杀接口对应同一用户只能有唯一的一个URL秒杀地址,不同用户间秒杀地址应是不同的,且配合订单表seckill_order联合主键的配置实现。
    • 可以根据正在进行秒杀的商品ID生成秒杀地址值(md5混合值, 避免用户抓包拿到秒杀地址)
    • 通过MD5加密以后,用户在秒杀之前模拟不出真实的地址

在这里插入图片描述

3.Nginx优化(可以防止一个ip多个账号抢购)

  • ​ 动态资源与静态资源进行分离,获取静态资源时不走Tomcat

  • ​ 限制某个IP同一时间段的访问次数,即针对某一个IP,限制单位时间内发起请求数量。

    http {
        limit_req_zone $binary_remote_addr zone=perip:10m rate=1r/s;
        limit_req_zone $server_name zone=perserver:10m rate=10r/s;
        ...
        server {
            ...
            limit_req zone=perip burst=5 nodelay;  #漏桶数为5个.也就是队列数.nodelay:不启用延迟.
            limit_req zone=perserver burst=10;    #限制nginx的处理速率为每秒10个
        }
    
    	# 静态资源
        server {
             #侦听端口
             listen       80;
             #当前虚拟机所配置的域名信息[所有该域名访问都遵循该配置规则]
             server_name  www.seckill.com;
    
             #所有以.jpg,.png。。。都遵循该配置路由规则
             location ~ \.(jpg|png|gif|js|css)$ {
                 #都直接到D盘的images目录找文件
                 root D:/images;
             }
        }
    }
    

4.库存超卖

​ 原子性(atomicity):一个事务是一个不可分割的最小工作单位,要么都成功要么都失败

​ Redis所有单个命令的执行都是原子性的

  • 秒杀的商品只有10个库存,可能一秒钟有1k个订单;核心思想就是保证库存递减是原子性操作

当减库存和高并发碰到一起的时候,由于操作的库存数目在同一行,就会出现争抢InnoDB行锁的问题,导致出现互相等待甚至死锁,从而大大降低MySQL的处理性能,最终导致前端页面出现超时异常。

  • 解决方案:

  • 数据库

  • update seckill set num=num-1 where num>0 and goodsId=1
    

数据库查询、更新的时候有用到行级锁,是可以保证更新操作的原子性的。数据库性能较差,不建议使用
```

  • Redis分布式锁

用redis来做一个分布式锁,reids->setnx(‘lock’, 1) 设置一个锁,程序执行完成再解锁。锁定的过程,不利于并发执行,所有线程都在等待锁解开,不建议使用。
```

  • 消息队列

    将订单请求全部放入消息队列,然后另外一个后台程序监听消息一个个处理队列中的订单请求。
    

并发不受影响,但是用户等待的时间较长,进入队列的订单也会很多,体验上不好,不建议使用。
```

  • redis递减

    通过 redis->incrby('product', -1) 得到递减之后的库存数。
    

redis的incr和decr 可以实现原子性的递增递减
```

  • Java代码的实现
```java
// 根据抢购的商品id
Seckill seckill = (Seckill) redisTemplate.boundHashOps("seckill").get(goodsId);
if (seckill.getStockCount() < 0) {
    // 商品已售完
    throw new SeckillException("商品已售完");
}
// 减少抢购商品的库存信息
Long count = redisTemplate.boundHashOps("seckill_count").increment(goodsId,-1);
// 更新库存信息
seckill.setStockCount(count);
if (count <= 0) {
    // 库存不足
    // 更新数据库库存数据
    seckill.setStockCount(count);
    seckillMapper.update(seckill);
    // 秒杀结束,把订单信息更新到MySQL中
    throw new SeckillException("库存不足,谢谢参与!");
} else {
    // 更新库存信息
    redisTemplate.boundHashOps("seckill").put(goodsId,seckill);
    // 存储订单信息,暂时存储到redis中

}
```

5.解决高并发

活动周期短,瞬间流量大(高并发),大量的人短期涌入服务器抢购,但是数量有限,最终只有少数人能成功下单。

  • 解决方案:

    • 利用Redis建立一条队列,将每个请求加入到队列中,然后异步获取队列数据进行处理,把多线程的事情变成单线程,处理完一个就从队列中删除一个。
    • 这样可能会发生redis雪崩现象,请求特别多的时候,一瞬间将redis队列内存撑爆,导致系统异常,又或者队列内存足够大
  • 可以使用限流限制过多请求访问系统

    • Ratelimiter: Google开源工具包Guava提供了限流工具类RateLimiter,该类基于令牌桶算法(Token Bucket)来完成限流

’栈’:从链表的头部添加元素,先进后出

’队列’:从链表的尾部添加元素,先进先出

// 存入队列, 构建用户抢单信息
SeckillStatus seckillStatus = new SeckillStatus(userPhone, new Date(), 1, seckillId);
// 存储用户排队信息(先进先出)
redisTemplate.boundListOps("seckillOrderStatus").leftPush(seckillStatus);


// 获取队列排队中的用户 
SeckillStatus seckillStatus = (SeckillStatus)redisTemplate.boundListOps("seckillOrderStatus").rightPop();
           

6.多个集群的数据怎么保持一致性

不要做多集群的数据同步,而是用散列,每个集群的数据是独立存在的。
假设,有10个商品,每个商品有1w库存,规划用10个集群,那么每个集群有10个商品,每个商品是1k库存。
每个集群只需要负责把自己的库存卖掉即可,至于说,会不会有用户知道有10个集群,然后每个集群都去抢。
这种情况就不要用程序来处理了,利用运营规则,活动结束后汇总订单的时候再去处理
比如:某个集群用户访问量特别少,那么可以引入一个中控服务,来监控各个集群的库存,然后再做平衡。

三。秒杀模块的数据库设计

1.数据库设计

  • 垂直(纵向)切分

    • 垂直分库就是根据业务耦合性,将关联度低的不同表存储在不同的数据库。做法与大系统拆分为多个小系统类似,按业务分类进行独立划分。与"微服务"的做法相似,每个微服务使用单独的一个数据库

    • 也能避免跨页问题,MySQL底层是通过数据页存储的,一条记录占用空间过大会导致跨页,造成额外的性能开销。另外数据库以行为单位将数据加载到内存中,这样表中字段长度较短且访问频率较高,内存能加载更多的数据,命中率更高,减少了磁盘IO,从而提升了数据库性能。
      
    • 秒杀跟普通商品购买是有区别的,所以数据库表设计也不同 进行业务隔离

    • 将这种秒杀数据隔离出来,不要让1%的请求影响到另外的99%,隔离出来后也更方便对这1%的请求做针对性优化

    • 进行表与表之间的分离

JavaWeb电商 秒杀业务分析_第4张图片

垂直切分的优点:

    解决业务系统层面的耦合,业务清晰
    与微服务的治理类似,也能对不同业务的数据进行分级管理、维护、监控、扩展等
    高并发场景下,垂直切分一定程度的提升IO、数据库连接数、单机硬件资源的瓶颈

1.Ratelimiter的简单使用

常用的两种限流方式
    使用漏桶(Leaky Bucket)算法来进行限流
    使用令牌桶(Token Bucket)算法来进行限流
    
漏桶: 类似于队列,  水(请求)先进入到漏桶里,漏桶以一定的速度出水(接口有响应速率),当水流入速度过大会直接溢出(访问频率超过接口响应速率),然后就拒绝请求,可以看出漏桶算法能强行限制数据的传输速率 

QPS-->Queries Per Second  “每秒查询率” 一台服务器每秒能够相应的查询次数

令牌桶: 系统会按恒定1/QPS时间间隔(如果QPS=100,则间隔是10ms)的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。
  • 项目中的使用

  • 导入pom.xml依赖


 <dependency>
     <groupId>com.google.guavagroupId>
     <artifactId>guavaartifactId>
     <version>27.1-jreversion>
 dependency>
  • ​ 代码实现
@Service
public class AccessLimitServiceImpl implements AccessLimitService {

    /**
     * 每秒钟只发出60个令牌,拿到令牌的请求才可以进入秒杀过程
     */
    private RateLimiter seckillRateLimiter = RateLimiter.create(60);

    /**
     * 尝试从令牌桶中获取令牌
     * @return
     */
    @Override
    public boolean tryAcquireSeckill() {
        return seckillRateLimiter.tryAcquire();
    }
}
@Autowired
private GuavaRateLimiterService rateLimiterService;

@ResponseBody
@RequestMapping("/ratelimiter")
public Result testRateLimiter(){
    if(rateLimiterService.tryAcquire()){
   		return ResultUtil.success1(1001,"成功获取令牌");
    }
    return ResultUtil.success1(1002,"未获取到令牌");
}

ava
@Autowired
private GuavaRateLimiterService rateLimiterService;

@ResponseBody
@RequestMapping("/ratelimiter")
public Result testRateLimiter(){
if(rateLimiterService.tryAcquire()){
return ResultUtil.success1(1001,“成功获取令牌”);
}
return ResultUtil.success1(1002,“未获取到令牌”);
}



你可能感兴趣的:(JavaWeb电商 秒杀业务分析)