秒杀系统
秒杀系统
项目介绍
使用SpringBoot和Mybatis框架搭建基本的电商系统,改进为支持高并发的秒杀系统
实现逻辑
项目框架搭建
-
搭建SpringBoot环境
-
集成Thymeleaf
-
封装Result结果
Controller的返回值Result定义如下
1
2
3
4public 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即可
-
集成Mybatis和Druid
dao包用来执行sql语句,语句中#{} 解析的是占位符?可以防止SQL注,而${}是拼接符,会直接替换成变量的值,没有单引号
daomain包用来存放数据表的实体类
-
集成Jedis
首先把application.properties中的配置读入到配置文件,用配置文件中的参数建立一个JedisPool连接池,并注入到容器中
在使用时,每次调用jedispool的getSource()方法生成一个jedis客户端就可以了,使用结束后一定要记得关闭jedis客户端防止资源浪费
往redis里面存数据的时候需要一个JSON.toJSONString()方法把对象转化成字符串,同理在读数据的时候也要用一个JSON.toJavaObject()方法把字符串转化回来
-
封装通用缓存Key
因为一个redis数据库中可能要存放不同业务的缓存,为了区别这些缓存需要给Key加上一个前缀Perfix
为了使用方便,采用模板模式,先定义一个前缀的接口
1
2
3
4public 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
4public class UserKeyPerfix extends BasePrefix{
private UserKeyPerfix(String prefix) {super(prefix);}
public static UserKeyPerfix getById = new UserKeyPerfix("id");
}最终生成的Key就是
prefix.getPrefix() + key
登陆功能
-
明文密码使用两次md5操作入库
第一次,为防止用户密码在明文传输的http协议中被泄露,用户端输入的密码要进行一次md5加密操作再传输至服务端
password1=md5(明文密码+固定salt)
这步操作在前端完成
第二次,为防止数据库泄露带来的风险,服务端接受到密码后还要进行一次md5加密操作再写入数据库中
password2=md5(password1+随机salt)
这个随机salt也要一并写入数据库中,不然以后就没法验证了
-
集成jsr303参数校验
每次要在Controller里面对传输的数据进行逐条的校验很麻烦,导入validation这个依赖,只需要在Controller里,传输的数据类前加上@Valid注解,并在该数据类的参数上加上参数校验的注解比如@NotNull,这样出错了就会自动报错,但这个报错信息不方便处理,可以设计一个全局异常的处理器,添加@ExceptionHandler注解,根据异常的种类返回不同的Result
同时定义一个含有Codemsg参数的全局异常类,在出现错误时抛出异常而不是直接返回Codemsg,而这里抛出的异常就会被之前设计的异常处理器拦截,进行处理
-
分布式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获取用户信息之后要重置缓存的有效期
秒杀功能
-
数据库设计
除了最基本的商品表和订单表,还需要单独的秒杀商品表和秒杀订单表,之所以要单独出来,是因为可以方便维护,不用频繁地去修改商品表和订单表
为防止同一用户秒杀两单,在订单表里面对用户id和商品id建立唯一索引
-
商品列表页
在dao层通过sql查询所有商品表并连接秒杀商品表,再输入Controller里面通过model传到前端
-
商品详情页
通过@PathVariable注解获取url中的商品id,然后通过id查询信息返回即可
-
秒杀功能
先判断库存,再判断该用户是否秒杀过该商品,最后再通过事务执行秒杀操作:减库存、下订单、写入秒杀订单(这三个操作要用@Transactional注解打包成一个事务(原子性)
减库存过程:传入商品id,通过id直接在数据库里面使用update语句使得stock_count=stock_count-1,在数据库里面减库存前再判断一次库存是否大于0
压力测试
工具:JMeter
方法:输入url和端口号,然后自定义一个http请求(方法、路径、token),新建一个线程组定义线程数,
结论:使用top命令行发现mysql的cpu占用率是最高的,通过减少数据库的访问可以提高并发度
页面优化
-
页面缓存
第一次访问页面的时候手动渲染html页面:把数据存储在Context文件中传入thymeleafViewResolver,它会结合html的模板文件生成最终的html,然后将它存储在redis中,下次访问的时候就可以直接从redis里面获取html文件了
-
url缓存
在页面缓存的基础上,通过url的参数获取不同的缓存,可以理解成粒度更小的页面缓存
-
对象缓存
在通过数据库取对象后把该对象放入redis中去,以后取对象先从redis里面找,找不到再从数据库找
**注意:**使用了对象缓存后,要在对象发生改动的时候,同时更新缓存,并且一定不能去调用别的模块的dao(因为可能是有缓存的)
-
页面静态化(前后端分离)
首先在项目的配置文件里添加有关静态文件的配置参数(如过期时间),然后把Controller里面的返回值都由原来的html文件改成Result并且加上@ResponseBody注解
-
解决超卖问题
- 数据库减库存之前加上库存判断,因为秒杀操作是一个事务,如果减库存失败的话就不会进行秒杀
- 给订单的用户id和商品id加上唯一索引,防止用户多次下单同件商品
接口优化
核心思路:减少数据库访问,优化后的秒杀过程如下
-
系统初始化时把数据库的库存读入到Reids中
让MiaoshaController实现InitializingBean接口的afterPropertiesSet方法,在这个方法里面把所有秒杀商品的库存写入Redis中
-
预减库存
调用秒杀接口的时候先在Redis里面让库存减一,如果此时库存小于0,直接返回错误信息即可
-
请求入队
引入rabbitMQ中间件,使用Direct模式(一个生产者对一个消费者)
新建一个队列,和消息的Sender,在Sender里用AmqpTemplate类实现向队列里面发送信息
在2的基础上,如果预减库存后库存仍>=0,就调用这个Sender向队列中发送秒杀所需的信息(用户和商品id),然后返回排队中
-
请求出队,生成订单,减少库存
新建一个消息的Receiver,在receive方法上加上
@RabbitListener(queues=队列名)
注解,从接收到的消息中获取用户信息和商品id,再次查询商品库存>0并且用户没有重复秒杀之后,才将数据库中的库存减一,并生成订单,如果此时没有库存,在返回错误的同时往Redis里面标记该商品已经卖完 -
客户端轮询
通过用户id和商品id查询订单是否已生成以及从Redis里面查询有没有标记卖完,如果没有订单并且商品已经卖完返回秒杀失败,如果没有订单但商品没有标记卖完就返回排队中,如果有订单返回订单号并且可以根据订单号跳转到订单界面
-
使用内存标记减少Redis访问
新建一个HashMap,键值分别是商品id和boolean,初始化的时候把所有商品id都读入进去并标记为false,访问redis之前先查询一下这个HashMap,如果为false才去访问redis。当redis的库存已经减为0,那么就把这个商品id标记为true,相当于就是在redis缓存外面再增加了一层缓存
安全优化
-
秒杀接口地址隐藏
秒杀时先向服务端获取实际秒杀的地址,获取地址后访问,服务端对地址进行校验,无误后再进行秒杀
地址生成:服务端生成一个随机数,并将其存到redis中,键为用户id和商品id,这样同一个用户秒杀同一个商品就有了单独的地址
地址验证:把上述的随机数添加到url里面,服务端通过@PathVariable和用户id、商品id验证地址
-
图形验证码
点击秒杀之前先输入图片公式的计算结果,可以起到分散用户请求,防止恶意刷新的作用,只有验证码正确了才会返回秒杀地址
首先随机生成字符版的公式,可以定义几个随机数然后通过+、-、*进行组合,然后调用ScriptEngine里面的JavaScript引擎就可以自动计算出结果,生成验证码后同样将用户id和商品id作为key存到redis中
生成图片的代码是网上找的,方法中传入上述随机生成的String类型的公式即可,图片通过javaIO里面的OutputStream输出
验证:也是通过@RequestParam注解在获取秒杀路径的接口里面将其和redis中的验证码比对
-
接口防刷
简易实现方法:以uri和用户id为key在redis中存储用户的访问次数,每次访问时先从redis里面获取,如果没有的话就添加上去并把值设为1,过期时间自定义,如果有的话判断它是否小于该过期时间内允许的最大次数,满足条件才允许访问
进阶方法:自定义一个拦截器,在接口上加上拦截器的注解并传入时间和最大访问次数即可
实现方法:定义一个类继承HandlerInterceptorAdapter类实现preHandle方法
把判断用户是否访问过于频繁的代码放到这里面,这样使用的时候只需要加上注解和参数就可以了