# SpringCloud-黑马-教程

# 1. 微服务技术栈导学

微服务技术栈:

image-20260213221822203

实用篇:

  1. 微服务治理

    • Eureka
    • Nacos
    • OpenFeign
    • 网关 Gateway
    • 配置中心 Nacos
  2. Docker

    • Docker 原理
    • Docker 使用
    • Dockerfile
    • DockerCompose
  3. 异步通信

    • 同步和异步
    • MQ技术选型
    • SpringAMQP
    • 消费者限流
  4. 分布式搜索

    • DSL 语法
    • HighLevelClient
    • 拼音搜索
    • 自动补全
    • 竞价排名
    • 地理搜索
    • 聚合统计
    • 分片集群

高级篇:

  1. 微服务保护

    • 流量控制
    • 系统保护
    • 熔断降级
    • 服务授权
  2. 分布式事务

    • XA 模式
    • TCC 模式
    • AT 模式
    • Saga 模式
  3. 分布式缓存

    • 数据持久化
    • Redis 主从集群
    • 哨兵机制
    • Redis 分片集群
  4. 多级缓存

    • 多级缓存分层
    • Nginx 缓存
    • Redis 缓存
    • Canal 数据同步
  5. 可靠消息服务

    • 消息三方确认
    • 惰性队列
    • 延迟队列
    • 镜像集群
    • 仲裁队列

面试篇:

  1. Nacos 源码

    • Nacos 的服务发现原理
    • Nacos 服务注册原理
    • Nacos 心跳机制
    • Nacos、Eureka 差异
  2. Sentinel 源码

    • Sentinel 滑动窗口算法
    • 令牌桶算法
    • 漏桶算法
  3. Redis 热点问题

    • 分布式锁问题
    • 缓存穿透
    • 缓存击穿
    • 缓存雪崩

# 2. 认识微服务

# 2.1. 服务架构演变

# 2.1.1. 单体架构

单体架构:将业务的所有功能集中在一个项目中开发,打成一个包部署。

image-20260214094539190

优点:

  • 架构简单
  • 部署成本低

缺点:

  • 耦合度高

# 2.1.2. 分布式架构

分布式架构:根据业务功能对系统进行拆分,每个业务模块作为独立项目开发,称为一个服务。

image-20260214101008818

优点:

  • 降低服务耦合
  • 有利于服务升级拓展

# 2.1.3. 服务治理

image-20260214101117827

分布式架构的要考虑的问题:

  • 服务拆分粒度如何?
  • 服务集群地址如何维护?
  • 服务之间如何实现远程调用?
  • 服务健康状态如何感知?

# 2.1.4. 微服务

image-20260214101302417

微服务是一种经过良好架构设计的分布式架构方案,微服务架构特征:

  • 单一职责:微服务拆分粒度更小,每一个服务都对应唯一的业务能力,做到单一职责,避免重复业务开发
  • 面向服务:微服务对外暴露业务接口
  • 自治:团队独立、技术独立、数据独立、部署独立
  • 隔离性强:服务调用做好隔离、容错、降级,避免出现级联问题

# 2.1.5. 小结

单体架构特点?

  • 简单方便,高度耦合,扩展性差。
  • 适合小型项目,例如:学生管理系统

分布式架构特点?

  • 松耦合,扩展性好,但架构复杂,难度大。
  • 适合大型互联网项目,例如:京东、淘宝

微服务:一种良好的分布式架构方案

  • 优点:拆分粒度更小、服务更独立、耦合度更低
  • 缺点:架构非常复杂,运维、监控、部署难度提高

# 2.2. 微服务技术对比

# 2.2.1. 微服务结构

微服务这种方案需要技术框架来落地,全球的互联网公司都在积极尝试自己的微服务落地技术。在国内最知名的就是 SpringCloud 和阿里巴巴的 Dubbo。

image-20260214103136602

# 2.2.2. 微服务技术对比

Dubbo SpringCloud SpringCloudAlibaba
注册中心 zookeeper、Redis Eureka、Consul Nacos、Eureka
服务远程调用 Dubbo协议 Feign(http协议) Dubbo、Feign
配置中心 SpringCloudConfig SpringCloudConfig、Nacos
服务网关 SpringCloudGateway、Zuul SpringCloudGateway、Zuul
服务监控和保护 dubbo-admin,功能弱 Hystix Sentinel

# 2.2.3. 企业需求

SpringCloud + Feign:

  • 使用SpringCloud技术栈
  • 服务接口采用Restful风格
  • 服务调用采用Feign方式

SpringCloudAlibaba + Feign:

  • 使用SpringCloudAlibaba技术栈
  • 服务接口采用Restful风格
  • 服务调用采用Feign方式

SpringCloudAlibaba + Dubbo:

  • 使用SpringCloudAlibaba技术栈
  • 服务接口采用Dubbo协议标准
  • 服务调用采用Dubbo方式

Dubbo 原始模式:

  • 基于Dubbo老旧技术体系
  • 服务接口采用Dubbo协议标准
  • 服务调用采用Dubbo方式

# 2.3. SpringCloud

SpringCloud是目前国内使用最广泛的微服务框架。

官网地址:https://spring.io/projects/spring-cloud 。

SpringCloud集成了各种微服务功能组件,并基于SpringBoot实现了这些组件的自动装配,从而提供了良好的开箱即用体验:

  • 服务注册发现: Eureka, Nacos, Consul
  • 服务远程调用: OpenFeign、 Dubbo
  • 服务链路监控: Zipkin、 Sleuth
  • 统一配置管理: SpringCloudConfig、 Nacos
  • 统一网关路由: SpringCloudGateway、 Zuul
  • 流控、降级、保护: Hystix, Sentinel

SpringCloud 与 SpringBoot 的版本兼容关系如下:

Release Train Spring Boot Generation
2025.1.x aka Oakwood 4.0.x
2025.0.x aka Northfields 3.5.x
2024.0.x aka Moorgate 3.4.x
2023.0.x aka Leyton 3.3.x, 3.2.x
2022.0.x aka Kilburn 3.0.x, 3.1.x (Starting with 2022.0.3)
2021.0.x aka Jubilee 2.6.x, 2.7.x (Starting with 2021.0.3)
2020.0.x aka Ilford 2.4.x, 2.5.x (Starting with 2020.0.3)
Hoxton 2.2.x, 2.3.x (Starting with SR5)

本教程的 SpringCloud 版本是 Hoxton.SR10,因此对应的 SpringBoot 版本是 2.3.x 版本。

# 3. 服务拆分及远程调用

# 3.1. 服务拆分

服务拆分注意事项:

  • 单一职责:不同微服务,不要重复开发相同业务
  • 数据独立:不要访问其它微服务的数据库
  • 面向服务:将自己的业务暴露为接口,供其它微服务调用

项目结构:

cloud-demo/          # 父工程
   order-service/       # 子项目
      根据 ID 查询订单
   user-service/        # 子项目
      根据 ID 查询用户

order-service 项目的数据库的脚本:

DROP DATABASE IF EXISTS cloud_order;
CREATE DATABASE cloud_order;
USE cloud_order;

