1.项目及启动说明

gulimall-search可以不看,后台管理用户名密码admin/admin,商城密码admin/123456

高并发总结:缓存,异步,队列

  • docker部署以下工具

工具

版本号

下载

Mysql

5.7

https://www.mysql.com

Redis

7.0

https://redis.io/download

RabbitMQ

3.8.5

http://www.rabbitmq.com/download.html

Nginx

1.1.6

http://nginx.org/en/download.html

  • 修改本机的host文件,映射域名端口
192.168.77.130	gulimall.com
192.168.77.130	search.gulimall.com
192.168.77.130  item.gulimall.com
192.168.77.130  auth.gulimall.com
192.168.77.130  cart.gulimall.com
192.168.77.130  order.gulimall.com
192.168.77.130  member.gulimall.com
192.168.77.130  seckill.gulimall.com
以上端口换成自己Linux的ip地址
  • 修改Linux中Nginx的配置文件
1、在nginx.conf中添加负载均衡的配置    
upstream gulimall {
        server 192.168.43.182:88;
    }
2、在gulimall.conf中添加如下配置
server {
    listen       80;
    server_name  gulimall.com  *.gulimall.com;

    #charset koi8-r;
    #access_log  /var/log/nginx/log/host.access.log  main;

    #配置静态资源的动态分离
    location /static/ {
        root   /usr/share/nginx/html;
    }

    #支付异步回调的一个配置
    location /payed/ {
        proxy_set_header Host order.gulimall.com;        #不让请求头丢失
        proxy_pass http://gulimall;
    }

    location / {
        #root   /usr/share/nginx/html;
        #index  index.html index.htm;
        proxy_set_header Host $host;        #不让请求头丢失
        proxy_pass http://gulimall;
    }

2.分布式架构

SpringCloud - Gateway:API 网关

SpringCloud - Feign:声明式HTTP 客户端(调用远程服务)

SpringCloud - Sleuth:调用链监控

SpringCloud Alibaba - Nacos:注册中心(服务发现/注册)/ 配置中心(动态配置管理)

SpringCloud Alibaba - Sentinel:服务容错(限流、降级、熔断)

SpringCloud Alibaba - Seata:原Fescar,即分布式事务解决方案

外网部署前端项目,请求经过nginx反向代理到spirngcloud-gateway,网关根据请求路径转发到不同服务

SpringCloud Alibaba-Nacos作为服务注册中心,并进行动态配置管理

服务之间互相调用使用spirngcloud-feign,登录系统使用基于OAuth2.0的认证中心

缓存使用Redis集群(分片+哨兵集群),数据库使用MySQL,读写分离和分库分表

服务和服务之间使用RabbitMQ,来完成订单关闭,库存解锁,保证分布式事务的一致性

对象存储使用阿里云的对象存储服务OSS

常见的负载均衡算法:

**轮询:**为第一个请求选择健康池中的第一个后端服务器,然后按顺序往后依次选择,直到最后一个,然后循环。

**最小连接:**优先选择连接数最少,也就是压力最小的后端服务器,在会话较长的情况下可以考虑采取这种方式。

**散列:**根据请求源的IP的散列(hash)来选择要转发的服务器。这种方式可以一定程度上保证特定用户能连接到相同的服务器。

3.Docker使用

以安装mysql为例

docker pull mysql:5.7 
# --name指定容器名字 -v目录挂载 -p指定端口映射  -e设置mysql参数 -d后台运行*
docker run -p 3306:3306 --name mysql \
-v /mydata/mysql/log:/var/log/mysql \
-v /mydata/mysql/data:/var/lib/mysql \
-v /mydata/mysql/conf:/etc/mysql \
-e MYSQL_ROOT_PASSWORD=root \
-d mysql:5.7

容器相关

docker ps #查看运行中的容器
docker exec -it mysql bin/bash #进入容器

4.SpringCloud组件使用

nacos注册中心

1.依赖

2.yml配置

spring:
  application:
    name: gulimall-coupon

  cloud:
    nacos:
      config:
        server-addr: 47.96.3.126:8848

3.启动类@EnableDiscoveryClient

4.启动nacos

nacos配置中心

1.依赖

2.创建bootstrap.properties,这个文件是springboot里规定的,优先级别比application.properties高

spring.application.name=服务名
spring.cloud.nacos.config.server-addr=127.0.0.1:8848
spring.cloud.nacos.config.namespace=命名空间的ID #切换配置环境
spring.cloud.nacos.config.group=组名 #切换配置组
#拆分配置
spring.cloud.nacos.config.extension-configs[0].data-id=datasource.yml
spring.cloud.nacos.config.extension-configs[0].group=dev
spring.cloud.nacos.config.extension-configs[0].refresh=true

spring.cloud.nacos.config.extension-configs[1].data-id=mybatis.yml
spring.cloud.nacos.config.extension-configs[1].group=dev
spring.cloud.nacos.config.extension-configs[1].refresh=true

spring.cloud.nacos.config.extension-configs[2].data-id=other.yml
spring.cloud.nacos.config.extension-configs[2].group=dev
spring.cloud.nacos.config.extension-configs[2].refresh=true

3.启动nacos,创建服务名-开发环境名.properties,添加任何配置

4.获取配置使用@RefreshScope和@Value,如果配置内容相同,优先使用配置中心

最终方案:每个微服务创建自己的命名空间,然后使用配置分组区分环境(dev/test/prod)

feign远程调用

1.依赖

2.创建feign接口,声明要调用其他服务中的哪些服务(直接复制方法定义),加注解@FeignClient(“服务名”)

@FeignClient("gulimall-coupon")
public interface CouponFeignService {

    @RequestMapping("/coupon/coupon/member/list")
    public R membercoupons();

}

3.启动类上加注解@EnableFeignClients(“feign接口全类名”)

@EnableFeignClients(basePackages="com.atguigu.gulimall.member.feign")

gateway网关

spring:
	gateway:
      routes:
        - id: test_root
          uri: https://www.baidu.com
          predicates:
            - Query=url,baidu #有url=baidu这个参数跳转
        - id: third_party_route
          uri: lb://gulimall-third-party
          predicates:
            - Path=/api/thirdparty/**
          filters:
            - RewritePath=/api/thirdparty/(?<segment>/?.*),/$\{segment} #把/api/thirdparty去掉
        - id: admin_route
          uri: lb://renren-fast
          predicates:
            - Path=/api/**
          filters:
            - RewritePath=/api/(?<segment>/?.*), /renren-fast/$\{segment}
            #验证码网关默认转发http://localhost:88/api/captcha.jpg->http://localhost:8080/api/captcha.jpg
            #需要路径重写为http://localhost:88/api/captcha.jpg->http://localhost:8080/renren-fast/captcha.jpg

5.后台管理开发

商品服务主要表结构

pms_category:商品三级分类表

分类id,分类名称,父分类id,菜单等级,是否显示,排序,图标地址,计量单位,商品数量

225 手机 34 3 。。。

pms_category_brand_relation:品牌分类关联表

主键id,品牌id,分类id,品牌名,分类名称

每个三级分类都可以关联多个品牌

pms_attr_group:属性分组表

分组id,组名,排序,描述,组图标,所属分类id

每个三级分类都可以关联多个属性,比如主芯片,主体等

pms_attr_attrgroup_relation:属性和属性分组关联表

id,属性id,属性分组id,属性组内排序

即一个属性分组可以有多个属性,如主芯片可以关联芯片上市时间,芯片型号

pms_attr:商品属性表

属性id,属性名,是否需要检索,可否多个值,属性图标,可选值列表,属性类型(销售属性,基本属性),启用状态,所属分类,是否展示在介绍上

芯片上市时间,芯片型号就是对应的这里一条记录

pms_product_attr_value:SPU规格参数表

id,spuId,基本属性id,基本属性名,基本属性值

一条数据对应一个spu的一个属性,一个spu可以有多个属性,通过属性id和属性表关联

spu商品聚合信息的最小单位,比如iphone14就是一个spu,属性都一样,比如重量240g,上市年份2022年

pms_spu_info:SPU信息表

spuId,spu名称,spu描述,所属分类id,品牌id,上架状态

pms_spu_images:SPU图片集表

id,spuId,图片名称,图片地址,顺序,是否默认

一条记录对应一张图片

pms_sku_sale_attr_value:SKU销售属性表

id,skuId,销售属性Id,销售属性名,销售属性值

sku商品不可再分的最小单位,比如iphone14 256G 紫色,通过属性id和属性表关联

pms_sku_info:SKU信息表

skuId,spuId,sku名称,sku描述,所属分类id,品牌id,标题,价格,销量

pms_sku_images:SKU图片集表

id,skuId,图片名称,图片地址,顺序,是否默认

一条记录对应一张图片

库存服务主要表结构

wms_purchase_detail:采购需求表

采购单id,采购商品id,采购数量,采购金额,仓库id,状态[0]

状态:新建,已分配,正在采购,已完成,采购失败

wms_purchase:采购单表

采购单id,采购人id,采购人名,联系方式,优先级,状态,仓库id,总金额

状态:新建,已分配,已领取,已完成,采购失败

wms_ware_info:仓库信息

id,仓库名,仓库地址,区域编码,仓库信息

后管功能模块开发

目前请求都是通过前端发送到网关,网关进行转发,没有nginx

分类维护,品牌管理,商品属性,商品维护属于商品系统菜单

仓库维护,库存工作单,商品库存,采购单维护属于库存系统菜单

每日秒杀属于优惠营销菜单

分类维护菜单

1.分类菜单CRUD
product/category/list/tree 获取所有分类以及子分类,并返回json树形结构
product/category/delete

如果为第一级菜单,父分类id为0,菜单等级为1

三级分类实体类,复习组合模式

@Data
@TableName("pms_category")
public class CategoryEntity implements Serializable {
	private static final long serialVersionUID = 1L;
	/**
	 * 分类id
	 */
	@TableId
	private Long catId;
	/**
	 * 分类名称
	 */
	private String name;
	/**
	 * 父分类id
	 */
	private Long parentCid;
	/**
	 * 层级
	 */
	private Integer catLevel;
	/**
	 * 所有子分类
	 */
	@JsonInclude(JsonInclude.Include.NON_EMPTY)
	@TableField(exist = false)
	private List<CategoryEntity> children;

}

mybatis逻辑删除配置

mybatis-plus:
  global-config:
    db-config:
      #逻辑删除
      logic-delete-value: 1
      logic-not-delete-value: 0
@TableLogic(value = "1",delval = "0")
private Integer showStatus;

品牌管理菜单

1.品牌管理CRUD
product/categorybrandrelation/save

一个品牌可以和多个分类关联

JSR303数据校验

@RequestMapping("/save")
public R save(@Validated({AddGroup.class}) @RequestBody BrandEntity brand, //BindingResult result){
    // Map<String,String> map = new HashMap<>();
    //
    // if (result.hasErrors()) {
    //     //获取效验错误结果
    //     result.getFieldErrors().forEach((item)-> {
    //         //获取到错误提示
    //         String message = item.getDefaultMessage();
    //         //获取错误的属性的名字
    //         String field = item.getField();
    //         map.put(field,message);
    //     });
    //     return R.error(400,"提交的数据不合法").put("data",map);
    // } else {
    //
    // }
    brandService.save(brand);
    return R.ok();
}
// 配合实体类注解
@NotNull(message = "修改必须指定品牌id",groups = {UpdateGroup.class})
@Null(message = "新增不能指定id",groups = {AddGroup.class})
@TableId
private Long brandId;

商品属性菜单

1.属性分组CRUD
product/attrgroup/list/{catelogId}

一个分类有多个属性分组,每个属性分组可以关联多个属性

2.属性分组关联属性CRUD
/product/attrgroup/{attrgroupId}/attr/relation
3.规格参数CRUD
product/attr/{attrType}/list/{catelogId}

规格参数就是SPU基本属性,attrType = base

4.销售属性CRUD
product/attr/{attrType}/list/{catelogId}

销售属性就是SKU销售属性,attrType = sale

商品维护菜单

1.SPU管理CRUD
product/spuinfo/list 查pms_spu_info
product/attr/base/listforspu/{spuId} 查pms_product_attr_value

展示SPU列表,点进单个SPU可以获取/修改规格参数

2.发布商品
product/spuinfo/save

前端:输入SPU基本信息->输入SPU规格参数->输入SKU规格参数->排列组合生成所有SKU,输入信息->发布商品

1、保存spu基本信息:pms_spu_info

2、保存spu的图片集:pms_spu_images

3、保存spu的规格参数(基本属性):pms_product_attr_value

4、远程保存spu的积分信息:gulimall_sms–>sms_spu_bounds

5、保存当前spu对应的所有sku信息:pms_sku_info

sku的基本信息:pms_sku_info

sku的图片信息:pms_sku_images

sku的销售属性:pms_sku_sale_attr_value

sku的阶梯价格、满减、会员价格等信息:gulimall_sms–>sms_sku_ladder、sms_sku_full_reduction、sms_member_price

OOS对象存储

商品图片使用阿里云对象存储,前端上传文件地址要修改http://gulimail-xxh.oss-cn-hangzhou.aliyuncs.com

接口地址

/oss/policy

返回实例

{"accessid":"LTAI4G4W1RA4JXz2QhoDwHhi","policy":"eyJleHBpcmF0aW9uIjoiMjAyMC0wNC0yOVQwMjo1ODowNy41NzhaIiwiY29uZGl0aW9ucyI6W1siY29udGVudC1sZW5ndGgtcmFuZ2UiLDAsMTA0ODU3NjAwMF0sWyJzdGFydHMtd2l0aCIsIiRrZXkiLCIyMDIwLTA0LTI5LyJdXX0=","signature":"s42iRxtxGFmHyG40StM3d9vOfFk=","dir":"2020-04-29/","host":"https://gulimall-images.oss-cn-shanghai.aliyuncs.com","expire":"1588129087"}
  1. 用户发送上传Policy请求到应用服务器。
  2. 应用服务器返回上传Policy和签名给用户。
  3. 用户直接上传数据到OSS
3.SKU管理CRUD
product/skuinfo/list

展示SKU列表,可根据分类,品牌,价格,标题检索

仓库维护菜单

1.仓库信息CRUD
/ware/waresku/list

采购单维护菜单

1.采购需求CRUD
/ware/purchasedetail/list 查询采购需求列表
/ware/purchase/merge 合并采购需求

创建一个/多个采购需求后,可以单个/批量分配给某个采购单,分配后,这个采购需求状态就变成已分配

如果没有选择采购单,将创建新采购单进行合并

通过采购需求中的采购单id关联到某个采购单

