Java微服务
Java微服务笔记(基础篇完结,高级篇持续更新中)
🌟该文档用于学习Java微服务全套技术栈,参考2021黑马程序员微服务技术栈课程
古人云:面试造火箭,工作拧螺丝
呦西,就这样顺理成章随随便便把java微服务给学完吧~
微服务导学
什么是微服务
微服务 != SpringCloud
微服务本质是拆分,根据业务功能将单体项目拆分成许多个独立的项目独立部署开发,称作一个
服务
微服务技术栈= 微服务架构 + 持续集成
微服务架构
包括拆分好的
服务集群,用于统一注册服务和统一配置的注册中心和配置中心,用于处理用户请求分发到不同服务与负载均衡的服务网关,用于异步通信提高服务之间通信效率的消息队列,用于提高查询效率减轻数据库负担的分布式缓存和分布式搜索
持续集成
用于自动化快速部署微服务
学习计划

其实也没多少东西,怀着轻松愉悦的心情一口气全都学完吧(笑)

Day01 2024.01.22 微服务入门与注册中心
认识微服务
架构演变
单体架构
将业务所有功能集中在一个项目中开发,打包成一个包直接部署
优点:架构简单,部署成本低
缺点:耦合度高
分布式架构
根据业务功能对系统进行拆分,每个业务模块作为独立项目开发
优点:降低服务耦合,有利于服务升级拓展
需要考虑的问题:
- 服务拆分粒度
- 服务集群地址如何维护
- 服务之间如何实现远程调用
- 服务健康状态如何感知
微服务
是一种经过良好架构设计的分布式架构方案
- 单一职责:微服务拆分粒度更小,每个服务都对应唯一的业务能力,做到单一职责,避免重复业务开发
- 面向服务:微服务对外暴露业务接口
- 自治:团队独立,技术独立,数据独立,部署独立
- 隔离型强:服务调用做好隔离,容错,降级,避免出现级联问题
结论
微服务技术方案
微服务方案需要技术框架来落地,国内最知名的是
SpringCloud和阿里巴巴的Dubbo微服务技术对比
Dubbo SpringCloud SrpingCloudAlibaba 注册中心 zookeeper/Redis Eureka/Consul Nacos/Eureka 服务远程调用 Dubbo协议 Feign(http协议) Dubbo/Feign 配置中心 无 SpringCloudConfig SpringCloudConfig/Nacos 服务网关 无 SpringCloudGateway/Zuul SpringCloudGateway/Zuul 服务监控和保护 dubbo-admin,功能弱 Hystrix Sentinel
SpringCloud
SpringBoot实现组件的自动装配,因此成为最受欢迎的微服务框架
服务拆分及远程调用
服务拆分注意事项
- 不同微服务,不要重复开发相同业务
- 微服务数据独立,不要访问其他微服务的数据库
- 微服务可以将自己的业务暴露为接口,供其他微服务使用

项目案例
导入demo项目初始工程
项目工程和建表sql在教程资料中获取(注意工程代码在资料文件中的才是初始工程,外面的是已经完成的代码)
分别创建cloud_user和cloud_order两个数据库,里面分别存放使用sql脚本创建好的tb_user和tb_order

记得在application.yml中修改数据库账号密码
调取接口测试成功

服务远程调用

