高并发秒杀系统方案的优化

最近接触了一个关于高并发秒杀的项目,在这里稍微整理一下关于这个项目的一些值得记录的一些点,以下是源码地址:github
高并发项目的瓶颈主要在于数据库访问次数上,访问次数越多,对数据库压力也就越大,因此项目中主要也是通过redis进行缓存以及RabbitMQ进行请求入队缓冲等来减少对数据库的访问。

一、项目结构搭建

1、集成Thymeleaf

Thymeleaf是一个Java库,是一个XML/XHTML/HTML5模板引擎,能够应用于转换模板文件,以显示应用程序产生的数据和文本。
Thymeleaf旨在提供⼀个优雅的、⾼度可维护的创建模板的⽅式。 为了实现这⼀⽬标,Thymeleaf建⽴在⾃然模板的概念上,将其逻辑注⼊到模板⽂件中,不会影响模板设计原型。 这改善了设计的沟通,弥合了设计和开发团队之间的差距。
Thymeleaf是一个类似于JSP的模板引擎,但他的区别在于,在运行项目之前,Thymeleaf也是纯HTML,可以在没有服务端的情况下进行运行,但JSP需要在服务端的支持下进行一定的转换。
Thymeleaf主要有以下三个优点:

  1. Thymeleaf不管在有网络或者无网络的情况下都可以运行,因为它支持html原型,通过在html标签中增加额外的属性来达到模板+数据的展示方式,当有数据返回到页面时,Thymeleaf标签会动态地替换掉静态内容,使页面动态显示。
  2. Thymeleaf 开箱即用的特性。它提供标准和spring标准两种方言,可以直接套用模板实现JSTL、 OGNL表达式效果,避免每天套模板、该jstl、改标签的困扰。同时开发人员也可以扩展和创建自定义的方言。
  3. Thymeleaf 提供spring标准方言和一个与 SpringMVC 完美集成的可选模块,可以快速的实现表单绑定、属性编辑器、国际化等功能.
//集成thymeleaf
<dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-thymeleaf</artifactId>
      <version>1.5.2.RELEASE</version>
</dependency>

2、集成Redis

redis的集成,其中使用jedisPool获取jedis,并使用jedis的方法封装在RedisService当中,方便后来使用redis进行缓存

/**
     * 设置对象
     * @param prefix 不同的前缀对应不同的缓存信息
     * @param key 该信息具体的的key值
     * @param value
     * @param <T>
     * @return
     */
    public <T> boolean set(KeyPrefix prefix,String key,T value){
    
    
        Jedis jedis=null;
        try{
    
    
            jedis=jedisPool.getResource();
            String str=beanToString(value);
            if(str==null||str.length()<=0) return false;
            //获取有前缀的key
            String realKey=prefix.getPrefix()+key;
            int seconds=prefix.expireSeconds();
            if(seconds<=0){
    
    
                jedis.set(realKey,str);
            }else{
    
    
                jedis.setex(realKey,seconds,str);
            }
            return true;
        }finally{
    
    
            returnToPool(jedis);
        }
    }

而前缀是为了分辨不同的缓存,例如商品缓存、订单缓存、用户缓存等等,其中的expireSeconds则是缓存的有效时间,0代表永久有效。

public class GoodsKey extends BasePrefix{
    
    
    private GoodsKey(int expireSeconds,String prefix) {
    
    
        super(expireSeconds,prefix);
    }
    public static GoodsKey getGoodsList=new GoodsKey(60,"gl");
    public static GoodsKey getGoodsDetail=new GoodsKey(60,"gd");
    public static GoodsKey getMiaoshaGoodsStock=new GoodsKey(0,"gs");

    }

3、JMeter的使用

