秒杀商城项目

导入依赖
<dependencies>
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-thymeleaf</artifactId>
         </dependency>
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-web</artifactId>
         </dependency>
 
         <dependency>
             <groupId>mysql</groupId>
             <artifactId>mysql-connector-java</artifactId>
             <scope>runtime</scope>
         </dependency>
         <dependency>
             <groupId>org.projectlombok</groupId>
             <artifactId>lombok</artifactId>
             <optional>true</optional>
         </dependency>
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-test</artifactId>
             <scope>test</scope>
         </dependency>
         <dependency>
             <groupId>com.baomidou</groupId>
             <artifactId>mybatis-plus-boot-starter</artifactId>
             <version>3.3.1.tmp</version>
         </dependency>
     </dependencies>
配置文件
spring:
 thymeleaf配置
   thymeleaf:
     #关闭缓存
     cache: false
 数据源配置
   datasource:
     driver-class-name: com.mysql.cj.jdbc.Driver
     url: jdbc:mysql://localhost:3306/seckill?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
     username: root
     password: hsp
 使用hikari数据源
     hikari:
 连接池名
       pool-name: DateHikariCP
 最小空闲连接数
       minimum-idle: 5
 空闲连接最大存活时间
       idle-timeout: 180000  #30分钟
 最大连接数
       maximum-pool-size: 10
 自动提交连接池返回的数据
       auto-commit: true
       #      连接最大存活时间
       max-lifetime: 180000
 连接超时时间
       connection-timeout: 30000
 测试连接是否可用的查询语句
       connection-test-query: SELECT 1
 
 mybatis-plus:
 mapper.xml映射文件
   mapper-locations: classpath*:/mapper/*Mapper.xml
 取别名的包
   type-aliases-package: com.mgy.pojo
 SQL打印(在方法接口所在的包)
 logging:
   level:
     com.mgy.mapper: debug
创建包
  1. controller
  2. mapper
  3. pojo
  4. service
  5. resource中的mapper
  6. 主文件中加入注释:@MapperScan(“com.mgy.mapper”):代表该包下每个接口都得到实现类
测试controller
  1. DemoController
@Controller
 @RequestMapping("/demo")
 public class DemoController {
 
     /**
      * 测试页面跳转
      * @param model
      * @return
      */
     @RequestMapping("/hello")
     public String hello(Model model){
         model.addAttribute("name","mgy");
         return "hello";
     }
 }
  1. 前端页面(在templates端口下)
    • hello.html
sql表创建
CREATE TABLE t_user(
   `id` BIGINT(20) NOT NULL COMMENT '用户ID,手机号码',
   `nickname` VARCHAR(255) NOT NULL,
   `password` VARCHAR(32) DEFAULT NULL COMMENT 'MD5(MD5(pass明文+固定salt)+salt)',
   `slat` VARCHAR(10) DEFAULT NULL,
   `head` VARCHAR(128) DEFAULT NULL COMMENT '头像',
   `register_date` DATETIME DEFAULT NULL COMMENT '注册时间',
   `last_login_date` DATETIME DEFAULT NULL COMMENT '最后一次登录事件',
   `login_count` INT(11) DEFAULT '0' COMMENT '登录次数',
   PRIMARY KEY(`id`)
   )
  • 两次MD5加密,一次输入密码时加密(前端传给后端),存入数据库时再加密
 <dependency>
            <groupId>commons-codec</groupId>
            <artifactId>commons-codec</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.6</version>
        </dependency>
@Component
public class MD5Util {
    public static String md5(String src){
        return DigestUtils.md5Hex(src);
    }
    private static final String salt="1a2b3c4d";
    public static String inputPassToFormPass(String inputPass){
        String str = salt.charAt(0)+salt.charAt(2)+inputPass+salt.charAt(5)+salt.charAt(4);
        return md5(str);
    }
    public static String formPassToDBPass(String formPass,String salt){
        String str = salt.charAt(0)+salt.charAt(2)+formPass+salt.charAt(5)+salt.charAt(4);
        return md5(str);
    }
    public static String inputPassToDBPass(String inputPass,String salt){
        String formPass = inputPassToFormPass(inputPass);
        String dbPass = formPassToDBPass(formPass,salt);
        return dbPass;
    }

}
创建逆向工程的模块
规定返回类型
  • 写一个RespBean用于统一返回类型
登录页面
  • 一个方法跳转登录页面
  • 登录页面将信息提交到doLogin
    RestController=Controller+ResponseBody
