目录
3.3、整合SpringBoot(Elasticsearch-Rest-Client)
6.5、异步更新缓存(基于Mysql binlog的同步机制 Canal)
11.8.3、消息的TTL(Time To Live)和死信队列(Dead Letter Exchanges(DLX))
前言
视频地址:
01、简介-项目介绍_哔哩哔哩_bilibili- 整体的微服务架构图
-
微服务划分图
一、基础环境搭建
1、安装Linux虚拟机
- 下载&安装 VirtualBox ,系统需要开启 CPU 虚拟化。
-
下载 & 安装 Vagrant
-
打开 window cmd 窗口,运行命令: vagrant init centos/7 ,即可初始化一个 centos7 系统
-
运行启动重启命令: vagrant up ,即可启动虚拟机。系统 root 用户的密码是 vagrant
- vagrant 其他常用命令
- vagrant ssh:自动使用 vagrant 用户连接虚拟机
- Vagrant 命令行
-
默认虚拟机的 ip 地址不是固定 ip ,开发不方便。修改 Vagrantfile
-
config.vm.network "private_network", ip: "192.168.56.10"
-
-
账号默认只允许 ssh 登录方式,为了后来操作方便,文件上传等,我们可以配置允许账号密码登录。
-
Vagrant ssh 进去系统之后
vi /etc/ssh/sshd_config #修改 PasswordAuthentication yes/no #重启服务 service sshd restart
-
2、安装docker
2.1、安装MySQL
- Linux直接安装
- 下载镜像文件
docker pull mysql:5.7
-
创建实例并启动
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
参数说明
-p 3306:3306:将容器的 3306 端口映射到主机的 3306 端口
-v /mydata/mysql/conf:/etc/mysql:将配置文件夹挂载到主机
-v /mydata/mysql/log:/var/log/mysql:将日志文件夹挂载到主机
-v /mydata/mysql/data:/var/lib/mysql/:将配置文件夹挂载到主机
-e MYSQL_ROOT_PASSWORD=root:初始化 root 用户的密码 -
配置 MySQL
vi /mydata/mysql/conf/my.cnf [client] default-character-set=utf8 [mysql] default-character-set=utf8 [mysqld] init_connect='SET collation_connection = utf8_unicode_ci' init_connect='SET NAMES utf8' character-set-server=utf8 collation-server=utf8_unicode_ci skip-character-set-client-handshake #跳过域名解析 skip-name-resolve
-
通过容器的 mysql 命令行工具连接
docker exec -it mysql mysql -uroot -proot
-
设置 root 远程访问,默认只能本机访问
grant all privileges on *.* to 'root'@'%' identified by 'root' with grant option; flush privileges;
2.2、docker 安装 redis
- Linux直接安装
- 下载镜像文件
docker pull redis
- 创建实例并启动
mkdir -p /mydata/redis/conf touch /mydata/redis/conf/redis.conf docker run -p 6379:6379 --name redis -v /mydata/redis/data:/data \ -v /mydata/redis/conf/redis.conf:/etc/redis/redis.conf \ -d redis redis-server /etc/redis/redis.conf
- 使用 redis 镜像执行 redis-cli 命令连接
docker exec -it redis redis-cli
2.3、docker 安装 Es和Kibana
-
下载镜像文件
#存储和检索数据 docker pull elasticsearch:7.4.2 #可视化检索数据 docker pull kibana:7.4.2
- 运行启动ES容器
#创建文件夹 用于挂载容器内部文件 mkdir -p /mydata/elasticsearch/config #创建文件夹 用于挂载容器内部文件 mkdir -p /mydata/elasticsearch/data #允许外部ip访问 echo "http.host: 0.0.0.0" >> /mydata/elasticsearch/config/elasticsearch.yml #保证权限 chmod -R 777 /mydata/elasticsearch/ #启动容器 docker run --name elasticsearch -p 9200:9200 -p 9300:9300 \ -e "discovery.type=single-node" \ -e ES_JAVA_OPTS="-Xms64m -Xmx512m" \ -v /mydata/elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml \ -v /mydata/elasticsearch/data:/usr/share/elasticsearch/data \ -v /mydata/elasticsearch/plugins:/usr/share/elasticsearch/plugins \ -d elasticsearch:7.4.2
解释:容器名:--name elasticsearch暴露端口:-p 9200:9200(Http访问连接端口) -p 9300:9300(ES集群之间的访问端口)单节点运行:-e "discovery.type=single-node"
设置内存大小:-e ES_JAVA_OPTS="-Xms64m -Xmx512m"挂载配置文件目录:-v /mydata/elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
挂载数据存储目录:-v /mydata/elasticsearch/data:/usr/share/elasticsearch/data
挂载插件目录:-v /mydata/elasticsearch/plugins:/usr/share/elasticsearch/plugins启动后:访问http://虚拟机ip:9200 端口特别注意:-e ES_JAVA_OPTS="-Xms64m -Xmx256m" \ 测试环境下,设置 ES 的初始内存和最大内存,否则导 致过大启动不了 ES - 运行启动Kibana
#http://192.168.56.10:9200 一定改为自己虚拟机的地址 docker run --name kibana -e ELASTICSEARCH_HOSTS=http://192.168.56.10:9200 -p 5601:5601 \ -d kibana:7.4.2
2.4、docker 安装 Nginx
-
随便启动一个 nginx 实例,只是为了复制出配置
#启动一个实例 docker run -p 80:80 --name nginx -d nginx:1.10 #将容器内的配置文件拷贝到当前目录 别忘了后面的点 docker container cp nginx:/etc/nginx . mkdir /mydata/nginx #修改文件名称, mv nginx conf #把这个 conf 移动到/mydata/nginx 下 mv conf /mydata/nginx
-
终止删除原容器
#停止容器 docker stop nginx #删除容器 docker rm 容器id
-
创建新的 nginx
docker run -p 80:80 --name nginx \ -v /mydata/nginx/html:/usr/share/nginx/html \ -v /mydata/nginx/logs:/var/log/nginx \ -v /mydata/nginx/conf:/etc/nginx \ -d nginx:1.10
-
给 nginx 的 html 下面放的所有资源可以直接访问;
2.5、docker 安装 Zipkin 服务器
- 拉取镜像
docker run -d -p 9411:9411 openzipkin/zipkin
- 项目环境配置
spring: zipkin: base-url: http://192.168.56.10:9411/ # zipkin 服务器的地址 # 关闭服务发现,否则 Spring Cloud 会把 zipkin 的 url 当做服务名称 discoveryClientEnabled: false sender: type: web # 设置使用 http 的方式传输数据 sleuth: sampler: probability: 1 # 设置抽样采集率为 100%,默认为 0.1,即 10%
二、SpringClound Alibaba
1、Nacos[注册中心、配置中心]
- 启动 nacos-server
双击 bin 中的 startup.cmd 文件访问 http://localhost:8848/nacos/使用默认的 nacos/nacos 进行登录 - 注册中心(A 服务调用 B 服务,A 服务并不知道 B 服务当前在哪几台服务器有,哪些正常的,哪些服务已经下线。解决这个问题可以引入注册中心)使用,配置配置文件,主启动类上加上@EnableDiscoveryClient即可
spring: cloud: nacos: discovery: #nacos 地址:端口 server-addr: 127.0.0.1:8848
- 配置中心(每一个服务最终都有大量的配置,并且每个服务都可能部署在多台机器上。我们经常需要变更配置,我们可以让每个服务在配置中心获取自己的配置。)的使用,配置配置文件
# Nacos同springcloud-config一样,在项目初始化时,要保证先从配置中心进行配置拉取,拉取配置之后,才能保证项目的正常启动。springboot中配置文件的加载是存在优先级顺序的,bootstrap优先级高于application server: port: 3377 spring: application: name: nacos-config-client cloud: nacos: discovery: server-addr: localhost:8848 #Nacos服务注册中心地址 config: server-addr: localhost:8848 #Nacos作为配置中心地址 file-extension: yaml #指定yaml格式的配置,文件后缀名,必须相同,不能是yml和yaml group: TEST_GROUP #指定分组,会读取namespace下分组为 DEV_GROUP 的 ${spring.application.name}-${spring.profile.active}.${spring.cloud.nacos.config.file-extension}文件,如果没有配置namespace,则读取的是public,下分组为 DEV_GROUP 的 ${spring.application.name}-${spring.profile.active}.${spring.cloud.nacos.config.file-extension}文件 namespace: 4027cd05-15b8-4c4b-ab8c-2cab3d14df06 #指定命名空间,读取的就是该空间下的某个文件 # ${spring.application.name}-${spring.profile.active}.${spring.cloud.nacos.config.file-extension} # nacos-config-client-dev.yaml # nacos-config-client-test.yaml ----> config.info
2、Gataway
2.1、简介
- 官方文档
- 网关作为流量的入口,常用功能包括路由转发、权限校验、限流控制等。而 springcloud gateway作为 SpringCloud 官方推出的第二代网关框架,取代了 Zuul 网关。
2.2、核心概念
- 路由。路由是网关最基础的部分,路由信息有一个 ID、一个目的 URL、一组断言和一组
Filter 组成。如果断言路由为真,则说明请求的 URL 和配置匹配. - 断言。Java8 中的断言函数。Spring Cloud Gateway 中的断言函数输入类型是 Spring5.0 框
架中的 ServerWebExchange。Spring Cloud Gateway 中的断言函数允许开发者去定义匹配
来自于 http request 中的任何信息,比如请求头和参数等。 - 过滤器。一个标准的 Spring webFilter。Spring cloud gateway 中的 filter 分为两种类型的
Filter,分别是 Gateway Filter 和 Global Filter。过滤器 Filter 将会对请求和响应进行修改
处理 - 一句话:满足某些断言(predicates)就路由到指定的地址(uri),使用指定的过滤器(filter)
2.3、使用
- 创建网关项目,引入网关
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> </dependency>
- 编写网关配置文件
spring: cloud: gateway: routes: - id: add_request_parameter_route uri: https://example.org predicates: - Query=baz filters: - AddRequestParameter=foo, bar
- 注意,各种 Predicates 同时存在于同一个路由时,请求必须同时满足所有的条件才被这个路
由匹配。一个请求满足多个路由的谓词条件时,请求只会被首个成功匹配的路由转发
2.4、配置统一的全局跨域
@Configuration
public class GulimallCorsConfiguration {
@Bean
public CorsWebFilter corsConfig(){
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration corsConfiguration = new CorsConfiguration();
//允许所有请求头、请求方式都进行跨域处理
corsConfiguration.addAllowedHeader("*");
corsConfiguration.addAllowedMethod("*");
corsConfiguration.addAllowedOrigin("*");
//允许携带cookie的请求
corsConfiguration.setAllowCredentials(true);
source.registerCorsConfiguration("/**",corsConfiguration);
return new CorsWebFilter(source);
}
}
2.5、Gataway配合Nginx进行请求转发
- 使用Nginx的反向代理,请求统一发送到Nginx中,由Nginx转发到网关(Gataway),在由网关自己决定转发负载均衡到某个微服务。
- Nginx配置文件配置
upstream gulimall{ #转发至网关,有网关转发到指定的服务 server 192.168.56.1:80; } server { listen 80; #多个域名映射同一个上游路径 server_name gulimall.com search.gulimall.com item.gulimall.com auth.gulimall.com cart.gulimall.com order.gulimall.com member.gulimall.com 4448227kv1.imdo.co seckill.gulimall.com; #charset koi8-r; #access_log /var/log/nginx/log/host.access.log main; location /payed/ { #Nginx转发请求时会丢失头部信息,需要自己设置头部信息 proxy_set_header Host order.gulimall.com; #proxy_pass http://192.168.56.1:10000; #路由到指定upstream上,需要在server块之上配置 proxy_pass http://gulimall; } #static请求路径转发到Nginx的html目录下,实现动静分离 location /static/{ root /usr/share/nginx/html; } location / { #Nginx转发请求时会丢失头部信息,需要自己设置头部信息 proxy_set_header Host $host; #proxy_pass http://192.168.56.1:10000; #路由到指定upstream上,需要在server块之上配置 proxy_pass http://gulimall; } #error_page 404 /404.html; # redirect server error pages to the static page /50x.html # error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; } # proxy the PHP scripts to Apache listening on 127.0.0.1:80 # #location ~ \.php$ { # proxy_pass http://127.0.0.1; #} # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000 # #location ~ \.php$ { # root html; # fastcgi_pass 127.0.0.1:9000; # fastcgi_index index.php; # fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name; # include fastcgi_params; #} # deny access to .htaccess files, if Apache's document root # concurs with nginx's one # #location ~ /\.ht { # deny all; #} }
- 网关配置(端口80)
spring: cloud: nacos: discovery: server-addr: 127.0.0.1:8848 gateway: routes: - id: gulimall-product uri: lb://gulimall-product predicates: - Path=/api/product/**,/testHello filters: - RewritePath=/api/?(?<segment>.*),/$\{segment} - id: gulimall-coupon uri: lb://gulimall-coupon predicates: - Path=/api/coupon/**,/testHello filters: - RewritePath=/api/?(?<segment>.*),/$\{segment} - id: gulimall-member uri: lb://gulimall-member predicates: - Path=/api/member/** filters: - RewritePath=/api/?(?<segment>.*),/$\{segment} - id: gulimall-ware uri: lb://gulimall-ware predicates: - Path=/api/ware/** filters: - RewritePath=/api/?(?<segment>.*),/$\{segment} - id: gulimall-third-party uri: lb://gulimall-third-party predicates: - Path=/api/thirdparty/** filters: - RewritePath=/api/thirdparty/?(?<segment>.*),/$\{segment} #path作用范围大的要写咋最后面 - id: gulimall-admin uri: lb://renren-fast predicates: #断言,路径相匹配的进行路由 - Path=/api/** filters: #重写路径 /api/.. 重写成 /... # http://localhost:9900/api/renren-fast/list 会映射到 lb://renren-fast:端口号/api/renren-fast/list #但会出现404 原因是真实的访问地址是 http://localhost:8080/api/renren-fast/list 要是过滤器进行路由重写 - RewritePath=/api/?(?<segment>.*),/renren-fast/$\{segment} #客户端发送请求->nginx转发到网关(会丢失头信息,需要在Nginx进行设置)->网关接收请求通过断言请求头信息,转发到对应服务 - id: gulimall-product-host uri: lb://gulimall-product predicates: #断言请求头匹配,能够匹配上 gulimall.com的请求头的转发到对应服务 - Host=gulimall.com,item.gulimall.com #客户端发送请求->nginx转发到网关(会丢失头信息,需要在Nginx进行设置)->网关接收请求通过断言请求头信息,转发到对应服务 - id: gulimall-search-host uri: lb://gulimall-search predicates: #断言请求头匹配,能够匹配上 search.gulimall.com的请求头的转发到对应服务 - Host=search.gulimall.com #客户端发送请求->nginx转发到网关(会丢失头信息,需要在Nginx进行设置)->网关接收请求通过断言请求头信息,转发到对应服务 - id: gulimall-auth-server uri: lb://gulimall-auth-server predicates: #断言请求头匹配,能够匹配上 search.gulimall.com的请求头的转发到对应服务 - Host=auth.gulimall.com #客户端发送请求->nginx转发到网关(会丢失头信息,需要在Nginx进行设置)->网关接收请求通过断言请求头信息,转发到对应服务 - id: gulimall-cart uri: lb://gulimall-cart predicates: #断言请求头匹配,能够匹配上 search.gulimall.com的请求头的转发到对应服务 - Host=cart.gulimall.com - id: gulimall-order uri: lb://gulimall-order predicates: - Host=order.gulimall.com - id: gulimall-member uri: lb://gulimall-member predicates: - Host=member.gulimall.com - id: gulimall-seckill uri: lb://gulimall-seckill predicates: - Host=seckill.gulimall.com
3、Feign 声明式远程调用
3.1、简介
- Feign 是一个声明式的 HTTP 客户端,它的目的就是让远程调用更加简单。Feign 提供了 HTTP请求的模板,通过编写简单的接口和插入注解,就可以定义好 HTTP 请求的参数、格式、地址等信息。
- Feign 整合了 Ribbon(负载均衡)和 Hystrix(服务熔断),可以让我们不再需要显式地使用这两个组件。
- SpringCloudFeign 在 NetflixFeign 的基础上扩展了对 SpringMVC 注解的支持,在其实现下,我们只需创建一个接口并用注解的方式来配置它,即可完成对服务提供方的接口绑定。简化了SpringCloudRibbon 自行封装服务调用客户端的开发量。
3.2、使用
- 引入依赖
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency>
- 启动类开启 feign 功能
@EnableFeignClients(basePackages = "com.atguigu.gulimall.pms.feign")
- 声明远程接口
@FeignClient("gulimall-ware")//指定远程调用的服务名(spring:application:name):gulimall-ware public interface WareFeignService { @PostMapping("/ware/waresku/skus") //gulimall-ware服务的请求全路径 public Resp<List<SkuStockVo>> skuWareInfos(@RequestBody List<Long> skuIds); }
- 原理
3.3、Feign远程调用丢失请求头问题
- 产生,远程查询购物车所有选中的购物项 解决feign 远程调用解决请求头丢失问题(远程调用其他服务会创建一个新的请求,该请求没有携带任何cookie信息,另外一个服务不知道已经进行了登陆,改拦截后拦截后,设置携带cookie信息,到新的请求中,被远程调用的服务就知道那个用户进行了登陆,能获取到登陆信息)
- 解决:添加一个feign的拦截器
import feign.RequestInterceptor; import feign.RequestTemplate; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import javax.servlet.http.HttpServletRequest; /** * @Description: feign拦截器功能 解决feign 远程调用解决请求头丢失问题(远程调用其他服务会创建一个新的请求,该请求没有携带任何cookie信息,另外一个服务不知道已经进行了登陆,改拦截后拦截后,设置携带cookie信息,到新的请求中,被远程调用的服务就知道那个用户进行了登陆,能获取到登陆信息) **/ @Configuration public class GuliFeignConfig { @Bean("requestInterceptor") public RequestInterceptor requestInterceptor() { RequestInterceptor requestInterceptor = new RequestInterceptor() { @Override public void apply(RequestTemplate template) { //1、使用RequestContextHolder拿到刚进来的请求数据 ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (requestAttributes != null) { //老请求 HttpServletRequest request = requestAttributes.getRequest(); if (request != null) { //2、同步请求头的数据(主要是cookie) //把老请求的cookie值放到新请求上来,进行一个同步 String cookie = request.getHeader("Cookie"); template.header("Cookie", cookie); } } } }; return requestInterceptor; } }
3.4、Feign异步情况丢失上下文问题
- 产生(每一个线程都来共享之前的请求数据 Feign异步情况丢失上下文问题 异步执行后,不同的线程,获取的上下文不同 所以设置为统一的上下文)
- 解决(设置为统一的上下文)
//TODO :获取当前线程请求头信息(解决Feign异步调用丢失请求头问题) RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); //开启第一个异步任务 CompletableFuture<Void> addressFuture = CompletableFuture.runAsync(() -> { //每一个线程都来共享之前的请求数据 Feign异步情况丢失上下文问题 异步执行后,不同的线程,获取的上下文不同 所以设置为统一的上下文 RequestContextHolder.setRequestAttributes(requestAttributes); //1、远程查询所有的收获地址列表 List<MemberAddressVo> address = memberFeignService.getAddress(memberResponseVo.getId()); confirmVo.setMemberAddressVos(address); }, threadPoolExecutor); //开启第二个异步任务 CompletableFuture<Void> cartInfoFuture = CompletableFuture.runAsync(() -> { //RequestContextHolder上下文 底层是ThreadLocal共享数据 每一个线程都来共享之前的请求数据 Feign异步情况丢失上下文问题 异步执行后,不同的线程,获取的上下文不同 所以设置为统一的上下文 RequestContextHolder.setRequestAttributes(requestAttributes); //2、远程查询购物车所有选中的购物项 解决feign 远程调用解决请求头丢失问题(远程调用其他服务会创建一个新的请求,该请求没有携带任何cookie信息,另外一个服务不知道已经进行了登陆,改拦截后拦截后,设置携带cookie信息,到新的请求中,被远程调用的服务就知道那个用户进行了登陆,能获取到登陆信息) 具体代码见:GuliFeignConfig List<OrderItemVo> currentCartItems = cartFeignService.getCurrentCartItems(); confirmVo.setItems(currentCartItems); //feign在远程调用之前要构造请求,调用很多的拦截器 }, threadPoolExecutor).thenRunAsync(() -> { List<OrderItemVo> items = confirmVo.getItems(); //获取全部商品的id List<Long> skuIds = items.stream() .map((itemVo -> itemVo.getSkuId())) .collect(Collectors.toList()); //远程查询商品库存信息 R skuHasStock = wmsFeignService.getSkuHasStock(skuIds); List<SkuStockVo> skuStockVos = skuHasStock.getData("data", new TypeReference<List<SkuStockVo>>() {}); if (skuStockVos != null && skuStockVos.size() > 0) { //将skuStockVos集合转换为map Map<Long, Boolean> skuHasStockMap = skuStockVos.stream().collect(Collectors.toMap(SkuStockVo::getSkuId, SkuStockVo::getHasStock)); confirmVo.setStocks(skuHasStockMap); } },threadPoolExecutor);
4、Sentinel
4.1、简介
1、熔断降级限流
- 熔断:A 服务调用 B 服务的某个功能,由于网络不稳定问题,或者 B 服务卡机,导致功能时间超长。如果这样子的次数太多。我们就可以直接将 B 断路了(A 不再请求 B 接口),凡是调用 B 的直接返回降级数据,不必等待 B 的超长执行。 这样 B 的故障问题,就不会级联影响到 A。
- 降级:整个网站处于流量高峰期,服务器压力剧增,根据当前业务情况及流量,对一些服务和页面进行有策略的降级[停止服务,所有的调用直接返回降级数据]。以此缓解服务器资源的压力,以保证核心业务的正常运行,同时也保持了客户和大部分客户的得到正确的相应。
- 熔断和降级是两个概念,熔断主要是在调用方控制,降级是在提供方控制。熔断主要是防止提供方宕机,降级则是提供方为了解压,给调用方提供了一些降级默认数据。
- 限流:对打入服务的请求流量进行控制,使服务能够承担不超过自己能力的流量压力
- 相同点:
- 为了保证集群大部分服务的可用性和可靠性,防止崩溃,牺牲小我。
- 用户最终都是体验到某个功能不可用。
- 不同点:
- 熔断是被调用方故障,触发的系统主动规则(A远程调用B,B出现故障,A不调用直接触发规则)。
- 降级是基于全局考虑,停止一些正常服务,释放资源。
2、Hystrix 与 Sentinel 比较
4.2、整合SpringBoot
- 引入依赖和下载监控控制台(需要更具当前引入的依赖版本下载对应的版本),并启动控制台(就是个jar包,java -jar 以jar的方式启动)
<!--引入限流--> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId> </dependency>
- 项目配置控制台和通信端口
spring: cloud: sentinel: transport: #sentinel 与 服务之间的通信端口 port: 8719 #dashboard 访问路径 dashboard: localhost:8080
- 访问服务中的任意一个请求,在sentinel控制台就可看到当前服务的请求信息
4.3、扩展功能
1、Endpoint 支持
- Sentinel控制台无法暴露实施监控信息
-
在使用 Endpoint 特性之前需要在 Maven 中添加 spring-boot-starter-actuator 依赖,并在配置中允许 Endpoints 的访问。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>
-
Spring Boot 1.x 中添加配置 management.security.enabled=false。暴露的 endpoint 路径为 /sentinel
-
Spring Boot 2.x 中添加配置 management.endpoints.web.exposure.include=*。暴露的 endpoint 路径为 /actuator/sentinel
#暴露所有端点 management: endpoints: web: exposure: include: '*'
-
Sentinel Endpoint 里暴露的信息非常有用。包括当前应用的所有规则信息、日志目录、当前实例的 IP,Sentinel Dashboard 地址,Block Page,应用与 Sentinel Dashboard 的心跳频率等等信息。
2、自定义 sentinel 流控返回的数据
/**
* @Description: Sentinel 自定义阻塞返回方法,不然是Sentinel默认的提示页面
**/
@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.getMsg());
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().write(JSON.toJSONString(error));
}
});
}
}
3、流控规则
4、熔断降级和Feign的支持
- 开启的支持Feign,配置文件打开 Sentinel 对 Feign 的支持
feign.sentinel.enabled=true
- FeignClient 的简单使用示例
//fallback:指定熔断降级类 @FeignClient(value = "gulimall-seckill",fallback = SeckillFeignServiceFallBack.class) public interface SeckillFeignService { /** * 根据skuId查询商品是否参加秒杀活动 * @param skuId * @return */ @GetMapping(value = "/sku/seckill/{skuId}") R getSkuSeckilInfo(@PathVariable("skuId") Long skuId); } @Slf4j @Component public class SeckillFeignServiceFallBack implements SeckillFeignService { @Override public R getSkuSeckilInfo(Long skuId) { log.info("SeckillFeignServiceFallBack 熔断方法调用"); return R.error(BizCodeEnum.TO_MANY_REQUEST.getCode(),BizCodeEnum.TO_MANY_REQUEST.getMsg()); } }
- 调用方手动指定远程服务的降级策略。远程服务被降级处理,触发调用方的熔断回调方法,但不影响其他业务逻辑,只是这一次远程调用得不到数据,其他的结构能够正常运行。
- 超大浏览的时候,必须牺牲一些远程服务。在服务的提供方(远程服务,全局降级)指定降级策略;提供方是在运行,但是不允许自己的业务逻辑,达到的降级策略后,返回的是默认的降级数据(限流的数据)。
5、自定义受保护的资源
- 自定义限流资源 在控制台就看到当前的资源名称,可以配置当前资源名称的限流规则
- 代码形式,然后在Sentinel控制台配置当前资源名的流控规则
//seckillSkus 资源名称 try (Entry entry = SphU.entry("seckillSkus")) { //业务逻辑 } catch(Exception e) {}
基于注解//注解方式配置限流规则 blockHandler:当前方法被限流或降级了,就会调用当前方法 //blockHandler:指定当前类中的降级处理的方法 //或者使用 //fallbackClass:指定当前方法的降级处理类 // fallback:指定当前方法的降级处理方法 会找到fallbackClass指定的类中找到fallback指定的方法,该方法的返回值要和当前方法的返回值一致,参数也要一致,且是静态的方法 @SentinelResource(value ="getCurrentSeckillSkusResource", blockHandler = "getCurrentSeckillSkusBlockHandler"/*,fallbackClass = ,fallback = "getCurrentSeckillSkusFallback"*/) public List<SeckillSkuRedisTo> getCurrentSeckillSkus(){} public List<SeckillSkuRedisTo> getCurrentSeckillSkusBlockHandler(BlockException e){ log.info("getCurrentSeckillSkus...原方法被限流了"); return null; }
6、小总结
- 远程调用:无论是代码、还是注解一定要配置限流以后的默认返回值。
- URL请求:可以设置统一返回:WebCallbackManager(GulimallSeckillSentinelConfig)
/** * @Description: Sentinel 自定义阻塞返回方法,不然是Sentinel默认的提示页面 **/ @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.getMsg()); response.setCharacterEncoding("UTF-8"); response.setContentType("application/json"); response.getWriter().write(JSON.toJSONString(error)); } }); } }
4.4、网关流控
- 官方整合文档
- 网关服务引入依赖
<dependency> <groupId>com.alibaba.csp</groupId> <artifactId>sentinel-spring-cloud-gateway-adapter</artifactId> <version>x.y.z</version> </dependency>
- route 维度:即在 Spring 配置文件中配置的路由条目,资源名为对应的 routeId,或者在控制台配置
- 自定义 API 维度:用户可以利用 Sentinel 提供的 API 来自定义一些 API 分组
- Sentinel 网关流控默认的粒度是 route 维度以及自定义 API 分组维度,默认不支持 URL 粒度。
- 自定义网关流控返回。
/** * @Description: Sentinel 网关回调 自定义阻塞返回方法,不然是Sentinel默认的提示页面 **/ @Configuration public class GulimallSeckillSentinelConfig { public GulimallSeckillSentinelConfig() { //网关限流了请求,就会调用此回调 GatewayCallbackManager.setBlockHandler((exchange, t) -> { R error = R.error(BizCodeEnum.TO_MANY_REQUEST.getCode(), BizCodeEnum.TO_MANY_REQUEST.getMsg()); String errorJson = JSON.toJSONString(error); Mono<ServerResponse> body = ServerResponse.ok().body(Mono.just(errorJson), String.class); return body; }); } }
- 其他具体的使用规则请查看官方文档。
4.5、Sentinel 规则持久化
5、Sleuth+Zipkin 服务链路追踪
5.1、为什么用
- 微服务架构是一个分布式架构,它按业务划分服务单元,一个分布式系统往往有很多个服务单元。由于服务单元数量众多,业务的复杂性,如果出现了错误和异常,很难去定位。主要体现在,一个请求可能需要调用很多个服务,而内部服务的调用复杂性,决定了问题难以定位。所以微服务架构中,必须实现分布式链路追踪,去跟进一个请求到底有哪些服务参与,参与的顺序又是怎样的,从而达到每个请求的步骤清晰可见,出了问题,很快定位。
- 链路追踪组件有 Google 的 Dapper,Twitter 的 Zipkin,以及阿里的 Eagleeye (鹰眼)等,它们都是非常优秀的链路追踪开源组件。
5.2、基本术语
- Span(跨度):基本工作单元,发送一个远程调度任务 就会产生一个 Span,Span 是一个 64 位 ID 唯一标识的,Trace 是用另一个 64 位 ID 唯一标识的,Span 还有其他数据信息,比如摘要、时间戳事件、Span 的 ID、以及进度 ID。
- Trace(跟踪):一系列 Span 组成的一个树状结构。请求一个微服务系统的 API 接口,这个 API 接口,需要调用多个微服务,调用每个微服务都会产生一个新的 Span,所有由这个请求产生的 Span 组成了这个 Trace。
- Annotation(标注):用来及时记录一个事件的,一些核心注解用来定义一个请求的开始和结束 。这些注解包括以下:
- cs - Client Sent :客户端发送一个请求,这个注解描述了这个 Span 的开始时间
- sr - Server Received :服务端获得请求并准备开始处理它,如果将其 sr 减去 cs 时间戳便可得到网络传输的时间。
- ss - Server Sent (服务端发送响应):该注解表明请求处理的完成(当请求返回客户端),如果 ss 的时间戳减去 sr 时间戳,就可以得到服务器请求的时间。
- cr - Client Received (客户端接收响应):此时 Span 的结束,如果 cr 的时间戳减去cs 时间戳便可以得到整个请求所消耗的时间。
- 官方文档
- 服务调用顺序如下
那么用以上概念完整的表示出来如下(请求调用的每一步都记录谁发的,发给谁,网络传输时间(发送时间)、接收时间、处理时间等): Span 之间的父子关系如下:
5.3、整合 Sleuth
- 导入依赖
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-sleuth</artifactId> </dependency>
- 配置文件,配置打开 debug 日志打印
logging: level: #链路追踪日志 org.springframework.cloud.openfeign: debug org.springframework.cloud.sleuth: debug
- 发起一次远程调用,观察控制台
- DEBUG [user-service,541450f08573fff5,541450f08573fff5,false]
- user-service:服务名
- 541450f08573fff5:是 TranceId,一条链路中
,只有一个 TranceIddocker run -d -p 9411:9411 openzipkin/zipkin
- 541450f08573fff5:是 spanId,链路中的基本工作单元 id
- false:表示是否将数据输出到其他服务,true 则会把信息输出到其他可视化的服务上观察
5.4、整合 zipkin 可视化观察
- zipkin 官网
- 通过 Sleuth 产生的调用链监控信息,可以得知微服务之间的调用链路,但监控信息只输出
到控制台不方便查看。我们需要一个图形化的工具-zipkin。 - docker 安装 zipkin 服务器
docker run -d -p 9411:9411 openzipkin/zipkin
- 导入依赖(zipkin 依赖也同时包含了 sleuth,可以省略 sleuth 的引用)
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-zipkin</artifactId> </dependency>
- 添加 zipkin 相关配置
spring: zipkin: base-url: http://192.168.56.10:9411/ # zipkin 服务器的地址 # 关闭服务发现,否则 Spring Cloud 会把 zipkin 的 url 当做服务名称 discoveryClientEnabled: false sender: type: web # 设置使用 http 的方式传输数据 sleuth: sampler: probability: 1 # 设置抽样采集率为 100%,默认为 0.1,即 10%
- 发送远程请求,测试 zipkin。
三、Java生态知识点
1、JSR303 数据校验
- 用于前端传递参数,后端进行数据校验,自定义校验规则等。
- 普通的校验用法
- 实体类(Bean)标记:javax.validation.constraints包下的注解,并定义自己的message提示。
public class BrandEntity implements Serializable { private static final long serialVersionUID = 1L; /** * 品牌名 * 给Bean添加校验注解:javax.validation.constraints包下的,并定义自己的message提示 */ @NotBlank(message = "品牌名不能为空") private String name; /** * 品牌logo地址 */ @NotEmpty @URL(message = "品牌logo必须是一个URL") private String logo; /** * 显示状态[0-不显示;1-显示] */ @NotNull(message = "显示状态不能为空") private Integer showStatus; /** * 检索首字母 */ @NotEmpty @Pattern(regexp = "^[a-zA-Z]$",message = "首字母必须是a-z或者A-Z") private String firstLetter; /** * 排序 */ @NotNull @Min(value = 0,message = "排序必须是大于等于0的数字") private Integer sort; }
- 开启校验功能@Valid。效果:校验错误以后会有默认的响应;
/** * bindingResult 绑定了不合校验规则的参数 * 给校验的bean后紧跟一个BindingResult,就可以获取到校验的结果 */ @RequestMapping("/save") public R save(@Valid() @RequestBody BrandEntity brand, BindingResult bindingResult){ if (bindingResult.hasErrors()){ List<ObjectError> allErrors = bindingResult.getAllErrors(); Map<String,String > map = new HashMap<>(); allErrors.forEach(item -> { map.put(item.getObjectName(),item.getDefaultMessage()); }); return R.error(400,"参数不合法").put("data",map); }else { brandService.save(brand); return R.ok(); } brandService.save(brand); return R.ok(); }
- 实体类(Bean)标记:javax.validation.constraints包下的注解,并定义自己的message提示。
- 分组校验(多场景的复杂校验)
- 分组接口标识
/** * 字段验证分组,更细分组 * @author pengjun */ public interface UpdateValidGroup { } /** * 字段验证分组,保存分组 * @author pengjun */ public interface SaveValidGroup { } /** * 字段验证分组,更细分组 * @author pengjun */ public interface UpdateStatusValidGroup { }
- 实体类
public class BrandEntity implements Serializable { /** * 品牌id */ @NotNull(message = "更新时,需要传递品牌id",groups = {UpdateValidGroup.class}) //更新时,需要传递品牌id @Null(message = "保存时无需传递品牌id",groups = {SaveValidGroup.class}) //指定分组,在保存分组时,无需传递品牌id @TableId private Long brandId; /** * 品牌名 */ @NotBlank(message = "品牌名不能为空",groups = {UpdateValidGroup.class, SaveValidGroup.class}) //指定分组,在保存更新时,都需要需传递品牌名称 private String name; /** * 品牌logo地址 */ @NotEmpty @URL(message = "品牌logo必须是一个URL",groups = {UpdateValidGroup.class, SaveValidGroup.class})//指定分组,在保存更新时,都需要符合URL private String logo; /** * 介绍 */ private String descript; /** * 显示状态[0-不显示;1-显示] */ @NotNull(message = "显示状态不能为空",groups = {SaveValidGroup.class, UpdateStatusValidGroup.class}) private Integer showStatus; /** * 检索首字母 */ @NotEmpty //未指定分组的,在指定了 @Validated(value = {SaveValid.class}) 指定分组验证时,不会进行验证 @Pattern(regexp = "^[a-zA-Z]$",message = "首字母必须是a-z或者A-Z",groups = {SaveValidGroup.class}) private String firstLetter; /** * 排序 */ @NotNull @Min(value = 0,message = "排序必须是大于等于0的数字") private Integer sort; }
- 开启 @Validated(value = {}) (@Valid()无法应用于分组)
//指定不同的分组标识 会验证不同的规则 @RequestMapping("/save") public R save(/*@Valid(无法应用于分组)*/@Validated(value = {SaveValidGroup.class})/*指定分组时验证,如果字段验证未指定分组,验证不生效*/ @RequestBody BrandEntity brand, BindingResult bindingResult){ if (bindingResult.hasErrors()){ List<ObjectError> allErrors = bindingResult.getAllErrors(); Map<String,String > map = new HashMap<>(); allErrors.forEach(item -> { map.put(item.getObjectName(),item.getDefaultMessage()); }); return R.error(400,"参数不合法").put("data",map); }else { brandService.save(brand); return R.ok(); } brandService.save(brand); return R.ok(); } //指定不同的分组标识 会验证不同的规则 @RequestMapping("/update") public R update(@Validated(value = {UpdateValidGroup.class, UpdateStatusValidGroup.class}) @RequestBody BrandEntity brand){......}
- 分组接口标识
- 自定义检验拦截器
- 导入依赖
<dependency> <groupId>javax.validation</groupId> <artifactId>validation-api</artifactId> <version>2.0.1.Final</version> </dependency>
- 编写一个自定义的校验注解
ValidationMessages.properties
com.pj.common.valid.ListValue.message=必须提交指定的值
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE}) @Retention(RetentionPolicy.RUNTIME) @Documented @Constraint( validatedBy = {ListValueConstraintValidator.class} //指定校验拦截器,可以指定多个,不同类型的属性,都可以指定不同的拦截器,如果没有配置,则需要在初始化的时候进行校验拦截 ) public @interface ListValue { String message() default "{com.pj.common.valid.ListValue.message}"; //指定默认提示信息,需要配置一个ValidationMessages.properties配置文件 Class<?>[] groups() default {}; //分组 Class<? extends Payload>[] payload() default {}; int[] vals() default {}; }
- 编写一个自定义的校验器 ConstraintValidator
/** * 自定义检验拦截器 * @author pengjun */ public class ListValueConstraintValidator implements ConstraintValidator<ListValue,Integer>{ private Set<Integer> set = new HashSet<>(); @Override public void initialize(ListValue constraintAnnotation) { //拿到指定的值,放入set集合中 int[] vals = constraintAnnotation.vals(); for (int val : vals) { set.add(val); } } /** * 进行拦截校验 * @param integer 属性的传入过来的值 * @param constraintValidatorContext * @return */ @Override public boolean isValid(Integer integer, ConstraintValidatorContext constraintValidatorContext) { return set.contains(integer); } }
- 关联自定义的校验器和自定义的校验注解()
//@Constraint(validatedBy = {ListValueConstraintValidator.class} //指定校验拦截器,可以指定多个,不同类型的属性,都可以指定不同的拦截器,如果没有配置,则需要在初始化的时候进行校验拦截) @Data public class BrandEntity implements Serializable { /** * 显示状态[0-不显示;1-显示] */ @NotNull(message = "显示状态不能为空",groups = {SaveValidGroup.class, UpdateStatusValidGroup.class}) @ListValue(vals = {0,1},message = "显示状态只能为0和1",groups = {SaveValidGroup.class, UpdateStatusValidGroup.class}) private Integer showStatus; }
- 导入依赖
2、统一异常处理
- 用于服务器全局异常处理,统一返回异常提示信息。
/** * 公共处理异常类 * * @author */ //@ControllerAdvice //@ResponseBody @Slf4j @RestControllerAdvice //ControllerAdvice 和 ResponseBody的合体 public class GulimallExceptionAdvice { //指定拦截的异常类,子类也会将被拦截 @ExceptionHandler(value = MethodArgumentNotValidException.class) public R handlerVException(MethodArgumentNotValidException exception) { BindingResult bindingResult = exception.getBindingResult(); List<FieldError> fieldErrors = bindingResult.getFieldErrors(); Map<String, String> map = new HashMap<>(); fieldErrors.forEach(item -> map.put(item.getField(), item.getDefaultMessage())); return R.error(BizCodeEnum.VALID_EXCEPTION.getCode(), BizCodeEnum.VALID_EXCEPTION.getMsg()).put("data", map); } @ExceptionHandler(value = Throwable.class) public R handlerException(Throwable throwable) { log.error("错误:",throwable); return R.error(BizCodeEnum.UNKNOW_EXCEPTION.getCode(), BizCodeEnum.UNKNOW_EXCEPTION.getMsg()); } }
3、ES
3.1、基本概念:
-
Index(索引)相当于 MySQL 中的 Database
-
Type(类型)在 Index (索引)中,可以定义一个或多个类型。类似于 MySQL 中的 Table ;每一种类型的数据放在一起
-
Document(文档)保存在某个索引(Index)下,某种类型(Type )的一个数据( Document ),文档是 JSON格 式的, Document 就像是 MySQL 中的某个 Table 里面的内容;
-
关系图如下:
-
倒排索引机制
3.2、分词(IK分词器安装)
- es会安装默认的分词规则,给不同的数据进行分词整合。如若不满足分词的结果,需自行配置分词器。
- 安装 ik 分词器
IK分词器对应es版本- 下载对应版本后,上传至虚拟机的 plugins目录下(也可以直接下载),解压(如果没有保存没有命令,请自行下载)。
wget https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.4.2/elasticsearch-analysis-ik-7.4.2.zip
- 进入容器内部,查看是否安装完成
#进入 es 容器内部 plugins 目录 docker exec -it 容器 id /bin/bash #进入 /usr/share/elasticesearch/bin 目录,列出系统的分词器 elasticsearch-plugin list
- 测试分词器(在Kibana 中发送请求测试)
#使用默认 POST _analyze { "text": "我是中国人" } #使用分词器 POST _analyze { "analyzer": "ik_smart", "text": "我是中国人" } POST _analyze { "analyzer": "ik_max_word", "text": "我是中国人" }
- 下载对应版本后,上传至虚拟机的 plugins目录下(也可以直接下载),解压(如果没有保存没有命令,请自行下载)。
- 自定义分词内容(Nginx转发)
- 按照"http://虚拟机ip/es/fenci.txe",利用 Nginx发布静态资源,按照请求路径,创建对应的文件夹以及文件,放在 Nginx的 html 下,重启Nginx。
mkdir es vi fenci.txt 中国 中国人
- 配置ik分词器配置
#修改/usr/share/elasticsearch/plugins/ik/config/中的 IKAnalyzer.cfg.xml cd /usr/share/elasticsearch/plugins/ik/config vi IKAnalyzer.cfg.xml <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd"> <properties> <comment>IK Analyzer 扩展配置</comment> <!--用户可以在这里配置自己的扩展字典 --> <entry key="ext_dict"></entry> <!--用户可以在这里配置自己的扩展停止词字典--> <entry key="ext_stopwords"></entry> <!--用户可以在这里配置远程扩展字典 更改行 配置成自己虚拟ip地址(Nginx默认端口80)--> <entry key="remote_ext_dict">http://192.168.56.10/es/fenci.txt</entry> <!--用户可以在这里配置远程扩展停止词字典--> <!-- <entry key="remote_ext_stopwords">words_location</entry> --> </properties>
- 配置完成后,重启es,在进行测试。
- 按照"http://虚拟机ip/es/fenci.txe",利用 Nginx发布静态资源,按照请求路径,创建对应的文件夹以及文件,放在 Nginx的 html 下,重启Nginx。
3.3、整合SpringBoot(Elasticsearch-Rest-Client)
-
导入依赖
<dependency> <groupId>org.elasticsearch.client</groupId> <artifactId>elasticsearch-rest-high-level-client</artifactId> <!--要根据自己安装的es版本对应--> <version>7.17.10</version> </dependency>
-
配置ES信息
@Configuration public class ElasticSearchConfig { //RequestOptions类包含请求的部分,这些部分应该在同一应用程序中的多个请求之间共享。您可以创建一个singleton实例,并在所有请求之间共享它 public static final RequestOptions COMMON_OPTIONS; static { RequestOptions.Builder builder = RequestOptions.DEFAULT.toBuilder(); //权限设置 // builder.addHeader("Authorization", "Bearer " + TOKEN); // builder.setHttpAsyncResponseConsumerFactory( // new HttpAsyncResponseConsumerFactory // .HeapBufferedResponseConsumerFactory(30 * 1024 * 1024 * 1024)); COMMON_OPTIONS = builder.build(); } /** * 配置连接es客户端 * @return */ @Bean public RestHighLevelClient restHighLevelClient(){ RestClientBuilder builder = RestClient. builder(new HttpHost("192.168.56.10",9200,"http")); // ,new HttpHost("192.168.56.10",9200,"http")); 如果有集群可以配置多个 RestHighLevelClient client = new RestHighLevelClient(builder); return client; } }
- 代码操作ES(API文档)
- 保存和更新文档
/** * 保存和更新 文档 * 具体API文档地址:https://www.elastic.co/guide/en/elasticsearch/client/java-rest/current/java-rest-high-document-index.html */ @Test public void saveEsIndex() throws IOException { //指定索引(库名) 也可以指定 type(表,弃用了) 和 文档id(一行行数据的id) IndexRequest indexRequest = new IndexRequest("users"); indexRequest.id("1"); //构造存储的数据 //indexRequest.source("name","zhangsan","age",16,"gender","F"); //构造成map进行存储 // Map<String ,Object> map = new HashMap<>(); // map.put("name","zs"); // map.put("age",15); // map.put("gender","F"); // indexRequest.source(map); //构造成对象转为json后进行存储 User user = new User(); user.setName("张三"); user.setAge(18); user.setGender("男"); String s = JSON.toJSONString(user); //指定存储类型 为JSON 不指定会报错 indexRequest.source(s, XContentType.JSON); //设置请求超时时间,还可以设置其他的,自己从官网查看 indexRequest .timeout(TimeValue.timeValueSeconds(1)); //发送请求个es存储数据 IndexResponse index = restHighLevelClient.index(indexRequest, GulimallElasticSearchConfig.COMMON_OPTIONS); System.out.println(index); } @Data class User { private String name; private Integer age; private String gender; }
- 检索数据(API文档)
/** * #查出所有年龄分布,并且这些年龄段中 M 的平均薪资和 F 的平均薪资以及这个年龄段的总体平均薪资 */ @Test public void searchIndex() throws IOException { //1. 创建检索请求 SearchRequest searchRequest = new SearchRequest(); //1.1)指定索引 searchRequest.indices("bank"); //1.2)构造检索条件 SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); // QueryBuilders.matchAllQuery(); 查询所有 // sourceBuilder.query(QueryBuilders.matchQuery("address", "Mill")); sourceBuilder.query(QueryBuilders.matchAllQuery()); //1.2.1)按照年龄分布进行聚合 TermsAggregationBuilder ageAgg = AggregationBuilders.terms("ageAgg").field("age").size(10); //在ageAgg分组下构建子聚合 求这个年龄段内的男女情况 TermsAggregationBuilder genderAgg = AggregationBuilders.terms("genderAgg").field("gender.keyword"); ageAgg.subAggregation(genderAgg); //在ageAgg分组下构建子聚合 求这个年龄段的平均工资 AvgAggregationBuilder balanceAvg2 = AggregationBuilders.avg("balanceAvg2").field("balance"); ageAgg.subAggregation(balanceAvg2); sourceBuilder.aggregation(ageAgg); // 构造builder /* //构造多个聚合 sourceBuilder .aggregation(AggregationBuilders .composite("buckets", Arrays.asList( new TermsValuesSourceBuilder("genderAgg").field("gender.keyword"), new TermsValuesSourceBuilder("balanceAvg2").field("balance"))));*/ /* TermsAggregationBuilder genderAgg = AggregationBuilders.terms("genderAgg").field("gender.keyword"); aggregation.aggregation(genderAgg); AvgAggregationBuilder balanceAvg2 = AggregationBuilders.avg("balanceAvg2"); aggregation.aggregation(balanceAvg2); */ //1.2.2)计算查询的数据平均年龄 AvgAggregationBuilder ageAvg = AggregationBuilders.avg("ageAvg").field("age"); sourceBuilder.aggregation(ageAvg); //1.2.3)计算查询的数据的平均薪资 AvgAggregationBuilder balanceAvg = AggregationBuilders.avg("balanceAvg").field("balance"); sourceBuilder.aggregation(balanceAvg); System.out.println("检索条件:" + sourceBuilder); searchRequest.source(sourceBuilder); //检索结果 SearchResponse searchResponse = restHighLevelClient.search(searchRequest, GulimallElasticSearchConfig.COMMON_OPTIONS); System.out.println("检索结果:" + searchResponse); //获取最外层查询结果,包括查询的数据,查询的索引、类型、命中率等 SearchHits hits = searchResponse.getHits(); //获取查询数据 SearchHit[] searchHits = hits.getHits(); for (SearchHit searchHit : searchHits) { //获取到每一条查询的数据,输出为json串 String sourceAsString = searchHit.getSourceAsString(); //json串转为对象 Account account = JSON.parseObject(sourceAsString, Account.class); System.out.println(account); } //4. 获取聚合信息 Aggregations aggregations = searchResponse.getAggregations(); //获取聚合名称为:ageAgg 的聚合结果 Terms ageAgg1 = aggregations.get("ageAgg"); //遍历聚合结果,获取每个年龄段下的的人数 for (Terms.Bucket bucket : ageAgg1.getBuckets()) { String keyAsString = bucket.getKeyAsString(); System.out.println("年龄:" + keyAsString + " ==> " + bucket.getDocCount()); //获取子聚合的值 Aggregations aggregations2 = bucket.getAggregations(); Terms genderAgg2 = aggregations2.get("genderAgg"); for (Terms.Bucket genderBucket : genderAgg2.getBuckets()) { long docCount = genderBucket.getDocCount(); System.out.println(keyAsString + "该年龄的性别为:" + genderBucket.getKeyAsString() + "的人数为:" + docCount); } //获取子聚合的值 Avg balanceAvgAggregation = aggregations2.get("balanceAvg2"); System.out.println(keyAsString +"改年龄的平均工资:" + balanceAvgAggregation.getValue()); } //获取所有人的平均年龄 Avg ageAvg1 = aggregations.get("ageAvg"); System.out.println("平均年龄:" + ageAvg1.getValue()); //获取所有人的平均工资 Avg balanceAvg1 = aggregations.get("balanceAvg"); System.out.println("平均薪资:" + balanceAvg1.getValue()); }
- 保存和更新文档
-
Es-数组的扁平化处理(nested嵌入式解决 文档链接 )
//插入数据 PUT my-index-000001/_doc/1 { "group" : "fans", "user" : [ { "first" : "John", "last" : "Smith" }, { "first" : "Alice", "last" : "White" } ] } //检索数据 GET my-index-000001/_search { "query": { "bool": { "must": [ { "match": { "user.first": "Alice" }}, { "match": { "user.last": "Smith" }} ] } } }
//删除索引 DELETE my-index-000001 //设置索引映射 嵌入式处理 PUT my-index-000001 { "mappings": { "properties": { "user": { "type": "nested" } } } } //插入数据 PUT my-index-000001/_doc/1 { "group" : "fans", "user" : [ { "first" : "John", "last" : "Smith" }, { "first" : "Alice", "last" : "White" } ] } //重新检索 GET my-index-000001/_search { "query": { "nested": { "path": "user", "query": { "bool": { "must": [ { "match": { "user.first": "Alice" }}, { "match": { "user.last": "Smith" }} ] } } } } } //获取索引类型映射 GET my-index-000001/_mapping
3.4、商城商品检索代码(可当做参考案例)
#ES 检索dtl
{
"query": {
"bool": {
"must": [
{
"match": {
"skuTitle": "HUAWEI"
}
}
],
"filter": [
{
"term": {
"catalogId": "225"
}
},
{
"terms": {
"brandId": [
"9",
"12"
]
}
},
{
"nested": {
"path": "attrs",
"query": {
"terms": {
"attrs.attrId": [
"15",
"16"
]
}
}
}
},
{
"nested": {
"path": "attrs",
"query": {
"terms": {
"attrs.attrValue": [
"骁龙855"
]
}
}
}
},
{
"term": {
"hasStock": "true"
}
},
{
"range": {
"skuPrice": {
"gte": 0,
"lte": 5000
}
}
}
]
}
},
"sort": [
{
"skuPrice": {
"order": "desc"
}
}
],
"from": 0,
"size": 1,
"highlight": {
"fields": {
"skuTitle": {}
},
"pre_tags": "<b style='color:red'>",
"post_tags": "</b>"
},
"aggs": {
"brand_agg": {
"terms": {
"field": "brandId",
"size": 100
},
"aggs": {
"brand_name_agg": {
"terms": {
"field": "brandName",
"size": 10
}
},
"brand_image_agg": {
"terms": {
"field": "brandImg",
"size": 10
}
}
}
},
"catalog_agg": {
"terms": {
"field": "catalogId",
"size": 10
},
"aggs": {
"catalog_name_agg": {
"terms": {
"field": "catalogName",
"size": 10
}
}
}
},
"attr_agg": {
"nested": {
"path": "attrs"
},
"aggs": {
"attr_id_agg": {
"terms": {
"field": "attrs.attrId",
"size": 10
},
"aggs": {
"attr_name_agg": {
"terms": {
"field": "attrs.attrName",
"size": 10
}
},
"attr_value_agg": {
"terms": {
"field": "attrs.attrValue",
"size": 10
}
}
}
}
}
}
}
}
/**=====================================================
* 商品检索服务dto
* 排序的条件:?keyword="abc"&catalog3Id=123&sort=skuPrice_desc&hasStock=0&skuPrice=1_500&brandId=1&brandId=2&attrs=1_5寸&attrs=2_8GB&pageNum=5
* sort=skuPrice_asc 按照价格排序
* sort=saleCount_desc 按照销量排序
* sort=saleCount_asc
* sort=hasScore_desc 按照热度分排序
* sort=hasScore_asc
* @author pengjun
*/
@Data
public class SearchProductDto {
/**
* 标题查找
*/
private String keyword;
/**
* 三级分类id查找
*/
private Long catalog3Id;
/**
* 排序规则
*/
private String sort;
/**
* 是否只显示有库存的 0无 1有
*/
private Integer hasStock;
/**
* 价格区间 skuPrice=1_500/_500/500_
* 1_500:1-500之间的价格
* _500:小于500的价格
* 500_:大于500的价格
*/
private String skuPrice;
/**
* 品牌。可以选择多个品牌 &brandId=1&brandId=2
*/
private List<Long> brandId;
/**
* 多个属性 id_属性值 如 &attrs=1_5寸:8寸&attrs=2_8GB:16G
* 属性id为1的,5寸、8寸的商品
* 属性id为2的,8G和16G的商品
*/
private List<String> attrs;
/**
* 页码
*/
private Integer pageNum = 1;
/**
* 请求参数
*/
private String uri;
}
//=================检索代码=========
@Autowired
private RestHighLevelClient restHighLevelClient;
/**
*
* @param searchProductDto
* @return
*/
@Override
public SearchResult searchProduct(SearchProductDto searchProductDto) {
SearchRequest searchRequest = builderSearchCondition(searchProductDto);
SearchResponse search;
SearchResult searchResult = null;
try {
search = restHighLevelClient.search(searchRequest, GulimallElasticSearchConfig.COMMON_OPTIONS);
searchResult = builderSearchResult(search, searchProductDto);
} catch (IOException e) {
e.printStackTrace();
}
return searchResult;
}
/**
* 构造检索条件 模糊匹配,过滤(按照属性、分类、品牌,价格区间,库存),完成排序、分页、高亮,聚合分析功能
*
* @param searchProductDto
* @return
*/
private SearchRequest builderSearchCondition(SearchProductDto searchProductDto) {
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
//1. 构建bool-query
BoolQueryBuilder boolQueryBuilder = new BoolQueryBuilder();
//1.1 bool-must 根据副标题,模糊匹配
String keyword = searchProductDto.getKeyword();
if (StringUtils.isNotBlank(keyword)) {
boolQueryBuilder.must(QueryBuilders.matchQuery("skuTitle", keyword));
}
//1.2 bool-fiter
//1.2.1 catelogId 根据分类id检索
Long catalog3Id = searchProductDto.getCatalog3Id();
if (catalog3Id != null) {
boolQueryBuilder.filter(QueryBuilders.termQuery("catalogId", catalog3Id));
}
//1.2.2 brandId 根据品牌id查找
List<Long> brandId = searchProductDto.getBrandId();
if (brandId != null && !brandId.isEmpty()) {
boolQueryBuilder.filter(QueryBuilders.termsQuery("brandId", brandId));
}
//1.2.3 attrs
//attrs=1_5寸:8寸&2_16G:8G
List<String> attrs = searchProductDto.getAttrs();
if (attrs != null && !attrs.isEmpty()) {
for (String attr : attrs) {
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
String[] s = attr.split("_");
//分类id
String attrId = s[0];
//这个属性检索用的值
String[] attrValues = s[1].split(":");
//一个分类id有多个对应不同的值
boolQuery.must(QueryBuilders.termQuery("attrs.attrId", attrId));
boolQuery.must(QueryBuilders.termsQuery("attrs.attrValue", attrValues));
//构建扁平化处理的查询条件数据
boolQueryBuilder.filter(QueryBuilders.nestedQuery("attrs", boolQuery, ScoreMode.None));
}
}
Integer hasStock = searchProductDto.getHasStock();
if (hasStock != null) {
//1.2.4 hasStock
boolQueryBuilder.filter(QueryBuilders.termQuery("hasStock", searchProductDto.getHasStock() == 1));
}
//1.2.5 skuPrice
//skuPrice形式为:1_500或_500或500_
String skuPrice = searchProductDto.getSkuPrice();
if (StringUtils.isNotBlank(skuPrice)) {
String[] s = skuPrice.split("_");
//1_500
if (s.length == 2) {
String startPrice = s[0];
String endPrice = s[1];
boolQueryBuilder.filter(QueryBuilders.rangeQuery("skuPrice").gte(startPrice).lte(endPrice));
} else if (s.length == 1) {
String price = s[0];
//_500
if (skuPrice.startsWith("_")) {
boolQueryBuilder.filter(QueryBuilders.rangeQuery("skuPrice").lte(price));
} else if (skuPrice.endsWith("_")) {
//500_
boolQueryBuilder.filter(QueryBuilders.rangeQuery("skuPrice").gte(price));
}
}
}
//封装所有的查询条件
searchSourceBuilder.query(boolQueryBuilder);
//构造分页
/**
* 从第几条开始:(页码 - 1 * size)
* 第一页 5 条数据 0,1,2,3,4
* 第二页 5 条数据 5,6,7,8,9
*/
searchSourceBuilder.from((searchProductDto.getPageNum() - 1) * ElasticSearchIndex.PRODUCT_PAGESIZE);
searchSourceBuilder.size(ElasticSearchIndex.PRODUCT_PAGESIZE);
//构造高亮
if (StringUtils.isNotBlank(keyword)) {
HighlightBuilder highlightBuilder = new HighlightBuilder();
highlightBuilder.field("skuTitle");
highlightBuilder.preTags("<b style='color:red'>");
highlightBuilder.postTags("</b>");
searchSourceBuilder.highlighter(highlightBuilder);
}
//排序 形式为sort=hotScore_asc/desc
String sort = searchProductDto.getSort();
if (StringUtils.isNotBlank(sort)) {
String[] s = sort.split("_");
SortOrder sortOrder = s[1].equalsIgnoreCase("asc") ? SortOrder.ASC : SortOrder.DESC;
searchSourceBuilder.sort(s[0], sortOrder);
}
/**
* 聚合分析
*/
//1. 按照品牌进行聚合
TermsAggregationBuilder brand_agg = AggregationBuilders.terms("brand_agg").field("brandId");
//1.1 品牌的子聚合- 按照品牌id聚合后,在按照品牌名字聚合,一个id对应一个名称
TermsAggregationBuilder brand_name_agg = AggregationBuilders.terms("brand_name_agg").field("brandName").size(1);
brand_agg.subAggregation(brand_name_agg);
//1.2 品牌的子聚合- 按照品牌id聚合后,在按照品牌图片聚合,一个id对应一个图片
TermsAggregationBuilder brand_image_agg = AggregationBuilders.terms("brand_image_agg").field("brandImg").size(1);
brand_agg.subAggregation(brand_image_agg);
searchSourceBuilder.aggregation(brand_agg);
//2. 按照分类信息进行聚合
TermsAggregationBuilder catalog_agg = AggregationBuilders.terms("catalog_agg").field("catalogId").size(20);
//2.1 分类信息子聚合-按照分类名称聚合,一个id对应一个分类名称
TermsAggregationBuilder catalog_name_agg = AggregationBuilders.terms("catalog_name_agg").field("catalogName").size(1);
catalog_agg.subAggregation(catalog_name_agg);
searchSourceBuilder.aggregation(catalog_agg);
//3. 按照属性信息进行聚合
//3.1 进行数据扁平化处理
NestedAggregationBuilder attr_agg = AggregationBuilders.nested("attr_agg", "attrs");
//3.2 在扁平化处理之后,在对属性id进行聚合
TermsAggregationBuilder attr_id_agg = AggregationBuilders.terms("attr_id_agg").field("attrs.attrId").size(50);
//3.3 在id聚合下 按照名字聚合,一个id对应一个属性名
TermsAggregationBuilder attr_name_agg = AggregationBuilders.terms("attr_name_agg").field("attrs.attrName").size(1);
attr_id_agg.subAggregation(attr_name_agg);
//3.3 在id聚合下 按照属性值聚合,一个id对应多个属性名
TermsAggregationBuilder attr_value_agg = AggregationBuilders.terms("attr_value_agg").field("attrs.attrValue").size(50);
attr_id_agg.subAggregation(attr_value_agg);
attr_agg.subAggregation(attr_id_agg);
searchSourceBuilder.aggregation(attr_agg);
System.out.println(searchSourceBuilder.toString());
SearchRequest searchRequest = new SearchRequest(new String[]{ElasticSearchIndex.PRODUCT_INDEX}, searchSourceBuilder);
return searchRequest;
}
//=================检索数据解析=========
/**
* 构建检索之后的数据封装成的对应的数据返回
* @param search
* @param searchProductDto
* @return
*/
private SearchResult builderSearchResult(SearchResponse search, SearchProductDto searchProductDto) {
SearchResult searchResult = new SearchResult();
SearchHits hits = search.getHits();
List<SkuEsModel> skuEsModels = new ArrayList<>();
//1、返回的所有查询到的商品
SearchHit[] datas = hits.getHits();
if (datas != null && datas.length > 0) {
for (SearchHit data : datas) {
//存储的时候就是按照 SkuEsModel 对象存储,可直接转化为对象
String sourceAsString = data.getSourceAsString();
SkuEsModel skuEsModel = JSON.parseObject(sourceAsString, SkuEsModel.class);
//带上keyword才有高亮
if (StringUtils.isNotBlank(searchProductDto.getKeyword())) {
//获取高亮数据
Map<String, HighlightField> highlightFields = data.getHighlightFields();
if (highlightFields != null && highlightFields.size() > 0) {
//获取高亮 skuTitle
HighlightField skuTitle = highlightFields.get("skuTitle");
//获取高亮之后的结果
String highlightSkuTitle = skuTitle.getFragments()[0].string();
skuEsModel.setSkuTitle(highlightSkuTitle);
}
}
skuEsModels.add(skuEsModel);
}
}
searchResult.setProduct(skuEsModels);
//获取聚合信息
Aggregations aggregations = search.getAggregations();
//2、当前商品涉及到的所有属性信息
List<SearchResult.AttrVo> attrVos = new ArrayList<>();
ParsedNested attr_agg = aggregations.get("attr_agg");
//获取扁平化处理的聚合信息下的 通过属性id聚合的结果
ParsedLongTerms attr_id_agg = attr_agg.getAggregations().get("attr_id_agg");
//遍历通过属性id聚合的结果
for (Terms.Bucket bucket : attr_id_agg.getBuckets()) {
SearchResult.AttrVo attrVo = new SearchResult.AttrVo();
//1、得到属性的id
long attrId = bucket.getKeyAsNumber().longValue();
attrVo.setAttrId(attrId);
//2、获取通过属性id聚合下的通过属性名字聚合的结果,一个id只会对应一个名称,可以直接.get(0) 得到属性的名字
ParsedStringTerms attr_name_agg = bucket.getAggregations().get("attr_name_agg");
String attrName = attr_name_agg.getBuckets().get(0).getKeyAsString();
attrVo.setAttrName(attrName);
//3、获取通过属性id聚合下的通过属性值聚合的结果,一个id只会对应多个属性值,提取所有的属性值 得到属性的所有值
ParsedStringTerms attr_value_agg = bucket.getAggregations().get("attr_value_agg");
List<String> attrValues = attr_value_agg.getBuckets().stream().map(item -> item.getKeyAsString()).collect(Collectors.toList());
attrVo.setAttrValue(attrValues);
attrVos.add(attrVo);
}
searchResult.setAttrs(attrVos);
List<SearchResult.BrandVo> brandVos = new ArrayList<>();
//3、当前商品涉及到的所有品牌信息
//获取品牌的聚合,遍历聚合信息
ParsedLongTerms brand_agg = aggregations.get("brand_agg");
for (Terms.Bucket bucket : brand_agg.getBuckets()) {
SearchResult.BrandVo brandVo = new SearchResult.BrandVo();
//获取品牌的id
long brandId = bucket.getKeyAsNumber().longValue();
brandVo.setBrandId(brandId);
//获取品牌id聚合下的子聚合,品牌名称
ParsedStringTerms brand_name_agg = bucket.getAggregations().get("brand_name_agg");
String brandName = brand_name_agg.getBuckets().get(0).getKeyAsString();
brandVo.setBrandName(brandName);
//获取品牌id聚合下的子聚合,默认图片
ParsedStringTerms brand_image_agg = bucket.getAggregations().get("brand_image_agg");
String brandImage = brand_image_agg.getBuckets().get(0).getKeyAsString();
brandVo.setBrandImg(brandImage);
brandVos.add(brandVo);
}
searchResult.setBrands(brandVos);
//4、当前商品涉及到的所有分类信息
List<SearchResult.CatalogVo> catalogVos = new ArrayList<>();
//获取分类的聚合,遍历聚合信息
ParsedLongTerms catalog_agg = aggregations.get("catalog_agg");
for (Terms.Bucket bucket : catalog_agg.getBuckets()) {
SearchResult.CatalogVo catalogVo = new SearchResult.CatalogVo();
//获取分类id
long catalogId = bucket.getKeyAsNumber().longValue();
catalogVo.setCatalogId(catalogId);
//获取分类id聚合下的子聚合,分类的名称
ParsedStringTerms catalog_name_agg = bucket.getAggregations().get("catalog_name_agg");
String catalogName = catalog_name_agg.getBuckets().get(0).getKeyAsString();
catalogVo.setCatalogName(catalogName);
catalogVos.add(catalogVo);
}
searchResult.setCatalogs(catalogVos);
//5、分页信息-页码
searchResult.setPageNum(searchProductDto.getPageNum());
//获取分页信息
TotalHits totalHits = hits.getTotalHits();
//获取总记录数
Long total = totalHits.value;
//5、1分页信息、总记录数
searchResult.setTotal(total);
//5、2分页信息-总页码-计算 总记录/size 有余数 总记录/size+1 否则 总记录/size
int totalPages = (int) (total % ElasticSearchIndex.PRODUCT_PAGESIZE == 0 ? total / ElasticSearchIndex.PRODUCT_PAGESIZE : total / ElasticSearchIndex.PRODUCT_PAGESIZE + 1);
searchResult.setTotalPages(totalPages);
//5.3 设置页码
List<Integer> pageNavs = new ArrayList<>();
for (int i = 1; i <= totalPages; i++) {
pageNavs.add(i);
}
searchResult.setPageNavs(pageNavs);
//6、面包屑导航数据
if (searchProductDto.getAttrs() != null && searchProductDto.getAttrs().size() > 0) {
List<SearchResult.NavVo> navVos = searchProductDto.getAttrs().stream().map(attr -> {
SearchResult.NavVo navVo = new SearchResult.NavVo();
String[] s = attr.split("_");
navVo.setNavValue(s[1]);
SearchResult.AttrVo attrVo = attrVos.stream().filter(item -> item.getAttrId().equals(Long.valueOf(s[0]))).findFirst().orElse(null);
if (attrVo != null){
navVo.setNavName(attrVo.getAttrName());
}else {
navVo.setNavName("");
}
//2、取消了这个面包屑以后,我们要跳转到哪个地方,将请求的地址url里面的当前的品牌条件置空,剩余的url就是需要取消之后去的地方
//拿到所有的查询条件,去掉当前
String encode = null;
try {
encode= URLEncoder.encode(attr, "UTF-8");//将空格转成 +
encode.replace("+","%20"); //浏览器对空格的编码和Java不一样,差异化处理
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
String uri = searchProductDto.getUri();
String replace;
if (uri.contains("&attrs")){
replace = searchProductDto.getUri().replace("&attrs=" + encode, "");
}else {
replace = searchProductDto.getUri().replace("attrs=" + encode, "");
}
navVo.setLink("http://search.gulimall.com/list.html?" + replace);
searchResult.getAttrsId().add(Long.valueOf(s[0]));
return navVo;
}).collect(Collectors.toList());
searchResult.setNavs(navVos);
}
if (searchProductDto.getBrandId() != null && searchProductDto.getBrandId().size() > 0){
List<SearchResult.NavVo> navs = searchResult.getNavs();
SearchResult.NavVo navVo = new SearchResult.NavVo();
navVo.setNavName("品牌:");
List<Long> brandIds = searchProductDto.getBrandId();
StringBuffer stringBuffer = new StringBuffer();
for (Long brandId : brandIds) {
SearchResult.BrandVo brandVo1 = brandVos.stream().filter(brandVo -> brandVo.getBrandId().equals(brandId)).findFirst().orElse(null);
if (brandVo1 != null){
stringBuffer.append(brandVo1.getBrandName()+";");
String encode = null;
try {
encode= URLEncoder.encode(brandId+"", "UTF-8");//将空格转成 +
encode.replace("+","%20"); //浏览器对空格的编码和Java不一样,差异化处理
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
navVo.setNavValue(stringBuffer.toString());
String uri = searchProductDto.getUri();
String replace;
if (uri.contains("&brandId")){
replace = searchProductDto.getUri().replace("&brandId=" + encode, "");
}else {
replace = searchProductDto.getUri().replace("brandId=" + encode, "");
}
navVo.setLink("http://search.gulimall.com/list.html?" + replace);
navs.add(navVo);
}
}
}
return searchResult;
}
4、Java虚拟机监控
4.1、jconsole 与 jvisualvm
- Jdk 的两个小工具 jconsole、jvisualvm(升级版的 jconsole);通过命令行启动,可监控本地和 远程应用。远程应用需要配置。
- jconsole 能干什么(不推荐)
-
打开控制台输入:jconsole 命令(安装Java8环境后)
-
监控内存,堆内存,线程,类.....
-
- jvisualvm 能干什么(推荐)
-
打开控制台输入:jconsole 命令(安装Java8环境后),也可以直接在Java安装目录下双击打开程序。
-
监控内存泄露,跟踪垃圾回收,执行时内存、 cpu 分析,线程分析...
- 运行:正在运行的线程
-
休眠:sleep的线程
-
等待:wait的线程
-
驻留:线程池里面的空闲线程
- 监视:阻塞的线程,正在等待锁
-
安装插件方便查看 gc情况
-
- 结合Jmeter使用,压力测试,测试某个服务器响应速度或者接口或网关等情况,使用 jvisualvm 监控内存、CPU占用等情况,查看当前的压力测试吞吐量等信息。
4.2、项目设值堆内存大小
-
-Xms1024m -Xmx1024m -Xmn512m
-Xms 堆内存的初始大小,默认为物理内存的1/64
-Xmx 堆内存的最大大小,默认为物理内存的1/4
-Xmn 堆内新生代的大小
-
设置后,jvisualvm 查看效果
5、缓存
1、缓存使用
- 哪些数据适合放入缓存?
- 即时性、数据一致性要求不高的
- 访问量大且更新频率不高的数据(读多,写少)
- 举例:电商类应用,商品分类,商品列表等适合缓存并加一个失效时间(根据数据更新频率来定),后台如果发布一个商品,买家需要 5 分钟才能看到新的商品一般还是可以接受的。
- 读模式缓存使用流程
data = cache.load(id);//从缓存加载数据 If(data == null){ data = db.load(id);//从数据库加载数据 cache.put(id,data);//保存到 cache 中 } return data;
注意:在开发中,凡是放入缓存中的数据我们都应该指定过期时间,使其可以在系统即使没有主动更新数据也能自动触发数据加载进缓存的流程。避免业务崩溃导致的数据永久不一致问题
2、本地缓存
- 本地缓存在分布式下的问题
-
1、本地缓存 只能获取自己部署的服务下的缓存,不能获取其他服务器内存存储的缓存,因为本地缓存存储的是本地服务中的内存。
-
2、数据一致性的问题:假设数据需要进行更新了。负载到第一个服务器,更新了第一服务器的本地缓存,其余两个节点未更新,下次负载到第二个服务器,导致数据不一致。
-
-
分布式缓存
3、整合 redis 作为缓存
-
引入 redis-starter
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency
-
配置文件配置 redis
spring: redis: host: 192.168.56.10 port: 6379 # password: **** #未设置密码可不配置
-
使用 RedisTemplate 操作 redis
@Autowired StringRedisTemplate stringRedisTemplate; @Test public void testStringRedisTemplate(){ ValueOperations<String, String> ops = stringRedisTemplate.opsForValue(); ops.set("hello","world_"+ UUID.randomUUID().toString()); String hello = ops.get("hello"); System.out.println(hello); }
注意:lettuce操作redis的客户端,产生堆外内存溢出OutOfDirectMemoryError。SpringBoot2,0以后默认使用 Lettuce作为操作 redis的客户端。它使用 netty进行网络通。lettuce的bug导致netty堆外内存溢出,可设置:-Dio.netty.maxDirectMemory
解决方案:不能直接使用-Dio.netty.maxDirectMemory去调大堆外内存
1)、升级lettuce客户端。 2)、切换使用jedisLettuce、 jedis都是操作 redis的底层客户端。 Spring再次封装 redis Template
4、高并发下缓存失效问题
4.1、缓存穿透
- 缓存穿透:指查询一个一定不存在的数据,由于缓存是不命中,将去查询数据库,但是数据库也无此记录,我们没有将这次查询的null写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。
- 风险:利用不存在的数据进行恶意攻击,数据库瞬时压力增大,最终导致崩溃。
- 解决:将查询的null数据缓存到起来,并加入短暂过期时间。
4.2、缓存雪崩
- 缓存雪崩:指在我们设置缓存时key采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩。
- 解决:原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
4.3、缓存击穿
- 缓存穿透:对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。如果这个key在大量请求同时进来前正好失效,那么所有对这个key的数据查询都落到db,我们称为缓存击穿。
- 解决:加锁大量并发只让一个去查,其他人等待,查到以后释放锁,其他人获取到锁,先查缓存,就会有数据,不用去db。
4.4、加锁(本地锁)解决缓存击穿问题
- 加本地锁(synchronized,JUC(Lock))
- synchronized 加锁:锁住this,SpringBoot所有的组件在容器中都是单例的,可以锁住。
/** * spring是单例模式,所有项目启动就是一个对象,当前类的service都能用,本地缓存(如果有多个商品的微服务,而且占用的是本地内存,缓存就无法共享) */ private Map<String, Object> cache = new HashMap<>(); @Autowired private StringRedisTemplate redisTemplate; /** * 本地锁 * * @return */ public Map<String, List<Catelog2Vo>> catalogLevel23WithLock() { synchronized (this) { return catalogLevel23DB(); } } private Map<String, List<Catelog2Vo>> catalogLevel23DB() { //先从redis缓存找,找到直接返回,否则查询数据库 String catalogLevel23String = redisTemplate.opsForValue().get("catalogLevel23"); if (StringUtils.isNotBlank(catalogLevel23String)) { Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogLevel23String, new TypeReference<Map<String, List<Catelog2Vo>>>() { }); return result; } System.out.println("查询数据库--23-"); //先从本地缓存找,找到直接返回,否则查询数据库 Map<String, List<Catelog2Vo>> catalogLevel23 = (Map<String, List<Catelog2Vo>>) cache.get("catalogLevel23"); if (catalogLevel23 != null) { return catalogLevel23; } List<CategoryEntity> categoryEntities = this.list(); //筛选出1级分类 List<CategoryEntity> categoryLevel1s = categoryEntities.stream().filter(category -> category.getParentCid() == 0).collect(Collectors.toList()); //筛选出2级、3级分类 List<CategoryEntity> categoryLevel23s = categoryEntities.stream().filter(category -> category.getParentCid() != 0).collect(Collectors.toList()); Map<String, List<Catelog2Vo>> result = categoryLevel1s.stream().collect(Collectors.toMap(categoryLevel1 -> categoryLevel1.getCatId().toString(), categoryLevel1 -> { //筛选出当前父分类的所有子分类 List<CategoryEntity> categoryLevel2s = categoryLevel23s.stream().filter(categoryLevel23 -> categoryLevel23.getParentCid().equals(categoryLevel1.getCatId())).collect(Collectors.toList()); List<Catelog2Vo> catelog2Vos = categoryLevel2s.stream().map(categoryLevel2 -> { //构造2级分类 Catelog2Vo catelog2Vo = new Catelog2Vo(categoryLevel1.getCatId().toString(), null, categoryLevel2.getCatId().toString(), categoryLevel2.getName()); //从2及分类中查出3及分类,构造出3级分类 List<Catelog2Vo.Category3Vo> category3Vos = categoryLevel23s.stream().filter(categoryLevel23 -> categoryLevel23.getParentCid().equals(categoryLevel2.getCatId())).map(categoryLevel3 -> { Catelog2Vo.Category3Vo category3Vo = new Catelog2Vo.Category3Vo(categoryLevel2.getCatId().toString(), categoryLevel3.getCatId().toString(), categoryLevel3.getName()); return category3Vo; }).collect(Collectors.toList()); catelog2Vo.setCatalog3List(category3Vos); return catelog2Vo; }).collect(Collectors.toList()); return catelog2Vos; })); //cache.put("catalogLevel23",result); //缓存的保存也需要加到锁上,如果未加到锁方法中可能出现,还未保存到缓存中的的时候其他线程抢占到线程,就进入了查询数据库的操作,导致多次查询数据库 String s = JSON.toJSONString(result); redisTemplate.opsForValue().set("catalogLevel23", s); return result; }
- 使用本地锁出现的问题:假设有多个商品服务的服务,this锁只能锁住当前的服务没其他的服务还是有对应的锁,可能会多次查询数据库,需要使用分布式锁,就可以只访问一次数据库。
- synchronized 加锁:锁住this,SpringBoot所有的组件在容器中都是单例的,可以锁住。
- 锁-时序问题(缓存保存时间问题)
- 设置缓存时,也需要和业务代码是原子性操作,需要加上锁内,如果未加到锁方法中可能出现,还未保存到缓存中的的时候其他线程抢占到线程,就进入了查询数据库的操作,导致多次查询数据库。
- 设置缓存时,也需要和业务代码是原子性操作,需要加上锁内,如果未加到锁方法中可能出现,还未保存到缓存中的的时候其他线程抢占到线程,就进入了查询数据库的操作,导致多次查询数据库。
- 分布式下如何加锁
本地锁只能锁住,当前服务下的进程,不能锁住其他服务的进程,会导致多次数据库查询,每个服务查完一次后,还是会走缓存结果,性能方面影响不大,要处理这个问题,就需要分布式锁解决。
- IDEA 模拟多服务器部署(可以多复制上几个),请求发送给网关,由网关负载均衡的跑到各个服务(4个服务的服务名一样spring.application.name)。
#网关配置 - id: gulimall-product-host uri: lb://gulimall-product predicates: #断言请求头匹配,能够匹配上 gulimall.com的请求头的转发到对应服务 - Host=gulimall.com,item.gulimall.com
4.5、加锁(分布式锁)解决缓存击穿问题
- 基本原理(redis 命令 set lock 111 NX 当lock键存在时,返回nil,当lock键不存在时,返回OK,两个核心:设置锁值时,保证设置时间和设置值保持原子性;解锁时,保证删除的是当前线程的锁和删除锁两个的原子性(LUA脚本解决))
- 阶段一:
- 问题:业务代码报错或者执行完毕后,服务器突然断电,还未删除完毕,其他服务永远无法占用到该锁,一直循环;
- 解决办法:使用 try-finally 和 设置过期时间(阶段二解决)
- 阶段二:
- 问题:在设置锁自动过期时间时,还未设置上时,突然断电,导致为设置上过期时间就退出了,其他服务永远无法占用到该锁,一直循环;
- 解决办法:设置值和设置时间,是原子性操作,要么完成要么不完成(阶段三解决)
- 阶段三:
- 问题:线程1执行业务1的过程超过了lock锁的时间,lock锁就会自动删除,导致其他服务线程2抢占到锁,执行业务1,执行业务逻辑,线程1执行完逻辑后,进行锁的删除操作,结果删除了线程2设置的锁(误删除锁),导致很多线程又可以同时访问业务,可能会多次查询数据库,出现问题(没锁住)。
- 解决办法:只能删除自己的锁,设置一个值为uuid,判断值相同才可以删除,否则不能删除(阶段四解决)
- 假设5:
- 问题:锁过期时间为10秒,业务逻辑执行了9.5秒,获取lock锁的时间0.3秒,值已经获取到了,在进入 if判读 时,花了0.2秒,此时redis lock锁过期,已经更新成了别的线程的lock值,但此时已经进入了if,此时还是删除掉了别人的锁,导致很多线程又可以同时访问业务,可能会多次查询数据库,出现问题(没锁住)。
- 解决办法:获取值和删除锁需要原子操作,lua脚本和redis命令一起操作。(最终版解决)
- 最终形态:
- 存在的问题,如果业务执行过长,锁无法自动续期。
- 解决:可以设置锁过期时间长一点;可以使用Redisson框架解决。
/** * redis分布式锁 * 从数据库查找2、3级分类数据 * * @return */ public Map<String, List<Catelog2Vo>> catalogLevel23RedisLock() { /** * 加锁:this,springboot所有的组件在容器中都是单例的,可以锁住 * 使用本地锁出现的问题:假设有多个商品服务的微服务,this锁只能锁住当前的微服务没其他的微服务还是有对应的锁,需要使用分布式锁 */ //加锁 setIfAbsent:如果存在当前的lock键,就返回false,不存在返回true //Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "0"); //原子命令:设置值和设置时间,是原子性操作,要么完成要么不完成 String uuid = UUID.randomUUID().toString(); //出现假设4的问题 // Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "0",30, TimeUnit.SECONDS); Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 30, TimeUnit.SECONDS); if (lock) { //抢占锁成功 /** * 出现的问题: * 假设1:catalogLevel23DB抛出异常,导致锁永远无法删除,其他服务永远无法占用到该锁,一直循环,解决办法:try-finally * 假设2:catalogLevel23DB执行完毕后,服务器突然断电,还未删除完毕,其他服务永远无法占用到该锁,一直循环;解决办法:可以设置过期时间 * 假设3:在设置锁自动过期时间时,还未设置上时,突然断电,导致为设置上过期时间就退出了,其他服务永远无法占用到该锁,一直循环;解决办法:设置值和设置时间,是原子性操作,要么完成要么不完成 * 假设4:线程1执行业务1的过程超过了lock锁的时间,lock锁就会自动删除,导致其他服务线程2抢占到锁,执行业务1,执行业务逻辑,线程1执行完逻辑后,进行锁的删除操作,结果删除了线程2设置的锁(误删除锁),导致很多线程又可以同时访问业务,可能会多次查询数据库,出现问题(没锁住)。解决办法:只能删除自己的锁,设置一个值为uuid,判断值相同才可以删除,否则不能删除 * 假设5:锁过期时间为10秒,业务逻辑执行了9.5秒,获取lock锁的时间0.3秒,值已经获取到了,在进入 if判读 时,花了0.2秒,此时redis lock锁过期,已经更新成了别的线程的lock值,但此时已经进入了if,此时还是删除掉了别人的锁,导致很多线程又可以同时访问业务,可能会多次查询数据库,出现问题(没锁住)。解决办法:获取值和删除锁需要原子操作,lua脚本和redis命令一起操作。 * */ //设置锁自动过期时间 出现假设2、3的问题 //redisTemplate.expire("lock",30, TimeUnit.SECONDS); //出现假设1的问题 Map<String, List<Catelog2Vo>> map; try { System.out.println("获取redis锁成功======="); map = catalogLevel23DB(); //redis锁自动续期,暂时没做,在删除之前,锁的过期时间就删除了,需要在业务层面继续续锁,或者把锁的过期时间加长 } finally { //出现假设4的问题 //redisTemplate.delete("lock"); /* String lockValue = redisTemplate.opsForValue().get("lock"); //值相同才可以删除:出现假设5的问题 if (uuid.equals(lockValue)){ redisTemplate.delete("lock"); }*/ //lua脚本 String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; //执行脚本 返回值为 Long类型,KEYS[1] 对应 lock ARGV[1] 对应uuid的值,如果redis存lock的值和uuid相同,则进行删除,保证了原子性 Long lock1 = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList("lock"), uuid); } return map; } else { System.out.println("获取redis锁失败成功=======进行重试"); //抢占锁失败 try { Thread.sleep(200); } catch (InterruptedException e) { e.printStackTrace(); } //自旋的方式获取锁 return catalogLevel23RedisLock(); } }
5、Redisson 整合
5.1、原生Redisson整合
- 导入依赖
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.12.0</version> </dependency>
- 配置 RedissonClient
/** * Redisson的配置类 * @author pengjun */ @Configuration public class MyRedissonConfig { /** * 所有对Redisson的使用都是通过RedissonClient * @return */ @Bean(destroyMethod = "shutdown") public RedissonClient redissonClient(){ Config config = new Config(); //设置集群模式 // config.useClusterServers() // .addNodeAddress("127.0.0.1:7004", "127.0.0.1:7001"); //设置单个节点模式 //Redis url should start with redis:// or rediss:// (for SSL connection) 配置路径必须配置redis: 开头 // config.useSingleServer().setAddress("192.168.56.10:6379").setPassword(); config.useSingleServer().setAddress("redis://192.168.56.10:6379"); RedissonClient redissonClient = Redisson.create(config); return redissonClient; } }
- 测试打印客户端
@Autowired RedissonClient redissonClient; @Test public void redisson(){ System.out.println(redissonClient); }
5.2、使用
- lock和unlock
@Autowired private RedissonClient redissonClient; /** * 测试简单请求 * @return */ @ResponseBody @GetMapping({"testHello"}) public String testHello(){ //RLock 集成了Lock api都是一样的 RLock lock = redissonClient.getLock("my-lock"); //redis存储的是 my-lock : uuid:线程id , 看门狗:过期时间是自动续期的(如果业务执行过长,过期时间会自动增加,默认30s) //加锁的业务只要运行完成,就不会给当前锁续期,即使不手动释放锁,锁默认也在30s以后自动删除 //lock.lock();//阻塞住,只能得到锁才能往下走,其他的都只能等待,我们自己写的redis分布式锁,则是自旋的方式获取锁 阻塞式等待。默认加的锁都是30s //指定自动解锁时间,10s后自定删除锁,不会自动续期,自动解锁时间一定要大于业务员执行时间,会其他线程抢占到锁,当前线程删除锁时,抛出异常,删除的是别人的锁 lock.lock(10, TimeUnit.SECONDS); /** * 1)、锁的自动续期,如果业务超长,运行期间自动锁上新的30s。不用担心业务时间长,锁自动过期被删掉 * 2)、加锁的业务只要运行完成,就不会给当前锁续期,即使不手动解锁,锁默认会在30s内自动过期,不会产生死锁问题 * 3)、myLock.lock(10,TimeUnit.SECONDS); //10秒钟自动解锁,自动解锁时间一定要大于业务执行时间 * 问题:在锁时间到了以后,不会自动续期 */ //最佳实战: lock.lock(30, TimeUnit.SECONDS); 省掉了自动续期,30s的业务执行不完,也就是业务有问题 /** * 1、如果我们传递了锁的时间,就发送redis执行脚本,进行站锁,默认超时就是我们指定的时间 * 2、如果我们未指定锁的超时时间,就是使用 30 * 1000 【lockWatchdogTimeout看门狗的默认时间】 * 只要占锁成功,就会启动一个定时任务【重新给锁设置过期时间,新的过期时间就是看门狗的默认时间】,每隔10秒都会自动的再次续期,续成30秒 * 定时任务 internalLockLeaseTime【看门狗的默认时间】 / 3 ,10s中执行一次, 10s后自动续期,续期到看门狗时间 */ try { System.out.println("获取锁成功,执行业务需求--"+Thread.currentThread().getName() + Thread.currentThread().getId()); //模拟业务超长,发送两次请求,一个只能等待,执行完成后,另一个才能获取锁 Thread.sleep(30000); } catch (Exception e) { e.printStackTrace(); } finally { //假设:有两个商品服务,一个请求 10000端口,一个请求10001端口,请求10000端口时,突然宕机,还未释放锁,10001端口的线程也能获取到锁,redisson会自己解锁。 lock.unlock(); System.out.println("解锁锁成功"+Thread.currentThread().getName() + Thread.currentThread().getId()); } return "hello"; }
- 读写锁
@Autowired private RedissonClient redissonClient; @Autowired private StringRedisTemplate redisTemplate; /** * 读写锁:保证一定能读到最新数据,修改期间,写锁是一个排他锁(互斥锁、独享锁),读锁是一个共享锁 * 写锁没释放读就必须等待,读取的是最新的数据 * http://localhost:10000/write http://localhost:10000/read * 先发送写锁请求,在发送读锁请求,读锁请求需要等待写锁请求写完之后,读锁才可以读取数据,处于阻塞状态 * 读锁可以共享,不会阻塞 * * 读 + 读 :相当于无锁,只会在redis中记录好,所有当前的读锁,他们都是会同时加锁成功 (模拟:写发送写请求,在四个浏览器窗口发读请求,都一起获取到了锁) * 读 + 写 :有读锁,写也需要等待 (模拟:发送读请求,在发送写请求,处理读业务20s,才能写入成功) * 写 + 读 :等待写锁释放,才可以读取 * 写 + 写 :阻塞方式,只能一个个写 * 只要有写的存在,必须等待 */ /** * 写锁 * @return */ @ResponseBody @GetMapping({"write"}) public String write(){ String s = UUID.randomUUID().toString(); //获取读写锁 RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("rw-lock"); //获取写锁 RLock wLock = readWriteLock.writeLock(); try { System.out.println("加写锁成功==========="); //上写锁 写锁没释放,读锁就不能读 wLock.lock(); redisTemplate.opsForValue().set("write",s); //模拟业务执行20s Thread.sleep(20000); } catch (Exception e) { e.printStackTrace(); } finally { wLock.unlock(); System.out.println("解写锁成功==========="); } return s; } /** * 读锁 * @return */ @ResponseBody @GetMapping({"read"}) public String read(){ //获取读写锁 RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("rw-lock"); //juc的读写锁 // ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(); // ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock(); //获取写锁 RLock rLock = readWriteLock.readLock(); String s = ""; try { System.out.println("加读锁成功==========="); //上写锁 写锁没释放,读锁就不能读 rLock.lock(); s = redisTemplate.opsForValue().get("write"); //模拟业务执行20s Thread.sleep(20000); } catch (Exception e) { e.printStackTrace(); } finally { rLock.unlock(); System.out.println("解读锁成功==========="); } return s; }
- 信号量锁
/** * CountDownLatch :减少计数 * CountDownLatch主要有两个方法,当一个或多个线程调用await方法时,这些线程会阻塞。 * 其它线程调用countDown方法会将计数器减1(调用countDown方法的线程不会阻塞), * 当计数器的值变为0时,因await方法阻塞的线程会被唤醒,继续执行。 * * CyclicBarrier:循环栅栏 * CyclicBarrier的字面意思是可循环(Cyclic)使用的屏障(Barrier)。 * 它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。 * 线程进入屏障通过CyclicBarrier的await()方法。 * * Semaphore信号灯 * 在信号量上我们定义两种操作: * acquire(获取) 当一个线程调用acquire操作时,它要么通过成功获取信号量(信号量减1),要么一直等下去,直到有线程释放信号量,或超时。 * release(释放)实际上会将信号量的值加1,然后唤醒等待的线程。 * 信号量主要用于两个目的,一个是用于多个共享资源的互斥使用,另一个用于并发线程数的控制。 * https://blog.csdn.net/weixin_43947102/article/details/123417002 */ /** * 信号量 * 模拟停车位:3个停车位,只能停3辆车,在来了车,就需要等待,其他的开走。 * 三次调用 停车请求,第四调用时,则需等待阻塞,等待开出停车位请求完成,才可以获取到停车位 * 可以做分布式的限流 */ @ResponseBody @GetMapping({"park"}) public String park() throws InterruptedException { // ReentrantLock reentrantLock = new ReentrantLock(); RSemaphore park = redissonClient.getSemaphore("park"); /* juc信号量 Semaphore semaphore = new Semaphore(3); semaphore.acquire(); semaphore.release();*/ //获取信号量,如果能获取到,则往下执行,获取不到则需要等待 //park.acquire(); //尝试获取一个信号量,如果能够获取到就返回ture,获取不到也不会等待,返回false boolean b = park.tryAcquire(); if (b){ //获取到了信号量,处理业务需求 }else { return "error"+ b; } return "ok"+ b; } /** * 开出停车位 */ @ResponseBody @GetMapping({"go"}) public String go(){ RSemaphore park = redissonClient.getSemaphore("park"); //释放信号量 park.release(); return "ok"; }
- 闭锁
/** * 模拟:5个班级,人全部走完了,门卫才可以关门 * 先访问 lockDoor请求,直接等待,需要访问 gogogo/{id}请求5次之后,lockDoor请求才会继续往下执行 */ @ResponseBody @GetMapping({"lockDoor"}) public String lockDoor() throws InterruptedException, BrokenBarrierException { RCountDownLatch door = redissonClient.getCountDownLatch("door"); //JUC:CountDownLatch countDownLatch = new CountDownLatch(5); /* CyclicBarrier cyclicBarrier = new CyclicBarrier(7, () ->{ System.out.println("七龙珠汇合"); }); cyclicBarrier.await();*/ door.trySetCount(5); //等待,只有等待5个班级的人走光了才会继续往下执行 door.await(); return "ok"; } /** * 班级走 */ @ResponseBody @GetMapping({"gogogo/{id}"}) public String gogogo(@PathVariable("id") Integer id) { RCountDownLatch door = redissonClient.getCountDownLatch("door"); //班级走一个,减一个,减到位0,lockDoo()才会继续往下走 door.countDown(); return "班级"+id+"走"; }
6、缓存数据一致性问题
6.1、双写模式
- 更新缓存数据的同时也更新缓存。
- 出现的问题: 假设两个线程都对同一条数据进行了更新操作,线程1更新完成后,还未写入缓存,线程2此时,进行了更新和写入缓存,线程1此时恢复,更新了缓存,导致数据还是不一致。产生了脏数据,这是暂时性的脏数据问题,但是在数据稳定,缓存过期以后,又能得到最新的正确数据,能保证最终一致性。
- 解决办法:
- 进行加锁操作,更新数据库和更新缓存都在同一把锁上
- 系统允不允许短暂的数据不一致问题存在
6.2、失效模式
- 更新缓存数据的同时删除缓存,由下次查询的时候插入缓存。
- 出现的问题
6.3、延迟双删
- 先删除缓存
- 再写数据库
- 休眠500毫秒
- 再次删除缓存
6.4、总结
- 无论是双写模式还是失效模式,都会导致缓存的不一致问题。即多个实例同时更新会出事。怎么办?
- 如果是用户纬度数据(订单数据、用户数据),这种并发几率非常小,不用考虑这个问题,缓存数据加上过期时间,每隔一段时间触发读的主动更新即可。
- 如果是菜单,商品介绍等基础数据,也可以去使用canal订阅binlog的方式。
- 缓存数据+过期时间也足够解决大部分业务对于缓存的要求。
- 通过加锁保证并发读写,写写的时候按顺序排好队。读读无所谓。所以适合使用读写锁。(业务不关心脏数据,允许临时脏数据可忽略)。
- 总结:
- 我们能放入缓存的数据本就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间,保证每天拿到当前最新数据即可。
- 我们不应该过度设计,增加系统的复杂性。
- 遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点。.
6.5、异步更新缓存(基于Mysql binlog的同步机制 Canal)
7、SpringCache
7.1、整合
- 导入依赖
<!-- 整合springsession 使用Redis作为缓存 --> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency>
- 自动配置
- CacheAutoConfiguration 会导入 RedisCacheConfiguration,它自动配置好了缓存管理器 RedisCacheManager,缓存管理器会判断是否RedisCacheConfiguration配置,有了就用门自定义的,没有用默认的。
- 启动类上标记 @EnableCaching 开启spring cache 缓存功能
- 配置文件使用redis作为缓存
spring: cache: type: redis redis: time-to-live: 360000 #${random.int} #key的过期时间,单位ms #key-prefix: CACHE_ #key的所有前缀,如果指定了前缀就用我们指定的前缀,如果没有就默认使用,注解上配置的 key 属性的值作为缓存的名字,以value的值作为分组 #use-key-prefix: true #是否开启前缀,开启了就会添加上述前缀CACHE_缓存名,不开启就只有 缓存名:value cache-null-values: true #是否缓存空置,防止缓存穿透 #指定缓存的名称,存储缓存时可以从指定的缓存名拿 # cache-names:
- 缓存注解使用(直接标记在方法上即可)
- @Cacheable: Triggers cache population,触发将数据保存到缓存的操作
- @CacheEvict; Triggers cache eviction,:触发将数据从缓存删除的操作。失效模式
- @CachePut: Updates the cache without interfering with the method execution,不影响方法执行更新缓存。双写模式
- @Caching: Regroups multiple cache operations to be applied on a method,组合以上多个操作
- @CacheConfig: Shares some common cache- related settings at class-level;在类级别共享缓存的相同配置
7.2、使用细节
//===============================用法========================
//@Cacheable(value = {"category"}) //value 指定在那个区 可以指定多个区内 自定义使用字符串作为key
//@Cacheable(value = {"category"},key = "'myCategory'") //自定义使用字符串作为key,必须加单引号,不加单引号会当做表达式解析
//@Cacheable(value = {"category"},key = "#root.method.name",sync = true) //获取当前方法名作为key sync 加本地锁的缓存
public List<CategoryEntity> queryCategoryLevel1(){}
//@CacheEvict(value = "category" ,key = "'queryCategoryLevel1'") //Redis和MySQL数据同步问题,失效模式:删除key为 queryCategoryLevel 的缓存
// @CacheEvict(value = "category",allEntries = true) //更新了分类数据,将所有category分组下的缓存全部删除 最好开启默认前缀
//@CachePut(value = "category",key = "'catalogLevel23'") //Redis和MySQL数据同步问题,双写模式:更新后,如果当前更新了,方法返回了的对象(可以再次去查找新的分类数据),在把该对象缓存起来
//@CachePut(value = "category",key = "'queryCategoryLevel1'")
@Caching(
//组合多个springcatch注解,evict:标记多个更新删除缓存后,删除多个key
evict = {
@CacheEvict(value = "category",key = "'catalogLevel23'"),
@CacheEvict(value = "category",key = "'queryCategoryLevel1'")
}
)
public void updateCascade(CategoryEntity category){}
//============================ 缓存的配置=======================
/**
* 缓存的配置(包括key和value的格式化,读取配置文件信息)
* @author pengjun
*/
@Configuration
@EnableCaching //开启spring cache 缓存功能
//@EnableConfigurationProperties(CacheProperties.class)
public class MyRedisCacheConfiguration {
// @Autowired
// CacheProperties cacheProperties;
/**
* 1、缓存的配置文件中的bean没有注入到容器中
* @ConfigurationProperties(prefix = "spring.cache")
* public class CacheProperties
* 2、要让他注入到容器中
* @EnableConfigurationProperties(CacheProperties.class)
* @return
*/
@Bean
public RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties){
//获取系统默认的缓存配置
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
// config.serializeKeysWith都是返回一个new的新的RedisCacheConfiguration,需要使用config = 去接收反回来的新对象,配置才可以全设置上
config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
//获取缓存的配置文件信息,设置当前的缓存配置应用上配置文件的配置
CacheProperties.Redis redisProperties = cacheProperties.getRedis();
//读取配置文件中的配置信息,如果不配置,配置文件配置的信息就会失效
if (redisProperties.getTimeToLive() != null) {
config = config.entryTtl(redisProperties.getTimeToLive());
}
if (redisProperties.getKeyPrefix() != null) {
config = config.prefixKeysWith(redisProperties.getKeyPrefix());
}
if (!redisProperties.isCacheNullValues()) {
config = config.disableCachingNullValues();
}
if (!redisProperties.isUseKeyPrefix()) {
config = config.disableKeyPrefix();
}
return config;
}
}
-
每一个需要缓存的数据我们都来指定要放到那个分区的缓存。【相当于缓存分区(按照业务类型分,也可以放到多个分组中)】
-
代表当前方法的结果需要缓存: 如果缓存中有,方法不用调用;如果缓存中没有,就调用方法。先从缓存中获取,缓存中有直接返回,缓存中没有,调用目标方法(查找数据库),在把执行的目标方法返回值存储到缓存中。
-
默认行为
-
Key的默人自动生成:缓存的名字::SimpleKey{}(自动生成的key值)。
-
缓存的value的值,默认使用Jdk序列化机制,将序列化的数据存到redis。
-
默认ttl时间 为 -1 ,永不过期。
-
-
自定义
-
指定生成的缓存的使用的Key:key属性指定,可以接收一个SpEL表达式。
-
定缓存的数据的存活时间 :配置文件中修改配置属性(spring.cache.redis.time-to-live: 360000 #${random.int} #key的过期时间,单位ms)。
-
将数据保存为Json格式:需要自定义 缓存管理器。
-
-
Spring-Cache的不足:CacheManager (RedisCacheManager) -> Cache(RedisCache)
-
读模式:
-
解决:加随机过期时间, SpringCache 配置文件 time-to-live: 360000 #${random.int}。
-
缓存雪崩:大量的key同时过期。
-
解决:加锁,默认是无锁的,sync = true 就是加锁状态,是本地锁,但不影响(多服务也就多查几次),不是分布式锁。没指定sync = true, RedisCache 就不会调用同步的 synchronized 的方法指定了sync = true 会调用 RedisCache 的 public synchronized <T> T get(Object key, Callable<T> valueLoader)。
-
缓存击穿:大量并发进来同时查询一个数据,此时该数据整好达到过期时间,请求直接达到数据库。
-
解决:缓存空数据;springCache 配置文件 cache-null-values: true
-
缓存穿透:查询一个数据库和Redis都不存在的数据,每次请求都进入数据库查询
-
-
写模式:(缓存与数据库一致,SpringCache没有管)
-
读写加锁:读多,写少的情况加锁。缓存的数据本就不应该是实时性、一致性要求超高的,遇到实时性、一致性要求高的数据,就应该查数据库。
-
引入Canal,感知到MySQL的更新去更新数据库(使用Canal订阅Binlog的方式)。
-
读多写多,直接去数据库查询就行。
-
读多写少读数据直接用SpringCache,写数据可以加读写锁,读多写多直接查数据库
-
-
总结:
-
常规数据(读多写少,及时性,一致性要求不高的数据),可以使用SpringCatch。
-
写模式:只要设置缓存时间即可。
-
特殊数据(及时性、一致性要求搞,又要求缓存的数据)特殊设计。
-
-
-
自定义缓存管理器原理:CacheAutoConfiguration -> RedisCacheConfiguration ->自动配置了 RedisCacheManager -> 初始化所有的缓存设置的名称(指定缓存的名称,存储缓存时可以从指定的缓存名拿 cache-names: ) -> 每个缓存决定使用什么配置 -> 如果RedisCacheConfiguration 有就用自己的配置,没有就用默认配置->想改缓存的配置,只需要给容器中放一个 RedisCacheConfiguration 即可->就会应用到当前 RedisCacheManager 管理的所有缓存分区中。
6、异步和线程
6.1、初始化线程的 4 种方式
- 继承 Thread
public class Thread01 extends Thread { @Override public void run() { System.out.println("Thread01"+Thread.currentThread().getId() + "执行了"); } } public static void main(String[] args) throws ExecutionException, InterruptedException { System.out.println("main......start....."); Thread thread = new Thread01(); thread.start(); System.out.println("main......end....."); }
- .实现 Runnable 接口
public class Runnable01 implements Runnable{ @Override public void run() { System.out.println("Runnable01"+Thread.currentThread().getId() + "执行了"); } } public static void main(String[] args) throws ExecutionException, InterruptedException { System.out.println("main......start....."); Runnable01 runnable01 = new Runnable01(); new Thread(runnable01).start(); System.out.println("main......end....."); }
- 实现 Callable 接口 + FutureTask (可以拿到返回结果,可以处理异常)
public class Callable01 implements Callable<Integer> { @Override public Integer call() throws Exception { System.out.println("Callable01"+Thread.currentThread().getId() + "执行了"); return 2; } } public static void main(String[] args) throws ExecutionException, InterruptedException { System.out.println("main......start....."); FutureTask<Integer> futureTask = new FutureTask<>(new Callable01()); new Thread(futureTask).start(); System.out.println(futureTask.get());//获取结果 会暂停当前线程 获取到线程结果后继续往下执行 System.out.println("main......end....."); }
- 线程池
public static ExecutorService executor = Executors.newFixedThreadPool(10); public static void main(String[] args) throws ExecutionException, InterruptedException { System.out.println("main......start....."); executor.execute(new Runnable01()); Future<Integer> submit = executor.submit(new Callable01()); //阻塞状态,没获取到结果,就会阻塞主线程 Integer integer = submit.get(); System.out.println("main......start....." + integer); }
- 区别和场景
- 方式 1 和方式 2:主进程无法获取线程的运算结果。不适合当前场景,可以导致服务器资源耗尽。
- 方式 3:主进程可以获取线程的运算结果,但是不利于控制服务器中的线程资源。可以导致服务器资源耗尽。
- 通过线程池性能稳定,也可以获取执行结果,并捕获异常。但是,在业务复杂情况下,一
个异步调用可能会依赖于另一个异步调用的执行结果。
6.2、线程池的七大参数
/**
* 线程池开启线程
* 详细查看此文章:https://blog.csdn.net/weixin_43947102/article/details/123417002
*/
public void threadPool() {
//固定的线程数 core = max
Executors.newFixedThreadPool(10);
//缓存线程池 core =0 ;max = Integer.MAX_VALUE
Executors.newCachedThreadPool();
//定时线程池 core = 10 ;max = Integer.MAX_VALUE
Executors.newScheduledThreadPool(10);
//一个线程数 core = max = 1
Executors.newSingleThreadExecutor();
/*
自定义线程池的七个参数:
public ThreadPoolExecutor(int corePoolSize,// 线程池中的常驻核心线程数 除非设置了 allowCoreThreadTimeOut
int maximumPoolSize, //线程池中能够容纳同时执行的最大线程数,此值必须大于等于1
long keepAliveTime,// 多余的控线线程的存活时间 当前线程池中数量超过corePoolSize时,当空闲时间达到KeepAliveTime时,多余线程会被销毁直到只剩下corePooleSize个线程为止
TimeUnit unit, //KeepAliveTime的单位
BlockingQueue<Runnable> workQueue,// 阻塞队列,用来存储等待执行的任务,如果当前对线程的需求超过了 corePoolSize大小,就会放在这里等待空闲线程执行。
ThreadFactory threadFactory, //表示生成线程池中工作线程的线程工厂,用于创建线程,一般默认的即可,比如指定线程名等
RejectedExecutionHandler handler)//拒绝策略,表示当队列名满了,并且工作线程大于等于线程池的最大线程数时,如何来拒绝请求执行的runnable的策略
}
*/
}
-
corePoolSize:池中一直保持的线程的数量,即使线程空闲。除非设置了allowCoreThreadTimeOut
-
maximumPoolSize:pool池中允许的最大的线程数
-
keepAliveTime:当线程数大于核心线程数的时候,线程在最大多长时间没有接到新任务就会终止释放,最终线程池维持在 corePoolSize 大小
-
unit:时间单位
-
workQueue:阻塞队列,用来存储等待执行的任务,如果当前对线程的需求超过了 corePoolSize大小,就会放在这里等待空闲线程执行。
-
threadFactory:创建线程的工厂,比如指定线程名等
-
handler:拒绝策略,如果线程满了,线程池就会使用拒绝策略。
6.3、线程池运行流程
- 线程池创建,准备好 core 数量的核心线程,准备接受任。务
- 新的任务进来,用 core 准备好的空闲线程执行。
- core 满了,就将再进来的任务放入阻塞队列中。空闲的 core 就会自己去阻塞队
列获取任务执行。 - 阻塞队列满了,就直接开新线程执行,最大只能开到 max 指定的数量。
- max 都执行好了。Max-core 数量空闲的线程会在 keepAliveTime 指定的时间后自
动销毁。最终保持到 core 大小。 - 如果线程数开到了 max 的数量,还有新任务进来,就会使用 reject 指定的拒绝策
略进行处理。
- core 满了,就将再进来的任务放入阻塞队列中。空闲的 core 就会自己去阻塞队
- 所有的线程创建都是由指定的 factory 创建的。
- 案例分析:
-
一个线程池 core 7 ; max 20 , queue : 50 , 100 并发进来怎么分配的:先有 7 个能直接得到执行,接下来 50 个进入队列排队,在多开 13 个继续执行。现在 70 个被安排上了。剩下 30 个默认拒绝策略。
-
6.4、开发中为什么使用线程池
- 降低资源的消耗:通过重复利用已经创建好的线程降低线程的创建和销毁带来的损耗
- 提高响应速度:因为线程池中的线程数没有超过线程池的最大上限时,有的线程处于等待分配任务的状态,当任务来时无需创建新的线程就能执行。
- 提高线程的可管理性:线程池会根据当前系统特点对池内的线程进行优化处理,减少创建和销毁线程带来的系统开销。无限的创建和销毁线程不仅消耗系统资源,还降低系统的稳定性,使用线程池进行统一分配
6.5、CompletableFuture异步编排
- 业务场景:查询商品详情页的逻辑比较复杂,有些数据还需要远程调用,必然需要花费更多的时间。假如商品详情页的每个查询,需要如下标注的时间才能完成,那么,用户需要 5.5s 后才能看到商品详情页的内容。很显然是不能接受的。如果有多个线程同时完成这 6 步操作,也许只需要 1.5s即可完成响应。
- 创建异步对象
- CompletableFuture 提供了四个静态方法来创建一个异步操作。
- runXxxx 都是没有返回结果的,supplyXxx 都是可以获取返回结果的
- executor参数:可以传入自定义的线程池,否则就用默认的线程池;
- CompletableFuture 提供了四个静态方法来创建一个异步操作。
- 计算完成时回调方法
public static ExecutorService executor = Executors.newFixedThreadPool(10); public static void main(String[] args) throws ExecutionException, InterruptedException { System.out.println("main......start....."); /** * 没有返回值的异步回调 */ CompletableFuture<Void> future0 = 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 / 0; System.out.println("运行结果:" + i); return i; }, executor).whenComplete((res,exception) -> { //res 返回结果 没异常返回null exception:异常信息 有异常无法回结果 //虽然能得到异常信息,但是没法修改返回数据 System.out.println("异步任务成功完成了...结果是:" + res + "异常是:" + exception); }).exceptionally(throwable -> { //可以感知异常,同时返回默认值,抛出异常返回默认值 return 10; }); //获取结果,也会阻塞主线程 System.out.println(future.get()); //10 System.out.println("main......end....."); }
- handle 方法
public static ExecutorService executor = Executors.newFixedThreadPool(10); public static void main(String[] args) throws ExecutionException, InterruptedException { System.out.println("main......start....."); /** * 统一处理结果和异常信息 */ CompletableFuture<Integer> future2 = 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; }); //获取结果,也会阻塞主线程 System.out.println(future2.get()); //抛出异常返回0 否则返回10 System.out.println("main......end....."); }
- 线程串行化方法
- thenApply 方法:当一个线程依赖另一个线程时,获取上一个任务返回的结果,并返回当前任务的返回值。
- thenAccept 方法:消费处理结果。接收任务的处理结果,并消费处理,无返回结果。
- thenRun 方法:只要上面的任务执行完成,就开始执行 thenRun,只是处理完任务后,执行thenRun 的后续操作。
- 带有 Async 默认是异步执行的。同之前。
- 以上都要前置任务成功完成。
- Function<? super T,? extends U> T:上一个任务返回结果的类型,U:当前任务的返回
值类型。
/** * 线程串行化:一步一步执行 run、accept、apply 就这三种,记住 * 1、thenRunAsync:不能获取上一步的执行结果,无返回值,继续调用其他业务 * 2、thenAcceptAsync:能接受上一步结果,但是无返回值 * 3、thenApplyAsync:能接受上一步结果,有返回值 * */ CompletableFuture<String> future3 = 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....." + future3.get());
- 两任务组合 - 都要完成(两个任务必须都完成,触发该任务)
- thenCombine:组合两个 future,获取两个 future 的返回结果,并返回当前任务的返回值
- thenAcceptBoth:组合两个 future,获取两个 future 任务的返回结果,然后处理任务,没有返回值。
- runAfterBoth:组合两个 future,不需要获取 future 的结果,只需两个 future 处理完任务后,处理该任务。
/** * 两个线程都完成了任务后在完成另外一个任务 */ CompletableFuture<Object> f1 = CompletableFuture.supplyAsync(() -> { System.out.println("任务1线程:" + Thread.currentThread().getId()); int i = 10 / 2; System.out.println("任务1运行结果:" + i); return i; }, executor); CompletableFuture<Object> f2 = CompletableFuture.supplyAsync(() -> { System.out.println("任务2线程:" + Thread.currentThread().getId()); try { Thread.sleep(3000); System.out.println("任务2运行结果:"); } catch (InterruptedException e) { e.printStackTrace(); } return "Hello"; }, executor); //任务1、2都完成才完成任务3,且无法感知,任务1、2的返回值结果,任务3本身也无返回值 /* f1.runAfterBothAsync(f2,()->{ System.out.println("任务3线程:" + Thread.currentThread().getId()); System.out.println("任务3运行结果:"); },executor);*/ //任务1、2都完成才完成任务3, 能够获取任务1、2的返回值结果,任务3本身无返回值 /* f1.thenAcceptBothAsync(f2,(f1,f2) ->{ System.out.println("任务3线程:" + Thread.currentThread().getId() +"之前的返回结果"+f1+"--->"+f2); System.out.println("任务3运行结果:"); },executor);*/ //任务1、2都完成才完成任务3, 能够获取任务1、2的返回值结果,任务3本身也可以有返回值结果 /* CompletableFuture<String> f3 = f1.thenCombineAsync(f2, (f1, f2) -> { System.out.println("任务3线程:" + Thread.currentThread().getId() + "之前的返回结果" + f1 + "--->" + f2); System.out.println("任务3运行结果:"); return f1 + "--->" + f2 + "--->" + "HaHa"; }, executor); System.out.println("任务3的返回结果:"+f3.get());*/
- 两任务组合 - 一个完成(当两个任务中,任意一个 future 任务完成的时候,执行任务)
- applyToEither:两个任务有一个执行完成,获取它的返回值,处理任务并有新的返回值。
- acceptEither:两个任务有一个执行完成,获取它的返回值,处理任务,没有新的返回值。
- runAfterEither:两个任务有一个执行完成,不需要获取 future 的结果,处理任务,也没有返回值。
CompletableFuture<Object> f1 = CompletableFuture.supplyAsync(() -> { System.out.println("任务1线程:" + Thread.currentThread().getId()); int i = 10 / 2; System.out.println("任务1运行结果:" + i); return i; }, executor); CompletableFuture<Object> f2 = CompletableFuture.supplyAsync(() -> { System.out.println("任务2线程:" + Thread.currentThread().getId()); try { Thread.sleep(3000); System.out.println("任务2运行结果:"); } catch (InterruptedException e) { e.printStackTrace(); } return "Hello"; }, executor); /** * 两个任务有一个执行就执行任务3 */ //任务1或者2完成一个任务,任务3就执行, 不能获取任务1、2的返回值结果,任务3本身无返回值结果 f1.runAfterEitherAsync(f2,()->{ System.out.println("任务3线程:" + Thread.currentThread().getId()); },executor); //任务1或者2完成一个任务,任务3就执行, 能获取任务1、2先执行完任务的返回值结果,任务3本身无返回值结果 /* f1.acceptEitherAsync(f2,(res)->{ System.out.println("任务3线程:" + Thread.currentThread().getId() + "====》上次任务执行结果"+res); },executor);*/ //任务1或者2完成一个任务,任务3就执行, 能获取任务1、2先执行完任务的返回值结果,任务3有返回值结果 /* CompletableFuture<String> f3 = f1.applyToEitherAsync(f2, (res) -> { System.out.println("任务3线程:" + Thread.currentThread().getId() + "====》上次任务执行结果" + res); return res + "hehe"; }, executor); System.out.println(f3.get());
- 多任务组合
- allOf:等待所有任务完成。
- anyOf:只要有一个任务完成。
/** * 所有异步任务都处理完后才可以往下走 默认线程池为守护线程,主线程结束就会结束 */ CompletableFuture<String> f11 = CompletableFuture.supplyAsync(() -> { System.out.println("获取商品默认图片"); return "hehe.jpg"; }, executor); CompletableFuture<String> f12 = CompletableFuture.supplyAsync(() -> { System.out.println("获取商品属性"); return "256G + 8G"; }, executor); CompletableFuture<String> f13 = CompletableFuture.supplyAsync(() -> { try { Thread.sleep(3000); System.out.println("获取商品分类"); } catch (InterruptedException e) { e.printStackTrace(); } return "华为"; }, executor); /* CompletableFuture<Void> allOf = CompletableFuture.allOf(f11, f12, f13); //阻塞等待所有的异步线程执行完成,才能往下执行 allOf.get(); //获取结果 System.out.println(f11.get()+"->"+f12.get()+"->"+f13.get());*/ CompletableFuture<Object> anyOf = CompletableFuture.anyOf(f11, f12, f13); //只有有一个异步线程完成后,就可以继续往下执行 Object o = anyOf.get(); System.out.println(o); System.out.println("main......end....." + future3.get());
7、MD5&盐值&BCrypt
- MD5:Message Digest algorithm 5,信息摘要算法。
- 压缩性:任意长度的数据,算出的MD5值长度都是固定的。
- 容易计算:从原数据计算出MD5值很容易。
- 抗修改性:对原数据进行任何改动,哪怕只修改1个字节,所得到的MD5值都有很大区别。
- 强抗碰撞:想找到两个不同的数据,使它们具有相同的MD5值,是非常困难的。
- 不可逆
- 加盐:通过生成随机数与MD5生成字符串进行组合,数据库同时存储MD5值与salt值。验证正确性时使用salt进行MD5即可。
@Test public void tests(){ //无盐的MD5 String s = DigestUtils.md5Hex("123456"); System.out.println(s); //随机盐值 System.out.println(Md5Crypt.md5Crypt("123456".getBytes())); //指定盐值加密 System.out.println(Md5Crypt.md5Crypt("123456".getBytes(),"$1$qqqqqqqq")); BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(); //$2a$10$GT0TjB5YK5Vx77Y.2N7hkuYZtYAjZjMlE6NWGE2Aar/7pk/Rmhf8S //$2a$10$cR3lis5HQQsQSSh8/c3L3ujIILXkVYmlw28vLA39xz4mHDN/NBVUi //盐值加密 使用同一个铭文 加密出来的数据都不同 String encode = bCryptPasswordEncoder.encode("123456"); //使用加密后的密文 进行验证 无需存储盐值 盐值已经存在了加密后的密文中 会自动算出盐值后匹配 boolean matches = bCryptPasswordEncoder.matches("123456", "$2a$10$GT0TjB5YK5Vx77Y.2N7hkuYZtYAjZjMlE6NWGE2Aar/7pk/Rmhf8S"); System.out.println(encode+"==>" + matches); }
8、社交登陆
- QQ、微博、github 等网站的用户量非常大,别的网站为了简化自我网站的登陆与注册逻辑,引入社交登陆功能;
- 步骤:
- 1)、用户点击 QQ 按钮
- 2)、引导跳转到 QQ 授权页
- 3)、用户主动点击授权,跳回之前网页。
1.1、OAuth2.0
- OAuth(不推荐使用): OAuth(开放授权)是一个开放标准,允许用户授权第三方网站访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方网站或分享他们数据的所有内容。
- OAuth2.0(推荐使用):对于用户相关的 OpenAPI(例如获取用户信息,动态同步,照片,日志,分享等),为了保护用户数据的安全和隐私,第三方网站访问用户数据前都需要显式的向用户征求授权。
- 流程
-
用户打开客户端以后,客户端要求用户给予授权。
-
用户同意给予客户端授权。
-
客户端使用上一步获得的授权,向认证服务器申请令牌。
-
认证服务器对客户端进行认证以后,确认无误,同意发放令牌。
-
客户端使用令牌,向资源服务器申请获取资源。
-
资源服务器确认令牌无误,同意向客户端开放资源。
-
1.2、微博登陆准备工作
1、进入微博开放平台,API使用
- 新浪微博开放平台-首页 登陆后,进如微联接,选择网站接入,选择立即接入
- 创建自己的应用,创建完成记住自己的 app key 和 app secret 我们一会儿用
- 进入高级信息,填写授权回调页的地址
- 添加测试账号(选做)
- 进入文档,按照流程测试社交登陆:授权机制说明 - 微博API
- 1. 引导需要授权的用户到如下地址:
<!--让用户直接跳转授权登录页--> <a href="https://api.weibo.com/oauth2/authorize?client_id=自己的app key &response_type=code&redirect_uri=自己的回调地址">
- 2.如果用户同意授权,页面跳转至 YOUR_REGISTERED_REDIRECT_URI/?code=CODE
- 3. 换取Access Token
其中client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET可以使用basic方式加入header中,返回值-- 返回值 access_token:用于调用API 传递的token uid:用户id expires_in过期时间 { "access_token": "xxxxxxxxx", "remind_in": "157679999", "expires_in": 157679999, "uid": "6541850998", "isRealName": "true" }
- 4. 使用获得的Access Token调用有权限的API
- 获取用户的信息
获取微博用户信息API
- 1. 引导需要授权的用户到如下地址:
2、微博登陆流程图和时序图
3、微博登陆代码
@Slf4j
@Controller
@RequestMapping("/oauth2.0")
public class Oauth2Controller {
@Autowired
private MemberFeignService memberFeignService;
/**
* 微博回调地址
* @param code
* @param session
* @return
*/
@SneakyThrows
@GetMapping("/weibo/success")
public String weibo(@RequestParam("code") String code, HttpSession session) {
//1、通过 code 换取accessToken
Map<String, String> map = new HashMap<>();
map.put("client_id", "xxxx");//自己的app key
map.put("client_secret", "xxx");//自己的 app secret
map.put("grant_type", "authorization_code");
map.put("redirect_uri", "http://auth.gulimall.com/oauth2.0/weibo/success");
map.put("code", code);
HttpResponse post = HttpUtils.doPost("https://api.weibo.com", "/oauth2/access_token", "POST", new HashMap<>(), map, new HashMap<>());
if (post.getStatusLine().getStatusCode() == 200) {
String json = EntityUtils.toString(post.getEntity());
//获取accessToken
SocialUser socialUser = JSON.parseObject(json, SocialUser.class);
//知道了哪个社交用户
//1)、当前用户如果是第一次进网站,自动注册进来(为当前社交用户生成一个会员信息,以后这个社交账号就对应指定的会员)
//登录或者注册这个社交用户
System.out.println(socialUser.getAccess_token());
//调用远程服务 方法在下面
R oauthLogin = memberFeignService.oauth2Login(socialUser);
if (oauthLogin.getCode() == 0) {
MemberResponseVo data = oauthLogin.getData(new TypeReference<MemberResponseVo>() {
});
log.info("登录成功:用户信息:{}", data.toString());
//1、第一次使用session,命令浏览器保存卡号,JSESSIONID这个cookie 具体看 谷粒商城-分布式基础-图.pdf
//以后浏览器访问哪个网站就会带上这个网站的cookie
//子域之间:gulimall.com auth.gulimall.com order.gulimall.com
//发卡的时候(指定域名为父域名),即使是子域系统发的卡,也能让父域直接使用
//TODO 1、默认发的令牌。当前域(解决子域session共享问题)
//TODO 2、使用JSON的序列化方式来序列化对象到Redis中
// new Cookie("JSESSOINUD","AAA").setDomain(".gulimall.com"); //这样吧cookie设置域之后 对应的域名都能够使用了当前这个cookie值
session.setAttribute(AuthServerConstant.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";
}
}
}
/**
* 微博社交登陆
* @param socialUser
* @return
*/
@SneakyThrows
@Override
public MemberEntity login(SocialUser socialUser) {
//具有登录和注册逻辑
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 {
//2、没有查到当前社交用户对应的记录我们就需要注册一个
MemberEntity register = new MemberEntity();
//3、查询当前社交用户的社交账号信息(昵称、性别等)
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;
}
}
1.3、Session共享问题
1.3.1、session原理
1.3.2、分布式下session共享问题
1.3.3、Session共享问题解决
-
Session复制
-
优点: web-server(Tomcat)原生支持,只需要修改配置文件。
-
缺点:
-
session同步需要数据传输,占用大量网络带宽,降低了服务器群的业务处理能力。
-
任意一台web-server保存的数据都是所有web- server的session总和,受到内存限制无法水平扩展更多的web-server。
-
大型分布式集群情况下,由于所有web-server都全量保存数据,所以此方案不可。
-
-
-
客户端存储(这种方式不会使用)
-
优点:服务器不需存储session,用户保存自己的session信息到cookie中。节省服务端资源。
-
缺点:都是缺点,这只是一种思路。具体如下
-
每次http请求,携带用户在cookie中的完整信息,浪费网络带宽。
-
session数据放在cookie中,cookie有长度限制4K,不能保存大量信息。
-
session数据放在cookie中,存在泄漏、篡改、窃取等安全隐患。
-
-
-
hash一致性
-
优点:
-
只需要改nginx配置,不需要修改应用代码
-
负载均衡,只要hash属性的值分布是均匀的,多台web-server的负载是均衡的
-
可以支持web-server水平扩展(session同步法是不行的,受内存限制)
-
-
缺点
-
session还是存在web-server中的,所以web-server重启可能导致部分session丢失,影响业务,如部分用户需要重新登录。
-
如果web-server水平扩展,rehash后session重新分布,也会有一部分用户路由不到正确的session
-
-
但是以上缺点问题也不是很大,因为session本来都是有有效期的。所以这两种反向代理的方式可以使用。
-
-
统一存储
-
优点:
-
没有安全隐患。
-
可以水平扩展,数据库/缓存水平切分即可。
-
web-server重启或者扩容都不会有session丢失。
-
-
不足
-
增加了一次网络调用,并且需要修改应用代码;如将所有的getSession方法替
换为从Redis查数据的方式。redis获取数据比内存慢很多。
-
-
上面缺点可以用SpringSession完美解决
-
1.3.4、不同服务,子域session共享问题
/**
*子域之间:gulimall.com auth.gulimall.com order.gulimall.com
*这样吧cookie设置域之后 对应的域名都能够使用了当前这个cookie值 设置父级域名即可,子域名即可拥有
* springsession 都可解决
**/
new Cookie("JSESSOINUD","AAA").setDomain(".gulimall.com");
1.4、整合SpringSession
- 导入依赖
<!--引入springseesion 完成session共享问题--> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency>
- 配置配置文件
# Session 存储方式 以Redis缓存存储 需要配置Redis连接用户名密码等 spring.session.store-type=redis # Session超时时间 server.servlet.session.timeout= 30m # Sessions 刷新策略 spring.session.redis.flush-mode=on_save # Sessions 前缀命名中心 spring.session.redis.namespace=spring:session
- 配置 SpringSession
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.session.web.http.CookieSerializer; import org.springframework.session.web.http.DefaultCookieSerializer; /** * 配置springsession * @author Administrator */ @Configuration public class GulimallSessionConfig { /** * 设置spring session cookie的作用范围 * @return */ @Bean public CookieSerializer cookieSerializer() { DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer(); //提高cookie的作用域 gulimall.com结尾的都能用 可以访问该Cookie的域名。如果设置为“.google.com”,则所有以“google.com”结尾的域名都可以访问该Cookie。注意第一个字符必须为“.”。 cookieSerializer.setDomainName("gulimall.com"); //更改cookie的名字 cookieSerializer.setCookieName("GULISESSION"); return cookieSerializer; } /** * redis 存储序列化 * @return */ @Bean public RedisSerializer<Object> redisSerializer(){ return new GenericJackson2JsonRedisSerializer(); } }
- 开启SpringSession
import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.client.discovery.EnableDiscoveryClient; import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession; @EnableDiscoveryClient @EnableFeignClients @SpringBootApplication @EnableRedisHttpSession //整合redis作为session的存储 public class GulimallAuthServerApplication { public static void main(String[] args) { SpringApplication.run(GulimallAuthServerApplication.class, args); } }
- SpringSession原理
- @EnableRedisHttpSession 导入了 RedisHttpSessionConfiguration 配置
- 给容器添加了一个组件:SessionRepository (RedisOperationsSessionRepository)==》对redis操作session的类,对session做增删改查。
- 给容器添加了一个组件:SessionRepositoryFilter ==》filter:session 存储过滤器,每个请求过来都经过filter。
- 创建的时候,自动从容器中获取SessionRepository
- 原始的request、response都被包装 SessionRepositoryRequestWrapper、SessionRepositoryRequestWrapper
- 获取session用的是包装之后的request获取的,包装后的request获取session都是从SessionRepository 中获取。
9、单点登陆(SSO)
1.1、前置概念
- Single Sign On 一处登陆、处处可用
- 早期单一服务器,用户认证即可。缺点:单点性能压力,无法扩展
- 分布式,SSO(single sign on)模式。解决 :用户身份信息独立管理,更好的分布式管理。可以自己扩展安全策略。跨域不是问题。缺点:认证服务器访问压力较大。
1.2、gitee基础代码演示
- 源码地址
- xxl-sso-server 登录服务器 8080 ssoserver.com
xxl-sso-web-sample-springboot 项目1 8081 client1.com
xxl-sso-web-sample-springboot 项目2 8082 client2.com - host 配置域名环境
- 更改配置文件
- 启动项目
- 通过域名访问,一键登陆,其他域名服务访问无需登陆,直接进入登陆状态,一处退出,处处退出。
- 核心:三个系统即使域名不一样,想办法给三个系统同步同一个用户的票据;
- 中央认证服务器;ssoserver.com、
- 其他系统,想要登录去ssoserver.com登录,登录成功跳转回来
- 只要有一个登录,其他都不用登录
- 全系统统一一个sso-sessionid;所有系统可能域名都不相同
1.3、单点登录流程
1.4、谷粒商城项目单点登录代码
- 项目结构:
一个服务端 端口:8080两个客户端:clinet1:8081、client2:80882
-
服务端代码
<!DOCTYPE html> <html lang="en" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>登录页</title> </head> <body> <form action="/doLogin" method="post"> 用户名:<input type="text" name="username" /><br /> 密码:<input type="password" name="password" /><br /> <input type="hidden" name="redirect_url" th:value="#{url}" /> <input type="submit" value="登录"> </form> </body> </html>
@Controller public class LoginController { @Autowired StringRedisTemplate redisTemplate; /** * 从缓存中获取用户信息 * @param token * @return */ @ResponseBody @GetMapping("/userinfo") public String userinfo(@RequestParam(value = "token") String token) { String s = redisTemplate.opsForValue().get(token); return s; } /** * 跳转到登录页,如果有cookie 证明有人登陆过,就直接跳转url页面 * @param url 需要转发的url * @param model * @param sso_token 有人登陆过,登陆后会存储 sso_token的cookie值,证明以及登陆路了系统,就无须去往登录页登陆,直接携带 token 转发到 url上 ,没人登陆过跳转到登陆页面 * @return */ @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"; } /** * 登陆接口 * @param username 用户名 * @param password 密码 * @param url 登陆成功后需要转发的url * @param response * @return */ @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 当前服务器 就有了这个cookie 证明就登陆过了 Cookie sso_token = new Cookie("sso_token", uuid); response.addCookie(sso_token); //登录成功跳转 携带当前的uuid 到对应的转发地址 return "redirect:" + url + "?token=" + uuid; } //跳回到登录页 return "login"; } }
- 客户端1
@Controller public class HelloController { /** * 无需登录就可访问 * * @return */ @ResponseBody @GetMapping(value = "/hello") public String hello() { return "hello"; } /** * * @param model * @param session 获取登陆信息 * @param token 是否携带token 参数 携带了 就证明登陆过了 未携带证明还未登陆直接转发到 gulimall-test-sso-server 的登录页 * @return */ @GetMapping(value = "/employees") public String employees(Model model, HttpSession session, @RequestParam(value = "token", required = false) String token) { if (!StringUtils.isEmpty(token)) { RestTemplate restTemplate=new RestTemplate(); //调用 gulimall-test-sso-server 服务的方法 通过token获取对应的Redis中的存储的值 ResponseEntity<String> forEntity = restTemplate.getForEntity("http://ssoserver.com:8080/userinfo?token=" + token, String.class); String body = forEntity.getBody(); session.setAttribute("loginUser", body); } Object loginUser = session.getAttribute("loginUser"); if (loginUser == null) { //redirect_url 跳转到登陆服务器后,登陆成功后的 在跳回当前域名上 return "redirect:" + "http://ssoserver.com:8080/login.html"+"?redirect_url=http://client1.com:8081/employees"; } else { List<String> emps = new ArrayList<>(); emps.add("张三"); emps.add("李四"); model.addAttribute("emps", emps); return "employees"; } } }
- 客户端2代码
@Controller public class HelloController { /** * 无需登录就可访问 * * @return */ @ResponseBody @GetMapping(value = "/hello") public String hello() { return "hello"; } /** * * @param model * @param session 获取登陆信息 * @param token 是否携带token 参数 携带了 就证明登陆过了 未携带证明还未登陆直接转发到 gulimall-test-sso-server 的登录页 * @return */ @GetMapping(value = "/boss") public String boss(Model model, HttpSession session, @RequestParam(value = "token", required = false) String token) { if (!StringUtils.isEmpty(token)) { RestTemplate restTemplate=new RestTemplate(); //调用 gulimall-test-sso-server 服务的方法 通过token获取对应的Redis中的存储的值 ResponseEntity<String> forEntity = restTemplate.getForEntity("http://ssoserver.com:8080/userinfo?token=" + token, String.class); String body = forEntity.getBody(); session.setAttribute("loginUser", body); } Object loginUser = session.getAttribute("loginUser"); if (loginUser == null) { //redirect_url 跳转到登陆服务器后,登陆成功后的 在跳回当前域名上 return "redirect:" + "http://ssoserver.com:8080/login.html"+"?redirect_url=http://client2.com:8082/boss"; } else { List<String> emps = new ArrayList<>(); emps.add("张三"); emps.add("李四"); model.addAttribute("emps", emps); return "employees"; } } }
- 启动服务后访问:http://client1.com:8081/employees,跳转登陆页,登陆成功,生成token,往服务端域名设置cookie,跳转至http://client1.com:8081/employees页面,展示信息;访问http://client2.com:8082/boss,先跳转到服务端,服务端获取cookie值,cookie值不为空证明之前已经登陆过,携带token信息,直接跳转到http://client2.com:8082/boss,获取token值,获取数据和用户信息。
10、ThreadLocal-同一个线程共享数据
/**
* 在执行目标方法之前,判断用户的登录状态.并封装传递给controller目标请求
* @author Administrator
*/
public class CartInterceptor implements HandlerInterceptor {
/**
* 存储每个线程保存的 登录人信息
* 一个请求 CartInterceptor ---> Controller ---> Service 都是同一个线程 可以直接在后面调用时通过线程直接获取对应的用户信息 UserInfoTo info = toThreadLocal.get();
*/
public static ThreadLocal<UserInfoTo> toThreadLocal = new ThreadLocal<>();
/***
* 目标方法执行之前
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
UserInfoTo userInfoTo = new UserInfoTo();
HttpSession session = request.getSession();
//获得当前登录用户的信息
MemberResponseVo memberResponseVo = (MemberResponseVo) session.getAttribute(AuthServerConstant.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(CartConstant.TEMP_USER_COOKIE_NAME)) {
userInfoTo.setUserKey(cookie.getValue());
//标记为已是临时用户
userInfoTo.setTempUser(true);
}
}
}
//如果没有临时用户一定分配一个临时用户
if (StringUtils.isEmpty(userInfoTo.getUserKey())) {
String uuid = UUID.randomUUID().toString();
userInfoTo.setUserKey(uuid);
}
//目标方法执行之前
toThreadLocal.set(userInfoTo);
return true;
}
/**
* 业务执行之后,分配临时用户来浏览器保存
* @param request
* @param response
* @param handler
* @param modelAndView
* @throws Exception
*/
@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(CartConstant.TEMP_USER_COOKIE_NAME, userInfoTo.getUserKey());
//扩大作用域
cookie.setDomain("gulimall.com");
//设置过期时间
cookie.setMaxAge(CartConstant.TEMP_USER_COOKIE_TIMEOUT);
response.addCookie(cookie);
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
}
}
/**
* @Description: 注册拦截器 拦截路径
**/
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new CartInterceptor())//注册拦截器
.addPathPatterns("/**");
}
}
11、消息中间件(MQ)
11.1、用途
- 异步处理
- 应用解耦
- 流量控制
11.2、重要概述
- 消息代理(message broker)和目的地(destination):当消息发送者发送消息以后,将由消息代理接管,消息代理保证消息传递到指定目的地。消息队列主要有两种形式的目的地:1、队列(queue):点对点消息通信(point-to-point)。2、主题(topic):发布(publish)/订阅(subscribe)消息通信
- 点对点式:消息发送者发送消息,消息代理将其放入一个队列中,消息接收者从队列中获
取消息内容,消息读取后被移出队列。消息只有唯一的发送者和接受者,但并不是说只能有一个接收者。 - 发布订阅式:发送者(发布者)发送消息到主题,多个接收者(订阅者)监听(订阅)这个
主题,那么就会在消息到达时同时收到消息。 - 规范一:JMS(Java Message Service)JAVA消息服务:基于JVM消息代理的规范。ActiveMQ、HornetMQ是JMS实现。
- 规范二:AMQP(Advanced Message Queuing Protocol):1、高级消息队列协议,也是一个消息代理的规范,兼容JMS。RabbitMQ是AMQP的实现。
- JMS和AMQP对比
11.3、RabbitMQ概念
- rabbitMq笔记_No码农的博客-CSDN博客
- Message:消息,消息是不具名的,它由消息头和消息体组成。消息体是不透明的,而消息头则由一系列的可选属性组成,这些属性包括routing-key(路由键)、priority(相对于其他消息的优先权)、delivery-mode(指出该消息可能需要持久性存储)等。
- Publisher:消息的生产者,也是一个向交换器发布消息的客户端应用程序。
- Exchange:交换器,用来接收生产者发送的消息并将这些消息路由给服务器中的队列。Exchange有4种类型:direct(默认),fanout, topic, 和headers,不同类型的Exchange转发消息的策略有所区别
- Queue:消息队列,用来保存消息直到发送给消费者。它是消息的容器,也是消息的终点。一个消息可投入一个或多个队列。消息一直在队列里面,等待消费者连接到这个队列将其取走。
- Binding:绑定,用于消息队列和交换器之间的关联。一个绑定就是基于路由键将交换器和消息队列连接起来的路由规则,所以可以将交换器理解成一个由绑定构成的路由表。Exchange 和Queue的绑定可以是多对多的关系。
- Connection:网络连接,比如一个TCP连接。
- Channel:信道,多路复用连接中的一条独立的双向数据流通道。信道是建立在真实的TCP连接内的虚拟连接,AMQP 命令都是通过信道发出去的,不管是发布消息、订阅队列还是接收消息,这些动作都是通过信道完成。因为对于操作系统来说建立和销毁 TCP 都是非常昂贵的开销,所以引入了信道的概念,以复用一条 TCP 连接。
- Consumer:消息的消费者,表示一个从消息队列中取得消息的客户端应用程序。
- Virtual Host:虚拟主机,表示一批交换器、消息队列和相关对象。虚拟主机是共享相同的身份认证和加密环境的独立服务器域。每个 vhost 本质上就是一个 mini 版的 RabbitMQ 服务器,拥有自己的队列、交换器、绑定和权限机制。vhost 是 AMQP 概念的基础,必须在连接时指定,RabbitMQ 默认的 vhost 是 / 。
- Broker:表示消息队列服务器实体。
- 图解
11.4、Docker安装RabbitMQ
docker run -d --name rabbitmq -p 5671:5671 -p 5672:5672 -p 4369:4369 -p
25672:25672 -p 15671:15671 -p 15672:15672 rabbitmq:management
- 安装完成后,访问:http://虚拟机ip:15672
- 4369, 25672 (Erlang发现&集群端口)
- 5672, 5671 (AMQP端口)
- 15672 (web管理后台端口)
- 61613, 61614 (STOMP协议端口)
- 1883, 8883 (MQTT协议端口)
- 官方文档
11.5、RabbitMQ运行机制
- AMQP 中消息的路由过程和 Java 开发者熟悉的 JMS 存在一些差别,AMQP 中增加了 Exchange 和Binding 的角色。生产者把消息发布到 Exchange 上,消息最终到达队列
并被消费者接收,而 Binding 决定交换器的消息应该发送到那个队列。
- Exchange分发消息时根据类型的不同分发策略有区别,目前共四种类型:direct、fanout、topic、headers 。headers 匹配 AMQP 消息的 header 而不是路由键,headers 交换器和 direct 交换器完全一致,但性能差很多,目前几乎用不到了,所以直接看另外三种类型。
- direct:消息中的路由键(routing key)如果和Binding 中的 binding key 一致, 交换器就将消息发到对应的队列中。路由键与队列名完全匹配,如果一个队列绑定到交换机要求路由键为“dog”,则只转发 routingkey 标记为“dog”的消息,不会转发“dog.puppy”,也不会转发“dog.guard” 等等。它是完全匹配、单播的模式。
- fanout(发布订阅/广播) :每个发到 fanout 类型交换器的消息都会分到所有绑定的队列上去(不关心路由key)。fanout 交换器不处理路由键,只是简单的将队列绑定到交换器上,每个发送到交换器的消息都会被转发到与该交换器绑定的所有队列上。很像子网广播,每台子网内的主机都获得了一份复制的消息。fanout 类型转发消息是最快的。
- topic: 交换器通过模式匹配分配消息的路由键属性,将路由键和某个模式进行匹配,此时队列需要绑定到一个模式上。它将路由键和绑定键的字符串切分成单词,这些单词之间用点隔开。它同样也会识别两个通配符:符号“#”和符号“*”。#匹配0个或多个单词,*匹配一个单词。
11.6、SpringBoot 整合 RabbitMQ
- 引入依赖(场景启动器): RabbitAutoConfiguration就会自动生效,给容器中自动配置了:RabbitTemplate、AmqpAdmin、CachingConnectionFactory、RabbitMessagingTemplate
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency>
- 配置文件配置连接
spring: rabbitmq: host: 192.168.56.10 port: 5672 #指定虚拟主机 virtual-host: / password: admin username: admin #开启java发送消息到mq服务器的确认回调 java发送消息 -> 交换机上收到消息后的确认回调 publisher-confirms: true #开启消息抵达队列确认回调 交换机 -> 消息未发送到队列上的回调 publisher-returns: true template: #只要抵达队列,就以异步发送优先回调我们的回调函数 mandatory: true listener: simple: #消费者消费消息后,主动通知 mq服务端进行了消息消费,我们手动告知mq服务端将此消息移除 默认是auto 自动应答,消费完成mq服务器自动移除 acknowledge-mode: manual
- 开启MQ:@EnableRabbit
- 创建交换机、队列、和绑定关系
@Autowired private AmqpAdmin amqpAdmin; /** * amq 交换机代码创建 */ @Test public void createExchanges(){ //DirectExchange(String name, boolean durable, boolean autoDelete) //direct类型的 交换机名称 是否持久化(mq重启后是否需要存在) 是否自动删除(没有绑定队列后,自动删除) DirectExchange directExchange = new DirectExchange("hello-java-exchanges",true,false); //创建交换机 amqpAdmin.declareExchange(directExchange); log.info("{}创建成功","hello-java-exchanges"); } /** * amq 队列代码创建 */ @Test public void createQueue(){ //Queue(String name, boolean durable, boolean exclusive, boolean autoDelete, Map<String, Object> arguments) //队列名称 是否持久化(队列重启后是否需要存在) 是否排他的(只能由一条连接使用 其他连接无法使用 独占) 是否自动删除(没有消息就自动删除) Queue queue = new Queue("hello-java-queue",true,false,false); //创建交换机 amqpAdmin.declareQueue(queue); log.info("{}创建成功","hello-java-queues"); } /** * amq 队列绑定交换机 */ @Test public void queueBingingExchanges(){ //Binding(String destination【目的地】, Binding.DestinationType destinationType,【目的类型】 String exchange【交换机】, String routingKey【路由key】, Map<String, Object> arguments【自定义参数】) //将 exchange指定的交换机和 destination目的地进行绑定,使用 routingKey 作为指定的路由 Binding binding = new Binding("hello-java-queue", Binding.DestinationType.QUEUE,"hello-java-exchanges","hello.java",null); //创建交换机 amqpAdmin.declareBinding(binding); log.info("{}交换机绑定{}队列成功","hello-java-exchanges","hello-java-queues"); // amqpAdmin.deleteExchange(); // amqpAdmin.deleteQueue() }
- 测试发送消息
@Autowired private RabbitTemplate rabbitTemplate; /** * amq 发送消息 */ @Test public void sendSms(){ OrderReturnReasonEntity orderReturnReasonEntity = new OrderReturnReasonEntity(); orderReturnReasonEntity.setId(111L); orderReturnReasonEntity.setCreateTime(new Date()); orderReturnReasonEntity.setSort(12); orderReturnReasonEntity.setStatus(1); //将换机名称 路由名称 消息值 // rabbitTemplate.convertAndSend("hello-java-exchanges","hello.java","Hello Word"); //发送的消息是对象 会使用序列号写出去 所以对象必须实现 Serializable 发送消息默认是jdk序列化 不是json 可以配置 MessageConverter 消息转换器(配置成Jackson2JsonMessageConverter就是json序列化) for (int i = 0; i < 10; i++) { if (i%2==0){ orderReturnReasonEntity.setName("AAA" + i); rabbitTemplate.convertAndSend("hello-java-exchanges","hello.java",orderReturnReasonEntity); }else { OrderEntity orderEntity = new OrderEntity(); orderEntity.setOrderSn(UUID.randomUUID().toString()); rabbitTemplate.convertAndSend("hello-java-exchanges","hello.java",orderEntity); } } log.info("消息发送成功"); } public class OrderReturnReasonEntity implements Serializable { private static final long serialVersionUID = 1L; /** * id */ @TableId private Long id; /** * 退货原因名 */ private String name; /** * 排序 */ private Integer sort; /** * 启用状态 */ private Integer status; /** * create_time */ private Date createTime; } public class OrderEntity implements Serializable { private static final long serialVersionUID = 1L; /** * id */ @TableId private Long id; /** * 订单号 */ private String orderSn; }
- 配置序列化方式
/** * mq 的配置类 */ @Configuration public class MyRabbitConfig { /** * 如果容器中有 converter的组件 就用我们自己的 如果没有默认使用 SimpleMessageConverter 进行数据序列化 * 如果有 就用我们自己指定的进行序列化 * * @return */ @Bean public MessageConverter messageConverter() { return new Jackson2JsonMessageConverter(); } }
- 接收消息
@RabbitListener(queues = {"hello-java-queue"}) @Service("mqMessageService") public class MqMessageServiceImpl extends ServiceImpl<MqMessageDao, MqMessageEntity> implements MqMessageService { /** * MQ监听消息 * @param * @return * queues:指定监听那个队列 可以监听多个 只要收到消息,队列删除消息,而且只能有一个收到此消息 * 1、Message msg:原生的消息详细信息内容 包括头和体 * 2、T<发送的消息类型>:内容,会根据发送的消息类型自动跳转不同的方法 * 3、通道 channel 当前传输的数据的通道 * * 场景: * 1、假设是集群部署当前项目 都有这段代码 同一个消息只能由一个服务消费 * 2、只有一个消息处理完成之后(方法执行完)才能继续接受下一个消息 * * RabbitListener (可以标记在方法(表示当前方法监听队列)和类上(配合 RabbitHandler,整个类中标记了改注解的都会监听指定的队列)):指定监听的队列,当发送的消息类型不同时,可以使用RabbitHandler标记不同的方法,接收的类型不同,进入的监听方法就不同 * RabbitHandler 只能标记在方法上,用于监听接收同一个队列中的不同的消息类型的消息 */ // @RabbitListener(queues = {"hello-java-queue"}) @RabbitHandler() public void recieveMessage(Message msg, OrderReturnReasonEntity orderReturnReasonEntity, Channel channel) throws InterruptedException { //(Body:'{"id":111,"name":"AAA","sort":12,"status":1,"createTime":1673935752934}' MessageProperties [headers={__TypeId__=com.pj.gulimall.order.entity.OrderReturnReasonEntity}, contentType=application/json, contentEncoding=UTF-8, contentLength=0, receivedDeliveryMode=PERSISTENT, priority=0, redelivered=false, receivedExchange=hello-java-exchanges, receivedRoutingKey=hello.java, deliveryTag=1, consumerTag=amq.ctag-UxdUZmW8q_vtHgHDtYww-Q, consumerQueue=hello-java-queue]) // byte[] body = msg.getBody(); //org.springframework.amqp.core.Message System.out.println("接收到消息....消息内容" + msg +"类型:"+ msg.getClass()); Thread.sleep(3000); System.out.println(orderReturnReasonEntity); //channel内按顺序自增的序列 long deliveryTag = msg.getMessageProperties().getDeliveryTag(); System.out.println("deliveryTag = " + deliveryTag); //手动应答消息 告知MQ服务器 当前消息进行了消费 false 非批量模式 try { if (deliveryTag % 2 == 0) { channel.basicAck(deliveryTag,false); System.out.println("签收成功!"); }else { //long deliveryTag, boolean multiple, boolean requeue //手动拒绝签收消息 消息自增序列号 是否批量签收 签收未成功是否重新入队 //如果重新入队了 当前会继续接收被拒绝且重新入队的消息 重新入队的消息的 deliveryTag 会继续累加 //如果手动拒绝签收 不重新入队列 就会将此消息丢弃 channel.basicNack(deliveryTag,false,true); //long deliveryTag, boolean requeue //消息自增序列号 签收未成功是否重新入队 // channel.basicReject(deliveryTag,false); System.out.println("没有签收!" + deliveryTag); } } catch (IOException e) { //网络中断异常 e.printStackTrace(); } } @RabbitHandler() public void recieveMessage(Message msg, OrderEntity orderEntity, Channel channel) throws InterruptedException { System.out.println("接收到消息....消息内容" + msg +"类型:"+ msg.getClass()); Thread.sleep(3000); System.out.println(orderEntity); } }
11.7、RabbitMQ消息确认机制-可靠抵达
- 保证消息不丢失,可靠抵达,可以使用事务消息,性能下降250倍,为此引入确认
机制 - publisher confirmCallback 确认模式(消息投递到交换机后的回调)
- 配置文件配置:spring.rabbitmq.publisher-confirms=true
- 在创建 connectionFactory 的时候设置 PublisherConfirms(true) 选项,开启
confirmcallback 。 - CorrelationData:用来表示当前消息唯一性。
- 消息只要被 broker 接收到就会执行 confirmCallback,如果是 cluster 模式,需要所有broker 接收到才会调用 confirmCallback。
- 被 broker 接收到只能表示 message 已经到达服务器,并不能保证消息一定会被投递到目标 queue 里。所以需要用到接下来的 returnCallback 。
- publisher returnCallback 未投递到 queue 退回模式(交换机投递的消息未被队列收到后的回调)
- 配置文件配置:spring.rabbitmq.publisher-returns=true
- confrim 模式只能保证消息到达 broker,不能保证消息准确投递到目标 queue 里。在有些业务场景下,我们需要保证消息一定要投递到目标 queue 里,此时就需要用到return 退回模式。
- 这样如果未能投递到目标 queue 里将调用 returnCallback ,可以记录下详细到投递数
据,定期的巡检或者自动纠错都需要这些数据。
- consumer ack机制(队列中的消息消费后的确认机制)
- 消费者获取到消息,成功处理,可以回复Ack给Broker
- basic.ack用于肯定确认;broker将移除此消息
- basic.nack用于否定确认;可以指定broker是否丢弃此消息,可以批量
- basic.reject用于否定确认;同上,但不能批量
- 默认自动ack,消息被消费者收到,就会从broker的queue中移除
- queue无消费者,消息依然会被存储,直到消费者消费
- 消费者收到消息,默认会自动ack。但是如果无法确定此消息是否被处理完成,或者成功处理。我们可以开启手动ack模式
- 消息处理成功,ack(),接受下一个消息,此消息broker就会移除
- 消息处理失败,nack()/reject(),重新发送给其他人进行处理,或者容错处理后ack
- 消息一直没有调用ack/nack方法,broker认为此消息正在被处理,不会投递给别人,此时客户端断开,消息不会被broker移除,会投递给别人
- 消费者获取到消息,成功处理,可以回复Ack给Broker
- 图解
- 配置文件配置
spring: rabbitmq: host: 192.168.56.10 port: 5672 #指定虚拟主机 virtual-host: / password: admin username: admin #开启java发送消息到mq服务器的确认回调 java发送消息 -> 交换机上收到消息后的确认回调 publisher-confirms: true #开启消息抵达队列确认回调 交换机 -> 消息未发送到队列上的回调 publisher-returns: true template: #只要抵达队列,就以异步发送优先回调我们的回调函数 mandatory: true listener: simple: #消费者消费消息后,主动通知 mq服务端进行了消息消费,我们手动告知mq服务端将此消息移除 默认是auto 自动应答,消费完成mq服务器自动移除 acknowledge-mode: manual
- 配置类定制 RabbitTemplate,设置 setConfirmCallback 和 setReturnCallback
import org.springframework.amqp.rabbit.connection.ConnectionFactory; import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; import org.springframework.amqp.support.converter.MessageConverter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; import javax.annotation.PostConstruct; /** * mq 的配置类 */ @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; } /** * 如果容器中有 converter的组件 就用我们自己的 如果没有默认使用 SimpleMessageConverter 进行数据序列化 * 如果有 就用我们自己指定的进行序列化 * * @return */ @Bean public MessageConverter messageConverter() { return new Jackson2JsonMessageConverter(); } /** * 定制 rabbitTemplate * * @PostConstruct //java自己提供的 该注解被用来修饰一个非静态的void方法,被修饰的方法会在服务器加载servlet时候运行,并且当前对象被初始化之后的回调函数,且只会被服务器执行一次,该注解在构造函数之后执行,inti()方法之前执行 */ public void initRabbitTemplate() { /** * 消息发送成功以后,基于生产者的消息回执,来确保生产者的可靠性 * 1、服务收到消息就会回调 * 开启java发送消息到mq服务器的确认回调 java发送消息 -> mq服务器上收到消息后的确认回调 * 1、spring.rabbitmq.publisher-confirms: true * 2、设置确认回调 * 2、消息正确抵达队列就会进行回调 * 开启消息抵达队列确认回调 mq服务器 -> 发送消息到队列上后的确认回调 * 1、spring.rabbitmq.publisher-returns: true * 只要抵达队列,就以异步发送优先回调我们的回调函数 * spring.rabbitmq.template.mandatory: true * 2、设置确认回调ReturnCallback * * 3、消费端确认(保证每个消息都被正确消费,此时才可以broker删除这个消息) * listener: * simple: * #消费者消费消息后,主动通知 mq服务端进行了消息消费,我们手动告知mq服务端将此消息移除 默认是auto 自动应答,消费完成mq服务器自动移除 * acknowledge-mode: manual * 1、默认是自动确认模式,当消费者消费消息后,客户端会自动像服务器发送确认消息,服务端就会移除这个消息 * 问题:收到很多个消息,自动回复给服务器ack,只有一个消息成功,客户端宕机了,其他消息还未进行消费,但又是服务器自动确认的方式,导致消息丢失 * 解决:手动确认:当客户端消费消息后,需要自己手动通知mq服务器将此消息移除,我们如果没有明确告知MQ,当前消息被消费了,消息就一直处于unacked状态,即使客户端宕机了,消息也不会丢失,消息会重新变为Ready状态,下次有客户端连接这个队列,消息将会通知到这个客户端 * long deliveryTag = msg.getMessageProperties().getDeliveryTag(); channel内按顺序自增的序列 * channel.basicAck(deliveryTag,false); 手动应答消息 告知MQ服务器 当前消息进行了消费 false 非批量模式 * correlationData:当前消息的唯一关联数据(这个是消息的唯一id) 发送消息时可以设置此值,如果需要记录,可以存储到数据库中,记录状态值 * ack:消息是否成功收到 * cause:失败的原因 */ rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> { System.out.println("setConfirmCallback" + 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 + "]"); }); rabbitTemplate.setMandatory(true); } }
- 消息发送:测试发送消息失败 触发回调函数
@RequestMapping("sendMessage") public String sendMessage(@RequestParam(value = "number",defaultValue = "10") Integer number){ OrderReturnReasonEntity orderReturnReasonEntity = new OrderReturnReasonEntity(); orderReturnReasonEntity.setId(111L); orderReturnReasonEntity.setCreateTime(new Date()); orderReturnReasonEntity.setSort(12); orderReturnReasonEntity.setStatus(1); //将换机名称 路由名称 消息值 消息的唯一值主键 // rabbitTemplate.convertAndSend("hello-java-exchanges","hello.java","Hello Word"); //发送的消息是对象 会使用序列号写出去 所以对象必须实现 Serializable 发送消息默认是jdk序列化 不是json 可以配置 MessageConverter 消息转换器(配置成Jackson2JsonMessageConverter就是json序列化) for (int i = 0; i < number; i++) { if (i%2==0){ orderReturnReasonEntity.setName("AAA" + i); //CorrelationData 给每个消息设置一个唯一id rabbitTemplate.convertAndSend("hello-java-exchanges","hello.java",orderReturnReasonEntity,new CorrelationData(UUID.randomUUID().toString())); }else { OrderEntity orderEntity = new OrderEntity(); orderEntity.setOrderSn(UUID.randomUUID().toString()); // rabbitTemplate.convertAndSend("hello-java-exchanges","hello.java",orderEntity); //测试发送消息失败 触发回调函数 rabbitTemplate.convertAndSend("hello-java-exchanges","hello22.java",orderEntity,new CorrelationData(UUID.randomUUID().toString())); } } return "ok"; }
- 接收消息确认机制:
- 默认是自动确认模式,当消费者消费消息后,客户端会自动像服务器发送确认消息,服务端就会移除这个消息。
- 问题:收到很多个消息,自动回复给服务器ack,只有一个消息成功,客户端宕机了,其他消息还未进行消费,但又是服务器自动确认的方式,导致消息丢失。
- 解决:手动确认:当客户端消费消息后,需要自己手动通知mq服务器将此消息移除,我们如果没有明确告知MQ,当前消息被消费了,消息就一直处于unacked状态,即使客户端宕机了,消息也不会丢失,消息会重新变为Ready状态,下次有客户端连接这个队列,消息将会通知到这个客户端。
- long deliveryTag = msg.getMessageProperties().getDeliveryTag(); channel内按顺序自增的序列
- channel.basicAck(deliveryTag,false); 手动应答消息 告知MQ服务器 当前消息进行了消费 false 非批量模式
@RabbitListener(queues = {"hello-java-queue"}) @Service("mqMessageService") public class MqMessageServiceImpl extends ServiceImpl<MqMessageDao, MqMessageEntity> implements MqMessageService { /** * MQ监听消息 * @param * @return * queues:指定监听那个队列 可以监听多个 只要收到消息,队列删除消息,而且只能有一个收到此消息 * 1、Message msg:原生的消息详细信息内容 包括头和体 * 2、T<发送的消息类型>:内容,会根据发送的消息类型自动跳转不同的方法 * 3、通道 channel 当前传输的数据的通道 * * 场景: * 1、假设是集群部署当前项目 都有这段代码 同一个消息只能由一个服务消费 * 2、只有一个消息处理完成之后(方法执行完)才能继续接受下一个消息 * * RabbitListener (可以标记在方法(表示当前方法监听队列)和类上(配合 RabbitHandler,整个类中标记了改注解的都会监听指定的队列)):指定监听的队列,当发送的消息类型不同时,可以使用RabbitHandler标记不同的方法,接收的类型不同,进入的监听方法就不同 * RabbitHandler 只能标记在方法上,用于监听接收同一个队列中的不同的消息类型的消息 * 3、消费端确认(保证每个消息都被正确消费,此时才可以broker删除这个消息) * listener: * simple: * #消费者消费消息后,主动通知 mq服务端进行了消息消费,我们手动告知mq服务端将此消息移除 默认是auto 自动应答,消费完成mq服务器自动移除 * acknowledge-mode: manual * 1、默认是自动确认模式,当消费者消费消息后,客户端会自动像服务器发送确认消息,服务端就会移除这个消息 * 问题:收到很多个消息,自动回复给服务器ack,只有一个消息成功,客户端宕机了,其他消息还未进行消费,但又是服务器自动确认的方式,导致消息丢失 * 解决:手动确认:当客户端消费消息后,需要自己手动通知mq服务器将此消息移除,我们如果没有明确告知MQ,当前消息被消费了,消息就一直处于unacked状态,即使客户端宕机了,消息也不会丢失,消息会重新变为Ready状态,下次有客户端连接这个队列,消息将会通知到这个客户端 * long deliveryTag = msg.getMessageProperties().getDeliveryTag(); channel内按顺序自增的序列 * channel.basicAck(deliveryTag,false); 手动应答消息 告知MQ服务器 当前消息进行了消费 false 非批量模式 * correlationData:当前消息的唯一关联数据(这个是消息的唯一id) 发送消息时可以设置此值,如果需要记录,可以存储到数据库中,记录状态值 * ack:消息是否成功收到 * cause:失败的原因 */ // @RabbitListener(queues = {"hello-java-queue"}) @RabbitHandler() public void recieveMessage(Message msg, OrderReturnReasonEntity orderReturnReasonEntity, Channel channel) throws InterruptedException { //(Body:'{"id":111,"name":"AAA","sort":12,"status":1,"createTime":1673935752934}' MessageProperties [headers={__TypeId__=com.pj.gulimall.order.entity.OrderReturnReasonEntity}, contentType=application/json, contentEncoding=UTF-8, contentLength=0, receivedDeliveryMode=PERSISTENT, priority=0, redelivered=false, receivedExchange=hello-java-exchanges, receivedRoutingKey=hello.java, deliveryTag=1, consumerTag=amq.ctag-UxdUZmW8q_vtHgHDtYww-Q, consumerQueue=hello-java-queue]) // byte[] body = msg.getBody(); //org.springframework.amqp.core.Message System.out.println("接收到消息....消息内容" + msg +"类型:"+ msg.getClass()); Thread.sleep(3000); System.out.println(orderReturnReasonEntity); //channel内按顺序自增的序列 long deliveryTag = msg.getMessageProperties().getDeliveryTag(); System.out.println("deliveryTag = " + deliveryTag); //手动应答消息 告知MQ服务器 当前消息进行了消费 false 非批量模式 try { if (deliveryTag % 2 == 0) { channel.basicAck(deliveryTag,false); System.out.println("签收成功!"); }else { //long deliveryTag, boolean multiple, boolean requeue //手动拒绝签收消息 消息自增序列号 是否批量签收 签收未成功是否重新入队 //如果重新入队了 当前会继续接收被拒绝且重新入队的消息 重新入队的消息的 deliveryTag 会继续累加 //如果手动拒绝签收 不重新入队列 就会将此消息丢弃 channel.basicNack(deliveryTag,false,true); //long deliveryTag, boolean requeue //消息自增序列号 签收未成功是否重新入队 // channel.basicReject(deliveryTag,false); System.out.println("没有签收!" + deliveryTag); } } catch (IOException e) { //网络中断异常 e.printStackTrace(); } } @RabbitHandler() public void recieveMessage(Message msg, OrderEntity orderEntity, Channel channel) throws InterruptedException { System.out.println("接收到消息....消息内容" + msg +"类型:"+ msg.getClass()); Thread.sleep(3000); System.out.println(orderEntity); } }
11.8、RabbitMQ延时队列(实现定时任务)
11.8.1、延时队列场景
- 比如未付款订单,超过一定时间后,系统自动取消订单并释放占有物品。
- 常用解决方案:spring的 schedule 定时任务轮询数据库
- 缺点:消耗系统内存、增加了数据库的压力、存在较大的时间误差
- 解决:rabbitmq的消息TTL和死信Exchange结合
11.8.2、Schedule 定时任务的时效性问题
11.8.3、消息的TTL(Time To Live)和死信队列(Dead Letter Exchanges(DLX))
- 消息的TTL就是消息的存活时间。
- RabbitMQ可以对队列和消息分别设置TTL。
- 对队列设置就是队列没有消费者连着的保留时间,也可以对每一个单独的消息做单独的设置。超过了这个时间,我们认为这个消息就死了,称之为死信。
- 如果队列设置了,消息也设置了,那么会取小的。所以一个消息如果被路由到不同的队列中,这个消息死亡的时间有可能不一样(不同的队列设置)。这里单讲单个消息的TTL,因为它才是实现延迟任务的关键。可以通过设置消息的expiration字段或者x- message-ttl属性来设置时间,两者是一样的效果。
- 一个消息在满足如下条件下,会进死信路由,记住这里是路由而不是队列,一个路由可以对应很多队列。(什么是死信)
- 一个消息被Consumer拒收了,并且reject方法的参数里requeue是false(手动拒绝)。也就是说不会被再次放在队列里,被其他消费者使用。(basic.reject/ basic.nack)requeue=false
- 上面的消息的TTL到了,消息过期了。
- 队列的长度限制满了。排在前面的消息会被丢弃或者扔到死信路由上。
- Dead Letter Exchange 其实就是一种普通的exchange(队列),和创建其他exchange没有两样。只是在某一个设置Dead Letter Exchange的队列中有消息过期了,会自动触发消息的转发,发送到Dead Letter Exchange中去。
- 我们既可以控制消息在一段时间后变成死信,又可以控制变成死信的消息被路由到某一个指定的交换机,结合二者,其实就可以实现一个延时队列
- 手动ack&异常消息统一放在一个队列处理建议的两种方式:
- catch异常后,手动发送到指定队列,然后使用channel给rabbitmq确认消息已消费
- 给Queue绑定死信队列,使用nack(requque为false)确认消息消费失败
11.8.4、延时队列实现
- 设置对列过期时间
- 设置消息过期时间
-
消息级别的TTL:当发布消息时,可以为特定的消息设置TTL。这意味着这条消息从进入队列开始只有指定的时间可以存活。如果在这段时间内消息没有被消费,则消息会从队列中被移除。
-
队列级别的TTL:可以为整个队列设置默认的TTL。这会影响进入该队列的所有消息,进入该队列的消息的过期时间就是队列设置的TTL。如果消息没有单独设置TTL,它会继承队列的TTL。如果队列中的消息设置了不同的TTL,每条消息都会根据其自身的TTL被独立处理。
-
消息A在10:00进入队列,TTL为5分钟。
-
消息B在10:03进入队列,TTL为2分钟。
-
在10:05时,消息A会过期并从队列中移除;而消息B会在10:05之前的10:05时过期并移除。
-
-
这就是说,即使两条消息都在队列中,它们也可以有不同的过期时间,并根据各自的TTL独立地被处理。
11.9、注解方式创建队列和交换机和绑定关系
/**
* mq 创建队列和交换机 和绑定关系 死信和延时队列
* @author Administrator
*/
@Configuration
public class MyRabbitMQConfig {
/**
* TopicExchange
*
* @return
*/
@Bean
public Exchange orderEventExchange() {
/*
* String name, 交换机名称
* boolean durable, 持久化
* boolean autoDelete, 是否自动删除
* Map<String, Object> arguments 参数
* */
return new TopicExchange("order-event-exchange", true, false);
}
/**
* 死信队列
* @Beab 直接把创建的队列或者交换机或者绑定关系 创建到mq服务器上,如果mq服务器上有不会再次创建(属性变化的也不会更改,只能删除后重新创建)
* @return
*/@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;
}
/**
* 普通队列
*
* @return
*/
@Bean
public Queue orderReleaseQueue() {
Queue queue = new Queue("order.release.order.queue", true, false, false);
return queue;
}
@Bean
public Binding orderCreateBinding() {
/*
* String destination, 目的地(队列名或者交换机名字)
* DestinationType destinationType, 目的地类型(Queue、Exhcange)
* String exchange, 交换机
* String routingKey, 路由key
* Map<String, Object> arguments
* */
return new Binding("order.delay.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.create.order",
null);
}
@Bean
public Binding orderReleaseBinding() {
return new Binding("order.release.order.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.release.order",
null);
}
/**
* 测试监听 order.release.order.queue 消息
*/
@RabbitListener(queues = "order.release.order.queue")
public void listenerReleaseOrderQueue(OrderEntity orderEntity, Channel channel, Message message){
try {
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
System.out.println("收到订单消息,需要关闭的订单信息为:"+ orderEntity);
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 订单释放直接和库存释放进行绑定
* @return
*/
@Bean
public Binding orderReleaseOtherBinding() {
return new Binding("stock.release.stock.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.release.other.#",
null);
}
/**
* 商品秒杀队列
* @return
*/
@Bean
public Queue orderSecKillOrrderQueue() {
Queue queue = new Queue("order.seckill.order.queue", true, false, false);
return queue;
}
@Bean
public Binding orderSecKillOrrderQueueBinding() {
//String destination, DestinationType destinationType, String exchange, String routingKey,
// Map<String, Object> arguments
Binding binding = new Binding(
"order.seckill.order.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.seckill.order",
null);
return binding;
}
/**
* 监听随便的队列 使创建出对应的交换机和队列 没有消费者 不会自动创建
* @param message
*/
@RabbitListener(queues = "stock.release.stock.queue")
public void handle(Message message) {
}
}
/**
* 测试发送消息 1分钟后,消息过期,进入死信队列,监听死信队列的消费消息
* @return
*/
@ResponseBody
@GetMapping(value = "/test/createOrder")
public String createOrderTest() {
//订单下单成功
OrderEntity orderEntity = new OrderEntity();
orderEntity.setOrderSn(UUID.randomUUID().toString());
orderEntity.setModifyTime(new Date());
//给MQ发送消息 路由key order.create.order
rabbitTemplate.convertAndSend("order-event-exchange","order.create.order",orderEntity);
return "ok";
}
11.10、如何保证消息可靠性
1、保证消息不丢失
- 消息发送出去,由于网络问题没有抵达MQ服务器
- 做好容错方法(try-catch),发送消息可能会网络失败,失败后要有重试机制(发送失败时,循环发送),可记录到数据库,采用定期扫描重发的方式
- 做好日志记录,每个消息状态是否都被服务器收到都应该记录(存储到数据库中)。
CREATE TABLE `mq_message` ( `message_id` char(32) NOT NULL, `content` text, `to_exchane` varchar(255) DEFAULT NULL, `routing_key` varchar(255) DEFAULT NULL, `class_type` varchar(255) DEFAULT NULL, `message_status` int(1) DEFAULT '0' COMMENT '0-新建 1-已发送 2-错误抵达 3-已抵达', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`message_id`) )
- 做好定期重发,如果消息没有发送成功,定期去数据库扫描未成功的消息进行重发
- 消息抵达Broker,Broker要将消息写入磁盘(持久化)才算成功。此时Broker尚未持久化完成,宕机。
- publisher(消息确认机制)也必须加入确认回调机制,确认成功的消息,修改数据库消息状态。如果消息未进入队列,触发失败回调,也修改数据库对应消息的状态。
- 自动ACK的状态下。消费者收到消息,但没来得及消息然后宕机
- 一定开启手动ACK,消费成功才移除,失败或者没来得及处理就noAck并重新入队
2、保证消息不重复
- 消息消费成功,事务已经提交,消息正要ACK时,MQ机器宕机。导致没有ACK成功,roker的消息重新由unack变为ready,并发送给其他消费者,导致消息重复消费。
- 消息消费失败,由于重试机制,自动又将消息发送出去
- 成功消费,ack时宕机,消息由unack变为ready,Broker又重新发送
- 消费者的业务消费接口应该设计为幂等性的。比如扣库存有工作单的状态标志。扣过一次的就不在继续扣。
- 使用防重表(redis/mysql),发送消息每一个都有业务的唯一标识,记录处理过的消息,处理过就不用处理。
- rabbitMQ的每一个消息都有redelivered字段(Boolean redelivered = message.getMessageProperties().getRedelivered();,如果为true,就是第二次投递,但可能是由于未手动ACK失败重新投递过来的的,须慎用)可以获取是否是被重新投递过来的,而不是第一次投递过来的
3、保证消息不积压
- 消费者宕机积压
- 消费者消费能力不足积压
- 发送者发送流量太大
- 上线更多的消费者,进行正常消费
- 上线专门的队列消费服务,将消息先批量取出来,记录数据库,离线慢慢处理
12、接口幂等性
12.1、什么是幂等性
- 接口幂等性就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因
为多次点击而产生了副作用;比如说支付场景,用户购买了商品支付扣款成功,但是返回结
果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结
果成功,用户查询余额返发现多扣钱了,流水记录也变成了两条...,这就没有保证接口
的幂等性
12.2、哪些情况需要防止
- 用户多次点击按钮
- 用户页面回退再次提交
- 微服务互相调用,由于网络问题,导致请求失败。feign 触发重试机制
- 其他业务情况
12.3、什么情况下需要幂等
- 以 SQL 为例,有些操作是天然幂等的。
- SELECT * FROM table WHER id=?,无论执行多少次都不会改变状态,是天然的幂等。
- UPDATE tab1 SET col1=1 WHERE col2=2,无论执行成功多少次状态都是一致的,也是幂等操作。
- delete from user where userid=1,多次操作,结果一样,具备幂等性
- insert into user(userid,name) values(1,'a') 如 userid 为唯一主键,即重复操作上面的业务,只会插入一条用户数据,具备幂等性。
- UPDATE tab1 SET col1=col1+1 WHERE col2=2,每次执行的结果都会发生变化,不是幂等的。
- insert into user(userid,name) values(1,'a') 如 userid 不是主键,可以重复,那上面业务多次操作,数据都会新增多条,不具备幂等性。
12.4、幂等解决方案
12.4.1、token 机制
- 服务端提供了发送 token 的接口。我们在分析业务的时候,哪些业务是存在幂等问题的,就必须在执行业务前,先去获取 token,服务器会把 token 保存到 redis 中。
- 然后调用业务接口请求时,把 token 携带过去,一般放在请求头部。
- 服务器判断 token 是否存在 redis 中,存在表示第一次请求,然后删除 token,继续执行业务。
- 如果判断 token 不存在 redis 中,就表示是重复操作,直接返回重复标记给 client,这样就保证了业务代码,不被重复执行。
- 危险性:
- 先删除 token 还是后删除 token;
- 先删除可能导致,业务确实没有执行,重试还带上之前 token,由于防重设计导致,请求还是不能执行。
- 后删除可能导致,业务处理成功,但是服务闪断,出现超时,没有删除 token,别人继续重试,导致业务被执行两边
- 我们最好设计为先删除 token,如果业务调用失败,就重新获取 token 再次请求。
- Token 获取、比较和删除必须是原子性
- redis.get(token) 、token.equals、redis.del(token)如果这两个操作不是原子,可能导致,高并发下,都 get 到同样的数据,判断都成功,继续业务并发执行
- 可以在 redis 使用 lua 脚本完成这个操作
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
- 先删除 token 还是后删除 token;
12.4.2、各种锁机制
1、数据库悲观锁
- select * from xxxx where id = 1 for update;悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,需要根据实际情况选用。另外要注意的是,id 字段一定是主键或者唯一索引,不然可能造成锁表的结果,处理起来会非常麻烦。
2、数据库悲观锁
- 这种方法适合在更新的场景中,update t_goods set count = count -1 , version = version + 1 where good_id=2 and version = 1。根据 version 版本,也就是在操作库存前先获取当前商品的 version 版本号,然后操作的时候带上此 version 号。我们梳理下,我们第一次操作库存时,得到 version 为 1,调用库存服务version 变成了 2;但返回给订单服务出现了问题,订单服务又一次发起调用库存服务,当订单服务传如的 version 还是 1,再执行上面的 sql 语句时,就不会执行;因为 version 已经变为 2 了,where 条件就不成立。这样就保证了不管调用几次,只会真正的处理一次。乐观锁主要使用于处理读多写少的问题
3、业务层分布式锁
- 如果多个机器可能在同一时间同时处理相同的数据,比如多台机器定时任务都拿到了相同数据处理,我们就可以加分布式锁,锁定此数据,处理完成后释放锁。获取到锁的必须先判断这个数据是否被处理过
12.4.3、各种唯一约束
1、数据库唯一约束
- 插入数据,应该按照唯一索引进行插入,比如订单号,相同的订单就不可能有两条记录插入。
我们在数据库层面防止重复。这个机制是利用了数据库的主键唯一约束的特性,解决了在 insert 场景时幂等问题。但主键的要求不是自增的主键,这样就需要业务生成全局唯一的主键。如果是分库分表场景下,路由规则要保证相同请求下,落地在同一个数据库和同一表中,要不然数据库主键约束就不起效果了,因为是不同的数据库和表主键不相关。
2、redis set 防重
- 很多数据需要处理,只能被处理一次,比如我们可以计算数据的 MD5 将其放入 redis 的 set,
每次处理数据,先看这个 MD5 是否已经存在,存在就不处理。
12.4.4、防重表
- 使用订单号 orderNo 做为去重表的唯一索引,把唯一索引插入去重表,再进行业务操作,且
他们在同一个事务中。这个保证了重复请求时,因为去重表有唯一约束,导致请求失败,避
免了幂等问题。这里要注意的是,去重表和业务表应该在同一库中,这样就保证了在同一个
事务,即使业务操作失败了,也会把去重表的数据回滚。这个很好的保证了数据一致性。之前说的 redis 防重也算
12.5、token机制解决案例
- 订单下单时的案例,点击下去结算页之前,携带一个token带给前端页面,后端服务器也存储这个token。
//TODO 5、防重令牌(防止表单重复提交) //为用户设置一个token,三十分钟过期时间(存在redis) String token = UUID.randomUUID().toString().replace("-", ""); redisTemplate.opsForValue().set(OrderConstant.USER_ORDER_TOKEN_PREFIX+memberResponseVo.getId(),token,30, TimeUnit.MINUTES); confirmVo.setOrderToken(token);
- 提交订单时,前端页面携带token,进行校验,防重复提交。
//1、验证令牌是否合法【令牌的对比和删除必须保证原子性】 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(); //通过lure脚本原子验证令牌和删除令牌 Long result = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId()), orderToken);
13、本地事务&分布式事务
13.1、本地事务
13.1.1、事务的基本性质
- 数据库事务的几个特性:原子性(Atomicity )、一致性( Consistency )、隔离性或独立性( Isolation)和持久性(Durabilily),简称就是 ACID;
- 原子性:一系列的操作整体不可拆分,要么同时成功,要么同时失败
- 一致性:数据在事务的前后,业务整体一致。转账。A:1000;B:1000; 转 200 事务成功; A:800 B:1200
- 隔离性:事务之间互相隔离。
- 持久性:一旦事务成功,数据一定会落盘在数据库
13.1.2、事务的隔离级别
- READ UNCOMMITTED(读未提交):该隔离级别的事务会读到其它未提交事务的数据,此现象也称之为脏读。
- READ COMMITTED(读提交):一个事务可以读取另一个已提交的事务,多次读取会造成不一样的结果,此现象称为不可重复读问题,Oracle 和 SQL Server 的默认隔离级别。
- REPEATABLE READ(可重复读):该隔离级别是 MySQL 默认的隔离级别,在同一个事务里,select 的结果是事务开始时时间点的状态,因此,同样的 select 操作读到的结果会是一致的,但是,会有幻读现象。MySQL的 InnoDB 引擎可以通过 next-key locks 机制(参考下文"行锁的算法"一节)来避免幻读。
- SERIALIZABLE(序列化):在该隔离级别下事务都是串行顺序执行的,MySQL 数据库的 InnoDB 引擎会给读操作隐式加一把读共享锁,从而避免了脏读、不可重读复读和幻读问题。
- 可通过 @Transactional(isolation = Isolation.DEFAULT) 的属性进行设置当前事务的隔离级别。
13.1.3、事务的传播行为
- 当前方法使用的事务,传播到内部方法的调用行为。
- PROPAGATION_REQUIRED:如果当前没有事务,就创建一个新事务,如果当前存在事务,就加入该事务,该设置是最常用的设置。
- PROPAGATION_SUPPORTS:支持当前事务,如果当前存在事务,就加入该事务,如果当前不存在事务,就以非事务执行。
- PROPAGATION_MANDATORY:支持当前事务,如果当前存在事务,就加入该事务,如果当前不存在事务,就抛出异常。
- PROPAGATION_REQUIRES_NEW:创建新事务,无论当前存不存在事务,都创建新事务。
- PROPAGATION_NOT_SUPPORTED:以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。
- PROPAGATION_NEVER:以非事务方式执行,如果当前存在事务,则抛出异常。
- PROPAGATION_NESTED:如果当前存在事务,则在嵌套事务内执行。如果当前没有事务,则执行与 PROPAGATION_REQUIRED 类似的操作。
13.1.4、SpringBoot 事务
//主启动类打上注解
@EnableAspectJAutoProxy(exposeProxy = true) //开启了aspect动态代理模式,对外暴露代理对象
@EnableRedisHttpSession //开启springsession
@EnableRabbit
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication(exclude = GlobalTransactionAutoConfiguration.class)
public class GulimallOrderApplication {
public static void main(String[] args) {
SpringApplication.run(GulimallOrderApplication.class, args);
}
}
/**
* 本地事务回顾
* @param
* @return
*/
@Transactional(propagation = Propagation.REQUIRED) //事务的传播行为 REQUIRED 使用当前方法的对象 REQUIRES_NEW 使用新的事务
public void a(){
//问题:同一个对象内事务方法互调默认失效,都是使用a的事务(如果想使用b、c方法设置的事务传播行为,就得使用代理对象调用),原因:事务是需要代理对象进行实现,直接调用绕过了代理对象 无法实现
b(); //使用a的事务 a抛出异常 b也回滚
c(); //不使用a的事务成 自己创建新的事务 抛出异常 c不回滚
//需要代理对象调用其方法后,事务配置才可生效
// bService.a();
// cService.c();
/**
* 如果需要自己本类互调事务生效 需要使用到本类的代理对象 调用方法
* 引入 starter-aop 开启 @EnableAspectJAutoProxy(exposeProxy = true) 所有的动态代理都是 AspectJ代理出来的对象(即使没有接口也可以创建动态代理)
* exposeProxy = true :对外暴露代理对象
*/
//使用aop直接获取当前类的代理对象
OrderServiceImpl o = (OrderServiceImpl) AopContext.currentProxy();
o.a();
o.c();
}
@Transactional(propagation = Propagation.REQUIRED)
public void b() {
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void c() {
}
13.2、分布式事务
13.2.1、CAP 定理与 BASE 理论
1、CAP 定理
- CAP 原则又称 CAP 定理,指的是在一个分布式系统中
- 一致性(Consistency):在分布式系统中的所有数据备份,在同一时刻是否同样的值。(等同于所有节点访问同一份最新的数据副本)
- 可用性(Availability):在集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求。(对数据更新具备高可用性)
- 分区容错性(Partition tolerance):大多数分布式系统都分布在多个子网络。每个子网络就叫做一个区(partition)。分区容错的意思是,区间通信可能失败,两个服务器之间无法正常通信。比如,一台服务器放在中国,另一台服务器放在美国,这就是两个区,它们之间可能无法通信。
- CAP 原则指的是,这三个要素最多只能同时实现两点,不可能三者兼顾。
- 一般来说,分区容错无法避免,因此可以认为 CAP 的 P 总是成立。CAP 定理告诉我们,
剩下的 C 和 A 无法同时做到,因为分区容错,导致服务器无法通信后,数据无法同步到服务器上,导致无法同步的服务器上的数据不具备高可用性。 - 分布式系统中实现一致性的 raft 算法、paxos。raft算法动画演示效果
2、BASE 理论
- 是对 CAP 理论的延伸,思想是即使无法做到强一致性(CAP 的一致性就是强一致性),但可
以采用适当的采取弱一致性,即最终一致性。 - BASE 是指
- 基本可用(Basically Available):基本可用是指分布式系统在出现故障的时候,允许损失部分可用性(例如响应时间、
功能上的可用性),允许损失部分可用性。需要注意的是,基本可用绝不等价于系
统不可用。- 响应时间上的损失:正常情况下搜索引擎需要在 0.5 秒之内返回给用户相应的查询结果,但由于出现故障(比如系统部分机房发生断电或断网故障),查询结果的响应时间增加到了 1~2 秒。
- 功能上的损失:购物网站在购物高峰(如双十一)时,为了保护系统的稳定性,部分消费者可能会被引导到一个降级页面。
- 软状态( Soft State):软状态是指允许系统存在中间状态,而该中间状态不会影响系统整体可用性。分布式存储中一般一份数据会有多个副本,允许不同副本同步的延时就是软状态的体现。mysql replication 的异步复制也是一种体现。
- 最终一致性( Eventual Consistency):最终一致性是指系统中的所有数据副本经过一定时间后,最终能够达到一致的状态。弱一致性和强一致性相反,最终一致性是弱一致性的一种特殊情况。
- 基本可用(Basically Available):基本可用是指分布式系统在出现故障的时候,允许损失部分可用性(例如响应时间、
3、强一致性、弱一致性、最终一致性
- 从客户端角度,多进程并发访问时,更新过的数据在不同进程如何获取的不同策略,决定了不同的一致性。对于关系型数据库,要求更新过的数据能立马被后续的访问都能看到,这是强一致性。如果能容忍后续的部分或者全部访问不到,则是弱一致性。如果经过一段时间后要求能访问到更新后的数据,则是最终一致性。
13.2.2、分布式事务几种方案
1、2PC 模式
- 数据库支持的 2PC【2 phase commit 二阶提交】,又叫做 XA Transactions。MySQL 从 5.5 版本开始支持,SQL Server 2005 开始支持,Oracle 7 开始支持。其中,XA 是一个两阶段提交协议,该协议分为以下两个阶段:
- 第一阶段:事务协调器要求每个涉及到事务的数据库预提交(precommit)此操作,并反映是否可以提交。
- 第二阶段:事务协调器要求每个数据库提交数据。
- 其中,如果有任何一个数据库否决此次提交,那么所有数据库都会被要求回滚它们在此事务中的那部分信息。
- XA 协议比较简单,而且一旦商业数据库实现了 XA 协议,使用分布式事务的成本也比较低。
- XA 性能不理想,特别是在交易下单链路,往往并发量很高,XA 无法满足高并发场景
- XA 目前在商业数据库支持的比较理想,在 mysql 数据库中支持的不太理想,mysql 的XA 实现,没有记录 prepare 阶段日志,主备切换回导致主库与备库数据不一致。
- 许多 NoSQL也没有支持 XA,这让 XA 的应用场景变得非常狭隘。
- 也有 3PC,引入了超时机制(无论协调者还是参与者,在向对方发送请求后,若长时间未收到回应则做出相应处理)
2、柔性事务-TCC 事务补偿型方案
- 刚性事务:遵循 ACID 原则,强一致性。
- 柔性事务:遵循 BASE 理论,最终一致性;
- 与刚性事务不同,柔性事务允许一定时间内,不同节点的数据不一致,但要求最终一致。
-
一阶段 prepare 行为:调用自定义 的 prepare 逻辑,数据修改准备逻辑。
-
二阶段 commit 行为: 调用自定义 的 commit 逻辑,事务提交。
-
二阶段 rollback 行为:调用自定义 的 rollback 逻辑,回滚事务做的补偿机制逻辑。
3、柔性事务-最大努力通知型方案
- 按规律进行通知,不保证数据一定能通知成功,但会提供可查询操作接口进行核对。这种方案主要用在与第三方系统通讯时,比如:调用微信或支付宝支付后的支付结果通知。这种方案也是结合 MQ 进行实现,例如:通过 MQ 发送 http 请求,设置最大通知次数。达到通知次数后即不再通知。
- 案例:银行通知、商户通知等(各大交易业务平台间的商户通知:多次通知、查询校对、对账文件),支付宝的支付成功异步回调。
4、柔性事务-可靠消息+最终一致性方案(异步确保型)
- 实现:业务处理服务在业务事务提交之前,向实时消息服务请求发送消息,实时消息服务只记录消息数据,而不是真正的发送。业务处理服务在业务事务提交之后,向实时消息服务确认发送。只有在得到确认发送指令后,实时消息服务才会真正发送
14、Seata
14.1、Seata术语
- TC (Transaction Coordinator) - 事务协调者:维护全局和分支事务的状态,驱动全局事务提交或回滚。
- TM (Transaction Manager) - 事务管理器:定义全局事务的范围:开始全局事务、提交或回滚全局事务。
- RM (Resource Manager) - 资源管理器:管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
14.2、整体机制
-
两阶段提交协议的演变:
-
一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。
-
二阶段:提交异步化,非常快速地完成。回滚通过一阶段的回滚日志进行反向补偿。
14.3、Seata使用
- 每一个微服务必须创建UNDO_LOG
-- 注意此处0.3.0+ 增加唯一索引 ux_undo_log CREATE TABLE `undo_log` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `branch_id` bigint(20) NOT NULL, `xid` varchar(100) NOT NULL, `context` varchar(128) NOT NULL, `rollback_info` longblob NOT NULL, `log_status` int(11) NOT NULL, `log_created` datetime NOT NULL, `log_modified` datetime NOT NULL, `ext` varchar(100) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
- 安装事务协调器(TC):seate-server下载
14.2.1、整合
- 示例代码
- 导入依赖
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-seata</artifactId> <exclusions> <exclusion> <groupId>io.seata</groupId> <artifactId>seata-all</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>io.seata</groupId> <artifactId>seata-all</artifactId> <version>1.4.1</version> </dependency>
- 解压并启动seata-server, registry.conf:注册中心配置 修改 registry : nacos
#registry.config registry { # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa 配置注册中心 type = "nacos" loadBalance = "RandomLoadBalance" loadBalanceVirtualNodes = 10 nacos { serverAddr = "127.0.0.1" namespace = "" group="DEFAULT_GROUP" } eureka { serviceUrl = "http://localhost:8761/eureka" application = "default" weight = "1" } redis { serverAddr = "localhost:6379" db = 0 password = "" cluster = "default" timeout = 0 } zk { cluster = "default" serverAddr = "127.0.0.1:2181" sessionTimeout = 6000 connectTimeout = 2000 username = "" password = "" } consul { cluster = "default" serverAddr = "127.0.0.1:8500" } etcd3 { cluster = "default" serverAddr = "http://localhost:2379" } sofa { serverAddr = "127.0.0.1:9603" application = "default" region = "DEFAULT_ZONE" datacenter = "DefaultDataCenter" cluster = "default" group = "SEATA_GROUP" addressWaitTime = "3000" } file { name = "file.conf" } } config { # file、nacos 、apollo、zk、consul、etcd3 配置seata文件加载路径 type = "file" nacos { serverAddr = "127.0.0.1" namespace = "" group="DEFAULT_GROUP" } consul { serverAddr = "127.0.0.1:8500" } apollo { appId = "seata-server" apolloMeta = "http://192.168.1.204:8801" namespace = "application" apolloAccesskeySecret = "" } zk { serverAddr = "127.0.0.1:2181" sessionTimeout = 6000 connectTimeout = 2000 username = "" password = "" } etcd3 { serverAddr = "http://localhost:2379" } file { name = "file.conf" } }
transport { # tcp, unix-domain-socket type = "TCP" #NIO, NATIVE server = "NIO" #enable heartbeat heartbeat = true # the client batch send request enable enableClientBatchSendRequest = false #thread factory for netty threadFactory { bossThreadPrefix = "NettyBoss" workerThreadPrefix = "NettyServerNIOWorker" serverExecutorThreadPrefix = "NettyServerBizHandler" shareBossWorker = false clientSelectorThreadPrefix = "NettyClientSelector" clientSelectorThreadSize = 1 clientWorkerThreadPrefix = "NettyClientWorkerThread" # netty boss thread size bossThreadSize = 1 #auto default pin or 8 workerThreadSize = "default" } shutdown { # when destroy server, wait seconds wait = 3 } serialization = "seata" compressor = "none" } ## transaction log store, only used in seata-server 日志保存记录位置 store { ## store mode: file、db、redis mode = "file" ## file store property file { ## store location dir dir = "sessionStore" # branch session size , if exceeded first try compress lockkey, still exceeded throws exceptions maxBranchSessionSize = 16384 # globe session size , if exceeded throws exceptions maxGlobalSessionSize = 512 # file buffer size , if exceeded allocate new buffer fileWriteBufferCacheSize = 16384 # when recover batch read size sessionReloadReadSize = 100 # async, sync flushDiskMode = async } ## database store property db { ## the implement of javax.sql.DataSource, such as DruidDataSource(druid)/BasicDataSource(dbcp)/HikariDataSource(hikari) etc. datasource = "druid" ## mysql/oracle/postgresql/h2/oceanbase etc. dbType = "mysql" driverClassName = "com.mysql.jdbc.Driver" url = "jdbc:mysql://127.0.0.1:3306/seata" user = "mysql" password = "mysql" minConn = 5 maxConn = 100 globalTable = "global_table" branchTable = "branch_table" lockTable = "lock_table" queryLimit = 100 maxWait = 5000 } ## redis store property redis { host = "127.0.0.1" port = "6379" password = "" database = "0" minConn = 1 maxConn = 10 maxTotal = 100 queryLimit = 100 } } #配置seata分组 service { vgroup_mapping.my-test-tx-group = "default" ##default.grouplist = "127.0.0.1:8091" } ## server configuration, only used in server side server { recovery { #schedule committing retry period in milliseconds committingRetryPeriod = 1000 #schedule asyn committing retry period in milliseconds asynCommittingRetryPeriod = 1000 #schedule rollbacking retry period in milliseconds rollbackingRetryPeriod = 1000 #schedule timeout retry period in milliseconds timeoutRetryPeriod = 1000 } undo { logSaveDays = 7 #schedule delete expired undo_log in milliseconds logDeletePeriod = 86400000 } #check auth enableCheckAuth = true #unit ms,s,m,h,d represents milliseconds, seconds, minutes, hours, days, default permanent maxCommitRetryTimeout = "-1" maxRollbackRetryTimeout = "-1" rollbackRetryTimeoutUnlockEnable = false } ## metrics configuration, only used in server side metrics { enabled = false registryType = "compact" # multi exporters use comma divided exporterList = "prometheus" exporterPrometheusPort = 9898 }
#配置setat配置文件 spring: cloud: alibaba: seata: registry: type: nacos nacos: application: seata-server server-addr: 127.0.0.1:8848 group: "DEFAULT_GROUP" namespace: "public" username: "nacos" password: "nacos" # 事务分组配置(在v1.5之后默认值为default_tx_group) 事务组的命名不要用下划线’_‘,可以用’-'因为在seata的高版本中使用underline下划线 将导致service not to be found。 tx-service-group: my-test-tx-group enabled: true service: #指定事务分组至集群映射关系(等号右侧的集群名需要与Seata-server注册到Nacos的cluster保持一致) vgroup_mapping: my-test-tx-group: default
- 所有想要用到分布式事务的微服务使用seata DataSourceProxy 代理自己的数据源
@Configuration public class MySeataConfig { /* @Bean @ConfigurationProperties(prefix = "spring.datasource") public DataSource druidDataSource(){ return new DruidDataSource(); } @Bean public DataSourceProxy dataSourceProxy(DataSource dataSource) { return new DataSourceProxy(dataSource); }*/ @Autowired DataSourceProperties dataSourceProperties; /** * 需要将 DataSourceProxy 设置为主数据源,否则事务无法回滚 * 将现有的数据源 使用seata 数据源进行包装 * @param dataSourceProperties * @return */ @Bean @Primary 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); } }
- 每个微服务,都必须导入 registry.conf file.conf
- vgroup_mapping.{application.name}-fescar-server-group = "default"
- 启动测试分布式事务
- 给分布式大事务的入口标注@GlobalTransactional
- 每一个远程的小事务用@Trabsactional
- seata 高并发不友好(会有很多锁,锁住数据,导致为串行化,执行速度慢),使用mq消息可以解决
15、定时任务
1、cron 表达式
- 语法:秒 分 时 日 月 周 年(Spring 不支持)
- 官方文档
- 特殊字符:
- ,:枚举:(cron="7,9,23 * * * * ?"):任意时刻的 7,9,23 秒启动这个任务;
- -:范围:(cron="7-20 * * * * ?"):任意时刻的 7-20 秒之间,每秒启动一次
- *:任意:指定位置的任意时刻都可以
- /:步长:(cron="7/5 * * * * ?"):第 7 秒启动,每 5 秒一次;
- (cron="*/5 * * * * ?"):任意秒启动,每 5 秒一次;
- ?:(出现在日和周几的位置):为了防止日和周冲突,在周和日上如果要写通配符使用?。(cron="* * * 1 * ?"):每月的 1 号,启动这个任务;
- L:(出现在日和周的位置)”,最后一个。(cron="* * * ? * 3L"):每月的最后一个周二
- W:Work Day:工作日。(cron="* * * W * ?"):每个月的工作日触发。(cron="* * * LW * ?"):每个月的最后一个工作日触发
- #:第几个(cron="* * * ? * 5#2"):每个月的第 2 个周 4
- 表达式生成器
2、SpringBoot 整合
/**
* 定时任务
* 1、@EnableScheduling 开启定时任务
* 2、@Scheduled开启一个定时任务
* 3、自动配置类:TaskSchedulingAutoConfiguration
* 异步任务
* 1、@EnableAsync:开启异步任务
* 2、@Async:给希望异步执行的方法标注
* 3、自动配置类:TaskExecutionAutoConfiguration
*/
@Slf4j
@Component
@EnableAsync //开启异步任务
@EnableScheduling //开启定时任务
public class HelloScheduled {
/**
* 1、在Spring中表达式是6位组成,不允许第七位的年份
* 2、在周几的的位置,1-7代表周一到周日
* 3、定时任务不该阻塞。默认是阻塞的
* 1)、可以让业务以异步的方式,自己提交到线程池
* CompletableFuture.runAsync(() -> {
* },execute);
*
* 2)、支持定时任务线程池;设置 TaskSchedulingProperties(不同版本可能不太好使)
* spring.task.scheduling.pool.size: 5
*
* 3)、让定时任务异步执行 TaskExecutionProperties 配置异步线程池的数量等信息 以后也可以使用spring配置的异步任务当做我们自己的线程池
* 异步任务
* 解决:使用异步任务 + 定时任务来完成定时任务不阻塞的功能
*
* 2023-04-03 16:17:50.001 INFO 39416 --- [ task-2] c.p.g.seckill.scheduled.HelloScheduled : hello...
* 2023-04-03 16:17:55.001 INFO 39416 --- [ task-3] c.p.g.seckill.scheduled.HelloScheduled : hello...
*/
@Async
@Scheduled(cron = "*/5 * * ? * 1")
public void hello() {
log.info("hello...");
try {
TimeUnit.SECONDS.sleep(6);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("Word...");
}
}
16、秒杀
1、定时任务使用场景(自动上架秒杀商品)
- 取重点关注看即可
/**
* @Description: 开启定时任务
**/
@EnableAsync
@EnableScheduling
@Configuration
public class ScheduledConfig {
}
/**
* 秒杀商品定时上架
* 每天晚上3点,上架最近三天需要三天秒杀的商品
* 当天00:00:00 - 23:59:59
* 明天00:00:00 - 23:59:59
* 后天00:00:00 - 23:59:59
*/
@Slf4j
@Service
public class SeckillScheduled {
@Autowired
private SeckillService seckillService;
@Autowired
private RedissonClient redissonClient;
//秒杀商品上架功能的锁
private final String upload_lock = "seckill:upload:lock";
//TODO 保证幂等性问题 当有多个服务部署时,会出现多个服务都会执行上架操作,所以需要保证每次只能由一个服务执行上架操作
@Scheduled(cron = "1 * * * * ? ")
// @Scheduled(cron = "0 0 1/1 * * ? ")
public void uploadSeckillSkuLatest3Days() {
//1、重复上架无需处理
log.info("上架秒杀的商品...");
//分布式锁 保证幂等性问题
RLock lock = redissonClient.getLock(upload_lock);
try {
//加锁
lock.lock(10, TimeUnit.SECONDS);
seckillService.uploadSeckillSkuLatest3Days();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
@Autowired
private RedissonClient redissonClient;
@Autowired
private RabbitTemplate rabbitTemplate;
private final String SESSION_CACHE_PREFIX = "seckill:sessions:";
private final String SECKILL_CHARE_PREFIX = "seckill:skus";
private final String SKU_STOCK_SEMAPHORE = "seckill:stock:"; //+商品随机码
@Override
public void uploadSeckillSkuLatest3Days() {
//TODO 上架秒杀的商品
log.info("上架秒杀的商品...");
//1、扫描最近三天的商品需要参加秒杀的活动
R lates3DaySession = couponFeignService.getLates3DaySession();
if (lates3DaySession.getCode() == 0) {
//上架商品
List<SeckillSessionWithSkusVo> sessionData = lates3DaySession.getData("data", new TypeReference<List<SeckillSessionWithSkusVo>>() {
});
if (Objects.nonNull(sessionData)) {
//缓存到Redis
//1、缓存活动信息
saveSessionInfos(sessionData);
//2、缓存活动的关联商品信息
saveSessionSkuInfo(sessionData);
}
}
}
/**
* 缓存秒杀活动信息
*
* @param sessions
*/
private void saveSessionInfos(List<SeckillSessionWithSkusVo> sessions) {
sessions.forEach(session -> {
//获取当前活动的开始和结束时间的时间戳
long startTime = session.getStartTime().getTime();
long endTime = session.getEndTime().getTime();
//存入到Redis中的key
String key = SESSION_CACHE_PREFIX + startTime + "_" + endTime;
//判断Redis中是否有该信息,如果没有才进行添加 重点关注=========================保证秒杀场次信息的幂等性==============================
Boolean hasKey = redisTemplate.hasKey(key);
//缓存活动信息
if (Boolean.FALSE.equals(hasKey)) {
//获取到活动中所有商品的skuId
List<String> skuIds = session.getRelationSkus().stream()
.map(item -> item.getPromotionSessionId() + "-" + item.getSkuId().toString()).collect(Collectors.toList());
if (!skuIds.isEmpty()) {
redisTemplate.opsForList().leftPushAll(key, skuIds);
}
}
});
}
/**
* 缓存秒杀活动所关联的商品信息
*
* @param sessions
*/
private void saveSessionSkuInfo(List<SeckillSessionWithSkusVo> sessions) {
sessions.forEach(session -> {
//准备hash操作,绑定hash
BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(SECKILL_CHARE_PREFIX);
session.getRelationSkus().forEach(seckillSkuVo -> {
String redisKey = seckillSkuVo.getPromotionSessionId().toString() + "-" + seckillSkuVo.getSkuId().toString();
//重点关注======================保证秒杀场次活动的下的商品幂等性=================================
if (Boolean.FALSE.equals(operations.hasKey(redisKey))) {
//缓存我们商品信息
SeckillSkuRedisTo redisTo = new SeckillSkuRedisTo();
Long skuId = seckillSkuVo.getSkuId();
//1、先查询sku的基本信息,调用远程服务
R info = productFeignService.getSkuInfo(skuId);
if (info.getCode() == 0) {
SkuInfoVo skuInfo = info.getData("skuInfo", new TypeReference<SkuInfoVo>() {
});
redisTo.setSkuInfo(skuInfo);
}
//2、sku的秒杀信息
BeanUtils.copyProperties(seckillSkuVo, redisTo);
//3、设置当前商品的秒杀时间信息
redisTo.setStartTime(session.getStartTime().getTime());
redisTo.setEndTime(session.getEndTime().getTime());
//重点关注======================随机码 防止别人恶意抢购,秒杀商品时,必须携带随机码,否则无法抢购=================================
//生成随机码
String token = UUID.randomUUID().toString().replace("-", "");
//4、设置商品的随机码(防止恶意攻击)
redisTo.setRandomCode(token);
//如果当前这个场次的商品库存信息已经上架就不需要上架
//5、使用库存作为分布式Redisson信号量(限流)
// 使用库存作为分布式信号量
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + token);
// 商品可以秒杀的数量作为信号量 重点关注======================存储秒杀商品的数量,如果实时扣减库数据存,容易高并发产生问题,存储到信号量,能够实时且高效==========================
semaphore.trySetPermits(seckillSkuVo.getSeckillCount());
//序列化json格式存入Redis中
String seckillValue = JSON.toJSONString(redisTo);
operations.put(redisKey, seckillValue);
}
});
});
}
2、秒杀(高并发)系统关注的问题
3、高并发有三宝
4、秒杀商品代码
- 秒杀功能:秒杀场景需要考虑(服务单一职责+独立部署、秒杀链接加密(防止恶意攻击,模拟秒杀请求)、库存预热+快速扣减(库存预热,信号量空虚感知进来的秒杀商品数量)、动静分离(保证秒杀和商品详情页的动态请求才打到后端的服务集群)、恶意请求拦截(识别非法攻击请求并进行拦截)、队列削峰(双11所有秒杀成功的请求,进入队列,慢慢创建订单,扣减库存即可。))、定时任务处理查询秒杀的场次和秒杀的商品,将秒杀的数据存储到Redis中,如果多服务部署时,需要保证幂等性操作,一次只能由其中一个服务处理,也需要保证缓存中秒杀场次和秒杀商品的数据的幂等性,不能存重复存储秒杀数据,秒杀的商品的数量使用redisson的信号量解决,且配置秒杀的随机码,只有随机码匹配的才能秒杀,防止恶意秒杀;秒杀商品时,需要判断,随机码、是否在秒杀时间内、秒杀数量是否充足(获取信号量中的数量)、人是否已经秒杀过同样的商品等,秒杀成功后,发送消息给mq,监听队列的创建订单,mq慢慢消费,慢慢消峰,秒杀库存信号量减少。
/**
* 当前商品进行秒杀(秒杀开始)
* @param killId 场次id_商品id
* @param key 商品的随机码
* @param num 秒杀数量
* @return
*/
@SneakyThrows
@Override
public String kill(String killId, String key, Integer num) {
long s1 = System.currentTimeMillis();
//获取当前用户的信息
MemberResponseVo user = LoginUserInterceptor.loginUser.get();
//1、获取当前秒杀商品的详细信息从Redis中获取
BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SECKILL_CHARE_PREFIX);
String skuInfoValue = hashOps.get(killId);
if (StringUtils.isEmpty(skuInfoValue)) {
return null;
}
//(合法性效验)
SeckillSkuRedisTo redisTo = JSON.parseObject(skuInfoValue, SeckillSkuRedisTo.class);
Long startTime = redisTo.getStartTime();
Long endTime = redisTo.getEndTime();
long currentTime = System.currentTimeMillis();
//判断当前这个秒杀请求是否在活动时间区间内(效验时间的合法性)
if (currentTime >= startTime && currentTime <= endTime) {
//2、效验随机码和场次id_商品id
String randomCode = redisTo.getRandomCode();
String skuId = redisTo.getPromotionSessionId() + "-" +redisTo.getSkuId();
if (randomCode.equals(key) && killId.equals(skuId)) {
//3、验证购物数量是否合理和库存量是否充足
Integer seckillLimit = redisTo.getSeckillLimit();
//获取信号量
String seckillCount = redisTemplate.opsForValue().get(SKU_STOCK_SEMAPHORE + randomCode);
int count = Integer.parseInt(seckillCount);
//判断信号量是否大于0,并且买的数量不能超过库存
if (count > 0 && num <= seckillLimit && count > num ) {
//4、验证这个人是否已经买过了(幂等性处理),如果秒杀成功,就去占位。userId-sessionId-skuId
//SETNX 原子性处理
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);
//TODO 秒杀成功,快速下单 取出1个信号量 不阻塞试 100ms立马尝试可不可以获取到信号量
boolean semaphoreCount = semaphore.tryAcquire(num, 100, TimeUnit.MILLISECONDS);
//保证Redis中还有商品库存
if (semaphoreCount) {
//创建订单号和订单信息发送给MQ
// 秒杀成功 快速下单 发送消息到 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());
rabbitTemplate.convertAndSend("order-event-exchange","order.seckill.order",orderTo);
long s2 = System.currentTimeMillis();
log.info("耗时..." + (s2 - s1));
return timeId;
}
}
}
}
}
long s3 = System.currentTimeMillis();
log.info("耗时..." + (s3 - s1));
return null;
}
四、运维知识点
1、Nginx的应用
1.1、正向代理与反向代理概念
-
正向代理是一个位于客户端和目标服务器之间的代理服务器(中间服务器)。为了从原始服务器取得内容,客户端向代理服务器发送一个请求,并且指定目标服务器,之后代理向目标服务器转交并且将获得的内容返回给客户端。正向代理的情况下客户端必须要进行一些特别的设置才能使用。
-
反向代理正好相反。对于客户端来说,反向代理就好像目标服务器。并且客户端不需要进行任何设置。客户端向反向代理发送请求,接着反向代理判断请求走向何处,并将请求转交给客户端,使得这些内容就好似他自己一样,一次客户端并不会感知到反向代理后面的服务,也因此不需要客户端做任何设置,只需要把反向代理服务器当成真正的服务器就好了。
- 区别
- 正向代理需要你主动设置代理服务器ip或者域名进行访问,由设置的服务器ip或者域名去获取访问内容并返回;
- 而反向代理不需要你做任何设置,直接访问服务器真实ip或者域名,但是服务器内部会自动根据访问内容进行跳转及内容返回,你不知道它最终访问的是哪些机器。
-
正向代理是代理客户端,为客户端收发请求,使真实客户端对服务器不可见;而反向代理是代理服务器端,为服务器收发请求,使真实服务器对客户端不可见。
-
从上面的描述也能看得出来正向代理和反向代理最关键的两点区别:
-
是否指定目标服务器
-
客户端是否要做设置
-
-
用一张图来表示两者的差异:正向代理中,proxy和client同属一个LAN,对server透明; 反向代理中,proxy和server同属一个LAN,对client透明。 实际上proxy在两种代理中做的事都是代为收发请求和响应,不过从结构上来看正好左右互换了下,所以把前者那种代理方式叫做正向代理,后者叫做反向代理。
-
"废话文学解释":
-
正向代理:A同学在大众创业、万众创新的大时代背景下开启他的创业之路,目前他遇到的最大的一个问题就是启动资金,于是他决定去找马云爸爸借钱,可想而知,最后碰一鼻子灰回来了,情急之下,他想到一个办法,找关系开后门,经过一番消息打探,原来A同学的大学老师王老师是马云的同学,于是A同学找到王老师,托王老师帮忙去马云那借500万过来,当然最后事成了。不过马云并不知道这钱是A同学借的,马云是借给王老师的,最后由王老师转交给A同学。这里的王老师在这个过程中扮演了一个非常关键的角色,就是代理,也可以说是正向代理,王老师代替A同学办这件事,这个过程中,真正借钱的人是谁,马云是不知道的,这点非常关键。
-
大家都有过这样的经历,拨打10086客服电话,可能一个地区的10086客服有几个或者几十个,你永远都不需要关心在电话那头的是哪一个,叫什么,男的,还是女的,漂亮的还是帅气的,你都不关心,你关心的是你的问题能不能得到专业的解答,你只需要拨通了10086的总机号码,电话那头总会有人会回答你,只是有时慢有时快而已。那么这里的10086总机号码就是我们说的反向代理。客户不知道真正提供服务人的是谁。
-
1.2、Nginx配置文件
1.3、Nginx+Windows搭建域名访问环境
- 流程和配置信息
#配置到http块下 #上游服务器路径 upstream gulimall{ #转发至网关,由网关转发到指定的服务 server 192.168.56.1:80; } server { listen 80; #会读取请求头中携带的host 路径进行域名匹配,多个域名映射同一个上游路径 server_name gulimall.com search.gulimall.com item.gulimall.com auth.gulimall.com cart.gulimall.com order.gulimall.com member.gulimall.com 4448227kv1.imdo.co; #charset koi8-r; #access_log /var/log/nginx/log/host.access.log main; location /payed/ { #接收内网穿透穿过来的 host主机头不匹配,导致无法转发指定服务,只要是/payed开始的请求,直接转到订单服务商,需要指定固定的订单的host主机头,转发到对应的服务上 proxy_set_header Host order.gulimall.com; #路由到指定路径和端口 #proxy_pass http://192.168.56.1:10000; #路由到指定upstream上,需要在server块之上配置 proxy_pass http://gulimall; } #static请求路径转发到Nginx的html目录下,实现动静分离,一个请求只会对应一个location处理,当前location能处理的就不会往下走 location /static/{ root /usr/share/nginx/html; } location / { #Nginx转发请求时会丢失头部信息,需要自己设置头部信息 网关需要使用对于的请求头信息进行路由跳转 proxy_set_header Host $host; #proxy_pass http://192.168.56.1:10000; #路由到指定upstream上,需要在server块之上配置 proxy_pass http://gulimall; } }
- 网关配置根据请求头中host地址转发
spring: cloud: gateway: routes: #客户端发送请求->nginx转发到网关(会丢失头信息,需要在Nginx进行设置)->网关接收请求通过断言请求头信息,转发到对应服务 - id: gulimall-product-host uri: lb://gulimall-product predicates: #断言请求头匹配访问的域名地址,能够匹配上 gulimall.com,item.gulimall.com的请求头的转发到对应服务 - Host=gulimall.com,item.gulimall.com
1.3、Nginx 动静分离
- Nginx 动静分离简单来说就是把动态跟静态请求分开,不能理解成只是单纯的把动态页面和静态页面物理分离。严格意义上说应该是动态请求跟静态请求分开,可以理解成使用 Nginx 处理静态页面,Tomcat 处理动态页面。动静分离从目前实现角度来讲大致分为两种。
- 在 liunx 系统中准备静态资源目录和资源文件(创建data目录,放入动态资源和静态资源),用于进行访问。
- 在 nginx 配置文件中进行配置。(需要重新启动Nginx)
- 访问静态资源。
- 访问静态资源的目录,会显示所有的静态资源。
- 访问动态资源。
- 商城项目配置
2、内网穿透
2.1、简介
- 内网穿透功能可以允许我们使用外网的网址来访问主机;
- 正常的外网需要访问我们项目的流程是:
- 买服务器并且有公网固定 IP
- 买域名映射到服务器的 IP
- 域名需要进行备案和审核
2.2、内网穿透图解
- 能ping的通不通的原理
- 内网穿透原理
2.3、内网穿透的几个常用软件
- natapp:https://natapp.cn/ 优惠码:022B93FD(9 折)[仅限第一次使用]
- 续断(视频所用):www.zhexi.tech 优惠码:SBQMEA(95 折)[仅限第一次使用]
- 花生壳(博主所用):https://www.oray.com
2.4、花生壳使用流程
- 下载花生壳客户端,并安装
- 客户端添加映射(需要在花生壳购买域名)
- 通过域名访问
3、K8S
篇幅太长,写到另外一篇博客中。
五、整合第三方平台
1、阿里云OSS
- 阿里云对象存储-普通上传方式(文件到后端后,后端直接上传)
- 由前端将文件流发送给服务器,服务器通过签名、秘钥和流文件发送给OSS,OSS进行验证、上传。
- 优点:较为安全,进行上传的秘钥等数据,全由服务器保存。
- 缺点:如果是大并发的情况下,会给服务器造成不必要的压力,造成瓶颈。
- 阿里云对象存储-服务端签名后直传(推荐)
- 由客户端发送请求给服务器,服务器通过秘钥和签名等生成防伪签名,服务器将防伪签名返回客户端,客户端通过当前的这个防伪签名进行文件上传。
- 优点:较为安全,进行上传的秘钥等数据,全由服务器保存;文件流数据不经过服务器,降低服务器带宽压力。
- 原生API操作OOS使用步骤
- 引入依赖
<dependency> <groupId>com.aliyun.oss</groupId> <artifactId>aliyun-sdk-oss</artifactId> <version>3.15.1</version> </dependency>
- 开通OOS后,OSS链接,创建OOS桶实例
- 创建一个AccessKey,使用 AccessKey 和 AccessSecret 进行身份验证,进行文件上传,必须申请 RAM 账号信息,并且分配 OSS 操作权限,不用阿里云账号密码,容易泄漏。
- 测试代码
@Test public void uploadYuanShengTest() { // Endpoint以华东1(杭州)为例,其它Region请按实际情况填写,创建OOS实例桶后的Endpoint。 String endpoint = "自己的endpoint "; // 阿里云账号AccessKey拥有所有API的访问权限,风险很高。强烈建议您创建并使用RAM用户进行API访问或日常运维,请登录RAM控制台创建RAM用户。 不是阿里云登陆密码,需要自己创建。 String accessKeyId = "自己的accessKeyId "; String accessKeySecret = "自己的accessKeySecret "; // 填写 Bucket 名称,例如examplebucket。 String bucketName = "自己创建OOS桶实例"; // 填写Object完整路径,完整路径中不能包含Bucket名称,例如exampledir/exampleobject.txt。 文件名 String objectName = "2e3a0b5ee45d446193118ecd6d987df5.png"; // 填写本地文件的完整路径,例如D:\\localpath\\examplefile.txt。 // 如果未指定本地路径,则默认从示例程序所属项目对应本地路径中上传文件流。 String filePath = "文件全路径名.文件后缀"; // 创建OSSClient实例。 OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret); try { InputStream inputStream = new FileInputStream(filePath); // 创建PutObject请求。 ossClient.putObject(bucketName, objectName, inputStream); } catch (OSSException oe) { System.out.println("Caught an OSSException, which means your request made it to OSS, " + "but was rejected with an error response for some reason."); System.out.println("Error Message:" + oe.getErrorMessage()); System.out.println("Error Code:" + oe.getErrorCode()); System.out.println("Request ID:" + oe.getRequestId()); System.out.println("Host ID:" + oe.getHostId()); } catch (FileNotFoundException e) { e.printStackTrace(); } finally { if (ossClient != null) { ossClient.shutdown(); } } }
- 引入依赖
- SpringCloudAlibaba 操作 OSS官方操作文档
- 导入依赖
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>aliyun-oss-spring-boot-starter</artifactId> </dependency>
- yaml配置文件
// application.properties alibaba.cloud.access-key=your-ak alibaba.cloud.secret-key=your-sk alibaba.cloud.oss.endpoint=***
- 测试代码 (文件到后端后,后端直接上传)
@Autowired public OSSClient ossClient; //使用springcloud alibaba 的API上传 配置文件配置如下内容: /** * alicloud: * oss: * sts: * access-key: ** * security-token: ** * endpoint: ** */ @Test public void uploadAlibabaTest() { String bucketName = "自己创建的oss实例桶名称"; // 填写Object完整路径,完整路径中不能包含Bucket名称,例如exampledir/exampleobject.txt。 String objectName = "上传文件名"; // 填写本地文件的完整路径,例如D:\\localpath\\examplefile.txt。 // 如果未指定本地路径,则默认从示例程序所属项目对应本地路径中上传文件流。 String filePath ="文件全路径地址.后缀"; try { InputStream inputStream = new FileInputStream(filePath); // 创建PutObject请求。 ossClient.putObject(bucketName, objectName, inputStream); } catch (OSSException oe) { System.out.println("Caught an OSSException, which means your request made it to OSS, " + "but was rejected with an error response for some reason."); System.out.println("Error Message:" + oe.getErrorMessage()); System.out.println("Error Code:" + oe.getErrorCode()); System.out.println("Request ID:" + oe.getRequestId()); System.out.println("Host ID:" + oe.getHostId()); } catch (FileNotFoundException e) { e.printStackTrace(); } finally { if (ossClient != null) { ossClient.shutdown(); } } }
- 测试代码2 (服务端签名后直传(推荐))
服务端签名官方文档代码文档@Value("${spring.cloud.alicloud.oss.endpoint}") private String endpoint; @Value("${spring.cloud.alicloud.oss.bucket}") private String bucket; @Value("${spring.cloud.alicloud.access-key}") private String accessId; /** * https://help.aliyun.com/document_detail/31926.html * 服务端签名后直传 * * @return */ @Autowired OSS ossClient; @RequestMapping("/oss/policy") protected R policy() { // 填写Host地址,格式为https://bucketname.endpoint。 String host = "https://" + bucket + "." + endpoint; // 设置上传到OSS文件的前缀,可置空此项。置空后,文件将上传至Bucket的根目录下。 String format = new SimpleDateFormat("yyyy-MM-dd").format(new Date()); String dir = format + "/"; Map<String, String> respMap = null; try { long expireTime = 30; long expireEndTime = System.currentTimeMillis() + expireTime * 1000; Date expiration = new Date(expireEndTime); PolicyConditions policyConds = new PolicyConditions(); policyConds.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, 1048576000); policyConds.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, dir); String postPolicy = ossClient.generatePostPolicy(expiration, policyConds); byte[] binaryData = postPolicy.getBytes("utf-8"); String encodedPolicy = BinaryUtil.toBase64String(binaryData); String postSignature = ossClient.calculatePostSignature(postPolicy); respMap = new LinkedHashMap<String, String>(); respMap.put("accessId", accessId); respMap.put("policy", encodedPolicy); respMap.put("signature", postSignature); respMap.put("dir", dir); respMap.put("host", host); respMap.put("expire", String.valueOf(expireEndTime / 1000)); } catch (Exception e) { System.out.println(e.getMessage()); } return R.ok().put("data",respMap); }
- 导入依赖
2、阿里云短信服务
- 案例代码
public static void main(String[] args) { String host = "https://cxkjsms.market.alicloudapi.com"; String path = "/chuangxinsms/dxjk"; String method = "POST"; String appcode = "你自己的AppCode";//开通服务后 买家中心-查看AppCode Map<String, String> headers = new HashMap<String, String>(); //最后在header中的格式(中间是英文空格)为Authorization:APPCODE 83359fd73fe94948385f570e3c139105 headers.put("Authorization", "APPCODE " + appcode); Map<String, String> querys = new HashMap<String, String>(); querys.put("content", "【创信】你的验证码是:5873,3分钟内有效!"); querys.put("mobile", "13800138001"); Map<String, String> bodys = new HashMap<String, String>(); try { /** * 重要提示如下: * HttpUtils请从 * https://github.com/aliyun/api-gateway-demo-sign-java/blob/master/src/main/java/com/aliyun/api/gateway/demo/util/HttpUtils.java * 下载 * * 相应的依赖请参照 * https://github.com/aliyun/api-gateway-demo-sign-java/blob/master/pom.xml */ HttpResponse response = HttpUtils.doPost(host, path, method, headers, querys, bodys); System.out.println(response.toString()); //获取response的body //System.out.println(EntityUtils.toString(response.getEntity())); } catch (Exception e) { e.printStackTrace(); } } public class HttpUtils { /** * get * * @param host * @param path * @param method * @param headers * @param querys * @return * @throws Exception */ public static HttpResponse doGet(String host, String path, String method, Map<String, String> headers, Map<String, String> querys) throws Exception { HttpClient httpClient = wrapClient(host); HttpGet request = new HttpGet(buildUrl(host, path, querys)); for (Map.Entry<String, String> e : headers.entrySet()) { request.addHeader(e.getKey(), e.getValue()); } return httpClient.execute(request); } /** * post form * * @param host * @param path * @param method * @param headers * @param querys * @param bodys * @return * @throws Exception */ public static HttpResponse doPost(String host, String path, String method, Map<String, String> headers, Map<String, String> querys, Map<String, String> bodys) throws Exception { HttpClient httpClient = wrapClient(host); HttpPost request = new HttpPost(buildUrl(host, path, querys)); for (Map.Entry<String, String> e : headers.entrySet()) { request.addHeader(e.getKey(), e.getValue()); } if (bodys != null) { List<NameValuePair> nameValuePairList = new ArrayList<NameValuePair>(); for (String key : bodys.keySet()) { nameValuePairList.add(new BasicNameValuePair(key, bodys.get(key))); } UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(nameValuePairList, "utf-8"); formEntity.setContentType("application/x-www-form-urlencoded; charset=UTF-8"); request.setEntity(formEntity); } return httpClient.execute(request); } /** * Post String * * @param host * @param path * @param method * @param headers * @param querys * @param body * @return * @throws Exception */ public static HttpResponse doPost(String host, String path, String method, Map<String, String> headers, Map<String, String> querys, String body) throws Exception { HttpClient httpClient = wrapClient(host); HttpPost request = new HttpPost(buildUrl(host, path, querys)); for (Map.Entry<String, String> e : headers.entrySet()) { request.addHeader(e.getKey(), e.getValue()); } if (StringUtils.isNotBlank(body)) { request.setEntity(new StringEntity(body, "utf-8")); } return httpClient.execute(request); } /** * Post stream * * @param host * @param path * @param method * @param headers * @param querys * @param body * @return * @throws Exception */ public static HttpResponse doPost(String host, String path, String method, Map<String, String> headers, Map<String, String> querys, byte[] body) throws Exception { HttpClient httpClient = wrapClient(host); HttpPost request = new HttpPost(buildUrl(host, path, querys)); for (Map.Entry<String, String> e : headers.entrySet()) { request.addHeader(e.getKey(), e.getValue()); } if (body != null) { request.setEntity(new ByteArrayEntity(body)); } return httpClient.execute(request); } /** * Put String * @param host * @param path * @param method * @param headers * @param querys * @param body * @return * @throws Exception */ public static HttpResponse doPut(String host, String path, String method, Map<String, String> headers, Map<String, String> querys, String body) throws Exception { HttpClient httpClient = wrapClient(host); HttpPut request = new HttpPut(buildUrl(host, path, querys)); for (Map.Entry<String, String> e : headers.entrySet()) { request.addHeader(e.getKey(), e.getValue()); } if (StringUtils.isNotBlank(body)) { request.setEntity(new StringEntity(body, "utf-8")); } return httpClient.execute(request); } /** * Put stream * @param host * @param path * @param method * @param headers * @param querys * @param body * @return * @throws Exception */ public static HttpResponse doPut(String host, String path, String method, Map<String, String> headers, Map<String, String> querys, byte[] body) throws Exception { HttpClient httpClient = wrapClient(host); HttpPut request = new HttpPut(buildUrl(host, path, querys)); for (Map.Entry<String, String> e : headers.entrySet()) { request.addHeader(e.getKey(), e.getValue()); } if (body != null) { request.setEntity(new ByteArrayEntity(body)); } return httpClient.execute(request); } /** * Delete * * @param host * @param path * @param method * @param headers * @param querys * @return * @throws Exception */ public static HttpResponse doDelete(String host, String path, String method, Map<String, String> headers, Map<String, String> querys) throws Exception { HttpClient httpClient = wrapClient(host); HttpDelete request = new HttpDelete(buildUrl(host, path, querys)); for (Map.Entry<String, String> e : headers.entrySet()) { request.addHeader(e.getKey(), e.getValue()); } return httpClient.execute(request); } private static String buildUrl(String host, String path, Map<String, String> querys) throws UnsupportedEncodingException { StringBuilder sbUrl = new StringBuilder(); sbUrl.append(host); if (!StringUtils.isBlank(path)) { sbUrl.append(path); } if (null != querys) { StringBuilder sbQuery = new StringBuilder(); for (Map.Entry<String, String> query : querys.entrySet()) { if (0 < sbQuery.length()) { sbQuery.append("&"); } if (StringUtils.isBlank(query.getKey()) && !StringUtils.isBlank(query.getValue())) { sbQuery.append(query.getValue()); } if (!StringUtils.isBlank(query.getKey())) { sbQuery.append(query.getKey()); if (!StringUtils.isBlank(query.getValue())) { sbQuery.append("="); sbQuery.append(URLEncoder.encode(query.getValue(), "utf-8")); } } } if (0 < sbQuery.length()) { sbUrl.append("?").append(sbQuery); } } return sbUrl.toString(); } private static HttpClient wrapClient(String host) { HttpClient httpClient = new DefaultHttpClient(); if (host.startsWith("https://")) { sslClient(httpClient); } return httpClient; } private static void sslClient(HttpClient httpClient) { try { SSLContext ctx = SSLContext.getInstance("TLS"); X509TrustManager tm = new X509TrustManager() { @Override public X509Certificate[] getAcceptedIssuers() { return null; } @Override public void checkClientTrusted(X509Certificate[] xcs, String str) { } @Override public void checkServerTrusted(X509Certificate[] xcs, String str) { } }; ctx.init(null, new TrustManager[] { tm }, null); SSLSocketFactory ssf = new SSLSocketFactory(ctx); ssf.setHostnameVerifier(SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER); ClientConnectionManager ccm = httpClient.getConnectionManager(); SchemeRegistry registry = ccm.getSchemeRegistry(); registry.register(new Scheme("https", 443, ssf)); } catch (KeyManagementException ex) { throw new RuntimeException(ex); } catch (NoSuchAlgorithmException ex) { throw new RuntimeException(ex); } } }
- 抽取公共方法,做成可配置项
spring: cloud: alicloud: sms: host: https://fesms.market.alicloudapi.com path: /sms/ skin: 1 sign: 175622 appcode: xxxxxxxxx
@ConfigurationProperties(value = "spring.cloud.alicloud.sms") @Data @Component public class SmsComponent { private String host; private String path; private String skin; private String sign; private String appcode; public void sendCode(String phone,String code) { String method = "GET"; Map<String, String> headers = new HashMap<String, String>(); //最后在header中的格式(中间是英文空格)为Authorization:APPCODE xxxxx headers.put("Authorization", "APPCODE " + appcode); Map<String, String> querys = new HashMap<String, String>(); querys.put("code", code); querys.put("phone", phone); querys.put("skin", skin); querys.put("sign", sign); //JDK 1.8示例代码请在这里下载: http://code.fegine.com/Tools.zip try { /** * 重要提示如下: * HttpUtils请从 * https://github.com/aliyun/api-gateway-demo-sign-java/blob/master/src/main/java/com/aliyun/api/gateway/demo/util/HttpUtils.java * 或者直接下载: * http://code.fegine.com/HttpUtils.zip * 下载 * * 相应的依赖请参照 * https://github.com/aliyun/api-gateway-demo-sign-java/blob/master/pom.xml * 相关jar包(非pom)直接下载: * http://code.fegine.com/aliyun-jar.zip */ HttpResponse response = HttpUtils.doGet(host, path, method, headers, querys); //System.out.println(response.toString());如不输出json, 请打开这行代码,打印调试头部状态码。 //状态码: 200 正常;400 URL无效;401 appCode错误; 403 次数用完; 500 API网管错误 //获取response的body System.out.println(EntityUtils.toString(response.getEntity())); } catch (Exception e) { e.printStackTrace(); } } }
- 验证码防刷校验
@Autowired private StringRedisTemplate stringRedisTemplate; @ResponseBody @GetMapping(value = "/sms/sendCode") public R sendCode(@RequestParam("phone") String phone) { //1、接口防刷 String redisCode = stringRedisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone); if (!StringUtils.isEmpty(redisCode)) { //活动存入redis的时间,用当前时间减去存入redis的时间,判断用户手机号是否在60s内发送验证码 long currentTime = Long.parseLong(redisCode.split("_")[1]); if (System.currentTimeMillis() - currentTime < 60000) { //60s内不能再发 return R.error(BizCodeEnum.SMS_CODE_EXCEPTION.getCode(), BizCodeEnum.SMS_CODE_EXCEPTION.getMsg()); } } //2、验证码的再次效验 redis.存key-phone,value-code int code = (int) ((Math.random() * 9 + 1) * 100000); String codeNum = String.valueOf(code); String redisStorage = codeNum + "_" + System.currentTimeMillis(); //存入redis,防止同一个手机号在60秒内再次发送验证码 stringRedisTemplate.opsForValue().set(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone, redisStorage, 10, TimeUnit.MINUTES); //远程调用第三方服务发送短信 thirdPartFeignService.sendCode(phone, codeNum); return R.ok(); }
3、支付宝支付
3.1、加密-对称加密
3.2、加密-非对称加密
3.3、什么是公钥、私钥、加密、签名和验签
- 公钥私钥:公钥和私钥是一个相对概念,它们的公私性是相对于生成者来说的。一对密钥生成后,保存在生成者手里的就是私钥,生成者发布出去大家用的就是公钥。
- 加密和数字签名
- 加密是指:我们使用一对公私钥中的一个密钥来对数据进行加密,而使用另一个密钥来进行解密的技术。
- 公钥和私钥都可以用来加密,也都可以用来解密。
- 但这个加解密必须是一对密钥之间的互相加解密,否则不能成功。
- 加密的目的是:
- 为了确保数据传输过程中的不可读性,就是不想让别人看到。
- 签名:
- 给我们将要发送的数据,做上一个唯一签名(类似于指纹)
- 用来互相验证接收方和发送方的身份;
- 在验证身份的基础上再验证一下传递的数据是否被篡改过。因此使用数字签名可以用来达到数据的明文传输。
- 验签
- 支付宝为了验证请求的数据是否商户本人发的
- 商户为了验证响应的数据是否支付宝发的
3.4、支付宝加密验签流程图
3.5、支付宝配置沙箱环境
- 沙箱控制平台(需要先登录)
- 支付宝配置
- 卖家和买家账号配置
- 代码配置(模板)
package com.alipay.config; import java.io.FileWriter; import java.io.IOException; /* * *类名:AlipayConfig *功能:基础配置类 *详细:设置帐户有关信息及返回路径 *修改日期:2017-04-05 *说明: *以下代码只是为了方便商户测试而提供的样例代码,商户可以根据自己网站的需要,按照技术文档编写,并非一定要使用该代码。 *该代码仅供学习和研究支付宝接口使用,只是提供一个参考。 */ public class AlipayConfig { //↓↓↓↓↓↓↓↓↓↓请在这里配置您的基本信息↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ // 应用ID,您的APPID,收款账号既是您的APPID对应支付宝账号 public static String app_id = "自己的沙箱支付应用id"; // 商户私钥,您的PKCS8格式RSA2私钥 public static String merchant_private_key = "自己的私钥"; // 支付宝公钥,查看地址:https://openhome.alipay.com/platform/keyManage.htm 对应APPID下的支付宝公钥。 public static String alipay_public_key = "支付宝公钥"; // 服务器异步通知页面路径 需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问 public static String notify_url = "服务器异步通知"; // 页面跳转同步通知页面路径 需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问 //支付宝成功支付之后跳转的地址 public static String return_url = "支付宝成功支付之后跳转的地址"; // 签名方式 public static String sign_type = "RSA2"; // 字符编码格式 public static String charset = "utf-8"; // 支付宝网关 public static String gatewayUrl = "支付宝网关"; // 支付宝日志 public static String log_path = "C:\\"; //↑↑↑↑↑↑↑↑↑↑请在这里配置您的基本信息↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑ /** * 写日志,方便测试(看网站需求,也可以改成把记录存入数据库) * @param sWord 要写入日志里的文本内容 */ public static void logResult(String sWord) { FileWriter writer = null; try { writer = new FileWriter(log_path + "alipay_log_" + System.currentTimeMillis()+".txt"); writer.write(sWord); } catch (Exception e) { e.printStackTrace(); } finally { if (writer != null) { try { writer.close(); } catch (IOException e) { e.printStackTrace(); } } } } }
- 请求付款页(模板)
//获得初始化的AlipayClient AlipayClient alipayClient = new DefaultAlipayClient(AlipayConfig.gatewayUrl, AlipayConfig.app_id, AlipayConfig.merchant_private_key, "json", AlipayConfig.charset, AlipayConfig.alipay_public_key, AlipayConfig.sign_type); //设置请求参数 AlipayTradePagePayRequest alipayRequest = new AlipayTradePagePayRequest(); alipayRequest.setReturnUrl(AlipayConfig.return_url); alipayRequest.setNotifyUrl(AlipayConfig.notify_url); //商户订单号,商户网站订单系统中唯一订单号,必填 String out_trade_no = new String(request.getParameter("WIDout_trade_no").getBytes("ISO-8859-1"),"UTF-8"); //付款金额,必填 String total_amount = new String(request.getParameter("WIDtotal_amount").getBytes("ISO-8859-1"),"UTF-8"); //订单名称,必填 String subject = new String(request.getParameter("WIDsubject").getBytes("ISO-8859-1"),"UTF-8"); //商品描述,可空 String body = new String(request.getParameter("WIDbody").getBytes("ISO-8859-1"),"UTF-8"); alipayRequest.setBizContent("{\"out_trade_no\":\""+ out_trade_no +"\"," + "\"total_amount\":\""+ total_amount +"\"," + "\"subject\":\""+ subject +"\"," + "\"body\":\""+ body +"\"," + "\"product_code\":\"FAST_INSTANT_TRADE_PAY\"}"); //若想给BizContent增加其他可选请求参数,以增加自定义超时时间参数timeout_express来举例说明 //alipayRequest.setBizContent("{\"out_trade_no\":\""+ out_trade_no +"\"," // + "\"total_amount\":\""+ total_amount +"\"," // + "\"subject\":\""+ subject +"\"," // + "\"body\":\""+ body +"\"," // + "\"timeout_express\":\"10m\"," // + "\"product_code\":\"FAST_INSTANT_TRADE_PAY\"}"); //请求参数可查阅【电脑网站支付的API文档-alipay.trade.page.pay-请求参数】章节 //请求 String result = alipayClient.pageExecute(alipayRequest).getBody(); //输出 out.println(result);
- 支付宝回调同步验签,获取支付宝返回的数据
/* * * 功能:支付宝服务器同步通知页面 * 日期:2017-03-30 * 说明: * 以下代码只是为了方便商户测试而提供的样例代码,商户可以根据自己网站的需要,按照技术文档编写,并非一定要使用该代码。 * 该代码仅供学习和研究支付宝接口使用,只是提供一个参考。 *************************页面功能说明************************* * 该页面仅做页面展示,业务逻辑处理请勿在该页面执行 */ //获取支付宝GET过来反馈信息 Map<String,String> params = new HashMap<String,String>(); Map<String,String[]> requestParams = request.getParameterMap(); for (Iterator<String> iter = requestParams.keySet().iterator(); iter.hasNext();) { String name = (String) iter.next(); String[] values = (String[]) requestParams.get(name); String valueStr = ""; for (int i = 0; i < values.length; i++) { valueStr = (i == values.length - 1) ? valueStr + values[i] : valueStr + values[i] + ","; } //乱码解决,这段代码在出现乱码时使用 valueStr = new String(valueStr.getBytes("ISO-8859-1"), "utf-8"); params.put(name, valueStr); } boolean signVerified = AlipaySignature.rsaCheckV1(params, AlipayConfig.alipay_public_key, AlipayConfig.charset, AlipayConfig.sign_type); //调用SDK验证签名 //——请在这里编写您的程序(以下代码仅作参考)—— if(signVerified) { //商户订单号 String out_trade_no = new String(request.getParameter("out_trade_no").getBytes("ISO-8859-1"),"UTF-8"); //支付宝交易号 String trade_no = new String(request.getParameter("trade_no").getBytes("ISO-8859-1"),"UTF-8"); //付款金额 String total_amount = new String(request.getParameter("total_amount").getBytes("ISO-8859-1"),"UTF-8"); out.println("trade_no:"+trade_no+"<br/>out_trade_no:"+out_trade_no+"<br/>total_amount:"+total_amount); }else { out.println("验签失败"); } //——请在这里编写您的程序(以上代码仅作参考)——
- 手动收单,发送请求给支付宝,手动关闭订单
//获得初始化的AlipayClient AlipayClient alipayClient = new DefaultAlipayClient(AlipayConfig.gatewayUrl, AlipayConfig.app_id, AlipayConfig.merchant_private_key, "json", AlipayConfig.charset, AlipayConfig.alipay_public_key, AlipayConfig.sign_type); //设置请求参数 AlipayTradeCloseRequest alipayRequest = new AlipayTradeCloseRequest(); //商户订单号,商户网站订单系统中唯一订单号 String out_trade_no = new String(request.getParameter("WIDTCout_trade_no").getBytes("ISO-8859-1"),"UTF-8"); //支付宝交易号 String trade_no = new String(request.getParameter("WIDTCtrade_no").getBytes("ISO-8859-1"),"UTF-8"); //请二选一设置 alipayRequest.setBizContent("{\"out_trade_no\":\""+ out_trade_no +"\"," +"\"trade_no\":\""+ trade_no +"\"}"); //请求 String result = alipayClient.execute(alipayRequest).getBody(); //输出 out.println(result);
3.7、整合SpringBoot
- 导入支付宝的SDK依赖
<!-- 支付宝sdk --> <!-- https://mvnrepository.com/artifact/com.alipay.sdk/alipay-sdk-java --> <dependency> <groupId>com.alipay.sdk</groupId> <artifactId>alipay-sdk-java</artifactId> <version>4.9.28.ALL</version> </dependency>
- 配置类(设置了1分组自动关单)
@ConfigurationProperties(prefix = "alipay") @Component @Data public class AlipayTemplate { /** 非对称加密: 加密解密使用不同钥匙 支付宝 -> 商家 支付宝一把公钥(alipay_public_key) 商家一把私钥(merchant_private_key) 支付宝给商家数据时,会带上公钥, 只能由私钥解开 给支付宝的数据 商家 -> 支付宝 支付宝一把私钥(未知的) 商家一把公钥(公钥配置在支付宝账号中),商家给支付宝发送数据时,会携带公钥, 需有支付宝私钥解密才能解开,但支付宝私钥永远不知道,其他人是无法处理数据的 */ // 应用ID,您的APPID,收款账号既是您的APPID对应支付宝账号 public String app_id; // 商户私钥,您的PKCS8格式RSA2私钥 public String merchant_private_key; // 支付宝公钥,查看地址:https://openhome.alipay.com/platform/keyManage.htm 对应APPID下的支付宝公钥。 public String alipay_public_key; // 服务器[异步通知]页面路径 需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问 // 支付宝会悄悄的给我们发送一个请求,告诉我们支付成功的信息 public String notify_url; // 页面跳转同步通知页面路径 需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问 //同步通知,支付成功,一般跳转到成功页 public String return_url; // 签名方式 private String sign_type; // 字符编码格式 private String charset; //订单超时时间 private String timeout = "1m"; // 支付宝网关; https://openapi.alipaydev.com/gateway.do public String gatewayUrl; public String pay(PayVo vo) throws AlipayApiException { //AlipayClient alipayClient = new DefaultAlipayClient(AlipayTemplate.gatewayUrl, AlipayTemplate.app_id, AlipayTemplate.merchant_private_key, "json", AlipayTemplate.charset, AlipayTemplate.alipay_public_key, AlipayTemplate.sign_type); //1、根据支付宝的配置生成一个支付客户端 AlipayClient alipayClient = new DefaultAlipayClient(gatewayUrl, app_id, merchant_private_key, "json", charset, alipay_public_key, sign_type); //2、创建一个支付请求 //设置请求参数 AlipayTradePagePayRequest alipayRequest = new AlipayTradePagePayRequest(); alipayRequest.setReturnUrl(return_url); alipayRequest.setNotifyUrl(notify_url); //商户订单号,商户网站订单系统中唯一订单号,必填 String out_trade_no = vo.getOut_trade_no(); //付款金额,必填 String total_amount = vo.getTotal_amount(); //订单名称,必填 String subject = vo.getSubject(); //商品描述,可空 String body = vo.getBody(); alipayRequest.setBizContent("{\"out_trade_no\":\""+ out_trade_no +"\"," + "\"total_amount\":\""+ total_amount +"\"," + "\"subject\":\""+ subject +"\"," + "\"body\":\""+ body +"\"," + "\"timeout_express\":\""+timeout+"\"," //相对超时时间 距离当前时间的timeout分钟后,订单失效,就无法支付 属于关单状态 更多的参数可以参考支付宝的文档 https://opendocs.alipay.com/open/028r8t?pathHash=8e24911d&ref=api&scene=22 + "\"product_code\":\"FAST_INSTANT_TRADE_PAY\"}"); String result = alipayClient.pageExecute(alipayRequest).getBody(); //会收到支付宝的响应,响应的是一个页面,只要浏览器显示这个页面,就会自动来到支付宝的收银台页面 System.out.println("支付宝的响应:"+result); return result; } } @Data public class PayVo { private String out_trade_no; // 商户订单号 必填 private String subject; // 订单名称 必填 private String total_amount; // 付款金额 必填 private String body; // 商品描述 可空 }
- 配置文件配置
#支付宝相关的配置 alipay: app_id: 应用id merchant_private_key: 商户私钥 alipay_public_key:支付宝公钥 #通知地址 必须外网可以正常访问 notify_url: https://4448227kv1.imdo.co/payed/notify #支付成功后返回地址 return_url: http://member.gulimall.com/memberOrder.html sign_type: RSA2 charset: utf-8 gatewayUrl: https://openapi.alipaydev.com/gateway.do
- 支付请求
@Autowired private AlipayTemplate alipayTemplate; @Autowired private OrderService orderService; /** * 用户下单:支付宝支付 * 1、让支付页让浏览器展示 * 2、支付成功以后,跳转到用户的订单列表页 * @param orderSn * @return * @throws AlipayApiException * produces = MediaType.TEXT_HTML_VALUE 告诉返回的是HTML页面 */ @ResponseBody @GetMapping(value = "/aliPayOrder", produces = MediaType.TEXT_HTML_VALUE) public String aliPayOrder(@RequestParam("orderSn") String orderSn) throws AlipayApiException { //构造payVo 直接模板调用支付请求 渲染页面(就是支付页) PayVo payVo = orderService.getOrderPay(orderSn); String pay = alipayTemplate.pay(payVo); System.out.println(pay); return pay; }
- 异步回调通知(支付宝最大努力通知方案,需要要外网能访问我们本机,配置内网穿透)
/** * @Description: 订单支付成功监听器 **/ @RestController public class OrderPayedListener { @Autowired private OrderService orderService; @Autowired private AlipayTemplate alipayTemplate; //支付宝回调 接口 使用了内网穿透(配置了查看图片:支付宝回调地址调用请求,转发至Nginx,Nginx设置请求头后转发到网关,网关根据host信息设置跳转对应的服务.png):https://4448227kv1.imdo.co/payed/notify @PostMapping(value = "/payed/notify") public String handleAlipayed(PayAsyncVo asyncVo,HttpServletRequest request) throws AlipayApiException, UnsupportedEncodingException { // 只要收到支付宝的异步通知,返回 success 支付宝便不再通知 // 获取支付宝POST过来反馈信息 System.out.println("收到支付宝的异步通知 " + asyncVo); /*Map<String, String[]> parameterMap = request.getParameterMap(); parameterMap.keySet().forEach(item ->{ System.out.println(item + " : " + request.getParameter(item)); });*/ //TODO 需要验签 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] + ","; } //乱码解决,这段代码在出现乱码时使用 // valueStr = new String(valueStr.getBytes("ISO-8859-1"), "utf-8"); params.put(name, valueStr); } boolean signVerified = AlipaySignature.rsaCheckV1(params, alipayTemplate.getAlipay_public_key(), alipayTemplate.getCharset(), alipayTemplate.getSign_type()); //调用SDK验证签名 //TODO 需要验签 验证是不是支付宝发的请求 防止恶意修改其他订单数据 if (signVerified) { System.out.println("签名验证成功..."); //去修改订单状态 String result = orderService.handlePayResult(asyncVo); return result; }else { System.out.println("签名验证失败..."); return "error"; } } } @ToString @Data public class PayAsyncVo { private String gmt_create; private String charset; private String gmt_payment; @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date notify_time; private String subject; private String sign; private String buyer_id;//支付者的id private String body;//订单的信息 private String invoice_amount;//支付金额 private String version; private String notify_id;//通知id private String fund_bill_list; private String notify_type;//通知类型; trade_status_sync private String out_trade_no;//订单号 private String total_amount;//支付的总额 private String trade_status;//交易状态 TRADE_SUCCESS private String trade_no;//流水号 private String auth_app_id;// private String receipt_amount;//商家收到的款 private String point_amount;// private String app_id;//应用id private String buyer_pay_amount;//最终支付的金额 private String sign_type;//签名类型 private String seller_id;//商家的id }
六、测试
1、压力测试
1.1、概念
-
压力测试考察当前软硬件环境下系统所能承受的最大负荷并帮助找出系统瓶颈所在。压测都是为了系统在线上的处理能力和稳定性维持在一个标准范围内,做到心中有数。
-
使用压力测试,我们有希望找到很多种用其他测试方法更难发现的错误。有两种错误类型是 :内存泄漏,并发与同步。 有效的压力测试系统将应用以下这些关键条件: 重复 , 并发 , 量级 , 随机变化 。
1.2、性能指标
- 响应时间(Response Time: RT):响应时间指用户从客户端发起一个请求开始,到客户端接收到从服务器端返回的响应结束,整个过程所耗费的时间。
-
HPS(Hits Per Second) :每秒点击次数,单位是次/秒。
-
TPS(Transaction per Second):系统每秒处理交易数(整个业务执行完成),单位是笔/秒。
-
QPS(Query per Second):系统每秒处理查询次数,单位是次/秒。对于互联网业务中,如果某些业务有且仅有一个请求连接,那么 TPS=QPS=HPS,一 般情况下用 TPS 来衡量整个业务流程,用 QPS 来衡量接口查询次数,用 HPS 来表示对服务器单击请求。
-
无论 TPS 、 QPS 、 HPS,此指标是衡量系统处理能力非常重要的指标,越大越好,根据经验,一般情况下:
-
金融行业: 1000TPS~50000TPS ,不包括互联网化的活动
-
保险行业: 100TPS~100000TPS ,不包括互联网化的活动
-
制造行业:10TPS~5000TPS
-
互联网电子商务:10000TPS~1000000TPS
-
互联网中型网站:1000TPS~50000TPS
-
互联网小型网站:500TPS~10000TPS
-
-
最大响应时间( Max Response Time ) 指用户发出请求或者指令到系统做出反应(响应)的最大时间。
-
最少响应时间( Mininum ResponseTime ) 指用户发出请求或者指令到系统做出反应(响应)的最少时间。
-
90%响应时间(90% Response Time ) 是指所有用户的响应时间进行排序,第 90% 的响 应时间
-
从外部看,性能测试主要关注如下三个指标:
- 吞吐量(QPS、TPS):每秒钟系统能够处理的请求数、任务数。
- 响应时间:服务处理一个请求或一个任务的耗时。
- 错误率:一批请求中结果出错的请求所占比例。
1.3、JMeter 安装和使用
- JMeter安装包下载地址:下载后解压安装包,解压后进入bin 目录,运行 jmeter.bat 即可(前提安装Java8环境)
- JMeter 压测示例
- 切换语言
- 添加线程组:用于建立压力测试任务,以及设置压力测试线程数等信息。
-
线程组参数详解:
-
线程数:虚拟用户数。一个虚拟用户占用一个进程或线程。设置多少虚拟用户数在这里也就是设置多少个线程数。
-
Ramp-Up Period(in seconds) 准备时长:设置的虚拟用户数需要多长时间全部启动。如果 线程数为 10 ,准备时长为 2 ,那么需要 2 秒钟启动 10 个线程,也就是每秒钟启动 5 个线程。
-
循环次数:每个线程发送请求的次数。如果线程数为 10 ,循环次数为 100,那么每个线程发送 100 次请求。总请求数为 10*100=1000 。如果勾选了 “ 永远”,那么所有线程会一直发送请求,一到选择停止运行脚本。
-
Delay Thread creation until needed:直到需要时延迟线程的创建。
-
调度器:设置线程组启动的开始时间和结束时间 (配置调度器时,需要勾选循环次数为永远)。
-
持续时间(秒):测试持续时间,会覆盖结束时间。
-
启动延迟(秒):测试延迟启动时间,会覆盖启动时间。
-
启动时间:测试启动时间,启动延迟会覆盖它。当启动时间已过,手动只需测试时当前 时间也会覆盖它。
-
结束时间:测试结束时间,持续时间会覆盖它。
-
-
- 添加 HTTP 请求和监听器(结果树、汇总报告、聚合报告),还可以添加其他很多的测试功能点。
- 启动压测&查看分析结果
- 案例:测试百度
结果树:
汇总报告:
聚合报告:
汇总图:
- 案例:测试百度
- 切换语言