DROP TABLE IF EXISTS `tb_order`;
CREATE TABLE `tb_order`  (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '订单id',
  `user_id` bigint(20) NOT NULL COMMENT '用户id',
  `name` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '商品名称',
  `price` bigint(20) NOT NULL COMMENT '商品价格',
  `num` int(10) NULL DEFAULT 0 COMMENT '商品数量',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE INDEX `username`(`name`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 109 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;

INSERT INTO `tb_order` VALUES (101, 1, 'Apple 苹果 iPhone 12 ', 699900, 1);
INSERT INTO `tb_order` VALUES (102, 2, '雅迪 yadea 新国标电动车', 209900, 1);
INSERT INTO `tb_order` VALUES (103, 3, '骆驼(CAMEL)休闲运动鞋女', 43900, 1);
INSERT INTO `tb_order` VALUES (104, 4, '小米10 双模5G 骁龙865', 359900, 1);
INSERT INTO `tb_order` VALUES (105, 5, 'OPPO Reno3 Pro 双模5G 视频双防抖', 299900, 1);
INSERT INTO `tb_order` VALUES (106, 6, '美的(Midea) 新能效 冷静星II ', 544900, 1);
INSERT INTO `tb_order` VALUES (107, 2, '西昊/SIHOO 人体工学电脑椅子', 79900, 1);
INSERT INTO `tb_order` VALUES (108, 3, '梵班(FAMDBANN)休闲男鞋', 31900, 1);

user-service 项目的数据库的脚本:

DROP DATABASE IF EXISTS cloud_user;
CREATE DATABASE cloud_user;
USE cloud_user;

DROP TABLE IF EXISTS `tb_user`;
CREATE TABLE `tb_user`  (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `username` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '收件人',
  `address` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '地址',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE INDEX `username`(`username`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 109 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;

INSERT INTO `tb_user` VALUES (1, '柳岩', '湖南省衡阳市');
INSERT INTO `tb_user` VALUES (2, '文二狗', '陕西省西安市');
INSERT INTO `tb_user` VALUES (3, '华沉鱼', '湖北省十堰市');
INSERT INTO `tb_user` VALUES (4, '张必沉', '天津市');
INSERT INTO `tb_user` VALUES (5, '郑爽爽', '辽宁省沈阳市大东区');
INSERT INTO `tb_user` VALUES (6, '范兵兵', '山东省青岛市');

访问:

  • http://localhost:8081/user/1
  • http://localhost:8080/order/101

小结:

  • 微服务需要根据业务模块拆分,做到单一职责,不要重复开发相同业务
  • 微服务可以将业务暴露为接口,供其它微服务使用
  • 不同微服务都应该有自己独立的数据库

# 3.2. 服务间调用

需求:根据订单id查询订单的同时,把订单所属的用户信息一起返回

分析:

  • 在 order-service 服务里,向 user-service 发送 HTTP 请求,获取用户信息,合并后返回给用户

使用 RestTemplate 发送 HTTP 请求

步骤 1. 在启动类里注册 RestTemplate

@SpringBootApplication
public class OrderApplication {

 // ...
 
    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

步骤 2. 修改订单服务

@Service
public class OrderService {

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private RestTemplate restTemplate;

    public Order queryOrderById(Long orderId) {
        // 1.查询订单
        Order order = orderMapper.findById(orderId);

        // 2. 发送请求获取用户
        String url = "http://localhost:8081/user/" + order.getUserId();
        User user = restTemplate.getForObject(url, User.class);

        // 3. 将用户塞入订单对象
        order.setUser(user);

        // 4.返回
        return order;
    }
}

小结:

  • 微服务调用方式
    • 基于RestTemplate发起的http请求实现远程调用
    • http 请求做远程调用是与语言无关的调用,只要知道对方的 ip、端口、接口路径、请求参数 即可。

# 3.3. 提供者与消费者

服务提供者:一次业务中,被其它微服务调用的服务。(提供接口给其它微服务)

服务消费者:一次业务中,调用其它微服务的服务。(调用其它微服务提供的接口)

服务 A 调用服务 B,服务 B 调用服务 C,那么服务 B 是什么角色?

服务调用关系:

  • 服务提供者:暴露接口给其它微服务调用
  • 服务消费者:调用其它微服务提供的接口
  • 提供者与消费者角色其实是相对的
  • 一个服务可以同时是服务提供者和服务消费者

# 4. Eureka 注册中心

[juˈriːkə]

# 4.1. 远程调用的问题

服务调用出现的问题:

  • 服务消费者该如何获取服务提供者的地址信息?
  • 如果有多个服务提供者,消费者该如何选择?
  • 消费者如何得知服务提供者的健康状态?

# 4.2. eureka 原理

Eureka 的作用:

2026-02-14_144636

消费者该如何获取服务提供者具体信息?

  • 服务提供者启动时向eureka注册自己的信息
  • eureka保存这些信息
  • 消费者根据服务名称向eureka拉取提供者信息

如果有多个服务提供者,消费者该如何选择?

  • 服务消费者利用负载均衡算法,从服务列表中挑选一个

消费者如何感知服务提供者健康状态?

  • 服务提供者会每隔 30 秒向 EurekaServer 发送心跳请求,报告健康状态
  • eureka 会更新记录服务列表信息,心跳不正常会被剔除
  • 消费者就可以拉取到最新的信息

在 Eureka 架构中,微服务角色有两类:

EurekaServer:服务端,注册中心

  • 记录服务信息
  • 心跳监控

EurekaClient:客户端

  • Provider:服务提供者,例如案例中的 user-service
    • 注册自己的信息到EurekaServer
    • 每隔30秒向EurekaServer发送心跳
  • Consumer:服务消费者,例如案例中的 order-service
    • 根据服务名称从 EurekaServer 拉取服务列表
    • 基于服务列表做负载均衡,选中一个微服务后发起远程调用

# 4.3. 搭建 EurekaServer

# 4.3.1. 动手实践

  1. 搭建注册中心

    • 搭建EurekaServer
  2. 服务注册

    • 将 user-service、order-service 都注册到 eureka
  3. 服务发现

    • 在 order-service 中完成服务拉取,然后通过负载均衡挑选一个服务,实现远程调用

# 4.3.2. 搭建 EurekaServer

步骤 1. 创建项目,引入 spring-cloud-starter-netflix-eureka-server 的依赖

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>

步骤 2. 编写启动类,添加 @EnableEurekaServer 注解

@SpringBootApplication
@EnableEurekaServer
public class EurekaApplication {
    public static void main( String[] args ) {
        SpringApplication.run(EurekaApplication.class, args);
    }
}

步骤 3. 添加 application.yml 文件,编写下面的配置:

server:
  port: 10086
spring:
  application:
    name: eurekaserver # eureka 的服务名称
eureka:
  client:
    service-url: # eureka 的地址信息
      defaultZone: http://127.0.0.1:10086/eureka

访问: http://localhost:10086/

# 4.4. 服务注册

将 user-service 服务注册到 EurekaServer 步骤如下:

步骤 1. 加 eureka-client 的依赖

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>

步骤 2. 配置 eureka 的地址

spring:
  application:
    name: userservice # user 的服务名称

eureka:
  client:
    service-url: # eureka 的地址信息
      defaultZone: http://127.0.0.1:10086/eureka

小结:

  • 服务注册
    • 引入 eureka-client 依赖
    • 在 application.yml 中配置 eureka 地址
  • 无论是消费者还是提供者,引入 eureka-client 依赖、知道 eureka 地址后,都可以完成服务注册

# 4.5. 服务发现

服务拉取是基于服务名称获取服务列表,然后在对服务列表做负载均衡

修改 OrderService 的代码,修改访问的 url 路径,用服务名代替 ip、端口:

// String url = "http://localhost:8081/user/" + order.getUserId();
String url = "http://userservice/user/" + order.getUserId();

在 order-service 项目的启动类 OrderApplication 中的 RestTemplate 添加负载均衡注解:

@LoadBalanced // 负载均衡注解
@Bean
public RestTemplate restTemplate() {
   return new RestTemplate();
}

# 4.6. 小结

搭建 EurekaServer:

  • 引入eureka-server依赖
  • 添加@EnableEurekaServer注解
  • 在application.yml中配置eureka地址

服务注册:

  • 引入 eureka-client 依赖
  • 在 application.yml 中配置 eureka 地址

服务发现:

  • 引入 eureka-client 依赖
  • 在 application.yml 中配置 eureka 地址
  • 给 RestTemplate 添加 @LoadBalanced 注解
  • 用服务提供者的服务名称远程调用

# 5. Ribbon 负载均衡原理

# 5.1. 负载均衡原理

image-20260214185202716

# 5.2. 负载均衡策略

Ribbon 的负载均衡规则是一个叫做 IRule 的接口来定义的,每一个子接口都是一种规则

内置负载均衡规则类 规则描述
RoundRobinRule 简单轮询服务列表来选择服务器。它是Ribbon默认的负载均衡规则。
AvailabilityFilteringRule 对以下两种服务器进行忽略: (1)在默认情况下,这台服务器如果3次连接失败,这台服务器就会被设置为“短路”状态。短路状态将持续30秒,如果再次连接失败,短路的持续时间就会几何级地增加。 (2)并发数过高的服务器。如果一个服务器的并发连接数过高,配置了AvailabilityFilteringRule规则的客户端也会将其忽略。并发连接数的上限,可以由客户端的<clientName>.<clientConfigNameSpace>.ActiveConnectionsLimit属性进行配置。
WeightedResponseTimeRule 为每一个服务器赋予一个权重值。服务器响应时间越长,这个服务器的权重就越小。这个规则会随机选择服务器,这个权重值会影响服务器的选择。
ZoneAvoidanceRule 以区域可用的服务器为基础进行服务器的选择。使用Zone对服务器进行分类,这个Zone可以理解为一个机房、一个机架等。而后再对Zone内的多个服务做轮询。
BestAvailableRule 忽略那些短路的服务器,并选择并发数较低的服务器。
RandomRule 随机选择一个可用的服务器。
RetryRule 重试机制的选择逻辑

通过定义 IRule 实现可以修改负载均衡规则,有两种方式:

方式 1,代码方式:在 order-service 中的 OrderApplication 类中,定义一个新的 IRule:

// 会替代默认的 轮询 规则
// 全局的: 当前整个项目访问的所有服务生效
@Bean
public IRule randomRule(){
    return new RandomRule(); 
}

方式 2,配置文件方式:在 order-service 的 application.yml 文件中,添加新的配置也可以修改规则:

# 局部的: 针对 配置 的服务生效
userservice:
  ribbon:
    NFLoadBalancerRuleClassName: com.netflix.loadbalancer.RandomRule # 负载均衡规则 

# 5.3. 饥饿加载

Ribbon 默认是采用懒加载,即第一次访问时才会去创建 LoadBalanceClient,请求时间会很长。

而饥饿加载则会在项目启动时创建,降低第一次访问的耗时,通过下面配置开启饥饿加载:

ribbon:
  eager-load:
    enabled: true # 开启饥饿加载
    clients: 
      - userservice # 指定对 userservice 这个服务饥饿加载

# 5.4. 小结

Ribbon 负载均衡规则:

  • 规则接口是 IRule
  • 默认实现是 ZoneAvoidanceRule,根据 zone 选择服务列表,然后轮询

负载均衡自定义方式:

  • 代码方式:配置灵活,但修改时需要重新打包发布
  • 配置方式:直观,方便,无需重新打包发布,但是无法做全局配置

饥饿加载:

  • 开启饥饿加载
  • 指定饥饿加载的微服务名称

# 6. Nacos 注册中心

# 6.1. 安装 Nacos

Nacos 是阿里巴巴的产品,现在是 SpringCloud 中的一个组件。相比 Eureka 功能更加丰富,在国内受欢迎程度较高。

官网: https://nacos.io/

下载:

  • https://nacos.io/download/release-history 【点击 发布说明 进入下载页】
  • nacos-server-1.4.1.zip

解压: D:\soft\nacos

启动:

# 进入 bin 目录

# 单机启动
startup.cmd -m standalone

访问:

  • http://192.168.18.1:8848/nacos/index.html
  • nacos / nacos

# 6.2. 服务注册到 Nacos

步骤 1: 在 cloud-demo 父工程中添加 spring-cloud-alilbaba 的管理依赖

<dependency>
      <groupId>com.alibaba.cloud</groupId>
      <artifactId>spring-cloud-alibaba-dependencies</artifactId>
      <version>2.2.6.RELEASE</version>
      <type>pom</type>
      <scope>import</scope>
</dependency>

步骤 2: 注释掉 order-service 和 user-service 中原有的 eureka 依赖

<!--
<dependency>
   <groupId>org.springframework.cloud</groupId>
   <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
-->

步骤 3: 添加 nacos 的客户端依赖

<!-- nacos 客户端依赖 -->
<dependency>
   <groupId>com.alibaba.cloud</groupId>
   <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

步骤 4: 修改 user-service 、 order-service 的 application.yml,注释 eureka 地址,添加 nacos 地址

spring:
  cloud:
    nacos:
      server-addr: localhost:8848 # 默认地址

# 6.3. 小结

Nacos 服务搭建:

  • 下载安装包
  • 解压
  • 在 bin 目录下运行指令:startup.cmd -m standalone

Nacos 服务注册或发现:

  • 引入 nacos.discovery 依赖
  • 配置 nacos 地址 spring.cloud.nacos.server-addr

# 6.4. Nacos 服务分级存储模型

# 6.4.1. Nacos 服务分级存储模型

image-20260215111708764

# 6.4.2. 服务跨集群调用问题

服务调用尽可能选择本地集群的服务,跨集群调用延迟较高

本地集群不可访问时,再去访问其它集群

# 6.4.3. 服务集群属性

spring:
  cloud:
    nacos:
      discovery:
        cluster-name: HB # 配置集群名称,默认为 "DEFAULT"

# 6.4.4. 小结

Nacos 服务分级存储模型:

  • 一级是服务,例如 userservice
  • 二级是集群,例如 杭州 或 上海
  • 三级是实例,例如 杭州机房的某台部署了 userservice 的服务器

如何设置实例的集群属性:

  • 修改 application.yml 文件,添加 spring.cloud.nacos.discovery.cluster-name 属性即可

# 6.5. NacosRule 负载均衡

在 order-service 中设置负载均衡的 IRule为NacosRule ,这个规则优先会寻找与自己同集群的服务

userservice:
  ribbon:
    NFLoadBalancerRuleClassName: com.alibaba.cloud.nacos.ribbon.NacosRule # 负载均衡规则

NacosRule 负载均衡策略:

  • 优先选择同集群服务实例列表
  • 本地集群找不到提供者,才去其它集群寻找,并且会报警告
  • 确定了可用实例列表后,再采用随机负载均衡挑选实例

# 6.6. 服务实例的权重设置

实际部署中会出现这样的场景:

  • 服务器设备性能有差异,部分实例所在机器性能较好,另一些较差,我们希望性能好的机器承担更多的用户请求

Nacos 提供了权重配置来控制访问频率,权重越大则访问频率越高

  1. 在 Nacos 控制台可以设置实例的权重值,首先选中实例后面的编辑按钮
  2. 将权重设置为 0.1,测试可以发现 8081 被访问到的频率大大降低

不停机更新:

  1. 先将 实例1 的权重设置为 0
  2. 待其处理完所有请求,将其停止并更新版本
  3. 待该服务器重启后,将其权重逐渐恢复为 1
  4. 其它 实例 也如此操作,可达到平滑更新的目录

实例的权重控制:

  • Nacos 控制台可以设置实例的权重值,0~1 之间
  • 同集群内的多个实例,权重越高被访问的频率越高
  • 权重设置为 0 则完全不会被访问

# 6.7. Nacos 环境隔离

Nacos 中服务存储和数据存储的最外层都是一个名为 namespace 的东西,用来做最外层隔离

步骤 1: 在 Nacos 控制台可以创建 namespace,用来隔离不同环境

步骤 2: 然后填写一个新的命名空间信息

步骤 3: 保存后会在控制台看到这个命名空间的 id

步骤 4: 修改 order-service 的 application.yml,添加 namespace

spring:
  cloud:
    nacos:
      discovery:
        namespace: 492a7d5d-237b-46a1-a99a-fa8e98e4b0f9 # 命名空间,填ID

步骤 5: 重启 order-service 后,再来查看控制台

步骤 6: 此时访问 order-service,因为 namespace 不同,会导致找不到 userservice,控制台会报错

Nacos环境隔离:

  • 每个 namespace 都有唯一 id
  • 服务设置 namespace 时要写 id 而不是名称
  • 不同 namespace 下的服务互相不可见

# 6.8. Nacos 和 Eureka 的对比

# 6.8.1. nacos 注册中心细节分析

image-20260215145251837

# 6.8.2. 临时实例和非临时实例

服务注册到Nacos时,可以选择注册为临时或非临时实例,通过下面的配置来设置:

spring:  
  cloud:
    nacos:
      discovery:
        ephemeral: false # 设置为非临时实例

临时实例宕机时,会从 nacos 的服务列表中剔除,而非临时实例则不会

# 6.8.3. 小结

Nacos 与 eureka 的共同点:

  • 都支持服务注册和服务拉取
  • 都支持服务提供者心跳方式做健康检测

Nacos 与 Eureka 的区别:

  • Nacos 支持服务端主动检测提供者状态:临时实例采用心跳模式,非临时实例采用主动检测模式
  • 临时实例心跳不正常会被剔除,非临时实例则不会被剔除
  • Nacos 支持服务列表变更的消息推送模式,服务列表更新更及时
  • Nacos 集群默认采用 AP 方式,当集群中存在非临时实例时,采用 CP 模式;Eureka 采用 AP 方式

# 7. Nacos 配置管理

# 7.1. Nacos 实现配置管理

配置更改热更新

在 Nacos 中添加配置信息

配置名称的格式: 服务名称-环境.后缀名

  • 比如: userservice-dev.yaml
pattern:
  dateformat: yyyy-MM-dd HH:mm:ss

# 7.2. 微服务配置拉取

# 7.2.1. 获取配置的流程

image-20260215162114038

# 7.2.2. 步骤

步骤 1: user-service 引入 Nacos 的配置管理客户端依赖

<!--nacos 配置管理依赖-->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>

步骤 2: 在 user-service 中的 resource 目录添加一个 bootstrap.yml 文件,这个文件是引导文件,优先级高于 application.yml

spring:
  application:
    name: userservice # 服务名称
  profiles:
    active: dev #开发环境,这里是dev
  cloud:
    nacos:
      server-addr: localhost:8848 # Nacos地址
      config:        
        file-extension: yaml # 文件后缀名

测试: 在 user-service 中将 pattern.dateformat 这个属性注入到 UserController 中做测试

@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {

    @Value("${pattern.dateformat}")
    private String dateformat;

    @GetMapping("/now")
    public String now() {
        log.info("dateformat ======> {}", dateformat);
        return LocalDateTime.now().format(DateTimeFormatter.ofPattern(dateformat));
    }
}

# 7.2.3. 小结

将配置交给 Nacos 管理的步骤:

  • 在 Nacos 中添加配置文件
  • 在微服务中引入 nacos 的 config 依赖
  • 在微服务中添加 bootstrap.yml ,配置 nacos 地址、当前环境、服务名称、文件后缀名。这些决定了程序启动时去 nacos 读取哪个文件

# 7.3. 配置热更新

Nacos 中的配置文件变更后,微服务无需重启就可以感知。不过需要通过下面两种配置实现

  • @Value + @RefreshScope
  • @ConfigurationProperties

# 7.3.1. @Value + @RefreshScope

@RefreshScope // 刷新 @Value
public class UserController {

    @Value("${pattern.dateformat}")
    private String dateformat;

    @GetMapping("/now")
    public String now() {
        log.info("dateformat ======> {}", dateformat);
        return LocalDateTime.now().format(DateTimeFormatter.ofPattern(dateformat));
    }
}

# 7.3.2. @ConfigurationProperties

@ConfigurationProperties(prefix = "pattern")
@Data
public class UserController {
    private String dateformat;
}

# 7.3.3. 小结

Nacos 配置更改后,微服务可以实现热更新,方式:

  • 通过 @Value 注解注入,结合 @RefreshScope 来刷新
  • 通过 @ConfigurationProperties 注入,自动刷新

注意事项:

  • 不是所有的配置都适合放到配置中心,维护起来比较麻烦
  • 建议将一些关键参数,需要运行时调整的参数放到 nacos 配置中心,一般都是自定义配置

# 7.4. 多环境配置共享

微服务启动时会从 nacos 读取多个配置文件:

  • [spring.application.name]-[spring.profiles.active].yaml,例如:userservice-dev.yaml
  • [spring.application.name].yaml,例如:userservice.yaml

无论 profile 如何变化,[spring.application.name].yaml 这个文件一定会加载,因此多环境共享配置可以写入这个文件

多种配置的优先级:

  • 服务名-环境.yaml > 服务名称.yaml > 本地配置

微服务会 从nacos 读取的配置文件:

  • [服务名]-[spring.profile.active].yaml,环境配置
  • [服务名].yaml,默认配置,多环境共享

优先级:

  • [服务名]-[环境].yaml > [服务名].yaml > 本地配置

# 7.5. Nacos 集群搭建

# 7.5.1. 集群结构图

其中包含 3 个 nacos 节点,然后一个负载均衡器代理 3 个 Nacos。这里负载均衡器可以使用 nginx。

我们计划的集群结构:

image

三个 nacos 节点的地址:

节点 ip port
nacos1 192.168.150.1 8845
nacos2 192.168.150.1 8846
nacos3 192.168.150.1 8847

# 7.5.2. 搭建集群

搭建集群的基本步骤:

  • 搭建数据库,初始化数据库表结构
  • 下载 nacos 安装包
  • 配置 nacos
  • 启动 nacos 集群
  • nginx 反向代理

# 7.5.3. 初始化数据库

Nacos 默认数据存储在内嵌数据库 Derby 中,不属于生产可用的数据库。

官方推荐的最佳实践是使用带有主从的高可用数据库集群,主从模式的高可用数据库。

这里我们以单点的数据库为例来讲解。

首先新建一个数据库,命名为 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');

# 7.5.4. 下载 Nacos

本例中才用 1.4.1 版本

# 7.5.5. 配置 Nacos

将这个包解压到任意非中文目录下:

D:\soft\
    nacos

进入 nacos 的 conf 目录,修改配置文件 cluster.conf.example,重命名为 cluster.conf,添加内容:

127.0.0.1:8845
127.0.0.1.8846
127.0.0.1.8847

修改 application.properties 文件,添加数据库配置:

spring.datasource.platform=mysql

db.num=1

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

# 7.5.6. 启动

将 nacos 文件夹复制三份,分别命名为:nacos1、nacos2、nacos3, 分别修改三个文件夹中的 application.properties

nacos1:

server.port=8845

nacos2:

server.port=8846

nacos3:

server.port=8847

分别启动三个 nacos 节点:

startup.cmd

# 7.5.7. 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;
    }
}

# 7.5.8. 访问

在浏览器访问:http://localhost/nacos

# 7.5.9. 修改服务的 nacos 地址

application.yml:

spring:
  cloud:
    nacos:
      server-addr: localhost:80 # Nacos地址

# 8. Feign 远程调用

[feɪn]

# 8.1. 基于 Feign 远程调用

# 8.1.1. RestTemplate 方式调用存在的问题

先来看我们以前利用 RestTemplate 发起远程调用的代码:

String url = "http://userservice/user/" + order.getUserId();
User user = restTemplate.getForObject(url, User.class);

存在下面的问题:

  • 代码可读性差,编程体验不统一
  • 参数复杂 URL 难以维护

# 8.1.2. Feign 的介绍

Feign 是一个声明式的 http 客户端,官方地址:https://github.com/OpenFeign/feign

其作用就是帮助我们优雅的实现 http 请求的发送,解决上面提到的问题。

# 8.1.3. 定义和使用 Feign 客户端

依赖:

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

开关: (启动类添加注解开启 Feign 的功能)

@EnableFeignClients // 开启 Feign
@SpringBootApplication
public class OrderApplication {  
  // ...
}

编写 Feign 客户端:

@FeignClient("userservice") // 默认已实现 负载均衡
public interface UserClient {
    @GetMapping("/user/{id}")
    User findById(@PathVariable("id") Long id);
}

/*
 * 主要是基于 SpringMVC 的注解来声明远程调用的信息,比如:
 *   服务名称:userservice
 *   请求方式:GET
 *   请求路径:/user/{id}
 *   请求参数:Long id
 *   返回值类型:User
 */

用 Feign 客户端代替 RestTemplate:

@Service
public class OrderService {

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private UserClient userClient;

    public Order queryOrderById(Long orderId) {
        // 1.查询订单
        Order order = orderMapper.findById(orderId);

        // 2. 发送请求获取用户
        // String url = "http://localhost:8081/user/" + order.getUserId();
        // String url = "http://userservice/user/" + order.getUserId();
        // User user = restTemplate.getForObject(url, User.class);

        User user = userClient.findById(order.getUserId());

        // 3. 将用户塞入订单对象
        order.setUser(user);

        // 4.返回
        return order;
    }
}

# 8.1.4. 小结

Feign 的使用步骤:

  • 引入依赖
  • 添加 @EnableFeignClients 注解
  • 编写 FeignClient 接口
  • 使用 FeignClient 中定义的方法代替 RestTemplate

# 8.2. 自定义配置

# 8.2.1. 可修改的配置

Feign 运行自定义配置来覆盖默认配置,可以修改的配置如下:

类型 作用 说明
feign.Logger.Level 修改日志级别 包含四种不同的级别:NONE、BASIC、HEADERS、FULL
feign.codec.Decoder 响应结果的解析器 http 远程调用的结果做解析,例如解析 json 字符串为 java 对象
feign.codec.Encoder 请求参数编码 将请求参数编码,便于通过 http 请求发送
feign.Contract 支持的注解格式 默认是 SpringMVC 的注解
feign.Retryer 失败重试机制 请求失败的重试机制,默认是没有,不过会使用 Ribbon 的重试

一般我们需要配置的就是日志级别

配置 Feign 日志有两种方式:

  • 配置文件方式
  • Java 代码方式

# 8.2.2. 配置文件方式

feign:
  client:
    config:
      default:      # 全局配置
        loggerLevel: FULL 
        
      userservice:  # 局部配置
        loggerLevel: BASIC 

# 8.2.3. Java 代码方式

先声明一个 Bean:

public class FeignClientConfiguration {
    @Bean
    public Logger.Level feignLogLevel() {
        return Logger.Level.BASIC;
    }
}

添加进注解:

// 全局配置
@EnableFeignClients(defaultConfiguration = FeignClientConfiguration.class)

// 局部配置
@FeignClient(value = "userservice", configuration = FeignClientConfiguration.class)

# 8.2.4. 小结

Feign的日志配置

方式一 是配置文件,feign.client.config.xxx.loggerLevel :

  • 如果 xxx 是 default 则代表全局
  • 如果 xxx 是服务名称,例如 userservice 则代表某服务

方式二 是 java 代码配置Logger.Level这个Bean :

  • 如果在 @EnableFeignClients 注解声明则代表全局
  • 如果在 @FeignClient 注解中声明则代表某服务

# 8.3. 性能优化

# 8.3.1. Feign 的性能优化

Feign 底层的客户端实现:

  • URLConnection:默认实现,不支持连接池 (JDK 提供的功能)
  • Apache HttpClient :支持连接池
  • OKHttp:支持连接池

因此优化 Feign 的性能主要包括:

  • 使用连接池代替默认的 URLConnection
  • 日志级别,最好用 basic 或 none

# 8.3.2. Feign 添加 HttpClient 的支持

依赖:

<dependency>
    <groupId>io.github.openfeign</groupId>
    <artifactId>feign-httpclient</artifactId>
</dependency>

配置:

feign:
  httpclient:
    enabled: true
    max-connections: 200 # 最大的连接数
    max-connections-per-route: 50 # 每个路径的最大连接数

# 8.3.3. 小结

日志级别尽量用 basic

使用 HttpClient 或 OKHttp 代替 URLConnection:

  • 引入 feign-httpClient依赖
  • 配置文件开启 httpClient 功能,设置连接池参数

# 8.4. 最佳实践分析

# 8.4.1. 方式一(继承)

给消费者的 FeignClient 和提供者的 controller 定义统一的父接口作为标准

// 统一的父接口
public interface UserAPI {     
    @GetMapping("/user/{id}")     
    User findById(@PathVariable("id") Long id); 
}

// 消费者
@FeignClient(value = "userservice")
public interface UserClient extends UserAPI {}

// 提供者
@RestController
public class UserController implements UserAPI{
    public User findById(@PathVariable("id") Long id) {
        // ...实现业务
    }
}

缺点:

  • 服务紧耦合
  • 父接口参数列表中的映射不会被继承

# 8.4.2. 方式二(抽取)

将 FeignClient 抽取为独立模块,并且把接口有关的 POJO、默认的 Feign 配置都放到这个模块中,提供给所有消费者使用

image-20260215203227345

# 8.4.3. 小结

Feign 的最佳实践:

  • 让 controller 和 FeignClient 继承同一接口
  • 将 FeignClient、POJO、Feign 的默认配置都定义到一个项目中,供所有消费者使用

# 8.5. 实现 Feign 最佳实践

实现最佳实践方式二的步骤如下:

  1. 首先创建一个 module,命名为 feign-api,然后引入 feign 的 starter 依赖
  2. 将 order-service 中编写的 UserClient、User、DefaultFeignConfiguration 都复制到 feign-api 项目中
  3. 在 order-service 中引入 feign-api 的依赖
  4. 修改 order-service 中的所有与上述三个组件有关的 import 部分,改成导入 feign-api 中的包
  5. 重启测试

当定义的 FeignClient 不在 SpringBootApplication 的扫描包范围时,这些 FeignClient 无法使用,有两种方式解决:

方式一:指定 FeignClient 所在包

@EnableFeignClients(basePackages = "cn.itcast.feign.clients")

方式二:指定 FeignClient 字节码 【推荐】

@EnableFeignClients(clients = {UserClient.class})

# 9. Gateway 服务网关

# 9.1. 网关作用介绍

image-20260216140226391

网关功能:

  • 身份认证和权限校验
  • 服务路由、负载均衡
  • 请求限流

在 SpringCloud 中网关的实现包括两种:

  • gateway
  • zuul

Zuul 是基于 Servlet 的实现,属于阻塞式编程。 而 SpringCloudGateway 则是基于 Spring5 中提供的 WebFlux,属于响应式编程的实现,具备更好的性能。

网关的作用:

  • 对用户请求做身份认证、权限校验
  • 将用户请求路由到微服务,并实现负载均衡
  • 对用户请求做限流

# 9.2. 快速入门

image-20260216153935917

步骤 1: 创建 module,引入 SpringCloudGateway、nacos 的依赖

<!--网关依赖-->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

<!--nacos服务发现依赖-->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

步骤 2: 编写路由配置及 nacos 地址

server:
  port: 10010
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: localhost:8848
    gateway:
      routes:
        - id: user-service # 路由标识,唯一
          uri: lb://userservice # 路由的目标地址
          predicates: # 路由断言,判断请求是否符合规则
            - Path=/user/** # 路径断言,判断路径是否以 /user 打头
        - id: order-service
          uri: lb://orderservice
          predicates:
            - Path=/order/**

访问:

  • http://localhost:10010/user/1
  • http://localhost:10010/order/101

网关搭建步骤:

  • 创建项目,引入 nacos 服务发现和 gateway 依赖
  • 配置 application.yml,包括服务基本信息、nacos地址、路由

路由配置包括:

  • 路由 id:路由的唯一标示
  • 路由目标(uri):路由的目标地址,http代表固定地址,lb代表根据服务名负载均衡
  • 路由断言(predicates):判断路由的规则,
  • 路由过滤器(filters):对请求或响应做处理

# 9.3. 路由断言工厂

网关路由可以配置的内容包括:

  • 路由id:路由唯一标示
  • uri:路由目的地,支持lb和http两种
  • predicates:路由断言,判断请求是否符合要求,符合则转发到路由目的地
  • filters:路由过滤器,处理请求或响应

我们在配置文件中写的断言规则只是字符串,这些字符串会被 Predicate Factory 读取并处理,转变为路由判断的条件

例如 Path=/user/** 是按照路径匹配,这个规则是由 org.springframework.cloud.gateway.handler.predicate.PathRoutePredicateFactory 类来处理的

像这样的断言工厂在 SpringCloudGateway 还有十几个

Spring 提供了 11 种基本的 Predicate 工厂:

名称 说明 示例
After 是某个时间点后的请求 - After=2037-01-20T17:42:47.789-07:00[America/Denver]
Before 是某个时间点之前的请求 - Before=2031-04-13T15:14:47.433+08:00[Asia/Shanghai]
Between 是某两个时间点之前的请求 - Between=2037-01-20T17:42:47.789-07:00[America/Denver], 2037-01-21T17:42:47.789-07:00[America/Denver]
Cookie 请求必须包含某些cookie - Cookie=chocolate, ch.p
Header 请求必须包含某些header - Header=X-Request-Id, \d+
Host 请求必须是访问某个host(域名) - Host=**.somehost.org,**.anotherhost.org
Method 请求方式必须是指定方式 - Method=GET,POST
Path 请求路径必须符合指定规则 - Path=/red/{segment},/blue/**
Query 请求参数必须包含指定参数 - Query=name, Jack或者- Query=name
RemoteAddr 请求者的 ip 必须是指定范围 - RemoteAddr=192.168.1.1/24
Weight 权重处理 -

参考: https://docs.spring.io/spring-cloud-gateway/docs/2.2.6.RELEASE/reference/html/#gateway-request-predicates-factories

PredicateFactory 的作用是什么?

  • 读取用户定义的断言条件,对请求做出判断

Path=/user/** 是什么含义?

  • 路径是以/user开头的就认为是符合的

# 9.4. 路由的过滤器配置

# 9.4.1. GatewayFilter

GatewayFilter 是网关中提供的一种过滤器,可以对进入网关的请求和微服务返回的响应做处理

image-20260216161350512

Spring 提供了 31 种不同的路由过滤器工厂。例如:

名称 说明
AddRequestHeader 给当前请求添加一个请求头
RemoveRequestHeader 移除请求中的一个请求头
AddResponseHeader 给响应结果中添加一个响应头
RemoveResponseHeader 从响应结果中移除有一个响应头
RequestRateLimiter 限制请求的流量

参考: https://docs.spring.io/spring-cloud-gateway/docs/2.2.6.RELEASE/reference/html/#gatewayfilter-factories

# 9.4.2. 案例

给所有进入 userservice 的请求添加一个请求头(AuthMethod=UsernamePassword)

spring:
  cloud:
    gateway:
      routes:
        - id: user-service # 路由标识,唯一
          uri: lb://userservice # 路由的目标地址
          predicates: # 路由断言,判断请求是否符合规则
            - Path=/user/** # 路径断言,判断路径是否以 /user 打头
          filters:
            - AddRequestHeader=AuthMethod, UsernamePassword
@GetMapping("/{id}")
public User queryById(@PathVariable("id") Long id, @RequestHeader(name = "AuthMethod", required = false) String header) {
    log.info("AuthMethod={}", header);
    return userService.queryById(id);
}

# 9.4.3. 默认过滤器

如果要对所有的路由都生效,则可以将过滤器工厂写到 default 下。格式如下:

spring:
  cloud:
    gateway:
      default-filters:
        - AddRequestHeader=AuthMethod, UsernamePassword

# 9.4.4. 小结

过滤器的作用是什么?

  • 对路由的请求或响应做加工处理,比如添加请求头
  • 配置在路由下的过滤器只对当前路由的请求生效

defaultFilters 的作用是什么?

  • 对所有路由都生效的过滤器

# 9.5. 全局过滤器

# 9.5.1. GlobalFilter

全局过滤器的作用也是处理一切进入网关的请求和微服务响应,与 GatewayFilter 的作用一样。

区别在于 GatewayFilter 通过配置定义,处理逻辑是固定的。而 GlobalFilter 的逻辑需要自己写代码实现。

定义方式是实现 GlobalFilter 接口。

public interface GlobalFilter {   
    /**    
     *  处理当前请求,有必要的话通过{@link GatewayFilterChain}将请求交给下一个过滤器处理
     *
     * @param exchange 请求上下文,里面可以获取 Request、Response 等信息    
     * @param chain 用来把请求委托给下一个过滤器     
     * @return {@code Mono<Void>} 返回标示当前过滤器业务结束    
     */   
    Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);
}

# 9.5.2. 案例

定义全局过滤器,拦截并判断用户身份

需求:定义全局过滤器,拦截请求,判断请求的参数是否满足下面条件:

  • 参数中是否有 authorization
  • authorization 参数值是否为 admin

如果同时满足则放行,否则拦截并返回 401

@Order(-1) // 数值越小,优先级越高,默认为最低
@Component
public class AuthorizeFilter implements GlobalFilter {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 1.获取请求参数
        MultiValueMap<String, String> params = exchange.getRequest().getQueryParams();
        // 2.获取authorization参数
        String auth = params.getFirst("authorization");
        // 3.校验
        if ("admin".equals(auth)) {
            // 放行
            return chain.filter(exchange);
        }
        // 4.拦截
        // 4.1.禁止访问
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(HttpStatus.UNAUTHORIZED); // 401

        // 4.2.结束处理
        return response.setComplete();
    }
}

