《谷粒商城》高级篇——★商城业务

一、商城首页

1.es中sku的存储结构分析

        只有上架的商品存储到Elasticsearch中才能被检索。
(1)需要存储到 es 中的商品信息
        我们先来分析一下需要存储商品的哪些信息到es中:
  • 需要保存 sku 信息
    当搜索商品名时,查询的是 sku 的标题 sku_title;
    可能通过 sku 的标题、销量、价格区间检索
  • 需要保存品牌、分类等信息
    点击分类,检索分类下的所有信息
    点击品牌,检索品牌下的商品信息
  • 需要保存 spu 信息
    选择规格,检索共有这些规格的商品
(2)存储结构的分析
        我们希望通过空间换取时间的方案来存储数据。所以采用以下存储结构:
        
  • 在es中新建mapping映射结构:
    "mappings": {
      "properties": {
        "skuId": { "type": "long" },
        "spuId": { "type": "keyword" }, # 精确检索,不分词
        "skuTitle": {
          "type": "text", # 全文检索
          "analyzer": "ik_smart" # 分词器
        },
        "skuPrice": { "type": "keyword" },
        "skuImg": {
          "type": "keyword",
          "index": false, # false 不可被检索
          "doc_values": false # false 不可被聚合
        },
        "saleCount":{ "type":"long" }, # 商品销量
        "hasStock": { "type": "boolean" }, # 商品是否有库存
        "hotScore": { "type": "long"  }, # 商品热度评分
        "brandId":  { "type": "long" }, # 品牌id
        "catalogId": { "type": "long"  }, # 分类id
        "brandName": {	# 品牌名,只用来查看,不用来检索和聚合
          "type": "keyword",
          "index": false,
          "doc_values": false
        },
        "brandImg":{	# 品牌图片,只用来查看,不用来检索和聚合
          "type": "keyword",
          "index": false,
          "doc_values": false
        },
        "catalogName": {	# 分类名,只用来查看,不用来检索和聚合
          "type": "keyword",
          "index": false,
          "doc_values": false
        },
        "attrs": {	# 属性对象
          "type": "nested",	# ★嵌入式,内部属性
          "properties": {
            "attrId": {"type": "long"  },
            "attrName": {	# 属性名
              "type": "keyword",
              "index": false,
              "doc_values": false
            },
            "attrValue": { "type": "keyword" }	# 属性值
          }
        }
      }
    }
  • 关于 nested 类型——官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/current/nested.html
    Object 数据类型的数组在不使用nested类型时会被扁平化处理为一个简单的键与值的列表(右图),即对象的相同属性会放到同一个数组中(同属于first的John和Alice放在了一个数组里),在检索时会出现错误。参考官网:https://www.elastic.co/guide/en/elasticsearch/reference/current/nested.html#nested-arrays-flattening-objects

    所以对于 Object 类型的数组,要使用 nested 字段类型。先指定数组的type类型为nested(如下图),再存值。参考官网:https://www.elastic.co/guide/en/elasticsearch/reference/current/nested.html#nested-fields-array-objects

2.商品上架

(1)封装es存储实体
        因为我们需要在product和search两个服务中传输es存储的实体类,所以这是一个to,为了方便我们将其封装在common中。
@Data
public class SpuEsModule {
    private Long skuId;
    private Long spuId;
    private String skuTitle;
    private BigDecimal skuPrice;
    private String skuImg;
    private Long saleCount;
    /**
     * 是否有库存
     */
    private Boolean hasStock;
    /**
     * 热度
     */
    private Long hotScore;
    private Long brandId;
    private Long catalogId;
    private String brandName;
    private String brandImg;
    private String catalogName;
    private List<Attrs> attrs;

    @Data
    public static class Attrs {
        private Long attrId;
        private String attrName;
        private String attrValue;
    }
}
(2)编写上架接口代码
        涉及到多个服务间的远程调用问题:①product调用ware查库存②★product调用search操作es将数据保存到索引库中。
        这里涉及到的代码及接口太多,就不放在这了。

3.商城首页

(1)首页视图请求过程
        用户请求通过nginx反向代理到gateway,再由gateway路由到相应的微服务。我们这里实现了动态资源和静态资源的分离(动静分离),将静态资源统一放到nginx中,将动态资源放到各个微服务中。
        这样做减少了微服务的压力。
        
(2)导入首页相关页面
        首页相关页面是属于product模块的操作,所以现在要操作product服务。
        这里我们使用 thymeleaf 作为模板来渲染页面。thymeleaf 是一个XML/XHTML/HTML5模板引擎,可用于Web与非Web环境中的应用开发,Thymeleaf提供了一个用于整合Spring MVC的可选模块,提供一种可被浏览器正确显示的、格式良好的模板创建方式,因此也可以用作静态建模
  • 导入thymeleaf模板引擎依赖
    <!-- 模板引擎 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
  • 将资料里首页的静态资源和页面分别复制到resources下的static(存放静态资源)和templates(存放模板)文件夹下。
  • 配置关闭thymeleaf缓存
    spring:
      thymeleaf:
        cache: false
(3)首页跳转功能
        我们希望访问 http://localhost:12000/ 或 http://localhost:12000/index.html 都能跳转到首页(index.html)。
        我们的首页页面是放在product项目resources/templates/中,即index.html。而在配置thymeleaf时可以看到,默认的文件路径前缀是"classpath:/templates/",后缀是".html",这个classpath(类路径)指的就是resources,所以我们在返回首页路径的时候,不需要写成 "resources/templates/index.html",直接写文件名"index"即可,thymeleaf会利用视图解析器进行拼串,将默认配置的前后缀和我们返回的字符串拼成首页页面的路径。
        
  • 编写首页跳转接口
    @GetMapping({"/","/index.html"})
    public String indexPage(Model model){
        //查询所有一级分类信息
        List<CategoryEntity> categoryEntities=categoryService.getLevel1();
    
        model.addAttribute("categories",categoryEntities);
        return "index";
    }
    
    //查询所有一级分类信息 
    
    @Override
    public List<CategoryEntity> getLevel1() {
        List<CategoryEntity> categoryEntities = this.list(new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));
        return categoryEntities;
    }
  • 查询二三级分类信息
    @Override
    public Map<String, List<Catelog2Vo>> getCatelogJSON() {
        //1、查出所有分类
        //1、1)查出所有一级分类
        List<CategoryEntity> level1Categories = getLevel1();
        //封装数据
        Map<String, List<Catelog2Vo>> parentCid = level1Categories.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
            //1、根据每个一级分类查询其所有二级分类信息
            List<CategoryEntity> categoryEntities = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", v.getCatId()));
            //2、封装二级分类
            List<Catelog2Vo> catalogs2Vos = null;
            if (categoryEntities != null) {
                catalogs2Vos = categoryEntities.stream().map(l2 -> {
                    Catelog2Vo catalogs2Vo = new Catelog2Vo(v.getCatId().toString(),l2.getCatId().toString(), l2.getName(),null);
                    //1、找当前二级分类的三级分类封装成vo
                    List<CategoryEntity> level3Catelog = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", l2.getCatId()));
                    if (level3Catelog != null) {
                        List<Catelog2Vo.Catelog3Vo> category3Vos = level3Catelog.stream().map(l3 -> {
                            //2、封装成指定格式
                            Catelog2Vo.Catelog3Vo category3Vo = new Catelog2Vo.Catelog3Vo(l2.getCatId().toString(), l3.getCatId().toString(), l3.getName());
                            return category3Vo;
                        }).collect(Collectors.toList());
                        catalogs2Vo.setCatalog3List(category3Vos);
                    }
                    return catalogs2Vo;
                }).collect(Collectors.toList());
            }
            return catalogs2Vos;
        }));
        return parentCid;
    }

