# 微服务
**微服务**是一种软件架构风格,它是以专注于单一职责的很多小型项目为基础,组合出复杂的大型应用。
- 服务拆分
- 远程调用
- 服务治理
- 请求路由
- 身份认证
- 配置管理
- 服务保护
- 分布式事务
- 异步通信
- 消息可靠性
- 延迟消息
- 分布式搜索
- 倒排索引
- 数据聚合
## 单体架构

**单体架构**:将业务所有的功能集中在一个项目中开发,打成一个包部署。
**优点**:
- 架构简单
- 部署成本低
**缺点**:
- 团队协作效率低
- 系统发布效率低
- 系统可用性差
单体架构适合开发功能相对简单,规模较小的项目

## 微服务
**微服务**架构,是服务化思想指导下的一套最佳实践架构方案。服务化,就是把单体架构中的功能模块拆分成多个独立项目。
- 粒度小
- 团队自治
- 服务自治

## SpringCloud
SpringCloud是目前国内使用最广泛的微服务框架。官网地址:https://spring.io/projects/spring-cloud。
SpringCloud集成了各种微服务功能组件,并基于SpringBoot实现了这些组件的自动装配,从而提供了良好的开箱即用体验:

## 服务拆分
### 熟悉黑马商城
**架构图**:

### 服务拆分原则
**什么时候拆分**
- **创业型项目**:先采用单体架构,快速开发,快速试错。随着规模扩大,逐渐拆分
- **确定的大型项目**:资金充足,目标明确,可以直接选择微服务架构,避免后续拆分的麻烦。
**怎么拆分**
从拆分目标来说,要做到:
- **高内聚**:每个微服务的职责要尽量单一,包含的业务相互关联度高、完整度高。
- **低耦合**:每个微服务的功能要相对独立,尽量减少对其它微服务的依赖。
从拆分方式来说,一般包含两种方式:
- **纵向拆分**:按照业务模块来拆分
- **横向拆分**:抽取公共服务,提高复用性
### 拆分服务
工程结构有两种:
- 独立Project
- Maven聚合

需求:
- 将hm-service中与商品管理相关功能拆分到一个微服务module中,命名为item-service
- 将hm-service中与购物车有关的功能拆分到一个微服务module中,命名为cart-service
## 远程调用
在查询购物车列表的时候,需要调用商品服务模块:

虽然在物理上两个服务隔开了,但是网络是相同的,所以,我们可以通过网络去发起请求:

**远程调用**
Spring给我们提供了一个RestTemplate工具,可以方便的实现Http请求的发送。使用步骤如下:
- 注入RestTemplate到Spring容器
```java
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
```
- 发起远程调用
```java
public <T> ResponseEntity<T> exchange(
String url, // 请求路径
HttpMethod method, // 请求方式
@Nullable HttpEntity<?> requestEntity, // 请求实体,可以为空
Class<T> responseType, // 返回值类型
Map<String, ?> uriVariables // 请求参数
)
```
```java
ResponseEntity<List<ItemDTO>> response = restTemplate.exchange(
"http://localhost:8081/items?ids={ids}", // 请求路径
HttpMethod.GET, // 请求方法
null, // 参数
new ParameterizedTypeReference<List<ItemDTO>>() {}, // 返回值泛型类型
Map.of("ids", CollectionUtil.join(itemIds, ","))
);
if (!response.getStatusCode().is2xxSuccessful()) {
return;
}
List<ItemDTO> items = response.getBody();
```
不需要掌握整个API,重点了解调用过程。
## 服务治理
服务远程调用存在的问题:根本不知道调用的服务是否还活着,无法负载均衡等等。

### 注册中心原理

服务治理中的三个角色分别是什么?
- 服务提供者:暴露服务接口,供其它服务调用
- 服务消费者:调用其它服务提供的接口
- 注册中心:记录并监控微服务各实例状态,推送服务变更信息
消费者如何知道提供者的地址?
- 服务提供者会在启动时注册自己信息到注册中心,消费者可以从注册中心订阅和拉取服务信息
消费者如何得知服务状态变更?
- 服务提供者通过心跳机制向注册中心报告自己的健康状态,当心跳异常时注册中心会将异常服务剔除,并通知订阅了该服务的消费者
- 当提供者有多个实例时,消费者该选择哪一个?
- 消费者可以通过负载均衡算法,从多个实例中选择一个
### Nacos注册中心
Nacos是目前国内企业中占比最多的注册中心组件。它是阿里巴巴的产品,目前已经加入SpringCloudAlibaba中。
#### 安装Nacos
docker安装:
```shell
docker run \
--name nacos \
-p 8848:8848 \
-p 9848:9848 \
-p 9849:9849 \
-e MODE=standalone \
-e MYSQL_SERVICE_HOST=localhost \
-e MYSQL_SERVICE_PORT=3306 \
-e MYSQL_DATABASE=nacos \
-e MYSQL_USER=root \
-e MYSQL_PASSWORD=123 \
-d nacos/nacos-server:v2.1.0-slim
```
#### 服务注册
- 引入依赖
```xml
<!-- nacos -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
```
- 配置nacos地址
```yaml
spring:
application:
name: item-service # 服务名称
cloud:
nacos:
server-addr: localhost:8848 # nacos地址
```
#### 服务发现
**消费者**需要连接nacos以拉取和订阅服务,因此服务发现的前两步与服务注册是一样,后面再加上服务调用即可:
- 引入nacos依赖
- 配置nacos地址
- 服务发现
```java
private final DiscoveryClient discoveryClient;
```
```java
// 1.获取商品id
Set<Long> itemIds = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet());
List<ServiceInstance> instances = discoveryClient.getInstances("item-service");
if (CollUtils.isEmpty(instances)) {
return;
}
ServiceInstance instance = instances.get(RandomUtil.randomInt(instances.size()));
// 2.查询商品
ResponseEntity<List<ItemDTO>> response = restTemplate.exchange(
instance.getUri() + "/items?ids={ids}", // 请求路径
HttpMethod.GET, // 请求方法
null, // 参数
new ParameterizedTypeReference<List<ItemDTO>>() {}, // 返回值泛型类型
Map.of("ids", CollectionUtil.join(itemIds, ","))
);
```
## OpenFeign
### 快速入门
OpenFeign是一个声明式的http客户端,是SpringCloud在Eureka公司开源的Feign基础上改造而来。官方地址:https://github.com/OpenFeign/feign
其作用就是基于SpringMVC的常见注解,帮我们优雅的实现http请求的发送。

OpenFeign已经被SpringCloud自动装配,实现起来非常简单:
- 引入依赖,包括OpenFeign和负载均衡组件SpringCloudLoadBalancer
```java
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
```
负载均衡早期用的是Ribbon,现在用的是最新版的loadbalancer。
- 通过`@EnableFeignClients`注解,启用OpenFeign功能
```java
@EnableFeignClients
@SpringBootApplication
public class CartApplication {
public static void main(String[] args) {
SpringApplication.run(CartApplication.class, args);
}
}
```
- 编写FeignClient
```java
@FeignClient("item-service")
public interface ItemClient {
@GetMapping("/items")
List<ItemDTO> queryItemByIds(@RequestParam("ids")Collection<Long> ids);
}
```
- 使用FeignClient,实现远程调用
```java
List<ItemDTO> items = itemClient.queryItemByIds(List.of(1, 2, 3));
```
### 连接池
OpenFeign对Http请求做了优雅的伪装,不过其底层发起http请求,依赖于其它的框架。这些框架可以自己选择,包括以下三种:
- **HttpURLConnection**:默认实现,不支持连接池
- **Apache** **HttpClient** :支持连接池
- **OKHttp**:支持连接池
#### 引入OKHttp
- 引入依赖
```xml
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-okhttp</artifactId>
</dependency>
```
- 开启连接池功能
```yaml
feign:
okhttp:
enabled: true
```
### 最佳实践

定义一个统一的api模块,所有的需要暴露接口的微服务都定义在api模块里面,其它微服务要用的时候,直接引用依赖即可。
当定义的FeignClient不在SpringBootApplication的扫描包范围时,这些FeignClient无法使用。解决方法:配置扫描包:
```java
@EnableFeignClients(basePackages = "com.hmall.api.clients")
```
### 日志
OpenFeign只会在FeignClient所在包的日志级别为**DEBUG**时,才会输出日志。而且其日志级别有4级:
- **NONE**:不记录任何日志信息,这是默认值。
- **BASIC**:仅记录请求的方法,URL以及响应状态码和执行时间
- **HEADERS**:在BASIC的基础上,额外记录了请求和响应的头信息
- **FULL**:记录所有请求和响应的明细,包括头信息、请求体、元数据。
由于Feign默认的日志级别就是NONE,所以默认我们看不到请求日志。
要自定义日志级别需要声明一个类型为Logger.Level的Bean,在其中定义日志级别:
```java
public class DefaultFeignConfig {
@Bean
public Logger.Level feignLoggerLevel() {
return Logger.Level.FULL;
}
}
```
配置全局生效:
```java
@EnableFeignClients(defaultConfiguration = DefaultFeignConfig.class)
```
## 网关
**网关**:就是网络的关口,负责请求的路由、转发、身份校验。