手机号码格式校验
@Pattern(regexp = "[1]([3-9])[0-9]{9}$",message = "手机号码格式错误")
  • @Valid
  • 异常捕获
    • @RestControllerAdvice
    • @ExceptionHandler(Exception.class)
登录信息传递
//生成cookie
        String ticket = UUIDUtil.uuid();
        request.getSession().setAttribute(ticket,user);
        CookieUtil.setCookie(request,response,"userTicket",ticket);
public String toList(HttpSession session, Model model, @CookieValue("userTicket") String ticket){
        //判断ticket是不是空,如果是空,代表没有用户信息,需要去登录
        if(StringUtils.isEmpty(ticket)){
            return "login";
        }
        User user = (User)session.getAttribute(ticket);
        if(user==null){
            return "login";
        }
        //如果user存在,说明已经登录,将用户对象传到前端页面
        model.addAttribute("user",user);
        return "goodsList";
    }
分布式session
<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>
  • 使用redis保存用户登录的信息,就可以实现任何tomcat存放的数据都能被从redis中取出
public User getUserByCookie(String userTicket,HttpServletRequest request,HttpServletResponse response) {

        if(StringUtils.isEmpty(userTicket)){
            return null;
        }

        User user = (User)redisTemplate.opsForValue().get("user:"+userTicket);

        if(user!=null){
                CookieUtil.setCookie(request,response,"userTicket",userTicket);
        }
        return user;
    }
//生成cookie
        String ticket = UUIDUtil.uuid();
        redisTemplate.opsForValue().set("user:"+ticket,user);

//        request.getSession().setAttribute(ticket,user);
        CookieUtil.setCookie(request,response,"userTicket",ticket);

使用拦截器完善登录功能
  • 用于这个老师教的方法没法没登录就都跳转到login界面,所以配置拦截器实现
@Component
public class AdminInterceptor implements HandlerInterceptor {
    @Autowired
    private IUserService userService;
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String ticket = CookieUtil.getCookieValue(request, "userTicket");
        if(ticket==null){
            response.sendRedirect(request.getContextPath()+"/login/toLogin");
        }
        User user = userService.getUserByCookie(ticket, request, response);
        if(user==null){
            response.sendRedirect(request.getContextPath()+"/login/toLogin");
        }else{
            return true;
        }
        return false;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {

    }
}
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private AdminInterceptor adminInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        InterceptorRegistration registration = registry.addInterceptor(adminInterceptor);
        registration.addPathPatterns("/**").excludePathPatterns("/login/toLogin","/login.html","/login/doLogin");

    }
}
秒杀详情页面准备
  • 由于秒杀详情页面有来自goods表的信息,还有来自seckill_goods表的信息,因此用一个新的类GoodsVo将两者关联
解决静态资源无法访问问题
  • 用webConfig进行配置之后,会覆盖原本的配置,导致静态资源无法访问,因此需要给静态资源放行
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private AdminInterceptor adminInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        InterceptorRegistration registration = registry.addInterceptor(adminInterceptor);
        registration.addPathPatterns("/**").excludePathPatterns("/login","/login.html","/login/toLogin","/login/doLogin","/static/**","/static/");

    }

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/");
    }


}
  • 注意,这里处理完之后要改一下前端页面,给前端页面的资源都加上/static/,才能成功被放行
秒杀功能的实现
  • 首先进行判断,判断库存是否充足。判断是否已经抢购过
  • 业务过程
    • 让秒杀库存中的数量减少一个
    • 新增一个订单
    • 新增秒杀订单
  • 外键的功能直接通过业务层实现,而不用做表之间的关联
    • 通过自动返回主键的功能,得到order的Id,从而创建secKillOrder的Id

下面开始做优化功能

页面缓存
  • 页面缓存就是将加载过的页面转成字符串存在redis里,直接取出来用