测试: http://localhost:10010/user/1?authorization=admin

# 9.5.3. 小结

全局过滤器的作用是什么?

  • 对所有路由都生效的过滤器,并且可以自定义处理逻辑

实现全局过滤器的步骤?

  • 实现 GlobalFilter 接口
  • 添加 @Order 注解或实现 Ordered 接口
  • 编写处理逻辑

# 9.6. 过滤器链执行顺序

# 9.6.1. 过滤器执行顺序

请求进入网关会碰到三类过滤器:当前路由的过滤器、DefaultFilter、GlobalFilter

请求路由后,会将 当前路由过滤器、DefaultFilter、GlobalFilter,合并到一个过滤器链(集合)中,排序后依次执行每个过滤器

每一个过滤器都必须指定一个 int 类型的 order 值,order 值越小,优先级越高,执行顺序越靠前。

GlobalFilter 通过实现 Ordered 接口,或者添加 @Order 注解来指定 order 值,由我们自己指定

路由过滤器、defaultFilter 的 order 由 Spring 指定,默认是按照声明顺序从 1 递增。

当过滤器的 order 值一样时,会按照 defaultFilter > 路由过滤器 > GlobalFilter 的顺序执行。

# 9.6.2. 示例

配置过滤器:

server:
  port: 10010
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: localhost:8848
    gateway:
      default-filters:
        - DefaultFilterA     # order(1)
        - DefaultFilterB     # order(2)
      routes:
        - id: user-service 
          uri: lb://userservice 
          predicates: 
            - Path=/user/** 
          filters:
            - 当前路由过滤器A # order(1)
            - 当前路由过滤器B # order(2)

自定义过滤器:

@Order(1)
@Component
public class AuthorizeFilter implements GlobalFilter {
    // ...
}

当访问 /user/1 时,过滤器的执行顺序: DefaultFilterA -> 当前路由过滤器A -> AuthorizeFilter

# 9.6.3. 小结

路由过滤器、defaultFilter、全局过滤器的执行顺序?

  • order 值越小,优先级越高
  • 当 order 值一样时,顺序是 defaultFilter 最先,然后是局部的路由过滤器,最后是全局过滤器

# 9.7. 网关的 cors 跨域配置

# 9.7.1. 跨域问题

跨域:域名不一致就是跨域

主要包括:

  • 域名不同: www.taobao.com 和 www.taobao.org , www.jd.com 和 miaosha.jd.com
  • 域名相同,端口不同:localhost:8080 和 localhost8081

跨域问题:浏览器 禁止 请求的发起者 与 服务端 发生跨域 ajax 请求,请求被浏览器拦截的问题

解决方案:CORS

# 9.7.2. CORS

网关处理跨域采用的同样是 CORS 方案,并且只需要简单配置即可实现

spring:
  cloud:
    gateway:
      globalcors: # 全局的跨域处理
        add-to-simple-url-handler-mapping: true # 解决 options 请求被拦截问题
        corsConfigurations:
          '[/**]':
            allowedOrigins: "*" # 允许哪些网站的跨域请求
#              - "http://localhost:8090"
#              - "http://www.leyou.com"
            allowedMethods: "*" # 允许的跨域ajax的请求方式
#              - "GET"
#              - "POST"
#              - "DELETE"
#              - "PUT"
#              - "OPTIONS"
            allowedHeaders: "*" # 允许在请求中携带的头信息
            allowCredentials: true # 是否允许携带cookie
            maxAge: 360000 # 这次跨域检测的有效期

# 9.7.3. 小结

CORS跨域要配置的参数包括哪几个?

  • 允许哪些域名跨域?
  • 允许哪些请求头?
  • 允许哪些请求方式?
  • 是否允许使用 cookie?
  • 有效期是多久?

# 10. Docker

# 10.1. 初识 Docker - 什么是 docker

大型项目组件较多,运行环境也较为复杂,部署时会碰到一些问题:

  • 依赖关系复杂,容易出现兼容性问题
  • 开发、测试、生产环境有差异

Docker如何解决依赖的兼容问题的?

  • 将应用的 Libs(函数库)、Deps(依赖)、配置 与 应用 一起打包
  • 将每个应用放到一个隔离容器去运行,避免互相干扰

Docker 如何解决不同系统环境的问题?

  • Docker 将用户程序与所需要调用的系统(比如 Ubuntu)函数库一起打包
  • Docker 运行到不同操作系统时,直接基于打包的库函数,借助于操作系统的 Linux 内核来运行

Docker 如何解决大型项目依赖关系复杂,不同组件依赖的兼容性问题?

  • Docker 允许开发中将应用、依赖、函数库、配置一起打包,形成可移植镜像
  • Docker 应用运行在容器中,使用沙箱机制,相互隔离

Docker 如何解决开发、测试、生产环境有差异的问题

  • Docker 镜像中包含完整运行环境,包括系统函数库,仅依赖系统的 Linux 内核,因此可以在任意 Linux 操作系统上运行

Docker 是一个快速交付应用、运行应用的技术:

  • 可以将程序及其依赖、运行环境一起打包为一个镜像,可以迁移到任意 Linux 操作系统
  • 运行时利用沙箱机制形成隔离容器,各个应用互不干扰
  • 启动、移除都可以通过一行命令完成,方便快捷

# 10.2. 初识 Docker - Docker 和虚拟机的差别

虚拟机(virtual machine)是在操作系统中模拟硬件设备,然后运行另一个操作系统,比如在 Windows 系统里面运行 Ubuntu 系统,这样就可以运行任意的Ubuntu应用了。

image-20260216200051640

Docker 和虚拟机的差异:

  • docker 是一个系统进程;虚拟机是在操作系统中的操作系统
  • docker 体积小、启动速度快、性能好;虚拟机体积大、启动速度慢、性能一般

# 10.3. 初识 Docker - Docker 架构

镜像和容器:

  • 镜像(Image):Docker 将应用程序及其所需的依赖、函数库、环境、配置等文件打包在一起,称为镜像。
  • 容器(Container):镜像中的应用程序运行后形成的进程就是容器,只是 Docker 会给容器做隔离,对外不可见。

Docker 和 DockerHub:

  • DockerHub:DockerHub 是一个 Docker 镜像的托管平台。这样的平台称为 Docker Registry。
  • 国内也有类似于 DockerHub 的公开服务,比如 网易云镜像服务、阿里云镜像库等。

Docker 是一个 CS 架构的程序,由两部分组成:

  • 服务端(server):Docker 守护进程,负责处理 Docker 指令,管理镜像、容器等
  • 客户端(client):通过命令或 RestAPI 向 Docker 服务端发送指令。可以在本地或远程向服务端发送指令

image-20260216201403635

镜像:

  • 将应用程序及其依赖、环境、配置打包在一起

容器:

  • 镜像运行起来就是容器,一个镜像可以运行多个容器

Docker结构:

  • 服务端:接收命令或远程请求,操作镜像或容器
  • 客户端:发送命令或者请求到Docker服务端

DockerHub:

  • 一个镜像托管的服务器,类似的还有阿里云镜像服务,统称为 DockerRegistry

# 10.4. 初识 Docker - Docker 的安装

# 10.4.1. 1.1.卸载(可选)

如果之前安装过旧版本的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

# 10.4.2. 安装 docker

首先需要大家虚拟机联网,安装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

然后输入命令:

yum install -y docker-ce

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

# 10.4.3. 启动 docker

Docker 应用需要用到各种端口,逐一去修改防火墙设置。非常麻烦,因此建议大家直接关闭防火墙!

# 关闭
systemctl stop firewalld

# 禁止开机启动防火墙
systemctl disable firewalld

# 查看防火墙状态
systemctl status firewalld

通过命令启动 docker:

# 启动docker服务
systemctl start docker  

# 停止docker服务
systemctl stop docker  

# 重启docker服务
systemctl restart docker  

验证 docker 已启动:

# 方式 1: 查看服务状态
systemctl status docker  


# 方式2: 查看 docker 版本
docker -v

# Docker version 26.1.4, build 5650f9b

# 10.4.4. 配置镜像加速

docker官方镜像仓库网速较差,我们需要设置国内镜像服务:

sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json <<EOF
{
  "registry-mirrors": [
    "https://docker.1ms.run",
    "https://hub.rat.dev",
    "https://dockerproxy.net",
    "https://proxy.vvvv.ee"
  ]
}
EOF
sudo systemctl daemon-reload
sudo systemctl restart docker
# 查看是否配置成功
docker info

参考: DockerHub 国内加速镜像列表 (opens new window)

# 10.5. 使用 Docker - 镜像命令

# 10.5.1. 镜像名称

镜像名称一般分两部分组成:[repository]:[tag]

在没有指定 tag 时,默认是 latest,代表最新版本的镜像

# 10.5.2. 镜像操作命令

image-20260217141043550

# 10.5.3. 案例

步骤 1: 到 官网 (opens new window) 搜索 nginx (注意要翻墙),查看 镜像名称及版本

步骤 2: 从 DockerHub 拉取 nginx 镜像

# 拉取
docker pull nginx
# Using default tag: latest

# 查看
docker images
# REPOSITORY   TAG       IMAGE ID       CREATED       SIZE
# nginx        latest    5cdef4ac3335   12 days ago   161MB

步骤 3: 导出 nginx 镜像到本地

docker save --help
# Usage:  docker save [OPTIONS] IMAGE [IMAGE...]

# Save one or more images to a tar archive (streamed to STDOUT by default)

# Aliases:
#   docker image save, docker save

# Options:
#   -o, --output string   Write to a file, instead of STDOUT

docker save -o nginx.tar nginx:latest

ll
# 总用量 160640
# -rw-------. 1 root root 164491264 2月  17 22:35 nginx.tar

步骤 4: 删除已安装的 nginx 镜像

docker rmi nginx:latest
# Untagged: nginx:latest

步骤 5: 从本地载入 nginx 镜像

docker load -i nginx.tar

# 10.6. 使用 Docker - 容器命令

# 10.6.1. 介绍

image-20260217152839417

# 10.6.2. 创建并运行 Nginx 容器

命令:

docker run \
  --name 容器名称 \
  -p 宿主机端口:容器端口 \
  -d \
  镜像名称

说明:

  • docker run: 创建并运行一个容器
  • --name: 容器名称。给容器起一个名字,比如叫做 mn
  • -p: 端口隐射。将宿主机端口与容器端口映射,冒号左侧是宿主机端口,右侧是容器端口
  • -d: 后台运行容器

示例:

docker run --name mn -p 80:80 -d nginx
# 输出 容器的 ID
# 5e07f6f52c092b9f03303bfe7dc3ef41b01597b9f84553e96c9349a12ebb9879

# 查看 所有运行的容器
docker ps
# CONTAINER ID   IMAGE     STATUS          PORTS                               NAMES
# 5e07f6f52c09   nginx     Up 46 seconds   0.0.0.0:80->80/tcp, :::80->80/tcp   mn

# 持续查看 mn 容器的日志
docker logs -f mn

# 10.6.3. 进入 Nginx 容器,修改 index.html 的内容

步骤 1: 进入容器

docker exec -it mn bash

# docker exec :进入容器内部,执行一个命令
# -it  : 给当前进入的容器创建一个标准输入、输出终端,允许我们与容器交互
# mn   :要进入的容器的名称
# bash :进入容器后执行的命令,bash 是一个 linux 终端交互命令

步骤 2:进入 nginx 的 HTML 所在目录 /usr/share/nginx/html

cd /usr/share/nginx/html

步骤 3: 修改 index.html 的内容

sed -i 's#Welcome to nginx#张三,你好#g' index.html
sed -i 's#<head>#<head><meta charset="utf-8">#g' index.html

步骤 4: 退出容器

exit

示例:

# 停止 容器
docker stop mn

# 查看所有容器的状态,包括已停止的,默认只能查看正在运行的
docker ps -a

# 启动 容器
docker start mn

# 强制删除 正在运行 的容器
docker rm -f mn

查看容器状态:

  • docker ps
  • 添加 -a 参数查看所有状态的容器

删除容器:

  • docker rm
  • 不能删除运行中的容器,除非添加 -f 参数

进入容器:

  • 命令是 docker exec -it [容器名] [要执行的命令]
  • exec 命令可以进入容器修改文件,但是在容器内修改文件是不推荐的

# 10.6.4. 创建并运行 redis 容器

# 创建并运行容器
docker run --name mr -p 6379:6379 -d redis redis-server --appendonly yes

# 进入容器
# 进入容器 直接执行 redis-cli
# docker exec -it mr redis-cli

docker exec -it mr bash

# 容器内操作: 打开 redis 客户端
redis-cli

# 设置 key
set num 666

# 查看
keys *

# 10.7. 使用 Docker - 数据卷

# 10.7.1. 命令

数据卷(volume)是一个虚拟目录,指向宿主机文件系统中的某个目录。

image-20260217171437713

数据卷的作用:

  • 将容器与数据分离,解耦合,方便操作容器内数据,保证数据安全

数据卷操作:

  • docker volume create : 创建一个volume
  • docker volume ls : 列出所有的volume
  • docker volume inspect : 显示一个或多个volume的信息
  • docker volume rm : 删除一个或多个指定的volume
  • docker volume prune : 删除未使用的volume

示例:

docker volume create html

docker volume ls

docker volume inspect html
# [
#     {
#         "CreatedAt": "2026-02-18T01:17:24+08:00",
#         "Driver": "local",
#         "Mountpoint": "/var/lib/docker/volumes/html/_data",
#         "Name": "html",
#         "Scope": "local"
#     }
# ]

# 10.7.2. 挂载数据卷 - nginx

语法:

# 数据卷不存在时,会自动创建
docker run --name 容器名称 -v 数据卷名称:容器内的路径 -p 宿主端口:容器内的端口 -d 镜像名称

示例:

# 创建、运行、挂载 nginx
docker run --name mn -v html:/usr/share/nginx/html -p 80:80 -d nginx

# 查看数据卷的位置
docker volume inspect html
# "Mountpoint": "/var/lib/docker/volumes/html/_data",

# 切换到卷位置
cd /var/lib/docker/volumes/html/_data

# 修改 index.html

# 10.7.3. 挂载目录或文件 - mysql

创建并运行一个 MySQL 容器,将宿主机目录直接挂载到容器

提示:目录挂载与数据卷挂载的语法是类似的:

  • -v [宿主机目录]:[容器内目录]
  • -v [宿主机文件]:[容器内文件]

实现思路如下:

  1. 拉取 mysql:5.7.25
  2. 创建目录: /tmp/mysql/data
  3. 创建目录: /tmp/mysql/conf
    • hmy.cnf 文件上传到该目录
  4. 创建并运行 MySQL 容器,要求
    • 挂载 /tmp/mysql/data 到 mysql 容器内数据存储目录
    • 挂载 /tmp/mysql/conf/hmy.cnf 到 mysql 容器的配置文件
    • 设置 MySQL 密码

hmy.cnf:

[mysqld]
skip-name-resolve
character_set_server=utf8
datadir=/var/lib/mysql
server-id=1000

示例:

# 拉取 mysql 镜像
docker pull mysql:5.7.25

# 创建 数据 目录
mkdir -p /tmp/mysql/data
# 创建 配置 目录
mkdir -p /tmp/mysql/conf

# 创建容器
docker run  \
  --name mysql  \
  -e MYSQL_ROOT_PASSWORD=123456 \
  -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.25
  

说明:

  • -e : 指定环境变量
  • 配置文件:
    • 主配置文件 /etc/my.cnf , 不建议修改
    • 扩展目录 /etc/mysql/conf.d , 存放在该目录的文件会 合并 到主配置文件

# 10.7.4. 数据卷挂载的方式对比

image-20260217185106352

docker run 的命令中通过 -v 参数挂载文件或目录到容器中:

  • -v volume名称:容器内目录
  • -v 宿主机文件:容器内文件
  • -v 宿主机目录:容器内目录

数据卷挂载 与 目录直接挂载的:

  • 数据卷挂载耦合度低,由 docker 来管理目录,但是目录较深,不好找
  • 目录挂载耦合度高,需要我们自己管理目录,不过目录容易寻找查看

# 10.8. 自定义镜像

# 10.8.1. 镜像结构

镜像是将应用程序及其需要的系统函数库、环境、配置、依赖打包而成。

镜像是分层结构,每一层称为一个Layer :

  • BaseImage 层:包含基本的系统函数库、环境变量、文件系统
  • Entrypoint:入口,是镜像中应用启动的命令
  • 其它:在 BaseImage 基础上添加依赖、安装程序、完成整个应用的安装和配置

# 10.8.2. Dockerfile 语法

Dockerfile 就是一个文本文件,其中包含一个个的指令(Instruction),用指令来说明要执行什么操作来构建镜像。每一个指令都会形成一层 Layer 。

指令 说明 示例
FROM 指定基础镜像 FROM centos:6
ENV 设置环境变量,可在后面指令使用 ENV key value
COPY 拷贝本地文件到镜像的指定目录 COPY ./mysql-5.7.rpm /tmp
RUN 执行Linux的shell命令,一般是安装过程的命令 RUN yum install gcc
EXPOSE 指定容器运行时监听的端口,是给镜像使用者看的 EXPOSE 8080
ENTRYPOINT 镜像中应用的启动命令,容器运行时调用 ENTRYPOINT java -jar xx.jar

更新详细语法说明,请参考官网文档: https://docs.docker.com/reference/dockerfile

# 10.8.3. 基于 Ubuntu 镜像构建 Java 项目

基于 Ubuntu 镜像构建一个新镜像,运行一个 java 项目

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

步骤 1:新建一个空文件夹 /tmp/docker-demo

步骤 2:拷贝 课前资料中的文件 到 docker-demo 目录

  • docker-demo.jar
  • jdk8.tar.gz
  • Dockerfile

步骤 3:进入 docker-demo 目录,运行命令

docker build -t javaweb:1.0 .
# -t: 指定 tag

运行:

# 查看镜像
docker images

# 运行
docker run --name web -p 8090:8090 -d javaweb:1.0

访问: http://192.168.124.10:8090/hello/count

# 10.8.4. 基于 java:8-alpine 镜像构建 Java 项目镜像

Dockerfile:

# 指定基础镜像
# java:8-alpine 已经不存在
FROM amazoncorretto:8
# FROM openjdk:8u292-jre

# 拷贝java项目的包
COPY ./docker-demo.jar /tmp/app.jar

# 暴露端口
EXPOSE 8090
# 入口,java项目的启动命令
ENTRYPOINT java -jar /tmp/app.jar

构建:

docker build -t javaweb:2.0 .

# 10.8.5. 小结

Dockerfile 的本质是一个文件,通过指令描述镜像的构建过程

Dockerfile 的第一行必须是 FROM,从一个基础镜像来构建

基础镜像 可以是基本操作系统,如 Ubuntu; 也可以是其他人制作好的镜像,例如:java:8-alpine

# 10.9. Docker-Compose

# 10.9.1. 初始 Compose

Docker Compose 可以基于 Compose 文件帮我们快速的部署分布式应用,而无需手动一个个创建和运行容器!

Compose 文件是一个文本文件,通过指令定义集群中的每个容器如何运行。

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"

DockerCompose 的详细语法参考官网:https://docs.docker.com/reference/compose-file/

# 10.9.2. 安装 DockerCompose

Linux 下需要通过命令下载:

# 安装
curl -L https://github.com/docker/compose/releases/download/1.23.1/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose

修改文件权限:

# 修改权限
chmod +x /usr/local/bin/docker-compose

Base 自动补全命令:

# 补全命令
curl -L https://raw.githubusercontent.com/docker/compose/1.29.1/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose

# 10.9.3. 部署微服务集群

将之前学习的 cloud-demo 微服务集群利用 DockerCompose 部署

步骤 1: 准备部署资源

cloud-demo/
  docker-compose.yml

  mysql/
    conf/
      hmy.cnf
    data/      

  gateway/
    app.jar
    Dockerfile

  order-service/
    app.jar
    Dockerfile

  user-service/
    app.jar
    Dockerfile

hmy.cnf:

[mysqld]
skip-name-resolve
character_set_server=utf8
datadir=/var/lib/mysql
server-id=1000

Dockerfile:

FROM openjdk:8u292-jre
COPY ./app.jar /tmp/app.jar
ENTRYPOINT java -jar /tmp/app.jar

docker-compose.yml:

version: "3.2"

services:
  nacos:
    image: nacos/nacos-server:1.4.2
    environment:
      MODE: standalone
    ports:
      - "8848:8848"
  mysql:
    image: mysql:5.7.25
    environment:
      MYSQL_ROOT_PASSWORD: 123456
    volumes:
      - "$PWD/mysql/data:/var/lib/mysql"
      - "$PWD/mysql/conf:/etc/mysql/conf.d/"
    ports:
      - "3306:3306"
  userservice:
    build: ./user-service
  orderservice:
    build: ./order-service
  gateway:
    build: ./gateway
    ports:
      - "10010:10010"

步骤 2: 修改微服务之间的访问地址,使用 docker 容器名称访问

spring:
  application:
    name: gateway
  cloud:
    nacos:
#      server-addr: localhost:8848
      server-addr: nacos:8848 # docker nacos 容器名称


spring:
  application:
    name: orderservice
  datasource:
#    url: jdbc:mysql://localhost:3306/cloud_order?useSSL=false
    url: jdbc:mysql://mysql:3306/cloud_order?useSSL=false
    username: root
    password: 123456
    driver-class-name: com.mysql.jdbc.Driver
  cloud:
    nacos:
#      server-addr: localhost:8848 # 默认地址
      server-addr: nacos:8848 # 默认地址


spring:
  application:
    name: userservice
  datasource:
#    url: jdbc:mysql://localhost:3306/cloud_user?useSSL=false
    url: jdbc:mysql://mysql:3306/cloud_user?useSSL=false
  cloud:
    nacos:
#      server-addr: localhost:8848
      server-addr: nacos:8848

# bootstrap.yml
spring:
  application:
    name: userservice # 服务名称
  profiles:
    active: dev #开发环境,这里是dev
  cloud:
    nacos:
#      server-addr: localhost:8848 # Nacos地址
      server-addr: nacos:8848 # Nacos地址
      config:
        file-extension: yaml # 文件后缀名

步骤 3: 修改微服务的 pom.xml 后进行打包:

<build>
    <!-- 打成 jar 包后的名称 -->
    <finalName>app</finalName> 
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

步骤 4: 将 cloud-demo 文件夹上传到 /tmp/ 目录

步骤 5: 切换到 /tmp/cloud-demo 执行部署

cd /tmp/cloud-demo

docker-compose up -d

# 先重启 nacos
docker-compose restart nacos

# 再重启 微服务,保证 nacos 先启动,不然微服务会注册失败 且不会重试
docker-compose restart userservice orderservice gateway

# 查看具体微服务的日志
docker-compose logs -f userservice

# 查看所有的日志
docker-compose logs -f

访问:

  • nacos 管理后台: http://192.168.124.10:8848/nacos/index.html
  • 测试: http://192.168.124.10:10010/user/1?authorization=admin

# 10.10. Docker 镜像仓库

# 10.10.1. 常见镜像仓库服务

镜像仓库( Docker Registry )有公共的和私有的两种形式:

  • 公共仓库:例如 Docker 官方的 Docker Hub,国内也有一些云服务商提供类似于 Docker Hub 的公开服务,比如 网易云镜像服务、DaoCloud 镜像服务、阿里云镜像服务等。
  • 除了使用公开仓库外,用户还可以在本地搭建私有 Docker Registry。企业自己的镜像最好是采用私有Docker Registry来实现。

# 10.10.2. 私有镜像仓库 - 简化版

Docker 官方的 Docker Registry 是一个基础版本的 Docker 镜像仓库,具备仓库管理的完整功能,但是没有图形化界面。

搭建方式比较简单,命令如下:

docker run -d \
    --restart=always \
    --name registry | \
    -p 5000:5000 \
    -v registry-data:/var/lib/registry \
    registry

命令中挂载了一个数据卷 registry-data 到容器内的 /var/lib/registry 目录,这是私有镜像库存放数据的目录。

访问 http://YourIp:5000/v2/_catalog 可以查看当前私有镜像服务中包含的镜像

# 10.10.3. 私有镜像仓库 - 图形化界面

使用 DockerCompose 部署带有图象界面的 DockerRegistry

步骤 1: 创建目录 /tmp/my-private-docker-registry

mkdir -p /tmp/my-private-docker-registry
cd /tmp/my-private-docker-registry

步骤 2: 创建文件 docker-compose.yml , 内容如下

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=Xxx私有仓库
      - REGISTRY_URL=http://registry:5000
    depends_on:
      - registry

步骤 3: 在 /etc/docker/daemon.json 添加 insecure-registries key

我们的私服采用的是 http 协议,默认不被 Docker 信任,所以需要做一个配置

# 打开要修改的文件
vi /etc/docker/daemon.json

# 添加内容:
"insecure-registries":["http://192.168.124.10:8080"]

# 重加载
systemctl daemon-reload
# 重启docker
systemctl restart docker

步骤 4: 运行

docker-compose up -d

步骤 5: 访问 http://192.168.124.10:8080

步骤 6: 创建本地镜像

# 将 nginx:latest 制作成本地镜像
docker tag nginx:latest 192.168.124.10:8080/nginx:1.0

步骤 7: 推送

docker push 192.168.124.10:8080/nginx:1.0

步骤 8: 拉取

docker pull 192.168.124.10:8080/nginx:1.0

# 10.10.4. 小结

推送本地镜像到仓库前都必须重命名(docker tag)镜像,以镜像仓库地址为前缀

镜像仓库推送前需要把仓库地址配置到 docker 服务的 daemon.json 文件中,被 docker 信任

推送使用 docker push 命令

拉取使用 docker pull 命令

# 11. 服务异步通讯

# 11.1. 初识 MQ

# 11.1.1. 同步通讯

微服务间基于 Feign 的调用就属于同步方式,存在一些问题。

同步调用的优点:

  • 时效性较强,可以立即得到结果

同步调用的问题:

  • 耦合度高: 每次加入新的需求,都要修改原来的代码
  • 性能和吞吐能力下降: 调用者需要等待服务提供者响应,如果调用链过长则响应时间等于每次调用的时间之和。
  • 有额外的资源消耗: 调用链中的每个服务在等待响应过程中,不能释放请求占用的资源,高并发场景下会极度浪费系统资源
  • 有级联失败问题: 如果服务提供者出现问题,所有调用方都会跟着出问题,如同多米诺骨牌一样,迅速导致整个微服务群故障

# 11.1.2. 异步通讯

异步调用常见实现就是事件驱动模式

image-20260218163312820

异步通信的优点:

  • 耦合度低
  • 吞吐量提升
  • 故障隔离
  • 流量削峰

异步通信的缺点:

  • 依赖于 Broker 的可靠性、安全性、吞吐能力
  • 架构复杂了,业务没有明显的流程线,不好追踪管理

# 11.1.3. MQ 常见框架

MQ (MessageQueue),中文是消息队列,字面来看就是存放消息的队列。也就是事件驱动架构中的 Broker。

- RabbitMQ ActiveMQ RocketMQ Kafka
公司/社区 Rabbit Apache 阿里 Apache
开发语言 Erlang Java Java Scala&Java
协议支持 AMQP,XMPP,SMTP,STOMP OpenWire,STOMP,REST,XMPP,AMQP 自定义协议 自定义协议
可用性 一般
单机吞吐量 一般 非常高
消息延迟 微秒级 毫秒级 毫秒级 毫秒以内
消息可靠性 一般 一般

海量数据,比如日志,可以使用 kafka

业务数据,可以使用 RabbitMQ 、 RocketMQ

如果要深度定制,可以使用 RocketMQ

# 11.2. RabbitMQ 快速入门

# 11.2.1. 介绍

RabbitMQ 是基于 Erlang 语言开发的开源消息通信中间件

官网地址:https://www.rabbitmq.com/

# 11.2.2. 安装

在线拉取

docker pull rabbitmq:3.8.5-management

执行下面的命令来运行MQ容器:

docker run \
  -e RABBITMQ_DEFAULT_USER=root \
  -e RABBITMQ_DEFAULT_PASS=123456 \
  --name mq \
  --hostname mq1 \
  -p 15672:15672 \
  -p 5672:5672 \
  -d \
  rabbitmq:3.8.5-management

说明:

  • --hostname mq1 : 做集群时会用到
  • -p 15672:15672: 管理界面的端口, 账号为 RABBITMQ_DEFAULT_USER / RABBITMQ_DEFAULT_PASS
  • -p 5672:5672 : 消息通信

访问: http://192.168.124.10:15672

# 11.2.3. 概念

image-20260218173631899

RabbitMQ 中的几个概念:

  • channel:操作MQ的工具
  • exchange:路由消息到队列中
  • queue:缓存消息
  • virtual host:虚拟主机,是对 queue、exchange 等资源的逻辑分组

虚拟主机 跟 多租户 一样,每个用户的数据都是隔离的,都有独属自己的虚拟主机

  • root/123456 -> /
  • zhangsan/123456 -> /zhangsan

# 11.2.4. 常见消息模型

MQ 的官方文档中给出的 5 个 MQ 的 Demo 示例,对应了几种不同的用法

  • 基本消息队列(BasicQueue)
  • 工作消息队列(WorkQueue)

"Hello World!":

Work Queues:

发布订阅(Publish、Subscribe) 又根据交换机类型不同分为三种:

  • Fanout Exchange:广播
  • Direct Exchange:路由
  • Topic Exchange:主题

Publish/Subscribe

Routing

Topics

# 11.2.5. HelloWorld 案例

image-20260218183551081

官方的 HelloWorld 是基于最基础的消息队列模型来实现的,只包括三个角色:

  • publisher:消息发布者,将消息发送到队列 queue
  • queue:消息队列,负责接受并缓存消息
  • consumer:订阅队列,处理队列中的消息

基本消息队列的消息发送流程:

  • 建立 connection
  • 创建 channel
  • 利用 channel 声明队列
  • 利用 channel 向队列发送消息

基本消息队列的消息接收流程:

  • 建立 connection
  • 创建 channel
  • 利用 channel 声明队列
  • 定义 consumer 的消费行为 handleDelivery()
  • 利用 channel 将消费者与队列绑定

生产者:

public class PublisherTest {
    @Test
    public void testSendMessage() throws IOException, TimeoutException {
        // 1.建立连接
        ConnectionFactory factory = new ConnectionFactory();
        // 1.1.设置连接参数,分别是:主机名、端口号、vhost、用户名、密码
        factory.setHost("192.168.124.10");
        factory.setPort(5672);
        factory.setVirtualHost("/");
        factory.setUsername("root");
        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();
    }
}

消费者:

public class ConsumerTest {
    public static void main(String[] args) throws IOException, TimeoutException {
        // 1.建立连接
        ConnectionFactory factory = new ConnectionFactory();
        // 1.1.设置连接参数,分别是:主机名、端口号、vhost、用户名、密码
        factory.setHost("192.168.124.10");
        factory.setPort(5672);
        factory.setVirtualHost("/");
        factory.setUsername("root");
        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("等待接收消息。。。。");
    }
}

# 11.3. SpringAMQP

# 11.3.1. 基本介绍

SpringAmqp 的官方地址:https://spring.io/projects/spring-amqp

AMQP:

  • Advanced Message Queuing Protocol
  • 是用于在应用程序之间传递业务消息的开放标准。
  • 该协议与语言和平台无关,更符合微服务中独立性的要求。

Spring AMQP:

  • Spring AMQP 是基于 AMQP 协议定义的一套API规范,提供了模板来发送和接收消息。
  • 包含两部分,其中 spring-amqp 是基础抽象,spring-rabbit 是底层的默认实现。

# 11.3.2. Basic Queue 的消息发送

流程如下:

  • 在父工程中引入 spring-amqp 的依赖
  • 在 publisher 服务中利用 RabbitTemplate 发送消息到 simple.queue 这个队列
  • 在 consumer 服务中编写消费逻辑,绑定 simple.queue 这个队列

步骤 1: 依赖。引入 AMQP 依赖

因为 publisher 和 consumer 服务都需要 amqp 依赖,因此这里把依赖直接放到父工程 mq-demo 中

<!--AMQP依赖,包含RabbitMQ-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

步骤 2: 配置

在 publisher 服务中编写 application.yml ,添加 mq 连接信息

spring:
  rabbitmq:
    host: 192.168.124.10
    port: 5672
    virtual-host: /
    username: root
    password: 123456

步骤 3: 发送消息。

在 publisher 服务中新建一个测试类,编写测试方法

@RunWith(SpringRunner.class)
@SpringBootTest
public class SpringAmqpTest {
    @Autowired
    private RabbitTemplate rabbitTemplate;

    @Test
    public void testSimpleQueue() {
        String queueName = "simple.queue";
        String message = "hello, spring amqp!";
        rabbitTemplate.convertAndSend(queueName, message);
    }
}

# 11.3.3. Basic Queue 的消息接收

步骤 1: 依赖。【同 publisher】

步骤 2: 配置。【同 publisher】

步骤 3: 接收消息。

@Component
public class SpringRabbitMqListener {
    @RabbitListener(queues = "simple.queue")
    public void listenSimpleQueueMessage(String msg) {
        System.out.println(String.format("spring 消费者接收到消息 :【%s】", msg));
    }
}

小结:

SpringAMQP如何接收消息?

  • 引入 amqp 的 starter 依赖
  • 配置 RabbitMQ 地址
  • 定义类,添加 @Component 注解
  • 类中声明方法,添加 @RabbitListener 注解,方法参数就时消息

注意:消息一旦消费就会从队列删除,RabbitMQ没有消息回溯功能

# 11.3.4. WorkQueue 模型

Work Queue 工作队列

Work queue,工作队列,可以提高消息处理速度,避免队列消息堆积

image-20260219115219239

模拟 WorkQueue,实现一个队列绑定多个消费者

基本思路如下:

  • 在 publisher 服务中定义测试方法,每秒产生 50 条消息,发送到 simple.queue
  • 在 consumer 服务中定义两个消息监听者,都监听 simple.queue 队列
  • 消费者1 每秒处理 50 条消息,消费者2 每秒处理 10 条消息

生产者:

@Test
public void testWorkQueue() throws InterruptedException {
    String queueName = "simple.queue";
    String message = "hello, spring amqp! ---";
    for (int i = 1; i <= 50; i++) {
        rabbitTemplate.convertAndSend(queueName, message + i);
        Thread.sleep(20);
    }
}

消费者:

@Component
public class SpringRabbitMqListener {
    @RabbitListener(queues = "simple.queue")
    public void listenWorkQueueMessage1(String msg) throws InterruptedException {
        System.out.println(String.format("消费者1 接收到消息 :【%s】 --- %s", msg, LocalTime.now()));
        Thread.sleep(20);
    }
    @RabbitListener(queues = "simple.queue")
    public void listenWorkQueueMessage2(String msg) throws InterruptedException {
        System.err.println(String.format("消费者2 接收到消息 :【%s】 --- %s", msg, LocalTime.now()));
        Thread.sleep(200);
    }
}
spring:
  rabbitmq:
    listener:
      simple:
        prefetch: 1 # 每次只能获取一条消息,处理完成才能获取下一个消息

说明:

  • 如果不设置 prefetch
    • 先生产 50 条消息,再启动消费者;则 消费者1 取完 50 条消息,消费者2 不处理
    • 先启动消费者,再生产 50 条消息;则 消费者1 取 25 条消息,消费者2 取 25 条消息
  • 设置 prefetch=1, 则取一条处理一条

Work 模型的使用:

  • 多个消费者绑定到一个队列,同一条消息只会被一个消费者处理
  • 通过设置 prefetch 来控制消费者预取的消息数量

# 11.3.5. 发布订阅模型介绍

发布订阅模式与之前案例的区别就是允许将同一消息发送给多个消费者。实现方式是加入了exchange(交换机)。

image-20260219150824606

常见 exchange 类型包括:

  • Fanout:广播
  • Direct:路由
  • Topic:话题

注意:exchange 负责消息路由,而不是存储,路由失败则消息丢失

# 11.3.6. FanoutExchange

Fanout Exchange 会将接收到的消息广播到每一个跟其绑定的 queue

案例: 利用 SpringAMQP 演示 FanoutExchange 的使用

实现思路如下:

  • 在 consumer 服务中,利用代码声明队列、交换机,并将两者绑定
  • 在 consumer 服务中,编写两个消费者方法,分别监听 fanout.queue1 和 fanout.queue2
  • 在 publisher 中编写测试方法,向 itcast.fanout 发送消息

image-20260219152707229

步骤 1: 在 consumer 服务声明 Exchange、Queue、Binding

SpringAMQP 提供了声明交换机、队列、绑定关系的 API

image-20260219152830516

在 consumer 服务创建一个类,添加 @Configuration 注解,并声明 FanoutExchange、Queue 和 绑定关系对象Binding,代码如下

@Configuration
public class FanoutExchangeConfig {
    // 交换机: itcast.fanout
    @Bean
    public FanoutExchange fanoutExchange() {
        return new FanoutExchange("itcast.fanout");
    }

    // 队列1: fanout.queue1
    @Bean
    public Queue fanoutQueue1() {
        return new Queue("fanout.queue1");
    }

    // 队列2: fanout.queue2
    @Bean
    public Queue fanoutQueue2() {
        return new Queue("fanout.queue2");
    }

    // 队列1 绑定到 交换机
    @Bean // 形参名一定要对应好,优先通过 名称 进行注入
    public Binding bindingQueue1(Queue fanoutQueue1, FanoutExchange fanoutExchange) {
        return BindingBuilder.bind(fanoutQueue1).to(fanoutExchange);
    }

    // 队列2 绑定到 交换机
    @Bean
    public Binding bindingQueue2(Queue fanoutQueue2, FanoutExchange fanoutExchange) {
        return BindingBuilder.bind(fanoutQueue2).to(fanoutExchange);
    }
}

运行代码后,查看 Exchanges 、Queues 页签,可以看到已经创建

步骤 2: 在 consumer 服务声明两个消费者

在 consumer 服务的 SpringRabbitListener 类中,添加两个方法,分别监听 fanout.queue1 和 fanout.queue2

@Component
public class SpringRabbitMqListener {
    @RabbitListener(queues = "fanout.queue1")
    public void listenFanoutQueue1(String msg) {
        System.out.println(String.format("消费者1 接收到 fanout.queue1 的消息:【%s】", msg));
    }

    @RabbitListener(queues = "fanout.queue2")
    public void listenFanoutQueue2(String msg) {
        System.out.println(String.format("消费者2 接收到 fanout.queue2 的消息:【%s】", msg));
    }
}

步骤 3: 在 publisher 服务发送消息到 FanoutExchange

在 publisher 服务的 SpringAmqpTest 类中添加测试方法

@Test
public void testFanoutExchange() {
    String exchangeName = "itcast.fanout";
    String message = "hello, fanout exchange!";
    rabbitTemplate.convertAndSend(exchangeName, "", message);
}

小结

交换机的作用是什么?

  • 接收 publisher 发送的消息
  • 将消息按照规则路由到与之绑定的队列
  • 不能缓存消息,路由失败,消息丢失
  • FanoutExchange 的会将消息路由到每个绑定的队列

声明队列、交换机、绑定关系的 Bean 是什么?

  • Queue
  • FanoutExchange
  • Binding

# 11.3.7. DirectExchange

Direct Exchange 会将接收到的消息根据规则路由到指定的 Queue,因此称为路由模式(routes)。

  • 每一个 Queue 都与 Exchange 设置一个 BindingKey
  • 发布者发送消息时,指定消息的 RoutingKey
  • Exchange 将消息路由到 BindingKey 与消息 RoutingKey 一致的队列

实现思路如下:

  • 利用 @RabbitListener 声明 Exchange、Queue、RoutingKey
  • 在 consumer 服务中,编写两个消费者方法,分别监听 direct.queue1 和 direct.queue2
  • 在 publisher 中编写测试方法,向 itcast.direct 发送消息

image-20260219162605926

步骤 1: 在 consumer 服务声明 Exchange、Queue

在 consumer 服务中,编写两个消费者方法,分别监听 direct.queue1 和 direct.queue2,

使用 @RabbitListener 声明 Exchange、Queue、RoutingKey , 而不是通过 bean 的方式

@Component
public class SpringRabbitMqListener {
    @RabbitListener(bindings = @QueueBinding(
            // 将 direct.queue1 队列 绑定到 itcast.direct 交换机
            value = @Queue(name = "direct.queue1"),
            exchange = @Exchange(name = "itcast.direct", type = ExchangeTypes.DIRECT),

            // 将 key 绑定到 队列
            key = {"red", "blue"})
    )
    public void listenDirectQueue1(String msg) {
        System.out.println(String.format("消费者1 接收到 direct.queue1 的消息:【%s】", msg));
    }

    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "direct.queue2"),
            exchange = @Exchange(name = "itcast.direct", type = ExchangeTypes.DIRECT),
            key = {"red", "yellow"})
    )
    public void listenDirectQueue2(String msg) {
        System.out.println(String.format("消费者2 接收到 direct.queue2 的消息:【%s】", msg));
    }
}

步骤 2: 在 publisher 服务发送消息到 DirectExchange

在 publisher 服务的 SpringAmqpTest 类中添加测试方法

@Test
public void testDirectExchange() {
    String exchangeName = "itcast.direct";
    String message = "hello, direct exchange! -- yellow";
    rabbitTemplate.convertAndSend(exchangeName, "yellow", message);
}

小结

描述下 Direct 交换机与 Fanout 交换机的差异?

  • Fanout 交换机将消息路由给每一个与之绑定的队列
  • Direct 交换机根据 RoutingKey 判断路由给哪个队列
  • 如果多个队列具有相同的 RoutingKey,则与 Fanout 功能类似

基于 @RabbitListener 注解声明队列和交换机有哪些常见注解?

  • @Queue
  • @Exchange

# 11.3.8. TopicExchange

TopicExchange 与 DirectExchange 类似,区别在于 routingKey 必须是多个单词的列表,并且以 . 分割。

Queue 与 Exchange 指定 BindingKey 时可以使用通配符:

  • #:代指0个或多个单词
  • *:代指一个单词

案例: 利用 SpringAMQP 演示 TopicExchange 的使用

实现思路如下:

  • 利用 @RabbitListener 声明Exchange、Queue、RoutingKey
  • 在 consumer 服务中,编写两个消费者方法,分别监听 topic.queue1 和 topic.queue2
  • 在 publisher 中编写测试方法,向 itcast.topic 发送消息

image-20260219165131696

步骤 1: 在 consumer 服务声明 Exchange、Queue

在 consumer 服务中,编写两个消费者方法,分别监听 topic.queue1 和 topic.queue2 ,

并利用 @RabbitListener 声明 Exchange、Queue、RoutingKey

@RabbitListener(bindings = @QueueBinding(
        value = @Queue(name = "topic.queue1"),
        exchange = @Exchange(name = "itcast.topic", type = ExchangeTypes.TOPIC),
        key = {"china.#"})
)
public void listenTopicQueue1(String msg) {
    System.out.println(String.format("消费者1 接收到 topic.queue1 的消息:【%s】", msg));
}

@RabbitListener(bindings = @QueueBinding(
        value = @Queue(name = "topic.queue2"),
        exchange = @Exchange(name = "itcast.topic", type = ExchangeTypes.TOPIC),
        key = {"#.weather"})
)
public void listenTopicQueue2(String msg) {
    System.out.println(String.format("消费者2 接收到 topic.queue2 的消息:【%s】", msg));
}

步骤 2: 在 publisher 服务发送消息到 TopicExchange

在 publisher 服务的 SpringAmqpTest 类中添加测试方法

@Test
public void testTopicExchange() {
    String exchangeName = "itcast.topic";

    // String message = "中国新闻: 每天都有好消息";
    // rabbitTemplate.convertAndSend(exchangeName, "china.news", message);

    String message = "中国天气: 今天是个好天气";
    rabbitTemplate.convertAndSend(exchangeName, "china.weather", message);
}

小结

描述下 Direct 交换机与 Topic 交换机的差异?

  • Topic 交换机接收的消息 RoutingKey 必须是多个单词,以 . 分割
  • Topic 交换机与队列绑定时的 bindingKey 可以指定通配符
  • #:代表0个或多个词
  • *:代表1个词

# 11.3.9. 消息转换器

案例: 发送 Object 类型消息

步骤 1: 在 consumer 中利用 @Bean 声明一个队列

@Bean
public Queue objectQueue() {
    return new Queue("object.queue");
}

步骤 2: 在 consumer 中修改转换器

Spring 的对消息对象的处理是由 org.springframework.amqp.support.converter.MessageConverter 来处理的。

而默认实现是 SimpleMessageConverter ,基于 JDK 的 ObjectOutputStream 完成序列化。

如果要修改只需要定义一个 MessageConverter 类型的 Bean 即可。推荐用 JSON 方式序列化,步骤如下

依赖:

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
</dependency>

定义消息转换器:

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

步骤 3: 在 consumer 中侦听并处理 object.queue 队列的消息

@RabbitListener(queues = "object.queue")
public void listenObjectQueue(Map<String, Object> msg) {
    System.out.println(String.format("消费者 接收到 object.queue 的消息:【%s】", msg));
}

步骤 4: 在 publisher 定义转换器(同 consumer)并发送消息

@Test
public void testObjectQueue() {
    String queueName = "object.queue";

    Map<String, Object> message = new HashMap<>();
    message.put("name", "张三");
    message.put("age", 18);

    rabbitTemplate.convertAndSend(queueName, message);
}

小结

SpringAMQP 中消息的序列化和反序列化是怎么实现的?

  • 利用 MessageConverter 实现的,默认是 JDK 的序列化
  • 注意发送方与接收方必须使用相同的 MessageConverter

# 12. elasticsearch 基础

# 12.1. 初识 ES

# 12.1.1. 什么是 elasticsearch

什么是 elasticsearch

elasticsearch 是一款非常强大的开源搜索引擎,可以帮助我们从海量数据中快速找到需要的内容。

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

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

image-20260219183631405

elasticsearch 的发展

Lucene 是一个 Java 语言的搜索引擎类库,是 Apache 公司的顶级项目,由 DougCutting 于 1999 年研发。

官网地址:https://lucene.apache.org/ 。

Lucene 的优势:

  • 易扩展
  • 高性能(基于倒排索引)

Lucene的缺点:

  • 只限于 Java 语言开发
  • 学习曲线陡峭
  • 不支持水平扩展

2004 年 Shay Banon 基于 Lucene 开发了 Compass

2010 年 Shay Banon 重写了 Compass,取名为 Elasticsearch。

官网地址: https://www.elastic.co/cn/

目前最新的版本是:7.12.1

相比与 lucene,elasticsearch 具备下列优势:

  • 支持分布式,可水平扩展
  • 提供 Restful 接口,可被任何语言调用

为什么学习 elasticsearch?

搜索引擎技术排名:

  • Elasticsearch:开源的分布式搜索引擎
  • Splunk:商业项目
  • Solr:Apache的开源搜索引擎

小结

什么是elasticsearch?

  • 一个开源的分布式搜索引擎,可以用来实现搜索、日志统计、分析、系统监控等功能

什么是 elastic stack(ELK)?

  • 是以 elasticsearch 为核心的技术栈,包括 beats、Logstash、kibana、elasticsearch

什么是 Lucene?

  • 是 Apache 的开源搜索引擎类库,提供了搜索引擎的核心 API

# 12.1.2. 倒排索引

正向索引

传统数据库(如 MySQL)采用正向索引,例如给下表(tb_goods)中的 id 创建索引:

image-20260219185637289

倒排索引

elasticsearch 采用倒排索引:

  • 文档(document):每条数据就是一个文档
  • 词条(term):文档按照语义分成的词语

image-20260219185821942

小结

什么是文档和词条?

  • 每一条数据就是一个文档
  • 对文档中的内容分词,得到的词语就是词条。(英文按空格分词,中文按语义分词)

什么是正向索引?

  • 基于文档 id 创建索引
  • 查询词条时必须先找到文档,而后判断是否包含词条

什么是倒排索引?

  • 对文档内容分词,对词条创建索引,并记录词条所在文档的信息。
  • 查询时先根据词条查询到 id,而后根据 id 获取文档

# 12.1.3. es 与 mysql 的概念对比

文档

elasticsearch 是面向文档存储的,可以是数据库中的一条商品数据,一个订单信息。

文档数据会被序列化为 json 格式后存储在 elasticsearch 中。

索引(Index)

索引(index):相同类型的文档的集合

映射(mapping):索引中文档的字段约束信息,类似表的结构约束

image-20260220095126541

概念对比

MySQL Elasticsearch 说明
Table Index 索引(index),就是文档的集合,类似数据库的表(table)
Row Document 文档(Document),就是一条条的数据,类似数据库中的行(Row),文档都是JSON格式
Column Field 字段(Field),就是JSON文档中的字段,类似数据库中的列(Column)
Schema Mapping Mapping(映射)是索引中文档的约束,例如字段类型约束。类似数据库的表结构(Schema)
SQL DSL DSL 是 elasticsearch 提供的 JSON 风格的请求语句,用来操作 elasticsearch,实现CRUD

架构

Mysql:擅长事务类型操作,可以确保数据的安全和一致性

Elasticsearch:擅长海量数据的搜索、分析、计算

image-20260220095341790

小结

文档:一条数据就是一个文档,es 中是 Json 格式

字段:Json 文档中的字段

索引:同类型文档的集合

映射:索引中文档的约束,比如字段名称、类型

elasticsearch 与数据库的关系:

  • 数据库负责事务类型操作
  • elasticsearch 负责海量数据的搜索、分析、计算

# 12.1.4. 安装 es

部署单点 es

创建网络

为我们还需要部署 kibana 容器,因此需要让 es 和 kibana 容器互联。

es、kibana 分开部署,但放在同一个网络中进行通信

这里先创建一个网络:

# es-net 为网络的名称
docker network create es-net

加载镜像

这里我们采用 elasticsearch 的 7.12.1 版本的镜像,这个镜像体积非常大,接近 1G。

docker pull elasticsearch:7.12.1

运行

运行 docker 命令,部署单点 es:

docker run -d \
	--name es \
    -e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \
    -e "discovery.type=single-node" \
    -v es-data:/usr/share/elasticsearch/data \
    -v es-plugins:/usr/share/elasticsearch/plugins \
    -v es-config:/usr/share/elasticsearch/config \
    --privileged \
    --network es-net \
    -p 9200:9200 \
    -p 9300:9300 \
elasticsearch:7.12.1

命令解释:

  • -e "cluster.name=es-docker-cluster": 设置集群名称
  • -e "http.host=0.0.0.0": 监听的地址,可以外网访问
  • -e "ES_JAVA_OPTS=-Xms512m -Xmx512m": 内存大小
  • -e "discovery.type=single-node": 非集群模式
  • -v es-data:/usr/share/elasticsearch/data: 挂载逻辑卷,绑定es的数据目录
  • -v es-logs:/usr/share/elasticsearch/logs: 挂载逻辑卷,绑定es的日志目录
  • -v es-plugins:/usr/share/elasticsearch/plugins: 挂载逻辑卷,绑定es的插件目录
  • -v es-config:/usr/share/elasticsearch/config: 挂载配置卷,在线安装的 ik,其配置文件会存在这里
  • --privileged: 授予逻辑卷访问权
  • --network es-net : 加入一个名为 es-net 的网络中
  • -p 9200:9200: 端口映射配置
  • -p 9300:9300: 集群节点互联的端口

访问: http://192.168.124.10:9200/

# 12.1.5. 安装 kibana

kibana 可以给我们提供一个 elasticsearch 的可视化界面,便于我们学习。

拉取

docker pull kibana:7.12.1

部署

运行 docker 命令,部署 kibana

docker run -d \
  --name kibana \
  -e ELASTICSEARCH_HOSTS=http://es:9200 \
  --network=es-net \
  -p 5601:5601  \
  kibana:7.12.1

命令解释:

  • --network es-net :加入一个名为 es-net 的网络中,与 elasticsearch 在同一个网络中
  • -e ELASTICSEARCH_HOSTS=http://es:9200":设置 elasticsearch 的地址,因为 kibana 已经与 elasticsearch 在一个网络,因此可以用容器名直接访问 elasticsearch
  • -p 5601:5601:端口映射配置

kibana 启动一般比较慢,需要多等待一会,可以通过命令查看日志:

docker logs -f kibana

# 当出现如下内容时,说明启动成功
# "message":"http server running at http://0.0.0.0:5601"}

访问: http://192.168.124.10:5601/

DevTools

kibana 中提供了一个 DevTools 界面

位置: 菜单 -> Management -> Dev Tools

这个界面中可以编写 DSL 来操作 elasticsearch。并且对 DSL 语句有自动补全功能。

# 12.1.6. 分词器

分词器

es 在创建倒排索引时需要对文档分词;在搜索时,需要对用户输入内容分词。

但默认的分词规则对中文处理并不友好。

我们在 kibana 的 DevTools 中测试:

POST /_analyze
{
  "analyzer": "standard",
  "text": "张三学习java!"
}


分词结果: "张", "三", "学", "习", "java"

IK 分词器

处理中文分词,一般会使用 IK 分词器。

官网: https://github.com/medcl/elasticsearch-analysis-ik

ik 分词器包含两种模式:

  • ik_smart:最少切分,粗粒度
  • ik_max_word:最细切分,细粒度

# 12.1.7. 安装 IK 分词器

在线安装ik插件(较慢)

# 进入容器 es 内部
docker exec -it es /bin/bash

# 在线下载并安装 (2026-02-20)
./bin/elasticsearch-plugin install https://get.infini.cloud/elasticsearch/analysis-ik/7.12.1

# 退出
exit

# 重启容器
docker restart es

# 查看日志是否加载 分词器
docker logs es | grep "ik"

# "loaded plugin [analysis-ik]"

离线安装ik插件(推荐)

安装插件需要知道 elasticsearch 的 plugins 目录位置,而我们用了数据卷挂载,因此需要查看 elasticsearch 的数据卷目录,通过下面命令查看:

docker volume inspect es-plugins

# 显示结果:
# "Mountpoint": "/var/lib/docker/volumes/es-plugins/_data",

将 elasticsearch-analysis-ik-7.12.1.zip 解压并重命名为 analysis-ik

将 analysis-ik 目录上传到 /var/lib/docker/volumes/es-plugins/_data

重启容器

测试

最少切分:

POST /_analyze
{
  "analyzer": "ik_smart",
  "text": "张三学习java!"
}


分词: "张三", "学习", "java"

最细切分:

POST /_analyze
{
  "analyzer": "ik_smart",
  "text": "张三学习java!"
}


分词: "张三", "三学", "三", "学习", "java"

# 12.1.8. IK 分词器的拓展和停用词典

如果是离线安装的,在 /var/lib/docker/volumes/es-plugins/_data/analysis-ik 就有 config 目录

如果是在线下载的,则需要去 /var/lib/docker/volumes/es-config/_data/analysis-ik 修改

修改 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

ext.dic :

白嫖
奥利给

stopword.dic :

的
了
啊

测试:

POST /_analyze
{
  "analyzer": "ik_smart",
  "text": "张三白嫖了黑马程序员的课程,奥利给!"
}

分词: "张三", "白嫖", "黑马", "程序员", "课程", "奥利给"

小结

分词器的作用是什么?

  • 创建倒排索引时对文档分词
  • 用户搜索时,对输入的内容分词

IK 分词器有几种模式?

  • ik_smart:智能切分,粗粒度。四个字能组成一个词,就不会继续分下去。
  • ik_max_word:最细切分,细粒度。四个字能组成一个词的情况下,还会继续往下分。

IK 分词器如何拓展词条?如何停用词条?

  • 利用 config 目录的 IkAnalyzer.cfg.xml 文件添加拓展词典和停用词典
  • 在词典中添加拓展词条或者停用词条

# 12.2. 索引库操作

# 12.2.1. mapping 属性

文档示例

{
  "age":21,
  "weight":52.1,
  "isMarried":false,
  "info":"黑马程序员Java讲师",
  "email":"zy@itcast.cn",
  "score":[99.1, 99.5, 98.9],
  "name":{
    "firstName":"云",
    "lastName":"赵"
  }
}

mapping 属性

mapping 是对索引库中文档的约束,常见的 mapping 属性包括:

type:字段数据类型,常见的简单类型有:

  • 字符串:text(可分词的文本)、keyword(精确值,例如:品牌、国家、ip地址)
  • 数值:long、integer、short、byte、double、float 。(json 中数组类型的值 按元素的类型算)
  • 布尔:boolean
  • 日期:date
  • 对象:object

index:是否创建索引,默认为 true。(为 false 时,则不参与搜索)

analyzer:使用哪种分词器。结合 "text" 使用

properties:该字段的子字段。用于对象嵌套

参考文档: https://www.elastic.co/guide/en/elasticsearch/reference/7.12/mapping.html

小结

mapping 常见属性有哪些?

  • type:数据类型
  • index:是否索引
  • analyzer:分词器
  • properties:子字段

type 常见的有哪些?

  • 字符串:text、keyword
  • 数字:long、integer、short、byte、double、float
  • 布尔:boolean
  • 日期:date
  • 对象:object

# 12.2.2. 创建索引库

ES 中通过 Restful 请求操作索引库、文档。请求内容用 DSL 语句来表示。

创建索引库和 mapping 的 DSL 语法如下:

PUT/索引库名称
{
  "mappings":{
    "properties":{
      "字段名":{
        "type":"text",
        "analyzer":"ik_smart"
      },
      "字段名2":{
        "type":"keyword",
        "index":"false"
      },
      "字段名3":{
        "type": "object",
        "properties":{
          "子字段":{
            "type":"keyword"
          }
        }
      },
      // ...略
    }
  }
}

示例:

PUT /userinfo
{
  "mappings": {
    "properties": {
      "info": {
        "type": "text",
        "analyzer": "ik_smart"
      },
      "email": {
        "type": "keyword",
        "index": "false"
      },
      "name": {
        "type": "object",
        "properties": {
          "firstName": {
            "type": "keyword"
          },
          "lastName": {
            "type": "keyword"
          }
        }
      }
    }
  }
}

# 12.2.3. 查询、删除、修改索引库

查看、删除索引库

查看索引库:

GET /索引库名

删除索引库:

DELETE /索引库名

修改索引库(添加新字段)

索引库和 mapping 一旦创建无法修改,但是可以添加 新的字段,语法如下:

PUT/索引库名/_mapping
{
  "properties":{
    "新字段名":{
      // ...
    }
  }
}

多次执行都会成功

示例

# 创建 索引库
PUT /userinfo
{
  "mappings": {
    "properties": {
      "info": {
        "type": "text",
        "analyzer": "ik_smart"
      },
      "email": {
        "type": "keyword",
        "index": "false"
      },
      "name": {
        "type": "object",
        "properties": {
          "firstName": {
            "type": "keyword"
          },
          "lastName": {
            "type": "keyword"
          }
        }
      }
    }
  }
}

# 查询 索引库
GET /userinfo

# 修改 索引库,新增字段
PUT /userinfo/_mapping
{
  "properties": {
    "age": {
      "type": "long"
    }
  }
}

# 删除 索引库
DELETE /userinfo

小结

索引库操作有哪些?

  • 创建索引库:PUT /索引库名
  • 查询索引库:GET /索引库名
  • 删除索引库:DELETE /索引库名
  • 添加字段:PUT /索引库名/_mapping

# 12.3. 文档操作

新增文档

POST /索引库名/_doc/文档id
{
  "字段1":"值1",
  "字段2":"值2",
  "字段3":{
    "子属性1":"值3",
    "子属性2":"值4"
  },
  // ...
}

查看文档

GET /索引库名/_doc/文档id

删除文档

DELETE /索引库名/_doc/文档id

全量修改文档

PUT /索引库名/_doc/文档id
{
  "字段1":"值1",
  "字段2":"值2",
  "字段3":{
    "子属性1":"值3",
    "子属性2":"值4"
  }
}

语法与新增唯一的区别就是请求方式

全量修改: 相当于 删除旧记录,再新增新的记录

如果 文档id 不存在,则会新增

局部修改文档

POST /索引库名/_update/文档id
{
  "doc": {
    "字段名":"新的值",
  }
}

局部修改(增量修改),只修改指定的字段

示例

# 新增 文档
POST /userinfo/_doc/1
{
  "info": "黑马程序员Java讲师",
  "email": "zy@itcast.cn",
  "name": {
    "firstName": "云",
    "lastName": "赵"
  }
}

# 查询 文档
GET /userinfo/_doc/1

# 删除 文档
DELETE /userinfo/_doc/1

# 全量修改 文档
PUT /userinfo/_doc/1
{
  "info": "黑马程序员Java讲师",
  "email": "ZhaoYun@itcast.cn",
  "name": {
    "firstName": "云",
    "lastName": "赵"
  }
}

# 局部修改 文档
POST /userinfo/_update/1
{
  "doc": {
    "email": "ZYun@itcast.cn"
  }
}

小结

文档操作有哪些?

  • 创建文档:POST /索引库名/_doc/文档id { json文档 }
  • 查询文档:GET /索引库名/_doc/文档id
  • 删除文档:DELETE /索引库名/_doc/文档id
  • 修改文档:
    • 全量修改:PUT /索引库名/_doc/文档id { json文档 }
    • 增量修改:POST /索引库名/_update/文档id { "doc": {字段}}

# 12.4. RestClient 操作索引库

# 12.4.1. RestClient - 步骤1

什么是RestClient

ES 官方提供了各种不同语言的客户端,用来操作 ES。

这些客户端的本质就是组装 DSL 语句,通过 http 请求发送给 ES。

官方文档地址:https://www.elastic.co/guide/en/elasticsearch/client/index.html

案例: 利用 JavaRestClient 实现创建、删除索引库,判断索引库是否存在

根据课前资料提供的酒店数据创建索引库,索引库名为 hotel,mapping 属性根据数据库结构定义。

基本步骤如下:

  1. 导入课前资料Demo
  2. 分析数据结构,定义mapping属性
  3. 初始化JavaRestClient
  4. 利用JavaRestClient创建索引库
  5. 利用JavaRestClient删除索引库
  6. 利用JavaRestClient判断索引库是否存在

资料

实用篇\day05-Elasticsearch01\资料

  • hotel-demo
  • tb_hotel.sql

# 12.4.2. 创建索引库 - 步骤2

分析数据结构

mapping 要考虑的问题:

字段名、数据类型、是否参与搜索、是否分词、如果分词,分词器是什么?

地理坐标

ES 中支持两种地理坐标数据类型:

  • geo_point:由纬度(latitude)和经度(longitude)确定的一个点。例如:"32.8752345, 120.2981576"
  • geo_shape:有多个 geo_point 组成的复杂几何图形。例如一条直线,"LINESTRING (-77.03653 38.897676, -77.009051 38.889939)"

copy_to

字段拷贝可以使用 copy_to 属性将当前字段拷贝到指定字段。示例:

"name": {
  "type": "text",
  "analyzer": "ik_max_word",
  "copy_to": "all"
},
"brand": {
  "type": "keyword",
  "copy_to": "all"
},
"all": {
  "type": "text",
  "analyzer": "ik_max_word"
}

"all" 同时具备 "name" 、"brand" 的功能,结果求并集

示例

# 酒店的 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"
      }
    }
  }
}

# 12.4.3. 初始化 RestClient - 步骤3

依赖: 引入 es 的 RestHighLevelClient 依赖:

<dependency>
    <groupId>org.elasticsearch.client</groupId>
    <artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>

版本: 因为 SpringBoot 默认的 ES 版本是 7.6.2,所以我们需要覆盖默认的 ES 版本

<properties>
    <!-- 显式指定 es 的版本 -->
    <elasticsearch.version>7.12.1</elasticsearch.version>
</properties>

初始化 RestHighLevelClient:

RestHighLevelClient client = new RestHighLevelClient(RestClient.builder(
          HttpHost.create("http://192.168.124.10:9200")
  ));

单元测试:

public class HotelIndexTest {
    private RestHighLevelClient client;

    @BeforeEach
    void setUp() {
        this.client = new RestHighLevelClient(RestClient.builder(
                HttpHost.create("http://192.168.124.10:9200")
        ));
    }

    @AfterEach
    void tearDown() throws IOException {
        this.client.close();
    }

    @Test
    void testInit() {
        System.out.println(client);
    }
}

# 12.4.4. 创建索引库 - 步骤4

常量:

package cn.itcast.hotel.constants;

public class HotelConstants {
    public static final String MAPPING_TEMPLATE = "{\n" +
            "  \"mappings\": {\n" +
            // ...
            "}\n";
}

示例:

@Test
void testCreateHotelIndex() throws IOException {
    // 1. 创建 request 对象 --> PUT /hotel
    CreateIndexRequest request = new CreateIndexRequest("hotel");

    // 2. 请求参数 (准 DSL 语句) --> MAPPING_TEMPLATE: { "mappings": { ... } }
    request.source(MAPPING_TEMPLATE, XContentType.JSON);

    // 3. 发起请求
    IndicesClient indicesClient = client.indices();
    indicesClient.create(request, RequestOptions.DEFAULT);
}

# 12.4.5. 删除和判断索引库

删除:

@Test
void testDeleteHotelIndex() throws IOException {
    // 1. 创建 request 对象 --> DELETE /hotel
    DeleteIndexRequest request = new DeleteIndexRequest("hotel");

    // 2. 发起请求
    client.indices().delete(request, RequestOptions.DEFAULT);
}

判断是否存在:

@Test
void testExistsHotelIndex() throws IOException {
    // 1. 创建 request 对象
    GetIndexRequest request = new GetIndexRequest("hotel");

    // 2. 发起请求
    boolean exists = client.indices().exists(request, RequestOptions.DEFAULT);

    System.out.println("exists = " + exists);
}

索引库操作的基本步骤:

  • 初始化 RestHighLevelClient
  • 创建 XxxIndexRequest: Xxx 是 Create、Get、Delete
  • 准备 DSL (Create时需要)
  • 发送请求。调用 RestHighLevelClient#indices().xxx() 方法,xxx 是 create、exists、delete

# 12.5. RestClient 操作文档

案例: 利用 JavaRestClient 实现文档的 CRUD

去数据库查询酒店数据,导入到 hotel 索引库,实现酒店数据的 CRUD。

基本步骤如下:

  • 初始化 JavaRestClient
  • 利用 JavaRestClient 新增酒店数据
  • 利用 JavaRestClient 根据id查询酒店数据
  • 利用 JavaRestClient 删除酒店数据
  • 利用 JavaRestClient 修改酒店数据

# 12.5.1. 新增文档

添加酒店数据到索引库

先查询酒店数据,然后给这条数据创建倒排索引,即可完成添加

/* 
    POST /userinfo/_doc/1
    {
      "info": "黑马程序员Java讲师",
      "email": "zy@itcast.cn",
      "name": {
        "firstName": "云",
        "lastName": "赵"
      }
    }
  */