2.采购单维护CRUD
ware/purchase/list 查询采购单列表
ware/purchase/received 领取采购单
ware/purchase/done 完成采购单

采购单生成后可以分配给某个用户,分配后,采购单状态就变成已分配

这个用户领取后采购单状态就变成已领取,采购项状态变成正在采购

采购完成后采购单状态变成已完成,采购项状态变成已完成,商品入库

每日秒杀菜单

1.秒杀活动场次CRUD及关联商品
coupon/seckillsession/list 查询所有秒杀活动场次

秒杀活动有开始时间和结束时间,实际上这里应该是每日秒杀活动,对应的是每日开始时间和结束时间,但是本项目中这个菜单被当成普通的秒杀活动用了,开始和结束时间都是带有日期的

/coupon/seckillskurelation/list 根据场次id查出关联的商品

一个场次可以关联很多商品

6.商城首页开发

gulimall-search商品上架和检索使用elasticsearch,跳过

请求流程:浏览器请求gulimall.com,根据本机配置的hosts被解析为虚拟机ip,虚拟机的80端口(nginx)接收到请求后,根据proxy_pass http://gulimall,将请求转发到项目ip(本机)的88端口,即网关,网关根据host负载均衡到对应的微服务,然后该服务响应请求,返回响应结果

# 这个配置在前面,根据路径中带api路由
- id: product_route
   uri: lb://gulimall-product
   predicates:
            - Path=/api/product/**
   filters:
            - RewritePath=/api/(?<segment>/?.*),/$\{segment}
# 配置在后面,优先级低,按照域名路由
- id: gulimall_host_route
  uri: lb://gulimall-product
  predicates:
    - Host=gulimall.com,item.gulimall.com

动静分离:

指定所有static的访问,都是到“/usr/share/nginx/html”路径下寻找

location /static/ {
	root /usr/share/nginx/html;
}

1.商城首页

1.访问首页渲染一级分类菜单

@GetMapping(value = {"/","index.html"})
private String indexPage(Model model) {
    // 查出所有的一级分类
    List<CategoryEntity> categoryEntities = categoryService.getLevel1Categorys();
    model.addAttribute("categories",categoryEntities);
    // thymeleaf默认后缀html
    return "index";
}

通过下面缓存的一系列演变过程,在原来方法基础上添加注解

/** 
 * 该注解代表当前方法的结果需要缓存,如果缓存中有,方法都不用调用,如果缓存中没有,会调用方法。最后将方法的结果放入缓存
 * 在redis中体现为category文件夹下,key的值是category::getLevel1Categorys,value的值是JSON格式
 */
@Cacheable(value = {"category"},key = "#root.method.name",sync = true)
@Override
public List<CategoryEntity> getLevel1Categorys() {
    System.out.println("getLevel1Categorys........");
    long l = System.currentTimeMillis();
    List<CategoryEntity> categoryEntities = this.baseMapper.selectList(
            new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));
    System.out.println("消耗时间:"+ (System.currentTimeMillis() - l));
    return categoryEntities;
}

访问gulimail.com返回gulimall-product\src\main\resources\templates\index.html

2.渲染二级三级分类数据

@GetMapping(value = "/index/catalog.json")
@ResponseBody
public Map<String, List<Catelog2Vo>> getCatalogJson() {
    Map<String, List<Catelog2Vo>> catalogJson = categoryService.getCatalogJson();
    return catalogJson;
}

一次性查询出所有的分类数据,减少对于数据库的访问次数,后面的数据操作并不是到数据库中查询,而是从内存中操作

@Override
public Map<String, List<Catelog2Vo>> getCatalogJson() {
    // 将数据库的多次查询变为一次
	List<CategoryEntity> selectList = this.baseMapper.selectList(null);
	// 封装数据,省略
	Map<String, List<Catelog2Vo>> parentCid = selectList.stream();
	return parentCid;
}

通过下面缓存的一系列演变过程,最终解决方案

@Cacheable(value = "category",key = "#root.methodName")
@Override
public Map<String, List<Catelog2Vo>> getCatalogJson() {
    // 将数据库的多次查询变为一次
	List<CategoryEntity> selectList = this.baseMapper.selectList(null);
	// 封装数据,省略
	Map<String, List<Catelog2Vo>> parentCid = selectList.stream();
	return parentCid;
}

2.分类数据缓存及有关问题解决

本项目使用Jedis作为操作Resdis的客户端,StringRedisTemplate是Springboot对Jedis相关API的进一步封装

一致性要求不高,读多写少的数据适合放入缓存,如果分类列表,商品列表,下面将数据库中获取的分类数据放入缓存

public Map<String, List<Catelog2Vo>> getCatalogJsonFromCache() {
    // 加入缓存逻辑,缓存中存的数据是json字符串
    ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
    String catalogJson = ops.get("catalogJson");
    if (StringUtils.isEmpty(catalogJson)) {
        System.out.println("缓存不命中...查询数据库...");
        // 缓存中没有数据,查询数据库
        Map<String, List<Catelog2Vo>> catalogJsonFromDb = getDataFromDb();
        return catalogJsonFromDb;
    }
    System.out.println("缓存命中...直接返回...");
    // 转为指定的对象
    Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJson,new TypeReference<Map<String, List<Catelog2Vo>>>(){});
    return result;
}
/**
 * 从数据库中查询并放入缓存,parentCid最终的结构是key是一级分类的id,value是二级分类的list,每个二级分类又包含一个三级分类list,每个分类的实体包含父分类id,自己的id和名字
 */
private Map<String, List<Catelog2Vo>> getDataFromDb() {
    // 将数据库的多次查询变为一次
    List<CategoryEntity> selectList = this.baseMapper.selectList(null);
	// 封装数据,省略
    Map<String, List<Catelog2Vo>> parentCid;
    // 将查到的数据转为Json放入缓存
    String valueJson = JSON.toJSONString(parentCid);
    stringRedisTemplate.opsForValue().set("catalogJson", valueJson, 1, TimeUnit.DAYS);
    return parentCid;
}

但是在分布式缓存中,还是存在缓存穿透,击穿,雪崩的问题

缓存穿透是指查询一个永不存在的数据;缓存雪崩是值大面积key同时失效问题;缓存击穿是指高频key失效问题

  1. 空结果缓存,解决缓存穿透
  2. 设置过期时间(随机加值),解决缓存雪崩
  3. 加锁,解决缓存击穿

下面通过加本地锁尝试解决缓存击穿问题

public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithLocalLock() {
    synchronized (this) {
        // 使用了双重检查锁定模式来控制线程的并发访问数据库,一个线程进入到临界区之前,判断缓存中是否有数据,进入到临界区后,再次判断缓存中是否有数据
        // 锁之后还要进行一次检查是因为如果多个线程同时查缓存不命中,就会多次查库
        String catalogJson = stringRedisTemplate.opsForValue().get("catalogJson");
        if (!StringUtils.isEmpty(catalogJson)) {
            // 缓存不为空直接返回
            Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJson, new TypeReference<Map<String, List<Catelog2Vo>>>() {
            });
            return result;
        }
        return getDataFromDb();
    }
}

2.1.分布式锁

本地锁只能锁住当前进程,分布式项目需要分布式锁,使用redis实现,这种方式没有实现锁在业务执行时自动续期

redis命令是SET key value [EX seconds] [PX milliseconds] [NX|XX]

public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {
    // 占分布式锁,去redis占坑
    // 1.设置过期时间避免宕机死锁,设置过期时间必须和加锁是同步的,也是为了防止正要设置过期时间时宕机死锁
    // 2.uuid避免锁过期导致锁被替换,保证只能删进程自己的锁
    String uuid = UUID.randomUUID().toString();
    Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid,30,TimeUnit.SECONDS);
    if (lock) {
        System.out.println("获取分布式锁成功...");
        Map<String, List<Catelog2Vo>> dataFromDb = null;
        try {
            // 加锁成功...执行业务
            dataFromDb = getDataFromDb();
        } finally {
            // 执行完业务后需要删除锁
            // 使用脚本是为了避免获取锁后,在返回传输途中锁到期被换成别人的锁,就会误删别人的锁
            // 脚本的意思是获取redis里key为lock的值,如果等于传过去的uuid,就删掉,对比并删除必须是原子操作
            // 如果uuid不一样,说明原来的锁到期自动删除了(异常/断电),别的进程抢到了新锁,执行删锁失败
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            // 删除锁,Long.class是脚本的返回值类型,lock是KEYS[1],uuid是ARGV[1]
            stringRedisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList("lock"), uuid);
        }
        return dataFromDb;
    } else {
        System.out.println("获取分布式锁失败...等待重试...");
        // 加锁失败...重试机制
        // 休眠一百毫秒
        try { TimeUnit.MILLISECONDS.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); }
        return getCatalogJsonFromDbWithRedisLock();     //自旋的方式
    }
}
1.使用Redisson框架实现分布式锁
@Configuration
public class MyRedissonConfig {
    /**
     * 所有对Redisson的使用都是通过RedissonClient
     */
    @Bean(destroyMethod="shutdown")
    public RedissonClient redisson() throws IOException {
        //1、创建配置
        Config config = new Config();
        config.useSingleServer().setAddress("redis://192.168.23.129:6379");
        //2、根据Config创建出RedissonClient实例
        //Redis url should start with redis:// or rediss://
        RedissonClient redissonClient = Redisson.create(config);
        return redissonClient;
    }
}
@Autowired
private RedissonClient redissonClient;

public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedissonLock() {
    // 占分布式锁。去redis占坑
    //(锁的粒度,越细越快:具体缓存的是某个数据,11号商品) product-11-lock
    //创建读锁
    RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("catalogJson-lock");
    RLock rLock = readWriteLock.readLock();
    Map<String, List<Catelog2Vo>> dataFromDb = null;
    try {
        rLock.lock();
        //加锁成功...执行业务
        dataFromDb = getDataFromDb();
    } finally {
        rLock.unlock();
    }
    return dataFromDb;
}
2.Ressison的各种锁

可重入锁

一个线程不用释放,可以重复的获取一个锁n次,只是在释放的时候,也需要相应的释放n次

@ResponseBody
@GetMapping(value = "/hello")
public String hello() {
    //1、获取可重入锁,只要锁的名字一样,就是同一把锁
    RLock myLock = redisson.getLock("my-lock");
    //2、加锁,阻塞式等待。默认加的锁都是30s
    myLock.lock();
    // 锁的自动续期,如果业务超长,运行期间自动锁上新的30s。不用担心业务时间长,锁自动过期被删掉
    // 加锁的业务只要运行完成,就不会给当前锁续期,即使不手动解锁,锁默认会在30s内自动过期,不会产生死锁问题
    // myLock.lock(10,TimeUnit.SECONDS); //10秒钟自动解锁,自动解锁时间一定要大于业务执行时间,推荐
    // 尽管相对于lock(),lock(long leaseTime, TimeUnit unit)存在到期后自动删除的问题,但是我们对于它的使用还是比较多的,通常都会评估一下业务的最大执行用时,在这个时间内,如果仍然未能执行完成,则认为出现了问题,则释放锁执行其他逻辑
    // 总结:
    // 1、如果我们传递了锁的超时时间,就发送给redis执行脚本,进行占锁,默认超时就是我们指定的时间
    // 2、如果我们未指定锁的超时时间,就使用 lockWatchdogTimeout = 30 * 1000 【看门狗默认时间】
    // 只要占锁成功,就会启动一个定时任务【重新给锁设置过期时间,新的过期时间就是看门狗的默认时间】,每隔10秒都会自动的再次续期,续成30秒
    try {
        System.out.println("加锁成功,执行业务..." + Thread.currentThread().getId());
        try { TimeUnit.SECONDS.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); }
    } catch (Exception ex) {
        ex.printStackTrace();
    } finally {
        //3、解锁
        myLock.unlock();
    }
    return "hello";
}

读写锁

保证一定能读到最新数据,修改期间,写锁是一个排它锁(互斥锁、独享锁),读锁是一个共享锁

读 + 读 :相当于无锁,并发读,只会在Redis中记录好,所有当前的读锁,他们都会同时加锁成功

写 + 读 :必须等待写锁释放

写 + 写 :阻塞方式

读 + 写 :有读锁,写也需要等待

只要有写的存在,不管是读锁/写锁必须等待

@GetMapping(value = "/write")
@ResponseBody
public String writeValue() {
    String s = "";
    RReadWriteLock readWriteLock = redisson.getReadWriteLock("rw-lock");
    RLock rLock = readWriteLock.writeLock();
    try {
        //1、改数据加写锁,读数据加读锁
        rLock.lock();
        s = UUID.randomUUID().toString();
        ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
        ops.set("writeValue",s);
        TimeUnit.SECONDS.sleep(10);
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        rLock.unlock();
    }

    return s;
}

@GetMapping(value = "/read")
@ResponseBody
public String readValue() {
    String s = "";
    RReadWriteLock readWriteLock = redisson.getReadWriteLock("rw-lock");
    //加读锁
    RLock rLock = readWriteLock.readLock();
    try {
        rLock.lock();
        ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
        s = ops.get("writeValue");
        try { TimeUnit.SECONDS.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); }
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        rLock.unlock();
    }
    return s;
}

闭锁

等待线程必须等其他工作线程完成后才可以继续

/**
 * 放假、锁门
 * 1班没人了
 * 5个班,全部走完,我们才可以锁大门
 */
@GetMapping(value = "/lockDoor")
@ResponseBody
public String lockDoor() throws InterruptedException {
    RCountDownLatch door = redisson.getCountDownLatch("door");
    door.trySetCount(5); //等待5个工作线程,等待5个班的人走完
    door.await();       //等待闭锁完成
    return "放假了...";
}

@GetMapping(value = "/gogogo/{id}")
@ResponseBody
public String gogogo(@PathVariable("id") Long id) {
    RCountDownLatch door = redisson.getCountDownLatch("door");
    door.countDown();       //计数-1
    return id + "班的人都走了...";
}

信号量

限制同时能够允许获取锁的线程数量

/**
 * 车库停车
 * 信号量也可以做分布式限流
 */
@GetMapping(value = "/park")
@ResponseBody
public String park() throws InterruptedException {
    RSemaphore park = redisson.getSemaphore("park"); // 分布式信号量
    park.trySetPermits(3); // 三个车位
    //park.acquire();     //获取一个信号、获取一个值,占一个车位,阻塞式
    boolean flag = park.tryAcquire(); //试着获取一个车位,立即返回结果
    if (flag) {
        //执行业务
    } else {
        return "error";
    }
    return "ok=>" + flag;
}

@GetMapping(value = "/go")
@ResponseBody
public String go() {
    RSemaphore park = redisson.getSemaphore("park");
    park.release();     //释放一个车位
    return "ok";
}