在SpringCloud中网关的实现包括两种:
- **Spring Cloud Gateway**
- **Netfilx** **Zuul**
前者的性能优于后者,所以我们优先选择前者。
### 网关路由

- 引入网关依赖
```xml
<!--网关-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!--nacos discovery-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!--负载均衡-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
```
- 编写启动类
```java
@SpringBootApplication
@Slf4j
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
log.info("网关服务启动");
}
}
```
- 配置路由规则
```yaml
server:
port: 8080
spring:
application:
name: gateway
cloud:
nacos:
server-addr: localhost:8848
gateway:
routes:
- id: item # 路由规则id,自定义,唯一
uri: lb://item-service # 路由的目标服务,lb代表负载均衡,会从注册中心拉取服务列表
predicates: # 路由断言,判断当前请求是否符合当前规则,符合则路由到目标服务
- Path=/items/**,/search/** # 这里是以请求路径作为判断规则
- id: cart
uri: lb://cart-service
predicates:
- Path=/carts/**
- id: user
uri: lb://user-service
predicates:
- Path=/users/**,/addresses/**
- id: trade
uri: lb://trade-service
predicates:
- Path=/orders/**
- id: pay
uri: lb://pay-service
predicates:
- Path=/pay-orders/**
```
### 路由属性
**网关路由**对应的Java类型是`RouteDefinition`,其中常见的属性有:
- id:路由唯一表示
- uri:路由目标地址
- predicates:路由断言
- filters:路由过滤器,对请求或响应做特殊处理
**路由断言的类型有很多**:
| **名称** | **说明** | **示例** |
| :--------- | :----------------------------- | :----------------------------------------------------------- |
| 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 | 权重处理 | |
### 网关登录校验
**网关请求处理流程**

**网关登录校验流程:**

#### 自定义过滤器
网关过滤器有两种,分别是:
- GatewayFilter:路由过滤器,作用于任意指定的路由,默认不生效,要配置到路由后生效
- GlobalFilter:全局过滤器,作用范围是所有路由,声明后生效
两种过滤器的过滤方法签名完全一致:
```java
public interface GlobalFilter {
Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);
}
```
- **exchange**:请求上下文,包含整个过滤器链内共享数据,例如:request、response等
- **chain**:过滤器链,当前过滤器执行完成后,需要调用过滤器链中的下一个过滤器
**自定义过滤器:**
```java
@Component
public class MyGlobalFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
HttpHeaders headers = request.getHeaders();
System.out.println("headers = " + headers);
// 调用下一个过滤器
return chain.filter(exchange);
}
@Override
public int getOrder() { // 过滤器的优先级,越小优先级越高
return 0;
}
}
```
#### 登录校验
**代码实现:**
```java
@Component
@RequiredArgsConstructor
public class AuthGlobalFilter implements GlobalFilter, Ordered {
private final AuthProperties authProperties;
private final JwtTool jwtTool;
private final AntPathMatcher antPathMatcher = new AntPathMatcher();
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1. 获取request
ServerHttpRequest request = exchange.getRequest();
// 2. 判断是否需要做登录拦截
if (isExclude(request.getPath().toString())) {
// 放行
return chain.filter(exchange);
}
// 3. 获取token
String token = null;
List<String> headers = request.getHeaders().get("authorization");
if (headers != null && !headers.isEmpty()) {
token = headers.get(0);
}
// 4. 检验并校验token
Long userId = null;
try {
userId = jwtTool.parseToken(token);
} catch (Exception e) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
// 完结,不要往下验证了,直接滚回去
return exchange.getResponse().setComplete();
}
// 5. 传递用户信息
String userInfo = userId.toString();
ServerWebExchange swe = exchange.mutate().request(builder -> builder.header("user-info", userInfo)).build();
// 6. 放行
return chain.filter(swe);
}
private boolean isExclude(String path) {
List<String> excludePaths = authProperties.getExcludePaths();
return excludePaths.stream().anyMatch(pattern -> antPathMatcher.match(pattern, path));
}
@Override
public int getOrder() {
return 0;
}
}
```
**网关传递用户信息**

- 定义拦截器
```java
public class UserInfoInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 获取用户登录信息
String userInfo = request.getHeader("user-info");
// 2. 判断是否获取了用户,如果有,存入ThreadLocal
if (StrUtil.isNotBlank(userInfo)) {
UserContext.setUser(Long.valueOf(userInfo));
}
// 3. 放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 清理用户信息
UserContext.removeUser();
}
}
```
- 添加配置类
```java
/**
* 配置MVC相关的设置
* 通过实现WebMvcConfigurer接口,可以定制Spring MVC的行为
* 该配置类仅在DispatcherServlet类存在时才被激活,确保了Spring MVC的相关配置能够按需加载
*/
@Configuration
@ConditionalOnClass(DispatcherServlet.class)
public class MvcConfig implements WebMvcConfigurer {
/**
* 添加拦截器
* 此方法用于注册自定义的拦截器,以便在请求处理前或处理后执行特定的操作
*
* @param registry 拦截器注册对象,通过它可将自定义拦截器添加到Spring MVC的拦截器列表中
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new UserInfoInterceptor()); // 注册UserInfoInterceptor拦截器
}
}
```
- 添加配置扫描包,在common包的resource/META-INF/spring.factories中
```factories
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.hmall.common.config.MvcConfig
```
**OpenFeign传递用户**
微服务项目中的很多业务要多个微服务共同合作完成,而这个过程中也需要传递登录用户信息,例如:

OpenFeign中提供了一个拦截器接口,所有由OpenFeign发起的请求都会先调用拦截器处理请求:
```java
public interface RequestInterceptor {
void apply(RequestTemplate var1);
}
```
**解决方案:**

在api模块,加入拦截器
```java
@Bean
public RequestInterceptor userInfoRequestInterceptor() {
return requestTemplate -> requestTemplate.header("user-info", UserContext.getUser().toString());
}
```
## 配置管理

### 配置共享配置到nacos
添加一些共享配置到Nacos中,包括:Jdbc、MybatisPlus、日志、Swagger、OpenFeign等配置


基于NacosConfig拉取共享配置代替微服务的本地配置。

- 引入依赖
```xml
<!--nacos配置管理-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!--读取bootstrap文件-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
```
- 编写bootstrap.yaml
```yaml
spring:
application:
name: cart-service # 服务名称
profiles:
active: dev
cloud:
nacos:
server-addr: localhost:8848 # nacos地址
config:
file-extension: yaml # 文件后缀名
shared-configs: # 共享配置
- dataId: shared-jdbc.yaml # 共享mybatis配置
- dataId: shared-log.yaml # 共享日志配置
- dataId: shared-swagger.yaml # 共享日志配置
```
- 修改application.yaml
```yaml
server:
port: 8082
feign:
okhttp:
enabled: true
hm:
db:
database: hm-cart
swagger:
title: "黑马商城购物车服务接口文档"
package: com.hmall.cart.controller
```
### 配置热更新
**配置热更新**:当修改配置文件中的配置时,微服务无需重启即可使配置生效。
**前提条件**:
- nacos中要有一个和微服务名有关的配置文件
- 微服务中要以特定方式读取热更新的配置属性
```java
@Data
@ConfigurationProperties(prefix = "hm.cart")
@Component
public class CartProperties {
private Integer maxItems;
}
```
### 动态路由
要实现**动态路由**首先要将路由配置保存到Nacos,当Nacos中的路由配置变更时,推送最新配置到网关,实时更新网关中的路由信息。
我们需要完成两件事:
- 监听Nacos配置变更消息
- 当配置变更时,将最新的路由信息更新到网关路由表
实现:
- 在网关模块中引入依赖
```xml
<!--nacos配置管理-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!--读取bootstrap文件-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
```
- 删除yaml文件中路由相关信息
- 在网关模块中添加路由动态加载类
```java
/**
* 动态路由加载器,用于在系统启动时和运行时根据Nacos中的配置动态更新路由表
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DynamicRouteLoader {
private final RouteDefinitionWriter writer;
private final NacosConfigManager nacosConfigManager;
// 路由配置文件的id和分组
private final String dataId = "gateway-routes.json";
private final String group = "DEFAULT_GROUP";
// 保存更新过的路由id
private final Set<String> routeIds = new HashSet<>();
@PostConstruct
public void initRouteConfigListener() throws NacosException {
// 1.注册监听器并首次拉取配置
String configInfo = nacosConfigManager.getConfigService()
.getConfigAndSignListener(dataId, group, 5000, new Listener() {
@Override
public Executor getExecutor() {
return null;
}
@Override
public void receiveConfigInfo(String configInfo) {
updateConfigInfo(configInfo);
}
});
// 2.首次启动时,更新一次配置
updateConfigInfo(configInfo);
}
private void updateConfigInfo(String configInfo) {
log.debug("监听到路由配置变更,{}", configInfo);
// 1.反序列化
List<RouteDefinition> routeDefinitions = JSONUtil.toList(configInfo, RouteDefinition.class);
// 2.更新前先清空旧路由
// 2.1.清除旧路由
for (String routeId : routeIds) {
writer.delete(Mono.just(routeId)).subscribe();
}
routeIds.clear();
// 2.2.判断是否有新的路由要更新
if (CollUtils.isEmpty(routeDefinitions)) {
// 无新路由配置,直接结束
return;
}
// 3.更新路由
routeDefinitions.forEach(routeDefinition -> {
// 3.1.更新路由
writer.save(Mono.just(routeDefinition)).subscribe();
// 3.2.记录路由id,方便将来删除
routeIds.add(routeDefinition.getId());
});
}
}
```
- 在nacos添加路由配置