(4)搭建域名访问环境

        我们希望通过在地址栏输入gulimall.com就可以访问到首页页面。将gulimall.com发送给nginx,再由nginx反向代理到网关,再由网关路由到product服务。
        
        (一)gulimall.com → nginx → product
        下面的例子我们先实现一个简单的域名访问,nginx跳过网关,直接反向代理到product。
  • 修改系统的hosts文件(C:\Windows\System32\drivers\etc),这样通过gulimall.com就能访问到虚拟机了
    # gulimall #
    我的虚拟机ip地址	gulimall.com
  • 修改虚拟机中Nginx的配置文件
    在nginx总配置文件中我们可以看到, http 块中最后有 include /etc/nginx/conf.d/*.conf; 这句配置说明在 conf.d 目录下所有 .conf 后缀的文件内容都会作为 nginx 配置文件 http 块中的配置。这是为了防止主配置文件太复杂,也可以对不同的配置进行分类。我们参考 conf.d 目录下的 default.conf 来配置 gulimall 的 server 块配置。
    server {
        listen       80;
        # 这里写我们再hosts中修改的虚拟机ip对应的域名
        server_name  gulimall.com;
        #charset koi8-r;
        #access_log  /var/log/nginx/log/host.access.log  main;
        location / {
          # 将gulimall.com反向代理到http://192.168.152.1:12000
          proxy_pass http://192.168.152.1:12000;
        }
        #error_page  404              /404.html;
        # redirect server error pages to the static page /50x.html
        #
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   /usr/share/nginx/html;
        }
    }
  • 效果演示:
        (二)gulimall.com → nginx → 网关 → product
  • 由nginx反向代理到gateway(上游服务器),在conf中配置上游服务器组(组名gulimall),这里我们只有一个gateway服务:

  • 配置反向代理地址
  • ★访问跳转分析
    在使用gulimall.com域名访问时,会在请求头中携带Host:gulimall.com信息。

    我们期望网关通过Host断言来将gulimall.com路由到product服务。但是由于我们是先将请求发给nginx,而nginx默认会丢掉这个Host:gulimall.com信息,这样由nginx反向代理给网关的请求里就不带Host信息了,导致即使在网关中配置了正确的路由规则,也无法正确显示首页所以我们在配置gateway路由规则前,要先配置nginx,使其保留Host:gulimall.com信息。
  • 配置nginx保留Host信息

  • 配置网关路由规则

4.使用JMeter进行压力测试

(1)基本使用
(2)动静分离优化
        以后将所有的静态资源都交给Nginx,所有的动态资源都由后台负责。
        我们规定让页面请求静态资源时都加上/static前缀,让/static/**都转到Nginx处理。
  • 把product服务中static文件夹下的 index包 放到 虚拟机中 nginx 的html里:/mydata/nginx/html/static(mkdir的)/index;

  • product服务中的index.html里所有静态资源的请求路径前都加上 /static;
  • 在nginx中配置静态资源处理规则:修改 Nginx 配置文件 /mydata/nginx/conf/conf.d/gulimall.conf:
    # 将 /static/请求 都转到/user/share/nginx/html文件夹,也就是在这个文件夹里找静态资源
    location /static/ {
    	root /user/share/nginx/html;
    }
(3)★查询三级分类数据优化
        之前我们是通过不断查询数据获取三级分类的数据。
        优化:先通过一次查询,将数据库所有分类信息查询出来,然后原来通过catId=parentId来查询数据库的方法抽取为一个getParentId方法,通过这个方法,从一次查询的到的所有分类信息中筛选出(filter)符合条件的三级分类信息
@Override
public Map<String, List<Catelog2Vo>> getCatelogJSON() {
    //1.将多次查询数据库变为查询一次数据库
    //先查询出所有的分类信息
    List<CategoryEntity> selectList = baseMapper.selectList(null);
    //1、查出所有分类
    //1、1)查出所有一级分类
    List<CategoryEntity> level1Categories = getLevel1();
    //封装数据
    Map<String, List<Catelog2Vo>> parentCid = level1Categories.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), v -> {
        //1、根据每个一级分类查询其所有二级分类信息
        List<CategoryEntity> categoryEntities = getParentCid(selectList,0L);
        //2、封装二级分类
        List<Catelog2Vo> catalogs2Vos = null;
        if (categoryEntities != null) {
            catalogs2Vos = categoryEntities.stream().map(l2 -> {
                Catelog2Vo catalogs2Vo = new Catelog2Vo(v.getCatId().toString(),l2.getCatId().toString(), l2.getName(),null);
                //1、找当前二级分类的三级分类封装成vo
                List<CategoryEntity> level3Catelog = getParentCid(selectList, l2.getCatId());
                if (level3Catelog != null) {
                    List<Catelog2Vo.Catelog3Vo> category3Vos = level3Catelog.stream().map(l3 -> {
                        //2、封装成指定格式
                        Catelog2Vo.Catelog3Vo category3Vo = new Catelog2Vo.Catelog3Vo(l2.getCatId().toString(), l3.getCatId().toString(), l3.getName());
                        return category3Vo;
                    }).collect(Collectors.toList());
                    catalogs2Vo.setCatalog3List(category3Vos);
                }
                return catalogs2Vo;
            }).collect(Collectors.toList());
        }
        return catalogs2Vos;
    }));
    return parentCid;
}
private List<CategoryEntity> getParentCid(List<CategoryEntity> selectList,Long parentCid) {
    List<CategoryEntity> collect = selectList.stream().filter(item -> item.getParentCid() == parentCid).collect(Collectors.toList());
    //return baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", v.getCatId()));
    return collect;
}

二、缓存与分布式锁

1.缓存的使用

(1)什么样的数据适合进行缓存?
  • 即时性、数据一致性要求不高的
  • 访问量大且更新频率不高的数据(读多,写少)
(2)读取缓存的过程
        
【tips】在开发中,凡是放入缓存中的数据我们都应该指定过期时间,使其可以在系统即使没有主动更新数据也能自动触发数据加载进缓存的流程,避免业务崩溃导致的数据永久不一致问题
(3)本地缓存和分布式缓存
        根据缓存是否与应用进程属于同一进程,缓存可分为本地缓存与分布式缓存。
  • 本地缓存在同一个进程内的内存空间中缓存数据,数据读写都是在同一个进程内完成。
  • 分布式缓存一般都是独立部署的一个进程,并且与应用进程部署在不同的机器上。

2.使用Redis分布式缓存

(1)整合Redis
  • 导入坐标
    <dependency>
    	<groupId>org.springframework.boot</groupId>
    	<artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
  • 配置Redis
    spring:
        #Redis相关配置
      redis:
        host: localhost
        port: 6379
        #password: 123456
        database: 0 #redis一共提供了16个数据库,默认使用0号数据库
(2)给业务中加入缓存
        这里使用依赖中自带的 StringRedisTemplate 来操作 Redis,这里存储的值可以直接转化成 JSON 字符串的对象信息。
  • 将原来直接从数据库中查询数据的方法抽取成一个方法:
    //从数据库中直接获取数据
    public Map<String, List<Catelog2Vo>> getCatelogJSONFromDB() {
        ...
    }
  • 加入缓存的业务逻辑:想从数据库中查数据时直接调用上面抽取的getCatelogJSONFromDB()方法即可:
    @Override
    public Map<String, List<Catelog2Vo>> getCatelogJSON() {
        //加入缓存逻辑
        //1.先判断redis中是否有缓存数据
        ValueOperations<String, String> ops = redisTemplate.opsForValue();
        String json = ops.get("catelogJSON");
        //2.如果没有缓存
        if (StringUtils.isEmpty(json)) {
            //2.1查询数据库
            Map<String, List<Catelog2Vo>> catelogJSON = getCatelogJSONFromDB();
            //2.2将查到的数据放入缓存
            ops.set("catelogJSON", JSON.toJSONString(catelogJSON));
            //2.3返回查询到的数据
            return catelogJSON;
        }
        //如果有缓存数据,将缓存的String数据转成复杂的Map<String, List<Catelog2Vo>>类型后返回
        return JSON.parseObject(json, new TypeReference<Map<String, List<Catelog2Vo>>>() {
        });
    }
(3)堆外内存溢出异常OutOfDirectMemoryError
        加入缓存之后可能会产生堆外内存溢出异常:OutOfDirectMemoryError。
  • 产生的原因
    SpringBoot 2.0 以后默认使用 lettuce 作为操作 redis 的客户端,它使用 netty 进行网络通信。
    lettuce 自身存在的 bug 会导致 netty 堆外内存溢出。
    netty 如果没有指定堆外内存,默认使用 -Xmx 参数指定的内存;可以通过 -Dio.netty.maxDirectMemory 进行设置,指定别的内存。
  • 解决方案
    虽然可以通过 -Dio.netty.maxDirectMemory 去调大堆外内存,但这样只会延缓异常出现的时间,而不能避免出现这个异常。
    可以通过升级 lettuce 客户端,或使用 jedis 客户端来解决。
  • 将lettuce换成jedis:
    <!-- redis -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-redis</artifactId>
      //将lettuce排除
      <exclusions>
        <exclusion>
          <groupId>io.lettuce</groupId>
          <artifactId>lettuce-core</artifactId>
        </exclusion>
      </exclusions>
    </dependency>
    //再导入jedis
    <dependency>
      <groupId>redis.clients</groupId>
      <artifactId>jedis</artifactId>
    </dependency>

3.高并发下可能出现的缓存失效问题

(1)缓存穿透
        缓存穿透是指在查询一个一定不存在的数据时,由于缓存不命中,就去查询数据库,但是数据库也无此记录,就无法将这次查询的 null 写入缓存,这将导致每次请求这个不存在的数据时都要到数据库去查询,这就失去了缓存的意义。 在流量大时,可能 DB 就挂掉了,要是有人恶意利用数据库中不存在的数据频繁攻击我们的应用,这就是漏洞。
        解决方法:缓存空结果,并且设置短的过期时间。
(2)缓存雪崩
        缓存雪崩是指在设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,此时大量请求全部转发到 DB,DB 瞬时压力过重导致雪崩。
        解决方法:在原有的失效时间基础上增加一个随机时间值,这样每一个缓存的过期时间的重复率就会降低,就很难导致缓存集体失效。
(3)缓存击穿
        缓存击穿是指对于一些设置了过期时间的热点数据(可能会在某些时间点被超高并发地访问),如果这些热点缓存数据刚失效,就有大量请求来请求这些数据,此时这些所有请求都要来查数据库,我们称为缓存击穿。
        解决方法:加锁。大量并发只让一个人去查,其他人等待,等一个人查到之后就释放锁。此时其他人获取到锁,会先查缓存,就会有数据了,避免了大量请求去查数据库。

4.分布式锁

(1)本地锁的缺点
        本地锁只能锁住当前服务的进程,每一个单独的服务都会有一个进程读取数据库,不能达到只查询一次数据库的效果,所以需要分布式锁。
        比如有80万并发请求,有8个分布式商品服务,每个商品服务处理10万,使用本地锁的话8个商品服务就要查8次数据库。
        
(2)分布式锁的原理
       
(3)分布式锁的使用
       
        redis 中有一个 SETNX 命令(jdk——setIfAbsent方法),该命令会向 redis 中保存一条数据,如果数据不存在则保存成功,存在则返回失败
        在使用redis分布式锁时,要注意以下问题:
  • 要设置锁的自动过期时间,保证能正常删除锁,防止死锁;
  • 一般使用uuid作为加锁(占坑)时存入Redis的值,在删锁时与自己的uuid匹配起来才能删,保证删除的是自己的锁;
  • 要保证加锁【占位+设置过期时间】和解锁【匹配uuid+删锁】操作的原子性
    /**
    * 从数据库查询并封装数据::分布式锁
    *
    * @return
    */
    public Map<String, List<Catalogs2Vo>> getCatalogJsonFromDbWithRedisLock() {
        //1、占分布式锁。去redis占坑 设置过期时间必须和加锁是同步的,保证原子性(避免死锁)
        String uuid = UUID.randomUUID().toString();
        Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 300, TimeUnit.SECONDS);
        if (lock) {
            System.out.println("获取分布式锁成功...");
            Map<String, List<Catalogs2Vo>> dataFromDb = null;
            try {
                //加锁成功...执行业务
                dataFromDb = getCatalogJsonFromDB();
            } finally {
                // lua 脚本解锁
                String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
                // 删除锁
                redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Collections.singletonList("lock"), uuid);
            }
            return dataFromDb;
        } else {
            System.out.println("获取分布式锁失败...等待重试...");
            //加锁失败...重试机制
            //休眠一百毫秒
            try {
                TimeUnit.MILLISECONDS.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return getCatalogJsonFromDbWithRedisLock();     //自旋的方式
        }
    }
(4)★使用Redisson实现分布式锁
        分布式锁与java中juc中的所有锁用法都是一样的。
        上面那种分布式锁的使用虽然能够为我们解决分布式锁的问题,但是只是实现了一种重试锁,而实际业务中要用到的锁有很多(参考juc中的锁,但juc中的锁是本地锁),而Redission可以解决分布式锁的问题。以后所有的分布式我们都会用这个工具去完成。
        Redisson同lettuce、jedis一样也可以用作Redis客户端,底层也是调用的redis的方法。redission中文文档:https://github.com/redisson/redisson/wiki/Table-of-Content
  • Redisson的使用
    • 导入依赖
       <dependency>
         <groupId>org.redisson</groupId>
         <artifactId>redisson</artifactId>
         <version>3.11.1</version>
      </dependency>
    • 配置Redisson
      @Configuration
      public class MyRedissonConfig {
          /**
           * 所有对 Redisson 的使用都是通过 RedissonClient
           *
           * @return
           * @throws IOException
           */
          @Bean(destroyMethod = "shutdown")
          public RedissonClient redisson() throws IOException {
              // 1、创建配置
              Config config = new Config();
              // Redis url should start with redis://&nbs***bsp;rediss://
              config.useSingleServer().setAddress("redis://192.168.163.131:6379");
              // 2、根据 Config 创建出 RedissonClient 实例
              return Redisson.create(config);
          }
      }
    • 使用

      【tips】Redisson的优点:
                  ①第2步加锁的操作是阻塞式等待,当其他进程获取不到锁时会一直等待直到有锁,作用相当于我们在前面写的通过自旋的方式实现的等待锁。
                  ②实现了锁的自动续期:业务运行时间过长时,不用担心锁在业务进行时被删除,因为在拿到锁后会自动给锁续期(30s)。
                  ③业务完成,自动解锁:加锁的业务只要完成,就不会给锁续期,即使不手动解锁(没有执行unlock方法),锁也会自动删除。
  • ★Redisson如何解决死锁问题?——lock看门狗原理
    在加锁时有两种方式:
        lock()默认30s超时自动解锁,且实现了锁的自动续期,如果业务未完成,会每隔一段时间给锁续期。
        lock( 指定超时时间 , 时间单位 ):在指定超时时间到了后自动解锁,没有自动续期。因此一般设置指定超时时间>业务执行时间
    两种加锁方式的实现原理(源码):
        
        
        
        两种lock方法都会调用tryAcquire方法尝试获取锁,lock()传入的leaseTime-1;如果是我们指定了超时时间的话传入的leaseTime就等于指定的超时时间。它会通过一个if判断leaseTime:
            如果leaseTime不等于-1的话就是指定了超时时间,会给Redis发送lua脚本进行占锁。(如下图)
            
            如果leaseTime等于-1的话就是lock(),就使用lockWatchdogTimeout作为过期时间(默认30s)。
            
        由此可知如果只是用lock.lock()不传过期时间的话,会启动看门狗机制;传过期时间的话,就不会启动看门狗机制。
        【接着lock()获取到看门狗超时时间(getLockWatchdogTimeout)后往下看】在没有异常的时候(e==null)会开启一个定时任务(new TimerTask),来重新给锁设置超时时间(还是看门狗超时时间)进行续期。续期也不是立马续期,而是在三分之一个看门狗时间(lockWatchdogTimeout / 3,默认10s)后进行续期。(也就是renewExpiration方法每次自我调用间隔10s。)
            
            
  • 两种加锁方式的选择:
    一般不推荐使用有看门狗机制的lock()方法进行加锁。
(5)Redisson读写锁(ReadWriteLock)
        读写锁把对共享资源的访问者划分成读者和写者,读者只对共享资源进行读访问,写者则需要对共享资源进行写操作一个读写锁同时只能有一个写者多个读者
        加了读写锁保证读者一定能读到最新数据。
        在写着修改共享资源时,写锁是一个排他锁(互斥锁、独享锁),读锁是一个共享锁。
  • 读写锁的几种组合情况:
    • 读+读:并发读,相当于无锁。只会在redis中记录好所有当前的读锁,他们都会同时加锁成功。
    • 写+读:读者需等待写锁释放才能读到数据。
    • 写+写:阻塞方式,必须等待写锁释放,其他写着才能访问资源。
    • 读+写:写锁需要等待读锁释放。
    总结:只要有写锁的存在,都必须等待。

(6)Redisson分布式信号量(semaphore)

【场景模拟】模拟停车,共有三个车位(共享资源)。
  • 在Redis中存一个名为 "park" 的信号量,设置信号量总量为3个。
  • 通过redisson客户端的getSemaphore方法来创建一个名为 "park" 的信号量。通过acquirerelease方法来获取和释放一个信号量,获取一个信号量,Redis中信号量就 - 1;释放一个信号量,Redis中信号量就 + 1。如果Redis中信号量为0,获取操作就必须等待。

  • 对于获取信号量,还可以使用tryAcquire方法,它的作用是尝试获取一个信号量,能获取就返回true,没有获取到就返回false,不会一直等待别人释放信号量。
        信号量还可以用来实现分布式限流

(7)分布式闭锁(CountDownLatch)

        闭锁可以延迟线程的进度直到其到达终止状态。闭锁的作用相当于一扇门:在闭锁结束之前,这扇门一直是关闭的,没有任何线程能通过,当到达结束状态时,这扇门会打开并允许所有的线程通过。当闭锁到达结束状态后,将不会再改变状态,因此这扇门将永远保持打开状态。 
        闭锁可以用来确保某些活动直到其他活动都完成后才继续执行
【场景模拟】模拟放假学校锁门,假设学校共有5个班,要等5个班都走完了才能锁门。
        

5.缓存数据的一致性问题

        常用的解决缓存数据一致性问题的方案有双写模式和失效模式。
1)双写模式——先写数据库,再写缓存
        
(2)失效模式——先写数据库,再删除缓存数据(这样下次查询缓存未命中,是从数据库得到最新数据)
        