2.2.缓存一致性

缓存里的数据和数据库的数据保持一致,加锁也可以保证,但是性能差

双写模式

数据库更新后更新缓存,如果更新缓存慢了,被别的线程更新了,就会导致缓存不一致

失效模式

数据库更新后删缓存,刚刚读数据库完,这个数据对应的缓存被其他线程更新并删掉了,放入缓存的就是旧数据

应该使用失效模式,如果数据库1小时内更新了1000次,那么缓存也要更新1000次,如果是删除的话,就算数据库更新了1000次,那么也只是做了1次缓存删除,只有当缓存真正被读取的时候才去数据库加载

2.3.数据库/缓存并发解决方案

数据库层面存在高并发读写的问题

缓存在高并发下读模式有缓存穿透,击穿,雪崩,写模式有缓存一致性等问题

数据库高并发读:性能问题

使用缓存

数据库高并发写:丢失更新与写入偏差

使用redis分布式锁

使用mysql的各种锁

本项目相关问题解决方案

数据库层面:

高并发读加入redis缓存,没有高并发写

缓存层面:

缓存空结果防止缓存穿透

设置随机过期时间防止缓存雪崩

使用分布式读写锁,保证只有一个线程查数据库,防止缓存击穿

分类数据一致性要求不高,缓存设置过期时间,数据过期下一次查询触发主动更新,保证最终一致性即可

2.4.SpringCache

整合SpringCache方便对缓存的操作

默认行为

  • value = {“category”},缓存名字是category,在redis中体现为文件夹名,key是默认生成的,在redis中体现为缓存的名字::SimpleKey::[]
  • 缓存的value值默认使用jdk序列化机制,将序列化的数据存到redis中
  • 默认时间是 -1:

指定缓存的数据的存活时间

spring.cache.redis.time-to-live=3600000

缓存空值防止缓存穿透

spring.cache.redis.cache-null-values=true

加锁防止缓存击穿

#同步模式
@Cacheable(value = {"category"},key = "#root.method.name",sync = true)

指定缓存key前缀

#如果指定了前缀就用我们指定的前缀,如果没有就默认使用缓存的名字作为前缀
#spring.cache.redis.key-prefix=CACHE_
spring.cache.redis.use-key-prefix=true

将缓存的数据保存为json形式

/**
 * 在spring开发过程中我们常使用到@ConfigurationProperties注解,通常是用来将properties和yml配置文件属性转化为bean对象使用,如@ConfigurationProperties(prefix="spring.cache")
 * @EnableConfigurationProperties注解的作用是:让使用了 @ConfigurationProperties 注解的类生效,并且将该类注入到 IOC 容器中,交由 IOC 容器进行管理,这里就是CacheProperties.class,它上面有@ConfigurationProperties(prefix = "spring.cache"
)
 */
@EnableConfigurationProperties(CacheProperties.class)
@Configuration
@EnableCaching
public class MyCacheConfig {

    @Bean
    public RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {

        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
        
        config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
        // 使用Json序列化
        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;
    }

}
1.获取一级菜单分类、获取所有二级三级菜单分类添加缓存

见上面最终解决方案

2.级联修改菜单分类 + 失效模式
/**
 * 级联更新所有关联的数据
 * @CacheEvict:失效模式
 * @CachePut:双写模式,需要有返回值
 * 1、同时进行多种缓存操作:@Caching
 * 2、指定删除某个分区下的所有数据 @CacheEvict(value = "category",allEntries = true)
 * 3、存储同一类型的数据,都可以指定为同一分区
 */
// @Caching(evict = {
//         @CacheEvict(value = "category",key = "'getLevel1Categorys'"),
//         @CacheEvict(value = "category",key = "'getCatalogJson'")
// })
@CacheEvict(value = "category",allEntries = true) 
@Transactional(rollbackFor = Exception.class)
@Override
public void updateCascade(CategoryEntity category) {

    RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("catalogJson-lock");
    //创建写锁
    RLock rLock = readWriteLock.writeLock();
    try {
        rLock.lock();
        this.baseMapper.updateById(category);
        // 修改菜单品牌关联表
        categoryBrandRelationService.updateCategory(category.getCatId(), category.getName());
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        rLock.unlock();
    }
    //同时修改缓存中的数据
    //删除缓存,等待下一次主动查询进行更新
}

3.CompletableFuture异步编排

1.开启异步任务

CompletableFuture 提供了四个静态方法来创建一个异步操作

public static CompletableFuture<Void> runAsync(Runnable runnable)
public static CompletableFuture<Void> runAsync(Runnable runnable, Executor executor)
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier)
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier, Executor executor)

没有指定Executor的方法会使用ForkJoinPool.commonPool() 作为它的线程池执行异步代码。如果指定线程池,则使用指定的线程池运行,runAsync方法不支持返回值,supplyAsync可以支持返回值

// 自定义线程池
public static ExecutorService executor = Executors.newFixedThreadPool(10);
// 当前线程:14
// 运行结果:5
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
    System.out.println("当前线程:" + Thread.currentThread().getId());
    int i = 10 / 2;
    System.out.println("运行结果:" + i);
}, executor);

CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
    System.out.println("当前线程:" + Thread.currentThread().getId());
    int i = 10 / 2;
    System.out.println("当前运行结果:" + i);
    return i;
}, executor);
Integer integer = future.get();
System.out.println("main.............end......."+integer);

2.计算完成回调

whencomplete 和 whenCompleteAsync的区别:
whencomplete :执行当前任务的线程继续执行whencomplete的任务,实际用这个就ok
whenCompleteAsync: 执行把whenCompleteAsync这个任务继续提交给线程池来进行

//可以处理异常,无返回值
public CompletableFuture<T> whenComplete(BiConsumer<? super T,? super Throwable> action)
public CompletableFuture<T> whenCompleteAsync(BiConsumer<? super T,? super Throwable> action)
public CompletableFuture<T> whenCompleteAsync(BiConsumer<? super T,? super Throwable> action, Executor executor)
//可以处理异常,有返回值,相当于try/catch
public CompletableFuture<T> exceptionally(Function<Throwable,? extends T> fn)
CompletableFuture<Integer> future2 = CompletableFuture.supplyAsync(() -> {
    System.out.println("当前线程:" + Thread.currentThread().getId());
    int i = 10 / 0;
    System.out.println("运行结果:" + i);
    return i;
}, executor).whenComplete((res,exception) -> {
    //虽然能得到异常信息,但是没法修改返回数据
    System.out.println("异步任务成功完成了...结果是:" + res + "异常是:" + exception);
}).exceptionally(throwable -> {
    //可以感知异常,同时返回默认值
    return 10;
});

3.handle最终处理

handle和whenComplete方法类似,但是whenComplete能感知异常但是不能返回结果。只能通过exceptionally进行处理。

而handle即可以获取执行结果,也可以感知异常信息,并能处理执行结果并返回

CompletableFuture<Integer> future3 = CompletableFuture.supplyAsync(() -> {
    System.out.println("当前线程:" + Thread.currentThread().getId());
    int i = 10 / 2;
    System.out.println("运行结果:" + i);
    return i;
}, executor).handle((result,thr) -> {
    if (result != null) {
        return result * 2;
    }
    if (thr != null) {
        System.out.println("异步任务成功完成了...结果是:" + result + "异常是:" + thr);
        return 0;
    }
    return 0;
});

4.线程串行化

  • thenApply 方法:获取上一个任务返回结果,有返回值。
  • thenAccept方法:获取上一个任务返回结果,并消费处理,无返回值。
  • thenRun方法:获取不到上个任务的返回结果,无返回值。
public <U> CompletableFuture<U> thenApply(Function<? super T,? extends U> fn)
public <U> CompletableFuture<U> thenApplyAsync(Function<? super T,? extends U> fn)
public <U> CompletableFuture<U> thenApplyAsync(Function<? super T,? extends U> fn, Executor executor)

public CompletableFuture<Void> thenAccept(Consumer<? super T> action)
public CompletableFuture<Void> thenAcceptAsync(Consumer<? super T> action)
public CompletableFuture<Void> thenAcceptAsync(Consumer<? super T> action,Executor executor)

public CompletableFuture<Void> thenRun(Runnable action)
public CompletableFuture<Void> thenRunAsync(Runnable action)
public CompletableFuture<Void> thenRunAsync(Runnable action,Executor executor)
CompletableFuture<String> future4 = CompletableFuture.supplyAsync(() -> {
    System.out.println("当前线程:" + Thread.currentThread().getId());
    int i = 10 / 2;
    System.out.println("运行结果:" + i);
    return i;
}, executor).thenApplyAsync(res -> {
    System.out.println("任务2启动了..." + res);
    return "Hello" + res;
}, executor);
System.out.println("main......end....." + future.get());

5.两任务组合

两个任务必须都完成,触发该任务

用法:第一个异步任务.thenCombine(第二个异步任务,第三个异步任务)

thenCombine 可以获取两个任务的返回值,并可以将任务三结果返回

thenAcceptBoth 可以获取两个任务的返回值,没有返回值

runAfterBoth 不能获取返回值,没有返回值

public <U,V> CompletionStage<V> thenCombine(CompletionStage<? extends U> other,
         BiFunction<? super T,? super U,? extends V> fn);
public <U,V> CompletionStage<V> thenCombineAsync(CompletionStage<? extends U> other,
         BiFunction<? super T,? super U,? extends V> fn);
public <U,V> CompletionStage<V> thenCombineAsync(CompletionStage<? extends U> other,
         BiFunction<? super T,? super U,? extends V> fn,Executor executor);         

public <U> CompletableFuture<Void> thenAcceptBoth(CompletionStage<? extends U> other,
        BiConsumer<? super T, ? super U> action)
public <U> CompletableFuture<Void> thenAcceptBothAsync(CompletionStage<? extends U> other,
        BiConsumer<? super T, ? super U> action)
public <U> CompletableFuture<Void> thenAcceptBothAsync(CompletionStage<? extends U> other,
        BiConsumer<? super T, ? super U> action, Executor executor)


public CompletableFuture<Void> runAfterBoth(CompletionStage<?> other,Runnable action)}
public CompletableFuture<Void> runAfterBothAsync(CompletionStage<?> other,Runnable action) }
public CompletableFuture<Void> runAfterBothAsync(CompletionStage<?> other,Runnable action,
                                                     Executor executor)}
CompletableFuture<Integer> future1 = CompletableFuture.supplyAsync(() -> {
    System.out.println("开启异步任务1...");
    int i = 10 / 2;
    return i;
}, service);
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> {
    System.out.println("开启异步任务2...");
    return "hello";
}, service);
CompletableFuture<String> stringCompletableFuture = future1.thenCombineAsync(future2, (res1, res2) -> {
    System.out.println("任务3 启动了.... 任务1的返回值:" + res1 + " 任务2的返回值:" + res2);
    return res1 + "-->" + res2;
}, service);
System.out.println("获取异步任务最终返回值:" + stringCompletableFuture.get());

两个任务只要有一个完成,执行该任务

public CompletableFuture<Void> runAfterEither(CompletionStage<?> other,Runnable action)
public CompletableFuture<Void> runAfterEitherAsync(CompletionStage<?> other,Runnable action)
public CompletableFuture<Void> runAfterEitherAsync(CompletionStage<?> other,Runnable action, Executor executor)

public CompletableFuture<Void> acceptEither(
              CompletionStage<? extends T> other, Consumer<? super T> action)
public CompletableFuture<Void> acceptEitherAsync(
              CompletionStage<? extends T> other, Consumer<? super T> action)}
public CompletableFuture<Void> acceptEitherAsync(
              CompletionStage<? extends T> other, Consumer<? super T> action,Executor executor)}

public <U> CompletableFuture<U> applyToEither(
              CompletionStage<? extends T> other, Function<? super T, U> fn)
public <U> CompletableFuture<U> applyToEitherAsync(
              CompletionStage<? extends T> other, Function<? super T, U> fn)
public <U> CompletableFuture<U> applyToEitherAsync(
        CompletionStage<? extends T> other, Function<? super T, U> fn,Executor executor)

5.多任务组合

  • allOf:等待所有任务完成
  • anyOf: 只要有一个任务完成
public static CompletableFuture<Void> allOf(CompletableFuture<?>... cfs)
public static CompletableFuture<Object> anyOf(CompletableFuture<?>... cfs)
CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> {
	System.out.println("查询商品图片...");
	return "图片地址";
}, service);
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> {
	System.out.println("查询商品属性...");
	return "黑色 256G";
}, service);
CompletableFuture<String> future3 = CompletableFuture.supplyAsync(() -> {
	System.out.println("查询商品品牌...");
	return "苹果手机";
}, service);
CompletableFuture<Void> future = CompletableFuture.allOf(future1, future2, future3);
// 等待索引结果完成
future.get();

4.商品详情

1.自定义线程池

创建线程池属性配置类,并添加配置文件

#配置线程池
gulimall.thread.coreSize=20
gulimall.thread.maxSize=200
gulimall.thread.keepAliveTime=10
@ConfigurationProperties(prefix = "gulimall.thread")
@Data
public class ThreadPoolConfigProperties {

    private Integer coreSize;

    private Integer maxSize;

    private Integer keepAliveTime;
}

让使用了 @ConfigurationProperties 注解的类生效,并且将该类注入到 IOC 容器中

@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()
        );
    }
}

2.展示商品详情(异步编排,线程池)

访问item.gulimall.com/{skuId}.html展示商品详情

一个SkuInfoEntity,需要查出这些信息:

@ToString
@Data
public class SkuItemVo {
    //1、sku基本信息的获取  pms_sku_info
    private SkuInfoEntity info;
    private boolean hasStock = true;
    //2、sku的图片信息    pms_sku_images
    private List<SkuImagesEntity> images;
    //3、获取sku的销售属性组合
    private List<SkuItemSaleAttrVo> saleAttr;
    //4、获取spu的介绍
    private SpuInfoDescEntity desc;
    //5、获取spu的规格参数信息
    private List<SpuItemAttrGroupVo> groupAttrs;
    //6、秒杀商品的优惠信息
    private SeckillSkuVo seckillSkuVo;
}
@GetMapping("/{skuId}.html")
public String skuItem(@PathVariable("skuId") Long skuId, Model model) throws Exception {
    System.out.println("准备查询" + skuId + "详情");
    SkuItemVo vos = skuInfoService.item(skuId);
    model.addAttribute("item",vos);
    return "item";
}
@Override
public SkuItemVo item(Long skuId) throws ExecutionException, InterruptedException {

    SkuItemVo skuItemVo = new SkuItemVo();

    CompletableFuture<SkuInfoEntity> infoFuture = CompletableFuture.supplyAsync(() -> {
        // sku基本信息的获取  pms_sku_info
        SkuInfoEntity info = this.getById(skuId);
        skuItemVo.setInfo(info);
        return info;
    }, executor);


    CompletableFuture<Void> saleAttrFuture = infoFuture.thenAcceptAsync((res) -> {
        // 获取sku的销售属性组合
        List<SkuItemSaleAttrVo> saleAttrVos = skuSaleAttrValueService.getSaleAttrBySpuId(res.getSpuId());
        skuItemVo.setSaleAttr(saleAttrVos);
    }, executor);


    CompletableFuture<Void> descFuture = infoFuture.thenAcceptAsync((res) -> {
        // 获取spu的介绍    pms_spu_info_desc
        SpuInfoDescEntity spuInfoDescEntity = spuInfoDescService.getById(res.getSpuId());
        skuItemVo.setDesc(spuInfoDescEntity);
    }, executor);


    CompletableFuture<Void> baseAttrFuture = infoFuture.thenAcceptAsync((res) -> {
        // 获取spu的规格参数信息
        List<SpuItemAttrGroupVo> attrGroupVos = attrGroupService.getAttrGroupWithAttrsBySpuId(res.getSpuId(), res.getCatalogId());
        skuItemVo.setGroupAttrs(attrGroupVos);
    }, executor);

    // sku的图片信息    pms_sku_images
    CompletableFuture<Void> imageFuture = CompletableFuture.runAsync(() -> {
        List<SkuImagesEntity> imagesEntities = skuImagesService.getImagesBySkuId(skuId);
        skuItemVo.setImages(imagesEntities);
    }, executor);

    CompletableFuture<Void> seckillFuture = CompletableFuture.runAsync(() -> {
        // 远程调用查询当前sku是否参与秒杀优惠活动
        R skuSeckilInfo = seckillFeignService.getSkuSeckilInfo(skuId);
        if (skuSeckilInfo.getCode() == 0) {
            // 查询成功
            SeckillSkuVo seckilInfoData = skuSeckilInfo.getData("data", new TypeReference<SeckillSkuVo>() {
            });
            skuItemVo.setSeckillSkuVo(seckilInfoData);
        }
    }, executor);

    //等到所有任务都完成
    CompletableFuture.allOf(saleAttrFuture,descFuture,baseAttrFuture,imageFuture,seckillFuture).get();
    return skuItemVo;
}

7.认证服务开发

1.会员服务主要表结构

ums_member:会员信息表

会员等级,用户名,密码,昵称,手机号,启用状态,注册时间,社交用户唯一id,访问令牌,令牌有效时间

2.注册功能

1.注册发送验证码

接口防刷:防止用户刷新页面重复发送请求,同一个手机号验证码60s只能发一次

@ResponseBody
@GetMapping(value = "/sms/sendCode")
public R sendCode(@RequestParam("phone") String phone) {

    // 验证码60s内只能发一次,验证码前缀sms:code:
    String redisCode = stringRedisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone);
    if (!StringUtils.isEmpty(redisCode)) {
        //用当前时间减去存入redis的时间,判断用户手机号是否在60s内发送验证码
        long currentTime = Long.parseLong(redisCode.split("_")[1]);
        if (System.currentTimeMillis() - currentTime < 60000) {
            return R.error(BizCodeEnum.SMS_CODE_EXCEPTION.getCode(),BizCodeEnum.SMS_CODE_EXCEPTION.getMessage());
        }
    }

    //redis的 key->phone value->code_当前时间
    int code = (int) ((Math.random() * 9 + 1) * 100000);
    String codeNum = String.valueOf(code);
    String redisStorage = codeNum + "_" + System.currentTimeMillis();

    //存入redis,验证码10分钟有效
    stringRedisTemplate.opsForValue().set(AuthServerConstant.SMS_CODE_CACHE_PREFIX+phone,
            redisStorage,10, TimeUnit.MINUTES);
	
    //发送验证码
    thirdPartFeignService.sendCode(phone, codeNum);

    return R.ok();
}

2.注册用户

注册需要输入用户名,密码,手机号,验证码

  • 若JSR303校验未通过,则通过BindingResult封装错误信息,并重定向至注册页面
  • 若通过JSR303校验,则需要从redis中取值判断验证码是否正确,正确的话通过会员服务注册
  • 使用重定向 ,防止数据重复提交,携带数据使用RedirectAttributes,其原理是放到session中
@PostMapping(value = "/register")
public String register(@Valid UserRegisterVo vos, BindingResult result,
                       RedirectAttributes attributes) {

    // 如果有错误回到注册页面
    if (result.hasErrors()) {
        Map<String, String> errors = result.getFieldErrors().stream().collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
        attributes.addFlashAttribute("errors",errors);
        // 校验出错回到注册页面
        return "redirect:http://auth.gulimall.com/reg.html";
    }

    // 获取用户输入的验证码,校验验证码
    String code = vos.getCode();

    // 获取存入Redis里的验证码
    String redisCode = stringRedisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + vos.getPhone());
    if (!StringUtils.isEmpty(redisCode)) {
        // 截取字符串
        if (code.equals(redisCode.split("_")[0])) {
            // 删除验证码
            stringRedisTemplate.delete(AuthServerConstant.SMS_CODE_CACHE_PREFIX+vos.getPhone());
            // 验证码通过,真正注册,调用远程服务进行注册
            R register = memberFeignService.register(vos);
            if (register.getCode() == 0) {
                // 成功
                return "redirect:http://auth.gulimall.com/login.html";
            } else {
                // 失败
                Map<String, String> errors = new HashMap<>();
                errors.put("msg", register.getData("msg",new TypeReference<String>(){}));
                attributes.addFlashAttribute("errors",errors);
                return "redirect:http://auth.gulimall.com/reg.html";
            }
        } else {
            // 验证码错误,回到注册页面
            Map<String, String> errors = new HashMap<>();
            errors.put("code","验证码错误");
            attributes.addFlashAttribute("errors",errors);
            return "redirect:http://auth.gulimall.com/reg.html";
        }
    } else {
        // 未发送验证码或验证码已经过期,回到注册页面
        Map<String, String> errors = new HashMap<>();
        errors.put("code","验证码错误");
        attributes.addFlashAttribute("errors",errors);
        return "redirect:http://auth.gulimall.com/reg.html";
    }
}

会员服务注册,密码使用BCryptPasswordEncoder加密,加密时会对明文随机加盐后生成hash,因此每次加密的结果是不同的,但是可以用matches()方法验证

/member/member/register
MemberEntity memberEntity = new MemberEntity();
// 密码进行MD5加密
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
String encode = bCryptPasswordEncoder.encode(vo.getPassword());
memberEntity.setPassword(encode);
// 保存数据
this.baseMapper.insert(memberEntity);

2.登录退出功能

1.用户名密码登录

登录展示页面

@GetMapping(value = "/login.html")
public String loginPage(HttpSession session) {

    // 从session先取出来用户的信息,判断用户是否已经登录过了
    Object attribute = session.getAttribute(LOGIN_USER);
    // 如果用户没登录那就返回登录页面,如果登录了就跳转到商城首页
    if (attribute == null) {
        return "login";
    } else {
        return "redirect:http://gulimall.com";
    }
}

发送登录请求,登录需要输入用户名,密码

@PostMapping(value = "/login")
public String login(UserLoginVo vo, RedirectAttributes attributes, HttpSession session) {

    // 远程登录
    R login = memberFeignService.login(vo);

    if (login.getCode() == 0) {
        // 登录成功,放置用户信息相关session
        MemberResponseVo data = login.getData("data", new TypeReference<MemberResponseVo>() {});
        session.setAttribute(LOGIN_USER,data);
        return "redirect:http://gulimall.com";
    } else {
        // 登录失败,跳转到登录页
        Map<String,String> errors = new HashMap<>();
        errors.put("msg",login.getData("msg",new TypeReference<String>(){}));
        attributes.addFlashAttribute("errors",errors);
        return "redirect:http://auth.gulimall.com/login.html";
    }
}

会员服务登录

@Override
public MemberEntity login(MemberUserLoginVo vo) {

    String loginacct = vo.getLoginacct();
    String password = vo.getPassword();

    //1、去数据库查询 SELECT * FROM ums_member WHERE username = ? OR mobile = ?
    MemberEntity memberEntity = this.baseMapper.selectOne(new QueryWrapper<MemberEntity>()
            .eq("username", loginacct).or().eq("mobile", loginacct));

    if (memberEntity == null) {
        //登录失败
        return null;
    } else {
        //获取到数据库里的password
        String password1 = memberEntity.getPassword();
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        //进行密码匹配
        boolean matches = passwordEncoder.matches(password, password1);
        if (matches) {
            //登录成功
            return memberEntity;
        }
    }

    return null;
}

2.社交登录

Oauth2.0开放授权协议-授权码模式

1.点击社交登录按钮,跳转到认证服务器(微博)授权页,要求用户给予授权

https://api.weibo.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=YOUR_REGISTERED_REDIRECT_URI

2.用户输入账号密码同意授权后,跳转到回调地址,微博会在后面拼接上/?code=CODE,这个code用来申请token

YOUR_REGISTERED_REDIRECT_URI/?code=CODE

如果社交用户第一次登录,注册关联一个账号

3.接口里需要给认证服务器发送请求申请token,需要应用id,应用密钥,回调地址,认证码

https://api.weibo.com/oauth2/access_token?client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&grant_type=authorization_code&redirect_uri=YOUR_REGISTERED_REDIRECT_URI&code=CODE

返回数据包含这些,转化成对象

@Data
public class SocialUser {
    private String access_token;
    private String remind_in;
    private long expires_in;
    private String uid;
    private String isRealName;
}

4.客户端使用令牌,向资源服务器(微博)申请获取资源

https://open.weibo.com/wiki/2/users/show

回调地址

/**
 * 社交登录成功回调
 */
@GetMapping(value = "/oauth2.0/weibo/success")
public String weibo(@RequestParam("code") String code, HttpSession session) throws Exception {

    Map<String, String> map = new HashMap<>();
    map.put("client_id","588997645");
    map.put("client_secret","5d7746d10c4d926ed38692c8d17b7e31");
    map.put("grant_type","authorization_code");
    map.put("redirect_uri","http://auth.gulimall.com/oauth2.0/weibo/success");
    map.put("code",code);

    // 根据用户授权返回的code换取access_token
    HttpResponse response = HttpUtils.doPost("https://api.weibo.com", "/oauth2/access_token", "post", new HashMap<>(), map, new HashMap<>());

    // 处理
    if (response.getStatusLine().getStatusCode() == 200) {
        // 获取到了access_token,转为通用社交登录对象
        String json = JSON.toJSONString(response.getEntity());
        SocialUser socialUser = JSON.parseObject(json, SocialUser.class);

        // 当前用户如果是第一次进网站,自动注册进来(为当前社交用户生成一个会员信息,以后这个社交账号就对应指定的会员)
        // 远程调用会员服务
        R oauthLogin = memberFeignService.oauthLogin(socialUser);
        if (oauthLogin.getCode() == 0) {
            MemberResponseVo data = oauthLogin.getData("data", new TypeReference<MemberResponseVo>() {});
            log.info("登录成功:用户信息:{}",data.toString());

            //1、浏览器保存JSESSIONID这个cookie,以后浏览器访问哪个网站就会带上这个网站的cookie
            //问题1、默认发的令牌,当前域(解决子域session共享问题),让所有服务都可以用
            //问题2、使用JSON的序列化方式来序列化对象到Redis中,单点登录
            session.setAttribute(LOGIN_USER,data);
            
            //2、登录成功跳回首页
            return "redirect:http://gulimall.com";
        } else {
            return "redirect:http://auth.gulimall.com/login.html";
        }
    } else {
        return "redirect:http://auth.gulimall.com/login.html";
    }
}

会员服务社交登录注册

@Override
public MemberEntity login(SocialUser socialUser) throws Exception {

    //具有登录和注册逻辑
    String uid = socialUser.getUid();

    //1、判断当前社交用户是否已经登录过系统
    MemberEntity memberEntity = this.baseMapper.selectOne(new QueryWrapper<MemberEntity>().eq("social_uid", uid));

    if (memberEntity != null) {
        // 这个用户已经注册过
        // 更新用户的访问令牌的时间和access_token
        MemberEntity update = new MemberEntity();
        update.setId(memberEntity.getId());
        update.setAccessToken(socialUser.getAccess_token());
        update.setExpiresIn(socialUser.getExpires_in());
        this.baseMapper.updateById(update);

        memberEntity.setAccessToken(socialUser.getAccess_token());
        memberEntity.setExpiresIn(socialUser.getExpires_in());
        return memberEntity;
    } else {
        // 没有查到当前社交用户对应的记录我们就需要注册一个
        MemberEntity register = new MemberEntity();
        // 查询当前社交用户的社交账号信息(昵称、性别等)
        Map<String,String> query = new HashMap<>();
        query.put("access_token",socialUser.getAccess_token());
        query.put("uid",socialUser.getUid());
        HttpResponse response = HttpUtils.doGet("https://api.weibo.com", "/2/users/show.json", "get", new HashMap<String, String>(), query);
        if (response.getStatusLine().getStatusCode() == 200) {
            // 查询成功,获取用户信息
            String json = EntityUtils.toString(response.getEntity());
            JSONObject jsonObject = JSON.parseObject(json);
            String name = jsonObject.getString("name");
            String gender = jsonObject.getString("gender");
            String profileImageUrl = jsonObject.getString("profile_image_url");

            register.setNickname(name);
            register.setGender("m".equals(gender)?1:0);
            register.setHeader(profileImageUrl);
            register.setCreateTime(new Date());
            register.setSocialUid(socialUser.getUid());
            register.setAccessToken(socialUser.getAccess_token());
            register.setExpiresIn(socialUser.getExpires_in());

            //把用户信息插入到数据库中
            this.baseMapper.insert(register);

        }
        return register;
    }

}

3.退出

@GetMapping(value = "/loguot.html")
public String logout(HttpServletRequest request) {
    request.getSession().removeAttribute(LOGIN_USER);
    request.getSession().invalidate();
    return "redirect:http://gulimall.com";
}

3.SpringSession

正常情况下,session不可以跨域,所以不能被多个服务共享,服务重启session也会丢失

引入SpringSession框架,使用redis统一存储session解决

Session概念

Session 是服务器端的一个 Key-Value 的数据结构,经常和 Cookie 配合,保持用户的登陆会话。客户端在第一次访问服务端的时候,服务端会响应一个 SessionId 并且将它存入到本地 Cookie 中,在之后的访问中浏览器会将 Cookie 中的 sessionId 放入到请求头中去访问服务器

