NWNU-Sun | 技术沉思录

代码是诗,bug是谜

   ::  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理
  77 随笔 :: 49 文章 :: 6 评论 :: 40763 阅读

秒杀项目

介绍

视频地址:https://www.bilibili.com/video/BV1sf4y1L7KE

内容

1.项目框架搭建

  • SpringBoot环境搭建

  • 集成Thymeleaf模板引擎

  • Mybatis

2.分布式会话

  • 用户登陆

    • 设计数据库

    • 明文密码二次MD5加密

      public class MD5Utils {
          private static final String salt = "1a2b3c4d";//盐
      
          /**
           * MD5加密
           *
           * @param str 机密的字符串
           * @return
           */
          public static String MD5(String str) {
              String s = DigestUtils.md5Hex(str);
              return s;
          }
      
          //第一次加密,固定盐
          public static String firstPassP(String passwd) {
              String s1 = "" +salt.charAt(0) + salt.charAt(2) + passwd + salt.charAt(5) + salt.charAt(4);
              return MD5(s1);
          }
      
          //第二次加密,数据库盐
          public static String secondPassP(String passwd2, String salt) {
              String str = "" +salt.charAt(0) + salt.charAt(2) + passwd2 + salt.charAt(5) + salt.charAt(4);
              return MD5(str);
          }
      
          //入库加密
          public static String InputPassToDb(String inputPass, String salt) {
              String passwd1 = firstPassP(inputPass);
              String passwd2 = secondPassP(passwd1, salt);
              return passwd2;
          }
      
          //测试
          //d3b1294a61a07da9b49b6e22b2cbd7f9
          public static void main(String[] args) {
              String s = InputPassToDb("123456", "test6666");
              System.out.println(s);
          }
      }
      
    • 参数校验+全局异常处理

      参数校验

      @Data
      @AllArgsConstructor
      @ApiModel("登录请求参数")
      public class TloginVo {
      
          @NotNull
          @Ismobile//自定义校验手机号
          @ApiModelProperty("手机号")
          private String mobile;
          @NotNull
          @ApiModelProperty("密码")
          private String password;
      }
      
      @Retention(RetentionPolicy.RUNTIME)
      @Documented
      @Constraint(
              validatedBy = {IsMobileValidato.class}
      )
      public @interface Ismobile {
          boolean required() default true;
      
          String message() default "手机号码格式不对";
      
          Class<?>[] groups() default {};
      
          Class<? extends Payload>[] payload() default {};
      }
      
      public class IsMobileValidato implements ConstraintValidator<Ismobile, String> {
          private boolean required = false;
      
          @Override
          public void initialize(Ismobile constraintAnnotation) {
      //        ConstraintValidator.super.initialize(constraintAnnotation);
              required = constraintAnnotation.required();
          }
      
          @Override
          public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
              if (required) {
                  return ValidatorUtil.isMobile(s);
              } else {
                  if (StringUtils.isEmpty(s)) {
                      return true;
                  } else {
                      return ValidatorUtil.isMobile(s);
                  }
              }
          }
      }
      
      public class ValidatorUtil {
          private static final Pattern mobile_patten = Pattern.compile("[1]([3-9])[0-9]{9}$");
          /**
           * 校验手机号
           * @param mobile
           * @return
           */
          public static Boolean isMobile(String mobile) {
              if (StringUtils.isEmpty(mobile)) {
                  return false;
              }
              Matcher matcher = mobile_patten.matcher(mobile);
              return matcher.matches();
          }
      }
      

      异常处理

      @Data
      @AllArgsConstructor
      @NoArgsConstructor
      public class GlobalException extends RuntimeException {
          private RespBeanEnum respBeanEnum;
      }
      
      @RestControllerAdvice
      public class GlobalExceptionHandler {
          private RespBean respBean;
      
          @ExceptionHandler(Exception.class)
          public RespBean ExceptionHandler(Exception e) {
              if (e instanceof GlobalException) {
                  GlobalException exception = (GlobalException) e;
                  return RespBean.error(exception.getRespBeanEnum());
              } else if (e instanceof BindException) {
                  BindException bindException = (BindException) e;
                  RespBean respBean = RespBean.error(RespBeanEnum.BIND_ERROR);
                  respBean.setMessage(bindException.getAllErrors().get(0).getDefaultMessage());
                  return respBean;
              }
              return RespBean.error(RespBeanEnum.ERROR);
          }
      }
      
  • 共享Session

    • SpringSession

      pom

       <!--Spring Boot Redis -->
              <dependency>
                  <groupId>org.springframework.boot</groupId>
                  <artifactId>spring-boot-starter-data-redis</artifactId>
              </dependency>
      
              <!-- 对象池依赖-->
              <dependency>
                  <groupId>org.apache.commons</groupId>
                  <artifactId>commons-pool2</artifactId>
              </dependency>
              <dependency>
                  <groupId>org.springframework.session</groupId>
                  <artifactId>spring-session-data-redis</artifactId>
              </dependency>
      

      application.yml

        #缓存配置
        redis:
          host: 192.168.2.217
          port: 6378
          password: 123456
          database: 0
          timeout: 10000ms
          lettuce:
            pool:
              #最大连接数 默认8
              max-active: 8
              #最大连接阻塞时间
              max-wait: 1000ms
              #最大空闲连接,默认8
              max-idle: 200
      

      配置完成之后,默认sessio会存入redis

    • Redis

      配置

      #缓存配置
        redis:
          host: 192.168.2.217
          port: 6378
          password: 123456
          database: 0
          timeout: 10000ms
          lettuce:
            pool:
              #最大连接数 默认8
              max-active: 8
              #最大连接阻塞时间
              max-wait: 1000ms
              #最大空闲连接,默认8
              max-idle: 200
      

      登陆代码

      @Override
          public RespBean login(TloginVo tloginVo, HttpServletRequest request, HttpServletResponse response) {
              String mobile = tloginVo.getMobile();
              String passwd = tloginVo.getPassword();
              TUser tUser = tUserMapper.selectById(mobile);
              log.info(tUser.toString());
              if (tUser == null) {
                  throw new GlobalException(RespBeanEnum.LOGIN_ERROR);
              }
              //校验密码是否正确
              if (MD5Utils.secondPassP(passwd, tUser.getSalt()).equals(tUser.getPassword())) {
                  //生成Cookie
                  String userTicket = UUIDUtil.getUuidCode();
                  //将用户信息存入redis
                  redisTemplate.opsForValue().set("user:" + userTicket, tUser);
                  CookieUtil.setCookie(request, response, "userTicket", userTicket);
                  return RespBean.success(userTicket);
              }
              return RespBean.error(RespBeanEnum.LOGIN_ERROR);
          }
      