(3)解决方案
        从上面可以看到,无论是双写模式还是失效模式,都会导致缓存的不一致问题,即多个实例同时更新会出事。那么该怎么办呢?
  • ①如果是用户纬度数据(订单数据、用户数据),这种并发几率非常小,缓存数据加上过期时间,每隔一段时间触发读的主动更新即可;
  • ②如果是菜单,商品介绍等基础数据,也可以去使用canal订阅binlog的方式
  • ③缓存数据+过期时间也足够解决大部分业务对于缓存的要求
  • ④通过加锁保证并发读写,写写的时候按顺序排好队。读读无所谓。所以适合使用读写锁。(业务不关心脏数据,允许临时脏数据可忽略) ;
    【总结】我们放入缓存的数据本就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间,保证每天拿到当前最新数据即可,我们不应该过度设计,增加系统的复杂性。遇到实时性、一致性要求高的数据,即使查数据库慢点,我们也应该查数据库

(4)基于 Canal的异步通知 解决缓存一致性问题

        
(5)当前项目开发的一致性解决方案
  • 给缓存的所有数据都设置过期时间,即使数据过期下一次查询会触发主动更新;
  • 读写数据的时候,加上分布式的读写锁

6.SpringCache

(1)简介
        Spring 从3.1开始定义了org.springframework.cache.Cache 和org.springframework.cache.CacheManager 接口来统一不同的缓存技术,并支持使用JCache(JSR-107)注解简化我们开发。
  • Cache
    • Cache 接口是缓存的组件规范定义,包含了缓存的各种操作集合;
    • 在Cache 接口下Spring 提供了各种 xxxCache 的实现,如RedisCache、EhCacheCache、ConcurrentMapCache 等
  • CacheManager 缓存管理器:用来管理各种Cache。
    • CacheManager 中只有两个方法:

    • CacheManager支持多种类型的缓存:

(2)整合SpringCache+Redis简化分布式缓存开发

  • 引入springcache的依赖
    <!--spring-boot-starter-cache-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>
    因为我们使用Redis作为缓存,因此还需要引入Redis相关依赖
    <!--引入redis-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
        <exclusions>
            <exclusion>
                <groupId>io.lettuce</groupId>
                <artifactId>lettuce-core</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
    <!--jedis不写版本springboot控制-->
    <dependency>
        <groupId>redis.clients</groupId>
        <artifactId>jedis</artifactId>
    </dependency>
  • 配置springcache
    • 引入依赖后帮我们做了哪些自动配置
      • CacheProperties封装了配置文件中可以配置的属性:
      • CacheConfiguration会根据缓存类型导入RedisCacheConfiguration,RedisCacheConfiguration自动配好了缓存管理器:
    • 需要手动配置——配置使用的缓存类型
  • 使用 @EnableCaching 开启缓存功能
  • springcache的使用——常用的注解
  • @Cacheable:加在方法上。将方法的返回数据保存到缓存中:如果缓存中有,就不调用该方法;缓存不命中才调用该方法并将结果放入缓存。
    //将每一个缓存的数据放入指定的缓存分区(通常按照业务类型分)
    @Cacheable({"categroy"})
    默认情况下存入Redis中的缓存数据的特点:
            ①key值自动生成——缓存分区名::SimpleKey{}
            ②
    缓存的Value值默认使用JDK序列化机制,即是将序列化后的数据存到Redis
            ③默认过期时间TTL:-1,即永不过期
    自定义缓存数据信息:
            ①自定义key值:使用key属性,接收SpEL表达式(以#root开头的那个),要么就用双引号里加单引号:" ' 指定的key值 ' "来指定key值。这里是把上面的SimpleKey{}换成了我们指定的key值。
                  
            ②设置数据过期时间:修改配置文件,time-to-live
    spring:
      cache:
        type: redis
        redis:
          time-to-live: 3600000  #设置存活时间,毫秒
            ③★使用RedisCacheConfiguration配置其他缓存配置信息
                a)原理:上面的自动配置中我们提到,CacheConfiguration会根据缓存类型(比如用Redis作为缓存)导入RedisCacheConfiguration,RedisCacheConfiguration自动配好了缓存管理器,初始化所有的缓存,每个缓存决定了要使用什么配置:配置类中有就用配置类的,没有就用自动配置时默认的。因此想要修改缓存的配置只需要在容器中放一个RedisCacheConfiguration配置类即可,这个Redis缓存配置类容器就会应用到当前RedisCacheManager管理的所有缓存分区中。

                b)具体代码实现
    @EnableCaching
    @Configuration
    public class MyCacheConfig {
        @Bean
        RedisCacheConfiguration redisCacheConfiguration(){
            RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
            //key的序列化配置——使用String存储key
            config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
            //值的序列化配置——使用JSON形式存储值
            config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
            return config;
        };
    }
            c)存在的问题:在配置文件中缓存配置没有生效(比如我们设置的TTL)。
                 原因 :上面我们自己写的RedisCacheConfiguration配置类没有读取配置文件中的配置。
                 解决:
    @EnableConfigurationProperties(CacheProperties.class)
    @Configuration
    @EnableCaching
    public class MyCacheConfig {
        @Bean
        public RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {
    
            RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
            // config = config.entryTtl();
            //设置Key的序列化方式
            config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
            //设置值的序列化方式
            config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
    
            CacheProperties.Redis redisProperties = cacheProperties.getRedis();
            //将配置文件中所有的配置都生效
            if (redisProperties.getTimeToLive() != null) {
                config = config.entryTtl(redisProperties.getTimeToLive());
            }
            if (redisProperties.getKeyPrefix() != null) {
                config = config.prefixKeysWith(redisProperties.getKeyPrefix());
            }
            if (!redisProperties.isCacheNullValues()) {
                config = config.disableCachingNullValues();
            }
            if (!redisProperties.isUseKeyPrefix()) {
                config = config.disableKeyPrefix();
            }
            return config;
        }
    }
  • @CacheEvict:将数据从缓存删除的操作。可以用来实现先写数据再删除缓存的失效模式
    //删除category缓存分区里key为getLevel1Categorys的缓存数据
    @CacheEvict(value="category缓存分区里的",key="'getLevel1Categorys'")
    如果一次修改操作需要删除两个或多个缓存该如何删除?
    ①使用 @Caching 组合两个或多个 @CacheEvict:
        
    ②指定删除某个缓存分区下的所有缓存数据(allEntries):
        
  • @CachePut:在不影响方法执行的情况下更新缓存。可以用来实现先写DB再写缓存的双写模式
  • @Caching:组合以上多个操作。
  • @CacheConfig:在类级别共享缓存的相同配置。