因为微服务不允许开发重复业务,订单模块需要远程调用获取用户信息
-
注册RestTemplate(在order启动类里面)
//OrderApplication /** * 注入RestTemplate * @return */ @Bean public RestTemplate restTemplate() { return new RestTemplate(); } -
在orderController中利用RestTemplate发起http请求查询并封装
@Autowired private RestTemplate restTemplate; @GetMapping("{orderId}") public Order queryOrderByUserId(@PathVariable("orderId") Long orderId) { // 根据id查询订单 Order order = orderService.queryOrderById(orderId); // 利用RestTemplate发起http请求,查询用户 order.setUser(restTemplate.getForObject("http://localhost:8081/user/" + order.getUserId(), User.class)); return order; }
服务远程调用相关概念
提供者:提供接口供其他微服务使用
消费者:调用其他微服务接口的服务抛开业务不谈,一个服务既可以是消费者,也可以是提供者,只有相对某个服务而言才有消费提供的概念
eureka注册中心
Eureka原理
- 服务消费者如何获取服务提供的地址信息
- 服务提供者启动时向eureka注册自己的信息
- eureka保存这些信息
- 消费者根据服务名称向eureka拉取提供者信息
- 如果有多个服务提供者,消费者该如何选择
- 负载均衡算法
- 消费者如何感知服务提供者的健康状态
- 服务提供者每隔30秒向EurekaServer发送心跳请求,报告健康状态
- erreka会更新记录服务列表信息,心跳不正常会被剔除
- 消费者可以拉取到最新的信息

搭建EurekaServer
-
新建模块并引入依赖(虽然别人说选择Spring Initializr更方便一些,不过这里就和老师一样来了)

pom文件导入依赖坐标(父工程starter已经指定了版本信息,不必再次指定)
<dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-autoconfigure</artifactId> </dependency> </dependencies>这里笔者发现maven加载依赖十分迟缓,连vpn都没有用,因此更换了maven镜像,选择国内的阿里云镜像站,效果十分显著,更改教程
-
编写启动类并添加@EnableEurekaServer注解
@EnableEurekaServer @SpringBootApplication public class EurekaApplication{ public static void main(String[] args) { SpringApplication.run(EurekaApplication.class, args); } } -
编写配置文件并在其中编写如下配置
server: port: 10086 spring: application: name: eureka-server eureka: client: service-url: defaultZone: http://localhost:10086/eureka/之所以要配置url是因为eureka自己本身也是微服务,需要完成自身注册,而且之后可能会部署多个eureka互相通信
-
功能测试
可以通过底部工具栏里的服务选项卡查看spring项目相关的所有启动类(如果没有,点击左上角+号添加SpringBoot类型服务,然后把相关启动类都开启一遍,就会在这里显示了,可以多选创建一个分组)
利用快捷键
ctrl+shift+F10可以实现一键启动所有微服务
😔 如果服务一直转圈,并且右边并没有显示可供跳转的端口,我目前也没有什么好的解决办法,不过不影响功能
跳转到eureka服务端口页面,了可以看到eureka-server自身已经注册了

服务注册
注册user-service
-
在user-service引入eureka-client依赖
<!-- eureka-client--> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId> </dependency> -
在配置文件编写如下配置
spring: application: name: user-service # 指定注册的服务名字 eureka: client: service-url: defaultZone: http://localhost:10086/eureka/ #eureka地址信息
order-service同理,这里就不赘述了
可以看到成功注册

如果想要启动同一个服务的多个实例,可以复制服务配置(运行在不同的端口)

调出VM选项来修改端口避免冲突

启动后可以发现注册了两个实例

服务发现
**在order-service完成服务拉取 **
-
修改原来controller的代码,将ip地址用服务名称代替
@GetMapping("{orderId}") public Order queryOrderByUserId(@PathVariable("orderId") Long orderId) { // 根据id查询订单 Order order = orderService.queryOrderById(orderId); // 利用RestTemplate发起http请求,查询用户 order.setUser(restTemplate.getForObject("http://user-service/user/" + order.getUserId(), User.class)); return order; } -
在RestTemplate上面添加
@LoadBalanced注解进行负载均衡设置/** * 注入RestTemplate * @return */ @Bean @LoadBalanced public RestTemplate restTemplate() { return new RestTemplate(); } -
功能测试
要保证服务选项中数据库保持开启状态才能正常查询(跳转到对应mapper上对标红的字段alt+enter选择合适的数据库就能够加载出来了)

笔者在测试的时候发现数据库一直报连接超时,之后一通操作后突然发现mysql登不进去了,于是免密码登入再修改密码等等一系列操作,参考这个教程mysql8密码重置
笔者用的是mysql8.0.34,不支持通过修改my.ini来免密登入,只能通过cmd通过命令设置选项开启服务
😔笔者查了好长时间资料也没有想明白为什么调用接口的时候会连接超时,所以为了不浪费过多时间,这个问题先暂时搁置在一边
总结

Ribbon负载均衡原理
eureka服务发现时候的负载均衡由ribbon来进行实现
负载均衡原理

源码细节
启动的时候会通过spring的负载均衡拦截器

实现的ClientHttpRequestInterceptor接口的intercept方法会拦截http请求

其中loadBalancer即RibbonLoadBalancer的一个实例,执行execute方法,根据服务名称向eureka拉取服务列表(即图中allServerList)
getServer方法通过负载均衡策略获取其中的一个服务

getServer会跳转到chooseServer方法,其中的rule.choose(key)便指定了选择的负载均衡策略

rule实现的接口IRule就包含了七种负载均衡策略,包含轮询,随机等等
默认规则是ZoneAvoidanceRule

总结

负载均衡策略


调整负载均衡策略
方案1(代码方式,针对一个service中的所有服务调用)
在启动类定义一个实现Irule接口的新的策略Bean即可
//OrderApplication
/**
* 注入随机负载均衡策略
* @return
*/
@Bean
public IRule randomRule() {
return new RandomRule();
}
方案2(配置文件方式,针对某个微服务单独配置)
在配置文件指定仅在调用user-service微服务的时候使用随机策略
user-service:
ribbon:
NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule
加载策略
懒加载:只有在第一次使用的时候才会加载资源
饥饿加载:一开始启动项目的时候就会加载资源ribbon默认使用懒加载
类似java基础里面讲到的饿汉式,懒汉式加载
切换加载策略
在消费者配置文件中进行如下配置,切换成饥饿加载
ribbon:
eager-load:
enabled: true #饥饿加载
clients: #指定调用什么服务的时候采用该种加载策略
- user-service
其中clients指定应用这一策略的提供者集合(- item是yaml文件中集合的写法)
nacos注册中心
Nacos是阿里巴巴的一个产品,现在已加入SpirngCloud全家桶,功能更加丰富,除了服务注册和发现,还有分布式配置,中国国内比较受欢迎
Nacos入门
认识和安装Nacos
解压老师资料提供的1.4.1版本nacos,进入bin目录打开cmd窗口,输入
startup.cmd -m standalone
启动,默认在8848端口开启(如果直接双击cmd启动可能会报错)

跳转显示的console地址,进入登录界面(账号密码默认都是nacos)

快速入门
-
导入nacos依赖,并注释掉原来的eureka依赖
cloud-demo模块管理依赖配置(不要放错位置了)
<!-- pom.xml(cloud-demo) <!-- nacos管理依赖--> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-alibaba-dependencies</artifactId> <version>2.2.5.RELEASE</version> <type>pom</type> <scope>import</scope> </dependency>在user-service和order-service引入nacos客户端依赖,并将eureka依赖注释掉
<!-- eureka-client--> <!-- <dependency>--> <!-- <groupId>org.springframework.cloud</groupId>--> <!-- <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>--> <!-- </dependency>--> <!-- nacos-client--> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency> -
修改相关配置(注释掉eureka配置)
#eureka: # client: # service-url: # defaultZone: http://localhost:10086/eureka spring: cloud: nacos: server-addr: localhost:8848 #nacos服务地址user-service和order-service操作相同
-
功能测试
启动order-service和user-service之后,在nacos发现成功注册

输入localhost:9090/order/101地址测试成功返回son数据

果然是eureka本身有问题啊,这次就成功远程调用了,枉费我连续两天调试源码找bug!
Nacos服务分级存储模型
一个服务分成多个服务集群,提高容灾性

服务集群调用问题
服务调用尽可能选择本地集群的服务,跨集群调用延迟较高,只有当本地集群不可用的时候才会跨集群调用
配置服务集群
-
修改application.yml.添加如下内容即可(user-service和order-service均配置一个集群名称)
spring: cloud: nacos: discovery: cluster-name: HZ # 指定集群在杭州 -
修改负载均衡设置,保证优先选择同一个地域(集群)的服务
# user-service配置文件 user-service: ribbon: NFLoadBalancerRuleClassName: com.alibaba.cloud.nacos.ribbon.NacosRule -
功能测试
复制一共三个user-service,前两个设置集群名为HZ(杭州),然后再修改user-service的集群名称为SH(上海),启动第三个user-service
记得先启动一下nacos
其实idea可以配置nacos启动服务,不必通过资源管理器找到文件打开,此处教程按照笔者这样添加运行配置即可

idea终端一定要设置成cmd而不是powershell,切换终端路径需要如下配置

运行之后服务成功注册,并且user-service分成了两个不同集群


调用
http://localhost:8080/order/101请求接口多次之后,可以看到确实第三个user-service没有处理请求
如果把前两个user-service停掉,那么就跨集群访问第三个集群,并产生警告信息
服务实例权重配置
现实生活中,服务器设备性能往往有差异,一部分服务器性能比较好,应该承担更多用户请求
通过修改权重来控制访问频率
通过nacos界面进行修改即可

如果将权重调成0,就不会有服务来访问,这个操作往往用来对服务器进行维护,同时不影响用户的使用
如果想对维护后的服务器进行测试,可以调很小的一个权重,让少量用户访问,平稳升级过渡,被称作灰度发布
Nacos环境隔离
nacos采用namespace基于同一个实例不同环境进行数据隔离

在nacos操作页面即可新建命名空间,这里新建dev开发环境

命名空间会生成对应id(如果没有指定的话)

将id配置在代码中
# order service配置文件
spring:
cloud:
nacos:
discovery:
namespace: e0445b23-956f-4b98-90a8-bdf65afb07ce
如果配置好之后再启动,order-service将无法调用user-service,因为两个服务不在一个命名空间里面
nacos和eureka对比

nacos和eureka都会使用如上流程进行注册,且将服务列表进行缓存以供消费者查询
差别在于健康监测上面,nacos会区分临时实例和非临时实例,对于临时实例,nacos采用心跳检测,如果心跳异常会直接从服务列表中移除,这一点和eureka是一样的
但是对于非临时实例nacos会主动询问(比心跳检测更加敏感,但性能消耗更多),如果不健康,会将其标记,不会从列表移除,只会等待其恢复正常
在心跳检测机制上,nacos相对于eureka,除了被动接受心跳信号以外,如果发现有服务挂掉的话会主动向消费者进行消息推送,让更新更加及时
配置非临时实例
# order service配置文件
spring:
cloud:
nacos:
discovery:
ephemeral: false
总结

关于集群采用AP或CP方式会在后续章节学习
Day02 2024.01.28 配置管理,远程调用和服务网关
Nacos配置管理
统一配置管理
需求
- 配置更改实现热更新,避免不必要的重启

新建配置
在public命名环境下面配置统一配置
在nacos的配置管理选项卡中选择添加配置进入页面
data id即配置名称,一般用服务名+命名空间的方式命名(用yaml格式的话推荐拓展名写全(.yaml))
配置内容应填写那些需要经常改变的开关类配置,例如日期格式切换,启用某个服务的开关等等

配置内容
pattern:
dateformat: yyyy-MM-dd HH:mm:ss
配置拉取
如果nacos统一配置需要较读取本地配置文件提前读取,不能还像原来一样将从配置文件获取nacos地址,因此可以选择在bootstrap这个启动优先级更高的地方进行配置
-
在user-service引入nacos配置管理依赖
<!-- nacos配置管理--> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> </dependency> -
在user-service中的resource目录添加一个
bootstrap.yml文件,该文件是引导文件,优先级高于application.ymlspring: application: name: user-service # 服务名称 profiles: active: dev # 配置作用环境,这里是dev cloud: nacos: server-addr: localhost:8848 # nacos服务地址 config: file-extension: yaml # 指定yaml格式将application.yml中重复的配置去掉
# server-addr: localhost:8848 #nacos服务地址 # application: # name: user-service -
在user-service获取添加的配置并编写功能接口测试
//user-service Controller @Value("${pattern.dateformat}") private String dateformat; /** * 根据指定的日期格式返回当前日期 * @return */ @GetMapping("/now") public String now() { return LocalDateTime.now().format(DateTimeFormatter.ofPattern(dateformat)); }功能测试
啊?为什么之前一直没解决的问题突然电脑自己就给解决了,离谱...(之前服务启动之后一直没能成功暴露端口,并且一直图标一直转圈)

调用对应接口后成功返回指定格式的当前日期

总结

配置热更新
实现配置热更新
要想实现配置的热更新,还需要下面两种方式实现
在@value注入变量的类上面添加
@RefreshScope注解@RefreshScope public class UserController{ ... }确实实现了热更新呢
使用
@ConfigurationProperties注解创建一个配置类用于获取配置
//config.PatternProperties @Data @Component @ConfigurationProperties(prefix = "pattern") public class PatternProperties { private String dateformat; }注入配置类来获取对应配置,不必添加上述@RefreshScope注解
@Autowired private PatternProperties patternProperties; /** * 根据指定的日期格式返回当前日期 * @return */ @GetMapping("/now") public String now() { return LocalDateTime.now().format(DateTimeFormatter.ofPattern(patternProperties.getDateformat())); }因为第二种方法将配置进行了封装,而且不必添加@RefreshScope注解,更加优雅方便,因此推荐使用第二种方法
总结

配置共享
微服务启动的时候会从nacos读取多个配置文件
- 特定环境的yaml配置,如user-service.yaml
- 没有环境限制的配置,如user-servie.yaml
因此只需要在nacos创建没有环境限制的配置即可
环境共享配置
命名上去掉命名空间,只保留服务名即为多环境共享配置

功能测试
修改原来的配置类读取新增属性
@Data
@Component
@ConfigurationProperties(prefix = "pattern")
public class PatternProperties {
private String dateformat;
private String envSharedValue;
}
添加新的功能接口用来读取获得的属性
/**
* 测试获取到的配置信息
* @return
*/
@GetMapping("/prop")
public PatternProperties prop() {
return patternProperties;
}
右键服务编辑运行配置,在有效配置文件(active profile)那里填写命名空间(这里是将8081端口user服务环境设置成'test')即可更改运行的环境而不用修改本身代码

可以看到test环境的user-service(端口8081)只能读取到envSharedValue

而其他两个dev环境的可以将本环境的和环境共享配置全部读取

配置优先级
nacos当前指定环境配置 > 多环境共享配置 > 本地配置

搭建Nacos集群

配置多个nacos服务器,在同一个mysql服务集群读取数据实现数据共享
搭建集群操作
-
搭建数据库,初始化数据库表结构
创建名为
nacos的数据库,然后在其中导入下面的建表sql语句(可以在资料对应文档中找到)CREATE TABLE `config_info` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id', `data_id` varchar(255) NOT NULL COMMENT 'data_id', `group_id` varchar(255) DEFAULT NULL, `content` longtext NOT NULL COMMENT 'content', `md5` varchar(32) DEFAULT NULL COMMENT 'md5', `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间', `src_user` text COMMENT 'source user', `src_ip` varchar(50) DEFAULT NULL COMMENT 'source ip', `app_name` varchar(128) DEFAULT NULL, `tenant_id` varchar(128) DEFAULT '' COMMENT '租户字段', `c_desc` varchar(256) DEFAULT NULL, `c_use` varchar(64) DEFAULT NULL, `effect` varchar(64) DEFAULT NULL, `type` varchar(64) DEFAULT NULL, `c_schema` text, PRIMARY KEY (`id`), UNIQUE KEY `uk_configinfo_datagrouptenant` (`data_id`,`group_id`,`tenant_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='config_info'; /******************************************/ /* 数据库全名 = nacos_config */ /* 表名称 = config_info_aggr */ /******************************************/ CREATE TABLE `config_info_aggr` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id', `data_id` varchar(255) NOT NULL COMMENT 'data_id', `group_id` varchar(255) NOT NULL COMMENT 'group_id', `datum_id` varchar(255) NOT NULL COMMENT 'datum_id', `content` longtext NOT NULL COMMENT '内容', `gmt_modified` datetime NOT NULL COMMENT '修改时间', `app_name` varchar(128) DEFAULT NULL, `tenant_id` varchar(128) DEFAULT '' COMMENT '租户字段', PRIMARY KEY (`id`), UNIQUE KEY `uk_configinfoaggr_datagrouptenantdatum` (`data_id`,`group_id`,`tenant_id`,`datum_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='增加租户字段'; /******************************************/ /* 数据库全名 = nacos_config */ /* 表名称 = config_info_beta */ /******************************************/ CREATE TABLE `config_info_beta` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id', `data_id` varchar(255) NOT NULL COMMENT 'data_id', `group_id` varchar(128) NOT NULL COMMENT 'group_id', `app_name` varchar(128) DEFAULT NULL COMMENT 'app_name', `content` longtext NOT NULL COMMENT 'content', `beta_ips` varchar(1024) DEFAULT NULL COMMENT 'betaIps', `md5` varchar(32) DEFAULT NULL COMMENT 'md5', `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间', `src_user` text COMMENT 'source user', `src_ip` varchar(50) DEFAULT NULL COMMENT 'source ip', `tenant_id` varchar(128) DEFAULT '' COMMENT '租户字段', PRIMARY KEY (`id`), UNIQUE KEY `uk_configinfobeta_datagrouptenant` (`data_id`,`group_id`,`tenant_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='config_info_beta'; /******************************************/ /* 数据库全名 = nacos_config */ /* 表名称 = config_info_tag */ /******************************************/ CREATE TABLE `config_info_tag` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id', `data_id` varchar(255) NOT NULL COMMENT 'data_id', `group_id` varchar(128) NOT NULL COMMENT 'group_id', `tenant_id` varchar(128) DEFAULT '' COMMENT 'tenant_id', `tag_id` varchar(128) NOT NULL COMMENT 'tag_id', `app_name` varchar(128) DEFAULT NULL COMMENT 'app_name', `content` longtext NOT NULL COMMENT 'content', `md5` varchar(32) DEFAULT NULL COMMENT 'md5', `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间', `src_user` text COMMENT 'source user', `src_ip` varchar(50) DEFAULT NULL COMMENT 'source ip', PRIMARY KEY (`id`), UNIQUE KEY `uk_configinfotag_datagrouptenanttag` (`data_id`,`group_id`,`tenant_id`,`tag_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='config_info_tag'; /******************************************/ /* 数据库全名 = nacos_config */ /* 表名称 = config_tags_relation */ /******************************************/ CREATE TABLE `config_tags_relation` ( `id` bigint(20) NOT NULL COMMENT 'id', `tag_name` varchar(128) NOT NULL COMMENT 'tag_name', `tag_type` varchar(64) DEFAULT NULL COMMENT 'tag_type', `data_id` varchar(255) NOT NULL COMMENT 'data_id', `group_id` varchar(128) NOT NULL COMMENT 'group_id', `tenant_id` varchar(128) DEFAULT '' COMMENT 'tenant_id', `nid` bigint(20) NOT NULL AUTO_INCREMENT, PRIMARY KEY (`nid`), UNIQUE KEY `uk_configtagrelation_configidtag` (`id`,`tag_name`,`tag_type`), KEY `idx_tenant_id` (`tenant_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='config_tag_relation'; /******************************************/ /* 数据库全名 = nacos_config */ /* 表名称 = group_capacity */ /******************************************/ CREATE TABLE `group_capacity` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', `group_id` varchar(128) NOT NULL DEFAULT '' COMMENT 'Group ID,空字符表示整个集群', `quota` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '配额,0表示使用默认值', `usage` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '使用量', `max_size` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单个配置大小上限,单位为字节,0表示使用默认值', `max_aggr_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '聚合子配置最大个数,,0表示使用默认值', `max_aggr_size` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单个聚合数据的子配置大小上限,单位为字节,0表示使用默认值', `max_history_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '最大变更历史数量', `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_group_id` (`group_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='集群、各Group容量信息表'; /******************************************/ /* 数据库全名 = nacos_config */ /* 表名称 = his_config_info */ /******************************************/ CREATE TABLE `his_config_info` ( `id` bigint(64) unsigned NOT NULL, `nid` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `data_id` varchar(255) NOT NULL, `group_id` varchar(128) NOT NULL, `app_name` varchar(128) DEFAULT NULL COMMENT 'app_name', `content` longtext NOT NULL, `md5` varchar(32) DEFAULT NULL, `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `src_user` text, `src_ip` varchar(50) DEFAULT NULL, `op_type` char(10) DEFAULT NULL, `tenant_id` varchar(128) DEFAULT '' COMMENT '租户字段', PRIMARY KEY (`nid`), KEY `idx_gmt_create` (`gmt_create`), KEY `idx_gmt_modified` (`gmt_modified`), KEY `idx_did` (`data_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='多租户改造'; /******************************************/ /* 数据库全名 = nacos_config */ /* 表名称 = tenant_capacity */ /******************************************/ CREATE TABLE `tenant_capacity` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', `tenant_id` varchar(128) NOT NULL DEFAULT '' COMMENT 'Tenant ID', `quota` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '配额,0表示使用默认值', `usage` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '使用量', `max_size` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单个配置大小上限,单位为字节,0表示使用默认值', `max_aggr_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '聚合子配置最大个数', `max_aggr_size` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单个聚合数据的子配置大小上限,单位为字节,0表示使用默认值', `max_history_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '最大变更历史数量', `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_tenant_id` (`tenant_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='租户容量信息表'; CREATE TABLE `tenant_info` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id', `kp` varchar(128) NOT NULL COMMENT 'kp', `tenant_id` varchar(128) default '' COMMENT 'tenant_id', `tenant_name` varchar(128) default '' COMMENT 'tenant_name', `tenant_desc` varchar(256) DEFAULT NULL COMMENT 'tenant_desc', `create_source` varchar(32) DEFAULT NULL COMMENT 'create_source', `gmt_create` bigint(20) NOT NULL COMMENT '创建时间', `gmt_modified` bigint(20) NOT NULL COMMENT '修改时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_tenant_info_kptenantid` (`kp`,`tenant_id`), KEY `idx_tenant_id` (`tenant_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='tenant_info'; CREATE TABLE `users` ( `username` varchar(50) NOT NULL PRIMARY KEY, `password` varchar(500) NOT NULL, `enabled` boolean NOT NULL ); CREATE TABLE `roles` ( `username` varchar(50) NOT NULL, `role` varchar(50) NOT NULL, UNIQUE INDEX `idx_user_role` (`username` ASC, `role` ASC) USING BTREE ); CREATE TABLE `permissions` ( `role` varchar(50) NOT NULL, `resource` varchar(255) NOT NULL, `action` varchar(8) NOT NULL, UNIQUE INDEX `uk_role_permission` (`role`,`resource`,`action`) USING BTREE ); INSERT INTO users (username, password, enabled) VALUES ('nacos', '$2a$10$EuWPZHzz32dJN7jexM34MOeYirDdFAZm2kuWj7VEOJhhZkDrxfvUu', TRUE); INSERT INTO roles (username, role) VALUES ('nacos', 'ROLE_ADMIN');
配置nacos
由于配置nacos集群,因此不能像往常一样直接在代码中配置,可以在nacos修改配置文件,然后复制多份模拟多个nacos
在nacos/conf中将clustter.conf.example文件更名为cluster.conf,将里面的ip地址用以下来替换
127.0.0.1:8845
127.0.0.1.8846
127.0.0.1.8847

然后进入application.properties将对应内容按照如下修改(数据库用户名和密码按照自己实际来修改)
#*************** Config Module Related Configurations ***************#
### If use MySQL as datasource:
spring.datasource.platform=mysql
### Count of DB:
db.num=1
### Connect URL of DB:
db.url.0=jdbc:mysql://127.0.0.1:3306/nacos?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&serverTimezone=UTC
db.user.0=root
db.password.0=123456
将nacos复制三份,分别命名成1,2,3

进入三个nacos的application.properties将启动端口分别改为8845,8846,8847
然后直接分别启动上述三个nacos(进入bin目录双击startup.cmd,不必添加-m参数了,因为默认是按照集群启动)
可以配置服务方便以后一键开启

内存已经占用88%了...,不知道自己的电脑配置能不能撑到微服务结束学习...(慌)
配置nginx
如果之前没有nginx的话可以找到资料中的安装包进行安装
修改conf/nginx.conf文件,配置如下:
upstream nacos-cluster {
server 127.0.0.1:8845;
server 127.0.0.1:8846;
server 127.0.0.1:8847;
}
server {
listen 80;
server_name localhost;
location /nacos {
proxy_pass http://nacos-cluster;
}
}
然后双击.exe启动nginx,浏览器输入localhost/nacos访问成功,nginx会通过80端口反向代理,在上述3个nacos服务之间进行负载均衡选择其中的一个

功能测试
java代码要想使用nacos,只需要将bootstap配置中nacos端口改成80即可

进入nacos添加一个环境配置

可以在数据库中看到配置数据已经进行了持久化存储

用完赶快关掉nacos吧,一个nacos就占了一个多G的内存...
Feign远程调用
Feign替代RestTemplate
RestTemplate调用存在的问题

对于上述代码存在以下问题
- 可读性差,变成体验不统一
- 参数复杂,url难以维护
配置Feign
Feign是一个声明式http客户端,通过它可以优雅实现发送http请求,解决上面提到的问题
-
引入依赖
//orderservice pom.xml <!-- Feign--> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency> -
在order-service启动类添加注解开启Feign功能
@EnableFeignClients -
编写Feign客户端
所谓声明式,指的是只要提供发送请求所需要的信息就能封装一个规范完整的http请求,例如服务名称,请求方式,请求路径,请求参数,返回值类型等
//clients.UserClient @FeignClient("user-service") public interface UserClient { @GetMapping("/user/{id}") User findById(@PathVariable Long id); }注释掉原来controller中使用RestTemplate编写的方法,采用feign来发送请求
@RestController @RequestMapping("order") public class OrderController { @Autowired private OrderService orderService; @Autowired private UserClient userClient; // @Autowired // private RestTemplate restTemplate; @GetMapping("{orderId}") public Order queryOrderByUserId(@PathVariable("orderId") Long orderId) { // 根据id查询订单 Order order = orderService.queryOrderById(orderId); // // 利用RestTemplate发起http请求,查询用户 // order.setUser(restTemplate.getForObject("http://user-service/user/" + order.getUserId(), User.class)); // 利用Feign查询用户 User user = userClient.findById(order.getUserId()); order.setUser(user); return order; } }
功能测试
❗因为nacos集群太消耗电脑性能了,这里还采用原来的单个nacos服务启动
这里笔者忘记持久化保存单机nacos配置了,因此没有之前创建的命名空间信息,如果发现不了服务直接将user-service的配置文件中nacos.namespace配置项注释掉即可
访问order-service接口可以看到成功返回数据,而且Feign自动实现了负载均衡

自定义配置
Feign通过自定义配置来覆盖原来的默认配置
类型(feign.开头) 作用 说明 Logger.Level 修改日志级别 四种不同的级别:NONE(默认),BASIC(请求开始结束时间和耗时),HEADERS(再加上请求头信息),FULL(将请求体和响应体也包括进去) codec.Decoder 响应结果的解析器 http远程调用结果解析,例如将json字符串解析为java对象 codec.Encoder 请求参数编码 将请求参数编码,便于通过http请求发送,例如将传送参数转换成请求体 Contract 支持的注解格式 默认使用SpringMVC的注解格式 Retryer 失败重试 默认没有重试,不过由于底层使用ribbon,可以使用ribbon的重试
代码配置
配置文件方式
-
全局生效
feign: client: config: default: #全局默认配置 loggerLevel: FULL #日志级别 -
局部生效
feign: client: config: user-service: #针对指定服务的配置 loggerLevel: FULL
功能测试
# order-service 配置文件
feign:
client:
config:
default:
logger-level: FULL
可以看到返回了日志

java代码配置方式
- 声明一个配置Bean
public class FeignClientConfiguration {
@Bean
public Logger.Level feignLogLevel(){
return Logger.Level.BASIC;
}
}
-
在注解上添加对应选项
-
全局配置,在启动类对应注解添加配置
@EnableFeignClients(defaultConfiguration = FeignClientConfiguration.class) -
局部配置,在对应clients注解上添加配置
@FeignClient(value='user-service',configuration = FeignClientConfiguration.class)
-
个人肯定是推荐用配置文件的方式啦
测试完之后记得把feign日志配置改成NONE或者BASIC,毕竟日志还是消耗一定性能的
Feign性能优化
Feign底层客户端实现
- URLConnection:默认实现,性能一般,并且不支持连接池
- Apache HttpClient" 支持连接池
- OKHttp: 支持用basic或none
因此优化性能包括
- 使用连接池替换默认底层实现客户端(这里使用httpClient)
- 日志级别采用NONE或者BASIC
优化配置
-
引入httpClient依赖
<!-- order-service pom.xml --> <!-- Feign HttpClient--> <dependency> <groupId>io.github.openfeign</groupId> <artifactId>feign-httpclient</artifactId> </dependency> -
配置连接池
# order-service feign: httpclient: enabled: true #启用httpclient作为feign的http请求客户端 max-connections: 200 #连接池最大连接数,根据业务具体需求来确定 max-connections-per-route: 50 #单个服务的最大连接数,根据业务具体需求来确定
企业最佳实践
继承方案
因为对于原功能接口和需要远程调用的接口的请求方法一定相同(例如根据id获取order信息的时候同时远程调用根据id查找用户信息的接口,可以共同用userAPI来约束,保证请求方法相同)
给消费者的FeignClient和提供者的controller定义统一的父接口作为标准

弊端
- 服务紧耦合,如果父接口变化了的话,继承的子类都要跟着改变
- 父接口参数列表中的映射不会被继承(springMVC本身并不支持如
@PathVariable这样的注解的继承)
抽取方案
将FeignClient抽取为独立的模块,将接口有关的pojo,默认的feign配置都放到这个模块中,提供给所有的消费者使用
避免了不同业务调用相同的一个服务重复造方法,后端使用服务的时候只需要调用api即可

弊端:
- 需要将一整套实现好的方法搬进来,项目可能会比较臃肿
个人比较倾向于方案2,本次项目也将采取方案2进行改造
抽取FeignClient
-
创建feign-api这个模块,引入feign依赖,将order-servcie中编写的UserClient,User,DefaultFeignConfiguration都复制到feign-api项目中(由于之前自己采用的是配置文件方式配置feign,因此没有复制配置类)
<!-- feign--> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> </dependency>
将原来order-service里面的feign配置转移到feign-api模块的application.yml文件中(自行创建)
feign: client: config: default: logger-level: NONE httpclient: enabled: true #启用httpclient作为feign的http请求客户端 max-connections: 200 #连接池最大连接数,根据业务具体需求来确定 max-connections-per-route: 50 #单个服务的最大连接数,根据业务具体需求来确定 -
在order-service中引入feign-api本地模块的依赖
<!-- feign-api--> <dependency> <groupId>cn.itcast.eureka</groupId> <artifactId>feign-api</artifactId> <version>1.0</version> </dependency> -
修改order-service中的所有与上述三个组件有关的import部分改成导入feign-api中的包
首先在order-service启动类中添加
@EnableFeignClients(clients = {UserClient.class})指定UserClient作为客户端进入IOC管理
之后检查每个文件的类的依赖是否配置正确(可以直接构建更快发现需要改动的地方) -
启动测试,正常返回结果

找不到client类的解决方案
- 启动类feign注解指定Feign的最佳实践
@EnableFeignClients(basePackages = "cn.itcast.feign.clients"),不推荐,扫描包浪费性能 - 精准定位指定的FeignClient
@EnableFeignClients(clients={UserClient.class}),即上面采用的方法,推荐,有多少用多少,节约性能
Gateway服务网关
想起「502 Bad Gateway」
究竟何年何月学校的教务系统不会在选课的时候炸掉呢
网关功能:
- 身份认证和权限校验
- 服务路由/负载均衡
- 请求限流
gateway快速入门
在SpringCloud中网关的实现包括两种:
- gateway(较新的技术,基于Spring5中提供的WebFlux,属于响应式编程,具备更好的性能)
- zuul(较早的技术,基于Servlet的实现,属于阻塞式编程)
搭建网关服务
-
创建新的module,引入SpringCloudGateWay依赖和nacos服务发现依赖,创建启动类

//pom.xml <dependencies> <!-- nacos服务注册发现--> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency> <!-- gateway网关--> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-autoconfigure</artifactId> </dependency> </dependencies>//GatewayApplication @SpringBootApplication public class GatewayApplication { public static void main(String[] args) { SpringApplication.run(GatewayApplication.class, args); } } -
在网关实现请求路由
编写路由配置和nacos地址
# application.yml server: port: 10010 #网关端口 spring: application: name: gateway #网关服务名称 cloud: nacos: server-addr: localhost:8848 #nacos服务地址 gateway: routes: #网关路由配置 - id: user-service #路由id,一般以定位到的服务名称命名 uri: lb://user-service #路由目标地址(lb代表负载均衡) predicates: #路由断言,也就是判断条件是否符合要求,如果符合才能进行路由 - Path=/user/** #按照路径匹配,只要路径符合以/user/开头的都可以进行路由 - id: order-service uri: lb://order-service predicates: - Path=/order/** -
启动网关和其他相关服务进行功能测试

网关流程

总结

断言工厂
断言工厂负责将配置文件中的断言规则字符串读取并解析,转换为路由判断条件
例如添加After谓词限制只能在指定时间段之后访问
predicates:
- After=2031-01-01T00:00:00+08:00[Asia/Shanghai] #时间断言,只有在指定时间之后才能进行路由
可以看到由于请求时间不符合要求,会报404

过滤器工厂
GatewayFilter对进入网关的请求和微服务返回的响应做处理,多个过滤器组成一套链

入门案例
routes: #网关路由配置
- id: order-service
filters:
- AddRequestHeader=Truth,Itcast is freaking awesome #添加请求头,在请求头中添加XTruth: Itcast is freaking awesome
default-filters:
- AddRequestHeader=Itcast,Itcast is freaking awesome #默认过滤器,在请求头中添加XItcast: Itcast is freaking awesome
全局过滤器
全局过滤器和上述的default filter功能相同,但与后者处理逻辑被gateway写死相比,可以自定义逻辑,实现更复杂的过滤功能
定义方式是实现GlobalFilter接口
入门案例
定义全局过滤器,拦截并判断用户身份
- 参数中含有authorization,且值为admin即可
@Order(-1) //设置过滤器的优先级,值越小,优先级越高
@Component //注册进Spring容器
public class AuthorizationFilter implements GlobalFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//1.获取请求参数
String authorization = exchange.getRequest().getQueryParams().getFirst("authorization");//getFirst()获取匹配上的第一个参数
//2.判断authorization是否为admin,符合则放行
if ("admin".equals(authorization)) {
//交给下一个过滤器处理,即放行
return chain.filter(exchange);
}
//3.不符合则拦截,设置响应状态码为401
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
}
过滤器执行顺序
请求进入网关会遇到三类过滤器:当前路由的过滤器,默认过滤器和全局过滤器
请求路由之后,会将当前三类过滤器合并到一个过滤器链(集合)中,排序后一次执行每个过滤器(全局过滤器可以通过适配器适配成GatewayFilter,因此可以这三类可以放在一个集合里面)

过滤器执行顺序
- 每个过滤器指定一个int类型的order值,值越小,顺序越靠前(可以通过注解或者实现接口的方式指定,对于配置声明的过滤器,由Spring根据顺序从1开始递增指定)
- 当过滤器order值一样的时候,会按照default > 路由过滤器 > 全局过滤器的顺序执行
跨域问题
跨域:
- 域名不同:如
www.taobao.com和www.jd.com和miaosha.jd.com - 域名相同,但端口不同
跨域问题:浏览器禁止请求的发起者与服务端发生跨域ajax请求,请求被浏览器拦截的问题
解决方案:CORS(自己查了一下,大概就是修改响应头允许跨域或者使用其他方法(jsonp)代替ajax请求,了解视频)
网关处理跨域采用的同样是CORS方案,只需要如下简单配置即可
入门案例
在指定端口开启前端页面(这里我是用的是vscode的live server插件)

可以看到前端报了CORS错误(跨域请求错误)
然后在gateway模块添加以下配置(允许跨域的端口号根据前端页面开启的端口号而定)
spring:
cloud:
gateway:
globalcors: # 全局的跨域处理
add-to-simple-url-handler-mapping: true # 解决options请求被拦截问题
corsConfigurations:
'[/**]':
allowedOrigins: # 允许哪些网站的跨域请求
- "http://localhost:5500"
- "http://www.leyou.com"
allowedMethods: # 允许的跨域ajax的请求方式
- "GET"
- "POST"
- "DELETE"
- "PUT"
- "OPTIONS"
allowedHeaders: "*" # 允许在请求中携带的头信息
allowCredentials: true # 是否允许携带cookie
maxAge: 360000 # 这次跨域检测的有效期
重启gateway之后再重新再浏览器访问index.html,成功返回数据(如果没成功请检查是否允许了前端开启的端口,并将访问地址127.0.0.1改成localhost(因为浏览器并不知道你的主机localhost对应的就是127.0.0.1))

Day03 2024.02.03 Docker
最喜欢的一集,很早就已经听闻了Docker的nb之处,今日终于有机会接触了!
初识Docker
项目部署的问题
大型项目组件很多,运行环境也比较复杂,部署的时候会碰到一些问题
- 依赖关系复杂,容易出现兼容性问题
- 开发,测试,生产环境有差异
什么是Docker
Docker
- 将应用的Libs(函数库),Deps(依赖),配置和应用一起打包
- 将每个应用放到一个隔离容器去运行,避免互相干扰
❓ 不同环境的操作系统不同,DOcker如何解决呢?
基于linux内核的系统使用了不同的函数库来封装内核指令,因此一个系统上的软件可能无法运行在另一个系统上面
而Docker如何解决不同系统环境的问题?
- Docker将用户程序所需要调用的系统(比如Ubuntu)函数库一同打包
- Docker运行到不同操作系统的时候,直接基于打包的库函数运行在系统内核(即linux内核)上
总结
Docker和虚拟机的区别
- Docker是直接调用操作系统内核,相比虚拟机性能更好,占用更少
- 虚拟机是基于本地模拟一个硬件环境,调用这个虚拟硬件环境搭建的操作系统内核,进而调用本地内核

总结

Docker架构
镜像和容器
镜像 : Docker将应用程序及其所需依赖,函数库,环境,配置等文件打包在一起的一个文件
容器 : 镜像中的应用程序运行后形成的进程就是容器,只是Docker会给容器进行隔离,对外不可见,一个镜像可以运行多个容器

Docker和DockerHub
- DockerHub : 是Docker镜像托管平台,这样的平台成为Docker Registry
Docker架构
Docker是一个CS架构的程序
- 服务端:Docekr守护进程,负责处理Docker指令,管理镜像和容器
- 客户端:通过命令或RestA向Docker服务端发送指令

安装Docker
这里选择在CentOS7上安装docker,因为笔者之前学习linux的时候已经装过这个系统了,就不演示如何安装linux,可自行上网搜索
-
先检查卸载之前残留的docker
yum remove docker \ docker-client \ docker-client-latest \ docker-common \ docker-latest \ docker-latest-logrotate \ docker-logrotate \ docker-selinux \ docker-engine-selinux \ docker-engine \ docker-ce
-
安装yum工具并更新镜像源
yum install -y yum-utils \ device-mapper-persistent-data \ lvm2 --skip-broken然后更新本地镜像源:
# 设置docker镜像源 yum-config-manager \ --add-repo \ https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo sed -i 's/download.docker.com/mirrors.aliyun.com\/docker-ce/g' /etc/yum.repos.d/docker-ce.repo yum makecache fast

-
安装Docker
yum install -y docker-ce
docker-ce为社区免费版本。稍等片刻,docker即可安装成功。

-
启动Docker
由于Docker需要用到许多端口,需要逐一设置防火墙,建议直接将防火墙关闭
启动Docekr前一定要关闭防火墙
# 关闭 systemctl stop firewalld # 禁止开机启动防火墙 systemctl disable firewalld通过命令启动docker:
systemctl start docker # 启动docker服务 systemctl stop docker # 停止docker服务 systemctl restart docker # 重启docker服务然后输入命令,可以查看docker版本:
docker -v如图:

-
配置镜像
sudo mkdir -p /etc/docker sudo tee /etc/docker/daemon.json <<-'EOF' { "registry-mirrors": ["https://6xz09k2b.mirror.aliyuncs.com"] } EOF sudo systemctl daemon-reload sudo systemctl restart docker
Docker基本操作
镜像操作

镜像命名规范
-
命名分成两部分:[repository]:[tag],在没有指定tag的时候,默认是latest,即最新版镜像

案例:拉取nginx镜像并查看
-
在Dockerhub网站查找镜像(进入网站貌似得FQ)

-
安装镜像
docker pull nginx
原来nginx已经两年没更新了吗
案例:镜像导出磁盘再重新加载
-
docker save来保存镜像为压缩包
# -o表示输出到一个文件 nginx.tar是输出的文件名 nginx:latest是指定要导出的镜像 docker save -o nginx.tar nginx:latest -
删除并重新加载镜像
#删除镜像 docker rmi nginx:latest #加载镜像 -i表示将指定文件作为输入文件 docker load -i nginx.tar
案例:自行拉取Redis镜像

很简单,因此我就不练习了,只拉取镜像即可
docker pull redis
容器操作

案例:创建运行nginx容器
-
运行nginx容器
docker run --name containerName -p 80:80 -d nginx-
--name 后面接指定的容器进程名称
-
-p 将宿主主机端口与容器端口映射,左侧是宿主机端口,右侧是容器端口

-
-d 后台运行容器
-
nginx 是指定运行的镜像名称

其中启动容器后返回的是进程的唯一id
然后在主机浏览器中访问

其中虚拟机的内网ip地址是通过ifconfig查询获取的

查看日志
#查看指定容器(进程)的日志 docker logs containerName #持续跟踪日志 docker logs -f containerName -
案例:进入容器修改文件内容
docker exec -it containerName bash
- docker exec 进入容器内部执行命令(bash 即为执行的命令,打开bash终端)
- -it 给进入的容器创建一个标准输入输出终端,允许我们与容器交互
在dockerhub上查看nginx html文件所在位置

修改容器的html文件内容
# 进入html所在目录
cd /usr/share/nginx/html
# 修改html内容
# 由于容器相当于一个阉割版的系统,只包含运行容器必要的依赖环境,没有vim等工具,因此使用最原始的sed指令进行修改
#其中-i表示直接对原文件内容进行替换,`#`表示分割符,s表示替换指令,第一个分隔符后面是要替换的字符串,第二个分隔符后面是替换成的内容,分隔符也可以用`/`,`|`等来表示
sed -i 's#Welcome to nginx#传智教育欢迎你#g' index.html
#让html页面支持显示中文
sed -i 's#<head>#<head><meta charset="utf-8">#g' index.html
可以看到成功替换了页面内容

杀死nginx容器进程并查看进程状态
# 杀掉进程
docker stop containerName
#查看docker容器状况,如果不加`-a`,则默认只显示运行中的容器
docker ps -a
#重启进程
docker start containerName
#删除容器,-f可以强制删除运行中的容器
docker rm -f containerName
❗ exec命令进入容器内修改文件并不推荐,因为不方便,而且没有修改记录,正确修改文件的方式将在下面讲解
案例:创建运行redis容器并实现持久化
docker pull redis
docker run --name mr -p 6379:6379 -d redis redis-server --appendonly yes
在主机上连接redis容器,个人觉得直接用idea自带的来连接redis就行了,不必再安装一个专门的软件
初始账号是redis,没有密码,连接的主机即为虚拟机的地址,这里笔者是配置了hosts文件将fishlulu01和虚拟机地址映射了
可以在linux终端对redis进行操作
# 进入容器内部
docker exec -it mr bash
# 进入redis客户端
redis-cli
# 上述两步操作也可以合并为一个操作
docker exec -it mr redis-cli
# redis命令操作
keys *
set num 666

ℹ️ 关于虚拟机挂起再恢复后主机无法在浏览器访问容器的问题
解决方案 简单来说就是开启IPv4连接,这个小小问题没想到花费了我一晚上时间去琢磨啊
数据卷(容器数据管理)
容器与数据耦合的问题
- 不便于修改,需要进入容器修改
- 数据不可复用.容器内部修改对外不可见
- 升级维护困难,如果升级容器必须连带数据和旧的容器一起删除
📑
数据卷:是一个虚拟目录,指向宿主机文件系统中的某个目录,可以理解为容器使用宿主机指定的目录来存储数据,容器本身不存储数据(并不是数据备份)
数据卷操作
- create 创建
- inspect 显示一个或者数据卷信息
- ls 列出所有的volume
- prune 删除未使用的数据卷(prune 意为修剪)
- rm 删除指定的volume

(貌似prune命令并没有将新建的数据卷删除,不过不是什么重要的事情)
挂载数据卷
可以通过-v参数来挂载数据卷到某个容器目录
docker run \
--name mn \
-v html:/root/html \ #将html数据卷挂载到容器内的/root/html目录中(如果之前没有创建html数据卷也没关系,docker会自动创建)
-p 8080:80 \
nginx\
案例:通过数据卷挂载niginx容器的html文件并修改内容
#启动nginx容器服务并挂载指定数据卷
docker run --name mn -p 80:80 -v html:/usr/share/nginx/html -d nginx

进入指定数据卷的_data目录找到对应关联的文件进行修改(通过vscode远程连接虚拟机,通过高级编辑工具来进行修改)

可以看到成功修改

案例:直接挂载mysql容器指定目录到宿主机目录上面
-
将课前资料中的mysql.tar文件上传到虚拟机,通过load命令加载为镜像(当然可以网上拉取,不过这里就和课程保持一致了)
这里使用xftp上传tar包到/root/Downloads目录下面

终端跳转到tar包目录,加载镜像
docker load -i mysql.tar
-
创建目录/tmp/mysql/conf,创建目录/tmp/mysql/data,将课前资料中提供的hmy.cnf文件上传到conf目录
这里创建目录和上传cnf文件都直接用xftp了

-
创建并运行mysql容器
docker run \ --name mysql \ -e MYSQL_ROOT_PASSWORD=123 \ #指定环境变量,这里直接指定root用户密码 -p 3306:3306 \ -v /tmp/mysql/conf/hmy.cnf:/etc/mysql/conf.d/hmy.cnf \ #挂载配置文件,容器对应目录可在dockerhub查阅(my.cnf是官方的默认配置,因此额外配置挂载到conf目录下面而非my.cnf上,其中`.d`代表是目录的含义) -v /tmp/mysql/data:/var/lib/mysql \ #挂载数据目录 -d \ mysql:5.7.25如何使用vscode终端运行命令的话,由于windows换行符和linux不同,无法识别上面多行文本为一行指令,因此直接拼接成一行执行即可
docker run --name mysql -e MYSQL_ROOT_PASSWORD=123 -p 3306:3306 -v /tmp/mysql/conf/hmy.cnf:/etc/mysql/conf.d/hmy.cnf -v /tmp/mysql/data:/var/lib/mysql -d mysql:5.7.25docker exec -it mysql bash # 在终端打开mysql容器 mysql -uroot -p # 密码登入mysql(密码为运行时环境变量指定的123)成功进入mysql

数据卷挂载和目录挂载对比
- 数据卷挂载耦合度更低,由docker来管理目录,但是目录比较深,不好找(/var/lib/docker/volumes/..)
- 数据卷不存在的时候会自动创建
- 数据卷中没有文件的时候会将容器目录中的文件自动挂载(转移)到数据卷中
- 数据卷中有文件的时候,数据卷中的文件会覆盖掉原来容器中的对应文件
- 目录直接挂载耦合度更高(因为需要我们自己指定目录),不过目录比较容易寻找和查看

Dockerfile自定义镜像
镜像结构

镜像是一个分层结构,每一层称作一个layer
- BaseImage层:包含基本的系统函数库,环境变量,文件系统
- EntryPoint:入口,是镜像中应用启动的命令
- 其他:在BaseImage基础上添加依赖/安装程序/完成整个应用的安装和配置
自定义镜像

案例:构建镜像运行java项目
-
创建/tmp/docker-demo文件夹,将
docker-demo.jar,Dockerfile,jdk8.tar.gz三个文件上传到文件夹里面#Dockerfile 内容 # 指定基础镜像 FROM ubuntu:16.04 # 配置环境变量,JDK的安装目录 ENV JAVA_DIR=/usr/local # 拷贝jdk和java项目的包 COPY ./jdk8.tar.gz $JAVA_DIR/ COPY ./docker-demo.jar /tmp/app.jar # 安装JDK RUN cd $JAVA_DIR \ && tar -xf ./jdk8.tar.gz \ && mv ./jdk1.8.0_144 ./java8 # 配置环境变量 ENV JAVA_HOME=$JAVA_DIR/java8 ENV PATH=$PATH:$JAVA_HOME/bin # 暴露端口 EXPOSE 8090 # 入口,java项目的启动命令 ENTRYPOINT java -jar /tmp/app.jar
-
创建容器并运行
# 创建容器 其中-t指定容器的标签(即1.0) docker build -t javaweb:1.0 /tmp/docker-demo # 运行容器 docker run --name web -p 8090:8090 -d javaweb:1.0
成功构建部署
ℹ️ 在先前的dockerfile文件中,存在许多可以复用的指令(层),可以统一构建一个通用的镜像再在此基础上进行改造,这里可以基于
java:8-alpine这个已经准备好jdk环境的镜像来部署项目,只需要改变dockerfie即可(需要联网拉取一下基础镜像)💡如果没有镜像,就改成openjdk:8-alpine,因为原来的镜像已经弃用了
#指定基础镜像
FROM java:8-alpine
COPY ./docker-demo.jar /tmp/app.jar
# 暴露端口
EXPOSE 8090
# 入口,java项目的启动命令
ENTRYPOINT java -jar /tmp/app.jar
Docker-Compose
入门
-
Docker Compose可以基于Compose文件快速部署分布式应用,而不用手动一个个创建和运行容器
-
Compose文件是一个yaml文本文件,通过指令定义集群中的每个如何运行(本质上就是把dockerfile指令转化成配置文件来批量运行)
version: "3.8" services: mysql: image: mysql:5.7.25 environment: MYSQL_ROOT_PASSWORD: 123 volumes: - /tmp/mysql/data:/var/lib/mysql - /tmp/mysql/conf/hmy.cnf:/etc/mysql/conf.d/hmy.cnf web: build: . ports: - 8090:8090
安装Docker-Compose
这里直接使用课程资料提供的docker-compose文件上传到/usr/local/bin目录中

修改文件权限给予执行权
chmod +x /usr/local/bin/docker-compose
配置自动补全
# 补全命令
curl -L https://raw.githubusercontent.com/docker/compose/1.29.1/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose
#如果出错了需要修改一下hosts文件
echo "199.232.68.133 raw.githubusercontent.com" >> /etc/hosts

部署cloud-demo项目
-
查看docker-compose文件,将自己项目中的nacos和mysql地址从原来的主机名改成对应的服务名
❓为什么在docker中只要对应服务名就可以访问不同服务?
因为docker底层实现了自动构建虚拟网络,可以根据对应服务名定位到指定ip,比如后面在部署nacos服务的时候nacos控制台172开头的ip地址就是docker生成的内网ip
docker-compose文件(记得把里面的数据库密码改成自己的)
#docker-compose文件
version: "3.2"
services:
nacos:
image: nacos/nacos-server
environment:
MODE: standalone
ports:
- "8848:8848"
mysql:
image: mysql:5.7.25
environment:
MYSQL_ROOT_PASSWORD: 123456
volumes:
#$PWD表示相对当前目录
- "$PWD/mysql/data:/var/lib/mysql"
- "$PWD/mysql/conf:/etc/mysql/conf.d/"
userservice:
# 服务是提供给内部调用的,因此不需要暴露端口
build: ./user-service
orderservice:
build: ./order-service
gateway:
build: ./gateway
ports:
- "10010:10010"
- 修改项目中服务的对应地址(网关,user和order微服务都需要修改)

-
使用maven打包成jar包(app.jar)
在每个要打包的服务的pom文件build标签中设置打包名称为app.jar(因为dockerfile文件配置是复制app.jar到指定目录)

先后执行父模块maven声明周期的clean和package操作,将target目录生成好的app.jar包分别放到资料cloud-demo文件的指定子文件夹中( 这里推荐不要用老师现成给的,不知道为什么运行后服务注册不到nacos上面,用自己的就可以)

-
将整理好的cloud-demo文件夹上传到/tmp目录下

-
构建镜像并运行
cd /tmp/cloud-demo docker-compose up -d # up指令表示构建并运行
这里可能需要先拉取nacos镜像,因此会慢一些
docker-compose logs -f #查看运行日志会看到nacos报错连接失败

原因貌似是nacos本身代码问题,nacos在较后面的位置启动,因此前面先启动的服务找不到nacos来注册
因此建议在生产环境
先部署nacos,然后再部署其他服务,这里直接重启gateway,user-service和order-service服务即可docker-compose restart orderservice userservice gateway #服务名是docker-compose.yml文件配置的,与服务本身spring的应用名和nacos服务注册名无关通过内网ip访问nacos控制台
192.168.181:8848/nacos(把ip地址换成自己虚拟机对应的内网ip即可)
可以看到服务注册成功

然后通过网关来访问服务测试
192.168.181.100:10010/order/101?authorization=admin(之前网关配置了拦截器,只有添加admin请求参数才能访问)成功返回

🐛 这里刚开始可能会报500错误,查看日志发现是请求超时导致的,进一步发现报错的是user-service
java.net.UnknownHostException: nacos过了一段时间之后可以正常访问了,暂时自己还不清楚原因
Docker镜像仓库
搭建私有镜像仓库
- 公共镜像仓库: Docker官方的DockerHub以及网易云,阿里云镜像服务等
- 用户在本地搭建私有Docker Registry
搭建简化版镜像仓库
具备仓库管理的完整功能,只是没有图形化界面
docker run -d \
--restart=always \
--name registry \
-p 5000:5000 \
-v registry-data:/var/lib/registry \
registry
搭建带有图形化界面仓库
由于图形化界面不是官方开发,这里使用DockerCompose集成部署带有图象界面的DockerRegistry,命令如下:
version: '3.0'
services:
registry:
image: registry
volumes:
- ./registry-data:/var/lib/registry
ui:
image: joxit/docker-registry-ui:static
ports:
- 8080:80
environment:
- REGISTRY_TITLE=禄禄鱼私有仓库
- REGISTRY_URL=http://registry:5000
depends_on:
- registry
之前提到的nacos没有先于其他服务配置导致其他服务注册失败也可以通过在docker-compose.yml文件中配置depends_on选项来指定其他服务在nacos之后注册
配置Docker信任地址
由于私服默认采用http协议,不被Docker信任,因此需要额外配置
# 打开要修改的文件
vim /etc/docker/daemon.json
#添加内容:
"insecure-registries":["http://192.168.181.100:8080"] #换成自己对应的虚拟机内网ip
#重新加载
systemctl daemon-reload
#重启docker
记得要在上一行末尾添加逗号分隔
案例:搭建本地图形化仓库
-
按照上述配置Docker信任地址
-
创建/tmp/registry-ui文件夹,里面新建docker-compose.yml文件,并将上述搭建图形化仓库的命令内容粘贴进里面
-
创建并运行容器
cd /tmp/registry-ui docker-compose up -d
在浏览器访问虚拟机8080端口

向镜像仓库推送镜像
将镜像tag重命名(比如要推送nginx镜像)
docker tag nginx:latest 192.168.181.100:8080/nginx:1.0
此时会发现两个镜像id一样,其实本质上都是一个镜像

将镜像推送
docker push 192.168.181.100:8080/nginx:1.0

此时在浏览器查看仓库,可以发现镜像

镜像仓库拉取镜像
可以直接通过页面复制拉取指令

先将原来的镜像删除,然后执行拉取
docker rmi 192.168.181.100:8080/nginx:1.0
docker rmi nginx:latest
docker pull 192.168.181.100:8080/nginx@sha256:ee89b00528ff4f02f2405e4ee221743ebc3f8e8dd0bfd5c4c20a2fa2aaa7ede3

总结

Day04 2024.02.19 服务异步通讯(消息队列)
初识MQ
同步通讯
同步通讯好比打电话,保证消息能立刻传达,但只能同时和一个连接
异步通讯好比微信聊天,发过去的消息别人可能不会立刻回复,但可以同时和多个连接
同步调用的问题

- 耦合度高,每次想新添功能都要改动现有代码
- 性能问题,必须等待一个完成才能进行下一个
- 系统资源浪费,等待期间持续占用内存和CPU消耗
- 级联失败,如果支付服务调用的一个服务挂了的话,支付服务也会阻塞
异步通讯
异步调用方案
异步调用常见实现就是事件驱动模式

服务发布时间,由broker进行代理转发,其他服务通过Broker获取消息
事件驱动优势
- 服务解耦,支付服务只负责发送事件,而不负责调用其他服务,因此不必对其进行改动,只要订阅事件即可
- 性能提升,吞吐量提高,支付服务只需要发送事件耗时
- 故障隔离,某一个调用的服务挂了的话不会影响上游服务
- 流量削峰,broker起到一个缓冲的作用,来不及处理的事件会暂存到Broker中
异步通信缺点
- 依赖于Broker的可靠性,安全性和吞吐能力
- 架构复杂了,并不清楚是具体哪一个业务处理事件,业务调用链不清晰,出了问题不好追踪排查
MQ常见框架
什么是MQ
MQ,即Message Queue 消息队列,也就是事件驱动中的Broker
| RabbitMQ | ActiveMQ | RocketMQ | Kafka | |
|---|---|---|---|---|
| 公司 | Rabbit | Apache | 阿里 | Apache |
| 开发语言 | Erlang | Java | Java | Scala&Java |
| 协议支持 | AMQP,XMPP,SMTP,STOMP | OpenWire,STOMP,REST,XMPP,AMQP | 自定义协议 | 自定义协议 |
| 可用性 | 高 | 一般 | 高 | 高 |
| 单机吞吐量 | 一般 | 差 | 高 | 非常高 |
| 消息延迟 | 微妙级 | 毫秒级 | 毫秒级 | 毫秒以内 |
| 消息可靠性 | 高 | 一般 | 高 | 一般 |
这里使用RabbitMQ,更稳定,社区活跃,并且没有定制需求,如果追求海量数据高吞吐的话可以选用Kafka
RabbitMQ快速入门
RabbitMQ概述和安装
Erlang语言是一个面向并发的编程语言,编写的RabbitMQ具备高稳定,低延迟和不错的吞吐量
安装
这里在centos7虚拟机中使用Docker来安装
-
将资料提供的mq.tar包上传到/tmp目录
-
加载镜像
docker load -i mq.tar -
运行容器
docker run \ -e RABBITMQ_DEFAULT_USER=fishlulu \ #配置用户名和密码 -e RABBITMQ_DEFAULT_PASS=123456 \ --name mq \ --hostname mq1 \ -p 15672:15672 \ #管理平台的端口,用来统一管理消息 -p 5672:5672 \ #用来进行收发信息通讯的端口 -d \ # 后台运行 rabbitmq:3-management
-
访问mq管理端端口
192.168.181.100:15672
结构概述

rabbitMQ设置多个虚拟主机实现业务的隔离,通过接受事件分发到不同队列上实现消息队列,最终通知给调用的服务
- channel:操作MQ的通道
- exchange:将消息路由到队列中
- queue:用于缓存消息的队列
- virtual host:虚拟主机,是对queue,exchange等资源的逻辑分组
常见消息模型
-
基本消息队列(BasicQueue)

-
工作消息队列(WorkQueue)

-
发布订阅(Publish,Subscribe)(包含交换机部分,根据交换机类型不同分为三种)
-
广播 Fanout Exchange

-
路由 Direct Exchange

-
主题 Topic Exchange

-
快速入门
基于官方HelloWorld案例,只包括三个角色
- publisher:消息发布者
- queue:消息队列
- consumer:订阅队列

由于代码比较复杂,这里导入课程资料提供的mq-demo项目工程调试跟进代码学习即可
调试publisher端
调试publisher的测试程序(记得把里面的用户密码和主机改成自己的)
public class PublisherTest {
@Test
public void testSendMessage() throws IOException, TimeoutException {
// 1.建立连接
ConnectionFactory factory = new ConnectionFactory();
// 1.1.设置连接参数,分别是:主机名、端口号、vhost、用户名、密码
factory.setHost("192.168.181.100");
factory.setPort(5672);
factory.setVirtualHost("/");
factory.setUsername("fishlulu");
factory.setPassword("123456");
// 1.2.建立连接
Connection connection = factory.newConnection();
// 2.创建通道Channel
Channel channel = connection.createChannel();
// 3.创建队列
String queueName = "simple.queue";
channel.queueDeclare(queueName, false, false, false, null);
// 4.发送消息
String message = "hello, rabbitmq!";
channel.basicPublish("", queueName, null, message.getBytes());
System.out.println("发送消息成功:【" + message + "】");
// 5.关闭通道和连接
channel.close();
connection.close();
}
}
- 代码通过connnectionFactory创建与消息队列的连接

- 然后创建相应通道

- 创建消息队列

-
发布者向通道发送消息,此时可以在管理端的消息队列中查看到

调试consumer端
public class ConsumerTest {
public static void main(String[] args) throws IOException, TimeoutException {
// 1.建立连接
ConnectionFactory factory = new ConnectionFactory();
// 1.1.设置连接参数,分别是:主机名、端口号、vhost、用户名、密码
factory.setHost("192.168.181.100");
factory.setPort(5672);
factory.setVirtualHost("/");
factory.setUsername("fishlulu");
factory.setPassword("123456");
// 1.2.建立连接
Connection connection = factory.newConnection();
// 2.创建通道Channel
Channel channel = connection.createChannel();
// 3.创建队列
String queueName = "simple.queue";
channel.queueDeclare(queueName, false, false, false, null);
// 4.订阅消息
channel.basicConsume(queueName, true, new DefaultConsumer(channel){
@Override
public void handleDelivery(String consumerTag, Envelope envelope,
AMQP.BasicProperties properties, byte[] body) throws IOException {
// 5.处理消息
String message = new String(body);
System.out.println("接收到消息:【" + message + "】");
}
});
System.out.println("等待接收消息。。。。");
}
}
❓ 为什么consumer端要重复创建一次队列
因为真正启动的时候不清楚先启动consumer端还是publisher端,防止还没有创建好队列就接受或者发送,因此两边都进行创建,然而rabbitMQ识别到同名队列并不会重复创建,因此可以放心使用
channel的basicConsume方法会提取队列中的消息并按照传入的实现类中重写的方法来处理消息(这里只是将如何处理消息告诉给队列,并没有真正执行处理,因此大概率会先打印等待接受消息,而后才打印接受的消息)(这就是异步通信的体现)

处理完消息后再来管理端会发现消息队列清空了,这就是RabbitMQ的阅后即焚机制

总结

SpringAMQP
AMQP是一个传递业务消息的规范
Basic Queue简单队列模型
hello world案例
-
父工程引入spring-amqp依赖
-
在publisher服务中利用RabbitTemplate发送消息到simple.queue这个队列(初始工程引完了)
-
在publisher编写测试方法发送消息
-
在publisher服务中application.yml 添加如下配置信息
spring: rabbitmq: host: 192.168.181.100 port: 5672 virtual-host: / username: fishlulu password: 123456 -
在测试类编写如下方法
@RunWith(SpringRunner.class) @SpringBootTest public class SpringAmqpTest { @Autowired private RabbitTemplate rabbitTemplate; @Test public void testSimpleQueue(){ String queueName = "simple.queue"; String message = "hello, rabbitmq!"; rabbitTemplate.convertAndSend(queueName, message); } }Runwith注解是为了提供Spring运行环境支持,其实不配这个注解应该也没问题
如果之前关闭了队列记得重新新建一个名为simple.queue的队列,否则接收不到信息

-
-
在consumer编写消费逻辑,监听simple.queue
-
配置yml
spring: rabbitmq: host: 192.168.181.100 port: 5672 virtual-host: / username: fishlulu password: 123456 -
编写监听类
// cn/itcast/mq/listener/SpringRabbitListener.java @Component public class SpringRabbitListener { @RabbitListener(queues = "simple.queue") public void listenSimpleQueue(String message){ System.out.println("接收到消息:【" + message + "】"); } }运行启动类
-

-
Work Queue工作队列模型
多个消费者来合作处理一个消息队列,可以提高消息处理速度,避免队列消息堆积

这里案例就不跟着做了
实现起来很简单,消费者监听类上面设置两个以上监听方法监听同一个队列即可
消息预取机制
消费者在处理信息之前,会先将信息暂时取出,rabbitmq默认为每一个消费者预先分配同等个数的消息进行处理,如果两个消费者消费信息的能力不同,则会造成性能上的浪费
因此可以在yml配置消息预取上限为1
spring: rabbitmq: listener: simple: prefetch: 1 # 每次只能获取一条消息,处理完成才能获取下一个消息
发布订阅模型
之前学到的模型消息在被一个消费者处理之后会直接销毁(阅后即焚),而发布订阅模型允许将同一个消息发送给多个消费者,实现方式是加入了exchanger(交换机)
exchanger负责消息路由,而非存储,如果路由失败,消息丢失

Fanout广播模式
会将消息发送给所有的绑定的队列
案例:实现FanoutExchange
-
在consumer服务声明Exchange,Queue,Binding
SpringAMQP提供了声明交换机,队列,绑定关系的API

@Configuration
public class FanoutConfig {
//声明交换机
@Bean
public FanoutExchange fanoutExchange(){
return new FanoutExchange("fanout.exchange");
}
@Bean
//声明队列
public Queue fanoutQueue1(){
return new Queue("fanout.queue1");
}
@Bean
public Queue fanoutQueue2(){
return new Queue("fanout.queue2");
}
//绑定队列到交换机
@Bean
public Binding fanoutBinding1(FanoutExchange fanoutExchange, Queue fanoutQueue1){
return BindingBuilder.bind(fanoutQueue1).to(fanoutExchange);
}
//绑定队列到交换机
@Bean
public Binding fanoutBinding2(FanoutExchange fanoutExchange, Queue fanoutQueue2){
return BindingBuilder.bind(fanoutQueue2).to(fanoutExchange);
}
}
这么麻烦,后续肯定会用注解之类的更简便的方式啊
可以在管理端查看到交换机的状况

-
发送和接受信息
-
publisher测试类发送信息
@Test public void testSendFanoutExchange(){ //交换机名称 String exchangeName = "fanout.exchange"; //消息 String message = "hello,every one!"; //发送消息 rabbitTemplate.convertAndSend(exchangeName,"",message)//第一个参数指定交换机名称,第二个参数是routing key,后续会学习 } -
consumer编写两个监听类来监听fanout.queue1和fanout.queue2
@Component public class SpringRabbitListener { @RabbitListener(queues = "fanout.queue1") public void listenSimpleQueue(String message){ System.out.println("接收到消息:【" + message + "】"); } @RabbitListener(queues = "fanout.queue2") public void listenSimpleQueue(String message){ System.out.println("接收到消息:【" + message + "】"); } }
-
Direct路由模式
Direct Exchange会将接受到的消息根据规则路由到指定的Queue
- 每一个Queue斗鱼Exchange设置一个bindingKey
- 发布者发送消息的时候指定消息的bindingkey
- exchange将消息路由到与消息bindingKey一致的队列
- 一个队列也可以指定多个bindingkey,且不同队列key可以相同,因此路由模式也可以模拟广播模式

案例
消费者监听类方法
@Component
public class SpringRabbitListener {
@RabbitListener(
bindings = @QueueBinding(
value = @Queue(name = "direct.queue1"),
exchange = @Exchange(
name = "direct.exchange",
type = ExchangeTypes.DIRECT), //exchange默认类型就是direct,因此可以不用指明
key = {"red","blue"}
))
public void listenDirectQueue(String message){
System.out.println("direct.queue1接收到消息:【" + message + "】");
}
}
发布者测试方法
@Test
public void testDirectQueue(){
String exchangeName = "direct.exchange";
String routingKey = "red";
String message = "hello, direct exchange!";
rabbitTemplate.convertAndSend(exchangeName, routingKey, message);
}
Topic主题模式
与DirectExchange类似,但routingKey必须是多个单词的列表,并且以.分隔
Queue与Exchange指定BindingKey时可以使用通配符
:代指0或多个单词
*:代指一个单词

案例
消费者
@RabbitListener(
bindings = @QueueBinding(
value = @Queue(name = "topic.queue1"),
exchange = @Exchange(
name = "topic.exchange",
type = ExchangeTypes.TOPIC),
key = {"china.*"}
))
public void listenTopicQueue1(String message){
System.out.println("topic.queue1接收到消息:【" + message + "】");
}
@RabbitListener(
bindings = @QueueBinding(
value = @Queue(name = "topic.queue2"),
exchange = @Exchange(
name = "topic.exchange",
type = ExchangeTypes.TOPIC),
key = {"#.news"}
))
public void listenTopicQueue2(String message){
System.out.println("topic.queue2接收到消息:【" + message + "】");
}
发布者
@Test
public void testTopicQueue(){
String exchangeName = "topic.exchange";
String routingKey = "china.news";
String message = "hello, topic exchange!";
rabbitTemplate.convertAndSend(exchangeName, routingKey, message);
}
消息转换器
案例
新建队列用于测试
//FanoutConfig.java
//objectQueue用于测试消息转换器
@Bean
public Queue objectQueue(){
return new Queue("object.queue");
}
发送消息
//测试消息转换器
@Test
public void testSendObjectQueue(){
Map<String, Object> map = new HashMap<>();
map.put("name", "张三");
map.put("age", 23);
rabbitTemplate.convertAndSend("object.queue", map);
}
发送的消息在管理端查看是序列化后的java对象
Spring对消息对象的处理是由MessageConverter来处理的,默认是实现是SimpleMessageConverter,是基于JDK的ObjectOutputStream完成序列化
然而默认方式存在序列化后长度过长,不安全存在注入风险,传输速度慢等缺点
因此这里推荐使用JSON方式序列化
首先在发布者那边修改
-
在publisher引入依赖
<dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> -
启动类设置消息转换器
@Bean public MessageConverter messageConverter(){ return new Jackson2JsonMessageConverter(); }
消费者那边同理
注意发布者和消费者双方要使用相同的Converter
Day05 2024.02.21 分布式搜索
初识elasticsearch(ES)
了解ES
elasticsearch是一款非常强大的开源搜索引擎,可以帮助我们从海量数据中快速找到需要的内容

此外elasticsearch结合kibana,logstash,beats,也就是elastic stack(ELK),被广泛应用在日志数据分析,实时监控等领域

elasticsearch是elastic stack的核心,负责存储,搜索和分析数据

elasticsearch发展
elasticsearch的核心是lucene,lucene是一个java语言的搜索引擎类库,是Apache公司的顶级项目
Lucene优势
- 易扩展
- 高性能(基于倒排索引)
Lucene缺点
- 只限于Java语言开发
- 学习曲线陡峭
- 不支持水平扩展
而后出现的es,相对lucene具备如下优势
- 支持分布式,可水平扩展
- 提供Restful接口,可被任意语言调用
当然不止有elasticsearch这一个搜索引擎
- Elasticsearch:开源的分布式搜索引擎
- Splunk:商业项目
- Solr:Apache开源搜索引擎
倒排索引
传统数据库(如MySQL)采用正向索引,例如给下表(tb_goods)中的id创建索引
如果用id查询速度会很快,但如果想要模糊查找包含的数据,只能逐条搜索,这样创建的索引就失去了意义,效率低下

而elasticsearch采用倒排索引
- 文档(document):每条数据就是一个文档
- 词条(term):文档按照语义分成的词语
所谓倒排索引,就是先对文档内容分词,对词条创建索引,并记录词条所在文档的信息,查询的时候先根据词条查询到文档id,然后根据id获取文档
虽然查询了两遍数据库,但每次都是走的索引查询,因此效率会更高

es一些概念
文档
elasticsearch是面向文档存储的,可以是数据库中的一条商品数据,一个订单信息等
文档数据会被序列化为json格式存储在es中
索引
- 索引(index):相同类型的文档的集合
- 映射(mapping):索引中文档的字段约束信息,类似表的结构约束

mysql与elasticsearch对比
概念对比

DSL相对于SQL语句通过http请求就可以发送,实现了不同语言通用
架构对比
- Mysql:擅长时务类型操作,可以确保数据的安全和一致性
- ElasticSearch:擅长海量数据的搜索,分析,计算
因此两个数据库是互补的关系,将来设计系统架构的时候两个数据库都会用到

安装es和kibana
-
创建网络
因为还需要部署kibana容器,因此需要让es和kibana容器互联,这里先创建一个网络
docker network create es-net -
加载镜像
由于镜像有1个G大小,这里使用课程资料提供的tar包加载
#加载镜像 cd /tmp docker load -i es.tarkibana包同理
-
运行容器
- 部署单点es
docker run -d \ --name es \ -e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \ #配置运行内存大小,由于es对内存消耗比较大,这里配置512MB -e "discovery.type=single-node" #单点配置 -v es-data:/usr/share/elasticsearch/data \ #挂载数据目录 -v es-plugins:/usr/share/elasticsearch/plugins \ #挂载插件目录 --privileged \ #给予逻辑卷(容器内数据卷挂载的位置)访问权 --network es-net \ #加入到之前创建的网络 -p 9200:9200 \ #http端口,供用户访问 -p 9300:9300 \ #es各个节点互联的端口,暂时用不到 elasticsearch:7.12.1
访问9200端口返回下面json信息即为成功

- 部署kibana
docker run -d \ --name kibana \ -e ELASTICSEARCH_HOSTS=http://es:9200 \ --network es-net \ -p 5601:5601 \ kibana:7.12.1 #kibana版本一定要和es保持一致

进入dev tools可以编辑DSL语句并发送给es
这里是查询所有记录(没想到只用了不到100ms,明明自己就是个轻薄本来着,不过虚拟机2g内存已经占用将近80%了)

分词器
默认分词器
es在创建倒排索引时需要对文档分词,搜索时需要对用户输入分词,但默认分词规则对中文处理并不友好
例如用中文测试
POST /_analyze # 对分词结果进行分析
{
"analyzer": "standard", #es默认分词器
"text": "趁现在红利学java,狠狠赚一笔"
}

如果用英文分词器的方法中文汉字只能一个一个分隔
即便使用"chinese"分词也是一样的结果
如果想要分词中文,需要使用第三方分词器
这里使用IK分词器(IKUN分词器)
安装ik分词器插件
这里直接用教程资料离线安装
-
查看插件数据卷所在位置
docker volume inspect es-plugins
-
将资料中的ik分词器文件上传到对应目录

-
重启es
docker restart es查看日志可以看到成功启用ik分词器插件

-
测试
ik分词器包括两种模式
-
ik_smart:最少切分,分词较少,词汇长度较长
占用内存更少,查询效率更高,但被搜索到的概率较低(比如只搜
程序的话搜不到这条文档)
-
ik_max_word:最细切分
相较上一个,会将每个词再细分一遍,比如分出
程序员以外,还分出了程序和员两个词汇占用内存更多,但被搜索到的概率也更高

-
分词器拓展与字典
分词器的局限
分词器是如何进行分词的?我们推测应该是内部有一个字典,如果词汇与字典中的匹配,则分出成为一个词
但这样就必定会存在时效性问题,比如近年来网络热词奥利给就分不出来了
还有分词器会将的这样没有实际意义的介词分出来,空占用存储空间
还有一些敏感词汇过滤等...
这些更细致的需求可以通过拓展实现

高级配置
可以修改一个ik分词器目录中的config/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">ext.dic</entry>
<!-- 用户可以在这里配置自己的停止词字典 *** 添加停用词词典 -->
<entry key="ext_stopwords">stopword.dic</entry>
</properties>
名为ext.dic的拓展词典
传智教育
白嫖
奥利给
stopword.dic
的
了
哦
啊
嘤嘤嘤
*** (敏感词汇)
当然ik分词器还有更多高级用法,比如远程热更新字典等,自行探索吧
索引库制作
mapping映射属性
mapping是对索引库中文档的约束,常见的mapping属性包括:
-
type:字段数据类型
- 字符串:text(可分词文本),keyword(精确值,例如品牌,国家,ip地址等不可拆分的词)
- 数值:long,integer,short,byte,double,float
- 布尔:boolean
- 日期:date
- 对象:object
数组不是单独一个数据类型,es允许多个字段放在一起,也就是数组了
-
index: 事后创建索引,默认为true(如果是url地址之类的没有必要参与搜索,就把index设置为false了)
-
analyzer:使用哪种分词器(只有text类型才会用到)
-
properties:该字段的子字段,附加的属性
索引库crud
创建索引库
PUT /heima # 索引库名称
{
"mappings":{
"properties":{
"info":{ # 字段名1
"type":"text",
"analyzer":"ik_smart"
},
"email":{ # 字段名2
"type":"keyword",
"index":"false" #email作为一个整体没有必要分词,而且也没有必要参与搜索
},
"name":{
"properties":{
"firstName":{ # 子字段
"type":"keyword"
}
}
},
}
}
}
在浏览器实际操作一下

查看删除修改索引库
查看
GET /heima #索引库名
删除
DELETE /heima
修改
索引库和mapping一旦创建就无法修改,因为会破坏原有索引,性能大打折扣,但是可以添加新的字段
PUT /数据库名/_mapping
{
"properties":{
"新字段名":{
"type":"integer"
}
}
}

文档操作
添加文档
POST /heima/_doc/1 #1代表文档id,不指定的话会自动生成
{
"info":"黑马程序员Java讲师", #指定mapping配置好的每个字段的值
"email":"zt@itcast.cn",
"name":{
"firstName":"云",
"lastName":"赵"
}
}

查询文档
GET /heima/_doc/1

其中version=1表示修改次数(是根据id来统计修改次数的,所以就算删除了,version值还是会正常加1)
删除文档
DELETE /heima/_doc/1
修改文档
-
方式1:全量修改,会删除旧的文档,添加新文档
其实和增加写法一样,如果id不存在就是新增了
PUT /heima/_doc/1
- 方式2:局部修改,修改指定字段名
POST /heima/_update/1
{
"doc":{
"email":"zy@itcast.cn"
}
}
RestAPI
什么是RestClient
ES官方提供了各种不同语言的客户端,用来操作es,这些客户端的本质就是组装dsl语句,通过http请求发送给es
我们用的事Java REST Client,可以将java代码转成dsl发送
IDEA连接操作es数据源(可选)

与连接mysql操作方法一样
需要注意es默认用户名和密码分别是elastic和changeme
然后需要将驱动配置成和使用的elastic-search一样的版本(例如这里是7.12.1)
如果没有找到对应版本驱动,需要到maven仓库下载到本地,然后手动在idea导入

如果连接仍然有问题,可以和这个教程仔细比对
在idea连接es(其中安装es插件和设置证书个人感觉是没有必要的,用正常账号密码的方式也能登录,关键是驱动版本要对应的上)
(自己折腾了好久,最后发现没有仔细看教程...)

没错,可以用sql的方式来操作es
不过sql功能貌似并不完善,比如直接双击索引库显示数据会报指令错误,意义不大
案例:利用javaRestClient实现索引库操作
导入环境
在idea打开课程资料的hotel-demo项目,导入sql建表,这里推荐新建一个hotel_demo的数据库

创建索引库
-
分析数据结构
mapping需要考虑的问题:
字段名/数据类型/是否参与搜索/是否分词/如果分词分词器如何选择...
-
编写mapping映射
PUT /hotel { "mappings": { "properties": { "id": { "type": "keyword" # 在es中id都是以字符串形式存储的,因为不参与分词,所以类型是keyword }, "name": { "type": "text", "analyzer": "ik_max_word" } }, "address": { "type": "keyword", #因为一般搜酒店没有直接根据地址去搜的,因此不参与索引 "index":false }, "price": { "type": "integer" }, "score": { "type": "integer" }, "brand": { "type": "keyword" }, "city": { "type": "keyword" }, "starName": { "type": "keyword" }, "business": { "type": "keyword" }, "location":{ "type":"geo_point" #经纬度坐标在es有特殊存法,详见下面 } "pic": { "type": "keyword", "index":false } } } }ℹ️ es中支持两种地理坐标数据类型
- geo_point:由经纬度确定的一个点
- geo_shape,由多个geo_point组成的复杂几何图形
❓es搜索的时候往往需要查询多个字段,性能比较浪费,如何仅通过查找一个字段实现同时查找所有字段?
-> 可以使用字段拷贝,使用copy_to属性将当前字段拷贝到指定字段,示例
"all":{ "type":"text", "analyzer":"ik_max_word" }, "brand":{ "type":"keyword", "copy_to":"all" }这种拷贝并不是真正将内容拷贝进去,而是基于拷贝字段整体创建一个新的倒排索引(可以理解成一个视图)
改造后的mapping映射
PUT /hotel { "mappings": { "properties": { "id": { "type": "keyword" }, "name": { "type": "text", "analyzer": "ik_max_word", "copy_to": "all" }, "address": { "type": "keyword", "index": false }, "price": { "type": "integer" }, "score": { "type": "integer" }, "brand": { "type": "keyword", "copy_to": "all" }, "city": { "type": "keyword" }, "starName": { "type": "keyword" }, "business": { "type": "keyword", "copy_to": "all" }, "location": { "type": "geo_point" }, "pic": { "type": "keyword", "index": false }, "all": { "type": "text", "analyzer": "ik_max_word" } } } }
配置javaRestClient
-
引入依赖
// hotel-demo的pom文件 <!-- elastic search--> <dependency> <groupId>org.elasticsearch.client</groupId> <artifactId>elasticsearch-rest-high-level-client</artifactId> </dependency>❗ 这里引入 javarestClient依赖之后相关依赖版本由spring-boot-starter管理,而这里父工程定义的es版本是
7.6.2而不是我们装在虚拟机上面的7.12.1,因此我们需要在自己的父工程上定义es版本来覆盖spring父工程的配置(即和上面同一个pom文件)<properties> <elasticsearch.version>7.12.1</elasticsearch.version> </properties>
-
功能测试
新建一个测试类
// cn/itcast/hotel/HotelIndexTest.java
@SpringBootTest
public class HotelIndexTest {
private RestHighLevelClient client;
//客户端初始化
@BeforeEach
void setUp(){
this.client = new RestHighLevelClient(RestClient.builder(
//也可以指定多个地址集群使用
HttpHost.create("http://192.168.181.100:9200")
));
}
//测试完成后统一客户端销毁
@AfterEach
void tearDown() throws Exception{
this.client.close();
}
@Test
public void testInit(){
System.out.println(client);
}
}
f
运行测试(记得开启虚拟机里面的es容器)(如果虚拟机挂起后再恢复,无法访问容器,记得看第三天docker部分关于这个问题的解决方案(配置ipv4链接))

利用restclient实现索引库操作
创建索引库

@Test
public void testCreateHotelIndex() throws IOException {
//1.创建索引请求
CreateIndexRequest request = new CreateIndexRequest("hotel");
//2.准备请求参数
request.source(HotelConstants.MAPPING_TEMPLATE, XContentType.JSON);
//3.客户端发送请求
//indices方法返回索引库操作对象,里面包括了索引库的所有操作方法
client.indices().create(request, RequestOptions.DEFAULT);
}
//将DSL封装成一个常量统一存放
//constants.HotelConstants.java
public class HotelConstants {
public static final String MAPPING_TEMPLATE =
"{\n" +
" \"mappings\": {\n" +
" \"properties\": {\n" +
" \"id\": {\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"name\": {\n" +
" \"type\": \"text\",\n" +
" \"analyzer\": \"ik_max_word\"\n" +
" },\n" +
" \"address\": {\n" +
" \"type\": \"keyword\",\n" +
" \"index\": false\n" +
" },\n" +
" \"price\": {\n" +
" \"type\": \"integer\"\n" +
" },\n" +
" \"score\": {\n" +
" \"type\": \"integer\"\n" +
" },\n" +
" \"brand\": {\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"city\": {\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"starName\": {\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"business\": {\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"location\": {\n" +
" \"type\": \"geo_point\"\n" +
" },\n" +
" \"pic\": {\n" +
" \"type\": \"keyword\",\n" +
" \"index\": false\n" +
" }\n" +
" }\n" +
" }\n" +
"}";
}
❗ 上面的createIndexRequest方法导入的是
org.elasticsearch.client.indices.CreateIndexRequest这个类
在kibana上查看创建的索引库,测试成功

删除索引库,判断索引库是否存在
@Test
public void testDeleteHotelIndex() throws IOException {
//1.创建删除索引请求
DeleteIndexRequest request = new DeleteIndexRequest("hotel");
//2.客户端发送请求
client.indices().delete(request, RequestOptions.DEFAULT);
}
@Test
public void testIfExistHotelIndex() throws IOException {
//1.创建删除索引请求
GetIndexRequest request = new GetIndexRequest("hotel");
//2.客户端发送请求
boolean isExists = client.indices().exists(request, RequestOptions.DEFAULT);
System.out.println(isExists);
}
利用restclient实现文档crud
新增酒店数据
由于创建索引库的时候字段名和数据库中的并不完全一致(比如经纬度),因此需要另创建一个实体类用来表示文档对象(可以理解为一个DTO)
初始工程已经提供好了名为HotelDoc的实体类
@Test
public void testIndexDocument() throws IOException {
//准备实体类数据(从数据库获取)
Hotel hotel = hotelService.getById(36934);
//1.创建索引请求
IndexRequest request = new IndexRequest("hotel").id(hotel.getId().toString());
//2.准备Json文档
HotelDoc hotelDoc = new HotelDoc(hotel);//这里是老师自定义的一个构造方法,其中将经纬度转换成location
request.source(JSON.toJSONString(hotelDoc),XContentType.JSON);
//3.客户端发送请求
client.index(request,RequestOptions.DEFAULT);
}

可以看到在es成功获取到了添加的数据

根据id查询酒店数据
@Test
public void testGetDocById() throws IOException {
//1.创建索引请求
GetRequest request = new GetRequest("hotel", "36934");
//2.客户端发送请求
GetResponse response = client.get(request, RequestOptions.DEFAULT);
//3.解析响应
System.out.println(response.getSourceAsString());
}

根据id修改和删除酒店数据
//修改(局部修改)
@Test
public void testUpdatePartlyById() throws IOException {
//局部更新操作
UpdateRequest request = new UpdateRequest("hotel", "36934");
//准备修改的参数
request.doc("price", 1000,
"score", 5);
//客户端发送请求
client.update(request, RequestOptions.DEFAULT);
}
❗注意doc传入键值对都是用逗号隔开的
//删除
@Test
public void testDeleteRequest() throws IOException {
DeleteRequest request = new DeleteRequest("hotel", "36934");
client.delete(request,RequestOptions.DEFAULT);
}
批量导入酒店数据到es
利用javaRestClient的bulk批处理可以实现批量新增文档,也即将多个request合并成一个request请求一次性提交
@Test
public void testBulkRequest(){
//从数据库批量查询酒店数据
List<Hotel> hotels = hotelService.list();
//批量操作请求
BulkRequest request = new BulkRequest();
//利用lambda表达式实现批量转doc对象并添加到request中
hotels.stream().map(HotelDoc::new).forEach(hotelDoc -> {
request.add(new IndexRequest("hotel").id(hotelDoc.getId().toString()).source(JSON.toJSONString(hotelDoc),XContentType.JSON));
});
//客户端发送请求
try {
client.bulk(request, RequestOptions.DEFAULT);
} catch (IOException e) {
e.printStackTrace();
}
}
可以看到201条数据都被添加进去了

Day06 2024.02.27 分布式搜索引擎
DSL查询文档
es提供了基于json的dsl来定义查询,常用类型包括
| 类型 | 作用 | 示例 |
|---|---|---|
| 查询所有 | 查询出所有数据 | match_all |
| 全文检索 | 利用分词器对用户输入内容粉刺,倒排索引匹配 | match_query/multi_match_query |
| 精确查询 | 根据词条值查找数据,一般是查找keyword,数值,日期等不需要分词字段 | ids,range,term |
| 地理查询 | 根据经纬度查询 | geo_distance/geo_bounding_box |
| 符合查询 | 将上述查询条件合并组合 | bool/function_score |
例如match_all查询
GET /hotel/_search
{
"query": {
"match_all": {}
}
}
全文检索查询
-
match查询,填写要查询的字段和匹配的内容
GET /hotel/_search { "query": { "match": { "city": "上海" } } }老师演示的all字段是之前有一次提到过的copy_to方法,不过自己可以确定老师当时创建索引库的时候并没有设置all字段,因此如果没有设置,可以自行删除索引库再重新构建和导入
-
multi_match,可以匹配多个字段
GET /hotel/_search { "query": { "multi_match": { "query": "外滩如家", "fields": ["brand","name","business"] } } }由于多个字段查询相对一个字段查询效率较低,因此推荐将所有字段拷贝到一个all字段上统一查询
精确查询
-
term:根据词条精确查询
因为查询的是keyword,因此不会对输入的内容进行分词
GET /hotel/_search { "query": { "term": { "city": { "value": "上海" } } } } -
range:根据值的范围查询
GET /hotel/_search { "query": { "range": { "price": { "gte": 1000, "lte": 2000 } } } } -
地理查询
按照范围查询
GET /hotel/_search { "query": { "geo_distance":{ "distance":"15km", "location":"31.21,121.5" } } }
复合查询
算分函数查询
function score 可以控制文档相关性算分,控制文档排名,例如百度竞价
- 相关性算分
当我们利用match查询时,文档会根据搜索词条的关联度打分(_score),返回结果时按照分值降序排列
通过BM25计算分数可以保证词频增加的时候分数不会无限增大,而是趋于稳定

- function score query
使用这种查询可以人为修改相关性算分,得到新的算分排序,记得将索引库改成带有all字段的版本
GET /hotel/_search
{
"query": {
"function_score": {
"query": {"match": {
"all": "外滩"
}},
"functions": [
{
"filter": {"term":{"id":"1"}},
"weight":10
}
],
"boost_mode": "multiply"
}
}
}

例如给如家品牌酒店排名靠前一些
可以考虑function score的三要素
- 需要加权的文档:品牌 = 如家的
- 算分函数:简单的weight即可
- 加权模式:求和即可
先来看看正常排名
GET /hotel/_search
{
"query": {
"match": {
"all": "上海外滩"
}
}
}

然后来看看加权后的排名
GET /hotel/_search
{
"query":{
"function_score": {
"query": {
"match": {
"all": "上海外滩"
}
},
"functions": [
{
"filter": {
"term": {
"brand": "如家"
}
}
, "weight": 10
}
],
"boost_mode": "sum"
}
}
}
可以看到原来排名第一的君悦酒店已经排到第三了

布尔查询
布尔查询是一个或多个查询子句的组合
| 语句 | 作用 |
|---|---|
| must | 必须匹配每个子查询 |
| should | 匹配其中一个 |
| must_not | 每一个都不匹配,不参与算分 |
| filter | 必须匹配,不参与算分 |
🔔因为must_not和filter不参与算分,因此查询效率会有所提升,因此除了需要进行分词匹配算分排序的字段必须用must和should以外,其余关键字字段都应该使用不参与算分的语句
需求:搜索名字包含"如家",价格不高于400,在坐标31.21,121.5周围10km范围内的酒店
GET /hotel/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"name": "如家"
}
}
],
"must_not": [
{
"range": {
"price": {
"gt": 400
}
}
}
],
"filter": [
{
"geo_distance": {
"distance": "10km",
"location": "31.21,121.5"
}
}
]
}
}
}
可以看到返回了三个结果

搜索结果处理
排序
es默认按照相关度算分(_score)来排序,可以根据各种类型来排序
案例:对酒店数据按照用户评价降序排序,评价相同按照价格升序,如果价格相同按照距离自己位置远近排序
#sort排序
GET /hotel/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"score": {
"order": "desc"
},
"price": {
"order": "asc"
},
"_geo_distance":{
"location":{
"lat":31,
"lon":121
},
örder:"asc",
"unit":"km"
}
}
]
}
当使用排序之后,查询显示的
_score项就会为null,因为不再需要相对算分来排序词条相关度了,只根据_sort得分来排序就可以了
分页
es默认情况下只返回top10的数据,如果要查询更多参数需要修改分页参数
#分页查询
GET /hotel/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"price": "asc"
}
],
"from":0,
"size":10
}
由于es底层采用倒排索引,因此并不能像mysql一样方便分页,如果要分页查询比如第500到1000条数据,只能先查询1000条数据再从中截取
深度分页问题:es是分布式idea,例如按price排序获取从990到1000的数据,es由于集群部署,并不知道总排序前1000条是多少,只能从每台es上分别获取前1000条数据,然后整合在一起选出前1000条数据