Apache JMeter是Apache组织开发的基于Java的压力测试工具。用于对软件做压力测试,JMeter是一个下载即用的软件,我们使用它来进行高并发的模拟,而它会在进行模拟后给予我们一些可视化的结果,是一个很强大的工具。似乎在5.的版本中,已经不需要去配置JMETER_HOME就可以运行了。在使用过程中,最大的感想就是这个软件巨坑,不知道是不是硬件的问题,在进行500010的并发压测中,出现大量的error,而每次出现error的情况都不尽相同,让人很是头疼。

4、Result的封装

这是对每次访问结果的一个封装,而CodeMsg类则是对返回码和信息的封装,而Result则是对CodeMsg和对data的封装,在访问过后,我们可以选择生成Result.success对象返回任何类型的信息,异或是返回Result.error来返回错误码和错误信息。

public class Result<T>{
    
    
    private int code;
    private String msg;
    private T data;

    /**
     * 成功时候的调用
     */
    public static <T> Result<T> success(T data){
    
    
        return new Result<T>(data);
    }
    /**
     * 失败时候的调用
     */
    public static <T> Result<T> error(CodeMsg codeMsg){
    
    
        return new Result<T>(codeMsg);
    }


    private Result(T data){
    
    
        this.data=data;
    }
    private Result(int code,String msg){
    
    
        this.code=code;
        this.msg=msg;
    }
    private Result(CodeMsg codeMsg){
    
    
        if(codeMsg!=null){
    
    
            this.code=codeMsg.getCode();
            this.msg=codeMsg.getMsg();
        }
    }

    public int getCode() {
    
    
        return code;
    }
    public void setCode(int code) {
    
    
        this.code = code;
    }
    public String getMsg() {
    
    
        return msg;
    }
    public void setMsg(String msg) {
    
    
        this.msg = msg;
    }
    public T getData() {
    
    
        return data;
    }
    public void setData(T data) {
    
    
        this.data = data;
    }
}

二、功能实现

功能实现这一块算是比较常规的实现,毕竟这个项目的重心在于秒杀的优化而不在于具体的实现,只是实现了常规的登录,商品列表,商品秒杀等功能。

1、全局异常控制器

为了使得代码更清爽而不十分复杂,我们实现了一个全局异常控制器。在这个类中,@ControllerAdvice是一个增强的Controller注解,使用这个Controller,可以实现以下功能:全局异常处理、全局数据绑定、全局数据预处理(SpringMVC提供,在SpringBoot中可以直接使用)@ExceptionHandler:如果只使用此注解,只能在当前Controller中处理异常,当配合ControllerAdvice一起使用的时候,就可以摆脱这种限制了。

import com.yuan.SecondsKill.result.CodeMsg;
import com.yuan.SecondsKill.result.Result;
import org.springframework.validation.BindException;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import javax.servlet.http.HttpServletRequest;
import java.util.List;

/**
 * 全局异常控制器
 * 拦截异常,在页面上显示错误信息
 */
@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {
    
    
    @ExceptionHandler(value = Exception.class)
    public Result<String> exceptionHandler(HttpServletRequest request,Exception e){
    
    
        e.printStackTrace();
        if(e instanceof GlobalException){
    
    
            GlobalException ge=(GlobalException)e;
            return Result.error(ge.getCm());
        }
        if(e instanceof BindException){
    
    
            BindException ex=(BindException)e;
            List<ObjectError> errors = ex.getAllErrors();
            ObjectError error = errors.get(0);
            String msg=error.getDefaultMessage();
            return Result.error(CodeMsg.BIND_ERROR.fillArgs(msg));
        }else{
    
    
            return Result.error(CodeMsg.SERVER_ERROR);
        }
    }
}

在登陆方法中,使用@Valid注解配合全局处理器达到对手机号和密码进行格式校验的目的

	@RequestMapping("/do_login")
    @ResponseBody
    public Result<String> doLogin(HttpServletResponse response, @Valid LoginVo loginVo){
    
    
        log.info(loginVo.toString());
        return null;
        }

实体类中的注解来校验是否为空等等,其中的isMobile是自己定义的。

public class LoginVo {
    
    
    @NotNull
    @isMobile
    private String mobile;