(3)SpringCache的不足
  • 对读模式(查询数据)下可能出现的问题的解决方案:
    • 缓存穿透:在配置文件中开启缓存空数据:cache-null-values:true;
    • 缓存击穿:SpringCache的读操作默认是不加锁的,可以使用@Cacheable(value = {“categroy”},key = “#root.method.name”,sync = true),通过sync开启加锁。只有Cacheable有这个属性;
    • 缓存雪崩:指定过期时间:time-to-live: 3600000。
  • 对于写模式(修改数据)下缓存数据一致性问题,SpringCache无法解决。一般可以通过读写加锁、引入Canal、直接去数据库中查询等来解决。
【总结】对于常规数据(读多写少、缓存一致性要求不高)的缓存可以不考虑加锁,只要有缓存的过期时间就可以满足需求了,因此对于这些数据完全可以使用SpringCache来缓存。
              但是对于一致性和即时性要求高的特殊数据,既想通过缓存提升数据读取速度,又想要保证缓存数据的一致性,就需要进行特殊设计,如使用读写锁、引入Canal等来解决一致性问题。

二、商城检索服务

1.检索页面环境搭建

(1)添加页面模板
  • 导入thymeleaf和devtools依赖
    <!-- 模板引擎 -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    <!-- devtools依赖 -->
    。。。。。。
    
    记得关闭缓存
  • 将资料中的检索页面放到 search 服务模块下的 resource/templates 下
(2)将静态资源放到Nginx
(3)配置请求跳转
  • 配置域名映射——修改 Windows hosts 文件:
    虚拟机ip地址  search.gulimall.com
  • 找到 Nginx 的配置文件,编辑 gulimall.conf,将所有 *.gulimall.com 的请求都经 Nginx 转发给网关;
    server {
        listen       80;
        server_name  *.gulimall.com;
    		...
     }
    
    记得重启nginx
  • 配置网关服务转发到 search 服务
    - id: gulimall_search_route
      uri: lb://gulimall-search
      predicates:
      - Host=search.gulimall.com
(3)配置页面跳转
  • 将 search.gulimall.com/list.html 请求转发到 list页面模板(之前的index.html改名了)
    /**
     * 自动将页面提交过来的所有请求参数封装成我们指定的对象
     * @param param
     * @return
     */
    @GetMapping(value = "/list.html")
    public String listPage() {
        return "list";
    }
(4)检索条件模型分析
  • 检索页可以用来检索的条件有很多(如下图),这些条件的请求参数是跟在URL后面的。
  • 我们定义一个叫SearchParam的vo,专门用来封装这些条件参数。
  • 然后还需要在Service(MallSearchService)中定义根据传入的条件参数查询相关数据的方法(search()),并将数据交给Controller然后返回给前端页面。

(5)检索结果模型分析
  • 上面Service中的search方法的返回值类型是Object,那么给前端页面响应的检索结果应该是什么类型呢?通过分析我们可以将返回的检索结果封装成一个vo——SearchResult。
    @Data
    public class SearchResult {
        /**
         * 查询到的所有商品信息
         */
        private List<SkuEsModel> product;
        /**
         * 当前页码
         */
        private Integer pageNum;
        /**
         * 总记录数
         */
        private Long total;
        /**
         * 总页码
         */
        private Integer totalPages;
        private List<Integer> pageNavs;
        /**
         * 当前查询到的结果,所有涉及到的品牌
         */
        private List<BrandVo> brands;
        /**
         * 当前查询到的结果,所有涉及到的所有属性
         */
        private List<AttrVo> attrs;
        /**
         * 当前查询到的结果,所有涉及到的所有分类
         */
        private List<CatalogVo> catalogs;
        //===========================以上是返回给页面的所有信息============================//
        @Data
        public static class BrandVo {
            private Long brandId;
            private String brandName;
            private String brandImg;
        }
        @Data
        public static class AttrVo {
            private Long attrId;
            private String attrName;
            private List<String> attrValue;
        }
        @Data
        public static class CatalogVo {
            private Long catalogId;
            private String catalogName;
        }
    }
  • 这样我们search的返回值类型就应该是SearchResult了。同时因为要将查询结果返回给页面展示,所以需要用Model来接收,最后在页面中渲染展示。

(6)ES检索DSL语句分析

GET mall_product/_search
{
  "query": {
    "bool": {
      "must": [ {"match": {  "skuTitle": "华为" }} ], # 检索出华为
      "filter": [ # 过滤
        { "term": { "catalogId": "225" } },
        { "terms": {"brandId": [ "2"] } }, 
        { "term": { "hasStock": "false"} },
        {
          "range": {
            "skuPrice": { # 价格1K~7K
              "gte": 1000,
              "lte": 7000
            }
          }
        },
        {
          "nested": {
            "path": "attrs", # 聚合名字
            "query": {
              "bool": {
                "must": [
                  {
                    "term": { "attrs.attrId": { "value": "6"} }
                  }
                ]
              }
            }
          }
        }
      ]
    }
  },
  "sort": [ {"skuPrice": {"order": "desc" } } ],
  "from": 0,
  "size": 5,
  "highlight": {  
    "fields": {"skuTitle": {}}, # 高亮的字段
    "pre_tags": "<b style='color:red'>",  # 前缀
    "post_tags": "</b>"
  },
  "aggs": { # 查完后聚合
    "brandAgg": {
      "terms": {
        "field": "brandId",
        "size": 10
      },
      "aggs": { # 子聚合
        "brandNameAgg": {  # 每个商品id的品牌
          "terms": {
            "field": "brandName",
            "size": 10
          }
        },
        "brandImgAgg": {
          "terms": {
            "field": "brandImg",
            "size": 10
          }
        }
      }
    },
    "catalogAgg":{
      "terms": {
        "field": "catalogId",
        "size": 10
      },
      "aggs": {
        "catalogNameAgg": {
          "terms": {
            "field": "catalogName",
            "size": 10
          }
        }
      }
    },
    "attrs":{
      "nested": {"path": "attrs" },
      "aggs": {
        "attrIdAgg": {
          "terms": {
            "field": "attrs.attrId",
            "size": 10
          },
          "aggs": {
            "attrNameAgg": {
              "terms": {
                "field": "attrs.attrName",
                "size": 10
              }
            }
          }
        }
      }
    }
  }
}
(7)根据DSL语句编写search方法
@Override
public SearchResult search(SearchParam param) {
    // 动态构建出查询需要的DSL语句
    SearchResult result = null;
    //1、准备检索请求
    SearchRequest searchRequest = buildSearchRequest(param);
    try {
        //2、执行检索请求
        SearchResponse response = esRestClient.search(searchRequest, MallElasticSearchConfig.COMMON_OPTIONS);
        //3、分析响应数据,封装成我们需要的格式
        result = buildSearchResult(response, param);
    } catch (IOException e) {
        e.printStackTrace();
    }
    return result;
}
-----------------------------------------------------------------------
//构建DSL语句和检索请求
private SearchRequest buildSearchRequest(SearchParam param) {
    // 检索请求构建
    SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
    /**
     * 查询:模糊匹配,过滤(按照属性,分类,品牌,价格区间,库存)
     */
    //1. 构建 bool-query
    BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
    //1.1 bool-must 模糊匹配
    if (!StringUtils.isEmpty(param.getKeyword())) {
        boolQueryBuilder.must(QueryBuilders.matchQuery("skuTitle", param.getKeyword()));
    }
    //1.2.1 bool-filter catalogId 按照三级分类id查询
    if (null != param.getCatalog3Id()) {
        boolQueryBuilder.filter(QueryBuilders.termQuery("catalogId", param.getCatalog3Id()));
    }
    //1.2.2 bool-filter brandId 按照品牌id查询
    if (null != param.getBrandId() && param.getBrandId().size() > 0) {
        boolQueryBuilder.filter(QueryBuilders.termsQuery("brandId", param.getBrandId()));
    }
    //1.2.3 bool-filter attrs 按照指定的属性查询
    if (param.getAttrs() != null && param.getAttrs().size() > 0) {
        param.getAttrs().forEach(item -> {
            //attrs=1_5寸:8寸&2_16G:8G
            BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
            //attrs=1_5寸:8寸
            String[] s = item.split("_");
            String attrId = s[0]; // 检索的属性id
            String[] attrValues = s[1].split(":");//这个属性检索用的值
            boolQuery.must(QueryBuilders.termQuery("attrs.attrId", attrId));
            boolQuery.must(QueryBuilders.termsQuery("attrs.attrValue", attrValues));
            // 每一个属性都要生成一个 nested 查询
            NestedQueryBuilder nestedQueryBuilder = QueryBuilders.nestedQuery("attrs", boolQuery, ScoreMode.None);
            boolQueryBuilder.filter(nestedQueryBuilder);
        });
    }
    //1.2.4 bool-filter hasStock 按照是否有库存查询
    if (null != param.getHasStock()) {
        boolQueryBuilder.filter(QueryBuilders.termQuery("hasStock", param.getHasStock() == 1));
    }
    //1.2.5 skuPrice bool-filter 按照价格区间查询
    if (!StringUtils.isEmpty(param.getSkuPrice())) {
        //skuPrice形式为:1_500或_500或500_
        RangeQueryBuilder rangeQueryBuilder = QueryBuilders.rangeQuery("skuPrice");
        String[] price = param.getSkuPrice().split("_");
        if (price.length == 2) {
            rangeQueryBuilder.gte(price[0]).lte(price[1]);
        } else if (price.length == 1) {
            if (param.getSkuPrice().startsWith("_")) {
                rangeQueryBuilder.lte(price[1]);
            }
            if (param.getSkuPrice().endsWith("_")) {
                rangeQueryBuilder.gte(price[0]);
            }
        }
        boolQueryBuilder.filter(rangeQueryBuilder);
    }
    // 封装所有的查询条件
    searchSourceBuilder.query(boolQueryBuilder);
    /**
     * 排序,分页,高亮
     */
    // 2.1 排序  形式为sort=hotScore_asc/desc
    if (!StringUtils.isEmpty(param.getSort())) {
        String sort = param.getSort();
        // sort=hotScore_asc/desc
        String[] sortFields = sort.split("_");
        SortOrder sortOrder = "asc".equalsIgnoreCase(sortFields[1]) ? SortOrder.ASC : SortOrder.DESC;
        searchSourceBuilder.sort(sortFields[0], sortOrder);
    }
    // 2.2 分页 from = (pageNum - 1) * pageSize
    searchSourceBuilder.from((param.getPageNum() - 1) * EsConstant.PRODUCT_PAGE_SIZE);
    searchSourceBuilder.size(EsConstant.PRODUCT_PAGE_SIZE);
    // 2.3 高亮
    if (!StringUtils.isEmpty(param.getKeyword())) {
        HighlightBuilder highlightBuilder = new HighlightBuilder();
        highlightBuilder.field("skuTitle");
        highlightBuilder.preTags("<b style='color:red'>");
        highlightBuilder.postTags("</b>");

        searchSourceBuilder.highlighter(highlightBuilder);
    }
    System.out.println("构建的DSL语句" + searchSourceBuilder.toString());
    /**
     * 聚合分析
     */
    //1. 按照品牌进行聚合
    TermsAggregationBuilder brand_agg = AggregationBuilders.terms("brand_agg");
    brand_agg.field("brandId").size(50);
    //1.1 品牌的子聚合-品牌名聚合
    brand_agg.subAggregation(AggregationBuilders.terms("brand_name_agg").field("brandName").size(1));
    //1.2 品牌的子聚合-品牌图片聚合
    brand_agg.subAggregation(AggregationBuilders.terms("brand_img_agg").field("brandImg").size(1));
    searchSourceBuilder.aggregation(brand_agg);
    //2. 按照分类信息进行聚合
    TermsAggregationBuilder catalog_agg = AggregationBuilders.terms("catalog_agg");
    catalog_agg.field("catalogId").size(20);
    catalog_agg.subAggregation(AggregationBuilders.terms("catalog_name_agg").field("catalogName").size(1));
    searchSourceBuilder.aggregation(catalog_agg);
    // 3. 按照属性信息进行聚合
    NestedAggregationBuilder attr_agg = AggregationBuilders.nested("attr_agg", "attrs");
    //3.1 按照属性ID进行聚合
    TermsAggregationBuilder attr_id_agg = AggregationBuilders.terms("attr_id_agg").field("attrs.attrId");
    attr_agg.subAggregation(attr_id_agg);
    //3.1.1 在每个属性ID下,按照属性名进行聚合
    attr_id_agg.subAggregation(AggregationBuilders.terms("attr_name_agg").field("attrs.attrName").size(1));
    //3.1.2 在每个属性ID下,按照属性值进行聚合
    attr_id_agg.subAggregation(AggregationBuilders.terms("attr_value_agg").field("attrs.attrValue").size(50));
    searchSourceBuilder.aggregation(attr_agg);
    log.debug("构建的DSL语句 {}", searchSourceBuilder.toString());
    SearchRequest searchRequest = new SearchRequest(new String[]{EsConstant.PRODUCT_INDEX}, searchSourceBuilder);
    return searchRequest;
}
---------------------------------------------------------------------------------
//根据es返回数据封装要返回的响应数据
private SearchResult buildSearchResult(SearchResponse response, SearchParam param) {
    SearchResult result = new SearchResult();
    //1、返回的所有查询到的商品
    SearchHits hits = response.getHits();
    List<SkuEsModel> esModels = new ArrayList<>();
    //遍历所有商品信息
    if (hits.getHits() != null && hits.getHits().length > 0) {
        for (SearchHit hit : hits.getHits()) {
            String sourceAsString = hit.getSourceAsString();
            SkuEsModel esModel = JSON.parseObject(sourceAsString, SkuEsModel.class);
            //判断是否按关键字检索,若是就显示高亮,否则不显示
            if (!StringUtils.isEmpty(param.getKeyword())) {
                //拿到高亮信息显示标题
                HighlightField skuTitle = hit.getHighlightFields().get("skuTitle");
                String skuTitleValue = skuTitle.getFragments()[0].string();
                esModel.setSkuTitle(skuTitleValue);
            }
            esModels.add(esModel);
        }
    }
    result.setProduct(esModels);
    //2、当前商品涉及到的所有属性信息
    List<SearchResult.AttrVo> attrVos = new ArrayList<>();
    //获取属性信息的聚合
    ParsedNested attrsAgg = response.getAggregations().get("attr_agg");
    ParsedLongTerms attrIdAgg = attrsAgg.getAggregations().get("attr_id_agg");
    for (Terms.Bucket bucket : attrIdAgg.getBuckets()) {
        SearchResult.AttrVo attrVo = new SearchResult.AttrVo();
        //1、得到属性的id
        long attrId = bucket.getKeyAsNumber().longValue();
        attrVo.setAttrId(attrId);
        //2、得到属性的名字
        ParsedStringTerms attrNameAgg = bucket.getAggregations().get("attr_name_agg");
        String attrName = attrNameAgg.getBuckets().get(0).getKeyAsString();
        attrVo.setAttrName(attrName);
        //3、得到属性的所有值
        ParsedStringTerms attrValueAgg = bucket.getAggregations().get("attr_value_agg");
        List<String> attrValues = attrValueAgg.getBuckets().stream().map(MultiBucketsAggregation.Bucket::getKeyAsString).collect(Collectors.toList());
        attrVo.setAttrValue(attrValues);
        attrVos.add(attrVo);
    }
    result.setAttrs(attrVos);
    //3、当前商品涉及到的所有品牌信息
    List<SearchResult.BrandVo> brandVos = new ArrayList<>();
    //获取到品牌的聚合
    ParsedLongTerms brandAgg = response.getAggregations().get("brand_agg");
    for (Terms.Bucket bucket : brandAgg.getBuckets()) {
        SearchResult.BrandVo brandVo = new SearchResult.BrandVo();
        //1、得到品牌的id
        long brandId = bucket.getKeyAsNumber().longValue();
        brandVo.setBrandId(brandId);
        //2、得到品牌的名字
        ParsedStringTerms brandNameAgg = bucket.getAggregations().get("brand_name_agg");
        String brandName = brandNameAgg.getBuckets().get(0).getKeyAsString();
        brandVo.setBrandName(brandName);
        //3、得到品牌的图片
        ParsedStringTerms brandImgAgg = bucket.getAggregations().get("brand_img_agg");
        String brandImg = brandImgAgg.getBuckets().get(0).getKeyAsString();
        brandVo.setBrandImg(brandImg);
        brandVos.add(brandVo);
    }
    result.setBrands(brandVos);
    //4、当前商品涉及到的所有分类信息
    //获取到分类的聚合
    List<SearchResult.CatalogVo> catalogVos = new ArrayList<>();
    ParsedLongTerms catalogAgg = response.getAggregations().get("catalog_agg");
    for (Terms.Bucket bucket : catalogAgg.getBuckets()) {
        SearchResult.CatalogVo catalogVo = new SearchResult.CatalogVo();
        //得到分类id
        String keyAsString = bucket.getKeyAsString();
        catalogVo.setCatalogId(Long.parseLong(keyAsString));
        //得到分类名
        ParsedStringTerms catalogNameAgg = bucket.getAggregations().get("catalog_name_agg");
        String catalogName = catalogNameAgg.getBuckets().get(0).getKeyAsString();
        catalogVo.setCatalogName(catalogName);
        catalogVos.add(catalogVo);
    }
    result.setCatalogs(catalogVos);
    //===============以上可以从聚合信息中获取====================//
    //5、分页信息-页码
    result.setPageNum(param.getPageNum());
    //5、1分页信息、总记录数
    long total = hits.getTotalHits().value;
    result.setTotal(total);
    //5、2分页信息-总页码-计算
    int totalPages = (int) total % EsConstant.PRODUCT_PAGE_SIZE == 0 ?
            (int) total / EsConstant.PRODUCT_PAGE_SIZE : ((int) total / EsConstant.PRODUCT_PAGE_SIZE + 1);
    result.setTotalPages(totalPages);
    List<Integer> pageNavs = new ArrayList<>();
    for (int i = 1; i <= totalPages; i++) {
        pageNavs.add(i);
    }
    result.setPageNavs(pageNavs);
    //6、构建面包屑导航
    if (param.getAttrs() != null && param.getAttrs().size() > 0) {
        List<SearchResult.NavVo> collect = param.getAttrs().stream().map(attr -> {
            //1、分析每一个attrs传过来的参数值
            SearchResult.NavVo navVo = new SearchResult.NavVo();
            String[] s = attr.split("_");
            navVo.setNavValue(s[1]);
            R r = productFeignService.attrInfo(Long.parseLong(s[0]));
            if (r.getCode() == 0) {
                AttrResponseVo data = r.getData("attr", new TypeReference<AttrResponseVo>() {
                });
                navVo.setNavName(data.getAttrName());
            } else {
                navVo.setNavName(s[0]);
            }
            //2、取消了这个面包屑以后,我们要跳转到哪个地方,将请求的地址url里面的当前置空
            //拿到所有的查询条件,去掉当前
            String encode = null;
            try {
                encode = URLEncoder.encode(attr, "UTF-8");
                encode.replace("+", "%20");  //浏览器对空格的编码和Java不一样,差异化处理
            } catch (UnsupportedEncodingException e) {
                e.printStackTrace();
            }
            String replace = param.get_queryString().replace("&attrs=" + attr, "");
            navVo.setLink("http://search.gulimall.com/list.html?" + replace);
            return navVo;
        }).collect(Collectors.toList());
        result.setNavs(collect);
    }
    return result;
}
(8)前端页面渲染(前端的,跳过了)
(9)面包屑导航功能

三、异步与线程池

1.业务需求

        查询商品详情页的逻辑比较复杂,有些数据还需要远程调用,因此需要花费更多的时间。假设在查询商品详情页时,需要进行以下工作并花费指定时间:
        
        假如商品详情页的每个查询,需要按照标注的时间才能完成,那么用户需要 5.5s 后才能看到商品详情页的内容,很显然这是不能接受的。
        如果有多个线程同时完成这 6 步操作,也许只需要 1.5s 即可完成响应,看到详情页内容。

2.异步编排

        在JUC中我们学了Fork/Join框架,它可以将大任务划分成小任务,等都完成后再合并到一起。
        在 Java 8 中,新增加了一个类——CompletableFuture,它提供了 Future 的扩展功能,可以简化异步编程的复杂性,可以通过回调的方式处理计算结果,并且提供了转换和组合 CompletableFuture 的方法。
        CompletableFuture 和 FutureTask 同属于 Future 接口的实现类,Future最大的特点就是可以获取到异步执行的结果。

四、商品详情页功能

1.域名跳转环境搭建

        打开搜索页时域名是search.gulimall.com,点击某个商品跳转到商品详情页时的域名是item.gulimall.com。因此现在我们需要搭建item.gulimall.com的跳转环境。

(1)修改host文件

        

(2)配置nginx

        在配置search.gulimall.com时,我们直接用" *.gulimall.com "将所有以gulimall.com结尾的都经nginx转发到网关了。
        

(3)配置网关

- id: gulimall_host_route
  uri: lb://gulimall-product
  predicates:
    - Host=gulimall.com, item.gulimall.com

(4)将静态资源放到nginx

2.展示当前商品详情功能开发

(1)模型抽取

        

(2)业务逻辑

        

3.详情页渲染(前端)

4.★异步编排优化

        以后每个模块我们都给它创建一个线程池。

(1)创建线程池

  • 创建线程池配置类
    @EnableConfigurationProperties(ThreadPoolConfigProperties.class)
    @Configuration//配置类
    public class MyThreadConfig {
    
        @Bean
        public ThreadPoolExecutor threadPoolExecutor(ThreadPoolConfigProperties pool) {
            //返回自定义线程池
            return new ThreadPoolExecutor(
                    pool.getCoreSize(),//通过配置文件配置参数
                    pool.getMaxSize(),
                    pool.getKeepAliveTime(),
                    TimeUnit.SECONDS,
                    new LinkedBlockingDeque<>(100000),
                    Executors.defaultThreadFactory(),
                    new ThreadPoolExecutor.AbortPolicy()
            );
        }
    }
  • 创建线程池属性配置类
    @ConfigurationProperties(prefix = "gulimall.thread")
    // @Component
    @Data
    public class ThreadPoolConfigProperties {
        private Integer coreSize;
        private Integer maxSize;
        private Integer keepAliveTime;
    }
  • 在application.properties配置文件中配置线程池信息
    #配置线程池
    gulimall.thread.coreSize=20
    gulimall.thread.maxSize=200
    gulimall.thread.keepAliveTime=10

(2)业务逻辑优化

        前面那样把查询sku基本信息等5个业务串行化会导致用时过长,此时就可以用异步编排对业务逻辑进行优化,让5个任务异步进行。
@Override
public SkuItemVo item(Long skuId) throws ExecutionException, InterruptedException {
    SkuItemVo skuItemVo = new SkuItemVo();
    CompletableFuture<SkuInfoEntity> infoFuture = CompletableFuture.supplyAsync(() -> {
        //1、sku基本信息的获取  pms_sku_info
        SkuInfoEntity info = this.getById(skuId);
        skuItemVo.setInfo(info);
        return info;
    }, executor);
    //3、4、5都要依赖1执行完接收响应数据后才能执行,所以都用thenAcceptAsync()
    CompletableFuture<Void> saleAttrFuture = infoFuture.thenAcceptAsync((res) -> {
        //3、获取spu的销售属性组合
        List<SkuItemSaleAttrVo> saleAttrVos = skuSaleAttrValueService.getSaleAttrBySpuId(res.getSpuId());
        skuItemVo.setSaleAttr(saleAttrVos);
    }, executor);
    CompletableFuture<Void> descFuture = infoFuture.thenAcceptAsync((res) -> {
        //4、获取spu的介绍    pms_spu_info_desc
        SpuInfoDescEntity spuInfoDescEntity = spuInfoDescService.getById(res.getSpuId());
        skuItemVo.setDesc(spuInfoDescEntity);
    }, executor);
    CompletableFuture<Void> baseAttrFuture = infoFuture.thenAcceptAsync((res) -> {
        //5、获取spu的规格参数信息
        List<SpuItemAttrGroupVo> attrGroupVos = attrGroupService.getAttrGroupWithAttrsBySpuId(res.getSpuId(), res.getCatalogId());
        skuItemVo.setGroupAttrs(attrGroupVos);
    }, executor);
    //2与1无关,且无返回值,所以用runAsync()就行
    //2、sku的图片信息    pms_sku_images
    CompletableFuture<Void> imageFuture = CompletableFuture.runAsync(() -> {
        List<SkuImagesEntity> imagesEntities = skuImagesService.getImagesBySkuId(skuId);
        skuItemVo.setImages(imagesEntities);
    }, executor);
    //等待5个任务都完成,再返回vo
    CompletableFuture.allOf(saleAttrFuture, descFuture, baseAttrFuture, imageFuture).get();
    return skuItemVo;
}

五、登录认证服务

        登录不是简单查询一下输入的用户名和密码在不在数据库中就行了,项目一开始我们提到,所有的用户登录请求,都应该由OAuth认证中心来统一进行认证。

1.环境搭建

(1)创建认证模块

        
        

(2)模块初始化

  • pom文件
    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
        <parent>
            <artifactId>guli-mall</artifactId>
            <groupId>com.zsy</groupId>
            <version>0.0.1-SNAPSHOT</version>
        </parent>
        <artifactId>mall-auth-server</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <name>mall-auth-server</name>
        <description>认证服务(社交登录、Oauth2.0、单点登录)</description>
        <dependencies>
            <dependency>
                <groupId>com.zsy</groupId>
                <artifactId>mall-common</artifactId>
                <exclusions>
                    <exclusion>
                        <groupId>com.baomidou</groupId>
                        <artifactId>mybatis-plus-boot-starter</artifactId>
                    </exclusion>
                </exclusions>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-thymeleaf</artifactId>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-devtools</artifactId>
                <scope>runtime</scope>
                <optional>true</optional>
            </dependency>
        </dependencies>
    </project>
  • application.yml
    spring:
      application:
        name: gulimall-auth-server
      cloud:
        nacos:
          discovery:
            server-addr: 192.168.163.131:8848
      thymeleaf:
        cache: false
    server:
      port: 20000
  • 主启动类
    @EnableFeignClients
    @EnableDiscoveryClient
    @SpringBootApplication
    public class MallAuthServerApplication {
        public static void main(String[] args) {
            SpringApplication.run(MallAuthServerApplication.class, args);
        }
    }

(3)域名访问环境搭建

  • 修改host文件
    # guli mall #
    192.168.163.131		gulimall.com
    192.168.163.131		search.gulimall.com
    192.168.163.131		item.gulimall.com
    192.168.163.131		auth.gulimall.com
  • 配置nginx转发域名(*.gulimall.com)
  • 配置网关转发域名
    - id: mall_auth_route
      uri: lb://gulimall-auth-server
      predicates:
        - Host=auth.gulimall.com

(4)导入登录页面

        将资料中的登录页面和注册页面放到 templates 下,静态文件选择 Nginx 动静分离配置。
         

2.短信验证码功能——第三方服务

        工具类HttpUtils:
spring:
  cloud:
    nacos:
      discovery:
        server-addr: 192.168.163.131:8848
    alicloud:
      sms:
        host: https://fesms.market.alicloudapi.com
        path: /sms/
        skin: 1
        sign: 175622
        appcode: 93b7e19861a24c519a7548b17dc16d75

(2)发送验证码组件

@ConfigurationProperties(prefix = "spring.cloud.alicloud.sms")
@Data
@Component
public class SmsComponent {
    private String host;
    private String path;
    private String skin;
    private String sign;
    private String appcode;
    public void sendCode(String phone, String code) {
        String method = "GET";
        Map<String, String> headers = new HashMap<>();
        // 最后在header中的格式(中间是英文空格)为 Authorization:APPCODE 93b7e19861a24c519a7548b17dc16d75
        headers.put("Authorization", "APPCODE " + appcode);
        Map<String, String> queries = new HashMap<String, String>();
        queries.put("code", code);
        queries.put("phone", phone);
        queries.put("skin", skin);
        queries.put("sign", sign);
        //JDK 1.8示例代码请在这里下载:  http://code.fegine.com/Tools.zip
        try {
            HttpResponse response = HttpUtils.doGet(host, path, method, headers, queries);
            //System.out.println(response.toString());如不输出json, 请打开这行代码,打印调试头部状态码。
            //状态码: 200 正常;400 URL无效;401 appCode错误; 403 次数用完; 500 API网管错误
            //获取response的body
            System.out.println(EntityUtils.toString(response.getEntity()));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

(3)发送短信接口

@Controller
@RequestMapping(value = "/sms")
public class SmsSendController {

    @Resource
    private SmsComponent smsComponent;

    /**
     * 提供给别的服务进行调用
     * @param phone
     * @param code
     * @return
     */
    @GetMapping(value = "/sendCode")
    public R sendCode(@RequestParam("phone") String phone, @RequestParam("code") String code) {
        //发送验证码
        smsComponent.sendCode(phone,code);
        return R.ok();
    }
}

(4)在认证服务模块中的登录controller编写发送验证码方法

@ResponseBody
@GetMapping(value = "/sms/sendCode")
public R sendCode(@RequestParam("phone") String phone) {
    //1、接口防刷
    String redisCode = stringRedisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone);
    if (!StringUtils.isEmpty(redisCode)) {
        //活动存入redis的时间,用当前时间减去存入redis的时间,判断用户手机号是否在60s内发送验证码
        long currentTime = Long.parseLong(redisCode.split("_")[1]);
        if (System.currentTimeMillis() - currentTime < 60000) {
            //60s内不能再发
            return R.error(BizCodeEnum.SMS_CODE_EXCEPTION.getCode(), BizCodeEnum.SMS_CODE_EXCEPTION.getMsg());
        }
    }
    //2、验证码的再次效验 redis.存key-phone,value-code
    int code = (int) ((Math.random() * 9 + 1) * 100000);
    String codeNum = String.valueOf(code);
    String redisStorage = codeNum + "_" + System.currentTimeMillis();
    //存入redis,防止同一个手机号在60秒内再次发送验证码
    stringRedisTemplate.opsForValue().set(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone,redisStorage, 10, TimeUnit.MINUTES);
    thirdPartFeignService.sendCode(phone, codeNum);

    return R.ok();
}

3.★数据加密存储——MD5与盐值

        存到数据库里的密码在数据库中肯定不能以明文展示,因此我们需要对密码进行加密存储。
        加密存储主要分为可逆加密不可逆加密。密码应该使用不可逆加密,即即使知道加密算法,也不能由密文推算出明文密码。

(1)MD5加密算法

        MD5是信息摘要算法,可以用来加密明文。
        1)MD5特点
  • 压缩性:任意长度的数据,算出的 MD5 值的长度都是固定的;
  • 容易计算:从原数据计算出 MD5 值很容易;
  • 抗修改性:对原数据进行任何改动,哪怕只修改 1 个字节,所得到的 MD5 值都有很大区别;
  • 强抗碰撞:想找到两个不同的数据,使它们具有相同的 MD5 值是非常困难的;
  • 不可逆加密
【tips】MD5虽然不可逆,但是网上有一种使用彩虹表,大量收集“所有信息”的MD5值,采用暴力破解的方式来对MD5进行解密,所以单纯的MD5也是不安全的。
        可以通过 DigestsUtils 工具类来使用MD5对明文进行加密:
String s = DigestUtils.md5Hex("123456");
        2)盐值加密
        前面提到纯MD5加密也是不安全的,所以我们一般使用MD5+盐值来对数据进行加密。
        盐值加密的原理是将明文拼上一段随机字符(盐值,默认是$1$+8位随机字符)后再用MD5加密。
        我们在存密码“123456”时,需要在数据库维护一个盐值字段,这样在进行登录验证时,将密码拼上这个盐值字段后用MD5加密,在与正确密码的密文进行比较验证。
        但是在数据库多维护一个盐值字段太麻烦了,所以Spring提供了一个基于盐值MD5加密的编码类——BCryptPasswordEncoder,可以直接实现密码加密与验证(分别对应两个方法):
  • encode():对明文加密,返回盐值加密后的密文;
  • matches(想验证的明文,正确密码的密文):对明文进行验证,返回boolean值。
    // Spring 盐值加密
    BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
    //加密
    String encode = bCryptPasswordEncoder.encode("123456");
    //★对同一明文得到的密文是不同的,防止暴力解密
    //$2a$10$GT0TjB5YK5Vx77Y.2N7hkuYZtYAjZjMlE6NWGE2Aar/7pk/Rmhf8S 
    //$2a$10$cR3lis5HQQsQSSh8/c3L3ujIILXkVYmlw28vLA39xz4mHDN/NBVUi
    //验证
    boolean matches = bCryptPasswordEncoder.matches("123456", "$2a$10$GT0TjB5YK5Vx77Y.2N7hkuYZtYAjZjMlE6NWGE2Aar/7pk/Rmhf8S");

4.社交登录

        社交登录:QQ、微博、github等网站的用户量非常大,别的网站为了简化自我网站的登陆与注册逻辑,可以使用这些账户直接登录,即社交登陆

(1)OAuth2.0协议

  • OAuth:是一个开放标准,允许用户授权第三方网站访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方网站或分享他们数据的所有内容。
  • OAuth2.0 :对于用户相关的 OpenAPI(例如获取用户信息,动态同步,照片,日志,分享等),为了保护用户数据的安全和隐私,第三方网站访问用户数据前都需要显式的向用户征求授权。
    以用QQ登录****为例,授权流程如下:
         

(2)实现weibo社交登录

(3)分布式Session

        我们知道,session的原理是当客户端第一次请求服务器时,服务器会为该客户端创建一个session,并为该客户端生成一个session_id,返回给客户端保存在cookie中。在这个过程中,session只在当前域名下起作用,跨域名就不能共享session数据了。
        所以在分布式中session会出现以下两个问题:
        

        1)不同微服务(跨域名)session数据不同步的问题

                session只能在当前域名下起作用,跨域名就不能共享session数据了。
