秒杀系统

Github地址

项目介绍

使用SpringBoot和Mybatis框架搭建基本的电商系统,改进为支持高并发的秒杀系统

实现逻辑

项目框架搭建

  1. 搭建SpringBoot环境

  2. 集成Thymeleaf

  3. 封装Result结果

    Controller的返回值Result定义如下

    1
    2
    3
    4
    public class Result<T> {
    private int code;
    private String msg;
    private T data;

    code用来表示不同情况,其中0代表成功,其他一律代表各种情况的异常

    msg用来返回执行结果的描述,如果成功返回success,如果失败的话返回对异常的描述

    data用来返回数据,只有成功的时候data才会有值,如果失败了自然就没有值了

    这里采用面向对象的封装方法,给Result定义两个函数,一个succes,一个error,成功的时候只需要传入data,失败的时候只需要传入code和msg即可,可以进一步把code和msg进行封装成一个类Codemsg,并且针对不同的异常提前定义不同的静态变量Codemsg,在使用的时候直接调用error方法传入不同的Codemsg即可

  4. 集成Mybatis和Druid

    dao包用来执行sql语句,语句中#{} 解析的是占位符?可以防止SQL注,而${}是拼接符,会直接替换成变量的值,没有单引号

    daomain包用来存放数据表的实体类

  5. 集成Jedis

    首先把application.properties中的配置读入到配置文件,用配置文件中的参数建立一个JedisPool连接池,并注入到容器中

    在使用时,每次调用jedispool的getSource()方法生成一个jedis客户端就可以了,使用结束后一定要记得关闭jedis客户端防止资源浪费

    往redis里面存数据的时候需要一个JSON.toJSONString()方法把对象转化成字符串,同理在读数据的时候也要用一个JSON.toJavaObject()方法把字符串转化回来

  6. 封装通用缓存Key

    因为一个redis数据库中可能要存放不同业务的缓存,为了区别这些缓存需要给Key加上一个前缀Perfix

    为了使用方便,采用模板模式,先定义一个前缀的接口

    1
    2
    3
    4
    public interface KeyPrefix {
    public int expireSeconds();
    public String getPrefix();
    }

    再用一个抽象类实现所有前缀共同的方法

    1
    2
    3
    4
    5
    6
    7
    //省略参数定义和构造函数
    public abstract class BasePrefix implements KeyPrefix{
    public String getPrefix() {
    String className = getClass().getSimpleName();
    return className+":" + prefix;
    }
    }

    需要使用前缀的时候只需要继承这个抽象类,然后实现一下私有的方法即可

    1
    2
    3
    4
    public class UserKeyPerfix extends BasePrefix{
    private UserKeyPerfix(String prefix) {super(prefix);}
    public static UserKeyPerfix getById = new UserKeyPerfix("id");
    }

    最终生成的Key就是prefix.getPrefix() + key

登陆功能

  1. 明文密码使用两次md5操作入库

    第一次,为防止用户密码在明文传输的http协议中被泄露,用户端输入的密码要进行一次md5加密操作再传输至服务端

    password1=md5(明文密码+固定salt)

    这步操作在前端完成

    第二次,为防止数据库泄露带来的风险,服务端接受到密码后还要进行一次md5加密操作再写入数据库中

    password2=md5(password1+随机salt)

    这个随机salt也要一并写入数据库中,不然以后就没法验证了

  2. 集成jsr303参数校验

    每次要在Controller里面对传输的数据进行逐条的校验很麻烦,导入validation这个依赖,只需要在Controller里,传输的数据类前加上@Valid注解,并在该数据类的参数上加上参数校验的注解比如@NotNull,这样出错了就会自动报错,但这个报错信息不方便处理,可以设计一个全局异常的处理器,添加@ExceptionHandler注解,根据异常的种类返回不同的Result

    同时定义一个含有Codemsg参数的全局异常类,在出现错误时抛出异常而不是直接返回Codemsg,而这里抛出的异常就会被之前设计的异常处理器拦截,进行处理

  3. 分布式session

    在用户登陆时随机生成一个token(Java自带生成uuid工具类),并把这个token写入到cookie中,同时也要在redis缓存中存储这个token和对应的用户对象,并且要保证这个cookie的max-age和这个缓存的expireSeconds一样长

    验证token的方式:客户端可能通过两种方式上传token,可能是传统的cookie,也可能通过参数上传,如果所有的页面都要使用cookie的话,就得在对应的方法里面传入response,用@CookieValue或者@RequestParam获取token的值,再通过redis获取用户信息,这样很不方便

    改进:使用全局的WebConfig配置(@Configuration注解),重写ArgumentResolver(用于处理Controller请求参数),把获取token并且根据token获取用户对象的操作逻辑放在这里面,这样无论什么界面,都可以直接获取这个用户对象

    注意:在每次通过token获取用户信息之后要重置缓存的有效期

秒杀功能

  1. 数据库设计

    除了最基本的商品表和订单表,还需要单独的秒杀商品表和秒杀订单表,之所以要单独出来,是因为可以方便维护,不用频繁地去修改商品表和订单表

    为防止同一用户秒杀两单,在订单表里面对用户id和商品id建立唯一索引

  2. 商品列表页

    在dao层通过sql查询所有商品表并连接秒杀商品表,再输入Controller里面通过model传到前端

  3. 商品详情页

    通过@PathVariable注解获取url中的商品id,然后通过id查询信息返回即可

  4. 秒杀功能

    先判断库存,再判断该用户是否秒杀过该商品,最后再通过事务执行秒杀操作:减库存、下订单、写入秒杀订单(这三个操作要用@Transactional注解打包成一个事务(原子性)

    减库存过程:传入商品id,通过id直接在数据库里面使用update语句使得stock_count=stock_count-1,在数据库里面减库存前再判断一次库存是否大于0

压力测试

工具:JMeter

方法:输入url和端口号,然后自定义一个http请求(方法、路径、token),新建一个线程组定义线程数,

结论:使用top命令行发现mysql的cpu占用率是最高的,通过减少数据库的访问可以提高并发度

页面优化

  1. 页面缓存

    第一次访问页面的时候手动渲染html页面:把数据存储在Context文件中传入thymeleafViewResolver,它会结合html的模板文件生成最终的html,然后将它存储在redis中,下次访问的时候就可以直接从redis里面获取html文件了

  2. url缓存

    在页面缓存的基础上,通过url的参数获取不同的缓存,可以理解成粒度更小的页面缓存

  3. 对象缓存

    在通过数据库取对象后把该对象放入redis中去,以后取对象先从redis里面找,找不到再从数据库找

    **注意:**使用了对象缓存后,要在对象发生改动的时候,同时更新缓存,并且一定不能去调用别的模块的dao(因为可能是有缓存的)

  4. 页面静态化(前后端分离)

    首先在项目的配置文件里添加有关静态文件的配置参数(如过期时间),然后把Controller里面的返回值都由原来的html文件改成Result并且加上@ResponseBody注解

  5. 解决超卖问题

    1. 数据库减库存之前加上库存判断,因为秒杀操作是一个事务,如果减库存失败的话就不会进行秒杀
    2. 给订单的用户id和商品id加上唯一索引,防止用户多次下单同件商品

接口优化

核心思路:减少数据库访问,优化后的秒杀过程如下

  1. 系统初始化时把数据库的库存读入到Reids中

    让MiaoshaController实现InitializingBean接口的afterPropertiesSet方法,在这个方法里面把所有秒杀商品的库存写入Redis中

  2. 预减库存

    调用秒杀接口的时候先在Redis里面让库存减一,如果此时库存小于0,直接返回错误信息即可

  3. 请求入队

    引入rabbitMQ中间件,使用Direct模式(一个生产者对一个消费者)

    新建一个队列,和消息的Sender,在Sender里用AmqpTemplate类实现向队列里面发送信息

    在2的基础上,如果预减库存后库存仍>=0,就调用这个Sender向队列中发送秒杀所需的信息(用户和商品id),然后返回排队中

  4. 请求出队,生成订单,减少库存

    新建一个消息的Receiver,在receive方法上加上@RabbitListener(queues=队列名)注解,从接收到的消息中获取用户信息和商品id,再次查询商品库存>0并且用户没有重复秒杀之后,才将数据库中的库存减一,并生成订单,如果此时没有库存,在返回错误的同时往Redis里面标记该商品已经卖完

  5. 客户端轮询

    通过用户id和商品id查询订单是否已生成以及从Redis里面查询有没有标记卖完,如果没有订单并且商品已经卖完返回秒杀失败,如果没有订单但商品没有标记卖完就返回排队中,如果有订单返回订单号并且可以根据订单号跳转到订单界面

  6. 使用内存标记减少Redis访问

    新建一个HashMap,键值分别是商品id和boolean,初始化的时候把所有商品id都读入进去并标记为false,访问redis之前先查询一下这个HashMap,如果为false才去访问redis。当redis的库存已经减为0,那么就把这个商品id标记为true,相当于就是在redis缓存外面再增加了一层缓存

安全优化

  1. 秒杀接口地址隐藏

    秒杀时先向服务端获取实际秒杀的地址,获取地址后访问,服务端对地址进行校验,无误后再进行秒杀

    地址生成:服务端生成一个随机数,并将其存到redis中,键为用户id和商品id,这样同一个用户秒杀同一个商品就有了单独的地址

    地址验证:把上述的随机数添加到url里面,服务端通过@PathVariable和用户id、商品id验证地址

  2. 图形验证码

    点击秒杀之前先输入图片公式的计算结果,可以起到分散用户请求,防止恶意刷新的作用,只有验证码正确了才会返回秒杀地址

    首先随机生成字符版的公式,可以定义几个随机数然后通过+、-、*进行组合,然后调用ScriptEngine里面的JavaScript引擎就可以自动计算出结果,生成验证码后同样将用户id和商品id作为key存到redis中

    生成图片的代码是网上找的,方法中传入上述随机生成的String类型的公式即可,图片通过javaIO里面的OutputStream输出

    验证:也是通过@RequestParam注解在获取秒杀路径的接口里面将其和redis中的验证码比对

  3. 接口防刷

    简易实现方法:以uri和用户id为key在redis中存储用户的访问次数,每次访问时先从redis里面获取,如果没有的话就添加上去并把值设为1,过期时间自定义,如果有的话判断它是否小于该过期时间内允许的最大次数,满足条件才允许访问

    进阶方法:自定义一个拦截器,在接口上加上拦截器的注解并传入时间和最大访问次数即可

    实现方法:定义一个类继承HandlerInterceptorAdapter类实现preHandle方法

    把判断用户是否访问过于频繁的代码放到这里面,这样使用的时候只需要加上注解和参数就可以了