如果搜索的页数过深,或者结果集(from+size)过大,对内存和CPU的消耗也越高,因此es设定结果集查询的上限是10000
而比如知名搜索引擎在业务上就对查询结果数量做了限制,比如百度限制用户一次查询最多76页数据
深度分页的解决方案
针对深度分页,es提供了两种解决方案
- search after : 分页的时候需要排序,原理是从上一次排序值开始,查询下一页数据,官方推荐使用,比如根据价格排序,先查出前十条,再查第二页的时候会记住第十条数据的价格值,作为筛选条件查出这个价格之后的另10条数据,但是只能向后翻页,不支持随机翻页
- scroll,原理是将排序数据全存在内存里面形成快照,分页的时候从内存里面取,内存占用大,而且如果数据更新的话还要同步更新快照,官方不推荐使用

高亮
就是把搜索中的关键字突出出来
从源码中可以看到高亮的java都用em标签包裹起来,藉由前端的em类型选择器来实现样式的高亮,也即
- 将搜索结果中的关键字用标签标记出来
- 再页面中给标签添加css样式

案例:搜索与如家有关的酒店,然后将名字中的部分高亮
默认es搜索字段和高亮字段必须保持一致,而我们这里用的是all字段搜索,因此可以指定require_field_match属性为false来单独高亮某一个字段