3.功能开发

  • 商品列表
  • 商品详情
  • 秒杀
  • 订单详情

4.系统压测

  • Jmeter的基本操作
  • 自定义变量模拟多用户
  • Jmeter命令行的使用
  • 正式压测
    • 商品列表
    • 秒杀

5.页面优化

  • 页面缓存+URL缓存+对象缓存

    页面缓存(将不长变动的页面,通过视图解析器渲染后存入第三方库)

    因为要使用redis,所以先引入

        //引用redis 缓存页面
        @Autowired
        private RedisTemplate redisTemplate;
        //手动渲染前端页面,视图解析器
        @Autowired
        private ThymeleafViewResolver thymeleafViewResolver;
    

    修改controller代码

    @RequestMapping(value = "/toList", produces = "text/html;charset=utf-8", method = RequestMethod.GET)
    @ResponseBody
    

    注解RequestMapping中produces属性可以设置返回数据的类型以及编码,可以是json或者xml,也可以是html

    前端发请求,进入商品页面时,先用redis缓存查找

     //第一次访问首先从redis查找
     ValueOperations valueOperations = redisTemplate.opsForValue();
            String html = (String) valueOperations.get("goodsdetail:" + goodsId);
            if (!StringUtils.isEmpty(html)) {
                return html;
            }
    

    缓存无法击中的话,通过ThymeleafViewResolver (Thymeleaf视图解析器)渲染页面,然后以String类型存入Redis中,并返回到前端

     WebContext webContext = new WebContext(request, response, request.getServletContext(), request.getLocale(), model.asMap());
            html = thymeleafViewResolver.getTemplateEngine().process("goodsDetail", webContext);
            if (!StringUtils.isEmpty(html)) {
                valueOperations.set("goodsdetail" + goodsId, html, 60, TimeUnit.SECONDS);
            }
            return html;
    

    对象缓存

    一开始登陆的时候,将用户登陆信息存入redis

  • 页面静态化(不通过模板引擎渲染,后端返回数据,前端调接口)、前后端分离

  • 静态资源优化

  • CDN优化

6.接口优化

1.Redis预减库存减少数据库的访问

思路:

秒杀过程中,大量并发请求都需要查库判断商品库存数量,影响Mysql数据库性能,可以在系统初次化时,将库存数存入缓存,减少数据库的访问,库存秒杀完毕后将拦截大量无效请求

public class SeckillController implements InitializingBean

当一个类实现这个接口之后,Spring启动后,初始化Bean时,若该Bean实现InitialzingBean接口,会自动调用afterPropertiesSet()方法,完成一些用户自定义的初始化操作

