暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

Java项目笔记之秒杀实现

java学途 2021-06-21
1604
不点蓝字,我们哪来故事?



页面详情页面

用户在抢购页面会不断刷新,不能每次都到数据读取信息。将页面信息保存进缓存中实现快速读的功能。缓存优化,无限前置化,异步多活。了解不同的优化方案。JVM的ehcache,静态页面,CDN,Nginx。用户的电脑。动态资源最多前置到网关,因为需要更新。

缓存前置化

缓存有2个重要的运用方式:预读取和延迟写。
  1. 预读取就是预先读取将要载入的数据,也可以称作“缓存预热”,它是在系统中先将硬盘中的一部分数据加载到内存中,然后再对外提供服务。

  2. 通过缓存机制来加速“写”的过程就可以称作“延迟写”,它是预先将需要写入到磁盘或者数据库的数据,暂时写入到内存,然后就返回成功,再定时将内存中的数据批量写入到磁盘。

哪里可以加缓存?
  • 热点数据:被高频访问,如几十次/秒以上

  • 静态数据:很少变化,读远大于写,如几天变更一次

每个设立点可以挡掉一些流量,最终形成一个漏斗状的拦截效果,以此保护最后面的系统以及最终的数据库。

下面简要描述一下每个运用场景以及需要注意的点。

浏览器缓存

这是离用户最近的可以作为缓存的地方,而且借助的是用户的“资源”(缓存的数据在用户的终端设备上),性价比可谓最好,让用户帮你分担压力。

当你打开浏览器的开发者工具,看到from cache或者from memory cache、from disk cache的时候,就意味着这些数据已经被缓存在了用户的终端设备上了,没网的时候也能访问到一部分内容就是这个原因。

这个过程是浏览器替我们完成的,一般用于缓存图片、js与css这些资源,我们可以通过Http消息头中的Cache-Control来控制它,具体细节这里就不展开了。此外,js里的全局变量、cookie等运用也属于该范畴。

浏览器缓存是在于用户侧的缓存点,所以我们对它的掌控力会比较差,在没有发起新请求的情况下,你无法主动去更新数据。

CDN缓存

提供CDN服务的服务商,在全国甚至是全球部署着大量的服务器节点(可以叫做“边缘服务器”)。

那么将数据分发到这些遍布各地服务器上作为缓存,让用户访问就近的服务器上的缓存数据,就可以起到压力分摊和加速效果。这在toC类型的系统上运用,效果格外显著。

但是需要注意的是,由于节点众多,更新缓存数据比较缓慢,一般至少是分钟级别,所以一般仅适用于不经常变动的静态数据。

题外话:解决方式也是有的,就是在url后面带个自增数或者唯一标示,如?v=1001。因为不同的url会被视作“新”的数据和文件,被重新create出来。

网关(代理)缓存

到这里做缓存就是在你自己的地盘了。很多时候我们会在源站前面架一层网关(或者说反向代理、正向代理),为的是做一些安全机制或者作为统一分流策略的入口。

同时这里也是做缓存的一个好场所,毕竟网关是“业务无关性”的,它能够拦下来请求,对背后的源站也有很大的受益,减少了大量的CPU运算。

常用的网关(代理)缓存有Varnish、Squid与Ngnix。一般情况下,简单的缓存运用场景,用Nginx即可,因为大部分时候我们会用它来做负载均衡,能少引入一个技术就少一份复杂度。如果是大量的小文件可以使用Varnish,而Squid则相对大而全,运用成本也更高一些。

进程内缓存

可能我们大多数程序员第一次刻意使用缓存的场景就是这个时候。

一个请求能走到这里说明它是“业务相关”的,需要经过业务逻辑的运算。

也正因为如此,从这里开始对缓存的引入成本比前面3种大大增加,因为对缓存与数据库之间的“数据一致性”要求更高了。

进程外缓存

这个大家也熟悉,就是Redis与Memcached之类,甚至也可以自己单独写一个程序来专门存放缓存数据,供其它程序远程调用。

这里先多说几句关于Redis和Memcached该怎么选择的思路。

对资源(cpu、内存等)利用率格外重视的话可以使用Memcached,但程序在使用的时候需要容忍可能发生的数据丢失,因为是纯内存的机制。如果无法容忍这点,并且对资源利用率也比较豪放的话可以使用Redis。而且Redis的数据库结构更多,Memcached只有key-value,更像是一个NoSQL存储。

数据库缓存

数据库本身是自带缓存模块的,否则也不会叫它内存杀手,基本上你给多少内存就能吃多少。数据库缓存是数据库的内部机制,一般都会给出设置缓存空间大小的配置来让你进行干预。