RestClient查询文档
快速入门
@Test
public void testMatchAll() throws IOException {
//创建查询请求
SearchRequest request = new SearchRequest("hotel");
//准备请求DSL
request.source().query(QueryBuilders.matchAllQuery());
//客户端发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
System.out.println(response);
//解析结果
SearchHits searchHits = response.getHits();
//查询总条数
long totalHits = searchHits.getTotalHits().value;
//查询结果数组
SearchHit[] hits = searchHits.getHits();
for (SearchHit hit : hits) {
System.out.println(hit.getSourceAsString());
HotelDoc hotelDoc = JSON.parseObject(hit.getSourceAsString(), HotelDoc.class);
System.out.println(hotelDoc);
}
}
❗ 注意query传入的参数要用
org.elasticsearch.index.query.QueryBuilders这个构造器
打印出来的response内容就是返回的json数据,通过json解析工具即可处理
match查询
全文检索查询
全文检索match和multi_match查询的api基本一致,差别是查询条件,也就是query部分
只需要在上述代码基础上做出下面调整即可
//准备请求DSL
request.source().query(QueryBuilders.matchQuery("all","如家"));
可以使用ctrl+alt+m抽取解析reponse部分的代码
精确查询
QueryBuilers.termQuery("city","杭州")
复合查询
QueryBuilders.rangeQuery("price".gte(100).lte(150)
排序分页高亮
@Test
public void testPageAndSort() throws IOException {
//创建查询请求
SearchRequest request = new SearchRequest("hotel");
//准备请求DSL
request.source().query(QueryBuilders.matchAllQuery());
//分页
request.source().from(0).size(2);
//排序
request.source().sort("price", SortOrder.ASC);
//客户端发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
System.out.println(response);
handleResponse(response);
}
其中因为高亮后的结果被单独存放在了"highlight"字段上,因此不能使用原来的结果解析方法

因此按照如下改造handleResponse代码
private static void handleResponse(SearchResponse response) {
//解析结果
SearchHits searchHits = response.getHits();
//查询总条数
long totalHits = searchHits.getTotalHits().value;
//查询结果数组
SearchHit[] hits = searchHits.getHits();
for (SearchHit hit : hits) {
System.out.println(hit.getSourceAsString());
HotelDoc hotelDoc = JSON.parseObject(hit.getSourceAsString(), HotelDoc.class);
//获取高亮字段
Map<String, HighlightField> highlightFields = hit.getHighlightFields();
if (!CollectionUtils.isEmpty(highlightFields)){
HighlightField highlightField = highlightFields.get("name");
if (highlightField != null) {
String name = highlightField.getFragments()[0].string();
hotelDoc.setName(name);
}
}
}
}
黑马旅游案例
实现酒店搜索功能呢,完成关键字搜索和分页
- 定义实体类,接受前端请求
//pojo.RequestParams.java
@Data
public class RequestParams {
private String key;
private Integer page;
private Integer size;
private String sortBy;
}
-
定义controller接口,接受页面请求,调用IHotelService的search方法
-
请求方式:post
-
请求路径/hotel/list
-
请求参数:对象,类型为RequestParam
-
返回值:PageResult,包含总条数和酒店数据List
//定义pageResult类存储分页数据 @Data public class PageResult { private Long total; private List<HotelDoc> hotels; }//在启动类上注入restclient //配置es客户端 @Bean public RestHighLevelClient restHighLevelClient(){ return new RestHighLevelClient(RestClient.builder( HttpHost.create("http://fishlulu01:9200") )); }//web.HotelController @RestController @RequestMapping("/hotel") public class HotelController { @Autowired private IHotelService hotelService; @PostMapping("/list") public PageResult list(@RequestBody RequestParams requestParams){ //调用service查询 return hotelService.search(requestParams); } }//IHotelService public interface IHotelService extends IService<Hotel> { PageResult search(RequestParams requestParams); }//HotelService(实现类) @Service public class HotelService extends ServiceImpl<HotelMapper, Hotel> implements IHotelService { @Autowired private RestHighLevelClient client; /** * 通过ES查询酒店数据 * @param requestParams * @return */ @Override public PageResult search(RequestParams requestParams){ try { //创建查询请求 SearchRequest request = new SearchRequest("hotel"); //准备请求DSL //全文关键字检索 String key = requestParams.getKey(); if (key.isEmpty()) { request.source().query(QueryBuilders.matchAllQuery()); } else { request.source().query(QueryBuilders.matchQuery("all", key)); } //分页 int page = requestParams.getPage(); int size = requestParams.getSize(); request.source().from((page - 1) * size).size(size); //客户端发送请求 SearchResponse response = client.search(request, RequestOptions.DEFAULT); //解析相应 return handleResponse(response); } catch (IOException e) { throw new RuntimeException(e); } } private static PageResult handleResponse(SearchResponse response) { //解析结果 SearchHits searchHits = response.getHits(); //查询总条数 long totalHits = searchHits.getTotalHits().value; //将查询结果封装到PageResult return PageResult.builder().total(totalHits).hotels(Arrays.stream(searchHits.getHits()).map( hit ->{ //将查询结果转换为HotelDoc对象 return JSON.parseObject(hit.getSourceAsString(), HotelDoc.class); } ).collect(Collectors.toList())).build(); } }测试成功

-
-
定义IhotelService中的search方法,利用match查询实现根据关键字搜索酒店信息
添加品牌,城市,星级,价格等过滤功能
-
修改RequestParams类,添加brand,city,starName,minPrice,maxPrice等参数
@Data public class RequestParams { private String key; private Integer page; private Integer size; private String sortBy; private String brand; private String city; private String starName; private Integer maxPrice; private Integer minPrice; } -
修改search方法实现,在关键字搜索时,如果brand等参数存在,进行过滤
多个过滤条件是and关系,因此要用booleanQuery
参数存在才能过滤,做好非空判断
@Service public class HotelService extends ServiceImpl<HotelMapper, Hotel> implements IHotelService { @Autowired private RestHighLevelClient client; /** * 通过ES查询酒店数据 * @param requestParams * @return */ @Override public PageResult search(RequestParams requestParams){ try { //创建查询请求 SearchRequest request = new SearchRequest("hotel"); //准备请求DSL BoolQueryBuilder boolQuery = buildBasicQuery(requestParams); request.source().query(boolQuery); //分页 int page = requestParams.getPage(); int size = requestParams.getSize(); request.source().from((page - 1) * size).size(size); //客户端发送请求 SearchResponse response = client.search(request, RequestOptions.DEFAULT); //解析相应 return handleResponse(response); } catch (IOException e) { throw new RuntimeException(e); } } private static BoolQueryBuilder buildBasicQuery(RequestParams requestParams) { //构建查询条件 BoolQueryBuilder boolQuery = QueryBuilders.boolQuery(); //关键字检索(must部分) String key = requestParams.getKey(); if (StringUtils.isEmpty(key)) { boolQuery.must(QueryBuilders.matchAllQuery()); } else { boolQuery.must(QueryBuilders.matchQuery("all", key)); } //过滤条件(filter部分) //城市 String city = requestParams.getCity(); if (!StringUtils.isEmpty(city)) { boolQuery.filter(QueryBuilders.termQuery("city", city)); } //品牌 String brand = requestParams.getBrand(); if (!StringUtils.isEmpty(brand)) { boolQuery.filter(QueryBuilders.termQuery("brand", brand)); } //星级 String star = requestParams.getStarName(); if (!StringUtils.isEmpty(star)) { boolQuery.filter(QueryBuilders.termQuery("starName", star)); } //价格 Integer minPrice = requestParams.getMinPrice(); Integer maxPrice = requestParams.getMaxPrice(); if(minPrice != null && maxPrice != null) { boolQuery.filter(QueryBuilders.rangeQuery("price").gte(minPrice).lte(maxPrice)); } return boolQuery; } private static PageResult handleResponse(SearchResponse response) { //解析结果 SearchHits searchHits = response.getHits(); //查询总条数 long totalHits = searchHits.getTotalHits().value; //将查询结果封装到PageResult return PageResult.builder().total(totalHits).hotels(Arrays.stream(searchHits.getHits()).map( hit ->{ //将查询结果转换为HotelDoc对象 return JSON.parseObject(hit.getSourceAsString(), HotelDoc.class); } ).collect(Collectors.toList())).build(); } }🔔老师前端在过滤四星和五星酒店的时候返回为空,是因为初始工程传入的数据是
五星级和四星级,而前端在发送请求的时候筛选的是四星和五星,应该是老师那边的疏忽,这里就不用在意了
我附近的酒店

-
requestParam对象添加字段
private String location; -
在上述分页功能代码下面添加如下代码
//添加地理排序 String location = requestParams.getLocation(); if (!StringUtils.isEmpty(location)) { request.source().sort(SortBuilders.geoDistanceSort("location",new GeoPoint(location)).order(SortOrder.ASC).unit( DistanceUnit.KILOMETERS)); }
可以看到前端的确传过来坐标数据了
不过奇怪的是,显示离我最近的酒店竟然在深圳(明明上海离北京比深圳更近的说),而且地图也没显示出我的位置,这个问题先放在一边

-
解析结果返回距离数据
按照如下改造handleResponse方法
private static PageResult handleResponse(SearchResponse response) { //解析结果 SearchHits searchHits = response.getHits(); //查询总条数 long totalHits = searchHits.getTotalHits().value; //将查询结果封装到PageResult return PageResult.builder().total(totalHits).hotels(Arrays.stream(searchHits.getHits()).map( hit ->{ //将查询结果反序列化为HotelDoc对象 HotelDoc hotelDoc = JSON.parseObject(hit.getSourceAsString(), HotelDoc.class); //获取排序值 Object[] sortValues = hit.getSortValues(); if (sortValues != null && sortValues.length > 0) { hotelDoc.setDistance(sortValues[0]); } return hotelDoc; } ).collect(Collectors.toList())).build(); }相应的,在hotelDoc对象添加字段
private Object distance;如果调试的时候报错空指针异常
无法调用hotel.getId(),因为hotel为null,记得在hoteldoc对象上添加@AllArgsConstructor注解,因为不添加这个注解的话解析json的时候默认用了自定义的构造器(里面需要传入hotel对象,显然为空)看到成功显示了距离

不过我明明在北京,为什么显示距离4000多公里啊,这缺德地图也太离谱了吧!
让指定酒店在搜索结果中排名置顶
可以给置顶的酒店添加标记,然后利用function score给带有标记的文档增加权重
-
hoteldoc添加标记字段
private boolean isAD; -
挑选几个酒店设置字段为true
POST /hotel/_update/1714520967 { "doc": { "isAD":true } } POST /hotel/_update/396506 { "doc": { "isAD":true } } POST /hotel/_update/2031683181 { "doc": { "isAD":true } } POST /hotel/_update/396471 { "doc": { "isAD":true } } -
修改search方法,添加function score功能,给标记的酒店添加权重
java代码示例

业务代码
@Service
public class HotelService extends ServiceImpl<HotelMapper, Hotel> implements IHotelService {
@Autowired
private RestHighLevelClient client;
/**
* 通过ES查询酒店数据
* @param requestParams
* @return
*/
@Override
public PageResult search(RequestParams requestParams){
try {
//创建查询请求
SearchRequest request = new SearchRequest("hotel");
//准备请求DSL
buildBasicQuery(request,requestParams);
//分页
int page = requestParams.getPage();
int size = requestParams.getSize();
request.source().from((page - 1) * size).size(size);
//添加地理排序
String location = requestParams.getLocation();
if (!StringUtils.isEmpty(location))
{
request.source().sort(SortBuilders.geoDistanceSort("location",new GeoPoint(location)).order(SortOrder.ASC).unit(
DistanceUnit.KILOMETERS));
}
//客户端发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//解析相应
return handleResponse(response);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private static void buildBasicQuery(SearchRequest request,RequestParams requestParams) {
//构建查询条件
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
//关键字检索(must部分)
String key = requestParams.getKey();
if (StringUtils.isEmpty(key))
{
boolQuery.must(QueryBuilders.matchAllQuery());
}
else
{
boolQuery.must(QueryBuilders.matchQuery("all", key));
}
//过滤条件(filter部分)
//城市
String city = requestParams.getCity();
if (!StringUtils.isEmpty(city))
{
boolQuery.filter(QueryBuilders.termQuery("city", city));
}
//品牌
String brand = requestParams.getBrand();
if (!StringUtils.isEmpty(brand))
{
boolQuery.filter(QueryBuilders.termQuery("brand", brand));
}
//星级
String star = requestParams.getStarName();
if (!StringUtils.isEmpty(star))
{
boolQuery.filter(QueryBuilders.termQuery("starName", star));
}
//价格
Integer minPrice = requestParams.getMinPrice();
Integer maxPrice = requestParams.getMaxPrice();
if(minPrice != null && maxPrice != null)
{
boolQuery.filter(QueryBuilders.rangeQuery("price").gte(minPrice).lte(maxPrice));
}
//算分控制
//如果有isAD广告标记,算分加权
FunctionScoreQueryBuilder functionScoreQuery = QueryBuilders.functionScoreQuery(
//原始查询
boolQuery,
//function score查询数组
new FilterFunctionBuilder[]{
//一个算分函数,包括过滤条件和算分加权
new FunctionScoreQueryBuilder.FilterFunctionBuilder(QueryBuilders.termQuery("isAD", true),
ScoreFunctionBuilders.weightFactorFunction(10))
});
request.source().query(functionScoreQuery);
}
private static PageResult handleResponse(SearchResponse response) {
//解析结果
SearchHits searchHits = response.getHits();
//查询总条数
long totalHits = searchHits.getTotalHits().value;
if (totalHits == 0)
{
return PageResult.builder().total(0L).build();
}
//将查询结果封装到PageResult
return PageResult.builder().total(totalHits).hotels(Arrays.stream(searchHits.getHits()).map(
hit ->{
//将查询结果反序列化为HotelDoc对象
HotelDoc hotelDoc = JSON.parseObject(hit.getSourceAsString(), HotelDoc.class);
//获取排序值
Object[] sortValues = hit.getSortValues();
if (sortValues != null && sortValues.length > 0)
{
hotelDoc.setDistance(sortValues[0]);
}
return hotelDoc;
}
).collect(Collectors.toList())).build();
}
}
这里将构造查询的函数返回类型修改重构了,推荐将代码重新复制粘贴一下

测试结果被广告标记的酒店的确置顶了,均分都在40多,而且返回ad字段为true,前端没有显示广告字眼是前端代码问题,不用担心
Day07 2024.03.01 深入es
数据聚合
📑
聚合aggregations可以实现对文档数据的统计,分析,运算,类似mysql中求和,计数等聚合函数,聚合常见有三类
桶(bucket):用来对文档进行分组
- TermAggregation:按照文档字段值来分组
- Date Histogram:文昭日期阶梯分组,例如一周为一组或一月为一组
度量(Metric)聚合:用来计算一些值,如平均值,最大最小值,stats(同时求上述三个值)
管道(pipeline)聚合:其他聚合结果基础上再作聚合,例如在对酒店进行分组后分别求每个组评分最大值
DSL实现聚合
实现桶聚合

GET /hotel/_search
{
"size": 0,
"aggs": {
"brandAgg": {
"terms": {
"field": "brand",
"size": 20
}
}
}
}
可以看到aggregations标签下显示了聚合分组情况

可以指定order,来根据每个组的数据个数来排序,并且添加查询语句来限定聚合的范围
GET /hotel/_search
{
"query":{
"range":{
"price":{
"lte":200
}
}
}
"size": 0,
"aggs": {
"brandAgg": {
"terms": {
"field": "brand",
"size": 20,
"order":{
"_count":"asc"
}
}
}
}
}
实现度量聚合
可以和上面的桶聚合实现嵌套聚合
# 嵌套聚合matric
GET /hotel/_search
{
"size":0,
"aggs":{
"brandAgg":{
"terms": {
"field": "brand",
"size": 20,
"order": {
"scoreAgg.avg": "desc"
}
},
"aggs": {
"scoreAgg": {
"stats": {
"field": "score"
}
}
}
}
}
}
上述查询表示对数据按照品牌分组,计算各组的评分最值和平均值,并按照计算的各组平均分对组间进行排序

RestAPI实现聚合
根据json的格式来构造java代码,很好理解

@Test
public void testAggregation() throws IOException {
//准备request
SearchRequest request = new SearchRequest("hotel");
//准备DSL
request.source().size(0)
.aggregation(AggregationBuilders.terms("brandAgg")
.field("brand")
.size(10));
//发出请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//解析聚合结果
Aggregation brandAgg = response.getAggregations().get("brandAgg");//获取指定名称的聚合
List<? extends Terms.Bucket> buckets = ((Terms)brandAgg).getBuckets();
//遍历buckets
for (Terms.Bucket bucket : buckets) {
//获取key(品牌名称)
String key = bucket.getKeyAsString();
System.out.println(key);
}
}
案例:在HotelService定义方法,实现对品牌,城市,星级的聚合
实现多条件聚合
在搜索页面的品牌城市等信息不应该是在页面上写死,而是通过聚合索引库中的酒店数据得到

//在IHotelService定义方法,实现对品牌,城市,星级的聚合,方法声明如下
/**
* 查询城市,星级,品牌的聚合结果
* return 聚合结果,格式:{"城市":["北京","上海"],"星级":["五星级","四星级"],"品牌":["如家","汉庭"]}
*/
Map<String, List<String>> filters();
//实现类
/**
* 查询城市,星级,品牌的聚合结果 return 聚合结果,格式:{"城市":["北京","上海"],"星级":["五星级","四星级"],"品牌":["如家","汉庭"]}
*/
@Override
public Map<String, List<String>> filters() {
Map<String, List<String>> resultMap = new HashMap<>();
try {
//准备request
SearchRequest request = new SearchRequest("hotel");
buildAggregation(request);
//发出请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//解析聚合结果
response.getAggregations().asMap().forEach((k,v) -> {
Terms terms = (Terms) v;
List<String> list = terms.getBuckets().stream().map(Bucket::getKeyAsString).collect(Collectors.toList());
resultMap.put(HotelConstants.AGG_FIELDS_MAP.get(k), list);
});
} catch (IOException e) {
throw new RuntimeException(e);
}
return resultMap;
}
private static void buildAggregation(SearchRequest request) {
//准备DSL,根据城市,星级,品牌进行聚合
request.source().size(0)
.aggregation(AggregationBuilders.terms("cityAgg").field("city").size(10))
.aggregation(AggregationBuilders.terms("starAgg").field("starName").size(10))
.aggregation(AggregationBuilders.terms("brandAgg").field("brand").size(10));
}
这里我采用了lambda流式编程,代码相较老师的应该是简洁了不少,不过需要通过额外的一个map来根据聚合名称获取显示的字段名,这里配置在了HotelConstants中
/** * 聚合查找时,每个聚合搜索分组对应的字段,展示给前端 */ public static final Map<String,String> AGG_FIELDS_MAP = new HashMap<String,String>(){{ put("cityAgg","城市"); put("starAgg","星级"); put("brandAgg","品牌"); }};
显示成功

对聚合进行过滤限制
前端在发起fliters请求的时候,可以看到和之前的list传递的参数都是RequestParams,这是因为要对聚合搜索进行范围限制
例如用户搜索"外滩",价格区间在300-600,则聚合必须要在这一限定条件执行(毕竟如果不限制聚合结果范围的话会出现按条件查询时并根据过滤器过滤,发现有许多查询结果为空白的情况)
- 在controller编写接口
/**
* 获取用于展示在前端的过滤条件
* @param requestParams
* @return
*/
@PostMapping("/filters")
public Map<String, List<String>> filters(@RequestBody RequestParams requestParams){
return hotelService.filters(requestParams);
}
- 修改IUserService的filters()方法,添加根据条件查询
非常简单,只需要用原来写好的buildBasicQuery对request进行改造即可
/**
* 查询城市,星级,品牌的聚合结果 return 聚合结果,格式:{"城市":["北京","上海"],"星级":["五星级","四星级"],"品牌":["如家","汉庭"]}
*/
@Override
public Map<String, List<String>> filters(RequestParams requestParams){
Map<String, List<String>> resultMap = new HashMap<>();
try {
//准备request
SearchRequest request = new SearchRequest("hotel");
//查询条件
buildBasicQuery(request,requestParams);
//设置size
request.source().size(0);
//设置聚合
buildAggregation(request);
//发出请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//解析聚合结果
response.getAggregations().asMap().forEach((k,v) -> {
Terms terms = (Terms) v;
List<String> list = terms.getBuckets().stream().map(Bucket::getKeyAsString).collect(Collectors.toList());
resultMap.put(HotelConstants.AGG_FIELDS_MAP.get(k), list);
});
} catch (IOException e) {
throw new RuntimeException(e);
}
return resultMap;
}
private static void buildAggregation(SearchRequest request) {
//准备DSL,根据城市,星级,品牌进行聚合
request.source()
.aggregation(AggregationBuilders.terms("cityAgg").field("city").size(10))
.aggregation(AggregationBuilders.terms("starAgg").field("starName").size(10))
.aggregation(AggregationBuilders.terms("brandAgg").field("brand").size(10));
}
这里后来觉得buildAggregation方法里面设置size参数不太妥当,因此搬到了外面
- 功能测试

可以看到标签名称没有显示正常,经过视频弹幕提醒,把聚合结果返回的map中key值从中文改成英文即可,我这里直接修改常量类就行了,非常方便
//HotelConstants.java
/**
* 聚合查找时,每个聚合搜索分组对应的字段,展示给前端
*/
public static final Map<String,String> AGG_FIELDS_MAP = new HashMap<String,String>(){{
put("cityAgg","city");
put("starAgg","starName");
put("brandAgg","brand");
}};
ok,这回正常了

并且在进行条件检索的时候过滤器只给出限定范围内的选项

自动补全
安装拼音分词器
要实现子啊搜索框仅输入字母就能根据拼音提示到对应数据,需要对文档按照拼音进行分词,在github上有es拼音分词插件
github仓库地址,注意一定要下载和自己es版本一致的压缩包,这里下载releasev7.12.1版本压缩包
将解压好的文件夹放在es容器在虚拟机上挂载的对应插件目录下面,一般这里设置的是/var/lib/docker/volumes/es-plugins/_data这个位置,可以通过docker inspect es来查看数据卷具体挂载情况
然后重启es容器加载插件
测试拼音分词器功能成功
POST /_analyze
{
"text": ["如家酒店还不错"],
"analyzer": "pinyin"
}

自定义分词器
配置自定义分词器
es中分词器包含是那个部分
- character filters:在tokenizer之前对文本进行处理,,例如删除字符,替换字符
- tokneizer:将文本按照一定的规则切割成词条,例如keyword就是部分词,还有ik_smart等
- tokenizer-filter:将tokenizer输出的词条做进一步处理,例如大小写转换,同义词处理,拼音处理等等

因此我们可以在创建索引库的时候通过settings来配置自定义的分词器
#创建测试库,设置自定义分词器,由tokenizer:ik将文本分词,并由filter:py(自定义的拼音分词器)将词条转换为拼音
PUT /test
{
"settings": {
"analysis": {
"analyzer": {
"my_analyzer":{
"tokenizer":"ik_max_word",
"filter":"py"
}
},
"filter": {
"py":{
"type":"pinyin",
"keep_full_pinyin":false,
"keep_joined_full_pinyin":true,
"keep_original":true,
"limit_first_letter_length":16,
"remove_duplicated_term":true,
"none_chinese_pinyin_tokenize":false
}
}
}
}
}
传入两个测试数据,拼音相同,但汉字含义不同
POST /test/_doc/1
{
"id":1,
"title":"狮子"
}
POST /test/_doc/2
{
"id":2,
"title":"虱子"
}
此时如果搜索有关狮子的内容,会发现狮子和虱子都被搜索出来了,因为自定义的分词器将汉语分词后全部转成拼音来存储,并不会区分这两个词语
(注意如果直接用拼音搜的的话可能会没有结果,个人猜测因为自定义分词器先是进行汉字分词处理,破坏了原有的拼音结构)

由上述可知,拼音分词器适合在创建倒排索引的时候使用,但不能再搜索的时候使用

因此创建倒排索引的时候应该用上述的my_analyzer分词器,而咋已进行字段搜索时应该使用ik_smart分词器
#重新创建测试库,用之前的拼音分词器来创建倒排索引,用ik分词器来进行文本检索
PUT /test
{
"settings": {
"analysis": {
"analyzer": {
"my_analyzer":{
"tokenizer":"ik_max_word",
"filter":"py"
}
},
"filter": {
"py":{
"type":"pinyin",
"keep_full_pinyin":false,
"keep_joined_full_pinyin":true,
"keep_original":true,
"limit_first_letter_length":16,
"remove_duplicated_term":true,
"none_chinese_pinyin_tokenize":false
}
}
}
},
"mappings": {
"properties": {
"name":{
"type":"text",
"analyzer": "my_analyzer",
"search_analyzer": "ik_smart"
}
}
}
}
可以看到这次确实只返回了狮子

总结

自动补全查询
completion suggester查询
es提供了completion suggester查询实现自动补全功能,这个查询会匹配以用户输入内容揩油的词条并返回,为了提高补全查询的效率,对于文档中的字段类型有一些约束
- 参与补全查询的字段必须是
completion类型 - 字段的内容一般是用来补全的多个词条形成的数组

查询语法如下

#用于测试自动补全的测试库
PUT /test2
{
"mappings": {
"properties":{
"title":{
"type":"completion"
}
}
}
}
#插入示例数据
POST /test2/_doc
{
"title":["Sony","WH-1000XM3"]
}
POST /test2/_doc
{
"title":["SK-II","PITERA"]
}
POST /test2/_doc
{
"title":["Nintendo","switch"]
}
#自动补全查询
GET /test2/_search
{
"suggest": {
"titleSuggest": {
"text": "s",
"completion":{
"field":"title",
"skip_duplicates":true,
"size":10
}
}
}
}
成功返回自动补全的text

实现酒店搜索框自动补全和拼音搜索功能
修改hotel库设置自定义分词器
将原来的hotel库删掉,重新创建
// 酒店数据索引库
PUT /hotel
{
"settings": {
"analysis": {
"analyzer": {
//定义倒排索引采用的自定义分词器
"text_anlyzer": {
"tokenizer": "ik_max_word",
"filter": "py"
},
//定义自动补全采用的分词器,之所以tokenizer采用keyword不分词,是因为completion字段本身就是分好的词条组成的数组,只需要之后进行拼音分词即可
"completion_analyzer": {
"tokenizer": "keyword",
"filter": "py"
}
},
//自定义拼音分词器,对分好的词条进一步处理
"filter": {
"py": {
"type": "pinyin",
"keep_full_pinyin": false,
"keep_joined_full_pinyin": true,
"keep_original": true,
"limit_first_letter_length": 16,
"remove_duplicated_term": true,
"none_chinese_pinyin_tokenize": false
}
}
}
},
"mappings": {
"properties": {
"id":{
"type": "keyword"
},
"name":{
"type": "text",
//进行倒排索引采用自定义拼音分词器
"analyzer": "text_anlyzer",
//全文检索采用传统中文分词器
"search_analyzer": "ik_smart",
"copy_to": "all"
},
"address":{
"type": "keyword",
"index": false
},
"price":{
"type": "integer"
},
"score":{
"type": "integer"
},
"brand":{
"type": "keyword",
"copy_to": "all"
},
"city":{
"type": "keyword"
},
"starName":{
"type": "keyword"
},
"business":{
"type": "keyword",
"copy_to": "all"
},
"location":{
"type": "geo_point"
},
"pic":{
"type": "keyword",
"index": false
},
"all":{
"type": "text",
"analyzer": "text_anlyzer",
"search_analyzer": "ik_smart"
},
//自动补全字段
"suggestion":{
"type": "completion",
"analyzer": "completion_analyzer"
}
}
}
}
新增hotelDoc字段
private List<String> suggestion; //用来进行搜索提示自动补全的字段
在hotel自定义的构造函数上设置suggestion字段的值,只需要用原有的字段构造即可
this.suggestion = Arrays.asList(this.brand,this.business);
重新运行单元测试中的导入全部数据testBulkRequest测试方法
成功导入数据,并生成了对应的suggestion字段

不过上述存在一个细节问题,有的酒店business字段含有两个以上的地点,如江湾/五角场商业区,可以在上述hoteldoc构造函数中根据/将地点进行分割之后存入suggestion数组
//对this.suggestion初始化进行进一步改进
if (this.business.contains("/") || this.business.contains("、")){
this.suggestion = new ArrayList<>();
this.suggestion.add(this.brand);
Collections.addAll(this.suggestion, this.business.split("[/、]"));
}else {
this.suggestion = Arrays.asList(this.brand, this.business);
}
❗ 由于实际数据中包含用
、和/进行分割的,因此将两者都添加上,split方法采用了正则语法进行匹配
🔔感觉有时候不能过于追求代码的简洁和优雅,最重要的是可读性和性能,自己想要用lambda函数的方法用一两行代码解决对suggestion的初始化,结果性能慢的要命,还费老大劲去想要怎么写
简单单元测试发现确实成功分割了

然后将数据重新导入一遍(不必删库,重新运行下批量添加的测试方法就行)
按照拼音搜索测试成功

RestAPI实现自动补全
就是按照json格式来一步步构建,很好理解

@Test
public void testSuggest() throws IOException {
//创建请求
SearchRequest request = new SearchRequest("hotel");
//准备DSL
request.source().suggest(new SuggestBuilder().addSuggestion(
"suggestions",
SuggestBuilders.completionSuggestion("suggestion")
.prefix("sh")
.skipDuplicates(true)
.size(10)
));
//发起请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//解析结果
response.getSuggest().getSuggestion("suggestions") // 获取指定名称的补全查询结果(上面在查询的时候指定了名称)
.getEntries().get(0).getOptions() // 获取补全结果
.forEach(option -> System.out.println(option.getText().string()));
}

成功获取了sh开头的所有suggestions

实现酒店搜索页面输入框的自动补全
//Controller
/**
* 实现搜索提示自动补全
* @param prefix
* @return
*/
@GetMapping("/suggestion")
public List<String> suggestion(@RequestParam("key") String prefix){
return hotelService.getSuggestion(prefix);
}
//ServiceImpl
/**
* 实现搜索提示自动补全
*
* @param prefix
* @return
*/
@Override
public List<String> getSuggestion(String prefix) {
ArrayList<String> resList = new ArrayList<>();
try {
//创建请求
SearchRequest request = new SearchRequest("hotel");
//准备DSL
request.source().suggest(new SuggestBuilder().addSuggestion(
"suggestions",
SuggestBuilders.completionSuggestion("suggestion")
.prefix(prefix)
.skipDuplicates(true)
.size(10)
));
//发起请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
//解析结果
response.getSuggest().getSuggestion("suggestions") // 获取指定名称的补全查询结果(上面在查询的时候指定了名称)
.getEntries().get(0).getOptions() // 获取补全结果
.forEach(option -> resList.add(option.getText().toString()));
} catch (IOException e) {
throw new RuntimeException(e);
}
return resList;
}
可以看到前端输入拼音的时候返回了提示

不过输入中文的时候貌似并不能给提示,听说是前端的问题,不过无关紧要
数据同步
问题分析
es中的酒店数据来自于mysql数据库,因此mysql数据发生改变时,elasticsearch也必须要跟着改变,这就是不同微服务之间的数据同步
方案一:同步调用

先写完数据库,然后调用接口让搜索服务对es进行更新,操作是依次进行的
问题
- 数据耦合,需要在业务操作mysql的代码上添加调用接口数据同步的代码
- 写数据库还得等待es同步完成,性能浪费
- 级联失败,如果恰巧es更新出现异常,整个业务都会出现问题
方案二:异步通知

推荐方案,不过会依赖mq的可靠性,而且实现复杂度有所上升
监听binlog

mysql在进行操作的时候binlog会发生变化(需要事先开启),canal中间件会对binlog进行监听,通知微服务及时更新
完全解除了和服务间的耦合,但是因为要开启binlog,对mysql服务器的压力增加了
案例:利用MQ实现mysql和es数据同步
导入hotel-admin微服务
在资料中找到hotel-admin文件夹然后用idea打开即可,运行时记得修改配置连接到自己的数据库上

配置mq
当酒店数据发生增删改的时候,要求对elasticsearch数据完成同样的操作(由于es中增和改都是put操作,因此可以看做同一种操作),可以声明两个队列

-
在
hotel-demo引入SpringAMQPy依赖<!-- SpringAMQP--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency>在配置文件中对mq连接进行相关配置
spring: rabbitmq: host: fishlulu01 port: 5672 username: fishlulu password: 123456 virtual-host: / -
在配置文件中配置连接mq(之前在虚拟机创建过mq容器)
首先设置mq相关常量,新建一个常量类
package cn.itcast.hotel.constants;
public class MqConstants {
/**
* 交换机名称
*/
public final static String HOTEL_EXCHANGE = "hotel.topic";
/**
* 监听新增和修改的队列
*/
public final static String HOTEL_INSERT_QUEUE = "hotel.insert.queue";
/**
* 监听删除的队列
*/
public final static String HOTEL_DELETE_QUEUE = "hotel.delete.queue";
/**
* 新增和修改的路由键
*/
public final static String HOTEL_INSERT_KEY = "hotel.insert";
/**
* 删除的路由键
*/
public final static String HOTEL_DELETE_KEY = "hotel.delete";
}
新建一个配置类用来声明交换机,队列和绑定关系
package cn.itcast.hotel.config;
@Configuration
public class MqConfig {
/**
* 定义交换机
*/
@Bean
public TopicExchange topicExchange(){
return new TopicExchange(MqConstants.HOTEL_EXCHANGE, true, false);
}
/**
* 定义新增和修改的队列
*/
@Bean
public Queue insertQueue(){
return new Queue(MqConstants.HOTEL_INSERT_QUEUE, true);
}
/**
* 定义删除的队列
*/
@Bean
public Queue deleteQueue(){
return new Queue(MqConstants.HOTEL_DELETE_QUEUE, true);
}
/**
* 定义绑定关系
*/
@Bean
public Binding insertQueueBinding(){
return BindingBuilder
.bind(insertQueue())
.to(topicExchange())
.with(MqConstants.HOTEL_INSERT_KEY);
}
@Bean
public Binding deleteQueueBinding(){
return BindingBuilder
.bind(deleteQueue())
.to(topicExchange())
.with(MqConstants.HOTEL_DELETE_KEY);
}
}
- 在
hotel-admin配置数据同步操作
从hotel-demo项目中将mq常量类和mq相关配置复制粘贴到admin项目下
其实个人认为这种重复的配置应该交给nacos进行配置统一管理,不过由于这个demo项目体量较小,因此暂时用复制粘贴的方式配置了
然后在controller下面配置发送到消息队列的操作
个人觉得这个后续可以自定义一个注解,使用AOP方法来统一添加数据同步操作
@Autowired
private RabbitTemplate rabbitTemplate;
@PostMapping
public void saveHotel(@RequestBody Hotel hotel){
hotelService.save(hotel);
rabbitTemplate.convertAndSend(MqConstants.HOTEL_EXCHANGE, MqConstants.HOTEL_INSERT_KEY, hotel.getId());
}
@PutMapping()
public void updateById(@RequestBody Hotel hotel){
if (hotel.getId() == null) {
throw new InvalidParameterException("id不能为空");
}
hotelService.updateById(hotel);
rabbitTemplate.convertAndSend(MqConstants.HOTEL_EXCHANGE, MqConstants.HOTEL_INSERT_KEY, hotel.getId());
}
@DeleteMapping("/{id}")
public void deleteById(@PathVariable("id") Long id) {
hotelService.removeById(id);
rabbitTemplate.convertAndSend(MqConstants.HOTEL_EXCHANGE, MqConstants.HOTEL_DELETE_KEY, id);
}
由于rabbitmq占用的是内存资源,因此发送消息不建议过大,这里就选择发送酒店数据ID过去,然后被通知的服务再根据id去查询
- 在
hotel-demo编写监听类
@Component
public class HotelListener {
@Autowired
private IHotelService hotelService;
/**
* 监听酒店数据新增和修改
* @param id
*/
@RabbitListener(queues = MqConstants.HOTEL_INSERT_QUEUE)
public void listenHotelInsertOrUpdate(Long id){
hotelService.insertById(id);
}
/**
* 监听酒店数据删除
*/
@RabbitListener(queues = MqConstants.HOTEL_DELETE_QUEUE)
public void listenHotelDelete(Long id){
hotelService.deleteById(id);
}
}
在HotelService编写监听后要实现的操作
/**
* 通过id删除酒店es索引库数据
*
* @param id
*/
@Override
public void insertById(Long id) {
try {
//准备新增request
IndexRequest request = new IndexRequest("hotel").id(id.toString());
//准备dsl
//获取酒店数据
Hotel hotel = getById(id);
HotelDoc hotelDoc = new HotelDoc(hotel);
request.source(JSON.toJSONString(hotelDoc), XContentType.JSON);
//发出请求
client.index(request, RequestOptions.DEFAULT);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
/**
* 通过id删除酒店es索引库数据
*
* @param id
*/
@Override
public void deleteById(Long id) {
try {
//准备request
DeleteRequest request = new DeleteRequest("hotel", id.toString());
client.delete(request, RequestOptions.DEFAULT);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
功能测试
开启虚拟机中的mq容器,然后启动两个微服务
可以看到队列和交换机声明成功

找到一个酒店id进行演示,这里用id为60223的上海希尔顿酒店

老师视频演示控制台查看vue元素是通过一个叫
vue.js devtools的浏览器插件实现的,可以自行魔法上网到谷歌插件商店下载
去酒店管理页面进行数据修改(端口8099),把60223酒店改成2686元

mq收到了消息转发

es数据成功被修改

集群
搭建ES集群
ES集群结构
单机es进行数据存储存在以下问题
-
海量数据存储问题
- 对策: 将索引库从逻辑上拆分为n个分片,存储到多个节点
-
单点故障问题
- 将芬片数据在不同节点备份(replica)

搭建ES集群
这里我们使用3个docker容器来模拟三个节点
首先编写docker-compose文件配置集群部署,将文件
version: '2.2'
services:
es01:
image: elasticsearch:7.12.1
container_name: es01
environment:
- node.name=es01
- cluster.name=es-docker-cluster #es天生支持分布式,因此只要设置es的cluster name保持一致,es会自动将其部署到一个集群上面
- discovery.seed_hosts=es02,es03 #指定集群另外两个机器的ip地址(docker构建网络的时候ip地址默认是容器名)
- cluster.initial_master_nodes=es01,es02,es03
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
volumes:
- data01:/usr/share/elasticsearch/data
ports:
- 9200:9200
networks:
- elastic
es02:
image: elasticsearch:7.12.1
container_name: es02
environment:
- node.name=es02
- cluster.name=es-docker-cluster
- discovery.seed_hosts=es01,es03
- cluster.initial_master_nodes=es01,es02,es03
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
volumes:
- data02:/usr/share/elasticsearch/data
ports:
- 9201:9200
networks:
- elastic
es03:
image: elasticsearch:7.12.1
container_name: es03
environment:
- node.name=es03
- cluster.name=es-docker-cluster
- discovery.seed_hosts=es01,es02
- cluster.initial_master_nodes=es01,es02,es03
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
volumes:
- data03:/usr/share/elasticsearch/data
networks:
- elastic
ports:
- 9202:9200
volumes:
data01:
driver: local
data02:
driver: local
data03:
driver: local
networks:
elastic:
driver: bridge
es运行需要修改一些linux系统权限,修改/etc/sysctl.conf文件
vi /etc/sysctl.conf
添加下面的内容:
vm.max_map_count=262144
vm.max_map_count是一个 Linux 内核参数,它决定了一个进程可以拥有的虚拟内存区域(或称为内存映射)的最大数量。这些内存区域通常用于映射文件、共享内存、动态链接库等。总的来说就是防止内存分配不够
然后执行命令,让配置生效:
sysctl -p
通过docker-compose后台启动集群:
记得先把之前的es容器关掉防止冲突,mq和kibana最好也关了,现在本人分配的4g内存已经占用84%了
docker-compose up -d
集群状态监控
docker部署es集群
kibana虽然可以监控es集群,不过新版需要配置x-pack功能,配置比较复杂
这里推荐使用cerebro来监控es集群状态,直接解压资料安装包在本机上运行即可
但是这里自己使用jdk17版本发现本机运行cerebro会发生闪退,于是决定采用容器部署的方式,这里我参考了这篇文章重新配置docker集群部署(将cerebro和kibana也加入到集群搭建中,并且为es配置了用于集群间通信的端口),参考文章
用这个docker-compose.yml文件来替换原来的重新部署
version: '2.2'
services:
cerebro:
image: lmenezes/cerebro:0.9.4
container_name: cerebro
ports:
- "9000:9000"
command:
- -Dhosts.0.host=http://es01:9200 #告诉 Cerebro 连接到指定的 Elasticsearch 实例,以便监控和管理 Elasticsearch 集群
networks:
- elastic
kibana:
image: kibana:7.12.1
container_name: kibana_cluster
environment:
# 配置kibana图形化集群监控
- I18N_LOCALE=zh-CN #将 Kibana 的界面语言设置为中文
- XPACK_GRAPH_ENABLED=true #启用图形插件
- TIMELION_ENABLED=true #该插件允许用户执行时间序列数据的查询和可视化
- XPACK_MONITORING_COLLECTION_ENABLED="true" #启用监控数据收集,这样可以在 Kibana 中查看和分析 Elasticsearch 集群的监控数据
- ELASTICSEARCH_HOSTS=http://es01:9200 #配置 Kibana 连接到 Elasticsearch 集群的地址,只写主节点即可
ports:
- "5601:5601"
networks:
- elastic
es01:
image: elasticsearch:7.12.1
container_name: es01
environment:
- node.name=es01
- cluster.name=es-docker-cluster #es天生支持分布式,因此只要设置es的cluster name保持一致,es会自动将其部署到一个集群上面
- discovery.seed_hosts=es02,es03 #指定集群另外两个机器的ip地址(docker构建网络的时候ip地址默认是容器名)
- cluster.initial_master_nodes=es01,es02,es03 #配置节点的主从关系
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
volumes:
- data01:/usr/share/elasticsearch/data
ports:
- 9200:9200
- 9300:9300
networks:
- elastic
es02:
image: elasticsearch:7.12.1
container_name: es02
environment:
- node.name=es02
- cluster.name=es-docker-cluster
- discovery.seed_hosts=es01,es03
- cluster.initial_master_nodes=es01,es02,es03
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
volumes:
- data02:/usr/share/elasticsearch/data
ports:
- 9201:9200
- 9301:9300
networks:
- elastic
es03:
image: elasticsearch:7.12.1
container_name: es03
environment:
- node.name=es03
- cluster.name=es-docker-cluster
- discovery.seed_hosts=es01,es02
- cluster.initial_master_nodes=es01,es02,es03
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
volumes:
- data03:/usr/share/elasticsearch/data
networks:
- elastic
ports:
- 9202:9200
- 9302:9300
volumes:
data01:
driver: local
data02:
driver: local
data03:
driver: local
networks:
elastic:
driver: bridge
如果拉取的时候报错
Get "https://registry-1.docker.io/v2/": dial tcp: lookup registry-1.docker.io on [::1]:53: read udp [::1]:32897->[::1]:53: read: connection refused可以参考这篇文章解决(很简单的方法)
访问虚拟机9000端口,并输入es01:9200地址进入cerebro集群管理界面

也可以通过在kibana上进行查看(在搜索栏里搜堆栈监测,然后开启即可(选择内部收集就行))
kibana加载可能会很慢,稍微有点耐心

创建索引库
可以创建集群索引库,指定分片数量和每个片需要备份的份数

回到主页可以看到创建成功,其中实线框是主分片(真正被调用数据的),虚线框是副本分片,存放的是其他分片的备份

es集群分布式查询
es集群中的角色
| 节点类型 | 配置参数 | 默认值 | 节点职责 |
|---|---|---|---|
| master eligible | node.master | true | 备选主节点:主节点可以管理和记录集群状态,决定分片在哪一个节点,处理创建和删除索引库的请求 |
| data | node.data | true | 数据节点:存储数据,搜索树,聚合,crud |
| ingest | node.ingest | true | 数据存储之前的预处理 |
| coordinating | 上面三个参数都为false则为这类节点 | 无 | 负责路由请求到其他节点,合并其他节点处理的结果返回给用户 |
es中的每个节点角色都有自己不同职责,因此建议集群部署的时候,每个节点都有独立的角色

集群脑裂问题
默认情况下,每个节点都是master eligible节点,因此一旦master节点宕机,其他候选节点会选举一个成为主节点,而当主节点只是与其他节点发生网络故障而非真正坏掉的时候,其他节点会误以为主节点挂掉了,会再选出一个主节点,当网络恢复的时候,就会出现两个主节点控制请求,会出现数据不一致的问题,这就是脑裂问题

而在es中为了避免脑裂问题,需要选票不小于(eligible节点数量+1)/2才能当选主节点(例如node3有自己和node2两张选票,不小于(3+1)/2,而node1只有自己一票,因此node3会当选新的主节点),因此eligible节点数量最好是奇数,7.0之后版本默认配置了这个选项,因此一般不会发生脑裂问题
es集群分布式存储
当新增文档时,应该保存到不同分片,保证数据均衡,coordinating node就是负责这个的
先在kibana上添加三条数据到itcast索引库上(这里id=1,3,5)

通过explain:true选项可以看数据存放在哪一个节点上,这里看到都是从主节点插入的数据,但三条数据分散在三个节点上
事实上,es会通过hash算法计算文档应该存储到哪一个分片上(就是最简单的取余方法)

其中_routing默认是文档id
因为算法和分片数量有关,因此索引库一旦创建,分片数量就不能修改了
分布式查询

上述只是通过id查询,但实际情况更多是通过字段检索来查询,一开始并不知道数据在哪一个分片上
因es分布查询分为两个阶段
- scatter phase 分散阶段,协调节点会把请求发到每个分片去查
- gather phase 聚集阶段,协调节点汇总data node的搜索结果,并将最终结果返回用户
集群故障转移
集群的master节点会监控集群中的节点状态,如果有节点宕机,会立即将宕机节点的分片数据迁移到其他节点,确保数据安全,这就叫做故障转移

我们现在就来模拟一下故障场景
docker-compose stop es01停掉es01节点
可以看到触发了黄色告警

数据最终完成了迁移,恢复了绿色

数据可以正常查询
这里本来想用kibana来试验的,结果发现之前自己因为只设置了es01作为入口,挂掉以后kibana直接进不去了,目前来看还是cerebro方便一些啊
然后我们再重启一下es01
可以看到之前额外备份的数据重新回到es01上了,不过理所当然不能让es01这个不靠谱的东西当老大了(笑)

🌟可喜可贺,你已经完成了基础篇,是时候去做一个微服务入门项目巩固巩固了,接下来是高级篇,笔者也会在后面进行更新,敬请期待~
禄禄鱼在某个平常的春夜里有感而发并自我感动着






















浙公网安备 33010602011771号