@Controller
@RequestMapping("/goods")
public class GoodsController {
    @Autowired
    private IUserService userService;
    @Autowired
    private IGoodsService goodsService;
    @Autowired
    private RedisTemplate redisTemplate;
    @Autowired
    private ThymeleafViewResolver thymeleafViewResolver;
    @RequestMapping(value="/toList",produces = "text/html;charset=utf-8")
    @ResponseBody
    public String toList(Model model,HttpServletRequest request,HttpServletResponse response){
        //从redis中取出缓存的页面
        ValueOperations valueOperations = redisTemplate.opsForValue();
        String html = (String)valueOperations.get("goodsList");
        if(!StringUtils.isEmpty(html)){
            return html;
        }

        //判断ticket是不是空,如果是空,代表没有用户信息,需要去登录
//        if(StringUtils.isEmpty(ticket)){
//            return "login";
//        }
        User user = (User)session.getAttribute(ticket);
        String ticket = CookieUtil.getCookieValue(request, "userTicket");
        User user = userService.getUserByCookie(ticket,request,response);
//
//        if(user==null){
//            return "login";
//        }

        //如果user存在,说明已经登录,将用户对象传到前端页面
        model.addAttribute("user",user);
        model.addAttribute("goodsList",goodsService.findGoodsVo());
        //如果redis中没有缓存的页面,就要手动渲染页面并存入redis
        WebContext webContext = new WebContext(request, response, request.getServletContext(), request.getLocale(), model.asMap());
        html = thymeleafViewResolver.getTemplateEngine().process("goodsList", webContext);
        if(!StringUtils.isEmpty(html)){
            valueOperations.set("goodsList",html,60, TimeUnit.MINUTES);
        }
        return html;
    }
    
    @RequestMapping(value="/toDetail/{goodsId}",produces = "text/html;charset=utf-8")
    @ResponseBody
    public String toDetail(@PathVariable Long goodsId,Model model,HttpServletRequest request,HttpServletResponse response){
        //从redis中取出缓存的页面
        ValueOperations valueOperations = redisTemplate.opsForValue();
        String html = (String)valueOperations.get("goodsDetail"+goodsId);
        if(!StringUtils.isEmpty(html)){
            return html;
        }

        String ticket = CookieUtil.getCookieValue(request, "userTicket");
        User user = userService.getUserByCookie(ticket,request,response);
        model.addAttribute("user",user);
        GoodsVo goods = goodsService.findGoodsByGoodsId(goodsId);
        model.addAttribute("goods",goods);
        Date startDate = goods.getStartDate();
        Date endDate = goods.getEndDate();
        Date nowDate = new Date();
        int secKillStatus = 0;
        int remainSeconds = 0;
        if(nowDate.before(startDate)){
            //秒杀还没有开始
            remainSeconds = (int)((startDate.getTime()-nowDate.getTime())/1000);
        }else if(nowDate.after(endDate)){
            //秒杀一结束
            secKillStatus = 2;
        }else{
            secKillStatus = 1;

        }
        model.addAttribute("secKillStatus",secKillStatus);
        model.addAttribute("remainSeconds",remainSeconds);
        //如果redis中没有缓存的页面,就要手动渲染页面并存入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.MINUTES);
        }
        return html;
    }

}
前后端分离
  • 前后端分离能保证每次变化的数据尽可能少,将不会变的静态数据缓存在浏览器里,每次只需要服务器传输需要变化的数据
  • 技术用的是之前学过的ajax,不做赘述
解决库存超卖问题
seckillGoods.setStockCount(goods.getStockCount()-1);
//        seckillGoodsService.updateById(seckillGoods);
        //防止超卖,设置条件库存要大于0
        UpdateWrapper wrapper = new UpdateWrapper<SeckillGoods>().setSql("stock_count=stock_count-1")
                .eq("id",seckillGoods.getId()).gt("stock_count",0);
        boolean seckillResult = seckillGoodsService.update(wrapper);
        if(!seckillResult){
            return null;
        }
  • 在order表中创建一个用户id和商品id的唯一索引,防止一个用户同一时间抢购两个
    • 使用的是Innodb行锁+复合索引(唯一)的方式
  • 加@Transactional的事务注解
  • 将秒杀订单存入redis,以用户id和商品id做key,判断订单是否重复
redisTemplate.opsForValue().set("order:"+user.getId()+":"+goods.getId(),seckillOrder);
SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:"+user.getId()+":"+goodsId);
        if (seckillOrder!=null){
            model.addAttribute("errmsg", RespBeanEnum.REPEAT_ERROR.getMessage());
            return RespBean.error(RespBeanEnum.REPEAT_ERROR);
        }

接口的优化

  • 关键在于少查数据库,多用缓存,缓存处理高并发的能力远强于数据库
  1. redis预减库存,减少数据库的访问
  2. 内存标记,减少redis的访问
  3. 请求进入队列缓存,异步下单(RabbitMQ)
  4. 数据库优化:集群,分库分表
队列的使用
  • 导入maven
<!--        rabbitMQ依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>
  • 创建队列
@Configuration
public class RabbitMQConfig {
    @Bean
    public Queue queue(){
        return new Queue("queue",true);
    }
}

  • 生产者