自定义配置

  • 由于默认使用jdk进行序列化,通过导入RedisSerializer修改为json序列化
  • 并且通过修改CookieSerializer扩大session的作用域至**.gulimall.com
  • 自定义session过期时间
@Configuration
public class GulimallSessionConfig {

    @Bean
    public CookieSerializer cookieSerializer() {
        DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
        // 放大作用域到gulimall.com
        cookieSerializer.setDomainName("gulimall.com");
        cookieSerializer.setCookieName("GULISESSION");
        return cookieSerializer;
    }

    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        return new GenericJackson2JsonRedisSerializer();
    }

}
// 默认1800秒,即30分钟
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 60 * 60 * 3)
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication
public class GulimallAuthServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(GulimallAuthServerApplication.class, args);
    }
}

核心原理

@EnableRedisHttpSession会导入RedisHttpSessionConfiguration.class,RedisHttpSessionConfiguration继承了 SpringHttpSessionConfiguration,SpringHttpSessionConfiguration中注册了 SessionRepositoryFilter,这个过滤器会拦截所有的请求,将 request 和 response 对象转换成 SessionRepositoryRequestWrapper 和 SessionRepositoryResponseWrapper 。后续当第一次调用 request 的getSession方法时,会调用到 SessionRepositoryRequestWrapper的getSession方法。这个方法是被重写过的,以后创建获取session,都是在redis中创建获取

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, this.servletContext);
    SessionRepositoryFilter.SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryFilter.SessionRepositoryResponseWrapper(wrappedRequest, response);
    try {
        filterChain.doFilter(wrappedRequest, wrappedResponse);
    } finally {
        wrappedRequest.commitSession();
    }

}

4.token验证实现单点登录

使用SpringSession作用域放大最多到guliamll,不能实现不同域之间的单点登录

token概念:

浏览器第一次访问服务器时,服务器根据传过来的唯一标识userId,通过一些算法,加一个密钥,生成一个token,接着通过base64编码将token返回给客户端。客户端将token保存起来,下次请求时需要带着token,服务器收到请求后,用相同的算法和密钥去验证token。和session作用一样,都是告诉服务器你是谁,但是token不需要储存空间,浏览器不会自动带上

关键点:

1.系统1第一次登录时,服务器会根据用户id生成一个token,并给浏览器用cookie的方式保存token(保存登录服务器的域名下,给登录服务器留下痕迹),把token带到地址上返回(用这个token来做认证,查询信息)

2.系统2系统登录时会把登录服务器cookie中的token发送给服务器,服务器一看有token了,直接把token带到地址上返回

3.token验证通过,查询用户信息,保存到每个系统自己的session中

系统1

没登录,跳转到登录服务器,带上跳回地址

@GetMapping(value = "/employees")
public String employees(Model model, HttpSession session, @RequestParam(value = "token", required = false) String token) {

    // 验证token成功就认为已经登录,将用户信息放到session中
    if (!StringUtils.isEmpty(token)) {
        RestTemplate restTemplate=new RestTemplate();
        ResponseEntity<String> forEntity = restTemplate.getForEntity("http://sso.mroldx.cn:8080/userinfo?token=" + token, String.class);
        String body = forEntity.getBody();

        session.setAttribute("loginUser", body);
    }
    // 没登录命令浏览器重定向到登录服务器
    Object loginUser = session.getAttribute("loginUser");

    if (loginUser == null) {
        // 重定向登录页+跳回地址
        return "redirect:" + "http://sso.mroldx.cn:8080/login.html"+"?redirect_url=http://localhost:8081/employees";
    } else {
        // 有session,就从session中取出数据
        List<String> emps = new ArrayList<>();
        emps.add("张三");
        emps.add("李四");
        model.addAttribute("emps", emps);
        return "employees";
    }
}

登录服务器

先跳转到登录页,如果系统1已经登录,因为token已经存放在用户浏览器的cookie中了,系统2访问登录页就会直接带着token返回

// 登录页
@GetMapping("/login.html")
public String loginPage(@RequestParam("redirect_url") String url, Model model, @CookieValue(value = "sso_token", required = false) String sso_token) {
    if (!StringUtils.isEmpty(sso_token)) {
        return "redirect:" + url + "?token=" + sso_token;
    }
    model.addAttribute("url", url);
    return "login";
}

输入用户名密码后发送登录请求(页面隐藏回调地址),给redis放置token,对应保存用户信息,将token保存在登录服务器的cookie返回原系统

@PostMapping(value = "/doLogin")
public String doLogin(@RequestParam("username") String username, @RequestParam("password") String password, @RequestParam("redirect_url") String url, HttpServletResponse response) {

    // 登录成功跳转,跳回到登录页
    if (!StringUtils.isEmpty(username) && !StringUtils.isEmpty(password)) {

        String uuid = UUID.randomUUID().toString().replace("_", "");
        redisTemplate.opsForValue().set(uuid, username);
        Cookie sso_token = new Cookie("sso_token", uuid);

        response.addCookie(sso_token);
        return "redirect:" + url + "?token=" + uuid;
    }
    return "login";
}

8.购物车开发

1.存储及数据结构

购物车是一个读多写多的场景,因此放入数据库并不合适,但购物车又需要持久化,因此选用redis存储购物车数据

Map<购物车id,Map<SKUid,SKU详情>>

2.ThreadLocal和拦截器

参考京东,在点击购物车时,会为临时用户生成一个名字为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 {
		
        // userId,userKey,isTempUser
        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);
        }

        //目标方法执行之前,登陆了就有userid,是临时用户就有user-key,如果先是临时用户,再登陆,两个都有
        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);
        }
    }
}
/**
 * 拦截器配置
 **/
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new CartInterceptor())//注册拦截器
                .addPathPatterns("/**");
    }
}

3.展示购物车页面

  • 若用户未登录,则直接使用user-key获取购物车数据
  • 若用户登陆,则使用userId获取购物车数据,并将user-key对应临时购物车数据与用户购物车数据合并,并删除临时购物车
@GetMapping(value = "/cart.html")
public String cartListPage(Model model) throws ExecutionException, InterruptedException {
    //快速得到用户信息:id,user-key
    // UserInfoTo userInfoTo = CartInterceptor.toThreadLocal.get();

    CartVo cartVo = cartService.getCart();
    model.addAttribute("cart",cartVo);
    return "cartList";
}

4.添加/修改/删除商品到购物车

  • 若当前商品已经存在购物车,只需增添数量
  • 否则需要查询商品购物项所需信息,并添加新商品至购物车
/**
 * 添加商品到购物车,重定向防止重复提交
 * attributes.addFlashAttribute():将数据放在session中,可以在页面中取出,但是只能取一次
 * attributes.addAttribute():将数据放在url后面
 * @return
 */
@GetMapping(value = "/addCartItem")
public String addCartItem(@RequestParam("skuId") Long skuId,
                          @RequestParam("num") Integer num,
                          RedirectAttributes attributes) throws ExecutionException, InterruptedException {

    cartService.addToCart(skuId,num);

    attributes.addAttribute("skuId",skuId);
    return "redirect:http://cart.gulimall.com/addToCartSuccessPage.html";
}
//拿到要操作的购物车信息
BoundHashOperations<String, Object, Object> cartOps = getCartOps();

//判断Redis是否有该商品的信息
String productRedisValue = (String) cartOps.get(skuId.toString());

// 给redis里放购物车信息
cartOps.put(skuId.toString(),cartItemJson);

5.获取操作的购物车

/**
 * 获取到我们要操作的购物车,区分操作登录用户的还是临时用户的
 */
private BoundHashOperations<String, Object, Object> getCartOps() {
    // 先得到当前用户信息
    UserInfoTo userInfoTo = CartInterceptor.toThreadLocal.get();

    String cartKey = "";
    if (userInfoTo.getUserId() != null) {
        // gulimall:cart:1 登录
        cartKey = CART_PREFIX + userInfoTo.getUserId();
    } else {
        // 临时用户
        cartKey = CART_PREFIX + userInfoTo.getUserKey();
    }

    // 绑定指定的key操作Redis
    BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(cartKey);

    return operations;
}

9.RabbitMQ消息队列

1.发送流程

生产者(message + route-key) -> broker(exchange -> queue) -> 被消费者监听

如果消息没有确认会变成unacked, 消费者断线又重新变成ready

public void sendMessageTest() {
    OrderReturnReasonEntity reasonEntity = new OrderReturnReasonEntity();
    reasonEntity.setId(1L);
    rabbitTemplate.convertAndSend("hello-java-exchange","hello2.java",
            reasonEntity,new CorrelationData(UUID.randomUUID().toString()));
}
// 如果队列没有会报错,注了 @RabbitListener(queues = {"hello-java-queue"})
// @RabbitHandler 可以用来重载不同的消息
public void revieveMessage(Message message,
                           OrderReturnReasonEntity content,
                           Channel channel) {
    // 拿到主体内容
    byte[] body = message.getBody();
    // 拿到的消息头属性信息
    MessageProperties messageProperties = message.getMessageProperties();
    System.out.println("接受到的消息...内容" + message + "===内容:" + content);
    // channel内按顺序自增的编号
    long deliveryTag = message.getMessageProperties().getDeliveryTag();
    System.out.println("deliveryTag===>" + deliveryTag);
    // 签收货物,非批量模式(第二个false)
    try {
        if (deliveryTag % 2 == 0) {
            // 收货
            channel.basicAck(deliveryTag, false);
        } else {
            // 退货,第三个参数是否重新入队,如果不退货(宕机),没有回复的消息会变成unacked,
            // 消费者断线又重新变成ready
            channel.basicNack(deliveryTag, false, false);
        }

    } catch (IOException e) {
        // 网络中断
    }

}

2.自定义配置

  • 注入rabbitTemplate
  • 消息使用JSON格式存储
  • 消息确认生产端2个,消费端1个确认机制
@Configuration
public class MyRabbitConfig {

    private RabbitTemplate rabbitTemplate;

    @Primary
    @Bean
    public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
        RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
        this.rabbitTemplate = rabbitTemplate;
        rabbitTemplate.setMessageConverter(messageConverter());
        initRabbitTemplate();
        return rabbitTemplate;
    }

    @Bean
    public MessageConverter messageConverter() {
        return new Jackson2JsonMessageConverter();
    }

    /**
     * 定制RabbitTemplate
     * 1、服务收到消息就会回调
     *      1、spring.rabbitmq.publisher-confirms: true
     *      2、设置确认回调
     * 2、消息正确抵达队列就会进行回调
     *      1、spring.rabbitmq.publisher-returns: true
     *         spring.rabbitmq.template.mandatory: true
     *      2、设置确认回调ReturnCallback
     *
     * 3、消费端确认(保证每个消息都被正确消费,此时才可以broker删除这个消息)
     *
     */
    //@PostConstruct  //MyRabbitConfig对象创建完成以后,执行这个方法
    public void initRabbitTemplate() {

        /**
         * 1、只要消息抵达Broker就ack=true
         * correlationData:当前消息的唯一关联数据(这个是消息的唯一id)
         * ack:消息是否成功收到
         * cause:失败的原因
         */
        //设置确认回调
        rabbitTemplate.setConfirmCallback((correlationData,ack,cause) -> {
            System.out.println("confirm...correlationData["+correlationData+"]==>ack:["+ack+"]==>cause:["+cause+"]");
        });


        /**
         * 只要消息没有投递给指定的队列,就触发这个失败回调
         * message:投递失败的消息详细信息
         * replyCode:回复的状态码
         * replyText:回复的文本内容
         * exchange:当时这个消息发给哪个交换机
         * routingKey:当时这个消息用哪个路邮键
         */
        rabbitTemplate.setReturnCallback((message,replyCode,replyText,exchange,routingKey) -> {
            System.out.println("Fail Message["+message+"]==>replyCode["+replyCode+"]" +
                    "==>replyText["+replyText+"]==>exchange["+exchange+"]==>routingKey["+routingKey+"]");
        });
    }
}
# 手动ack消息,不使用默认的消费端确认(默认收到就被移除)
# ack肯定确认,nack否定确认可以批量,reject否定确认不能批量
spring.rabbitmq.listener.simple.acknowledge-mode=manual

10.订单服务开发

1.订单服务主要表结构

oms_order:订单表

订单号,订单状态(0->待付款;1->待发货;2->已发货;3->已完成;4->已关闭;5->无效订单)

2.库存服务主要表结构

wms_ware_sku:商品库存表

skuid,仓库id,库存数,sku名称,锁定库存数

wms_ware_order_task:库存工作单表

订单号,收货人,付款方式,状态,物流单号,仓库id

wms_ware_order_task_detail:库存工作单详情表

skuid,sku名称,购买个数,工作单id,仓库id,锁定状态(1-已锁定 2-已解锁 3-扣减)

3.登录拦截器

@Component
public class LoginUserInterceptor implements HandlerInterceptor {

    public static ThreadLocal<MemberResponseVo> loginUser = new ThreadLocal<>();

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        // MQ调用直接放行
        String uri = request.getRequestURI();
        AntPathMatcher antPathMatcher = new AntPathMatcher();
        boolean match = antPathMatcher.match("/order/order/status/**", uri);
        boolean match1 = antPathMatcher.match("/payed/notify", uri);
        if (match || match1) {
            return true;
        }

        // 获取登录的用户信息
        MemberResponseVo attribute = (MemberResponseVo) request.getSession().getAttribute(LOGIN_USER);

        if (attribute != null) {
            // 把登录后用户的信息放在ThreadLocal里面进行保存
            loginUser.set(attribute);
            return true;
        } else {
            // 未登录,返回登录页面
            response.setContentType("text/html;charset=UTF-8");
            PrintWriter out = response.getWriter();
            out.println("<script>alert('请先进行登录,再进行后续操作!');location.href='http://auth.gulimall.com/login.html'</script>");
            return false;
        }
    }
}

4.订单确认页

开启两个异步任务查询收货地址,购物车中所有购物项 -> 每个商品剩余库存,用户积分,计算总价

/**
 * 去结算确认页
 */
@GetMapping(value = "/toTrade")
public String toTrade(Model model, HttpServletRequest request) throws ExecutionException, InterruptedException {

    OrderConfirmVo confirmVo = orderService.confirmOrder();

    model.addAttribute("confirmOrderData",confirmVo);
    //展示订单确认的数据

    return "confirm";
}

1.Feign远程调用丢失请求头

问题一:查询购物车信息时,由于请求头中SESSIONID丢失,会认为用户没有登录

在feign的调用过程中,会使用容器中的RequestInterceptorRequestTemplate进行处理,因此我们可以通过向容器中导入定制的RequestInterceptor为请求加上cookie