```json
[
{
"id": "item",
"predicates": [{
"name": "Path",
"args": {"_genkey_0":"/items/**", "_genkey_1":"/search/**"}
}],
"filters": [],
"uri": "lb://item-service"
},
{
"id": "cart",
"predicates": [{
"name": "Path",
"args": {"_genkey_0":"/carts/**"}
}],
"filters": [],
"uri": "lb://cart-service"
},
{
"id": "user",
"predicates": [{
"name": "Path",
"args": {"_genkey_0":"/users/**", "_genkey_1":"/addresses/**"}
}],
"filters": [],
"uri": "lb://user-service"
},
{
"id": "trade",
"predicates": [{
"name": "Path",
"args": {"_genkey_0":"/orders/**"}
}],
"filters": [],
"uri": "lb://trade-service"
},
{
"id": "pay",
"predicates": [{
"name": "Path",
"args": {"_genkey_0":"/pay-orders/**"}
}],
"filters": [],
"uri": "lb://pay-service"
}
]
```
## 微服务保护和分布式事务
### 雪崩问题
微服务调用链路中某个服务故障,引起整个链路中的所有微服务都不可用,这就是雪崩。
**雪崩问题产生的原因**
- 微服务间相互调用,服务提供者出现故障或者阻塞
- 服务调用者没有做好异常处理,导致自身故障
- 调用链路中的所有服务级联失败,导致整个集群故障
**解决的思路**
- 尽量避免出现故障或者阻塞
- 保证代码的健壮性
- 保证网络通畅
- 能应对较高的并发
- 服务调用者做好远程调用异常的的后备方案,避免故障扩散
### 服务保护方案
- 请求限流:限制访问微服务的请求的并发量,避免服务因流量激增出现故障
- 线程隔离:也叫做舱壁模式,模拟船舱隔板的防水原理,通过限定每个业务能使用的线程数量而将故障业务隔离,避免故障扩散
- 失败处理:给业务编写一个调用失败时的处理的逻辑,称为fallback。当调用出现故障(比如无线程可用)时,按照失败处理逻辑执行业务并返回,而不是直接抛出异常。
- 服务熔断:由**断路器**统计请求的异常比例或慢调用比例,如果超出阈值则会**熔断**该业务,则拦截该接口的请求。
熔断期间,所有请求快速失败,全都走fallback逻辑。
### Sentinel
Sentinel是阿里巴巴开源的一款服务流量控制组件。官网地址:[ https://sentinelguard.io/zh-cn/index.html](https://sentinelguard.io/zh-cn/index.html)
#### 安装控制台
下载Sentinel官方的jar包,运行:
```shell
java -Dserver.port=8090 -Dcsp.sentinel.dashboard.server=localhost:8090 -Dproject.name=sentinel-dashboard -jar sentinel-dashboard.jar
```
或者docker运行
```shell
docker run --name sentinel -p 8090:8858 -td bladex/sentinel-dashboard
```
访问`http://localhost:8090/`,输入用户名和密码sentinel,sentinel。
#### 微服务整合Sentinel
- 引入依赖
```xml
<!--sentinel-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
```
- 配置控制台
修改application.yaml文件,添加下面内容:
```yaml
spring:
cloud:
sentinel:
transport:
dashboard: localhost:8090 # sentinel的地址
http-method-specify: true # 是否设置请求方式作为资源名称
```
#### 请求限流
选择要限流的请求,添加流控规则

#### 线程隔离
当商品服务出现阻塞或故障时,调用商品服务的购物车服务可能会因此而被拖慢,甚至资源耗尽。所以必须限制购物车服务中查询商品这个业务的可用线程数,实现线程隔离。
修改cart-service模块的application.yml文件,开启Feign的sentinel功能:
```yaml
feign:
sentinel:
enabled: true # 开启feign对sentinel的支持
```

#### Fallback
触发限流或熔断后的请求不一定要直接报错,也可以返回一些默认数据或者友好提示,用户体验会更好。
给FeignClient编写失败后的降级逻辑有两种方式:
- 方式一:FallbackClass,无法对远程调用的异常做处理
- 方式二:FallbackFactory,可以对远程调用的异常做处理,我们一般选择这种方式。
实现:
- 在api模块中,自定义类,实现FallbackFactory,编写对某个FeignClient的fallback逻辑:
```java
@Slf4j
public class ItemClientFallback implements FallbackFactory<ItemClient> {
@Override
public ItemClient create(Throwable cause) {
return new ItemClient() {
@Override
public List<ItemDTO> queryItemByIds(Collection<Long> ids) {
log.error("远程调用ItemClient#queryItemByIds方法出现异常,参数:{}", ids, cause);
// 查询购物车允许失败,查询失败,返回空集合
return CollUtils.emptyList();
}
@Override
public void deductStock(List<OrderDetailDTO> items) {
// 库存扣减业务需要触发事务回滚,查询失败,抛出异常
throw new BizIllegalException(cause);
}
};
}
}
```
- 注入配置
```java
@Bean
public ItemClientFallbackFactory itemClientFallbackFactory() {
return new ItemClientFallbackFactory();
}
```
- 配置
```java
@FeignClient(value = "item-service", fallbackFactory = ItemClientFallbackFactory.class)
public interface ItemClient {
...
}
```
#### 服务熔断
熔断是解决雪崩问题的重要手段。思路是由**断路器**统计服务调用的异常比例、慢请求比例,如果超出阈值则会**熔断**该服务。即拦截访问该服务的一切请求;而当服务恢复时,断路器会放行访问该服务的请求。

配置熔断策略:

### 分布式事务
在分布式系统中,如果一个业务需要多个服务合作完成,而且每一个服务都有事务,多个事务必须同时成功或失败,这样的事务就是**分布式事务**。其中的每个服务的事务就是一个**分支事务**。整个业务称为**全局事务**。

#### Seata
Seata是 2019 年 1 月份蚂蚁金服和阿里巴巴共同开源的分布式事务解决方案。致力于提供高性能和简单易用的分布式事务服务,为用户打造一站式的分布式解决方案。
官网地址:http://seata.io/,其中的文档、播客中提供了大量的使用说明、源码分析。
Seata事务管理中有三个重要的角色:
- **TC (Transaction Coordinator) -** **事务协调者:**维护全局和分支事务的状态,协调全局事务提交或回滚。
- **TM (Transaction Manager) -** **事务管理器:**定义全局事务的范围、开始全局事务、提交或回滚全局事务。
- **RM (Resource Manager) -** **资源管理器:**管理分支事务,与TC交谈以注册分支事务和报告分支事务的状态
#### 部署TC服务
- 准备数据库表
```mysql
CREATE DATABASE IF NOT EXISTS `seata`;
USE `seata`;
CREATE TABLE IF NOT EXISTS `global_table`
(
`xid` VARCHAR(128) NOT NULL,
`transaction_id` BIGINT,
`status` TINYINT NOT NULL,
`application_id` VARCHAR(32),
`transaction_service_group` VARCHAR(32),
`transaction_name` VARCHAR(128),
`timeout` INT,
`begin_time` BIGINT,
`application_data` VARCHAR(2000),
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`xid`),
KEY `idx_status_gmt_modified` (`status` , `gmt_modified`),
KEY `idx_transaction_id` (`transaction_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
CREATE TABLE IF NOT EXISTS `branch_table`
(
`branch_id` BIGINT NOT NULL,
`xid` VARCHAR(128) NOT NULL,
`transaction_id` BIGINT,
`resource_group_id` VARCHAR(32),
`resource_id` VARCHAR(256),
`branch_type` VARCHAR(8),
`status` TINYINT,
`client_id` VARCHAR(64),
`application_data` VARCHAR(2000),
`gmt_create` DATETIME(6),
`gmt_modified` DATETIME(6),
PRIMARY KEY (`branch_id`),
KEY `idx_xid` (`xid`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
CREATE TABLE IF NOT EXISTS `lock_table`
(
`row_key` VARCHAR(128) NOT NULL,
`xid` VARCHAR(128),
`transaction_id` BIGINT,
`branch_id` BIGINT NOT NULL,
`resource_id` VARCHAR(256),
`table_name` VARCHAR(32),
`pk` VARCHAR(36),
`status` TINYINT NOT NULL DEFAULT '0' COMMENT '0:locked ,1:rollbacking',
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`row_key`),
KEY `idx_status` (`status`),
KEY `idx_branch_id` (`branch_id`),
KEY `idx_xid_and_branch_id` (`xid` , `branch_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
CREATE TABLE IF NOT EXISTS `distributed_lock`
(
`lock_key` CHAR(20) NOT NULL,
`lock_value` VARCHAR(20) NOT NULL,
`expire` BIGINT,
primary key (`lock_key`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('AsyncCommitting', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('RetryCommitting', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('RetryRollbacking', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('TxTimeoutCheck', ' ', 0);
```
- 准备配置文件
下载:gitee地址:https://gitee.com/ehsbsbksje/seata-config.git
根据自己机器的配置,修改配置文件。
- Docker部署
```shell
docker run --name seata \
-p 8099:8099 \
-p 7099:7099 \
-e SEATA_IP=127.0.0.1 \
-v ./seata:/seata-server/resources \
--privileged=true \
--network hm-net \
-d \
seataio/seata-server:1.5.2
```
#### 微服务集成seata
参与分布式事务的每一个微服务都需要集成Seata,我们以`trade-service`为例。
- 添加依赖
```xml
<!--统一配置管理-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!--读取bootstrap文件-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>
<!--seata-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
```
- 在nacos上添加一个共享的seata配置,命名为`shared-seata.yaml`:
```yaml
seata:
registry: # TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址
type: nacos # 注册中心类型 nacos
nacos:
server-addr: localhost:8848 # nacos地址
namespace: "" # namespace,默认为空
group: DEFAULT_GROUP # 分组,默认是DEFAULT_GROUP
application: seata-server # seata服务名称
username: nacos
password: nacos
tx-service-group: hmall # 事务组名称
service:
vgroup-mapping: # 事务组与tc集群的映射关系
hmall: "default"
```
#### XA模式
XA规范是 X/Open组织定义的分布式事务处理(DTP)标准,XA规范描述全局的TM与局部的RM之间的接口,几乎所有主流的关系型数据库都对XA规范提供了支持,Seata的XA模式如下:

- 优点
- 事务强一致性
- 常用数据库都支持,代码零侵入
- 缺点
- 一阶段需要锁定数据资源,二阶段才释放,性能较差
- 依赖关系型数据库实现事务
#### AT模式
Seata主推的是AT模式,AT模式同样是分阶段提交的事务模型,不过缺弥补了XA模型中资源锁定周期过长的缺陷。

#### AT模式和XA模式的区别
- XA模式一阶段不提交事务,锁定资源;AT模式一阶段直接提交,不锁定资源。
- XA模式依赖数据库机制实现回滚;AT模式利用数据快照实现数据回滚。
- XA模式强一致;AT模式最终一致
#### 实现AT模式
首先,添加seata-at.sql到微服务对应的数据库中:
```mysql
-- for AT mode you must to init this sql for you business database. the seata server not need it.
CREATE TABLE IF NOT EXISTS `undo_log`
(
`branch_id` BIGINT NOT NULL COMMENT 'branch transaction id',
`xid` VARCHAR(128) NOT NULL COMMENT 'global transaction id',
`context` VARCHAR(128) NOT NULL COMMENT 'undo_log context,such as serialization',
`rollback_info` LONGBLOB NOT NULL COMMENT 'rollback info',
`log_status` INT(11) NOT NULL COMMENT '0:normal status,1:defense status',
`log_created` DATETIME(6) NOT NULL COMMENT 'create datetime',
`log_modified` DATETIME(6) NOT NULL COMMENT 'modify datetime',
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB
AUTO_INCREMENT = 1
DEFAULT CHARSET = utf8mb4 COMMENT ='AT transaction mode undo table';
```
修改配置
```yaml
seata:
data-source-proxy-mode: AT
```
## MQ

### RabbitMQ
#### 安装
基于docker安装:
```shell
docker run \
-e RABBITMQ_DEFAULT_USER=itheima \
-e RABBITMQ_DEFAULT_PASS=123321 \
-v mq-plugins:/plugins \
--name mq \
--hostname mq \
-p 15672:15672 \
-p 5672:5672 \
--network hm-net\
-d \
rabbitmq:3.8-management
```
登录控制台:`localhost:15672`,用户名:`itheima`,密码:`123321`。
#### 整体概念
RabbitMQ的整体架构及核心概念:
- virtual-host:虚拟主机,起到数据隔离的作用
- publisher:消息发送者
- consumer:消息的消费者
- queue:队列,存储消息
- exchange:交换机,负责路由消息

#### 快速入门
- **新建队列**

- **SpringAMQP**
Spring AMQP是基于AMQP协议定义的一套API规范,提供了模板来发送和接受消息,包含两部分,其中spring-amqp是基础抽象,spring-rabbit是底层默认实现。
**在每个微服务中引入依赖**
```xml
<!--AMQP依赖,包含RabbitMQ-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
```
**在每个微服务中配置MQ服务端信息**
```yaml
spring:
rabbitmq:
host: localhost # 虚拟机
port: 5672 # 端口
virtual-host: /hmall # 虚拟主机
username: hmall # 用户名
password: 123 # 密码logging:
listener:
simple:
prefetch: 1 # 消费者配置,每次只能获取一条消息,处理完成才能获取下一个消息
```
**使用RabbitTemplate工具类,方便发送消息**
```java
@Autowired
private RabbitTemplate rabbitTemplate;
...
rabbitTemplate.convertAndSend("simple.queue","hello rabbitmq");
```
**接受消息**
```java
@RabbitListener(queues = "work.queue")
public void listenWorkQueue1(String msg) throws InterruptedException {
System.out.println("消费者1接收到消息:【" + msg + "】" + LocalTime.now());
Thread.sleep(20);
}
@RabbitListener(queues = "work.queue")
public void listenWorkQueue2(String msg) throws InterruptedException {
System.err.println("消费者2........接收到消息:【" + msg + "】" + LocalTime.now());
Thread.sleep(200);
}
```
多个消费者在实际开发中是多实例,不是多方法。
#### 交换机
交换机的作用主要是**接收**发送者发送的消息,并将消息**路由**到与其绑定的队列。
常见交换机的类型有以下三种:
- Fanout:广播
- Direct:定向
- Topic:话题
**广播**
Fanout Exchange会将收到的消息路由到每一个跟其绑定的queue,也叫广播模式。
新建交换机,类型选择fanout:

绑定队列

发送消息
```java
String exchangeName = "hmall.fanout";
rabbitTemplate.convertAndSend(exchangeName, "", "hello fanout");
```
接受消息
```java
@Slf4j
@Component
public class SpringRabbitListener {
@RabbitListener(queues = "fanout.queue1")
public void listenFanoutQueue1(String msg) {
log.info("消费者1接收到消息:{}", msg);
}
@RabbitListener(queues = "fanout.queue2")
public void listenFanoutQueue2(String msg) {
log.info("消费者2接收到消息:{}", msg);
}
}
```
一条消息会被多个消费者处理。
**定向**
Direct Exchange会将接受到的消息根据规则路由到指定的Queue,因此也叫做**定向**路由。
- 每个Queue都和Exchange设置一个BindingKey
- 发布者发送消息,指定消息的RoutingKey
- Exchange会将消息路由到BindingKey和RoutingKey一致的队列
**创建交换机,类型选择direct**
**绑定队列**

这里要选择RoutingKey。
**接收者**
```java
@Slf4j
@Component
public class SpringRabbitListener {
@RabbitListener(queues = "direct.queue1")
public void listenDirectQueue1(String msg) {
log.info("消费者1接收到消息:{}", msg);
}
@RabbitListener(queues = "direct.queue2")
public void listenDirectQueue2(String msg) {
log.info("消费者2接收到消息:{}", msg);
}
}
```
**发送者**
```java
String exchangeName = "hmall.direct";
String routingKey1 = "yellow";
String routingKey2 = "red";
rabbitTemplate.convertAndSend(exchangeName, routingKey1, "震惊一万年!");
rabbitTemplate.convertAndSend(exchangeName, routingKey2, "震惊一千年!");
```
**话题**
TopicExchange与DirectExchange类似,区别在于routingKey可以是多个单词的列表,并且以 **.** 分割。
Queue和Exchange指定BindingKey时可以使用通配符:
- #:代指0个或多个单词
- *:代指一个单词
**创建交换机,选择topic**
**绑定队列**

RoutingKey如上。
**接收者**
```java
@Slf4j
@Component
public class SpringRabbitListener {
@RabbitListener(queues = "topic.queue1")
public void listenTopicQueue1(String msg) {
log.info("消费者1接收到消息:{}", msg);
}
@RabbitListener(queues = "topic.queue2")
public void listenTopicQueue2(String msg) {
log.info("消费者2接收到消息:{}", msg);
}
}
```
**发送者**
```java
String exchangeName = "hmall.topic";
String routingKey = "china.news";
rabbitTemplate.convertAndSend(exchangeName, routingKey, "中国新闻!");
```
#### Java客户端声明队列和绑定交换机
SpringAMQP提供了几个类,用来声明队列、交换机及其绑定关系:
- Queue:用于声明队列,可以用工厂类QueueBuilder构建
- Exchange:声明交换机,用ExchangeBuilder构建
- Binding:声明队列和交换机的绑定关系,用工厂类BindingBuilder构建
**基于Bean的方式声明**
```java
@Configuration
public class FanoutConfig {
@Bean
public FanoutExchange fanoutExchange() {
return ExchangeBuilder.fanoutExchange("hmall.fanout").build();
}
@Bean
public Queue fanoutQueue1() {
return QueueBuilder.durable("fanout.queue2").build();
}
@Bean
public Queue fanoutQueue2() {
return QueueBuilder.durable("fanout.queue1").build();
}
@Bean
public Binding fanoutQueue1Binding(Queue fanoutQueue1, FanoutExchange fanoutExchange) {
return BindingBuilder.bind(fanoutQueue1).to(fanoutExchange);
}
@Bean
public Binding fanoutQueue2Binding(Queue fanoutQueue2, FanoutExchange fanoutExchange) {
return BindingBuilder.bind(fanoutQueue2).to(fanoutExchange);
}
}
```
**基于注解的方式声明**
```java
@Slf4j
@Component
public class SpringRabbitListener {
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "direct.queue1", durable = "true"),
exchange = @Exchange(name = "hmall.topic", type = ExchangeTypes.TOPIC, durable = "true"),
key = {"china.#", "#.news"}
))
public void listenTopicQueue1(String msg) {
log.info("消费者1接收到消息:{}", msg);
}
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "direct.queue2", durable = "true"),
exchange = @Exchange(name = "hmall.topic", type = ExchangeTypes.TOPIC, durable = "true"),
key = {"china.#"}
))
public void listenTopicQueue2(String msg) {
log.info("消费者2接收到消息:{}", msg);
}
}
```
推荐使用注解的方式,更优雅、简洁,而且是在消费者类里面自动绑定,无需配置类。
#### 消息转换器
默认情况下Spring采用的序列化方式是JDK序列化。众所周知,JDK序列化存在下列问题:
- 数据体积过大
- 有安全漏洞
- 可读性差
我们希望消息体的体积更小、可读性更高,因此可以使用JSON方式来做序列化和反序列化。
在`publisher`和`consumer`两个服务中都引入依赖:
```XML
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
<version>2.9.10</version>
</dependency>
```
配置消息转换器,在`publisher`和`consumer`两个服务的启动类中添加一个Bean即可:
```java
@Bean
public MessageConverter messageConverter(){
// 1.定义消息转换器
Jackson2JsonMessageConverter jackson2JsonMessageConverter = new Jackson2JsonMessageConverter();
// 2.配置自动创建消息id,用于识别不同消息,也可以在业务中基于ID判断是否是重复消息
jackson2JsonMessageConverter.setCreateMessageIds(true);
return jackson2JsonMessageConverter;
}
```
### MQ高级
#### 发送者重连
消息从生产者到消费者的每一步都可能导致消息丢失:
- 发送消息时丢失:
- 生产者发送消息时连接MQ失败
- 生产者发送消息到达MQ后未找到`Exchange`
- 生产者发送消息到达MQ的`Exchange`后,未找到合适的`Queue`
- 消息到达MQ后,处理消息的进程发生异常
- MQ导致消息丢失:
- 消息到达MQ,保存到队列后,尚未消费就突然宕机
- 消费者处理消息时:
- 消息接收后尚未处理突然宕机
- 消息接收后处理过程中抛出异常
综上,我们要解决消息丢失问题,保证MQ的可靠性,就必须从3个方面入手:
- 确保生产者一定把消息发送到MQ
- 确保MQ不会将消息弄丢
- 确保消费者一定要处理消息
首先第一种情况,就是生产者发送消息时,出现了网络故障,导致与MQ的连接中断。
为了解决这个问题,SpringAMQP提供的消息发送时的重试机制。即:当`RabbitTemplate`与MQ连接超时后,多次重试。
修改`publisher`模块的`application.yaml`文件,添加下面的内容:
```YAML
spring:
rabbitmq:
connection-timeout: 1s # 设置MQ的连接超时时间
template:
retry:
enabled: true # 开启超时重试机制
initial-interval: 1000ms # 失败后的初始等待时间
multiplier: 1 # 失败后下次的等待时长倍数,下次等待时长 = initial-interval * multiplier
max-attempts: 3 # 最大重试次数
```
**注意**:当网络不稳定的时候,利用重试机制可以有效提高消息发送的成功率。不过SpringAMQP提供的重试机制是**阻塞式**的重试,也就是说多次重试等待的过程中,当前线程是被阻塞的。
如果对于业务性能有要求,建议禁用重试机制。如果一定要使用,请合理配置等待时长和重试次数,当然也可以考虑使用异步线程来执行发送消息的代码。
#### 发送者确认
SpringAMQP提供了Publisher Cinfirm和Publisher Return两种确认机制,开启确认机制后,当发送者发送消息给MQ后,MQ会返回确结果给发送者,返回的结果有以下情况:
- 消息投递到了MQ,但是路由失败,此时会通过PublisherReturn返回路由异常原因,然后返回ACK,告知投递成功
- 临时消息投递到了MQ,并且入队成功,返回ACK,告知投递成功
- 持久消息投递到了MQ,并且入队完成持久化,返回ACK,告知投递成功
- 其它情况都返回NACK,告知投递失败
通常情况下,收到NACK都要重发消息,ACK只需要记录日志。
**开启确认机制**
- 在publisher中添加配置
```yaml
spring:
rabbitmq:
publisher-confirm-type: correlated # 开启publisher confirm机制,并设置confirm类型
publisher-returns: true # 开启publisher return机制
```
- 给RabbitTemplate配置ReturnCallback
```java
@Slf4j
@AllArgsConstructor
@Configuration
public class MqConfig {
private final RabbitTemplate rabbitTemplate;
@PostConstruct
public void init(){
rabbitTemplate.setReturnsCallback(new RabbitTemplate.ReturnsCallback() {
@Override
public void returnedMessage(ReturnedMessage returned) {
log.error("触发return callback,");
log.debug("exchange: {}", returned.getExchange());
log.debug("routingKey: {}", returned.getRoutingKey());
log.debug("message: {}", returned.getMessage());
log.debug("replyCode: {}", returned.getReplyCode());
log.debug("replyText: {}", returned.getReplyText());
}
});
}
}
```
- 发送消息
```java
@Test
void testPublisherConfirm() {
// 1.创建CorrelationData
CorrelationData cd = new CorrelationData();
// 2.给Future添加ConfirmCallback
cd.getFuture().addCallback(new ListenableFutureCallback<CorrelationData.Confirm>() {
@Override
public void onFailure(Throwable ex) {
// 2.1.Future发生异常时的处理逻辑,基本不会触发
log.error("send message fail", ex);
}
@Override
public void onSuccess(CorrelationData.Confirm result) {
// 2.2.Future接收到回执的处理逻辑,参数中的result就是回执内容
if(result.isAck()){ // result.isAck(),boolean类型,true代表ack回执,false 代表 nack回执
log.debug("发送消息成功,收到 ack!");
}else{ // result.getReason(),String类型,返回nack时的异常描述
log.error("发送消息失败,收到 nack, reason : {}", result.getReason());
}
}
});
// 3.发送消息
rabbitTemplate.convertAndSend("hmall.direct", "q", "hello", cd);
}
```
通常不建议开启发送者确认机制,很消耗性能。
#### Lazy Queue
Lazy Queue也就是**惰性队列**,特征如下:
- 收到消息后直接存入磁盘,不再存储到内存
- 消费者要消费消息时才会从磁盘读取并加载到内存
3.12版本之后的所有的队列默认都是Lazy Queue模式,无法更改。
3.12以前的版本可以在Java代码中指定
```java
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "direct.queue1", durable = "true"),
exchange = @Exchange(name = "hmall.topic", type = ExchangeTypes.TOPIC, durable = "true"),
arguments = @Argument(name = "x-queue-mode", value = "lazy"),
key = {"china.#", "#.news"}
))
```
**开启持久化和发送者确认时候,只有在消息持久化完成之后才会给生产者返回ACK,提高消息可靠性。**
#### 消费者确认
为了确认消费者是否成功处理消息,RabbitMQ提供了消费者确认机制(**Consumer Acknowledgement**)。即:当消费者处理消息结束后,应该向RabbitMQ发送一个回执,告知RabbitMQ自己消息处理状态。回执有三种可选值:
- ack:成功处理消息,RabbitMQ从队列中删除该消息
- nack:消息处理失败,RabbitMQ需要再次投递消息
- reject:消息处理失败并拒绝该消息,RabbitMQ从队列中删除该消息
由于消息回执的处理代码比较统一,因此SpringAMQP帮我们实现了消息确认。并允许我们通过配置文件设置ACK处理方式,有三种模式:
- **`none`**:不处理。即消息投递给消费者后立刻ack,消息会立刻从MQ删除。非常不安全,不建议使用
- **`manual`**:手动模式。需要自己在业务代码中调用api,发送`ack`或`reject`,存在业务入侵,但更灵活
- **`auto`**:自动模式。SpringAMQP利用AOP对我们的消息处理逻辑做了环绕增强,当业务正常执行时则自动返回`ack`. 当业务出现异常时,根据异常判断返回不同结果:
- 如果是**业务异常**,会自动返回`nack`;
- 如果是**消息处理或校验异常**,自动返回`reject`;
通过下面的配置可以修改SpringAMQP的ACK处理方式:
```YAML
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: auto # 不做处理
```
#### 失败重试机制
当消费者出现异常后,消息会不断requeue(重入队)到队列,再重新发送给消费者。如果消费者再次执行依然出错,消息会再次requeue到队列,再次投递,直到消息处理成功为止。
极端情况就是消费者一直无法执行成功,那么消息requeue就会无限循环,导致mq的消息处理飙升,带来不必要的压力。
为了应对上述情况Spring又提供了消费者失败重试机制:在消费者出现异常时利用本地重试,而不是无限制的requeue到mq队列。
修改consumer服务的application.yml文件,添加内容:
```YAML
spring:
rabbitmq:
listener:
simple:
retry:
enabled: true # 开启消费者失败重试
initial-interval: 1000ms # 初识的失败等待时长为1秒
multiplier: 1 # 失败的等待时长倍数,下次等待时长 = multiplier * last-interval
max-attempts: 3 # 最大重试次数
stateless: true # true无状态;false有状态。如果业务中包含事务,这里改为false
```
重启consumer服务,重复之前的测试。可以发现:
- 消费者在失败后消息没有重新回到MQ无限重新投递,而是在本地重试了3次
- 本地重试3次以后,抛出了`AmqpRejectAndDontRequeueException`异常。查看RabbitMQ控制台,发现消息被删除了,说明最后SpringAMQP返回的是`reject`
结论:
- 开启本地重试时,消息处理过程中抛出异常,不会requeue到队列,而是在消费者本地重试
- 重试达到最大次数后,Spring会返回reject,消息会被丢弃
#### 失败处理策略
在之前的测试中,本地测试达到最大重试次数后,消息会被丢弃。这在某些对于消息可靠性要求较高的业务场景下,显然不太合适了。
因此Spring允许我们自定义重试次数耗尽后的消息处理策略,这个策略是由`MessageRecovery`接口来定义的,它有3个不同实现:
- `RejectAndDontRequeueRecoverer`:重试耗尽后,直接`reject`,丢弃消息。默认就是这种方式
- `ImmediateRequeueMessageRecoverer`:重试耗尽后,返回`nack`,消息重新入队
- `RepublishMessageRecoverer`:重试耗尽后,将失败消息投递到指定的交换机
比较优雅的一种处理方案是`RepublishMessageRecoverer`,失败后将消息投递到一个指定的,专门存放异常消息的队列,后续由人工集中处理。
Java实现
```java
@Configuration
@ConditionalOnProperty(name = "spring.rabbitmq.listener.simple.retry.enabled", havingValue = "true")
public class ErrorMessageConfig {
@Bean
public DirectExchange errorMessageExchange(){
return new DirectExchange("error.direct");
}
@Bean
public Queue errorQueue(){
return new Queue("error.queue", true);
}
@Bean
public Binding errorBinding(Queue errorQueue, DirectExchange errorMessageExchange){
return BindingBuilder.bind(errorQueue).to(errorMessageExchange).with("error");
}
@Bean
public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate){
return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error");
}
}
```
#### 业务幂等性
何为幂等性?
**幂等**是一个数学概念,用函数表达式来描述是这样的:`f(x) = f(f(x))`,例如求绝对值函数。
在程序开发中,则是指同一个业务,执行一次或多次对业务状态的影响是一致的。例如:
- 根据id删除数据
- 查询数据
- 新增数据
但数据的更新往往不是幂等的,如果重复执行可能造成不一样的后果。比如:
- 取消订单,恢复库存的业务。如果多次恢复就会出现库存重复增加的情况
- 退款业务。重复退款对商家而言会有经济损失。
所以,我们要尽可能避免业务被重复执行。
然而在实际业务场景中,由于意外经常会出现业务被重复执行的情况,例如:
- 页面卡顿时频繁刷新导致表单重复提交
- 服务间调用的重试
- MQ消息的重复投递
我们在用户支付成功后会发送MQ消息到交易服务,修改订单状态为已支付,就可能出现消息重复投递的情况。如果消费者不做判断,很有可能导致消息被消费多次,出现业务故障。
举例:
1. 假如用户刚刚支付完成,并且投递消息到交易服务,交易服务更改订单为**已支付**状态。
2. 由于某种原因,例如网络故障导致生产者没有得到确认,隔了一段时间后**重新投递**给交易服务。
3. 但是,在新投递的消息被消费之前,用户选择了退款,将订单状态改为了**已退款**状态。
4. 退款完成后,新投递的消息才被消费,那么订单状态会被再次改为**已支付**。业务异常。
因此,我们必须想办法保证消息处理的幂等性。这里给出两种方案:
- 唯一消息ID
- 业务状态判断
**唯一消息ID**
这个思路非常简单:
1. 每一条消息都生成一个唯一的id,与消息一起投递给消费者。
2. 消费者接收到消息后处理自己的业务,业务处理成功后将消息ID保存到数据库
3. 如果下次又收到相同消息,去数据库查询判断是否存在,存在则为重复消息放弃处理。
我们该如何给消息添加唯一ID呢?
其实很简单,SpringAMQP的MessageConverter自带了MessageID的功能,我们只要开启这个功能即可。
以Jackson的消息转换器为例:
```Java
@Bean
public MessageConverter messageConverter(){
// 1.定义消息转换器
Jackson2JsonMessageConverter jjmc = new Jackson2JsonMessageConverter();
// 2.配置自动创建消息id,用于识别不同消息,也可以在业务中基于ID判断是否是重复消息
jjmc.setCreateMessageIds(true);
return jjmc;
}
```
**业务判断**
业务判断就是基于业务本身的逻辑或状态来判断是否是重复的请求或消息,不同的业务场景判断的思路也不一样。
例如我们当前案例中,处理消息的业务逻辑是把订单状态从未支付修改为已支付。因此我们就可以在执行业务时判断订单状态是否是未支付,如果不是则证明订单已经被处理过,无需重复处理。
相比较而言,消息ID的方案需要改造原有的数据库,所以我更推荐使用业务判断的方案。
#### 延迟消息
**延迟消息**:发送者发送消息时指定一个时间,消费者不会立即收到消息,而是在指定时间之后才收到消息。
**延迟任务**:设置在一定时间之后才执行的任务
**延迟消息插件**
RabbitMQ社区提供了一个延迟消息插件。
首先去官网下载插件 https://www.rabbitmq.com/community-plugins.html,上传到虚拟机,注意,版本要和mq一致。
docker安装:
```shell
docker cp rabbitmq_delayed_message_exchange-3.8.17.8f537ac.ez mq:/plugins
docker exec -it mq rabbitmq-plugins enable rabbitmq_delayed_message_exchange
```
**声明延迟消息的交换机**
```Java
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "delay.queue", durable = "true"),
exchange = @Exchange(name = "delay.direct", delayed = "true"),
key = "delay"
))
public void listenDelayMessage(String msg){
log.info("接收到delay.queue的延迟消息:{}", msg);
}
```
发送消息时,必须通过x-delay属性设定延迟时间:
```Java
@Test
void testPublisherDelayMessage() {
// 1.创建消息
String message = "hello, delayed message";
// 2.发送消息,利用消息后置处理器添加消息头
rabbitTemplate.convertAndSend("delay.direct", "delay", message, new MessagePostProcessor() {
@Override
public Message postProcessMessage(Message message) throws AmqpException {
// 添加延迟消息属性
message.getMessageProperties().setDelay(5000);
return message;
}
});
}
```
## Elasticsearch
官网:[Elasticsearch:官方分布式搜索和分析引擎 | Elastic](https://www.elastic.co/cn/elasticsearch)
Elasticsearch是由elastic公司开发的一套搜索引擎技术,它是elastic技术栈中的一部分。完整的技术栈包括:
- Elasticsearch:用于数据存储、计算和搜索
- Logstash/Beats:用于数据收集
- Kibana:用于数据可视化
整套技术栈被称为ELK,经常用来做日志收集、系统监控和状态分析等等。
整套技术栈的核心就是用来**存储**、**搜索**、**计算**的Elasticsearch,因此我们接下来学习的核心也是Elasticsearch。
我们要安装的内容包含2部分:
- elasticsearch:存储、搜索和运算
- kibana:图形化展示
首先Elasticsearch不用多说,是提供核心的数据存储、搜索、分析功能的。
然后是Kibana,Elasticsearch对外提供的是Restful风格的API,任何操作都可以通过发送http请求来完成。不过http请求的方式、路径、还有请求参数的格式都有严格的规范。这些规范我们肯定记不住,因此我们要借助于Kibana这个服务。
Kibana是elastic公司提供的用于操作Elasticsearch的可视化控制台。它的功能非常强大,包括:
- 对Elasticsearch数据的搜索、展示
- 对Elasticsearch数据的统计、聚合,并形成图形化报表、图形
- 对Elasticsearch的集群状态监控
- 它还提供了一个开发控制台(DevTools),在其中对Elasticsearch的Restful的API接口提供了**语法提示**
### 安装elasticsearch
通过下面的Docker命令即可安装单机版本的elasticsearch:
```shell
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 \
--privileged \
--network hm-net \
-p 9200:9200 \
-p 9300:9300 \
elasticsearch:7.12.1
```
安装kibana
```shell
docker run -d \
--name kibana \
-e ELASTICSEARCH_HOSTS=http://es:9200 \
--network=hm-net \
-p 5601:5601 \
kibana:7.12.1
```
### 倒排索引
传统数据库采用正向索引,可以根据id这样的索引查询,效率非常快。但是es采用的是倒排索引:
- 文档:每条数据就是一个文档
- 词条:文档按照语义分成的词语
**创建倒排索引**是对正向索引的一种特殊处理和应用,流程如下:
- 将每一个文档的数据利用**分词算法**根据语义拆分,得到一个个词条
- 创建表,每行数据包括词条、词条所在文档id、位置等信息
- 因为词条唯一性,可以给词条创建**正向**索引
### IK分词器
Elasticsearch的关键就是倒排索引,而倒排索引依赖于对文档内容的分词,而分词则需要高效、精准的分词算法,IK分词器就是这样一个中文分词算法。
**安装**:
```shell
docker exec -it es ./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.12.1/elasticsearch-analysis-ik-7.12.1.zip
```
```shell
docker restart es
```
**测试**
在kinbana输入
```json
POST /_analyze
{
"analyzer": "ik_max_word",
"text": "黑马程序员学习java太棒了"
}
```
结果:
```json
{
"tokens" : [
{
"token" : "黑马",
"start_offset" : 0,
"end_offset" : 2,
"type" : "CN_WORD",
"position" : 0
},
{
"token" : "程序员",
"start_offset" : 2,
"end_offset" : 5,
"type" : "CN_WORD",
"position" : 1
},
{
"token" : "学习",
"start_offset" : 5,
"end_offset" : 7,
"type" : "CN_WORD",
"position" : 2
},
{
"token" : "java",
"start_offset" : 7,
"end_offset" : 11,
"type" : "ENGLISH",
"position" : 3
},
{
"token" : "太棒了",
"start_offset" : 11,
"end_offset" : 14,
"type" : "CN_WORD",
"position" : 4
}
]
}
```
#### 拓展词典
随着互联网的发展,“造词运动”也越发的频繁。出现了很多新的词语,在原有的词汇列表中并不存在。比如:“泰裤辣”,“传智播客” 等。
IK分词器无法对这些词汇分词,因此需要扩展词典。
- 打开IK分词器config目录
- 在IKAnalyzer.cfg.xml配置文件内容添加:
```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>
</properties>
```
- 在IK分词器的config目录新建一个 `ext.dic`,可以参考config目录下复制一个配置文件进行修改
```Plain
传智播客
泰裤辣
```
- 重启elasticsearch
```Shell
docker restart es
# 查看 日志
docker logs -f elasticsearch
```
### 基础概念
| **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 |
### 索引库操作
#### Mapping映射属性
mapping是对索引库中文档的约束,常见的mapping属性包括:
- type:字段数据类型,常见的简单类型有:
- 字符串:text(可分词文本)、keyword(精确值,例如:品牌、国家、ip地址)
- 数值:long、integer、short、byte、double、float
- 布尔:boolean
- 对象:object
- 日期:date
- index:是否创建索引,默认为true
- analyzer:使用哪种分词器,只有text需要分词器
- properties:该字段的子字段
#### 索引库操作
**创建索引库和mapping的请求语法如下**:
**基本语法**:
- 请求方式:`PUT`
- 请求路径:`/索引库名`,可以自定义
- 请求参数:`mapping`映射
**格式**:
```JSON
PUT /索引库名称
{
"mappings": {
"properties": {
"字段名":{
"type": "text",
"analyzer": "ik_smart"
},
"字段名2":{
"type": "keyword",
"index": "false"
},
"字段名3":{
"properties": {
"子字段": {
"type": "keyword"
}
}
},
// ...略
}
}
}
```
比如:
```json
PUT /heima
{
"mappings": {
"properties": {
"info":{
"type": "text",
"analyzer": "ik_smart"
},
"email":{
"type": "keyword",
"index": "false"
},
"name":{
"properties": {
"firstName": {
"type": "keyword"
}
}
}
}
}
}
```
**查询**
**基本语法**:
- 请求方式:GET
- 请求路径:/索引库名
- 请求参数:无
**格式**:
```Plain
GET /索引库名
```
**示例**:
```Plain
GET /heima
```
**修改索引库**
倒排索引结构虽然不复杂,但是一旦数据结构改变(比如改变了分词器),就需要重新创建倒排索引,这简直是灾难。因此索引库**一旦创建,无法修改mapping**。
虽然无法修改mapping中已有的字段,但是却允许添加新的字段到mapping中,因为不会对倒排索引产生影响。因此修改索引库能做的就是向索引库中添加新字段,或者更新索引库的基础属性。
**语法说明**:
```JSON
PUT /索引库名/_mapping
{
"properties": {
"新字段名":{
"type": "integer"
}
}
}
```
**示例**:
```JSON
PUT /heima/_mapping
{
"properties": {
"age":{
"type": "integer"
}
}
}
```
### 文档操作
- 新增文档
**语法:**
```JSON
POST /索引库名/_doc/文档id
{
"字段1": "值1",
"字段2": "值2",
"字段3": {
"子属性1": "值3",
"子属性2": "值4"
},
}
```
**示例:**
```JSON
POST /heima/_doc/1
{
"info": "黑马程序员Java讲师",
"email": "zy@itcast.cn",
"name": {
"firstName": "云",
"lastName": "赵"
}
}
```
- 查询文档
根据rest风格,新增是post,查询应该是get,不过查询一般都需要条件,这里我们把文档id带上。
**语法:**
```JSON
GET /{索引库名称}/_doc/{id}
```
**示例:**
```JavaScript
GET /heima/_doc/1
```
- 删除文档
删除使用DELETE请求,同样,需要根据id进行删除:
**语法:**
```JavaScript
DELETE /{索引库名}/_doc/id值
```
**示例:**
```JSON
DELETE /heima/_doc/1
```
- 修改文档
修改有两种方式:
- 全量修改:直接覆盖原来的文档
- 局部修改:修改文档中的部分字段
全量修改是覆盖原来的文档,其本质是两步操作:
- 根据指定的id删除文档
- 新增一个相同id的文档
**注意**:如果根据id删除时,id不存在,第二步的新增也会执行,也就从修改变成了新增操作了。
**语法:**
```JSON
PUT /{索引库名}/_doc/文档id
{
"字段1": "值1",
"字段2": "值2",
// ... 略
}
```
**示例:**
```JSON
PUT /heima/_doc/1
{
"info": "黑马程序员高级Java讲师",
"email": "zy@itcast.cn",
"name": {
"firstName": "云",
"lastName": "赵"
}
}
```
局部修改是只修改指定id匹配的文档中的部分字段。
**语法:**
```JSON
POST /{索引库名}/_update/文档id
{
"doc": {
"字段名": "新的值",
}
}
```
**示例:**
```JSON
POST /heima/_update/1
{
"doc": {
"email": "ZhaoYun@itcast.cn"
}
}
```
### RestAPI
在elasticsearch提供的API中,与elasticsearch一切交互都封装在一个名为`RestHighLevelClient`的类中,必须先完成这个对象的初始化,建立与elasticsearch的连接。
- 引入依赖
```xml
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>
```
- 指定版本
```xml
<properties>
<elasticsearch.version>7.12.1</elasticsearch.version>
</properties>
```
- 初始化RestHighLevelClient
```java
RestHighLevelClient client = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://192.168.150.101:9200")
));
```
#### 创建索引
```java
@SpringBootTest
public class EsTest {
private RestHighLevelClient client;
static final String MAPPING_TEMPLATE = "{\n" +
" \"mappings\": {\n" +
" \"properties\": {\n" +
" \"id\": {\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"name\":{\n" +
" \"type\": \"text\",\n" +
" \"analyzer\": \"ik_max_word\"\n" +
" },\n" +
" \"price\":{\n" +
" \"type\": \"integer\"\n" +
" },\n" +
" \"stock\":{\n" +
" \"type\": \"integer\"\n" +
" },\n" +
" \"image\":{\n" +
" \"type\": \"keyword\",\n" +
" \"index\": false\n" +
" },\n" +
" \"category\":{\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"brand\":{\n" +
" \"type\": \"keyword\"\n" +
" },\n" +
" \"sold\":{\n" +
" \"type\": \"integer\"\n" +
" },\n" +
" \"commentCount\":{\n" +
" \"type\": \"integer\"\n" +
" },\n" +
" \"isAD\":{\n" +
" \"type\": \"boolean\"\n" +
" },\n" +
" \"updateTime\":{\n" +
" \"type\": \"date\"\n" +
" }\n" +
" }\n" +
" }\n" +
"}";
@Test
public void test() throws IOException {
CreateIndexRequest request = new CreateIndexRequest("items");
request.source(MAPPING_TEMPLATE, XContentType.JSON);
client.indices().create(request, RequestOptions.DEFAULT);
}
@BeforeEach
void setUp() {
client = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://localhost:9200")
));
}
@AfterEach
void tearDown() throws Exception {
if (client != null) {
client.close();
}
}
}
```
#### 获取索引
```java
@SpringBootTest
public class EsTest {
private RestHighLevelClient client;
@Test
public void test() throws IOException {
GetIndexRequest request = new GetIndexRequest("items");
boolean exists = client.indices().exists(request, RequestOptions.DEFAULT);
System.out.println(exists);
}
@BeforeEach
void setUp() {
client = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://localhost:9200")
));
}
@AfterEach
void tearDown() throws Exception {
if (client != null) {
client.close();
}
}
}
```
#### 删除索引
```java
@SpringBootTest
public class EsTest {
private RestHighLevelClient client;
@Test
public void test() throws IOException {
DeleteIndexRequest request = new DeleteIndexRequest("items");
client.indices().delete(request, RequestOptions.DEFAULT);
}
@BeforeEach
void setUp() {
client = new RestHighLevelClient(RestClient.builder(
HttpHost.create("http://localhost:9200")
));
}
@AfterEach
void tearDown() throws Exception {
if (client != null) {
client.close();
}
}
}
```
#### 新增文档
```java
@Test
public void test() throws IOException {
// 1.根据id查询商品数据
Item item = itemService.getById(100002644680L);
// 2.转换为文档类型
ItemDoc itemDoc = BeanUtil.copyProperties(item, ItemDoc.class);
// 3.将ItemDTO转json
String doc = JSONUtil.toJsonStr(itemDoc);
// 1.准备Request对象
IndexRequest request = new IndexRequest("items").id(itemDoc.getId());
// 2.准备Json文档
request.source(doc, XContentType.JSON);
// 3.发送请求
client.index(request, RequestOptions.DEFAULT);
}
```
#### 查询文档
```java
@Test
public void test() throws IOException {
GetRequest request = new GetRequest("items", "100002644680");
GetResponse response = client.get(request, RequestOptions.DEFAULT);
String json = response.getSourceAsString();
ItemDoc itemDoc = JSONUtil.toBean(json, ItemDoc.class);
System.out.println("doc = " + itemDoc);
}
```
#### 删除文档
```java
@Test
void testDeleteDocument() throws IOException {
// 1.准备Request,两个参数,第一个是索引库名,第二个是文档id
DeleteRequest request = new DeleteRequest("item", "100002644680");
// 2.发送请求
client.delete(request, RequestOptions.DEFAULT);
}
```
#### 修改文档
- 全量修改:本质是先根据id删除,再新增
- 局部修改:修改文档中的指定字段值
```java
@Test
void testUpdateDocument() throws IOException {
// 1.准备Request
UpdateRequest request = new UpdateRequest("items", "100002644680");
// 2.准备请求参数
request.doc(
"price", 58800,
"commentCount", 1
);
// 3.发送请求
client.update(request, RequestOptions.DEFAULT);
}
```
### DSL查询
Elasticsearch的查询可以分为两大类:
- **叶子查询(Leaf** **query** **clauses)**:一般是在特定的字段里查询特定值,属于简单查询,很少单独使用。
- **复合查询(Compound** **query** **clauses)**:以逻辑方式组合多个叶子查询或者更改叶子查询的行为方式。
#### Java RestClient
实例:
```java
@Test
public void test() throws IOException {
SearchRequest request = new SearchRequest("items");
request.source()
.query(QueryBuilders.matchAllQuery());
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
for (var item : response.getHits().getHits()) {
String json = item.getSourceAsString();
ItemDoc itemDoc = JSONUtil.toBean(json, ItemDoc.class);
System.out.println("itemDoc = " + itemDoc);
}
}
```
```java
@Test
public void test() throws IOException {
SearchRequest request = new SearchRequest("items");
request.source()
.query(QueryBuilders.boolQuery()
.must(QueryBuilders.matchQuery("name", "脱脂牛奶"))
.filter(QueryBuilders.termQuery("brand", "德亚"))
.filter(QueryBuilders.rangeQuery("price").lt(10000)));
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
for (var item : response.getHits()) {
System.out.println(item.getSourceAsMap());
}
}
```
**分页**
```java
@Test
public void testSort() {
int pageNum = 1, pageSize = 5;
SearchRequest request = new SearchRequest("items");
request.source().query(QueryBuilders.matchAllQuery());
request.source().from((pageNum - 1) * pageSize).size(pageSize);
request.source().sort("sold", SortOrder.DESC);
try {
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
for (var item : response.getHits()) {
System.out.println(item.getSourceAsMap());
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
```
### 数据聚合
聚合(`aggregations`)可以让我们极其方便的实现对数据的统计、分析、运算。例如:
- 什么品牌的手机最受欢迎?
- 这些手机的平均价格、最高价格、最低价格?
- 这些手机每月的销售情况如何?
实现这些统计功能的比数据库的sql要方便的多,而且查询速度非常快,可以实现近实时搜索效果。
**Bucket聚合**
例如我们要统计所有商品中共有哪些商品分类,其实就是以分类(category)字段对数据分组。category值一样的放在同一组,属于`Bucket`聚合中的`Term`聚合。
基本语法如下:
```JSON
GET /items/_search
{
"size": 0,
"aggs": {
"category_agg": {
"terms": {
"field": "category",
"size": 20
}
}
}
}
```
语法说明:
- `size`:设置`size`为0,就是每页查0条,则结果中就不包含文档,只包含聚合
- `aggs`:定义聚合
- `category_agg`:聚合名称,自定义,但不能重复
- `terms`:聚合的类型,按分类聚合,所以用`term`
- `field`:参与聚合的字段名称
- `size`:希望返回的聚合结果的最大数量
**Metric聚合**
对`brand_agg`形成的每个桶中的文档分别统计。
- `stats_meric`:聚合名称
- `stats`:聚合类型,stats是`metric`聚合的一种
- `field`:聚合字段,这里选择`price`,统计价格