@Service
@Slf4j
public class MQSender {
    @Autowired
    private RabbitTemplate rabbitTemplate;
    public void send(Object msg){
        log.info("发送消息"+msg);
        rabbitTemplate.convertAndSend("queue",msg);
    }
}
  • 消费者
@Service
@Slf4j
public class MQReceiver {
    @RabbitListener(queues="queue")
    public void receive(Object msg){
        log.info("接收消息::"+msg);

    }
}
  • 生产者→交换机→队列→消费者
  • 交换机模式
    • fanout(发布订阅模式,广播模式):消息能够被多个队列同时接收
      • 不处理路由键
      • 转发消息最快
@Configuration
public class RabbitMQConfig {

    private static final String QUEUE01  = "queue_fanout01";
    private static final String QUEUE02  = "queue_fanout02";
    private static final String EXCHANGE  = "fanoutExchange";



    @Bean
    public Queue queue(){
        return new Queue("queue",true);
    }
    @Bean
    public Queue queue01(){
        return new Queue(QUEUE01);
    }
    @Bean
    public Queue queue02(){
        return new Queue(QUEUE02);
    }
    @Bean
    public FanoutExchange fanoutExchange(){
        return new FanoutExchange(EXCHANGE);
    }
    @Bean
    public Binding binding01(){
        return BindingBuilder.bind(queue01()).to(fanoutExchange());
    }
    @Bean
    public Binding binding02(){
        return BindingBuilder.bind(queue02()).to(fanoutExchange());
    }

}
@Service
@Slf4j
public class MQSender {
    @Autowired
    private RabbitTemplate rabbitTemplate;
    public void send(Object msg){
        log.info("发送消息"+msg);
        rabbitTemplate.convertAndSend("fanoutExchange","",msg);
    }
}
@Service
@Slf4j
public class MQReceiver {
    @RabbitListener(queues="queue")
    public void receive(Object msg){
        log.info("接收消息::"+msg);

    }
    @RabbitListener(queues = "queue_fanout01")
    public void receive01(Object msg){
        log.info("QUEUE01接收消息::"+msg);
    }
    @RabbitListener(queues = "queue_fanout02")
    public void receive02(Object msg){
        log.info("QUEUE02接收消息::"+msg);
    }
}
  • direct
    • 设置路由键,通过匹配路由键将消息发给不同的队列
    • 路由键不匹配的消息默认会丢失
@Configuration
public class RabbitMQConfig {

    private static final String QUEUE01  = "queue_direct01";
    private static final String QUEUE02  = "queue_direct02";
    private static final String EXCHANGE  = "directExchange";
    private static final String ROUTINGKEY01 = "queue.red";
    private static final String ROUTINGKEY02 = "queue.green";

    @Bean
    public Queue queue01(){
        return new Queue(QUEUE01);
    }
    @Bean
    public Queue queue02(){
        return new Queue(QUEUE02);
    }
    @Bean
    public DirectExchange directExchange(){
        return new DirectExchange(EXCHANGE);
    }
    @Bean
    public Binding binding01(){
        return BindingBuilder.bind(queue01()).to(directExchange()).with(ROUTINGKEY01);
    }
    @Bean
    public Binding binding02(){
        return BindingBuilder.bind(queue02()).to(directExchange()).with(ROUTINGKEY02);
    }

}
@Service
@Slf4j
public class MQSender {
    @Autowired
    private RabbitTemplate rabbitTemplate;
    public void send01(Object msg){
        log.info("发送red消息"+msg);
        rabbitTemplate.convertAndSend("directExchange","queue.red",msg);
    }

    public void send02(Object msg){
        log.info("发送green消息"+msg);
        rabbitTemplate.convertAndSend("directExchange","queue.green",msg);
    }
}
@Service
@Slf4j
public class MQReceiver {
    @RabbitListener(queues="queue")
    public void receive(Object msg){
        log.info("接收消息::"+msg);

    }
    @RabbitListener(queues = "queue_direct01")
    public void receive01(Object msg){
        log.info("QUEUE01接收消息::"+msg);
    }
    @RabbitListener(queues = "queue_direct02")
    public void receive02(Object msg){
        log.info("QUEUE02接收消息::"+msg);
    }
}
  • topic
    • *匹配一个,#匹配两个
    • 如果同一个队列匹配上了多次,只向它发送一次消息
    • 一个都匹配不到就丢弃消息
    • topic模式既能实现fanout也能实现direct
@Configuration
public class RabbitMQConfig {

    private static final String QUEUE01  = "queue_topic01";
    private static final String QUEUE02  = "queue_topic02";
    private static final String EXCHANGE  = "topicExchange";
    private static final String ROUTINGKEY01 = "#.queue.#";
    private static final String ROUTINGKEY02 = "*.queue.#";