@Configuration
public class GuliFeignConfig {

    @Bean("requestInterceptor")
    public RequestInterceptor requestInterceptor() {

        RequestInterceptor requestInterceptor = new RequestInterceptor() {
            @Override
            public void apply(RequestTemplate template) {
                // 使用RequestContextHolder拿到刚进来的请求数据,RequestContextHolder为SpingMVC中共享request数据的上下文,底层由ThreadLocal实现
                ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();

                if (requestAttributes != null) {
                    // 老请求,即订单服务的请求
                    HttpServletRequest request = requestAttributes.getRequest();

                    if (request != null) {
                        // 同步请求头的数据(主要是cookie)
                        // 把老请求的cookie值放到新请求上来,进行一个同步
                        String cookie = request.getHeader("Cookie");
                        template.header("Cookie", cookie);
                    }
                }
            }
        };
        return requestInterceptor;
    }
}

问题二:异步模式查询购物车时,新开了一个线程,由于RequestContextHolder使用ThreadLocal共享数据,所以在开启异步时获取不到老请求的信息,自然也就无法共享cookie了

在这种情况下,我们需要在开启异步的时候将老请求的RequestContextHolder的数据设置进去,让上面拦截器requestAttributes.getRequest()的时候有值

// 获取当前线程请求头信息
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();

//开启异步任务
CompletableFuture<Void> cartInfoFuture = CompletableFuture.runAsync(() -> {

    // 每一个线程都来共享之前的请求数据
    RequestContextHolder.setRequestAttributes(requestAttributes);

    // 远程查询购物车所有选中的购物项
}, threadPoolExecutor).thenRunAsync(() -> {
    // 远程查询商品库存信息
   
},threadPoolExecutor);

5.提交订单

1.防止用户重复提交订单(接口幂等性)

展示订单确认页的时候,创建一个防重令牌

// 为用户设置一个token,三十分钟过期时间(存在redis)
String token = UUID.randomUUID().toString().replace("-", "");
redisTemplate.opsForValue().set(USER_ORDER_TOKEN_PREFIX+memberResponseVo.getId(),token,30, TimeUnit.MINUTES);
confirmVo.setOrderToken(token);

2.下单

@PostMapping(value = "/submitOrder")
public String submitOrder(OrderSubmitVo vo, Model model, RedirectAttributes attributes) {

    try {
        SubmitOrderResponseVo responseVo = orderService.submitOrder(vo);
        //下单成功来到支付选择页
        //下单失败回到订单确认页重新确定订单信息
        if (responseVo.getCode() == 0) {
            //成功
            model.addAttribute("submitOrderResp",responseVo);
            return "pay";
        } else {
            String msg = "下单失败";
            switch (responseVo.getCode()) {
                case 1: msg += "令牌订单信息过期,请刷新再次提交"; break;
                case 2: msg += "订单商品价格发生变化,请确认后再次提交"; break;
            }
            attributes.addFlashAttribute("msg",msg);
            return "redirect:http://order.gulimall.com/toTrade";
        }
    } catch (Exception e) {
        if (e instanceof NoStockException) {
            String message = ((NoStockException)e).getMessage();
            attributes.addFlashAttribute("msg",message);
        }
        return "redirect:http://order.gulimall.com/toTrade";
    }
}

验证令牌,创建订单,验证价格,保存订单,锁定库存

@Transactional(rollbackFor = Exception.class)
@Override
public SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {

    confirmVoThreadLocal.set(vo);

    SubmitOrderResponseVo responseVo = new SubmitOrderResponseVo();

    //获取当前用户登录的信息
    MemberResponseVo memberResponseVo = LoginUserInterceptor.loginUser.get();
    responseVo.setCode(0);

    //1、验证令牌是否合法(令牌的对比和删除必须保证原子性)
    String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
    String orderToken = vo.getOrderToken();

    //通过lura脚本原子验证令牌和删除令牌
    Long result = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
            Arrays.asList(USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId()),
            orderToken);

    if (result == 0L) {
        //令牌验证失败
        responseVo.setCode(1);
        return responseVo;
    } else {
        //令牌验证成功
        //1、创建订单、订单项等信息
        OrderCreateTo order = createOrder();

        //2、验证价格
        BigDecimal payAmount = order.getOrder().getPayAmount();
        BigDecimal payPrice = vo.getPayPrice();

        if (Math.abs(payAmount.subtract(payPrice).doubleValue()) < 0.01) {
            //3、保存订单
            saveOrder(order);

            //4、库存锁定,只要有异常,回滚订单数据
            //订单号、所有订单项信息(skuId,skuNum,skuName)
            WareSkuLockVo lockVo = new WareSkuLockVo();
            lockVo.setOrderSn(order.getOrder().getOrderSn());

            //获取出要锁定的商品数据信息
            List<OrderItemVo> orderItemVos = order.getOrderItems().stream().map((item) -> {
                OrderItemVo orderItemVo = new OrderItemVo();
                orderItemVo.setSkuId(item.getSkuId());
                orderItemVo.setCount(item.getSkuQuantity());
                orderItemVo.setTitle(item.getSkuName());
                return orderItemVo;
            }).collect(Collectors.toList());
            lockVo.setLocks(orderItemVos);

            // 调用远程锁定库存的方法
            // 出现的问题:扣减库存成功了,但是由于网络原因超时,出现异常,导致订单事务回滚,库存事务不回滚(解决方案:seata)
            // 为了保证高并发,不推荐使用seata,因为是加锁,并行化,提升不了效率,可以发消息给库存服务
            R r = wmsFeignService.orderLockStock(lockVo);
            if (r.getCode() == 0) {
                // 锁定成功
                responseVo.setOrder(order.getOrder());
                // int i = 10/0; 这里出异常库存不会回滚

                // 订单创建成功,发送消息给MQ
                rabbitTemplate.convertAndSend("order-event-exchange","order.create.order",order.getOrder());

                //删除购物车里的数据
                redisTemplate.delete(CART_PREFIX+memberResponseVo.getId());
                return responseVo;
            } else {
                //锁定失败
                String msg = (String) r.get("msg");
                throw new NoStockException(msg);
            }

        } else {
            responseVo.setCode(2);
            return responseVo;
        }
    }
}

3.锁定库存

构造对象:哪件商品,需要锁几件,在哪个仓库有库存

每个仓库尝试锁定,库存不够就尝试下一个仓库

**所有商品锁定成功:**因为订单服务有可能回滚,因此如果所有商品锁定成功,需要将当前商品锁定了几件的工作单记录发给MQ,消息进入延迟队列2分钟过期

**锁定失败:**抛出异常回滚

<!-- 锁定库存sql -->
<update id="lockSkuStock">
    UPDATE wms_ware_sku
    SET stock_locked = stock_locked + #{num}
    WHERE
        sku_id = #{skuId}
      AND ware_id = #{wareId}
      AND stock - stock_locked >= #{num}
</update>
@Transactional(rollbackFor = Exception.class)
@Override
public boolean orderLockStock(WareSkuLockVo vo) {

    // 保存库存工作单详情信息,为了追溯
    WareOrderTaskEntity wareOrderTaskEntity = new WareOrderTaskEntity();
    wareOrderTaskEntity.setOrderSn(vo.getOrderSn());
    wareOrderTaskEntity.setCreateTime(new Date());
    wareOrderTaskService.save(wareOrderTaskEntity);

    //1、找到每个商品在哪个仓库都有库存
    // 构造库存锁定类
    List<OrderItemVo> locks = vo.getLocks();
    List<SkuWareHasStock> collect = locks.stream().map((item) -> {
        SkuWareHasStock stock = new SkuWareHasStock();
        Long skuId = item.getSkuId();
        // 商品Id
        stock.setSkuId(skuId);
        // 锁几件
        stock.setNum(item.getCount());
        // 查询这个商品在哪个仓库有库存
        List<Long> wareIdList = wareSkuDao.listWareIdHasSkuStock(skuId);
        stock.setWareId(wareIdList);
        return stock;
    }).collect(Collectors.toList());

    //2、锁定库存
    for (SkuWareHasStock hasStock : collect) {
        // false代表没锁住
        boolean skuStocked = false;
        Long skuId = hasStock.getSkuId();
        List<Long> wareIds = hasStock.getWareId();

        if (StringUtils.isEmpty(wareIds)) {
            //没有任何仓库有这个商品的库存
            throw new NoStockException(skuId);
        }

        // 遍历有库存的仓库
        //1、如果每一个商品都锁定成功,将当前商品锁定了几件的工作单记录发给MQ
        //2、锁定失败。前面保存的工作单信息都回滚了。发送出去的消息,即使要解锁库存,由于在数据库查不到指定的id,所有就不用解锁
        for (Long wareId : wareIds) {
            //锁定成功就返回1,失败就返回0
            Long count = wareSkuDao.lockSkuStock(skuId,wareId,hasStock.getNum());
            if (count == 1) {
                skuStocked = true;
                // 告诉MQ库存锁定成功
                WareOrderTaskDetailEntity taskDetailEntity = WareOrderTaskDetailEntity.builder()
                        .skuId(skuId)
                        .skuName("")
                        .skuNum(hasStock.getNum())
                        .taskId(wareOrderTaskEntity.getId())
                        .wareId(wareId)
                        .lockStatus(1)
                        .build();
                wareOrderTaskDetailService.save(taskDetailEntity);
                StockLockedTo lockedTo = new StockLockedTo();
                lockedTo.setId(wareOrderTaskEntity.getId());
                StockDetailTo detailTo = new StockDetailTo();
                BeanUtils.copyProperties(taskDetailEntity,detailTo);
                lockedTo.setDetailTo(detailTo);
                rabbitTemplate.convertAndSend("stock-event-exchange","stock.locked",lockedTo);
                break;
            } else {
                //当前仓库锁失败,重试下一个仓库
            }
        }
        if (skuStocked == false) {
            //当前商品所有仓库都没有锁住
            throw new NoStockException(skuId);
        }
    }
    //3、能走到这肯定全部都是锁定成功的
    return true;
}
@Data
class SkuWareHasStock {
    private Long skuId;
    private Integer num;
    private List<Long> wareId;
}

6.分布式事务

如果库存锁定成功,返回订单服务,接下来的代码出错,库存服务无法回滚

利用消息队列实现最终一致性:

库存锁定成功给MQ发消息(库存工作单id + 库存工作单详情),过段时间判断订单状态来实现解锁 /不解锁

柔性事务满足BASE理论(基本可用,软状态,最终一致性)
刚性事务满足ACID理论(一致性,可用性,分区容错性只能实现两个)

1.刚性事务-2PC模式

数据库支持的 2PC【2 phase commit 二阶提交】,又叫做 XA Transactions。

  • 第一阶段:事务协调器要求每个涉及到事务的数据库预提交(precommit)此操作,并反映是否可以提交
  • 第二阶段:事务协调器要求每个数据库提交数据

如果有任何一个数据库否决此次提交,所有数据库回滚

缺点是锁定资源时间长,性能不理想

2.柔性事务-TCC事务补偿型方案

  • 一阶段 prepare 行为:调用 自定义 的 prepare 逻辑。
  • 二阶段 commit 行为:调用 自定义 的 commit 逻辑。
  • 二阶段 rollback 行为:调用 自定义 的 rollback 逻辑。
    TCC的核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作
    从正常的流程上讲,TCC仍然是一个两阶段提交协议。但是,在执行出现问题的时候,有一定的自我修复能力,如果任何一个事务参与者出现了问题,协调者可以通过执行逆操作来取消之前的操作,达到最终的一致状态

3.柔性事务-最大努力通知型方案

  • 按规律进行通知,不保证数据一定能通知成功,但会提供可查询操作接口进行核对。
  • 这种方案主要用在与第三方系统通讯时,比如:调用微信或支付宝支付后的支付结果通知。
  • 这种方案也是结合 MQ 进行实现,例如:通过MQ发送 http 请求,设置最大通知次数。达到通知次数后即不再通知。

4.柔性事务-基于消息中间件(本项目使用)

  • 一定要确保消息不丢失

5.Seata分布式事务框架

详细可以看文档

使用步骤

1.每一个微服务先必须创建undo_logo

2.安装事务协调器seata-server

3.导入依赖spring-cloud-starter-alibaba-seata seata-all-1.0.0.jar

4.解压并启动seata-server,将这两个文件导入每个微服务

registry.conf注册中心相关的配置,修改registry type=nacos

file.conf修改vgroup_mapping.gulimall-order-fescar-service-group = “default”

5.所有想要用到分布式事务的微服务使用seata DatasourceProxy代理自己的数据源

@Configuration
public class MySeataConfig {

    @Autowired
    DataSourceProperties dataSourceProperties;

    /**
     * 需要将 DataSourceProxy 设置为主数据源,否则事务无法回滚
     */
    @Bean
    public DataSource dataSource(DataSourceProperties dataSourceProperties) {

        HikariDataSource dataSource = dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
        if (StringUtils.hasText(dataSourceProperties.getName())) {
            dataSource.setPoolName(dataSourceProperties.getName());
        }
        return new DataSourceProxy(dataSource);
    }
}

6、给分布式大事务的路口标注@GlobalTransactional,每一个远程的小事务用 @Transactional

7.RabbitMQ延时队列

场景:

比如未付款订单,超过一定时间后,系统自动取消订单并解锁库存。

常用解决方案:spring的 schedule 定时任务轮询数据库,缺点是消耗系统内存、增加了数据库的压力、存在较大的时间误差

解决:rabbitmq的消息TTL和死信交换机结合

消息TTL就是设置消息的存活时间

一个消息被Consumer拒收了,并且reject方法的参数里requeue是false也就是说不
会被再次放在队列里,消息的TTL到了,队列的长度限制满了这三种情况会让一个消息投递到死信交换机

1.订单服务交换机,绑定关系,队列

订单服务的消息都需要经过这个交换机

@Bean
public Exchange orderEventExchange() {
   
    return new TopicExchange("order-event-exchange", true, false);

}

这个交换机对应三个绑定关系,第一个使用order.create.order路由键绑定order.delay.queue,即创建完订单的时候进入延迟队列,设置消息1分钟过期,过期之后发回给交换机order-event-exchange,路由键order.release.order进入普通队列