@Test
void testAddDocument() throws IOException {
    // 1.创建request对象
    IndexRequest request = new IndexRequest("索引库名称");

    // 2.准备JSON文档
    request.source("文档 JSON", XContentType.JSON).id("文档 ID");

    // 3.发送请求
    this.client.index(request, RequestOptions.DEFAULT);
}

示例:

@Test
void testAddDocument() throws IOException {
    // 根据 id 查询 hotel 记录
    Hotel hotel = hotelService.getById(61083L);

    // 将记录转换为 文档对象
    HotelDoc hotelDoc = new HotelDoc(hotel);

    // 将文档对象转换为 json
    String hotelDocJson = JSON.toJSONString(hotelDoc);

    // 1.创建request对象
    IndexRequest request = new IndexRequest("hotel");

    // 2.准备JSON文档
    request.source(hotelDocJson, XContentType.JSON).id(hotel.getId().toString());

    // 3.发送请求
    this.client.index(request, RequestOptions.DEFAULT);
}

# 12.5.2. 查询文档

根据id查询酒店数据

根据 id 查询到的文档数据是 json,需要反序列化为 java 对象

@Test
void testGetDocument() throws IOException {
    // 1. 准备 Request
    GetRequest request = new GetRequest("hotel", "61083");

    // 2. 发送请求,得到响应
    GetResponse response = this.client.get(request, RequestOptions.DEFAULT);

    // 3. 拿到原始 json 数据
    String json = response.getSourceAsString();

    // 转换为 HotelDoc
    HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);

    System.out.println("hotelDoc = " + hotelDoc);
}