    @Bean
    public Queue queue01(){
        return new Queue(QUEUE01);
    }
    @Bean
    public Queue queue02(){
        return new Queue(QUEUE02);
    }
    @Bean
    public TopicExchange topicExchange(){
        return new TopicExchange(EXCHANGE);
    }
    @Bean
    public Binding binding01(){
        return BindingBuilder.bind(queue01()).to(topicExchange()).with(ROUTINGKEY01);
    }
    @Bean
    public Binding binding02(){
        return BindingBuilder.bind(queue02()).to(topicExchange()).with(ROUTINGKEY02);
    }

}
@Service
@Slf4j
public class MQSender {
    @Autowired
    private RabbitTemplate rabbitTemplate;
    public void send01(Object msg){
        log.info("发送01消息"+msg);
        rabbitTemplate.convertAndSend("topicExchange","queue.red.message",msg);
    }

    public void send02(Object msg){
        log.info("发送01,02消息"+msg);
        rabbitTemplate.convertAndSend("topicExchange","red.queue.green",msg);
    }

}
@Service
@Slf4j
public class MQReceiver {

    @RabbitListener(queues = "queue_topic01")
    public void receive01(Object msg){
        log.info("QUEUE01接收消息::"+msg);
    }
    @RabbitListener(queues = "queue_topic02")
    public void receive02(Object msg){
        log.info("QUEUE02接收消息::"+msg);
    }
}
redis预减库存
  • redis库存为0之后,其他请求都不会再去数据库查询
  • 项目初始化的时候就加载库存到redis中
@Controller
@RequestMapping("/seckill")
public class SecKillController implements InitializingBean {
    @Autowired
    private IUserService userService;
    @Autowired
    private IGoodsService goodsService;
    @Autowired
    private ISeckillOrderService seckillOrderService;
    @Autowired
    private IOrderService orderService;
    @Autowired
    private RedisTemplate redisTemplate;
    @ResponseBody
    @RequestMapping(value="/doSeckill",method = RequestMethod.POST)
    public RespBean doSecKill(Long goodsId, Model model, HttpServletRequest request, HttpServletResponse response){
        String ticket = CookieUtil.getCookieValue(request, "userTicket");
        User user = userService.getUserByCookie(ticket,request,response);
        if(user==null){
            return RespBean.error(RespBeanEnum.LOGIN_ERROR);
        }
        GoodsVo goods = goodsService.findGoodsByGoodsId(goodsId);
        ValueOperations valueOperations = redisTemplate.opsForValue();
        //判断是否重复抢购
        SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:"+user.getId()+":"+goodsId);
        if (seckillOrder!=null){
            return RespBean.error(RespBeanEnum.REPEAT_ERROR);
        }
        //给redis里的库存递减,并获得递减之后的库存
        Long stock = valueOperations.decrement("seckillGoods:" + goodsId);
        if(stock<0){
            //表示抢购失败
            //因为抢购失败了,所以库存应该是0
            valueOperations.increment("seckillGoods:" + goodsId);
            return RespBean.error(RespBeanEnum.EMPTY_STOCK);
        }
        Order order = orderService.sekill(user,goods);
        return RespBean.success(order);
        
        return null;

    }

  

//一个初始化方法,将商品库存数量加载到redis
    @Override
    public void afterPropertiesSet() throws Exception {
        //将所有的商品库存存到redis里
        List<GoodsVo> list = goodsService.findGoodsVo();
        if(CollectionUtils.isEmpty(list)){
            return;
        }
        for (GoodsVo goodsVo : list) {
            redisTemplate.opsForValue().set("seckillGoods:"+goodsVo.getId(),goodsVo.getStockCount());
        }
    }
}
给Redis增加一个内存标记,减少redis的访问
//创建一个map用于redis内存标记,
    private Map<Long,Boolean> EmptyStockMap = new HashMap<>();

  • 内存满了之后(即Redis内存清零)后将map设为true,之后读取redis前先判断一下,如果为true就不读redis了
先将请求加载到队列中,显示排队中的状态
  • 所有秒杀请求都先存在队列中,此时给前端返回一个排队中的状态
  • 消费者挨个接收消息,并判断库存,进行处理,生成订单,减小库存,此时只能生成10个订单