@Bean
public Binding orderCreateBinding() {
    /*
     * String destination, 目的地(队列名或者交换机名字)
     * DestinationType destinationType, 目的地类型(Queue、Exhcange)
     * String exchange,交换机
     * String routingKey,路由键
     * Map<String, Object> arguments,属性
     * */
    return new Binding("order.delay.queue",
            Binding.DestinationType.QUEUE,
            "order-event-exchange",
            "order.create.order",
            null);
}
@Bean
public Queue orderDelayQueue() {
    /*
        Queue(String name,  队列名字
        boolean durable,  是否持久化
        boolean exclusive,  是否排他
        boolean autoDelete, 是否自动删除
        Map<String, Object> arguments) 属性
     */
    HashMap<String, Object> arguments = new HashMap<>();
    arguments.put("x-dead-letter-exchange", "order-event-exchange");
    arguments.put("x-dead-letter-routing-key", "order.release.order");
    arguments.put("x-message-ttl", 60000); // 消息过期时间 1分钟
    Queue queue = new Queue("order.delay.queue", true, false, false, arguments);

    return queue;
}

第二个使用order.release.order路由键绑定order.release.order.queue,就是上面延时队列中设置的,延迟队列中消息过期了就会进入这里

@Bean
public Binding orderReleaseBinding() {

    return new Binding("order.release.order.queue",
            Binding.DestinationType.QUEUE,
            "order-event-exchange",
            "order.release.order",
            null);
}
/**
 * 普通队列
 */
@Bean
public Queue orderReleaseQueue() {

    return new Queue("order.release.order.queue", true, false, false);
}

第三个使用order.release.other.#路由键绑定stock.release.stock.queue

/**
 * 订单释放直接和库存释放进行绑定
 */
@Bean
public Binding orderReleaseOtherBinding() {

    return new Binding("stock.release.stock.queue",
            Binding.DestinationType.QUEUE,
            "order-event-exchange",
            "order.release.other.#",
            null);
}

2.库存服务交换机,绑定关系,队列

库存服务的交换机

@Bean
public Exchange stockEventExchange() {

    TopicExchange topicExchange = new TopicExchange("stock-event-exchange", true, false);
    return topicExchange;
}

这个交换机对应两个绑定关系,第一个通过stock.locked路由键绑定延迟队列stock.delay.queue,这个队列中的消息2分钟过期,过期之后发回给库存交换机,路由键stock.release进入普通队列

@Bean
public Binding stockLockedBinding() {
    return new Binding("stock.delay.queue",
            Binding.DestinationType.QUEUE,
            "stock-event-exchange",
            "stock.locked",
            null);
}
@Bean
public Queue stockDelay() {

    HashMap<String, Object> arguments = new HashMap<>();
    arguments.put("x-dead-letter-exchange", "stock-event-exchange");
    arguments.put("x-dead-letter-routing-key", "stock.release");
    // 消息过期时间 2分钟
    arguments.put("x-message-ttl", 120000);

    Queue queue = new Queue("stock.delay.queue", true, false, false,arguments);
    return queue;
}

第二个通过stock.release.#路由键绑定stock.release.stock.queue,就是上面延时队列中设置的,延时队列消息过期后就会进入这里

@Bean
public Binding stockLocked() {
    //String destination, DestinationType destinationType, String exchange, String routingKey,
    // 			Map<String, Object> arguments
    Binding binding = new Binding("stock.release.stock.queue",
                                  Binding.DestinationType.QUEUE,
                                  "stock-event-exchange",
                                  "stock.release.#",
                                  null);

    return binding;
}
@Bean
public Queue stockReleaseStockQueue() {
    Queue queue = new Queue("stock.release.stock.queue", true, false, false);
    return queue;
}

8.定时关单

订单创建完成发送消息,1分钟过期进入order.release.order.queue,监听这个队列

如果用户1分钟未付款,自动取消订单,需要和库存解锁结合

@RabbitListener(queues = "order.release.order.queue")
@Service
public class OrderCloseListener {

    @Autowired
    private OrderService orderService;

    @RabbitHandler
    public void listener(OrderEntity orderEntity, Channel channel, Message message) throws IOException {
        System.out.println("收到过期的订单信息,准备关闭订单" + orderEntity.getOrderSn());
        try {
            orderService.closeOrder(orderEntity);
            channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
        } catch (Exception e) {
            channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
        }

    }
}
/**
 * 关闭订单
 * 下单会给订单取消和库存解锁发MQ延时消息,分别是1分钟和2分钟,如果下单的时候订单消息延迟,
 * 库存先解锁,此时查订单状态还是新建,就不解锁了,此时订单消息才发到,取消订单后库存没法释放
 * 所以等订单消息发到,取消订单的时候再通知一次解锁库存,直接发库存队列(不延时)
 */
@Override
public void closeOrder(OrderEntity orderEntity) {

    //关闭订单之前先查询一下数据库,判断此订单状态是否已支付
    OrderEntity orderInfo = this.getOne(new QueryWrapper<OrderEntity>().
            eq("order_sn",orderEntity.getOrderSn()));

    if ((orderInfo != null) && orderInfo.getStatus().equals(OrderStatusEnum.CREATE_NEW.getCode())) {
        //待付款状态进行关单
        OrderEntity orderUpdate = new OrderEntity();
        orderUpdate.setId(orderInfo.getId());
        orderUpdate.setStatus(OrderStatusEnum.CANCLED.getCode());
        this.updateById(orderUpdate);

        // 发送消息给MQ,发给订单交换机,绑定的是库存解锁队列,订单关闭解锁
        OrderTo orderTo = new OrderTo();
        BeanUtils.copyProperties(orderInfo, orderTo);

        try {
            //TODO 确保每个消息发送成功,给每个消息做好日志记录,(给数据库保存每一个详细信息)保存每个消息的详细信息
            rabbitTemplate.convertAndSend("order-event-exchange", "order.release.other", orderTo);
        } catch (Exception e) {
            //TODO 定期扫描数据库,重新发送失败的消息
        }
    }
}

9.解锁库存

库存解锁分为库存服务解锁+订单服务解锁

库存服务的消息在锁定所有商品成功后发送,延时2分钟进入队列,为了实现分布式事务+订单取消解锁库存,但是出现延迟问题时库存永远不会解锁

订单服务的消息在关闭订单后进入队列,是为了延迟发生时保证库存一定能解锁成功(如果只有这个,不能实现分布式事务)

收到的消息StockLockedTo包含:工作单id(为了查出订单信息)和对应工作单详情的全部数据(哪几件商品在哪几个仓库锁定了几件)

/**
 * 库存被动解锁+订单主动解锁
 */
@Slf4j
@RabbitListener(queues = "stock.release.stock.queue")
@Service
public class StockReleaseListener {

    @Autowired
    private WareSkuService wareSkuService;

    /**
     * 库存自动解锁
     * 只要解锁库存的消息失败,一定要告诉服务解锁失败
     */
    @RabbitHandler
    public void handleStockLockedRelease(StockLockedTo to, Message message, Channel channel) throws IOException {
        try {
            // 解锁库存
            wareSkuService.unlockStock(to);
            // 手动删除消息
            channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
        } catch (Exception e) {
            // 解锁失败 将消息重新放回队列,让别人消费
            channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
        }
    }

    /**
     * 下单会给订单取消和库存解锁发MQ延时消息,分别是1分钟和2分钟,如果订单消息延迟,
     * 库存先解锁,此时查订单状态还是新建,就不解锁了,此时订单消息才发到,取消订单后库存没法释放
     * 所以订单消息发到,取消订单的时候再通知一次解锁库存,直接发解锁库存队列(不延时)
     */
    @RabbitHandler
    public void handleOrderCloseRelease(OrderTo orderTo, Message message, Channel channel) throws IOException {
        try {
            wareSkuService.unlockStock(orderTo);
            // 手动删除消息
            channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
        } catch (Exception e) {
            // 解锁失败 将消息重新放回队列,让别人消费
            channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
        }
    }
}
<!-- 解锁库存sql -->
<update id="unLockStock">
    UPDATE wms_ware_sku
    SET stock_locked = stock_locked - #{num}
    WHERE
        sku_id = ${skuId}
      AND ware_id = #{wareId}
</update>

库存服务解锁:

所有商品锁定成功,发送消息,两分钟过期发到这个队列里

1.下订单成功,库存锁定成功,接下来的业务调用失败,导致订单回滚。之前锁定的库存就要解锁

2.订单主动取消/未支付导致取消,也需要解锁

3.如果查不到对应的工作单,说明锁定失败被回滚了,自然无需解锁

4.如果订单状态为已支付,也不用解锁

@Override
public void unlockStock(StockLockedTo to) {
    //库存工作单的id
    StockDetailTo detail = to.getDetailTo();
    Long detailId = detail.getId();

    /**
     * 解锁
     * 1、查询数据库关于这个订单锁定库存信息
     *   有:证明库存锁定成功了
     *      解锁:订单状况
     *          1、没有这个订单,说明被回滚了,必须解锁库存
     *          2、有这个订单,不一定解锁库存
     *              订单状态:已取消:解锁库存
     *                      已支付:不能解锁库存
     */
    WareOrderTaskDetailEntity taskDetailInfo = wareOrderTaskDetailService.getById(detailId);
    if (taskDetailInfo != null) {
        //查出wms_ware_order_task工作单的信息
        Long id = to.getId();
        WareOrderTaskEntity orderTaskInfo = wareOrderTaskService.getById(id);
        //获取订单号查询订单状态
        String orderSn = orderTaskInfo.getOrderSn();
        //远程查询订单信息
        R orderData = orderFeignService.getOrderStatus(orderSn);
        if (orderData.getCode() == 0) {
            //订单数据返回成功
            OrderVo orderInfo = orderData.getData("data", new TypeReference<OrderVo>() {});
            //判断订单状态是否已取消或者订单不存在,解锁库存,消息可以确认
            if (orderInfo == null || orderInfo.getStatus() == 4) {
                if (taskDetailInfo.getLockStatus() == 1) {
                    //当前库存工作单详情状态1,已锁定才可以解锁,保证幂等,防止库存解锁后ack时发生宕机
                    //解锁:加回数量并将工作单详情状态变为已经解锁
                    unLockStock(detail.getSkuId(),detail.getWareId(),detail.getSkuNum(),detailId);
                }
            }
        } else {
            // 远程调用失败
            // 消息拒绝以后重新放在队列里面,让别人继续消费解锁,抛出异常代表解锁失败
            throw new RuntimeException("远程调用服务失败");
        }
    } else {
        //没有工作单,无需解锁,消息可以确认
    }
}

订单服务解锁:

定时关单完成,消息发送到这个队列

/**
  * 下单会给订单取消和库存解锁发MQ延时消息,分别是1分钟和2分钟,如果订单消息延迟,
  * 库存先解锁,此时查订单状态还是新建,就不解锁了,此时订单消息才发到,取消订单后库存没法释放
  * 所以订单消息发到,取消订单的时候再通知一次解锁库存,直接发库存队列(不延时)
  */
@Transactional(rollbackFor = Exception.class)
@Override
public void unlockStock(OrderTo orderTo) {

    String orderSn = orderTo.getOrderSn();
    //查一下最新的库存解锁状态,防止重复解锁库存
    WareOrderTaskEntity orderTaskEntity = wareOrderTaskService.getOrderTaskByOrderSn(orderSn);

    //按照工作单的id找到所有没有解锁的库存,进行解锁
    Long id = orderTaskEntity.getId();
    List<WareOrderTaskDetailEntity> list = wareOrderTaskDetailService.list(new QueryWrapper<WareOrderTaskDetailEntity>().eq("task_id", id).eq("lock_status", 1));

    for (WareOrderTaskDetailEntity taskDetailEntity : list) {
        unLockStock(taskDetailEntity.getSkuId(),
                    taskDetailEntity.getWareId(),
                    taskDetailEntity.getSkuNum(),
                    taskDetailEntity.getId());
    }

}

10.消息可靠性解决方案

消息丢失

1、做好消息确认机制(pulisher开启confirms和return确认机制,consumer手动ack)

2、开启RabbitMQ的持久化,创建queue的时候将其设置为持久化,发送消息的时候将消息的deliveryMode设置为 2

3、每一个发送的消息都在数据库做好记录。定期将失败的消息再次发送一 遍

消息重复

1.接口应该设计成幂等性的,比如扣减库存要判断工作单的状态标志

2.rabbitmq的每个消息都有redelivered字段,可以判断当前消息是否被第二次及以后派发过来了

Boolean redelivered = message.getMessageProperties().getRedelivered();

消息积压

1.上线更多的消费者

2.上线专门的队列消费服务,将消息批量取出记录数据库,后续慢慢处理

11.订单支付

  • 支付宝加密采用RSA非对称加密,分别在商户端和支付宝端有两对公钥和私钥
  • 在发送订单数据时,直接使用明文,但会使用商户私钥加一个对应的签名,支付宝端会使用商户公钥对签名进行验签
  • 支付成功后,支付宝发送支付成功数据之外,还会使用支付宝私钥加一个对应的签名,商户端收到支付成功数据之后也会使用支付宝公钥验签
/**
 * 用户点击去支付宝支付
 * 1、返回支付页让浏览器展示
 * 2、支付成功以后,跳转到用户的订单列表页
 */
@ResponseBody
@GetMapping(value = "/aliPayOrder",produces = "text/html")
public String aliPayOrder(@RequestParam("orderSn") String orderSn) throws AlipayApiException {

    PayVo payVo = orderService.getOrderPay(orderSn);
    // 返回的是支付页,直接交给浏览器
    String pay = alipayTemplate.pay(payVo);
    System.out.println(pay);
    return pay;
}

支付成功后,如果使用同步通知模式(支付宝回调商户接口的时候修改订单状态),有可能因为网路原因修改失败

因此使用支付宝异步通知模式,隔一段时间会通知商户支付成功,返回success支付宝便不再通知

/**
 * 支付宝异步通知接口
 */
@PostMapping(value = "/payed/notify")
public String handleAlipayed(PayAsyncVo asyncVo, HttpServletRequest request) throws AlipayApiException, UnsupportedEncodingException {
    // 拼接参数,验签
    Map<String, String> params = new HashMap<>();
    Map<String, String[]> requestParams = request.getParameterMap();
    for (String name : requestParams.keySet()) {
        String[] values = requestParams.get(name);
        String valueStr = "";
        for (int i = 0; i < values.length; i++) {
            valueStr = (i == values.length - 1) ? valueStr + values[i]
                    : valueStr + values[i] + ",";
        }
        params.put(name, valueStr);
    }

    boolean signVerified = AlipaySignature.rsaCheckV1(params, alipayTemplate.getAlipay_public_key(),
            alipayTemplate.getCharset(), alipayTemplate.getSign_type()); //调用SDK验证签名

    if (signVerified) {
        System.out.println("签名验证成功...");
        //去修改订单状态
        String result = orderService.handlePayResult(asyncVo);
        return result;
    } else {
        System.out.println("签名验证失败...");
        return "error";
    }
}

12.秒杀

1.秒杀服务主要表结构

sms_seckill_promotion:秒杀活动表