【解决方案】
  • 扩大session作用域:在给客户端返回session_id时,通过指定域名setDomain()来扩大session的作用域。

        2)分布式集群下同一微服务session数据不同步问题

        虽然是同一微服务(域名相同),但是两次请求转发到了不同主机上的服务器,session数据(保存在服务器中)也会不同。
【解决方案】
  • ①session复制(同步):让同一微服务的多个服务器之间进行session数据同步。

  • ②客户端存储cookie:让客户端自己把信息保存到cookie中。

  • ③hash一致性利用负载均衡的机制,只要是同一个客户端访问的,就一直定位到同一台主机服务器,这样就实现了session数据的同步。

  • ★④统一存储session:之前session都是存在各自服务器中,所以会造成数据不同步,现在我们可以对session进行统一存储,将每个服务器的session都存到一个DB或Redis中。可以用SpringSession来实现。

(4)整合SpringSession

  • 引入依赖
    <dependency>
    	<groupId>org.springframework.session</groupId>
    	<artifactId>spring-session-data-redis</artifactId>
    </dependency>
  • 在application.yml中配置session的保存类型
    spring:
      session:
        store-type: redis  //我们用redis保存session
  • 在主启动类上加注解
    @EnableRedisHttpSession 

(5)扩大session作用域、修改序列化机制

        创建一个配置类:
@Configuration
public class GulimallSessionConfig {
    @Bean
    public CookieSerializer cookieSerializer() {
        DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
        //扩大作用域
        cookieSerializer.setDomainName("gulimall.com");
        cookieSerializer.setCookieName("GULISESSION");
        return cookieSerializer;
    }
    //用json进行序列化将缓存保存到redis
    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        return new GenericJackson2JsonRedisSerializer();
    }
}

(6)SpringSession 核心原理

        @EnableRedisHttpSession 导入了 RedisHttpSessionConfiguration 配置,主要做了以下这些事情:


  • 给容器中添加了一个组件 RedisOperationsSessionRepository,相当于一个Redis操作session的dao,封装了对session的增删改查操作;
  • 继承 SpringHttpSessionConfiguration 初始化了一个 SessionRepositoryFilter,这是一个 session 存储过滤器;每个请求过来都必须经过 Filter 组件;创建的时候,自动从容器中获取到了 SessionRepository;
  • SessionRepositoryFilter:
    • 将原生的 HttpServletRequest/Response 包装成 SessionRepositoryRequestWrapper/ResponseWrapper,包装后的对象应用到了后面整个执行链;
    • 之前获取session需要通过HttpServletRequest的对象request.getSession(),现在包装后会调用 SessionRepositoryRequestWrapper的对象wrappedRequesr.getSession(),从SessionRepository中获取session。
      protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
          request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
          SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryFilter.SessionRepositoryRequestWrapper(request, response);
          SessionRepositoryFilter.SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryFilter.SessionRepositoryResponseWrapper(wrappedRequest, response);
          try {
              filterChain.doFilter(wrappedRequest, wrappedResponse);
          } finally {
              wrappedRequest.commitSession();
          }
      }


  • 装饰者模式

5.单点登录SSO

        单点登录(Single Sign On):在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统
        
        单点登录就是单独起一个认证服务器,其他服务要登录先请求认证服务,认证服务判断自己域名下是否有cookie保存了登录信息,如果有直接返回;如果没有就登录并保存cookie重定向到申请地址。

六、购物车功能

1.环境搭建

(1)创建认证模块

(2)模块初始化

  • pom文件
  • application.yml
  • 主启动类

(3)域名访问环境搭建

  • 修改host文件
  • 配置nginx转发域名(*.gulimall.com)
  • 配置网关转发域名

(4)导入购物车页面

2.数据模型

(1)购物车需求分析

  • 离线购物车(游客购物车):未登录时加入购物车的商品,在关闭浏览器后,下次再次打开购物车,里面的商品应该是还在的。也就是说即使不登录,也能保存加入到购物车里的商品。
  • 登录购物车(在线购物车):用户在登录状态下加入购物车的商品。
        上面两种购物车应该是能进行交互的:
  • 在同一个浏览器下,离线购物车中的商品,在登录后要能合并到登录购物车,同时清空离线购物车中的商品。
        那么购物车数据应该保存在哪里呢?
        ——有以下几种方案:
  • 对于登录购物车:
    • 用数据库存:购物车属于读写都频繁的操作,所以放在数据库压力太大了,不推荐。
    • 用redis存:redis可以解决读写高并发的问题,但是redis默认是非持久化的(基于内存),所以还要解决redis持久化问题
  • 对于离线购物车:
    • 用cookie存:cookie是存在客户端中,没有将用户的购物车信息存到我们的后台服务器中,不利于大数据分析,推荐相关的喜好商品;
    • 用redis存:同上。

(2)购物车数据分析

        
        购物车中的数据是由我们添加的一件件商品(sku)组成的,每一件商品应该包括以下信息(左图),而购物车里有很多件商品,所以购物车里的数据结构应该如右图所示。
             
        我们知道redis保存数据是以key-value的形式,key可以用 用户id或者账户,而value有5种数据类型,那么该选哪种呢?
  • 用 list 存:缺点是如果需要对购物车里的某个商品进行修改,如修改数量,就需要遍历list,然后找到对应商品,再修改(类似链表不适合检索的道理)。
  • 用 hash 存:hash的key可以用skuId,value再对应该sku的其他信息,这样方便修改。

        所以说为了方便存到redis中,购物车数据结构应该是一个Map
        

(3)编写VO

  • 购物车vo——Cart

  • 购物车里的购物项vo——CartItem

3.购物车功能开发

(1)进入购物车功能开发

        上面提到有两种购物车,所以进入购物车之前要先判断是否登录。可以用拦截器Interceptor来实现在执行目标方法前,先判断是否登录,并封装用户信息(登录了封装用户id,没登录给个user-key)
        前面我们在做社交登录时,用SpringSession解决了分布式下的session数据共享问题,并将作用域扩大到了整个“gulimall.com”,所以在购物车模块也可以通过session来判断是否登录
【tips】user-key:用户第一次用自己的浏览器(未登录)打开商城时,我们要给用户浏览器的cookie保存一个user-key,来标识这个用户(游客)。以后只要是这个浏览器访问我们的商城,都会带一个user-key,即使登陆了也会带。
/**
    在执行目标方法之前,判断用户的登录状态.并封装传递给controller目标请求
 **/
public class CartInterceptor implements HandlerInterceptor {
    public static ThreadLocal<UserInfoTo> toThreadLocal = new ThreadLocal<>();

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        UserInfoTo userInfoTo = new UserInfoTo();
        HttpSession session = request.getSession();
        //获得当前登录用户的信息
        MemberResponseVo memberResponseVo = (MemberResponseVo) session.getAttribute(LOGIN_USER);
        if (memberResponseVo != null) {
            //用户登录了
            userInfoTo.setUserId(memberResponseVo.getId());
        }
        Cookie[] cookies = request.getCookies();
        if (cookies != null && cookies.length > 0) {
            for (Cookie cookie : cookies) {
                //user-key
                String name = cookie.getName();
                if (name.equals(TEMP_USER_COOKIE_NAME)) {
                    userInfoTo.setUserKey(cookie.getValue());
                    //标记为已是临时用户
                    userInfoTo.setTempUser(true);
                }
            }
        }
        //如果没有临时用户一定分配一个临时用户
        if (StringUtils.isEmpty(userInfoTo.getUserKey())) {
            String uuid = UUID.randomUUID().toString();
            userInfoTo.setUserKey(uuid);
        }
        //目标方法执行之前
        toThreadLocal.set(userInfoTo);
        return true;
    }
    /**
     * 业务执行之后,分配临时用户来浏览器保存
     */
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        //获取当前用户的值
        UserInfoTo userInfoTo = toThreadLocal.get();
        //如果没有临时用户一定保存一个临时用户
        if (!userInfoTo.getTempUser()) {
            //创建一个cookie
            Cookie cookie = new Cookie(TEMP_USER_COOKIE_NAME, userInfoTo.getUserKey());
            //扩大作用域
            cookie.setDomain("gulimall.com");
            //设置过期时间
            cookie.setMaxAge(TEMP_USER_COOKIE_TIMEOUT);
            response.addCookie(cookie);
        }
    }
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    }
}

(2)★ThreadLocal 本地线程

        ThreadLocal可以用来实现同一线程共享数据
        
        每一个请求进来后,Tomcat会开一个线程来进行处理,从拦截器→controller→service→dao,一直到请求结束给浏览器响应,从始至终都是同一个线程。这样在拦截器中保存的数据想在controller中取出来用,就不用传给controller了,直接通过ThreadLocal获取即可。
        ThreadLocal相当于一个Map<Thread,Object>,key就是thread id,value就可以保存该线程的数据。
        

(3)添加购物车功能开发

  • 解决商品重复提交问题
    使用重定向
  • 获取和合并购物车
    • 获取临时购物车:直接根据临时用户id从redis中获取临时购物车中的所有购物项即可;
    • 获取登陆购物车:先判断临时购物车中有没有购物项,如果有,要先将临时购物车中的所有购物项加到登陆购物车中(合并购物车),并清空临时购物车。然后再获取登录购物车的所有购物项。

七、★订单功能

1.环境搭建

(1)页面环境搭建
(2)整合SpringSession——用来同步登陆状态

2.订单服务基本概念及重难点

        电商系统涉及到 3 流,分别是信息流、资金流、物流
  • 信息流:用户信息、商品信息等;
  • 资金流:付款、退款等金钱操作;
  • 物流:商品的发货退货等物流状态。
       订单系统作为中枢将三者有机的集合起来。订单模块是电商系统的枢纽,订单模块需要获取多个模块的数据和信息,同时对这些信息进行加工处理后流向下个环节,这一系列就构成了订单的信息流通。

(1)一个订单的完整组成

        

(2)订单状态

  • 待付款
    用户提交订单后,订单进行预下单,目前主流电商网站都会唤起支付,便于用户快速完成支付,需要注意的是待付款状态下可以对库存进行锁定,锁定库存需要配置支付超时时间,超时后将自动取消订单,订单变更关闭状态
  • 已付款/ 待发货
    用户完成订单支付,订单系统需要记录支付时间,支付流水单号便于对账,订单下放到 WMS系统,仓库进行调拨,配货,分拣,出库等操作。
  • 待收货/ 已发货
    仓储将商品出库后,订单进入物流环节,订单系统需要同步物流信息,便于用户实时知悉物品物流状态
  • 已完成
    用户确认收货后,订单交易完成。后续支付侧进行结算,如果订单存在问题进入售后状态
  • 已取消
    付款之前取消订单。包括超时未付款或用户商户取消订单都会产生这种订单状态。
  • 售后中
    用户在付款后申请退款,或商家发货后用户申请退换货。售后也同样存在各种状态,当发起售后申请后生成售后订单,售后订单状态为待审核,等待商家审核,商家审核通过后订单状态变更为待退货,等待用户将商品寄回,商家收货后订单状态更新为待退款状态,退款到用户原账户后订单状态更新为售后成功。

(3)订单流程

        
  •  远程调用会员服务
    • 根据会员id查询地址    √

  • 远程调用购物车
    • 获取当前用户的所有选中购物项:current-user-cart-items
    • 需要获取每个购物项的最新价格

  • 查询用户积分
  • 计算应付、总付
  • 防重令牌 

3.★订单登录拦截

        操作整个订单服务的前提,一定要保证用户是登录状态的,所以要拦截未登录的请求。
        使用拦截器Interceptor实现订单登录拦截。拦截器的使用——https://blog.nowcoder.net/n/59a97232aa7b4eb7b8d5c9971d54a6bb

(1)创建一个拦截器

        

(2)配置拦截内容——拦截器配置类

        

(3)实现登录拦截

        之前我们整合了SpringSession,实现了多个微服务间共享登录状态。因此我们可以通过session获取用户的登录状态。
        

4.订单确认页(结算页面)功能开发

(1)订单确认页模型抽取

        

(2)数据获取

  • 远程调用member服务查询用户收货地址
  • 远程查询购物车所有选中的购物项信息

【★Feign远程调用丢失请求头问题】

  • 直接访问购物车时,会在请求头的cookie中携带JSESSIONID,用来标注用户登录的session

  • Feign远程调用购物车服务时,在请求头中没有JSESSIONID,购物车就会认为用户没登录
    • 原因:从Feign的源码入手,Feign的远程调用会先调用一些拦截器,创建一个新的请求模板,由于新模板中没有请求头信息,所以Feign用新请求访问购物车时就会导致购物车误以为用户没登录。
    • 解决:Feign在远程调用前要构造一个新请求,会调用一些拦截器,经过每个拦截器的apply(),之所以丢失请求头是因为之前没有拦截器,所以要携带请求头只需要加上请求拦截器(RequestInterceptor),重写apply()加上请求头信息

      如何加之前的请求头信息?
      -- 可以使用ThreadLocal来获取之前的请求头信息;
      -- 也可以使用RequestContextHolder(上下文保持器)来获取之前的请求头信息,原理还是通过ThreadLocal。