@Configuration
public class RabbitMQConfig {
    private static final String QUEUE = "seckillQueue";
    private static String EXCHANGE = "seckillExchange";
    @Bean
    public Queue queue(){
        return new Queue(QUEUE);
    }
    @Bean
    public TopicExchange topicExchange(){
        return new TopicExchange(EXCHANGE);
    }
    @Bean
    public  Binding binding(){
        return BindingBuilder.bind(queue()).to(topicExchange()).with("seckill.#");
    }
}
@Service
@Slf4j
public class MQSender {
    @Autowired
    private RabbitTemplate rabbitTemplate;
    public void sendSeckillMessage(String msg){
        log.info("发送消息:"+msg);
        rabbitTemplate.convertAndSend("seckillExchange","seckill.message",msg);
    }
}
@Service
@Slf4j
public class MQReceiver {
    @Autowired
    private IGoodsService goodsService;
    @Autowired
    private RedisTemplate redisTemplate;
    @Autowired
    private IOrderService orderService;
    @RabbitListener(queues="seckillQueue")
    public void receive(String msg){
        //进行下单操作
        log.info("接收到消息"+msg);
        SeckillMessage seckillMessage = JSON.parseObject(msg, SeckillMessage.class);
        Long goodsId = seckillMessage.getGoodsId();
        User user = seckillMessage.getUser();
        //开始进入下单操作
        GoodsVo goodsVo = goodsService.findGoodsByGoodsId(goodsId);
        if(goodsVo.getStockCount()<1){
            return;
        }
        //判断是否重复抢购
        SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:"+user.getId()+":"+goodsId);
        if (seckillOrder!=null){
            return;
        }
        //下单操作
        orderService.sekill(user,goodsVo);
    }
}
//将信息传入队列
 SeckillMessage seckillMessage = new SeckillMessage(user, goodsId);
 mqSender.sendSeckillMessage(JSON.toJSONString(seckillMessage));
 //传入队列后,前端接收到消息0,显示正在排队中
 return RespBean.success(0);
轮询判断是否真的秒杀成功
  • 前端页面收到正在排队中后,回去查找订单现在的状态
    • 如果查到订单了,说明抢购成功
    • 如果没查到,而且商品没有了,说明抢购失败
    • 如果商品还有,则返回还在排队中
  • 如果前端得到的还是排队中,就再次调用这个方法,即进行轮询
function doSecKill(){
        $.ajax({
            url: '/seckill/doSeckill',
            type: 'POST',
            data:{
                goodsId:$("#goodsId").val()
            },
            success:function (data){
                if(data.code==200){
                    // window.location.href="/static/orderDetail.htm?orderId="+data.obj.id;
                    getResult($("#goodsId").val());
                }else{
                    layer.msg(data.message);
                }
            },
            error:function (){
                layer.msg("客户端请求错误");
            }
        })
    }

    function getResult(goodsId){
        //加载条动画
        g_showLoading();
        $.ajax({
            url:"/seckill/getResult",
            type:"GET",
            data:{
                goodsId:goodsId,
            },
            success:function(data){
                if(data.code==200){
                    var result = data.obj;
                    if(result<0){
                        layer.msg("对不起,秒杀失败!")
                    }else if(result==0){
                        setTimeout(function (){
                            //轮询,设置轮询时间为50
                            getResult(goodsId);
                        },50);
                    }else{
                        layer.confirm("恭喜你,秒杀成功!查看订单吗?",{btn:["确定","取消"]},
                        function(){
                            window.location.href="/static/orderDetail.htm?orderId="+result;
                        },
                        function(){
                            //取消就直接关掉
                            layer.close();
                        })
                    }
                }else{
                    layer.msg(data.message);
                }
            },
            error:function (){
                layer.msg("客户端请求错误");
            }

        })
    }
@Service
public class SeckillOrderServiceImpl extends ServiceImpl<SeckillOrderMapper, SeckillOrder> implements ISeckillOrderService {
    @Autowired
    private SeckillOrderMapper seckillOrderMapper;
    @Autowired
    private RedisTemplate redisTemplate;
    /**
     * 获取秒杀结果
     * @param user
     * @param goodsId
     * @return orderId:成功,-1;秒杀失败,0;排队中
     */
    @Override
    public Long getResult(User user, Long goodsId) {
        QueryWrapper<SeckillOrder> wrapper = new QueryWrapper<SeckillOrder>().eq("user_id", user.getId()).eq("goods_id", goodsId);
        SeckillOrder seckillOrder = seckillOrderMapper.selectOne(wrapper);
        if(null != seckillOrder){
            return seckillOrder.getOrderId();
        }else if(redisTemplate.hasKey("isStockEmpty:"+goodsId)){
            //表示库存已经为空,这个人没有抢到
            return -1L;
        }else{
            //表示还在排队
            return 0L;
        }

    }
}
  • redis的递增递减操作具有原子性