最后,其实磁盘本身也有缓存。所以你会发现,为了让数据能够平稳地写到物理磁盘中真的是一波三折,不知道什么时候可以有“快”到不需要程序来考虑缓存的磁盘出现来拯救我们程序员呢。




课堂正题

怎么获取到url中的参数值

拆 ?后面的字符串,再根据 & 对字符串拆分, 再以 = 拆成变量和值。

     // 获取地址栏参数
    function getQueryString(name) {
    var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
    var r = window.location.search.substr(1).match(reg);
    if (r != null) return unescape(r[2]);
    return null;
    }


    秒杀商品详情信息展示:

    controller:

    findById方法和query中的操作大部分是一样的,区别就是一个查的是集合,一个查的是单个而已。将公共的部分抽取成一个方法。查单个就是list中只装一个对象即可;

       
      @Service
      public class SeckillGoodServiceImpl implements ISeckillGoodService {

      @Autowired
      private SeckillGoodMapper seckillGoodMapper;

      @Autowired
      private GoodFeignApi goodFeignApi; // 涉及RPC 检查启动类有无注解:@EnableFeignClients

      //查秒杀商品列表
      @Override
      public List<SeckillGoodVO> query() {
      // 秒杀商品列表的查询
      // 1.查询商品秒杀的列表
      List<SeckillGood> seckillGoodList = seckillGoodMapper.listAll();
      List<SeckillGoodVO> seckillGoodVOList = getVo(seckillGoodList);
      return seckillGoodVOList;
      }

      //通过 id 查秒杀商品
      @Override
      public SeckillGoodVO findById(Long id) {
      SeckillGood seckillGood = seckillGoodMapper.selectByPrimaryKey(id);
      // List<SeckillGood> seckillGoodList = new ArrayList<>(1);
      // seckillGoodList.add(seckillGood);
      List<SeckillGood> seckillGoodList = Collections.singletonList(seckillGood);
      List<SeckillGoodVO> seckillGoodVOList = getVo(seckillGoodList);
      return seckillGoodVOList.size() > 0 ? seckillGoodVOList.get(0) : null;
      }

      // 抽取出来的查询秒杀商品的方法,对 query 和 findById 复用
      private List<SeckillGoodVO> getVo(List<SeckillGood> seckillGoodList) {
      // 2.根据商品秒杀的列表,得到商品 id 的列表, ids 应该是Set集合,因为不同场秒杀关联的商品可能相同
      Set<Long> goodIds = new HashSet<>(seckillGoodList.size());
      for (SeckillGood seckillGood : seckillGoodList) {
      goodIds.add(seckillGood.getId());
      }

      // 3.通过 feign 远程调用商品服务,查询列表
      // TODO 商品服务的远程调用,通过 feign 暴露的接口
      Result<List<Good>> result = goodFeignApi.getGoodListByIds(goodIds);
      // 商品服务可能返回三种结果:1. 正常返回; 2. 降级的null;3. code != 200 返回result
      if (result.hasError()) { // result == null || result.getCode() != 200 的情况是有错误
      // 返回 null 或者抛出异常给调用者响应的提示
      throw new BusinessException(SeckillServerCodeMsg.DEFAULT_ERROR);
      }
      //正常调用返回的数据
      List<Good> goodList = result.getData();

      // 通过遍历的方式来给属性赋值,效率低。采用HashMap做缓存的方式来操作
      HashMap<Long, Good> tempCache = new HashMap<>(goodList.size());
      // 把查到的列表先丢到map中
      for (Good good : goodList) {
      tempCache.put(good.getId(), good);
      }
      // 4.遍历商品列表,将列表中每项的属性,复制到 VO 中,得到 vo 列表
      List<SeckillGoodVO> seckillGoodVOList = new ArrayList<>(seckillGoodList.size());
      for (SeckillGood seckillGood : seckillGoodList) {
      Good good = tempCache.get(seckillGood.getGoodId());// 拿到map中的值
      SeckillGoodVO vo = new SeckillGoodVO(); // 创建vo对象,复制两者属性

      // ------ 注意属性复制的顺序:vo的id应该和秒杀商品的id相同 -------
      // * 复制商品的属性
      if (good != null)
      //有值才复制属性:BeanUtils
      BeanUtils.copyProperties(good, vo);
      // * 复制秒杀商品的属性
      BeanUtils.copyProperties(seckillGood, vo);

      seckillGoodVOList.add(vo); // 添加进list中
      }
      return seckillGoodVOList;
      }

      }


      判断用户是否登录:

      商品详情的前提是需要用户登陆之后才可以有展示效果,用户没有登录就提示用户进行登录。


      prop



      member-server的controller:

      使用了前台传过来的token去Redis中查询了登陆过的用户信息。

      更好的方式:自定义参数解析器

      响应回来的信息不能把密码等敏感信息返回:不让他序列化,就不会再网络中进行传输了。


      定义参数解析器

      用自定义参数解析器,来优化验证用户是否登录的过程:

      controller可以变得这样简单:

             // 查询当前登录用户信息
        @RequestMapping("/current")
        public Result<User> current(@UserParam User user) {
        return Result.success(user);
        }

        自定义的标识注解:

        目的:注册和登录的时候的参数user不应该被我们定义的解析器给解析了。两者情况场景不一样,用注解标记区分开。

           @Target(ElementType.PARAMETER)
          @Retention(RetentionPolicy.RUNTIME)
          public @interface UserParam {
          }


          自定义的参数解析器:

             /**
            * 用户参数解析器
            */
            public class UserArgumentResolver implements HandlerMethodArgumentResolver {

            @Autowired
            private StringRedisTemplate redisTemplate;

            // 参数解析的条件
            @Override
            public boolean supportsParameter(MethodParameter methodParameter) {
            return User.class.equals(methodParameter.getParameterType()) &&
            methodParameter.hasParameterAnnotation(UserParam.class);
            }

            // 解析逻辑
            @Override
            public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {
            //拿到请求中的token
            HttpServletRequest request = nativeWebRequest.getNativeRequest(HttpServletRequest.class); // 指定类型转成 HttpServletRequest 类型的请求
            if (request == null) {
            return null;
            }

            // 拿到Redis中的key
            String token = CookieUtils.getCookieValue(request, CookieUtils.USERTOKEN_NAME);
            if (StringUtils.isEmpty(token)) {
            return null;
            }

            // 通过 key 查询数据
            String userJson = redisTemplate.opsForValue().get(RedisKeys.USER_LOGIN_TOKEN.join(token));
            if (StringUtils.isEmpty(userJson)) {
            return null;
            }
            return JSON.parseObject(userJson, User.class);
            }
            }

            需要注册注解解析器:才会生效

            创建配置类的方式:@Configration
              implements WebMVCConfigurer
            重新addXxx方法;

               /**
              * 配置类:参数解析器和跨域等各种配置不应该放在启动类里,启动类中一个main方法就可以了
              * 一切配置放这里
              */
              @Configuration // 标识这是一个配置类
              public class WebConfig implements WebMvcConfigurer {

              // 让Spring创建参数解析器的实例
              @Bean
              public UserArgumentResolver userArgumentResolver() {
              return new UserArgumentResolver();
              }

              // 将参数解析器的实例添加注册
              @Override
              public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
              resolvers.add(userArgumentResolver());
              }
              }


              前端:

              详情页面的秒杀按钮,应该有倒计时,并且时间未到不能点击;

              代码拷贝;


              秒杀流程:

              前端代码:


              创建订单对象实体类:

              拷贝过来,放在seckill-api中;

              该服务中使用到自定义的参数解析器,需要一个配置类,注意:配置类不可以复用,每个server应该是单独的WebConfig类。

              这样做虽然提升了性能,但是同时也增加了服务之间的耦合:

                 @Configuration
                public class WebConfig implements WebMvcConfigurer {
                @Bean
                public UserArgumentResolver userArgumentResolver(){
                return new UserArgumentResolver();
                }

                @Override
                public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
                resolvers.add(userArgumentResolver());
                }
                }

                还要注意:这里用户是从Redis中获取到的,我们使用到redis-server服务,所以我们要加上配置将它引入进来:


                controller:根据订单流程来完成


                创建基础订单的时候:orderNo做主键

                秒杀服务的服务端做了集群之后,每个服务注册到注册中心,用户通过负载均衡来访问到服务器。如果集群很多,那么压力就到了数据库这里,因为所有的请求都直接访问到额是数据库。数据量随着生产期的增加会不断增加,所以需要对表进行分库分表操作,不同的数据库不同的表,此时再用id做注解就不行了,id都是从1开始的。所以这里选择使用orderNo做主键。企业开发中有使用雪花算法(SnowFlake)来做主键进行分库分表。



                   @RestController
                  @RequestMapping("/api/orders")
                  public class OrderInfoController {
                  @Autowired
                  private ISeckillGoodService seckillGoodService;
                  @Autowired
                  private ISeckillOrderService seckillOrderService;
                  @Autowired
                  private IOrderInfoService orderInfoService;

                  @RequestMapping("/doSeckill")
                  public Result<String> doSeckill(Long seckillId, @UserParam User user) {
                  // 校验逻辑
                  if (user == null || seckillId == null) {
                  throw new BusinessException(SeckillServerCodeMsg.OPS_ERROR);
                  }
                  // 1. 获取秒杀商品对象,判断秒杀活动是否存在
                  SeckillGoodVO seckillGoodVO = seckillGoodService.findById(seckillId);
                  if (seckillGoodVO == null) {
                  throw new BusinessException(SeckillServerCodeMsg.OPS_ERROR);
                  }
                  // 2. 判断当前时间是否在秒杀活动的时间内
                  Date now = new Date(); // 当前时间
                  // 当前小于开始时间,活动未开始
                  if (now.compareTo(seckillGoodVO.getStartDate()) < 0) {
                  throw new BusinessException(SeckillServerCodeMsg.NOT_START_ERROR);
                  }
                  // 当前大于等于结束时间,活动已结束
                  if (now.compareTo(seckillGoodVO.getEndDate()) >= 0) {
                  throw new BusinessException(SeckillServerCodeMsg.ALREADY_OVER_ERROR);
                  }

                  // 3. 判断当前用户是不是重复下单了,不允许重复下单
                  SeckillOrder seckillOrder = seckillOrderService.findBySeckillIdAndUserId(seckillId, user.getId());
                  if (seckillOrder != null) {
                  throw new BusinessException(SeckillServerCodeMsg.ALREADY_AGEIN_ERROR);
                  }
                  // 4. 判断秒杀商品的库存是否足够
                  if (seckillGoodVO.getStockCount() <= 0){
                  throw new BusinessException(SeckillServerCodeMsg.STOCKCOUNT_OVER_ERROR);
                  }
                  // 5. 创建秒杀订单:
                  // 包括 1.减库存 2.创建普通订单(秒杀订单依赖普通订单) 3.创建秒杀订单
                  String orderNo = orderInfoService.doSeckill(seckillId, user.getId());
                  return Result.success(orderNo);
                  }
                  }
                  doSeckill做秒杀操作的方法:

                  注意@Transactional(rollbackFor = Exception.class)
                  DML 操作,要保证原子性等,要加上事务,显示的指定Exception

                    public class OrderInfoServiceImpl implements IOrderInfoService {
                    @Autowired
                    private ISeckillGoodService seckillGoodService;
                    @Autowired
                    private ISeckillOrderService seckillOrderService;
                    @Autowired
                    private OrderInfoMapper orderInfoMapper;


                    // DML 操作,要保证原子性等,要加上事务,显示的指定Exception
                    @Transactional(rollbackFor = Exception.class)
                    public String doSeckill(Long seckillId, Long userId) {
                    // 做秒杀操作,包括
                    // 1.减库存 t_seckill_goods
                    seckillGoodService.decrStockCount(seckillId);
                    // 2.创建普通订单(秒杀订单依赖普通订单) t_order_info
                    String orderNo = this.creatOrder(seckillId, userId);
                    // 3.创建秒杀订单 t_seckill_order
                    seckillOrderService.creatSeckillOrder(seckillId, userId, orderNo);
                    return orderNo;
                    }


                    // 2.创建普通订单
                    private String creatOrder(Long seckillId, Long userId) {
                    SeckillGoodVO vo = seckillGoodService.findById(seckillId);
                    OrderInfo orderInfo = new OrderInfo();
                    // 分布式唯一主键
                    String orderNo = "";
                    long id = IdGenerateUtil.get().nextId();
                    orderNo = orderNo + id; // 主键一般用 String
                    // generatAll 插件快速生成
                    orderInfo.setOrderNo(orderNo); // TODO
                    orderInfo.setGoodCount(1); // 商品数量
                    orderInfo.setUserId(userId);
                    orderInfo.setGoodId(vo.getGoodId());
                    orderInfo.setGoodName(vo.getGoodName());
                    orderInfo.setGoodImg(vo.getGoodImg());
                    orderInfo.setGoodCount(vo.getStockCount());
                    orderInfo.setGoodPrice(vo.getGoodPrice());
                    orderInfo.setSeckillPrice(vo.getSeckillPrice());
                    orderInfo.setCreateDate(vo.getEndDate());


                    // 保存订单对象
                    orderInfoMapper.insert(orderInfo);


                    return orderNo;
                    }
                    }


                    订单详情:

                    前端代码:

                    controller:

                    存在一个问题,通过id就可以查询订单信息了(这样订单信息不安全),查询的时候必须是当前用户查询的。

                    impl:

                    查询条件:



                    java学途

                    只分享有用的Java技术资料 

                    扫描二维码关注公众号

                     


                    笔记|学习资料|面试笔试题|经验分享 

                    如有任何需求或问题欢迎骚扰。微信号:JL2020aini

                    或扫描下方二维码添加小编微信

                     




                    小伙砸,欢迎再看分享给其他小伙伴!共同进步!


                    文章转载自java学途,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                    评论