【★★异步下Feign远程调用丢失请求头问题】

  • 上面我们分别远程调用member服务和购物车服务获取数据时,是串行化的同步调用(如下图),为了提高性能我们可以将这两个远程调用改成异步执行。但是新的问题又来了,我们发现报了一个空指针异常,这个空指针是请求拦截器(RequestInterceptor)为空了,我们之前已经加上请求拦截器了,为什么还会为空呢?

  • 原因:我们在请求拦截器RequestInterceptor中通过RequestContextHolder(上下文保持器)来获取老请求的请求头数据,之前同步调用时,从请求进来到拿到数据返回结果一直是同一个线程(72号线程),所以通过上下文保持器可以拿到老请求的数据;但是在将两次远程调用改为异步远程调用后,是由两个新线程(101、102号)来完成远程调用过程,会根据新线程的ThreadLocal来获取上下文中的数据,所以就获取不到老请求的数据了。
            
  • 解决:在异步调用之前,先将主线程中的原始请求的上下文数据共享出来,再在新开启的异步任务中重新设置上下文数据,即可:
            

5.★订单防重复提交——接口幂等性讨论

(1)接口幂等性

        接口幂等性就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用,比如说支付场景,用户购买了商品支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额返发现多扣钱了,流水记录也变成了两条。这就没有保证接口的幂等性。
  • 需要保证幂等性的常见情况:
    • 用户多次点击按钮
    • 用户页面回退再次提交
    • 微服务互相调用,由于网络问题,导致请求失败,feign就会触发重试机制,此时相当于进行了多次远程调用

(2)如何保证幂等性?

  • 令牌token机制
    • 原理
      可以在需要保证幂等性的操作执行前,先去获取token,并将token存在Redis中,然后在发送请求的时候带上这个token,服务器通过判断 token 是否在 redis 中有来判断是否是第一次请求,有就表示第一次请求,然后删除 token,继续执行业务;如果判断 redis 中没有token,就表示是重复操作,直接返回重复标记给 client,这样就保证了业务代码不会被重复执行。
    • token机制存在的问题
      • 删除token的时机
        先删除token再执行业务逻辑(推荐):先删除 token,如果业务调用失败,就重新获取 token 再次请求。
        ②先执行业务逻辑再删除token:如果执行该业务耗时比较长,在业务未完成之前又有重复请求进来,此时token还没删,所以还会认为是第一次请求,这样就无法保证幂等性了。
      • 原子性操作
        如果token的获取、判断是否存在、删除不能保证原子性,在高并发下可能会导致重复获取token,无法保证幂等性。因此必须要保证获取、判断、删除token这三个操作的原子性
        可以在 redis 使用 lua 脚本完成这个操作:
        if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
  • 各种锁机制
  • 各种唯一约束
    • 数据库唯一索引约束:比如每次提交新订单都要在订单表中插入一条新纪录,我们可以给订单号order_id加上唯一约束,这样就保证了同一订单不能被多次插入,从而保证了订单提交的幂等性。
    • Redis Set 防重:我们知道Redis中的set不允许重复元素,因此可以利用set进行防重。在处理数据之前先计算数据的 MD5 将其放入 redis 的 set,每次处理数据前,先看这个 MD5 是否已经存在,存在说明重复,就不处理。
  • 全局请求唯一id

6.★锁定库存问题

(1)锁库存的流程

        在提交订单时,我们需要“抢占库存”,让订单中的每个商品先去库存数据库占一个位置,只有所有商品都有库存,才能提交订单。
        

(2)本地事务(@Transactional)在分布式下的问题

        下单涉及到三个微服务——订单服务(下订单)、库存服务(锁定库存)、用户服务(扣减积分)。本地事务在分布式下可能出现以下问题:
  • 当订单服务远程调用库存服务进行锁库存时,锁库存成功了,但是在给订单服务返回结果时由于网络中断等故障没有返回成功,订单服务就会报异常,进行订单回滚,但库存没有回滚。这种属于假失败
  • 多个微服务其中一个失败了回滚只能对本服务回滚,无法对其他成功完成的微服务进行回滚。比如锁库存已经成功了,但是扣减积分时出现了异常需要回滚,现在只能对积分进行回滚,而库存不能回滚了。

(3)★使用SpringSeata解决分布式事务

  • 如果使用 Seata AT(Auto Transaction,自动事务)模式,需要为每个用到分布式事务的微服务创建一个 UNDO_LOG 表(回滚日志表);
  • 下载安装TC服务器——seata-server
  • 配置并启动TC服务器
  • 整合seata,导入依赖
  • 注入代理数据源,所有想要用到分布式事务的微服务中使用seata DataSourceProxy代理自己的数据源

  • 给分布式大事务的入口方法,添加全局事务@GlobalTransactional,每一个小事务(如远程调用的事务)添加@Transactional,即可实现分布式事务管理。

(4)★高并发下锁库存解决方案

        seata的AT模式使用了锁机制,因此不适合高并发情况。而下订单锁库存就是一个高并发场景,我们选择可靠消息+最终一致性来解决锁库存问题。
        为了保证高并发,订单这一块还是自己回滚,让库存服务实现自动解锁库存进行回滚
  • 使用mq实现库存服务自动解锁
            如果要解锁库存,首先需要给库存服务发一个消息(包含库存工作清单等,也就是要解锁哪些库存), 同时库存解锁服务去订阅stock.release.stock.queue里的消息。 比如用路由键stock.releasestock-event-exchange发送了一个消息,交换机会把这个消息路由给stock.release.stock.queue,stock.release.stock.queue里边存的都是库存要解锁的消息,库存解锁服务订阅了该队列,它就会在后台慢慢地根据消息进行库存解锁。这样虽然不是强一致性,但是我们也强求数据一致,哪怕是二十分钟、三十分钟,乃至于一天以后把这个库存解锁了,实现最终一致就行。
  • 需要库存解锁回滚的情况
    • 1)下订单成功,但订单没有支付被系统自动取消或被用户手动取消,都要解锁库存进行回滚
    • 2)下订单成功,库存锁定成功,但接下来的业务(如扣减积分)调用失败,导致订单回滚,之前锁定的库存就要自动解锁。

(5)定时关闭未支付订单

        如果我们订单提交了同时库存锁也成功了,但是迟迟没有支付,我们就不能一直占着这个订单和库存,所以就要使用延迟队列实现关闭长时间未支付的订单
  • 使用延迟队列实现定时关闭订单
            订单创建成功之后,使用order.create.order路由键将消息路由到order-event-exchange交换机,再由交换机路由到order.delay.queue里,如果过了30分钟还未支付订单,这个延时队列里面的消息就过期了(成为了死信),然后通过order.release.order又路由回order-event-exchange交换机,再由交换机路由到order.release.order.queue,最终监听order.release.order.queue的释放订单服务,发现有消息(未支付订单)进来了,就会对其进行关闭订单。

(6)订单服务完整的消息队列

        

(7)如何保证消息的可靠性?——消息确认+失败重试

        我们在利用mq实现自动关单和自动解锁功能时,都是通过发送消息来完成的,因此消息必须能顺利到达,保证消息的可靠性。造成消息丢失的情况有三种:
  • 1)由于网络问题,消息没有到达exchange;
  • 2)消息到达exchange了,但是还没交给队列,就宕机了;
  • 3)消费者从队列拿到消息还没消费就宕机了。
        我们在保证消息的可靠性时,以上三种情况都要考虑到:
  • 失败重试机制:无论以上哪种情况,只要消息发送失败,就要有重试机制。
            我们可以在发送消息前,把消息的详细信息记录到数据库,然后定期扫描数据库重发失败的消息
            
  • 生产者、消费者确认机制:通过确认机制,可以判断消息是否发送失败。
    • 生产者确认机制ConfirmCallback成功抵达exchange确认ReturnCallback是否抵达mq确认(没有抵达mq才回发ReturnCallback),所以可以在收到ReturnCallback时修改数据库中消息的发送状态。
    • 消费者确认机制:为了防止消费者收到消息后还未消费就宕机造成的消息丢失,我们可以开启手动ACK,消息消费成功才发送ACK。

(8)如何防止消息的重复发送?

        当消费者把消息成功消费了,但由于我们是手动ack,如果机器在没ack之前宕机了,消息就会由unack重新变为了ready,然后就会重新发送一遍消息,造成消息重复。
【解决方案】
  • 1)将消费者的消费接口设计成幂等的;
  • 2)在rabbitMQ中,每一个发送过来的消息都有一个redelivered字段,用来标记该消息是不是第一次发送过来的,true就代表不是第一次发送的,是重复的消息

(9)如何处理消息积压(堆积)?

        当生产者发送消息的速度超过了消费者处理消息的速度,就会导致队列中的消息堆积,直到队列存储消息达到上限。最早接收到的消息,可能就会成为死信,会被丢弃,这就是消息堆积问题。
        可以使用惰性队列解决消息积压问题。——https://blog.nowcoder.net/n/a914cdf308104ba9a8981a4c024e6299

八、订单支付功能

        使用支付宝API实现订单支付功能。

1.内网穿透

        现在别人用电脑访问我们的gulimall.com是访问不到的,即使告诉别人我们电脑的ip地址也不行。
        按照正规的流程,应该是把我们这个项目写好以后,我们自己买一个服务器。这个服务器必须有一个东西叫 公网IP,公网IP 呢相当于我们分配到全世界的人都能访问的一个IP, 然后给这个IP 再来绑定一个域名,域名相当于它的别名一样,假设域名gulimall.com,以后呢只要别人访问gulimall.com,公网上的域名解析器,就知道这个域名对应的是我们的 IP 地址,就能让请求跳转过来,用户就能访问到我们的网站 域名跟电脑绑定,这是正规流程,然后把网站放上去,最终还要去来进行我们整个网站的备案。
        但是开发测试期间我们可以利用内网穿透,来实现别人访问我们机器上的项目。
        内网穿透的几个常用软件:



2.项目整合支付宝

3.内网穿透联调

        

4.收单

        

九、★★秒杀服务——瞬时高并发

1. 秒杀功能简介

        商品秒杀具有瞬间高并发的特点,因此必须要做限流 + 异步 + 缓存(页面静态化)+独立部署秒杀服务
        为什么要把秒杀单独作为一个微服务?
        ——如果将秒杀与其他服务放在一个模块,一旦秒杀时间到了,峰值流量就会到来,会使其他服务无法正常工作。

2.定时任务

(1)定时任务Quartz框架——Cron 表达式

        执行定时任务需要给一个时间计划,这个时间计划可以用 Cron 表达式来编写。Cron 表达式是一个字符串,是用空格分割的六到七个属性,分别代表"秒 分 时 日(几号) 周(周几) 月 年",也就是说定时任务只能精确到秒,而且其中的年可以忽,而且spring中也不支持年。
        
  • 特殊字符

    ,:枚举

    • (cron="7,9,23 * * * * ?"):代表任意时刻的第7,9,23秒启动这个任务;

    -:范围

    • (cron="7-20 * * * * ?"):任意时刻的 7 到 20 秒之间,每秒启动一次

    *:任意

    • 指定位置的任意时刻都可以;

    /:步长,每...一次

    • (cron="7/5 * * * * ?"):第 7 秒启动,每 5 秒一次
    • (cron="*/5 * * * * ?"):任意时间启动之后,每 5 秒一次;

    ?:(出现在日和周几的位置)为了防止日和周冲突,如果1个精确了,另一个就得写?

    • (cron="* * * 1 * ?"):每月的 1 号,启动这个任务,如果两个都写精确值的话,可能会导致冲突,所以其中一个要使用?

    L:(出现在日和周的位置)”,last:最后一个

    • (cron="* * * ? * 3L"):每月的最后一个周二

    W:  写在日的位置,代表工作日(Work Day)

    • (cron="* * * W * ?"):每个月的工作日触发
    • (cron="* * * LW * ?"):每个月的最后一个工作日触发

    #: 第几个

    • (cron="* * * ? * 5#2"):5 代表周四,#2 代表第 2 个,合起来就是每个月的第 2 个周四 
  • 示例

(2)Spring Boot整合定时任务

        
【注意】
        1)定时任务默认是阻塞的,比如我们想每秒打印一次hello,但是如果这一秒打印hello阻塞了(用Thread.sleep()模拟阻塞),那么下一秒想要打印hello就必须等待,也就实现不了每秒打印一次的效果了。
        
        2)解决定时任务阻塞的方法
  • ①可以利用异步编排——CompletableFuture.runAsync(),自己提交到线程池,让任务异步执行;
  • ②直接让任务异步执行,可以替代CompletableFuture:
    • 首先在类上面标注@EnableAsync,开启异步任务功能
    • 然后在方法上标注@Async,执行异步任务
    • 自动配置类参考 TaskExecutionAutoConfiguration
    • 它在配置文件中的线程池属性是:spring.task.execution.pool.xxx

(3)定时上架要秒杀的商品

        我们可以定时每天凌晨3点上架最近3天所需要秒杀的商品,因为这个时间段服务器压力较小,并且比较空闲;同时上架最近3天的商品,可以给用户一个预告 ,让用户提前知道哪个商品什么时间将要开启秒杀。
        要秒杀的商品我们需要放到Redis中,因为秒杀流量太大了,怕压垮数据库。
        
  • 可以使用LocalDate和LocalTime构造时间。
  • 缓存活动信息(每一场活动对应哪些商品)、缓存商品信息(就不用每次都从数据库中查了)
  • 随机码:用UUID实现。是一种保护机制为了防止有用户在得知秒杀请求时,发送大量请求对商品进行秒杀,我们采取了随机码的方式,即每个要参加秒杀的商品,都有一个随机码,只有通过正常提交请求的流程才可以获取避免了恶意秒杀。