# 12.5.3. 更新文档

根据 id 修改酒店数据

修改文档数据有两种方式:

  • 方式一:全量更新。再次写入id一样的文档,就会删除旧文档,添加新文档
  • 方式二:局部更新。只更新部分字段,我们演示方式二
@Test
void testUpdateDocument() throws Exception {
    // 1. 准备 Request
    UpdateRequest request = new UpdateRequest("hotel", "61083");

    // 2. 准备请求参数: 可变参数,每两个为一组 key-value
    request.doc(
            "price", "800",
            "starName", "三钻"
    );

    // 3. 发送请求
    client.update(request, RequestOptions.DEFAULT);
}

# 12.5.4. 删除文档

根据 id 删除文档数据

@Test
void testDeleteDocument() throws IOException {
    // 1. 准备 Request
    DeleteRequest request = new DeleteRequest("hotel", "61083");
    
    // 2. 发送请求
    client.delete(request, RequestOptions.DEFAULT);
}

# 12.5.5. 小结

文档操作的基本步骤:

  • 初始化 RestHighLevelClient
  • 创建 XxxRequest。XXX 是 Index、Get、Update、Delete
  • 准备参数(Index 和 Update 时需要)
  • 发送请求。调用 RestHighLevelClient#.xxx() 方法,xxx是index、get、update、delete
  • 解析结果(Get时需要)