活动标题,活动名称,开始时间,结束时间,启用状态

这张表没有用到,可以不看

sms_seckill_session:每日秒杀活动表

活动标题,场次名称,每日开始时间,每日结束时间,启用状态

在这个项目中每日秒杀活动表被当成秒杀活动表用了

sms_seckill_sku_relation:秒杀活动商品关联表

活动场次id,每日活动场次id,skuid,秒杀价格,秒杀总量,每人限购数量,

本项目中,只关联了每日活动场次id

2.秒杀架构思路

  • 项目独立部署,独立秒杀模块gulimall-seckill
  • 库存预热,使用定时任务每天三点上架最新秒杀商品,削减高峰期压力
  • 秒杀链接加密,为秒杀商品添加唯一商品随机码,在开始秒杀时才暴露接口,防止恶意攻击
  • 快速扣减+控制流量,先从数据库中扣除一部分库存以redisson信号量的形式存储在redis中
  • 队列削峰,秒杀成功后立即返回,然后以发送消息的形式创建订单

3.定时提前缓存秒杀活动

配置类开启异步定时任务

@EnableAsync
@EnableScheduling
@Configuration
public class ScheduledConfig {
}

配置异步任务线程池

spring:
  task:
    execution:
      pool:
        core-size: 5
        max-size: 50

每天凌晨三点自动上架最近三天的秒杀活动和商品,由于在分布式情况下该方法可能同时被调用多次,因此加入分布式锁,同时只有一个服务可以调用该方法

@Scheduled(cron = "0 0 3 * * ? ")
public void uploadSeckillSkuLatest3Days() {
    RLock lock = redissonClient.getLock(upload_lock);
    try {
        // 加锁,10秒释放
        lock.lock(10, TimeUnit.SECONDS);
        seckillService.uploadSeckillSkuLatest3Days();
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        lock.unlock();
    }
}

redis中秒杀活动场次缓存结构:

key: seckill:sessions:开始时间_结束时间
value: 场地id_商品skuId(list结构)

redis中秒杀商品缓存结构:

key: seckill:skus
value: key:场地id_商品skuId value:sku所有详细信息+商品秒杀表信息+秒杀开始结束时间+随机码JSON(hash结构)

随机码在秒杀活动开始后才可以返回给接口

redisson信号量限流:

// 每个商品将可以秒杀的数量以redisson信号量的形式存储在redis中
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + token);
semaphore.trySetPermits(seckillSkuVo.getSeckillCount());
redis中缓存结构
key: seckill:stock:随机码
value: 库存数(string)

4.首页获取当前可秒杀商品

/getCurrentSeckillSkus

从Redis中查询到所有key以seckill:sessions开头的所有数据

Set<String> keys = redisTemplate.keys(SESSION_CACHE_PREFIX + "*");

遍历这些秒杀场次,如果在当前时间内,获取这个秒杀场次关联的所有商品信息(场地id-skuid)

if (currentTime >= startTime && currentTime <= endTime) {
	List<String> range = redisTemplate.opsForList().range(key, -100, 100);
}

在seckill:skus开头的缓存中,依次根据场次id-skuid这个key获取value

BoundHashOperations<String, String, String> hasOps = redisTemplate.boundHashOps(SECKILL_CHARE_PREFIX);
List<String> listValue = hasOps.multiGet(range);

5.商品详情页展示秒杀信息

/sku/seckill/{skuId}
/**
 * 根据skuId查询商品是否参加秒杀活动
 */
@Override
public SeckillSkuRedisTo getSkuSeckilInfo(Long skuId) {

    // 找到所有需要秒杀的商品的key信息,前缀seckill:skus
    BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SECKILL_CHARE_PREFIX);

    // 拿到所有的key,场次id-skuid
    Set<String> keys = hashOps.keys();
    if (keys != null && keys.size() > 0) {
        // 正则表达式进行匹配
        String reg = "\\d-" + skuId;
        for (String key : keys) {
            // 如果匹配上了
            if (Pattern.matches(reg,key)) {
                // 从Redis中取出数据来
                String redisValue = hashOps.get(key);
                // 进行序列化
                SeckillSkuRedisTo redisTo = JSON.parseObject(redisValue, SeckillSkuRedisTo.class);
                Long currentTime = System.currentTimeMillis();
                Long startTime = redisTo.getStartTime();
                Long endTime = redisTo.getEndTime();
                // 如果当前时间大于等于秒杀活动开始时间并且要小于活动结束时间
                if (currentTime >= startTime && currentTime <= endTime) {
                    return redisTo;
                }
                // 随机码不能提前暴露
                redisTo.setRandomCode(null);
                return redisTo;
            }
        }
    }
    return null;
}

全部代码见展示商品详情部分

CompletableFuture<Void> seckillFuture = CompletableFuture.runAsync(() -> {
	// 远程调用查询当前sku是否参与秒杀优惠活动
	R skuSeckilInfo = seckillFeignService.getSkuSeckilInfo(skuId);
	if (skuSeckilInfo.getCode() == 0) {
	// 查询成功
	SeckillSkuVo seckilInfoData = skuSeckilInfo.getData("data", new TypeReference<SeckillSkuVo>() {
	});
	skuItemVo.setSeckillSkuVo(seckilInfoData);
}}, executor);

6.秒杀下单

1./kill请求配置登录拦截器

拦截器配置都类似,完整参考购物车拦截器配置

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

    String uri = request.getRequestURI();
    AntPathMatcher antPathMatcher = new AntPathMatcher();
    boolean match = antPathMatcher.match("/kill", uri);

    if (match) {
        HttpSession session = request.getSession();
        // 获取登录的用户信息
        MemberResponseVo attribute = (MemberResponseVo) session.getAttribute(LOGIN_USER);
        if (attribute != null) {
            // 把登录后用户的信息放在ThreadLocal里面进行保存
            loginUser.set(attribute);
            return true;
        } else {
            // 未登录,返回登录页面
            response.setContentType("text/html;charset=UTF-8");
            PrintWriter out = response.getWriter();
            out.println("<script>alert('请先进行登录,再进行后续操作!');location.href='http://auth.gulimall.com/login.html'</script>");
            return false;
        }
    }
    return true;
}

2.点击按钮开始秒杀

  • 点击立即抢购时,会发送请求
  • 秒杀请求会对请求校验时效、商品随机码、当前用户是否已经抢购过当前商品、库存和购买量,通过校验的则秒杀成功,发送消息创建订单

返回页面,根据有无订单号判断是否成功

<div th:if="${orderSn != null}" class="mc success-cont">
    <h1>恭喜秒杀成功 订单号 [[${orderSn}]]</h1>
    <h2>正在准备订单数据 10秒钟后自动跳转支付
        <a style="color: red" th:href="${'http://order.gulimall.com/aliPayOrder?orderSn=' + orderSn}">点击支付</a>
    </h2>
</div>
<div th:if="${orderSn == null}">
    <h1>手气不好 秒杀失败 下次再来</h1>
</div>

秒杀请求

@GetMapping(value = "/kill")
public String seckill(@RequestParam("killId") String killId,
                      @RequestParam("key") String key,
                      @RequestParam("num") Integer num,
                      Model model) {
    String orderSn = null;
    try {
        orderSn = seckillService.kill(killId,key,num);
        model.addAttribute("orderSn",orderSn);
    } catch (Exception e) {
        e.printStackTrace();
    }
    return "success";
}

验证活动时效,随机码,商品id,数量后,请求合法,根据userid-skuid在redis中占位,占位成功才可以获取信号量,占位失败说明之前已经秒杀过了

String redisKey = user.getId() + "-" + skuId;
// 设置自动过期(活动结束时间-当前时间)
Long ttl = endTime - currentTime;
Boolean aBoolean = redisTemplate.opsForValue().setIfAbsent(redisKey, num.toString(), ttl, TimeUnit.MILLISECONDS);
if (aBoolean) {
    // 占位成功说明从来没有买过,分布式锁(获取信号量-1)
    RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + randomCode);
    boolean semaphoreCount = semaphore.tryAcquire(num);
    // 获取信号量成功即为秒杀成功
    if (semaphoreCount) {
        // 创建订单号和订单信息发送给MQ,整个操作时间在10ms左右
        String timeId = IdWorker.getTimeId();
        SeckillOrderTo orderTo = new SeckillOrderTo();
        orderTo.setOrderSn(timeId);
        orderTo.setMemberId(user.getId());
        orderTo.setNum(num);
        orderTo.setPromotionSessionId(redisTo.getPromotionSessionId());
        orderTo.setSkuId(redisTo.getSkuId());
        orderTo.setSeckillPrice(redisTo.getSeckillPrice());
        // 主启动类不用@EnableRabbit, 因为我们只用来发送消息,不接收消息
        rabbitTemplate.convertAndSend("order-event-exchange","order.seckill.order",orderTo);
        // 返回订单号
        return timeId;
    }
}

3.订单服务监听消息创建秒杀单

秒杀队列和绑定关系

@Bean
public Queue orderSecKillOrrderQueue() {
    Queue queue = new Queue("order.seckill.order.queue", true, false, false);
    return queue;
}

@Bean
public Binding orderSecKillOrrderQueueBinding() {
    Binding binding = new Binding(
            "order.seckill.order.queue",
            Binding.DestinationType.QUEUE,
            "order-event-exchange",
            "order.seckill.order",
            null);
    return binding;
}

订单服务监听秒杀队列的消息,收到消息只需要根据订单号和订单信息创建订单

@Slf4j
@Component
@RabbitListener(queues = "order.seckill.order.queue")
public class OrderSeckillListener {

    @Autowired
    private OrderService orderService;

    @RabbitHandler
    public void listener(SeckillOrderTo orderTo, Channel channel, Message message) throws IOException {
        
        try {
            orderService.createSeckillOrder(orderTo);
            channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
        } catch (Exception e) {
            channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
        }

    }

}

13.Sentinel

用分布式锁信号量只能控制单个商品的流量,如果很多商品同时秒杀也会产生大流量

整合

  • 导入依赖spring-cloud-starter-alibaba-sentinel和spring-boot-starter-actuator
  • 下载sentinel控制台
  • 配置sentienl
spring:
    sentinel:
      transport:
        #配置sentinel dashboard地址
        dashboard: localhost:8080
        #默认8719端口,假如被占用会自动从8719开始依次+1扫描,直至找到未被占用的端口
        port: 8719
#暴露所有端点,让控制台上有图形化的展示
management:
  endpoints:
    web:
      exposure:
        include: '*'

1.限流

  • 在控制台配置

监控应用流量的QPS或并发线程数,当达到指定的阈值时对流量进行控制

  • 自定义URL被限流后的返回方法,每个自定义资源返回方法的定义方式见第3节
@Configuration
public class GulimallSeckillSentinelConfig {

    public GulimallSeckillSentinelConfig() {

        WebCallbackManager.setUrlBlockHandler(new UrlBlockHandler() {
            @Override
            public void blocked(HttpServletRequest request, HttpServletResponse response, BlockException ex) throws IOException {
                R error = R.error(BizCodeEnum.TO_MANY_REQUEST.getCode(), BizCodeEnum.TO_MANY_REQUEST.getMessage());
                response.setCharacterEncoding("UTF-8");
                response.setContentType("application/json");
                response.getWriter().write(JSON.toJSONString(error));

            }
        });

    }

}

2.降级(熔断由feign提供)

开启feign对sentinel的支持后,在控制台上就可以看到所有feign的远程调用接口

feign:
  sentinel:
    enabled: true

调用远程服务熔断保护:

  • 如果远程服务宕机,触发熔断回调方法,如商品服务远程调用秒杀服务,这个回调是写在调用方(商品服务),这个功能由feign提供
@FeignClient(value = "gulimall-seckill",fallback = SeckillFeignServiceFallBack.class)
public interface SeckillFeignService {
    /**
     * 根据skuId查询商品是否参加秒杀活动
     */
    @GetMapping(value = "/sku/seckill/{skuId}")
    R getSkuSeckilInfo(@PathVariable("skuId") Long skuId);
@Component
public class SeckillFeignServiceFallBack implements SeckillFeignService {
    @Override
    public R getSkuSeckilInfo(Long skuId) {
        return R.error(BizCodeEnum.TO_MANY_REQUEST.getCode(),BizCodeEnum.TO_MANY_REQUEST.getMessage());
    }
}

调用远程服务降级策略:

  • 在控制台根据平均响应时间,异常比例,异常数配置,如果触发了降级,在一定时间内(可配置)就会调用熔断方法

不调用远程服务配置降级策略:

  • 如果不是在商品服务配置,而是在秒杀服务配置降级策略,秒杀服务被降级后返回的是限流后的数据(WebCallbackManager.setUrlBlockHandler)

3.自定义受保护的资源

  • 基于代码
try (Entry entry = SphU.entry("seckillSkus")) {
//业务逻辑
} catch(BlockException e) {
	log.error("资源被限流{}",e.getMessage());
}
  • 基于注解,被限流/降级后调用blockHandler方法
@SentinelResource(value = "getCurrentSeckillSkusResource",blockHandler = "blockHandler")
@Override
public List<SeckillSkuRedisTo> getCurrentSeckillSkus() {}
public List<SeckillSkuRedisTo> blockHandler(BlockException e) {
    log.error("getCurrentSeckillSkusResource被限流了,{}",e.getMessage());
    return null;
}

4.整合SpringCloud-Gateway

导入依赖

<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-alibaba-sentinel-gateway</artifactId>
    <version>2.1.0.RELEASE</version>
</dependency>

自定义网关流控返回

@Configuration
public class SentinelGatewayConfig {

    public SentinelGatewayConfig() {
        GatewayCallbackManager.setBlockHandler(new BlockRequestHandler() {
            // 网关限流了请求,就会调用此回调
            @Override
            public Mono<ServerResponse> handleRequest(ServerWebExchange exchange, Throwable t) {
                R error = R.error(BizCodeEnum.TO_MANY_REQUEST.getCode(), BizCodeEnum.TO_MANY_REQUEST.getMessage());
                String errorJson = JSON.toJSONString(error);
                Mono<ServerResponse> body = ServerResponse.ok().body(Mono.just(errorJson), String.class);
                return body;
            }
        });
    }

}

14.Sleuth+Zipkin链路追踪

引入依赖

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-zipkin</artifactId>
</dependency>

配置

#服务追踪地址
spring.zipkin.base-url=http://192.168.77.130:9411/
#不要让nacos也把zipkin注册到注册中心
spring.zipkin.discovery-client-enabled=false
#使用http方式传输数据
spring.zipkin.sender.type=web
#配置采样器,数据收集的比例
spring.sleuth.sampler.probability=1