    @NotNull
    @Length(min=6)
    private String password;
}
@Target({
    
    ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = {
    
    isMobileValidator.class})
public @interface isMobile {
    
    
    boolean required() default true;

    String message() default "手机号格式错误";

    Class<?>[] groups() default {
    
    };

    Class<? extends Payload>[] payload() default {
    
    };
}

2、拦截器的使用

使用拦截器与实现了HandlerMethodArgumentResolver接口的类从session中获取user对象,并作为参数注入Controller中。

3、分布式Session的使用

此处分布式session的实现主要是在登录的过程中,为用户随机生成一个token,并保存到数据库中,随后封装成cookie返回前端,并且,获取用户也由这个cookie中的token来实现。关于分布式Session一般有4种解决方案,详细可参考这篇文章:分布式Session的4种解决方案。项目中所使用的就是其中的利用cookie记录Session。

三、页面缓存优化

页面缓存你优化主要使用redis完成了页面缓存、URL缓存和对象缓存,并且使得频繁访问的商品详情页面和订单详情页面静态化,成为纯html页面,使用ajax异步的方式与后端交互,实现前后端分离。另外还有静态资源优化和CDN优化没有实现。

1、页面缓存

这里是一个商品列表的展示,可以看到前后代码的区别,刚开始只是直接去数据库中获取商品列表并返回到前台的页面,前台页面获取其中的信息进行填充。而下面的代码则是对页面做了一个缓存,流程大概是这样的:从redis中取缓存->从数据库中取数据->手动渲染模板->返回html。
首先从redis中取相应的缓存,如果有直接返回,没有则到数据库中取商品列表的数据,并且进行手动渲染,渲染后将html页面存进redis中,并且返回。在redis的保存方法中设置了过期时间,默认是60秒。

@RequestMapping(value="/to_list")
    public String list(Model model,MiaoshaUser user) {
    
    
    	model.addAttribute("user", user);
    	List<GoodsVo> goodsList = goodsService.listGoodsVo();
    	model.addAttribute("goodsList", goodsList);
    	 return "goods_list";
    }
/**
     * 页面缓存:取缓存、手动渲染模板和结果输出
     * @param model
     * @param user
     * @return
     */
    @RequestMapping(value = "/to_list",produces = "text/html")
    @ResponseBody
    public String toList(HttpServletRequest request, HttpServletResponse response,
                         Model model, KillsUser user){
    
    
        //无cookie返回登录界面
        model.addAttribute("user",user);
        //取缓存,如果redis中有相应的缓存,直接取出使用
        String html = redisService.get(GoodsKey.getGoodsList, "", String.class);
        if(!StringUtils.isEmpty(html)){
    
    
            return html;
        }
        //查询商品列表
        List<GoodsVo> goodsList = goodsService.listGoodsVo();
        model.addAttribute("goodsList",goodsList);
        //return "goods_list";

        SpringWebContext ctx=new SpringWebContext(request,response,request.getServletContext(),
                request.getLocale(),model.asMap(),applicationContext);
        //手动渲染模板并且存入redis,框架渲染的具体方式也是如此
        html=thymeleafViewResolver.getTemplateEngine().process("goods_list",ctx);
        if(!StringUtils.isEmpty(html)){
    
    
            redisService.set(GoodsKey.getGoodsList,"",html);
        }
        //结果返回
        return html;
    }

redis页面缓存

2、对象缓存

根据token对秒杀对象进行缓存,如果缓存中还有秒杀对象的信息,则不去数据库进行取出。
在这里插入图片描述

3、页面静态化

页面静态化常用的技术为AngularJS、Vue.js等等,此处使用js简单模拟该过程,实现前后端分离。页面静态化,其实就是把用户经常访问的页面做成一个静态的页面,避免每次都要从数据库取出数据并且渲染到对应的模板文件中,从而减少服务器的压力。
页面静态化是将动态渲染生成的画面保存为html文件,放到静态文件服务器中。用户访问的是处理好的静态文件,而需要展示不同数据内容时,则可以在请求完静态化的页面后,在页面中向后端发送请求,获取属于用户的特殊数据。
页面静态化是一个以空间换时间的操作,虽然页面增多时会占用更多的空间,但提高了网站的访问速度,提升了用户的体验,还是非常值得的。
关于原来的页面,正常的前端访问后端接口,获取html页面,展示,而页面静态化,是利用ajax异步传递数据,在前台进行数据的填充,实现了前后端分离。

@RequestMapping(value = "/to_detail/{goodsId}",produces = "text/html")
    @ResponseBody
    public String detail(HttpServletRequest request, HttpServletResponse response,
                         Model model,KillsUser user,
            @PathVariable("goodsId")long goodsId){
    
    
        model.addAttribute("user",user);

        //取缓存,如果redis中有相应的缓存,直接取出使用
        String html = redisService.get(GoodsKey.getGoodsDetail, ""+goodsId, String.class);
        if(!StringUtils.isEmpty(html)){
    
    
            return html;
        }

        GoodsVo goods=goodsService.getGoodsVoByGoodsId(goodsId);
        model.addAttribute("goods",goods);

        long startAt=goods.getStartDate().getTime();
        long endAt=goods.getEndDate().getTime();
        long now=System.currentTimeMillis();

        int KillStatus=0;//表示还未秒杀
        int remainSeconds=0;

        if(now < startAt){
    
    //秒杀未开始
            KillStatus=0;
            remainSeconds=(int)((startAt-now)/1000);
        }else if(now > endAt){
    
    //秒杀已结束
            KillStatus=2;
            remainSeconds=-1;
        }else{
    
    //秒杀进行中
            KillStatus=1;
            remainSeconds=0;
        }
        model.addAttribute("KillStatus",KillStatus);
        model.addAttribute("remainSeconds",remainSeconds);


        SpringWebContext ctx=new SpringWebContext(request,response,request.getServletContext(),
                request.getLocale(),model.asMap(),applicationContext);
        //手动渲染模板并且存入redis,框架渲染的具体方式也是如此
        html=thymeleafViewResolver.getTemplateEngine().process("goods_detail",ctx);
        if(!StringUtils.isEmpty(html)){
    
    
            redisService.set(GoodsKey.getGoodsDetail,""+goodsId,html);
        }
        //结果返回
        return html;
        //return "goods_detail";
    }

前端代码通过ajax异步请求后端的接口返回数据,并在前台取出数据对页面进行填充。

//前端
$(function () {
    
    
    //countDown();
    getDetail();
});
function getDetail(){
    
    
    var goodsId=g_getQueryString("goodsId");
    $.ajax({
    
    
        url:"/goods/detail2/"+goodsId,
        type:"GET",
        success:function(data){
    
    
            if(data.code==0){
    
    
                render(data.data);
            }else{
    
    
                layer.msg(data.msg);
            }
        },
        error:function(){
    
    
            layer.msg("客户端请求有误")
        }

    });
}
/*text() :设置或返回匹配元素的内容
* val() :设置或返回匹配元素的值
* */
function render(detail){
    
    
    var goods=detail.goods;
    var KillStatus=detail.KillStatus;
    var remainSeconds=detail.remainSeconds;
    var user=detail.user;
    if(user){
    
    
        $("#userTip").hide();
    }
    $("#goodsName").text(goods.goodsName);
    $("#goodsImg").attr("src",goods.goodsImg);
    $("#startDate").text(new Date(goods.startDate).format("yyyy-MM-dd hh:mm:ss"))
    $("#remainSeconds").val(remainSeconds);
    $("#goodsId").val(goods.id);
    $("#goodsPrice").text(goods.goodsPrice);
    $("#miaoshaPrice").text(goods.miaoshaPrice);
    $("#stockCount").text(goods.stockCount);
    countDown();
}
//后台
@RequestMapping(value = "/detail2/{goodsId}")
    @ResponseBody
    public Result<GoodsdetailVo> detail2(HttpServletRequest request, HttpServletResponse response,
                                         Model model, KillsUser user,
                                         @PathVariable("goodsId")long goodsId){
    
    
        GoodsVo goods=goodsService.getGoodsVoByGoodsId(goodsId);

        long startAt=goods.getStartDate().getTime();
        long endAt=goods.getEndDate().getTime();
        long now=System.currentTimeMillis();

        int KillStatus=0;//表示还未秒杀
        int remainSeconds=0;

        if(now < startAt){
    
    //秒杀未开始
            KillStatus=0;
            remainSeconds=(int)((startAt-now)/1000);
        }else if(now > endAt){
    
    //秒杀已结束
            KillStatus=2;
            remainSeconds=-1;
        }else{
    
    //秒杀进行中
            KillStatus=1;
            remainSeconds=0;
        }

        GoodsdetailVo vo=new GoodsdetailVo();
        vo.setGoods(goods);
        vo.setKillStatus(KillStatus);
        vo.setRemainSeconds(remainSeconds);
        vo.setUser(user);
        return Result.success(vo);
    }

四、接口优化

接下来是对于秒杀接口的优化,这是最初始的秒杀接口:
刚开始的秒杀接口的业务流程是这样的:判断用户状态->获取商品库存并判断->获取秒杀订单并判断->减库存。这个过程需要调用数据库共3次。
秒杀需要面对一个很严峻的问题:如何防止超卖或者少卖?此项目中总共用了两种方式,第一种是在减库存的sql语句中加上对库存的判断,来防止库存变成负数;第二种是对数据库中的秒杀订单表中的用户id列和商品id列加上唯一索引,防止用户重复购买。

@RequestMapping("/do_miaosha")
    public String miaosha(Model model, KillsUser user,
                         @RequestParam("goodsId")long goodsId){
    
    
        model.addAttribute("user",user);
        if(user==null){
    
    
            return "login";
        }
        GoodsVo goodsVo = goodsService.getGoodsVoByGoodsId(goodsId);
        int stockCount = goodsVo.getStockCount();
        //库存不足
        if(stockCount<=0){
    
    
            model.addAttribute("errormsg", CodeMsg.MIAO_SHA_OVER.getMsg());
            return "miaosha_fail";
        }
        //判断是否已经秒杀到了
        MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
        if(order!=null){
    
    
            model.addAttribute("errormsg",CodeMsg.REPEATE_MIAOSHA.getMsg());
            return "miaosha_fail";
        }
        //减库存,下单,写入秒杀订单
        OrderInfo orderInfo = miaoshaService.miaosha(user, goodsVo);
        model.addAttribute("orderInfo",orderInfo);
        model.addAttribute("goods",goodsVo);
        return "order_detail";
    }

优化:秒杀静态化,通过ajax异步请求调用秒杀接口并进行轮询查看是否成功,redis预减库存减少对数据库的访问,内存标记减少redis访问,RabbitMQ队列缓冲,异步下单,增强用户体验,同时减少服务器压力。首先是使用redis预减库存,我们使用一个HashMap来存储某商品是否售空,这里用到一个方法afterPropertiesSet(),该方法会在Bean的所有属性初始化完成后调用,在这个方法中,我们从数据库中查出商品列表,将每个商品的库存存入redis中,并将库存不为0的商品在HashMap中设置为false,意为未售空。(此处应加上对商品库存是否大于0的判断)
在秒杀方法中,我们首先获取mao中的布尔值来判断是否售空决定是否进行下一步操作。使用redis的decr()来预减库存,减完判断是否小于0,如果小于0就设置map中的值为true,并且返回,否则进行下一步操作。
关于RabbitMQ的操作,我个人感觉还是有点迷的,毕竟真正的秒杀不会让你一直等待吧?(没事,咱也没秒杀到过东西,问题应该不是很大,也可能等待的时间很短人们感受不出来?这里请懂的朋友评论区指导指导呗)
此项目中使用RabbitMQ将秒杀请求入队,前端进行轮询确认是否下单成功来实现异步下单的操作。关于RabbitMQ,RabbitMQ是一个开源的消息代理和队列服务器,用来通过普通协议在完全不同的应用之间共享数据,或者简单的将作业排队以便让分布式服务器进行处理。
RabbitMQ有四种交换机模式,分别是Direct模式、Topic模式、Fanout模式和Headers模式。Direct模式需要将一个队列绑定到交换机上,要求该信息与一个特定的路由键完全匹配;Topic模式是将路由键和某模式进行匹配,队列需要绑定到一个模式上;Fanout模式则是不处理路由键,只需要将队列绑定到交换机上,一个发送到该交换机的信息都会被转发到与该交换机绑定的所有队列上,类似于子网传播;Headers则是以key-value形式来匹配。
队列和交换机绑定使用的是BindingBuilder.bind(队列).to(交换机)
需要的类
注意此时秒杀操作并不在秒杀功能中处理,而是在RabbitMQ中的receive()方法中处理(见第三段代码)

	//用于保存商品的id和是否售空
	private HashMap<Long, Boolean> localOverMap =  new HashMap<Long, Boolean>();
	
	/**
	 * 系统初始化
	 * */
	public void afterPropertiesSet() throws Exception {
    
    
		List<GoodsVo> goodsList = goodsService.listGoodsVo();
		if(goodsList == null) {
    
    
			return;
		}
		for(GoodsVo goods : goodsList) {
    
    
			redisService.set(GoodsKey.getMiaoshaGoodsStock, ""+goods.getId(), goods.getStockCount());
			localOverMap.put(goods.getId(), false);
		}
	}


	@RequestMapping(value="/do_miaosha", method=RequestMethod.POST)
    @ResponseBody
    public Result<Integer> miaosha(Model model,MiaoshaUser user,
    		@RequestParam("goodsId")long goodsId) {
    
    
    	model.addAttribute("user", user);
    	if(user == null) {
    
    
    		return Result.error(CodeMsg.SESSION_ERROR);
    	}
    	//内存标记,减少redis访问
    	boolean over = localOverMap.get(goodsId);
    	if(over) {
    
    
    		return Result.error(CodeMsg.MIAO_SHA_OVER);
    	}
    	//预减库存
    	long stock = redisService.decr(GoodsKey.getMiaoshaGoodsStock, ""+goodsId);//10
    	if(stock < 0) {
    
    
    		 localOverMap.put(goodsId, true);
    		return Result.error(CodeMsg.MIAO_SHA_OVER);
    	}
    	//判断是否已经秒杀到了
    	MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
    	if(order != null) {
    
    
    		return Result.error(CodeMsg.REPEATE_MIAOSHA);
    	}
    	//入队
    	MiaoshaMessage mm = new MiaoshaMessage();
    	mm.setUser(user);
    	mm.setGoodsId(goodsId);
    	sender.sendMiaoshaMessage(mm);
    	return Result.success(0);//排队中
    }
    
    /**MQReceiver类中的方法
     * 此时到达这里的请求大大减少
     * 因为有redis库存的拦截
     * @param message
     */
    @RabbitListener(queues = MQConfig.MIAOSHA_QUEUE)
    public void receive(String message){
    
    
        log.info("receive message:"+message);
        MiaoshaMessage mm = redisService.stringToBean(message, MiaoshaMessage.class);
        KillsUser user = mm.getUser();
        long goodsId = mm.getGoodsId();
        //判断库存
        GoodsVo goodsVo = goodsService.getGoodsVoByGoodsId(goodsId);
        int stockCount = goodsVo.getStockCount();
        //库存不足
        if(stockCount<=0){
    
    
            return;
        }
        //判断是否已经秒杀到了,这里可以不去数据库查找是否有订单
        MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
        if(order!=null){
    
    
            return;
        }
        //减库存,下单,写入秒杀订单
        OrderInfo orderInfo = miaoshaService.miaosha(user, goodsVo);
    }

五、安全优化

1、秒杀接口地址隐藏

在秒杀真正开始之前,我们的秒杀地址不应该是可以访问的,这样会造成极大的不公平,如果你获得秒杀地址就可以在秒杀开始之前买到商品。因此我们可以在地址中间加上一个随机的path,可以利用随机生成随机码,并把用户id和商品id作为key,把path存入redis中,在真正秒杀之前会再度把传入的path和redis中的path作比较。

2、数学公式验证码

为了减少真正进入秒杀接口的用户,在商品详情页面进行一个验证码的输入,对算式进行计算并把值存进redis,在秒杀方面之前对用户 输入的验证码和redis中的值进行比较,从而判断是否进入秒杀。

3、接口防刷

接口防刷是防止用户短时间内多次访问同一页面,可以避免某些脚本的攻击。原理是使用redis缓存,假如在k秒内访问超过5次就拦截,key值是固定前缀加上用户id,有效时间是k秒,而value则是从1开始,在不超过5次的前提下,调用redis的incr方法。如果超过限制,就拒绝访问,在拦截器的preHandle()方法中返回false,拒绝访问。
但是可能会有很多的接口需要限制,所以会出现冗余代码很多的情况,并且这样也会使得接口方法变得十分复杂,代码可读性也会降低。因此我们可以声明一个相关的注解,如下:
在这里插入图片描述

@Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,Object handler)
    throws Exception{
    
    
        if(handler instanceof HandlerMethod){
    
    
            KillsUser user=getUser(request,response);
            //将user对象存入ThreadLocal中
            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();
            //这里判断获取的user是否为null cookie决定
            if(needLogin){
    
    
                if(user==null){
    
    
                    render(response, CodeMsg.SESSION_ERROR);
                    return false;
                }
            }else{
    
    
                //do nothing
            }
            key+="_"+user.getId();
            AccessKey ak=AccessKey.withExpire(seconds);
            Integer count=redisService.get(ak,key,Integer.class);
            if(count==null){
    
    
                redisService.set(ak,key,1);
            }else if(count<maxCount){
    
    
                redisService.incr(ak,key);
            }else{
    
    //短时间内频繁访问
                render(response,CodeMsg.FREQUENCY_ACCESS);
                return false;
            }
        }
        return true;
    }