(4)使用分布式信号量应对高并发流量——限流

          我们可以在Redis缓存中每个参与秒杀的商品保存一个信号量,初始值等于该商品的秒杀库存。当有请求进来想要秒杀时,肯定是瞬时百万流量涌入,我们让这百万流量都去数据库实时地扣减库存,而是通过Redis中的信号量,如果信号量不为0,说明还能抢到,就给信号量减一,然后去数据库扣减库存;如果请求进来一看信号量为0了,这些请求就抢不到商品,也就没有必要去查数据库了。所以这里信号量起着限流的作用。
        同时我们在上面引入了随机码,一定要保证请求带上了正确的随机码,才能给它操作信号量的机会,不然就判定为恶意秒杀。
        

3.保证幂等性——已上架的商品不能重复上架

(1)定时任务在分布式下的问题

        
        在分布式系统中,定时任务会出现一个问题。比如我们这有三台机器:A1、A2、A3,它们代表我们同一个服务的三个副本,所以这三台机器每台都有一个相同的定时任务,所以等时间一到,它们都会启动定时任务,这就导致同一个秒杀商品被重复上架3遍。所以,我们要做的就是不应该让每个机器都执行这个定时任务,应该只让一台机器去执行。

(2)使用分布式锁解决问题

        
        我们可以加一个分布式锁,获取到锁的机器才能执行定时任务;获取不到锁的机器继续等待,如果之前获取到锁的机器执行失败,当前机器就能获取到锁,再次尝试上架;如果之前获取到锁的机器已经把定时任务执行完了,那其它机器也就不需要执行了。
        

4.查询并展示秒杀商品

5.★★★秒杀(高并发)系统设计

        秒杀业务属于高并发场景,因此秒杀的关键就是如何设计一个能满足高并发、大流量的服务
        秒杀高并发系统应该关注一下几个问题:
        

(1)服务单一职责、独立部署

        秒杀服务需要单独作为一个微服务,这样即使秒杀服务出问题,也不会影响其他服务。并且方便独立部署。

(2)链接加密

        可以对链接进行 MD5 加密,也可以使用随机码机制,就是在真正开始秒杀的时候,用户才会得知随机码,其它时间都不会知道。防止提前知道秒杀链接,使用其他手段进行恶意秒杀,比如知道了秒杀某个商品的链接,通过写一个脚本,实现1s进行1000次秒杀请求。

(3)库存预热+快速扣减

        如果还是让秒杀走正常的加入购物车流程,就需要先锁库存然后去支付,这样整个流程太慢了,在高并发系统里边肯定会出现整个级联崩溃的情况。
        我们应该先做到预热库存,比如要秒杀的商品数量有400件,我们给 redis 里面存一个 400 的信号量,想要秒杀的人进来之后,必须要先拿到信号量,这一块我们会对 redis 的信号量进行快速扣减,直接扣减1个数,所以无论有多少请求进来,即使有百万请求,最终也只有 400个人能拿到这个信号量的值。然后我们会将这 400 个人放行给我们后台的集群系统,这400个请求即使走正常的下单逻辑,系统也不会出现什么问题。
        当然只使用使用一台Redis进行预热可能扛不住百万级并发,所以可以做 redis 集群,让它能扛住百万的并发。

(4)资源的动静分离

        目前使用的是 nginx ,可以复制多个 nginx,组件 nginx 集群。
        上线以后更好的条件就是使用 CDN 来做压力承担。我们将现有的静态资源,全部分享给这个 CDN 网络,比如我们使用阿里云,我们让阿里云来保存这个静态资源,阿里云会将这些静态资源放进各个服务节点,比如有一个上海节点,还有北京节点,还有杭州节点。那接下来如果我们访问我们的静态资源,阿里云会就近选择一个最快的节点,给我们返回这个静态资源。 做好动静分离之后,放到后台的请求就很少了,以首页为例,5、60个请求,只有1个是动态请求,静态请求全过滤掉了,这样服务器的压力就小很多了。

(5)恶意请求拦截

        对于高并发系统来说,恶意请求有很多种。
  • 1)恶意脚本
    比如有的恶意脚本会向服务器每秒发1000次秒杀请求,但用户正常的流量访问,刷的再快每秒可能也就五六次,所以每秒1000次请求肯定是有别的脚本在模拟这个访问,我们就应该把这些恶意请求拦截下来;
  • 2)伪造的请求
    比如我们有很多请求需要带一些令牌,有一些请求不带令牌,直接发请求,我们也应该直接拦截下来。

        综上,只要经过网关以后,放给我们后台的整个请求,应该是一个具有正常行为的请求恶意请求的拦截,我们最好在网关层拦截,最终经过网关层拦截之后,放给后台集群就只有正常请求。

(6)流量错峰

        假设现在有100万个人来进行秒杀,同时点了立即抢购,那么瞬间流量就会达到一百万,此时我们就需要处理这些流量,比如可以将流量分散到之后的几秒。
  • 方案一:
    用户点完立即抢购之后,让其输入一个验证码,验证码有两个好处:
    • 第一个好处,这个验证码可以区分是机器还是人,人识别了验证码,然后输完了,点击提交,我们这个请求才是正常的,机器识别不了,所以它的这个请求就是非法的。
    • 第二个好处,输验证码的速度有快有慢,这样我们就相当于把百万瞬时流量分散开了。
  • 方案二:
    我们使用的购物车机制,用户选中了这个商品,点了加入购物车,再结账、锁库存,这都需要时间,大家的操作快慢都不一样。所以在这里又把流量给错开了,最终流量就会分摊到各个时间轴上。

(7)限流&熔断&降级

  • 限流:
    限流采用前端限流+后端限流。
    • 前端限流可以给按钮设置点击频率,比如点了第一次抢购之后,一秒以后才能点第二、第三次。通过前端的限流,可以限制一部分的流量。但是如果用恶意脚本去无限制的访问,前端也限不住。
    • 后端限流:用前端限流限制了一些请求,其他请求来到后台时,后台再来识别哪些请求是用户的正常行为,哪些是恶意行为,然后再进行过滤;包括即使是用户的正常行为,服务器也可以对其限流,比如1秒点了十几次,我们只放行一两次,所以通过一步一步的操作,每到达一层,无论是前端还是后端,我们都给它来做一个限流的操作,把不合理的过滤掉,哪怕请求是合理的,点的次数太多了,也将其限制起来,最终后台集群里边收到的流量就会少很多。
  • 熔断:
    加入熔断机制,只要调用链的任何一个服务出现问题,我们就给它返回快速失败。这样一失败以后,就能保证我们整个调用链是一个快速返回的,而不是阻塞的。 所以我们的熔断先保证快速失败,如果哪个服务出现问题,我们把它断路之后,相当于就把它隔离了,这样也不会影响别的服务。
  • 降级:
    如果我们自己的服务出现问题,我们也可以让它降级运行,比如流量太大了,秒杀服务快被压垮了,我们可以将一部分的流量直接引导到一个降级页面,告知用户当前服务太忙,请稍后再访问。

(8)队列削峰

        我们之前采用信号量扣减库存,接下来把拿到信号量的请求(即抢到商品的用户),放行给我们的后台,后台将这些请求发给一个队列,然后订单服务就来监听这个秒杀队列,针对队列里面的数据来创建订单等操作。由于放进队列里的用户都是抢到了商品的,所以创建订单这些后序操作即使花费5秒,甚至10秒都行,我们可以先告诉用户秒杀成功了,过一会再去支付这个订单即可。
        其实对于秒杀单品的场景引入秒杀队列意义不大,因为比如有一个商品,它有一百件可以秒杀,也就是说只能放进来一百个请求,那么这一百个请求即使走正常流程都没问题。
        但如果是淘宝的双十一,所有商品都降价了大家都在抢,这个时候队列的作用就特别明显,所有请求一进来,只要用户能抢到,就把这个请求放到队列里边,整个后台的订单集群就来监听这个队列。所以引入队列就是让抢到商品的用户可能会出现订单延迟,但是最终都可以支付成功

6.目前已有的功能

  • 服务单一职责+独立部署
    • 我们单独创建了秒杀服务。
  • 秒杀链接加密
    • 我们采用随机码的机制,只有在真正秒杀的时候,用户发送秒杀请求,才可以得到随机码。
  • 库存预热+快速扣减
    • 我们采用了 redis 信号量的方式,来存储秒杀商品的库存,并且我们可以对信号量进行快速扣减,我们使用分布式锁 redisson 进行原子减量,最终能放过去的请求数,就是它的秒杀量。
  • 动静分离
    • 我们一开始就使用 nginx 进行了动静分离
  • 恶意请求拦截
    • waiting...
  • 流量错峰
    • 我们使用的是添加购物车,然后去购物车创建订单,然后再支付的方式,所以这个我们也做到了。
  • 限流&熔断&降级
    • waiting...
  • 流量削峰
    • waiting...

7.登录检查功能——使用拦截器实现登录检查

        

8.★秒杀流程及功能实现

(1)秒杀流程简介

        

        用户点击“立即抢购”之后,秒杀请求发送给秒杀系统,后台会先做登录检查,用户必须登录了才能抢购;然后是合法性校验,包括是否到了秒杀时间、随机码与 skuId 是否正确、用户是否已经秒杀过了;如果这些判断都通过了,再来获取信号量,如果能获取到信号量,就是秒杀成功,否则就是秒杀失败。获取到信号量之后,不走以前的下单流程,而是在秒杀服务里生成一个订单号,再把订单号、用户秒杀信息等,直接发给 MQ,后台的订单服务会一直监听这个mq,监听到消息后,会创建这个秒杀订单,此时就可以通知用户“秒杀成功正在为您准备订单”,订单创建好之后,跳转到订单支付页等待用户支付。
  • 优点
            从秒杀请求进来,只需要进行一系列判断处理,一直到后面的给前端用户通知,这期间没有操作过一次数据库(因为我们通过定时器提前将要秒杀的商品缓存到了Redis),没做过任何一次远程调用,这是一个非常快的流程,我们只需要校验好所有的合法性就行。因为所有的数据我们都在缓存里边放着。一切正常以后,我们给秒杀订单快速的创建一个单号。然后告诉前端个单号已经准备好了,后台的订单服务,再慢慢的消费。
  • 缺点
            秒杀系统将消息放进mq中,如果说订单服务炸了,就没有消费者来消费这个订单,会导致订单的后续信息准备不好,用户一直支付不成功,所以这一块的后续处理,跟前面的整套业务又不一样,需要加入一些独立的业务处理能力。

(2)登录检查(上面已经实现了)

(3)合法性验证

@Override
public String kill(String killId, String randomCode, Integer num) {
    MemberTO memberTO = LoginUserInterceptor.threadLocal.get();
    // 获取当前killid的sku信息
    BoundHashOperations<String, String, String> skusOperations = stringRedisTemplate
            .boundHashOps(SeckillConstant.SECKILL_SKU_PREFIX);
    String jsonStr = skusOperations.get(killId);
    if (StringUtils.isNotEmpty(jsonStr)) {
        SeckillSkuRedisDTO seckillSkuRedisDTO = JSON.parseObject(jsonStr, SeckillSkuRedisDTO.class);
        // 校验时间
        long now = new Date().getTime();
        Long startTime = seckillSkuRedisDTO.getStartTime();
        Long endTime = seckillSkuRedisDTO.getEndTime();
        long ttl = endTime - now;
        if (now >= startTime || now <= endTime) {
            // 校验随机码
            if (seckillSkuRedisDTO.getRandomCode().equals(randomCode)) {
                // 验证购买数量是否合理
                if (num <= seckillSkuRedisDTO.getSeckillSkuRelationDTO().getSeckillLimit()) {
                    // 校验用户是否已经购买过,只要秒杀成功就去redis占位absent,需要自动过期,
                    // userId_promotionSessionId_skuId,end-now,milliseconds
                    String key = memberTO.getId() + "_" + killId;
                    Boolean absent = stringRedisTemplate.opsForValue().setIfAbsent(key, num.toString(), ttl,
                            TimeUnit.MILLISECONDS);
                    if (absent) {
                        // 占位成功,说明没买过,减信号量,tryAcquire (num,100,seconds)
                        RSemaphore semaphore = redissonClient.getSemaphore(SeckillConstant
                                .SECKILL_SKU_STOCK_SEMAPHORE + randomCode);
                        try {
                            boolean acquire = semaphore.tryAcquire(num, 100, TimeUnit.MILLISECONDS);
                            if (acquire) {
                                // 秒杀成功,快速下单
                                String orderSn = IdWorker.getTimeId();
                                return orderSn;
                            }
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        }
    }
    return null;
}

(4)秒杀后创建订单的流程

        
        














全部评论
🐂
点赞 回复 分享
发布于 2023-08-02 21:47 北京

相关推荐

我是小红是我:学校换成中南
点赞 评论 收藏
分享
无敌虾孝子:喜欢爸爸还是喜欢妈妈
点赞 评论 收藏
分享
不愿透露姓名的神秘牛友
11-27 10:46
点赞 评论 收藏
分享
评论
3
21
分享
牛客网
牛客企业服务