项目简介
此项目为秒杀系统第一版(实现业务逻辑)的优化版本
页面级高并发秒杀优化(redis缓存+静态化分离)
加页面缓存
因为商品列表和商品详情变动不大,所以加一个过期时间为60s的缓存,这样可以避免频繁访问数据库
把页面html字符串放到redis中,每次访问先从redis取页面,如果取不到再从数据库中取数据然后进行手动的渲染thymeleaf模板(为了获取html字符串),然后加入缓存返回
为goods_list加缓存
首先新建一个通用前缀GoodsKey
过期时间设置为60s,设置的要短一些
public class GoodsKey extends BasePrefix {
public static GoodsKey getGoodsList = new GoodsKey(60,"gl");
public static GoodsKey getGoodsDetail = new GoodsKey(60,"gd");
改造GoodsController 的list方法(商品列表)
首先改造一下返回数据的格式 为html字符串 不再是让spring进行转发
注解 加上@ResponseBody
@RequestMapping(value = "/to_list",produces = "text/html")
@ResponseBody
public String list(HttpServletRequest request, HttpServletResponse response
, Model model, MiaoshaUser user)
设计思路就是,先去缓存里取goods_list取不到再访问数据库然后进行手动的渲染,再把他加到缓存里,最后返回
如何进行手动渲染?
需要使用thymeleafViewResolver 视图处理器获取到模板引擎然后处理
参数需要IContext接口的实现类WebContext
WebContext webContext = new WebContext(request,response
,request.getServletContext(),request.getLocale()
,model.asMap());
html = thymeleafViewResolver.getTemplateEngine().process("goods_list",webContext);
最后把html加到缓存里并返回到前段即可
为goods_detail加缓存
跟上面一样的
为对象加缓存
把user对象加到缓存里(不是会话机制),控制器登录时会用到MiaoshaUserService的login方法 而
login方法会调用本层的getbyid 获得user
这里强调下 service只能调用别人的service不可以调用别人的dao 因为service可能有缓存功能
更改MiaoshaUserService getById方法 新增一个userkey的前缀getById
设置缓存key为 “”+id
更新对象缓存 updatePassword
新增updatePassword方法(当前场景并没有用到此方法)
当更新用户密码的时候需要更新数据库和缓存里的user 先更新数据库然后再让缓存失效,然后把token对应的user更新一下
商品详情静态化
把GoodsController的detail方法copy一份,然后把旧方法改名
新方法的签名如下 不再返回html而只返回数据
@RequestMapping(value = "/detail/{goodsId}")
@ResponseBody
public Result<GoodsDetailVo> detail(@PathVariable("goodsId") long goodsId
, MiaoshaUser user)
{
静态化跟前后端分离是一样的,新建一个goods_detail.htm放在static文件夹下,然后把商品列表的跳转url更改下,改为这个静态资源的路径
新建一个GoodsDetailVo这商品详情的数据包括goodsVo user miaoshaStatus(秒杀状态) remainSeconds(倒计时)
最后返回return Result.success(goodsDetail);
前端用jquery来模拟分离 (ajax请求接口)
秒杀静态化
在商品详情点击立即秒杀按钮会发出ajax请求miaosha接口,如果返回状态码是0就重定向到订单详情页面
//get与post本质区别
@RequestMapping(value = "do_miaosha",method = RequestMethod.POST)
@ResponseBody
public Result<OrderInfo> miaosha(@RequestParam(value = "goodsId" , required = true) long goodsId
, MiaoshaUser user , Model model){
把miaosha方法改造一下,返回Result不再转发,这里请求用post
post与get的本质区别是 get请求数据 post提交数据改动数据比如 /delete?id=1
这种删除的接口如果用get请求很可能被搜索引擎遍历一遍给删除了
订单详情静态化与超卖问题
点击立即秒杀返回的订单id,重定向到这个页面然后再取出订单数据
@RequestMapping("/detail")
@ResponseBody
@NeedLogin
public Result<OrderDetailVo> orderDetail(@RequestParam("orderId") String orderId
, MiaoshaUser user){
新增拦截器加注解校验用户登录
利用aop可以拦截方法校验用户有没有登录
首先是定义一个注解 @NeedLogin
/**
* @author loser
* 自定义拦截注解
*/
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NeedLogin {
boolean required() default true;
}
然后定义用户登录的Interceptor LoginInterceptor
复写preHandle方法,然后判断一下handler是不是HandlerMethod或者子类(控制器方法)如果是的话,获取他的注解看下NeedLogin注解的值是否为true,为true的话调用工具类LoginUtil获取到user返回,没获取到抛出异常
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if(!(handler instanceof HandlerMethod)){
return true;
}
HandlerMethod method = (HandlerMethod) handler;
NeedLogin needLogin = method.getMethodAnnotation(NeedLogin.class);
if(needLogin == null || needLogin.required() == false){
return true;
}else{
MiaoshaUser user = LoginUtil.getUserByTokenOrRequest(request,response);
if(user != null){
return true;
}
throw new GlobalException(CodeMsg.SESSION_ERROR);
}
}
LoginUtil工具类 出错NullPoninter 给静态变量自动注入需要setter方法
这个工具类的主要逻辑跟之前的自定义参数解析器UserArgumentResolver的逻辑差不多
主要是这个方法跟拦截器重复所以单独抽出来了
逻辑改变为先从request里获取user 获取不到再从redis取,然后加入到request里
这里出错 如何为静态变量自动注入依赖
@Component
public class LoginUtil {
//为静态变量注入service 报出nullpointerexception
private static MiaoshaUserService userService;
@Autowired
public void setUserService(MiaoshaUserService userService) {
LoginUtil.userService = userService;
}
public static MiaoshaUser getUserByTokenOrRequest(HttpServletRequest request , HttpServletResponse response){
MiaoshaUser user = (MiaoshaUser) request.getAttribute("user");
if(user != null){
return user;
}
String cookieString = getCookie(request.getCookies());
String paramString = request.getParameter(MiaoshaUserService.COOKIE_NAME_TOKEN);
if(StringUtils.isEmpty(cookieString) && StringUtils.isEmpty(paramString)){
return null;
}
String token = StringUtils.isEmpty(cookieString) ? paramString : cookieString;
user = userService.getByToken(response,token);
if(user != null){
request.setAttribute("user",user);
}
return user;
}
最后把这个拦截器加入到配置里就行
WebConfig
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor);
}
超卖问题
减库存sql需要判断,避免库存出现负数的情况
数据库miaosha_order表添加唯一索引 userid和goodid 这样避免出现同一用户下单两次的情况
判断秒杀订单是否重复 秒杀订单也可以加入到redis中
静态资源优化
参考cdn 压缩css js
服务及高并发优化(rabbitmq+接口优化)
接口优化目标概述
- redis预减库存减少数据库访问
- 内存标记减少redis访问
- 请求入队缓冲,异步下单,增强用户体验
思路:减少数据库的访问
- 系统初始化后,把库存数量加载到redis
- 收到请求,预减库存,库存不足直接返回,否则进入第三个步骤
- 请求入队,立即返回排队中
- 请求出队,生成订单,减少库存
- 客户端轮询,是否秒杀成功
rabbitmq安装
可以在windows平台安装,也可以在linux平台安装
安装过程网上有博客
rabbitmq的四种交换器模式
rabbitmq的原理图
交换器起调度作用把生产的消息按照一定策略放在消息队列里面消费者从队列里取出消息
先说下rabbitmq服务代码的实现
先定义一个MQConfig
配置类,这个类主要定义queue名常量和队列的bean可能还有绑定交换器与队列的bean
@Configuration
public class MQConfig {}
然后定义一个发消息的服务对象MQSender
和监听消息的服务对象MQReceiver
发送对象包含一个依赖rabbitTemplate,这是spring实现的rabbitmq接口我们可以用它来发消息
@Service
public class MQSender {
private Logger log = LoggerFactory.getLogger(MQSender.class);
@Autowired
private AmqpTemplate rabbitTemplate;
接收对象只要在接收方法上加一个注解,queues值为队列名字就可以接收消息了
// @RabbitListener(queues = MQConfig.Queue)
// public void receive(String message){
// log.info("receive message:"+message);
// }
Direct模式
direct模式是按照routingkey匹配bindingkey实现的,因为topic模式是在这个原理的基础上加上通配符,所以direct模式我们不用交换器,发送的消息直接指定队列。
MQSender
中的发送代码如下
// public void send(Object message){
// String msg = RedisService.beanToString(message);
// rabbitTemplate.convertAndSend(MQConfig.Queue,message);
// log.info(“sendmessage:”+msg);
// }
// public void send(Object message){
// String msg = RedisService.beanToString(message);
// rabbitTemplate.convertAndSend(MQConfig.Queue,message);
// log.info("sendmessage:"+msg);
// }
把object的消息转换成json字符串可以用redissevice里面的方法
Topic模式
topic模式发送消息就需要指定交换器的bean了,而且还需要指定routingkey
路由键规则 一组用.分隔的字符 *通配符表示一个词,#表示0个或多个词
定义绑定的bean,queue1绑定的topic.key1 queue2绑定的topic.# 那么queue2会接收到所有以topic开头的消息
// @Bean
// public TopicExchange getTopicExchange(){
// return new TopicExchange(TOPIC_EXCHANGE);
// }
//
// @Bean
// public Binding topicBinding1(){
// return BindingBuilder.bind(getTopicQueue1()).to(getTopicExchange()).with("topic.key1");
// }
//
// @Bean
// public Binding topicBinding2(){
// return BindingBuilder.bind(getTopicQueue2()).to(getTopicExchange()).with("topic.#");
// }
send消息方法
// public void sendTopic(Object message){
// String msg = RedisService.beanToString(message);
// rabbitTemplate.convertAndSend(MQConfig.TOPIC_EXCHANGE,"topic.key1",msg);
// rabbitTemplate.convertAndSend(MQConfig.TOPIC_EXCHANGE,"topic.key2",msg);
// log.info("sendmessage:"+msg);
// }
Headers模式 自定义规则
自定义规则了解
Fanout模式 广播模式
只要交换机绑定的队列都可以收到消息
接口优化
预减库存以及内存标记
首先是让MiaoshaController实现容器初始化接口InitializingBean ,实现afterPropertiesSet这个方法,把商品列表取出来然后把库存存入redis。
这里定义一个服务器的map内存标记 localOverMap,key为goodsid,value为布尔值代表商品是否卖光,减少对redis访问
public class MiaoShaController implements InitializingBean
/**
* 初始化容器将库存存入redis
* @throws Exception
*/
@Override
public void afterPropertiesSet() throws Exception {
List<GoodsVo> goodsList = goodsService.goodsVoList();
if(goodsList == null){
return;
}
for(GoodsVo good:goodsList){
redisService.set(GoodsKey.getGoodsStock,""+good.getId(),good.getStockCount());
localOverMap.put(good.getId(), false);
}
}
然后就是更改miaosha方法,先检查是否重复秒杀从redis取出秒杀订单,没有的话就先预减库存,**调用redis的decr方法(原子操作)**然后把下单信息入队,返回成功
@RequestMapping(value = "do_miaosha",method = RequestMethod.POST)
@ResponseBody
@NeedLogin
public Result<Integer> miaosha(@RequestParam(value = "goodsId" , required = true) long goodsId
, MiaoshaUser user , Model model){
//先判断内存里的库存是否为0减少对redis访问
boolean res = localOverMap.get(goodsId);
if(res){
return Result.error(CodeMsg.MIAO_SHA_OVER);
}
//检查是否重复秒杀
MiaoshaOrder miaoshaOrder = orderService
.getMiaoshaOrderByGoodsIdUserId(goodsId,user.getId());
if(miaoshaOrder != null){
return Result.error(CodeMsg.REPEATE_MIAO_SHA);
}
//预减库存
long stock = redisService.decr(GoodsKey.getGoodsStock,""+goodsId);
if(stock < 0){
localOverMap.put(goodsId,true);
return Result.error(CodeMsg.MIAO_SHA_OVER);
}
//下单入队
MiaoshaMessage mm = new MiaoshaMessage();
mm.setUser(user);
mm.setGoodsId(goodsId);
sender.sendMiaoshaMessage(mm);
return Result.success(0);
异步下单
先在rabbitmq包下定义一个MiaoshaMessage 秒杀的信息对象 字段是user和goodsId
然后就是MQConfig类定义秒杀队列,MQSender的sendMiaoshaMessge将这个消息发送到队列里
public void sendMiaoshaMessage(MiaoshaMessage mm){
String msg = RedisService.beanToString(mm);
rabbitTemplate.convertAndSend(MQConfig.MIAOSHA_QUEUE,msg);
}
这里的接收方法做的和以前控制器的miaosha方法做的事差不多
先检查数据库的库存然后判断是否重复秒杀然后调用service的miaosha
@RabbitListener(queues = MQConfig.MIAOSHA_QUEUE)
public void receive(String message){
log.info("receive message:"+message);
MiaoshaMessage mm = RedisService.stringToBean(message,MiaoshaMessage.class);
//检测数据库库存
GoodsVo goods = goodsService.getGoodsVoByGoodsId(mm.getGoodsId());
if(goods.getStockCount()<=0){
return;
}
//判断是否已经秒杀到了
MiaoshaOrder order = orderService.getMiaoshaOrderByGoodsIdUserId( mm.getGoodsId(),mm.getUser().getId());
if(order != null) {
return;
}
//下单获取订单信息
miaoshaService.miaosha(mm.getUser(),goods);
}
然后是service层的miaosha方法,减库存 下单 存入秒杀订单,这里减库存失败的话调用setGoodsOver在redis上标记下已经卖完
@Transactional
public OrderInfo miaosha(MiaoshaUser user, GoodsVo goods) {
//减库存 下单 存入秒杀订单
boolean success = goodsService.reduceStock(goods);
if(success){
return orderService.createOrder(user,goods);
}else{
setGoodsOver(goods.getId());
return null;
}
}
客户端轮询
前端的ajax请求会不断的向后端要结果 -1表示失败 0表示排队 正数则表示订单id
控制器的方法如下,调用服务层的getMiaoshaResult
/**
* 返回 -1 代表失败 返回0代表排队 返回正数是orderid值
* @param user
* @param goodsId
* @return
*/
@RequestMapping(value = "/result",method = RequestMethod.GET)
@ResponseBody
@NeedLogin
public Result<Long> getMiaoshaResult(MiaoshaUser user,@RequestParam("goodsId") long goodsId){
long res = miaoshaService.getMiaoshaResult(user,goodsId);
return Result.success(res);
}
服务层这里的逻辑是从redis中获取订单,如过存在直接返回id,不存在则看一下redis中的数据库库存标记getGoodsOver 如果没了则返回秒杀失败,否则返回0排队中
public long getMiaoshaResult(MiaoshaUser user, long goodsId) {
MiaoshaOrder order = orderService.getMiaoshaOrderByGoodsIdUserId(goodsId,user.getId());
if(order != null){
return order.getOrderId();
}else{
boolean isOver = getGoodsOver(goodsId);
if(isOver){
return -1;
}else{
return 0;
}
}
}
压测(讲解nginx横向拓展)
利用nginx可以把项目横向拓展
nginx把所有请求反向代理给pool(集群) 在pool里配置多台服务器
weight参数的值代表负载均衡
图形验证码及恶意防刷
- 秒杀接口地址隐藏 动态获取秒杀的地址 防止开始前偷跑
- 数学公式验证码
- 接口限流防刷
隐藏秒杀地址
- miaosha接口改造,带上pathvariable参数
- 添加生成地址的接口
- 秒杀收到请求,先验证pathvariable
隐藏秒杀地址的目的是为了 **防止恶意用户写程序一直请求秒杀接口,**在00s开始的时候就下单了,服务端对每个用户都产生一个加密path参数,前端拼接处真正的地址,只有获得地址才能请求秒杀。
前端请求getMiaoshaPath这个接口,带上token和goodsId的信息,控制器调用MiaoshaService的创建path方法
生成的加密字符串 的key为 商品和用户id存到redis里
然后前端更改一下,第一步先请求path接口然后获取到地址,再请求后端的miaosha接口
这里调用服务层的方法验证一下path是否正确
图形验证码
- 添加生成验证码的接口
- 在获取秒杀路径时候,验证验证码
- scriptengine 可以计算字符串表达式的值 如 1+2
图形验证码为了流量削峰用,把1s的请求分散到10s
这里在内存生成一个验证码图片,然后把它刷新到输出流当中
更改获取path的方法,加上验证码的验证
这里验证验证码,如果正确返回true并把redis中的验证码删除。
接口限流防刷
可以用拦截器减少对业务侵入,不同的接口可能需要不同的访问次数的阈值
先定义一个Accesslimit访问限流的注解
public @interface AccessLimit {
int seconds() default 5;
int maxCount() default 30;
boolean needLogin() default true;
}
然后定义AccessInterceptor拦截器,获取注解的值,然后在redis里设置请求接口的次数,如果超过定义的值则返回请求(利用response输出流)频繁并且拦截请求
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if(handler instanceof HandlerMethod){
MiaoshaUser user = UserContext.getUser();
if(user == null){
user = getUser(request, response);
UserContext.setUser(user);
}
HandlerMethod hm = (HandlerMethod) handler;
AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);
if(accessLimit == null){
return true;
}
int seconds = accessLimit.seconds();
int maxCount = accessLimit.maxCount();
boolean needLogin = accessLimit.needLogin();
String key = request.getRequestURI();
if(needLogin){
if(user == null){
render(response, CodeMsg.SESSION_ERROR);
}
key += "_" +user.getId();
}
AccessKey ak = AccessKey.withExpire(seconds);
Integer count = redisService.get(ak,key,Integer.class);
if(count == null){
redisService.set(ak,key,2);
}else if(count < maxCount){
log.info("接口加1");
redisService.incr(ak,key);
}else{
render(response,CodeMsg.REQUEST_FREQUENTLY);
return false;
}
}
return true;
}
这里获取user用的是UserArgumentResolver的逻辑 把原先needlogin注解的逻辑删除放到这个类,然后把获取到的user存到UserContext这个threadlocal容器中,保证线程安全