4、用户存储

在多个方法中都需要使用秒杀用户作为参数,判断用户是否为空而决定需不需要跳转到登录页面。因此每次都要从数据库中查找相应的对象来返回。我们可以使用一个ThreadLocal来对User对象进行保存,而ThreadLocal的有效期就是在当前线程内,所以在用户访问的过程中都可以从本地拿到一个秒杀用户对象而不用访问数据库。

public class UserContext {
    
    
    //将user存到当前线程中
    private static ThreadLocal<KillsUser> userHolder=new ThreadLocal<>();

    public static void setUser(KillsUser user){
    
    
        userHolder.set(user);
    }

    public static KillsUser getUser(){
    
    
        return userHolder.get();
    }
}

六、个人感受

对于高并发秒杀的解决方案,其实是有很多种的,通过这个项目让我这个小菜鸡初窥门径,了解到一些相关的解决方式,但其实里面有一些做法还是有待商榷的。
通过这个项目,还是认识到了redis的强大,有很大一部分优化都是通过redis缓存来实现的,关于redis还是应该多多学习。我自己对于此项目中的很多东西都是比较陌生的,比如Thymeleaf、JMeter和RabbitMQ都是刚刚接触的,如果问我有关的问题,我还是比较懵的,因此对于这些东西还是应该多了解。
最后,找了两个多月的实习,我依然没有上岸,可能是我太菜了吧,希望大家跟我一起加油趴!

猜你喜欢

转载自blog.csdn.net/fucccck_ly/article/details/106022784