redis锁

  • 一个线程去操作redis就会占位,别的线程就无法操作,这个线程执行完之后删除锁
@Autowired
    private RedisTemplate redisTemplate;
    @Test
    public void testLock01() {
        ValueOperations valueOperations = redisTemplate.opsForValue();
        //随便设置一个key,每个线程都有去尝试存入这个key,只有这个key不存在的情况下才能存入,返回是否存入成功。
        Boolean isLock = valueOperations.setIfAbsent("k1", "v1");
        if(isLock){
            valueOperations.set("name","mgy");
            String name = (String) valueOperations.get("name");
            System.out.println(name);
            //一个线程结束后将k1删除,下一个线程才能进来
            redisTemplate.delete("k1");
        }
        else{
            System.out.println("有线程在使用,请稍后");
        }
    }
  • 使用lua脚本能先获取锁,然后判断锁的值是否一致,然后再进行删除(保证每次只删自己的锁,不删别人的锁)
    • 这三个操作在lua脚本中具有原子性

安全优化

隐藏接口地址
  • 先获得一个接口地址,再进行秒杀,根据用户和商品给出唯一的接口地址,让其他人不能使用
  • 将以用户和商品为key,地址为value的数据存入Redis
function getSeckillPath(){
        var goodsId = $("#goodsId").val();
        console.log(goodsId);
        g_showLoading();
        $.ajax({
            url:"/seckill/path",
            type:'GET',
            data:{
                goodsId:goodsId,
            },
            success:function(data){
                if(data.code==200){
                    var path = data.obj;
                    doSecKill(path);
                }else{
                    layer.msg(data.message);
                }
            },
            error:function(){
                layer.msg("客户端请求错误");
            }
        })
    }



    function doSecKill(path){
        $.ajax({
            url: '/seckill/'+path+'/doSeckill',
            type: 'POST',
            data:{
                goodsId:$("#goodsId").val(),
            },
            success:function (data){
                if(data.code==200){
                    // window.location.href="/static/orderDetail.htm?orderId="+data.obj.id;
                    getResult($("#goodsId").val());
                }else{
                    layer.msg(data.message);
                }
            },
            error:function (){
                layer.msg("客户端请求错误");
            }
        })
    }
@RequestMapping(value = "/path",method = RequestMethod.GET)
    @ResponseBody
    public RespBean getPath(Long goodsId, HttpServletRequest request, HttpServletResponse response){
        System.out.println("获得路径");
        String ticket = CookieUtil.getCookieValue(request, "userTicket");
        User user = userService.getUserByCookie(ticket,request,response);
        if(user==null){
            return RespBean.error(RespBeanEnum.LOGIN_ERROR);
        }
        String str = orderService.createPath(user,goodsId);
        System.out.println(str);
        return RespBean.success(str);
    }

@Override
    public String createPath(User user, Long goodsId) {
        String str = MD5Util.md5(UUIDUtil.uuid() + "123456");
        //将每个用户随机生成的接口地址存在redis,之后进行校验
        redisTemplate.opsForValue().set("seckillPath:"+user.getId()+":"+goodsId,str,60, TimeUnit.MINUTES);
        return str;
    }