# 12.5.6. 批量导入文档

利用 JavaRestClient 批量导入酒店数据到ES

需求:批量查询酒店数据,然后批量导入索引库中

思路:

  1. 利用 mybatis-plus 查询酒店数据
  2. 将查询到的酒店数据(Hotel)转换为文档类型数据(HotelDoc)
  3. 利用 JavaRestClient 中的 Bulk 批处理,实现批量新增文档,示例代码如下
@Test
void testBatchAddDocument() throws IOException {
    // 查询所有的 hotel
    List<Hotel> hotels = hotelService.list();

    // 1. 准备 Request
    BulkRequest request = new BulkRequest();

    // 2. 准备请求参数: 装入多个 新增 的请求
    for (Hotel hotel : hotels) {
        HotelDoc hotelDoc = new HotelDoc(hotel);
        String hotelDocJson = JSON.toJSONString(hotelDoc);

        IndexRequest indexRequest = new IndexRequest("hotel");

        indexRequest.source(hotelDocJson, XContentType.JSON).id(hotel.getId().toString());

        request.add(indexRequest);
    }

    // 3. 发送请求
    client.bulk(request, RequestOptions.DEFAULT);
}

# 12.6. DSL 查询语法

# 12.6.1. DSL 查询分类

Elasticsearch 提供了基于 JSON 的 DSL(Domain Specific Language)来定义查询。