/**
     * 系统初始化,将库存数量加载到redis
     *
     * @throws Exception
     */
    @Override
    public void afterPropertiesSet() throws Exception {
        List<TgoodInfoVo> list = tGoodsService.findGoodVo();
        if (CollectionUtils.isEmpty(list)) {
            return;
        }
        list.forEach(tgoodInfoVo -> {
            redisTemplate.opsForValue().set("seckillgoods:" + tgoodInfoVo.getId(), tgoodInfoVo.getStockCount());
            EmpStack.put(tgoodInfoVo.getId(), false);
        });
    }

秒杀请求进来时,进行相应库存-1,并判断是否为0

//采用redis缓存预减库存,主要是为了减缓数据库压力
        Long stock = value.decrement("seckillgoods:" + goodsId);
        if (stock < 0) {
            value.increment("seckillgoods:" + goodsId);//不让redis库存变为-1
            return RespBean.error(RespBeanEnum.EMPTY_STOCK);
 }

2.内存标记减少redis的访问

描述:库存数10,有50000请求访问,每次通过redis缓存判断库存数量,当10个库存全部卖光时,后续无效请求依旧通过redis进行判断,增大redis负担

解决:通过内存标记,当库存为0时,将不再访问redis

private HashMap<Long, Boolean> EmpStack = new HashMap<>();

初始化时,将每个商品标记为false,当库存数为0时,置为true

image-20220727104133828

image-20220727104159139

流程:

image-20220727110124094

如图所示,内存标记的好处是直接返回库存卖光之后的大量请求

RabbitMQ异步下单

  • SpringBoot整合RabbitMQ

    spring:  
      #RabbitMQ
      rabbitmq:
        #服务器
        host: 192.168.2.217
        #用户名
        username: guest
        #密码
        password: guest
        #虚拟主机
        virtual-host: /
        #端口
        port: 5673
        listener:
          simple:
            #消费者最小数量
            concurrency: 10
            #消费者最大数量
            max-concurrency: 10
            #限制消费者每次只能处理一条消息,处理完在继续下一条消息
            prefetch: 1
            #启动是默认启动容器
            auto-startup: true
            #被拒绝时重新进入队列
            default-requeue-rejected: true
        template:
          retry:
            #发布重试,默认false
            enabled: true
            #重试时间,默认1000ms
            initial-interval: 1000ms
            #重试最大次数,默认3次
            max-attempts: 3
            #最大重试间隔时间
            max-interval: 10000ms
            #重试的间隔乘数,比如配2。0  第一等10s 第二次等20s 第三次等40s
            multiplier: 1
    
     <!--amqp-->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-amqp</artifactId>
            </dependency>
    
  • 交换机

    • 扇形交换机(Fanout exchange)
      • 预声明的默认名称:amq.fanout
      • 将消息路由给绑定到它身上的所有队列,不理会绑定的路由键。
      • 用来交换机处理消息的广播路由。

image-20220727110616206

  • 直连交换机(Direct exchange)

    • 预声明的默认名称:空串 or amq.direct  
    • 根据消息携带的routing key 将消息传递给对应的队列。
    • 用来处理消息的单播路由。

    image-20220727110731773

  • 主题交换机(Topic exchange)

    • 预声明的默认名称:amq.topic
    • 通过对消息的路由键和队列到交换机的绑定模式之间的匹配,将消息路由给一个或多个队列。
    • 用来实现各种分发/订阅模式及其变种。

项目思路:

大量秒杀请求进来时,通过队列去做异步处理,后端返回给正在处理的状态,前端做轮询判断订单是否生成

优化代码

 //推送消息到队列,异步操作,流量削峰
        RabbitMessageVo rabbitMessageVo = new RabbitMessageVo(goodsId, user);//封装参数
        seckillRabbitService.seckillMsg(JsonUtil.object2JsonStr(rabbitMessageVo));//异步处理
        return RespBean.success(0);
@RabbitListener(queues = "seckill_queue")
    public void seckillMsgRevicer(String msg) {
        log.info("接收消息:" + msg);
        RabbitMessageVo message = JsonUtil.jsonStr2Object(msg, RabbitMessageVo.class);//string-->object

        //查询订单数量
        TUser tUser = message.getTUser();
        Long goodsId = message.getGoodsId();
        TgoodInfoVo goodVo = tGoodsService.findGoodVoById(goodsId);
        if (goodVo.getStockCount() < 1) {
            return;
        }
        //判断是否重复抢购
        TSeckillOrder o = (TSeckillOrder) redisTemplate.opsForValue().get("order:" + goodsId + ":" + tUser.getId());
        if (o != null) {
            return;
        }
        //下订单
        TOrder seckill = tOrderService.seckill(tUser, goodVo);
    }