@ResponseBody
    @RequestMapping(value="/{path}/doSeckill",method = RequestMethod.POST)
    public RespBean doSecKill(Long goodsId, @PathVariable String path, HttpServletRequest request, HttpServletResponse response){
        String ticket = CookieUtil.getCookieValue(request, "userTicket");
        User user = userService.getUserByCookie(ticket,request,response);
        if(user==null){
            return RespBean.error(RespBeanEnum.LOGIN_ERROR);
        }
        GoodsVo goods = goodsService.findGoodsByGoodsId(goodsId);
        ValueOperations valueOperations = redisTemplate.opsForValue();
        boolean check = orderService.checkPath(user,goodsId,path);
@Override
    public boolean checkPath(User user, Long goodsId, String path) {
        if(user==null||goodsId<0|| StringUtils.isEmpty(path)){
            return false;
        }
        String redisPath = (String) redisTemplate.opsForValue().get("seckillPath:" + user.getId() + ":" + goodsId);
        return path.equals(redisPath);
    }
添加验证码
  • 增加机器抢购难度
  • 减少并发
  • 将用户id和商品id做为key,验证码的值作为参数存进Redis里
  • 只有输入正确验证码才能通过redis的校验
  • 来自gitee上的开源代码,不写了
接口限流
  • 用redis记录次数,有100个缓存之后就无法通过,一分钟到了部分缓存失效,就又可以存入
ValueOperations valueOperations = redisTemplate.opsForValue();
        //获得请求的地址
        String uri = request.getRequestURI();
        //限制访问次数,五秒内访问五次
        Integer count = (Integer) valueOperations.get(uri + ":" + user.getId());
        if(count==null){
            //如果还没有这个,就设置一个,此时值为1
            valueOperations.set(uri+":"+user.getId(),1,5,TimeUnit.SECONDS);
        }else if(count<5){
            //如果值小于5,就递增
            valueOperations.increment(uri+":"+user.getId());
        }else{
            return RespBean.error(RespBeanEnum.ACCESS_LIMIT_REACHED);
        }
  • 限流控制在最大能承受的QPS的70%-80%
  • 这个方法的问题在于临界失效前后如果有大量请求的话,还是会超过QPS
  • 漏桶算法:一部分进一部分出,通常用队列实现,但是可能导致桶被装满,太少会造成资源的浪费
  • 令牌算法:以恒定的速度生成令牌,放进令牌桶里,如果桶满了就丢弃令牌,一个请求出现后要去拿令牌,拿到令牌才能够被执行
  • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3TIsm9Hf-1648091808661)(.doc.markdown_images/9d58a6b5.png)]
  • redis优势:具有递增原子性,能够设计失效时间
通用接口限流
  • threadLocal:每个线程绑定自己的值,不会造成用户信息紊乱。相当于每个线程中一个存放私有数据的盒子
@Retention(RetentionPolicy.RUNTIME)//在运行时使用
@Target(ElementType.METHOD)//在方法上使用
public @interface AccessLimit {

    int second();

    int maxCount();

    boolean needLogin() default true;
}
public class AccessLimitInterceptor implements HandlerInterceptor {
    @Autowired
    private IUserService userService;
    @Autowired
    private RedisTemplate redisTemplate;
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//获取用户
        String ticket = CookieUtil.getCookieValue(request, "userTicket");
        User user = userService.getUserByCookie(ticket,request,response);
        UserContext.setUser(user);

        //      判断拦截的是不是个方法
        if(handler instanceof HandlerMethod){
            HandlerMethod hm = (HandlerMethod) handler;
            AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);
            //如果没有这个注解
            if(accessLimit==null){
                return true;
            }
            int second = accessLimit.second();
            int maxCount = accessLimit.maxCount();
            boolean needLogin = accessLimit.needLogin();
            String key = request.getRequestURI();
            if(needLogin){
                if(user==null){
                    return false;
                }
                key+=":"+user.getId();
            }
            ValueOperations valueOperations = redisTemplate.opsForValue();
            Integer count = (Integer) valueOperations.get(key);
            if(count==null){
                valueOperations.set(key,1,second, TimeUnit.SECONDS);
            }else if(count<maxCount){
                valueOperations.increment(key);
            }else{
                return false;
            }
        }


        return false;
    }
}

秒杀主流方案分析

需要注意的问题
  • 高并发,刷接口等黑客请求,超出负载
  • 高并发时导致的超卖
  • 该负载情况下下单成功率的保障
网关限流
  • 黑名单,放在内存里
  • 多次请求,redis缓存重复
  • 没有预约(没有令牌)
  • 预约可以提前发放部分令牌
  • redission加分布式锁,redis集群
AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);
            //如果没有这个注解
            if(accessLimit==null){
                return true;
            }
            int second = accessLimit.second();
            int maxCount = accessLimit.maxCount();
            boolean needLogin = accessLimit.needLogin();
            String key = request.getRequestURI();
            if(needLogin){
                if(user==null){
                    return false;
                }
                key+=":"+user.getId();
            }
            ValueOperations valueOperations = redisTemplate.opsForValue();
            Integer count = (Integer) valueOperations.get(key);
            if(count==null){
                valueOperations.set(key,1,second, TimeUnit.SECONDS);
            }else if(count<maxCount){
                valueOperations.increment(key);
            }else{
                return false;
            }
        }


        return false;
    }
}

秒杀主流方案分析

需要注意的问题
  • 高并发,刷接口等黑客请求,超出负载
  • 高并发时导致的超卖
  • 该负载情况下下单成功率的保障
网关限流
  • 黑名单,放在内存里
  • 多次请求,redis缓存重复
  • 没有预约(没有令牌)
  • 预约可以提前发放部分令牌
  • redission加分布式锁,redis集群
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值