文档: https://www.elastic.co/docs/explore-analyze/query-filter/languages/querydsl

常见的查询类型包括:

  • 查询所有:

    • 查询出所有数据,一般测试用。
    • 例如:match_all
  • 全文检索(full text)查询:

    • 利用分词器对用户输入内容分词,然后去倒排索引库中匹配。
    • 例如: match_query , multi_match_query
  • 精确查询:

    • 根据精确词条值查找数据,一般是查找keyword、数值、日期、boolean等类型字段。
    • 例如: ids , range , term
  • 地理(geo)查询:

    • 根据经纬度查询。
    • 例如: geo_distance , geo_bounding_box
  • 复合(compound)查询:

    • 复合查询可以将上述各种查询条件组合起来,合并查询条件。
    • 例如: bool , function_score

# 12.6.2. 基本语法

查询的基本语法如下:

GET /索引库名称/_search
{
  "query": {
    "查询类型": {
      "查询条件": "条件值"
    }
  }
}

示例:

// 查询所有,默认只返回 10 条
GET /hotel/_search
{
  "query": {
    "match_all": {}
  }
}

# 12.6.3. 全文检索查询

全文检索查询,会对用户输入内容分词,常用于搜索框搜索

针对 text 类型的字段进行搜索

match 查询

全文检索查询的一种,会对用户输入内容分词,然后去倒排索引库检索

语法:

GET /indexName/_search
{
  "query": {
    "match": {
      "FIELD": "TEXT"
    }
  }
}

示例:

// match 查询
GET /hotel/_search
{
  "query": {
    "match": {
      "all": "外滩如家"
    }
  }
}

multi_match

与 match 查询类似,只不过允许同时查询多个字段

语法:

GET /indexName/_search
{
  "query": {
    "multi_match": {
      "query": "TEXT",
      "fields": ["FIELD1", " FIELD12"]
    }
  }
}

示例:

// multi_match 查询
GET /hotel/_search
{
  "query": {
    "multi_match": {
      "query": "外滩如家",
      "fields": ["name", "brand", "business"]
    }
  }
}

小结

match 和 multi_match 的区别是什么?

  • match: 根据一个字段查询
  • multi_match: 根据多个字段查询,参与查询字段越多,查询性能越差,建议将多个字段 copyTo 到一个字段进行查询

# 12.6.4. 精确查询

精确查询一般是查找 keyword、数值、日期、boolean 等类型字段。所以不会对搜索条件分词。

常见的有:

  • term:根据词条精确值查询
  • range:根据值的范围查询

term 查询

语法:

GET /indexName/_search
{
  "query": {
    "term": {
      "FIELD": {
        "value": "VALUE"
      }
    }
  }
}

示例:

// term 查询
GET /hotel/_search
{
  "query": {
    "term": {
      "city": {
        "value": "上海"
      }
    }
  }
}

range 查询

语法:

GET /indexName/_search
{
  "query": {
    "range": {
      "FIELD": {
        "gte": 10,
        "lte": 20
      }
    }
  }
}

示例:

// range 查询
GET /hotel/_search
{
  "query": {
    "range": {
      "price": {
        "gte": 200,
        "lte": 300
      }
    }
  }
}

小结

精确查询常见的有哪些?

  • term查询:根据词条精确匹配,一般搜索keyword类型、数值类型、布尔类型、日期类型字段
  • range查询:根据数值范围查询,可以是数值、日期的范围

# 12.6.5. 地理查询

根据经纬度查询。常见的使用场景包括:

  • 携程:搜索我附近的酒店
  • 滴滴:搜索我附近的出租车
  • 微信:搜索我附近的人

geo_bounding_box

查询 geo_point 值落在某个矩形范围的所有文档

语法:

// geo_bounding_box查询
GET /indexName/_search
{
  "query": {
    "geo_bounding_box": {
      "FIELD": {
        "top_left": {
          "lat": 31.1,
          "lon": 121.5
        },
        "bottom_right": {
          "lat": 30.9,
          "lon": 121.7
        }
      }
    }
  }
}

geo_distance

查询到指定中心点小于某个距离值的所有文档

语法:

// geo_distance 查询
GET /indexName/_search
{
  "query": {
    "geo_distance": {
      "distance": "15km",
      "FIELD": "31.21,121.5"
    }
  }
}

示例:

// geo_distance 查询
GET /hotel/_search
{
  "query": {
    "geo_distance": {
      "distance": "2km",
      "location": "31.21,121.5"
    }
  }
}

# 12.6.6. 相关性算分

复合查询

复合(compound)查询:复合查询可以将其它简单查询组合起来,实现更复杂的搜索逻辑

例如:

  • fuction score:算分函数查询,可以控制文档相关性算分,控制文档排名。例如百度竞价

相关性算分

当我们利用 match 查询时,文档结果会根据与搜索词条的关联度打分(_score),返回结果时按照分值降序排列。

image-20260226192958512

例如,我们搜索 "虹桥如家",结果如下:

[
  {
    "_score" : 17.850193,
    "_source" : {
      "name" : "虹桥如家酒店真不错",
    }
  },
  {
    "_score" : 12.259849,
    "_source" : {
      "name" : "外滩如家酒店真不错",
    }
  },
  {
    "_score" : 11.91091,
    "_source" : {
      "name" : "迪士尼如家酒店真不错",
    }
  }
]

小结

elasticsearch 中的相关性打分算法是什么?

  • TF-IDF:在 elasticsearch 5.0 之前,会随着词频增加而越来越大
  • BM25:在 elasticsearch 5.0 之后,会随着词频增加而增大,但增长曲线会趋于水平

# 12.6.7. Function Score Query

使用 function score query,可以修改文档的相关性算分(query score),根据新得到的算分排序。

GET /hotel/_search
{
  "query": {
    "function_score": {
      
      // 原始查询条件,搜索文档并根据相关性打分(query score)
      "query": { "match": {"all": "外滩"} },  

      "functions": [
        {
          // 过滤条件,符合条件的文档才会被重新算分
          "filter": { "term": { "id": "1" } },

          // 算分函数,算分函数的结果称为function score ,将来会与query score运算,得到新算分,常见的算分函数有:
          //    weight:给一个常量值,作为函数结果(function score)
          //    field_value_factor:用文档中的某个字段值作为函数结果
          //    random_score:随机生成一个值,作为函数结果
          //    script_score:自定义计算公式,公式结果作为函数结果
          "weight": 10
        }
      ],
      
      // 加权模式,定义 function score 与 query score 的运算方式,包括:
      //    multiply:两者相乘。默认就是这个
      //    replace:用function score 替换 query score
      //    其它:sum、avg、max、min
      "boost_mode": "multiply"
    }
  }
}

案例

给“如家”这个品牌的酒店排名靠前一些

把这个问题翻译一下,function score 需要的三要素:

  1. 哪些文档需要算分加权?
    • 品牌为如家的酒店
  2. 算分函数是什么?
    • weight就可以
  3. 加权模式是什么?
    • 求和

示例:

// function score 查询
GET /hotel/_search
{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "all": "外滩"
        }
      },
      "functions": [
        {
          "filter": {
            "term": {
              "brand": "如家"
            }
          },
          "weight": 10
        }
      ],
      "boost_mode": "sum"
    }
  }
}

小结

function score query定义的三要素是什么?

  • 过滤条件:哪些文档要加分
  • 算分函数:如何计算 function score
  • 加权方式:function score 与 query score 如何运算

# 12.6.8. Boolean Query

复合查询 Boolean Query

布尔查询是一个或多个查询子句的组合。子查询的组合方式有:

  • must:必须匹配每个子查询,类似“与”
  • should:选择性匹配子查询,类似“或”
  • must_not:必须不匹配,不参与算分,类似“非”
  • filter:必须匹配,不参与算分
GET /hotel/_search
{
  "query": {
    "bool": {
      "must": [
        {"term": {"city": "上海" }}
      ],
      "should": [
        {"term": {"brand": "皇冠假日" }},
        {"term": {"brand": "华美达" }}
      ],
      "must_not": [
        { "range": { "price": { "lte": 500 } }}
      ],
      "filter": [
        { "range": {"score": { "gte": 45 } }}
      ]
    }
  }
}

案例

利用 bool 查询实现功能

需求:搜索名字包含“如家”,价格不高于 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": {"lat": 31.21, "lon": 121.5}
          }
        }
      ]
    }
  }
}

# 12.7. 搜索结果处理

# 12.7.1. 排序

elasticsearch 支持对搜索结果排序,默认是根据相关度算分(_score)来排序。

可以排序字段类型有:keyword 类型、数值类型、地理坐标类型、日期类型等。

语法:

GET /indexName/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "FIELD": "desc"  // 排序字段和排序方式 ASC、DESC
    }
  ]
}
// 地理坐标类型
GET /indexName/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "_geo_distance" : {
          "FIELD" : "纬度,经度",
          "order" : "asc",
          "unit" : "km"
      }
    }
  ]
}

案例 1

对酒店数据按照用户评价降序排序,评价相同的按照价格升序排序

评价是 score 字段,价格是 price 字段,按照顺序添加两个排序规则即可。

GET /hotel/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "score": "desc"  
    },
    {
      "price": "asc"  
    }
  ]
}