7.安全优化

  • 秒杀接口地址隐藏

    描述:防止提前使用脚本访问秒杀接口

    处理:前端发起秒杀请求时,首先调接口得到随机数path ,然后再进行url拼接后真正调用秒杀接口

    第一次:url: "/seckill/path"

        @RequestMapping(value = "/path", method = RequestMethod.GET)
        @ResponseBody
        public RespBean path(TUser user, Long goodsId, HttpServletResponse response) {
            if (user == null) {
                return RespBean.error(RespBeanEnum.ERROR);
            }
            String path = tOrderService.getPath(user, goodsId);
            return RespBean.success(path);
        }
    
       @Override
        public String getPath(TUser user, Long goodsId) {
            String str = MD5Utils.MD5(UUIDUtil.getUuidCode() + "123456");
            redisTemplate.opsForValue().set("seckillpath:" + user.getId() + ":" + goodsId, str, 5, TimeUnit.SECONDS);
            //存入redis并设置失效时间
            return str;
        }
    

    第二次:url: 'seckill/' + path + '/doSeckill'

        @RequestMapping(value = "/{path}/doSeckill", method = RequestMethod.POST)
        @ApiOperation("秒杀-rabbitmq优化")
        @ResponseBody
        public RespBean seckill3(@PathVariable String path, TUser user, Long goodsId) {
            //判断用户是否为空
            if (user == null) {
                return RespBean.error(RespBeanEnum.SESSION_ERROR);
            }
            ValueOperations value = redisTemplate.opsForValue();
    
            //校验path
            Boolean flag = tOrderService.checkPath(user, goodsId, path);
            log.info("隐藏地址校验:" + path + ":" + flag);
            if (!flag) {
                return RespBean.error(RespBeanEnum.REQUEST_ILLEGAL);
            }
    
  • 算术验证码

描述:限流,比如QPS:10000个请求,通过验证码可以将请求分散

工具:https://gitee.com/ele-admin/EasyCaptcha?_from=gitee_search

代码:

    @RequestMapping(value = "/captcha", method = RequestMethod.GET)
    public void verifyCode(TUser user, Long goodsId, HttpServletResponse response) {
        if (user == null || goodsId < 0) {
            throw new GlobalException(RespBeanEnum.REQUEST_ILLEGAL);
        }
        // 设置请求头为输出图片类型
        response.setContentType("image/gif");
        response.setHeader("Pragma", "No-cache");
        response.setHeader("Cache-Control", "no-cache");
        response.setDateHeader("Expires", 0);
        // 算术类型
        ArithmeticCaptcha captcha = new ArithmeticCaptcha(130, 32, 3);
        captcha.setLen(3);  // 几位数运算,默认是两位
        captcha.getArithmeticString();  // 获取运算的公式:3+2=?
        captcha.text();  // 获取运算的结果:5
        //存入redis
        redisTemplate.opsForValue().set("captcha:" + user.getId() + ":" + goodsId, captcha.text(), 120, TimeUnit.SECONDS);
        try {
            captcha.out(response.getOutputStream());  // 输出验证码
        } catch (IOException e) {
            log.info("验证码生成失败:", e.getMessage());
        }

将验证码存入redis,设置失效时间,在秒杀获取path时,进行判断

    @RequestMapping(value = "/path", method = RequestMethod.GET)
    @ResponseBody
    public RespBean path(TUser user, Long goodsId, String captcha, HttpServletResponse response) {
        if (user == null) {
            return RespBean.error(RespBeanEnum.ERROR);
        }
        //验证码校验
        Boolean captcha_check = tOrderService.checkCaptcha(user, goodsId, captcha);
        if (!captcha_check) {
            return RespBean.error(RespBeanEnum.ERROR_CAPTCHA);
        }
        String path = tOrderService.getPath(user, goodsId);
        return RespBean.success(path);
    }

接口防刷

redis设置 key = url:userid value=number

微服务,采用sentinel进行限流

软件架构

技术 版本 说明
Spring Boot 2.6.4
MySQL 8
MyBatis Plus 3.5.1
Swagger2 2.9.2 Swagger-models2.9.2版本报错,使用的是1.5.22
Kinfe4j 2.0.9 感觉比Swagger UI漂亮的一个工具,访问地址是ip:端口/doc.html
Spring Boot Redis
posted on   匿名者nwnu  阅读(150)  评论(0编辑  收藏  举报
编辑推荐:
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构
· AI与.NET技术实操系列(六):基于图像分类模型对图像进行分类
点击右上角即可分享
微信分享提示