案例 2

实现对酒店数据按照到你的位置坐标的距离升序排序

获取经纬度的方式:https://lbs.amap.com/demo/jsapi-v2/example/map/click-to-get-lnglat/

// # 位置 排序: 某个坐标周边的酒店,距离升序排列
GET /hotel/_search
{
  "query": {
    "match_all": {}
  },
  "sort": [
    {
      "_geo_distance": {
        "location": {
          "lat": 31.034661,
          "lon": 121.612262
        },
        "order": "asc",
        "unit": "km"
      }
    }
  ]
}

# 12.7.2. 分页

elasticsearch 默认情况下只返回 top 10 的数据。

而如果要查询更多数据就需要修改分页参数了。

elasticsearch 中通过修改 from、size 参数来控制要返回的分页结果:

GET /hotel/_search
{
  "query": {
    "match_all": {}
  },
  "from": 990, // 分页开始的位置,默认为0
  "size": 10, // 期望获取的文档总数
  "sort": [
    {"price": "asc"}
  ]
}

from + size 跟 MySQL 中的 limit 的用法是一样的。

不过 ES 是先查出 1000 条,在截取 990 ~ 1000 条的数据

深度分页问题

ES 是分布式的,数据分布存储在各个不同的机器,所以会面临深度分页问题。

例如按 price 排序后,获取 from = 990,size =10 的数据:

  1. 首先在每个数据分片上都排序并查询前 1000 条文档。
  2. 然后将所有节点的结果聚合,在内存中重新排序选出前 1000 条文档
  3. 最后从这 1000 条中,选取从 990 开始的 10 条文档

如果搜索页数过深,或者结果集(from + size)越大,对内存和 CPU 的消耗也越高。

因此 ES 设定结果集查询的上限是 10000

深度分页解决方案

针对深度分页,ES 提供了两种解决方案,官方文档:

  • search after:分页时需要排序,原理是从上一次的排序值开始,查询下一页数据。官方推荐使用的方式。
  • scroll:原理将排序数据形成快照,保存在内存。官方已经不推荐使用。

小结

from + size:

  • 优点:支持随机翻页
  • 缺点:深度分页问题,默认查询上限(from + size)是10000
  • 场景:百度、京东、谷歌、淘宝这样的随机翻页搜索

after search:

  • 优点:没有查询上限(单次查询的 size 不超过 10000)
  • 缺点:只能向后逐页查询,不支持随机翻页
  • 场景:没有随机翻页需求的搜索,例如手机向下滚动翻页

scroll:

  • 优点:没有查询上限(单次查询的 size 不超过 10000)
  • 缺点:会有额外内存消耗,并且搜索结果是非实时的
  • 场景:海量数据的获取和迁移。从 ES7.1 开始不推荐,建议用 after search 方案。

# 12.7.3. 高亮

高亮:就是在搜索结果中把搜索关键字突出显示。

原理:

  • 将搜索结果中的关键字用标签标记出来
  • 在页面中给标签添加css样式

语法:

GET /hotel/_search
{
  "query": {
    "match": {
      "FIELD": "TEXT"
    }
  },
  "highlight": {
    "fields": { // 指定要高亮的字段
      "FIELD": {
        "pre_tags": "<em>",  // 用来标记高亮字段的前置标签
        "post_tags": "</em>" // 用来标记高亮字段的后置标签
      }
    }
  }
}

示例:

GET /hotel/_search
{
  "query": {
    "match": {
      "all": "如家"
    }
  },
  "highlight": {
    "fields": { 
      "name": {
        // 默认情况下 搜索的字段与高亮的字段 要一致
        // 搜索的字段是 all,高亮的字段是 name,两者不一致时,需要指定 require_field_match
        "require_field_match": "false"
        // 默认就是使用 <em></em> 包裹匹配的词条
      }
    }
  }
}

小结

搜索结果处理整体语法:

GET /hotel/_search
{
  "query": {
    "match": {
      "name": "如家"
    }
  },
  "from": 0, // 分页开始的位置
  "size": 20, // 期望获取的文档总数
  "sort": [ 
    {  "price": "asc" }, // 普通排序
    {
      "_geo_distance" : { // 距离排序
          "location" : "31.040699,121.618075", 
          "order" : "asc",
          "unit" : "km"
      }
    }
  ],
  "highlight": {
    "fields": { // 高亮字段
      "name": {
        "pre_tags": "<em>",  // 用来标记高亮字段的前置标签
        "post_tags": "</em>" // 用来标记高亮字段的后置标签
      }
    }
  }
}

# 12.8. RestClient 查询文档

# 12.8.1. 快速入门

我们通过 match_all 来演示下基本的 API,先看请求 DSL 的组织

@Test
void testMatchAll() throws IOException {
    // 1. 准备 Request
    SearchRequest request = new SearchRequest("hotel");

    // 2. 准备 DSL
    request.source().query(QueryBuilders.matchAllQuery());

    // 3. 发送请求
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);

    // 4. 解析响应
    SearchHits searchHits = response.getHits();

    // 4.1 获取总数
    long total = searchHits.getTotalHits().value;
    System.out.println("total = " + total);

    // 4.2 文档数组
    SearchHit[] hits = searchHits.getHits();

    for (SearchHit hit : hits) {
        // 获取文档 source
        String json = hit.getSourceAsString();

        // 反序列化
        HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
        System.out.println("hotelDoc = " + hotelDoc);
    }
}

RestAPI 中其中构建 DSL 是通过 HighLevelRestClient 中的 resource() 来实现的,其中包含了查询、排序、分页、高亮等

RestAPI 中其中构建查询条件的核心部分是由一个名为 QueryBuilders 的工具类提供的,其中包含了各种查询方法

小结

查询的基本步骤是:

  1. 创建 SearchRequest 对象
  2. 准备 Request.source( ),也就是 DSL。
    • QueryBuilders 来构建查询条件
    • 传入 Request.source() 的 query() 方法
  3. 发送请求,得到结果
  4. 解析结果(参考 JSON 结果,从外到内,逐层解析)

# 12.8.2. match、term、range、bool 查询

全文检索查询

全文检索的 match 和 multi_match 查询与 match_all 的 API 基本一致。

抽取:

void search(Consumer<SearchRequest> buildRequest) throws IOException {
    // 1. 准备 Request
    SearchRequest request = new SearchRequest("hotel");

    // 2. 准备 DSL
    buildRequest.accept(request);

    // 3. 发送请求
    SearchResponse response = client.search(request, RequestOptions.DEFAULT);

    // 4. 解析响应
    SearchHits searchHits = response.getHits();

    // 4.1 获取总数
    long total = searchHits.getTotalHits().value;
    System.out.println("total = " + total);

    // 4.2 文档数组
    SearchHit[] hits = searchHits.getHits();

    for (SearchHit hit : hits) {
        // 获取文档 source
        String json = hit.getSourceAsString();

        // 反序列化
        HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
        System.out.println("hotelDoc = " + hotelDoc);
    }
}

差别是查询条件,也就是 query 的部分。

同样是利用 QueryBuilders 提供的方法:

// 单字段查询
@Test
void testMatch() throws IOException {
    search((request) -> {
        request.source().query(QueryBuilders.matchQuery("all", "如家"));
    });
}

// 多字段查询
@Test
void testMultiMatch() throws IOException {
    search((request) -> {
        request.source().query(QueryBuilders.multiMatchQuery("如家", "name", "business"));
    });
}

精确查询

精确查询常见的有 term 查询和 range 查询,同样利用 QueryBuilders 实现

// 词条查询 
QueryBuilders.termQuery("city", "杭州");  

// 范围查询
QueryBuilders.rangeQuery("price").gte(100).lte(150);

复合查询-boolean query

@Test
void testBool() throws IOException {
    search((request) -> {
        BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();

        // 添加 term
        boolQueryBuilder.must(QueryBuilders.termQuery("city", "上海"));

        // 添加 range
        boolQueryBuilder.filter(QueryBuilders.rangeQuery("price").gte(200).lte(250));

        request.source().query(boolQueryBuilder);
    });
}

小结

要构建查询条件,只要记住一个类:QueryBuilders

# 12.8.3. 排序和分页

搜索结果的排序和分页是与 query 同级的参数

@Test
void testPageAndSort() throws IOException {
    search((request) -> {
        SearchSourceBuilder sourceBuilder = request.source();

        // 查询
        sourceBuilder.query(QueryBuilders.matchAllQuery());

        // 排序
        sourceBuilder.sort("price", SortOrder.ASC);

        // 分页
        sourceBuilder.from(0).size(5);
    });
}

# 12.8.4. 高亮显示

高亮 API 包括两部分:

  1. 请求 DSL 构建
  2. 结果解析

请求 DSL 构建:

SearchSourceBuilder sourceBuilder = request.source();

// 查询
sourceBuilder.query(QueryBuilders.matchQuery("all", "如家"));

// 高亮
sourceBuilder.highlighter(new HighlightBuilder()
        .field("name")
        .requireFieldMatch(false)
);

结果解析:

// 4.2 文档数组
SearchHit[] hits = searchHits.getHits();

for (SearchHit hit : hits) {
    // 获取文档 source
    String json = hit.getSourceAsString();

    // 反序列化
    HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);

    // 获取高亮结果集
    Map<String, HighlightField> highlightFields = hit.getHighlightFields();

    if (!CollectionUtils.isEmpty(highlightFields)) {
        // 根据 字段名称 获取高亮结果
        HighlightField highlightField = highlightFields.get("name");

        if (highlightField != null) {
            // 获取高亮结果中的第一个条目,并转为字符串
            String highlightName = highlightField.getFragments()[0].toString();

            // 使用高亮结果覆盖 name 属性
            hotelDoc.setName(highlightName);
        }

    }

    System.out.println("hotelDoc = " + hotelDoc);
}

小结

所有搜索 DSL 的构建,记住一个API:

  • SearchRequest 的 source() 方法

高亮结果解析是参考 JSON 结果,逐层解析

# 12.9. 黑马旅游案例

# 12.9.1. 搜索、分页

案例

实现黑马旅游的酒店搜索功能,完成关键字搜索和分页

先实现其中的关键字搜索功能,实现步骤如下:

  1. 定义实体类,接收前端请求
  2. 定义 controller 接口,接收页面请求,调用 IHotelService 的 search 方法
  3. 定义 IHotelService 中的 search 方法,利用 match 查询实现根据关键字搜索酒店信息

步骤 1: 定义类,接收前端请求参数

@Data
public class RequestParams {
    private String key;
    private Integer page;
    private Integer size;
    private String sortBy;
}

步骤 2: 定义 controller 接口,接收前端请求

定义一个 HotelController,声明查询接口,满足下列要求:

  • 请求方式:Post
  • 请求路径:/hotel/list
  • 请求参数:对象,类型为 RequestParam
  • 返回值:PageResult,包含两个属性
    • Long total:总条数
    • List<HotelDoc> hotels:酒店数据
@Data
public class PageResult {
    private Long total;
    private List<HotelDoc> hotels;
}

# 12.9.2. 条件过滤

修改 search 方法,在 match 查询基础上添加过滤条件

过滤条件包括:

  • city 精确匹配
  • brand 精确匹配
  • starName 精确匹配
  • price 范围过滤

注意事项:

  • 多个条件之间是AND关系,组合多条件用BooleanQuery
  • 参数存在才需要过滤,做好非空判断
private static void buildBasicQuery(RequestParams params, SearchRequest request) {
    BoolQueryBuilder boolQuery = new BoolQueryBuilder();

    request.source().query(boolQuery);

    // query (must)
    String keyword = params.getKey();

    if (StringUtils.hasText(keyword)) {
        boolQuery.must(QueryBuilders.matchQuery("all", keyword));
    } else {
        boolQuery.must(QueryBuilders.matchAllQuery());
    }

    // 城市条件
    String city = params.getCity();
    if (!StringUtils.isEmpty(city)) {
        boolQuery.filter(QueryBuilders.termQuery("city", city));
    }

    // 品牌条件
    String brand = params.getBrand();
    if (!StringUtils.isEmpty(brand)) {
        boolQuery.filter(QueryBuilders.termQuery("brand", brand));
    }

    // 星级条件
    String starName = params.getStarName();
    if (!StringUtils.isEmpty(starName)) {
        boolQuery.filter(QueryBuilders.termQuery("starName", starName));
    }

    // 价格
    Integer minPrice = params.getMinPrice();
    Integer maxPrice = params.getMaxPrice();
    if (minPrice != null && maxPrice != null) {
        boolQuery.filter(QueryBuilders.rangeQuery("price").gte(minPrice).lte(maxPrice));
    }
}

# 12.9.3. 我附近的酒店

距离排序:

request.source().sort(SortBuilders
        .geoDistanceSort(
                // 地理位置字段,
                "location",
                // 中心点位置: "维度,经度"
                new GeoPoint(location)
        )
        // 降序
        .order(SortOrder.ASC)
        // 距离的单位(默认为 米)
        .unit(DistanceUnit.KILOMETERS)
);

获取(距离)排序的值:

// 获取排序值(距离)
Object[] sortValues = hit.getSortValues();
if (sortValues.length > 0) {
    Object sortValue = sortValues[0];
    hotelDoc.setDistance(sortValue);
}

# 12.9.4. 广告置顶

让指定的酒店在搜索结果中排名置顶

我们给需要置顶的酒店文档添加一个标记。然后利用 function score 给带有标记的文档增加权重。

实现步骤分析:

  1. 给 HotelDoc 类添加 isAD 字段,Boolean 类型
  2. 挑选几个你喜欢的酒店,给它的文档数据添加 isAD 字段,值为 true
  3. 修改 search 方法,添加 function score 功能,给 isAD 值为 true 的酒店增加权重
POST /hotel/_update/39106
{
  "doc": {
    "isAD": true
  }
}

GET /hotel/_doc/39106
// 算法控制
FunctionScoreQueryBuilder functionScoreQuery = QueryBuilders.functionScoreQuery(
        // 原始查询,相关性算法的查询
        boolQuery,
        // function score 数组
        new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{
                // function score 元素
                new FunctionScoreQueryBuilder.FilterFunctionBuilder(
                        // 过滤条件
                        QueryBuilders.termQuery("isAD", true),
                        // 算法函数
                        ScoreFunctionBuilders.weightFactorFunction(10)
                )
        }
);

# 12.10. 数据聚合

# 12.10.1. 聚合的分类

聚合(aggregations) (opens new window)可以实现对文档数据的统计、分析、运算。

聚合常见的有三类

  • 桶(Bucket)聚合:用来对文档做分组

    • TermAggregation:按照文档字段值分组
    • Date Histogram:按照日期阶梯分组,例如一周为一组,或者一月为一组
  • 度量(Metric)聚合:用以计算一些值,比如:最大值、最小值、平均值等

    • Avg:求平均值
    • Max:求最大值
    • Min:求最小值
    • Stats:同时求max、min、avg、sum等
  • 管道(pipeline)聚合:其它聚合的结果为基础做聚合

小结

什么是聚合?

  • 聚合是对文档数据的统计、分析、计算

聚合的常见种类有哪些?

  • Bucket:对文档数据分组,并统计每组数量
  • Metric:对文档数据做计算,例如avg
  • Pipeline:基于其它聚合结果再做聚合

参与聚合的字段类型必须是:

  • keyword
  • 数值
  • 日期
  • 布尔

# 12.10.2. DSL 实现 Bucket 聚合

现在,我们要统计所有数据中的酒店品牌有几种,此时可以根据酒店品牌的名称做聚合。

类型为 term 类型,DSL示例:

GET /hotel/_search
{
  "size": 0,  // 设置 size 为 0,结果中不包含文档,只包含聚合结果
  "aggs": {   // 定义聚合
    "brandAgg": {   // 【聚合的名字】给聚合起个名字
      "terms": {      // 【聚合的类型】按照品牌值聚合,所以选择 term
        "field": "brand", // 【聚合的字段】参与聚合的字段
        "size": 20        // 希望获取的聚合结果数量,默认为 10
      }
    }
  }
}

示例:

// # 聚合功能
GET /hotel/_search
{
  "size": 0,  
  "aggs": { 
    "brandAgg": { 
      "terms": { 
        "field": "brand"
      }
    }
  }
}

结果:

{
  "aggregations" : {
    "brandAgg" : {
      "buckets" : [
        {
          "key" : "7天酒店",
          "doc_count" : 30
        },
        // ...
      ]
    }
  }
}

聚合结果排序

默认情况下,Bucket 聚合会统计 Bucket 内的文档数量,记为 _count ,并且按照 _count 降序排序。

我们可以修改结果排序方式:

// # 聚合功能,自定义排序规则
GET /hotel/_search
{
  "size": 0,  
  "aggs": { 
    "brandAgg": { 
      "terms": { 
        "field": "brand", 
        "order": {
          "_count": "asc"
        }
      }
    }
  }
}

限定聚合范围

默认情况下,Bucket 聚合是对索引库的所有文档做聚合,我们可以限定要聚合的文档范围,只要添加 query 条件即可:

// # 聚合功能,限定聚合范围
GET /hotel/_search
{
  "query": {
    "range": {
      "price": {
        "lte": 200
      }
    }
  }, 
  "size": 0,  
  "aggs": { 
    "brandAgg": { 
      "terms": { 
        "field": "brand"
      }
    }
  }
}

小结

aggs 代表聚合,与 query 同级,此时 query 的作用是?

  • 限定聚合的的文档范围

聚合必须的三要素:

  • 聚合名称
  • 聚合类型
  • 聚合字段

聚合可配置属性有:

  • size:指定聚合结果数量
  • order:指定聚合结果排序方式
  • field:指定聚合字段

# 12.10.3. DSL 实现 Metrics 聚合

# 12.10.4. RestClient 实现聚合

# 12.10.5. 多条件聚合

# 12.10.6. 带过滤条件的聚合

# 12.11. 自动补全

# 12.12. 数据同步

# 12.13. es 集群

本章目录