开发框架
约 45916 字大约 153 分钟
2025-11-16
1.Spring Boot 全面概述
Spring Boot 是一个构建在 Spring 框架顶部的项目(也就是一个模块)。它提供了一种简便,快捷的方式来设置,配置和运行基于 Web 的简单应用程序。是简化版的 Spring,旨在快速开发中小型的、快速交付、微服务架构的项目,要比 Spring 来得轻量。
它是一个 Spring 模块,提供了 RAD(快速应用程序开发) 功能。它用于创建独立的基于 Spring 的应用程序,因为它需要最少的 Spring 配置,因此可以运行。
基本上 Spring Boot = Spring Framework + Embedded HTTP Server - XML
可以使用 Spring STS IDE, Spring Initializr, IDEA Ultimate 构建一个 Spring Boot 项目。
2.Spring Boot 基本功能
Web 开发 这是用于
Web应用程序开发的非常适合的子模块。我们可以轻松创建一个独立的HTTP应用程序,该应用程序使用Tomca, Jetty, Undertow等嵌入式服务器。我们可以使用spring-boot-starter-web模块快速启动和运行应用程序。SpringApplication 类 提供了一种方便的方式来引导
Spring应用程序,可以从main方法开始,我们可以仅通过调用静态run()方法来调用应用程序。// Main.java public static void main(String[] args) { SpringApplication.run(ClassName.class, args); }应用程序事件和侦听器
Spring Boot使用事件来处理各种任务。它允许我们创建用于添加侦听器的工厂文件。我们可以使用ApplicationListener 键来引用它。总是在META-INF文件夹中创建工厂文件,例如META-INF/spring.factories。应用管理
Spring Boot提供了为应用程序启用与管理员相关的功能的功能。它用于远程访问和管理应用程序。我们可以使用spring.application.admin.enabled属性在Spring Boot应用程序中启用它。外部配置
Spring Boot允许我们外部化我们的配置,以便我们可以在不同环境中使用同一应用程序。该应用程序使用YAML文件来外部化配置。属性文件
Spring Boot提供了一组丰富的应用程序属性。因此,我们可以在项目的属性文件中使用它。该属性文件用于设置诸如server-port = 8082等属性,它有助于组织应用程序属性。YAML 支持 它提供了一种方便的方法来指定层次结构。它是
JSON的超集(任何JSON文档复制进去都可以通过使用)。SpringApplication类自动支持YAML,它是属性文件的代替方法。类型安全配置 强大的类型安全配置用于管理和验证应用程序的配置。应用程序配置始终是至关重要的任务,应该是类型安全的。我们还可以使用此库提供的注释。
日志
Spring Boot对所有内部记录都使用通用记录。默认情况下管理日志记录依赖项。如果不需要自定义,我们不应更改日志记录依赖项。安全性
Spring Boot应用程序是spring的Web应用程序。因此,默认情况下,通过所有HTTP端点上的基本身份验证,它是安全的。可以使用一组丰富的端点来开发安全的Spring Boot应用程序。
3.Spring Boot 使用教程
3.1.快速使用
注意
注意:不过我的习惯是先体验原生的生成器来配置,然后再来体验 IDE 这些便携工具,这种知晓一切的感觉很舒服。因此本教程对于没有下载 IDEA 的新手来说是可以无痛运行的(当然,您需要安装 Java),因此本教程使用在 Linux 环境下使用,请您最好在 Linux 操作系统中运行。
3.1.1.构建项目
下面我们将依据 官方快速入门文档 尝试构建一个 Spring Boot 项目。

- 我们将打开浏览器访问 spring initializr 来快速生成一个
Spring Boot项目开发脚手架 - 项目构建工具选择
Maven - 项目语言选择
Java,这也是主要的工业开发语言 - 选择官方推荐的
Spring Boot 3.4.0版本 - 创建
web项目,在Dependencies(依赖项)对话框中,搜索并添加Spring Web依赖项 - 填写元数据 项目公司域名组、项目包名、项目名、项目描述、项目包名
- 选择项目包类型
- 选择语言版本,这里我选择
Java 17,请注意在您的Linux里使用sudo apt install -y openjdk-17-jdk下载Java 17,避免版本不匹配 - 点击 “生成” 按钮,下载
zip文件,然后将其解压缩到计算机上的文件夹中 - 此时浏览器可以从远端下载到您配置好的
Spring Boot项目脚手架代码
重要
补充:如果您需要更换 Spring Boot 版本,则可以直接在项目生成后的 pom 中修改,不过其实您也可以考虑使用别的初始化模板网站,只是官方的初始化模板网站不再支持这种行为而已。例如访问 https://start.aliyun.com/ 即可得到其他更多的 Spring Boot 版本。
3.1.2.目录结构
下面就是一个 Spring Boot 生成的目录结构,相关注释我都写在里面了。
# 查看项目目录结构
$ tree demo
demo
├── HELP.md # 项目相关的帮助文档或使用指南
├── mvnw # 在 Linux 或 macOS 上执行的脚本(如果开发环境中没有安装 Maven, mvnw 可以自动下载并使用指定版本的 Maven)
├── mvnw.cmd # 在 Windows 上执行的脚本
├── pom.xml
└── src # 项目源代码
├── main # 主要代码
│ ├── java
│ │ └── com
│ │ └── example
│ │ └── demo
│ │ └── DemoApplication.java # 程序入口文件
│ └── resources
│ ├── application.properties
│ ├── static
│ └── templates
└── test # 测试代码
└── java
└── com
└── example
└── demo
└── DemoApplicationTests.java # 测试程序入口文件
15 directories, 7 files重要
补充:Maven 有什么用呢?主要是 Java 项目的构建工具,类似 C++ 中的 CMake 工具,但是使用起来会稍微简单一些,可以简化和自动化软件的构建、测试、部署、依赖流程。
3.1.3.代码实践
我们将依据 官方快速入门文档 在 Spring Boot 项目运行打印出 Hello World。
在 src/main/java/com/example/demo 文件夹中找到 DemoApplication.java 文件,初始内容如下,我给出了部分注释。
// DemoApplication.java
package com.example.demo; // 反域名包名, 声明当前类所在的包
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/* @SpringBootApplication 一个复合注解 */
@SpringBootApplication
public class DemoApplication { // 入口类
public static void main(String[] args) { // 入口方法
SpringApplication.run(DemoApplication.class, args); // 启动 Spring Boot
}
}接下来的编码很简单,我们只需要让 Spring Boot 项目中添加我们自己的代码,然后运行起来就可以了。
// DemoApplication.java
package com.example.demo;
// 这里的 import 引入了关于 Spring Boot 和 Spring Web 必要的类和注解
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
@RestController // 控制器注解, 表示该类是一个 RESTful 控制器, 返回的是对象本身, 并自动将其序列化成 JSON 格式返回给客户端, 而不是返回视图
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
@GetMapping("/hello") // 这是一个 Spring Web 注解, 用于将 HTTP GET 请求映射到 hello() 上, 路径为 '/hello'
public String hello(@RequestParam(value = "name", defaultValue = "World") String name) {
return String.format("Hello %s!", name);
}
}总的来说:
- 使用
@GetMapping注释可确保将对/hello的HTTP GET请求映射到hello() - 使用
@RequestParam将查询字符串参数name的值绑定到hello()的name参数中,如果请求中不存在name参数,则使用defaultValue中定义的值 - 使用
@GetMapping可以映射HTTP GET方法到您自己的方法上 - 使用
@RestController并且默认存在序列库Jackson的情况下,Spring Boot会根据一个对象的成员自动序列化为一个JSON数据(不过有些是直接返回字符串,例如字符串对象),后面会介绍自定义序列化数据的操作
重要
补充:补充一些基础知识,如果您缺少这些知识,请一定要进行补全。
- RESTful 控制器:
REST(Representational State Transfer)是一种基于HTTP协议的通信方式,主要用于客户端和服务器之间的通信,通常用于提供 Web API。RESTful API遵循一系列的设计原则,通常会使用HTTP动作(如GET、POST、PUT、DELETE等)来进行数据的操作。控制器注解可以让类中经过映射的方法接收HTTP请求并返回响应。 - 返回对象本身,而非视图:传统的
Spring MVC控制器(使用@Controller注解)通常会返回一个视图名(例如JSP标签或Thymeleaf模板),Spring会将数据与视图模板结合并返回给客户端。不过这种开发方式已经有些过时,是不可能完全做到前后端分离开发的,和早期的C++ Web开发是差不多的,存在很多开发困难。但是,RESTful 控制器 的作用是提供一个 Web API,可以直接返回 数据(如JSON或XML),而不是视图。在@RestController中,返回的对象会被自动序列化为JSON格式(默认使用 Jackson 序列化库来将 Java 对象转换成 JSON 格式),直接作为HTTP响应返回给客户端。
该怎么在命令行中启动我们的 Spring Boot 项目就像运行一个普通 Java 程序一样呢?首先需要使用 mvnw 安装需要的依赖(我们得到的仅仅是带有脚本的脚手架和一些基本的文件,不包含 Spring Boot 的源代码),然后才能让我们的 Spring Boot 项目生成 Java 的字节码,进而
# 命令行原生启动 Spring Boot
# 进入项目
$ cd demo
$ pwd
/home/ljp/test/java/demo
# 安装依赖
$ ./mvnw clean install # 哪怕是有稳定的网络都需要安装很久, 请耐心等待...
# 运行项目
$ ./mvnw spring-boot:run
# 打包项目
# $ ./mvnw package # 会在 target/ 中打包好 jar 包
# 前台部署
# java -jar target/demo-0.0.1-SNAPSHOT.jar在本地机器的浏览器 URL 中直接访问 http://127.0.0.1:8080/hello(或者携带参数的 http://127.0.0.1:8080/hello?name=limou),就可以在浏览器页面中渲染出 Hello World!(Hello limou!),其中直接运行项目适合进行开发测试,打包适合直接部署到服务器上运行。
重要
补充:为什么返回的字符串后不会被渲染为 JSON 字符串?
Spring框架如果检测到返回值是一个字符串,会将它直接作为HTTP响应体(text/plain或text/html)。- 如果返回的内容是一个复杂对象,例如
Map或自定义类实例,Spring才会自动将其序列化为JSON格式。
重要
补充:当然仅仅是这样是不够的,为什么,因为这样仅仅是写好了一个后端接口而已,cpp-http 可以做到、python-flask、python-django 可以做到、node.js-koa.js 可以做到,为什么偏偏工业偏爱使用 java-spring boot 呢?因此您需要了解更多关于 Spring Boot 的配置内容。
3.2.搭建服务
根据常见的服务器开发,这里给出一份关于 Spring Boot 的总文档,旨在让您只看一遍就知道如何编写一个完整的 Web 服务器。
下面是您可能需要的资料:
这里将先根据 构建 RESTful Web 服务 和 使用 RESTful Web 服务 这两份官方文档、其他官方文档、我个人的见解来讲述如何完整编写 RESTful Web 规范的 Web 服务器。
3.2.1.编码目标
- 我们将实现一个
Web服务器 - 构建常见的
HTTP请求 - 需要自定义返回的
JSON格式
3.2.2.创建项目
这次我们不再使用 Spring Initializr 来创建 Spring Boot 项目了,而使用著名的 IDEA 编辑器来构建一个 Spring Boot 项目。


重要
补充:注意观察这里的 “服务器 url”,您可以修改这里的地址为我们之前说的网址 https://start.aliyun.com/ 以支持更多过往版本的 Spring Boot。


然后打开侧边的 Maven 执行和上一节一样的命令行操作,在生命周期中先点击 clean 再点击 install,最后打开启动文件,点击运行启动函数即可。

可以看到在终端中,Spring boot 成功被启动起来了。
3.2.3.简单使用
由于我们需要自定义返回的 JSON 字符串,因此需要使用一些 Resource Representation 来进行数据建模(就是下面定义的 Greeting)。
# 项目文件结构
$ tree my-restful-web
my-restful-web
├── HELP.md
├── mvnw
├── mvnw.cmd
├── pom.xml
├── src
│ ├── main
│ │ ├── java
│ │ │ └── com
│ │ │ └── limou
│ │ │ └── myrestfulweb
│ │ │ ├── Greeting.java # 我们将要编写的 "资源表示类"
│ │ │ ├── GreetingController.java # 我们将要编写的 "资源控制类"
│ │ │ └── MyRestfulWebApplication.class # 不需要改动的 "启动文件"
│ │ └── resources
│ │ ├── application.properties
│ │ ├── static
│ │ └── templates
...// Greeting.java: 资源表示
package com.limou.myrestfulweb;
public record Greeting(long id, String content) {
/**
* record 会自动为类的字段生成以下内容:
*
* 常用方法:
* 构造函数
* getter()
* toString()
* equals()
* hashCode()
*
* 不可变性: record 中的字段是 final 的, 不能更改(在创建后即为只读), 没有提供 setter()
*/
}// GreetingController.java: 资源控制
package com.limou.myrestfulweb;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
/**
* @RestController 表示这是一个资源控制类
* 其中每个方法都返回域对象而不是视图, 它是包括 @Controller 和 @ResponseBody 的简写
*/
public class GreetingController {
private static final String template = "Hello, %s!"; // 需要返回数据的模板
private final AtomicLong counter = new AtomicLong(); // 用于生成线程安全计数器的对象, 常用于对资源的访问进行计数或生成唯一标识符(ID), 具有一个 incrementAndGet() 可以对计数器进行递增
@GetMapping("/greeting")
/**
* @GetMapping 表示这是一个 Get 方法的映射接口
*
* @RequestMapping(method = RequestMethod.GET) <=> @GetMapping
* @RequestMapping(method = RequestMethod.POST) <=> @PostMapping
*
* 简而言之 @RequestMapping 可以用于处理任何 HTTP 方法的请求,而 @GetMapping、@PostMapping 等是专门为常见 HTTP 方法提供的简化版本
*/
public Greeting greeting(@RequestParam(value = "name", defaultValue = "World") String name) {
/**
* @RequestParam 表示做一个查询参数到函数参数的映射, 常用于 Get 方法中
* 'value' 是表单中的键值名
* '"name"' 是本函数的参数名
* 剩下的 'defaultValue' 就是当查询参数中没有传递时使用的默认值
*/
return new Greeting(counter.incrementAndGet(), String.format(template, name)); // 使用前面的资源表示类来进行数据返回(存放了一个不断递增的 ID 字段和一个 name 字段), 可以在浏览器上访问注意到: 这里返回的是一个对象, 严格来说只是一个引用, 但是在浏览器中自动被转化为 JSON 文档对象(自动序列化)
}
}// MyRestfulWebApplicationTests.java: 启动文件
// 注意无需进行任何改动!
package com.limou.myrestfulweb;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* @SpringBootApplication 是一个便捷的注释,它添加了以下所有内容:
* = @SpringBootConfiguration 标识这是一个 Spring Boot 应用程序的配置类
* + @EnableAutoConfiguration 启用 Spring Boot 的自动配置机制, 根据依赖和类路径内容自动配置应用程序, 避免手动配置大量的 Spring 配置类
* + @ComponentScan 启用组件扫描, 默认会扫描当前包及其子包中的所有类(如 @Controller, @Service, @Repository, @Component 等注解标记的类)
*/
@SpringBootApplication
public class MyRestfulWebApplication {
public static void main(String[] args) {
SpringApplication.run(MyRestfulWebApplication.class, args);
}
}重要
补充:Jackson JSON 库将 Greeting 类型的实例自动封送到 JSON 中。默认情况下,Jackson 由 Web 启动器包含。
注意
警告:在编码过程中请注意您的包名不一定和我一样...
还是像上一小节一样运行 Spring Boot 不过这次我们可以使用 IDEA 快速点击运行,然后使用浏览器访问 http://127.0.0.1:8080/greeting(或者使用类似 http://127.0.0.1:8080/greeting?=limou 的写法)。

3.2.4.高自定义
如果您对 HTTP 协议足够熟悉,您一定会像如何高自定义化网络报文的,因此下面给出了一个稍微现代化的写法供您参考,为节省一些说明,我把文件稍微精简了一些...
# 项目结构
$ tree my-restful-web
my-restful-web
├── HELP.md
├── mvnw
├── mvnw.cmd
├── pom.xml
├── src
│ ├── main
│ │ ├── html
│ │ │ └── test.html # 将要编写的 "前端页面"
│ │ ├── java
│ │ │ └── com
│ │ │ └── limou
│ │ │ └── myrestfulweb
│ │ │ ├── GreetingController.java # 将要编写的 "资源控制"
│ │ │ └── MyRestfulWebApplication.class # 不需要改动的 "启动文件"
│ │ └── resources
│ │ ├── application.properties # 将要编写的 "配置文件"
│ │ ├── static
│ │ └── templates
...// GreetingController.java: 资源控制
package com.limou.myrestfulweb;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import org.springframework.boot.autoconfigure.ssl.SslProperties;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
@RestController
/**
* @RestController 表示这是一个资源控制类
* 其中每个方法都返回域对象而不是视图, 它是包括 @Controller 和 @ResponseBody 的简写
*/
public class GreetingController {
// 1. 定义一些后面需要用的成员和方法
private static final String template = "Hello, %s!"; // 需要返回数据的模板
private final AtomicLong counter = new AtomicLong(); // 用于生成线程安全计数器的对象, 常用于对资源的访问进行计数或生成唯一标识符(ID), 具有一个 incrementAndGet() 可以对计数器进行递增
// @JsonIgnoreProperties(ignoreUnknown = true)
public record Name(String name) {} // 资源描述(请求)
public record Greeting(long id, String content, String message) {} // 资源描述(响应)
private static final Map<Integer, String> statusCodeToMessage = Map.of(
200, "OK: 成功",
404, "Not Found: 没有找到资源",
500, "Internal Server Error: 出现服务器问题"
); // 自定义错误码映射错误描述的函数
// 2. 演示如何 获取请求报文中的数据 和 设置响应报文中的数据(GET)
@GetMapping("/greeting_get")
/**
* @GetMapping 表示这是一个 Get 方法的映射接口
*
* @RequestMapping(method = RequestMethod.GET) <=> @GetMapping
* @RequestMapping(method = RequestMethod.POST) <=> @PostMapping
*
* 简而言之 @RequestMapping 可以用于处理任何 HTTP 方法的请求,而 @GetMapping、@PostMapping 等是专门为常见 HTTP 方法提供的简化版本
*/
public ResponseEntity<Greeting> greetingPlus(
@RequestParam(value = "name", defaultValue = "World") String name,
@RequestHeader("Cookie") String cookie,
@RequestBody(required = false) String requestBody) { // 如果传递的数据载荷中是 JSON 时这里其实会自动映射, 如果有未绑定的键可以使用 @JsonIgnoreProperties(ignoreUnknown = true) 来进行不严格匹配(后面的 POST 接口有演示)
// (1) 演示获取请求报文中的数据
/**
* @RequestParam 获取请求路径中的查询参数
* 常用于 Get 方法中
* 'value' 是表单中的键值名
* '"name"' 是本函数的参数名
* 剩下的 'defaultValue' 就是当查询参数中没有传递时使用的默认值
*/
/**
* @RequestHeader 获取请求头部中的键
* 这里获取的是 cookie
*/
/**
* @RequestBody 获取请求载荷中的键
* 不过一般 Get 方法很少有传递业务需要使用的键, 因此这里打印出来大概率是空, 毕竟浏览器没有传递任何的载荷
* 这里的 required = false 表示如果请求体为空, 则不会抛出异常
* 在 POST 方法中可以使用其他类型的对象来接受获取内部更加详细的数据
*/
System.out.println(name);
System.out.println(cookie);
System.out.println(requestBody);
// (2) 演示设置响应报文中的数据
// a. 准备报头数据
int customStatusCode = 404; // 错误码
HttpHeaders headers = new HttpHeaders();
headers.add("X-Custom-Message", "This is a custom message header");
// b. 准备载荷数据
String customStatusMessage = statusCodeToMessage.getOrDefault(customStatusCode, "Unknown status code"); // 错误描述
Greeting customResponse = new Greeting(counter.incrementAndGet(), String.format(template, name), customStatusMessage);
// (3) 使用函数调用链返回 ResponseEntity
return ResponseEntity
.status(customStatusCode) // 设置状态码
.headers(headers) // 设置响应头
.body(customResponse); // 设置响应体
}
// 3. 演示如何 获取请求报文中的数据 和 设置响应报文中的数据(POST)
@PostMapping("/greeting_post")
public ResponseEntity<Greeting> greeting_plus_post(
@RequestBody(required = false) Name nameObj) {
String name = "World";
if (nameObj!= null && nameObj.name!= null) {
name = nameObj.name;
System.out.println(nameObj);
}
// 准备报头数据
int customStatusCode = 200;
HttpHeaders headers = new HttpHeaders();
headers.add("X-Custom-Message", "This is a custom message header for POST request");
// 准备载荷数据
String customStatusMessage = statusCodeToMessage.getOrDefault(customStatusCode, "Unknown status code");
Greeting customResponse = new Greeting(counter.incrementAndGet(), String.format(template, name), customStatusMessage);
return ResponseEntity
.status(customStatusCode)
.headers(headers)
.body(customResponse);
}
// 4.可以看到 Spring boot 也有能力做到 Spring MVC 一样返回一个完整的页面视图(不过这种方式不常用, 我这里之所以写这个接口仅仅是为了让您不至于遇到跨域问题从而进行麻烦的配置)
@GetMapping("/html")
public String getHTML() throws IOException {
File file = new File("/home/ljp/test/java/my-restful-web/src/html/test.html");
byte[] encoded = Files.readAllBytes(Paths.get(file.getAbsolutePath()));
return new String(encoded, StandardCharsets.UTF_8);
}
}<!-- test.html: 前端页面 -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>POST Request Example</title>
</head>
<body>
<form id="myForm">
<label for="name">Name:</label>
<input type="text" id="name" name="name"><br/>
<label for="age">Age:</label>
<input type="text" id="age" name="age"><br/> <!-- 这个字段后端是不需要处理的, 但是我依旧强行传递过去了 -->
<input type="submit" value="Submit">
</form>
<script>
document.getElementById('myForm').addEventListener('submit', function (e) {
e.preventDefault();
var name = document.getElementById('name').value;
var age = document.getElementById('age').value;
var requestBody = {
"name": name,
"age": age // 这里表单虽然传递给了后端, 但是由于后端的 "/greeting_plus_post" 接口内的资源表示类使用了 "@JsonIgnoreProperties(ignoreUnknown = true)" 注解, 因此不会抛出异常(您可以去掉这个注解试一试)
};
fetch('http://127.0.0.1:8080/greeting_post', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
})
.then(response => response.json())
.then(data => {
console.log(data);
// 在这里可以根据返回的数据进行进一步的页面展示等操作,比如显示响应中的消息等
})
.catch(error => {
console.error('Error:', error);
});
});
</script>
</body>
</html># application.properties: 配置文件
spring.application.name=my-restful-web
spring.jackson.deserialization.fail-on-unknown-properties=true # 加上这一句就可以
# 注意这个文件不可以加入任何注释, 记得删除我在这里写的注释// MyRestfulWebApplicationTests.java
// 注意无需进行任何改动!
package com.limou.myrestfulweb;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* @SpringBootApplication 是一个便捷的注释,它添加了以下所有内容:
* = @SpringBootConfiguration 标识这是一个 Spring Boot 应用程序的配置类
* + @EnableAutoConfiguration 启用 Spring Boot 的自动配置机制, 根据依赖和类路径内容自动配置应用程序, 避免手动配置大量的 Spring 配置类
* + @ComponentScan 启用组件扫描, 默认会扫描当前包及其子包中的所有类(如 @Controller, @Service, @Repository, @Component 等注解标记的类)
*/
@SpringBootApplication
public class MyRestfulWebApplication {
public static void main(String[] args) {
SpringApplication.run(MyRestfulWebApplication.class, args);
}
}


重要
补充:如果不在 application.properties 配置文件中加入 spring.jackson.deserialization.fail-on-unknown-properties=true 就会默认不约束 JSON 报文数据。但是如果加上了,并且没有对资源描述做 // @JsonIgnoreProperties(ignoreUnknown = true) 忽略,在有字段缺失时,就会抛出异常。

重要
补充:@JsonIgnoreProperties 其实还有一些其他的用法,比如对某些特定字段进行忽略...
警告
警告:上述代码中有一个 /html 的接口,可以直接组合 HTML 返回一个前端页面,这是前后端不分离的做法,也就是类似 Spring MVC 的做法,Spring Boot 开发一般不会这么做,这只是我为了进行方便的测试 POST 方法而已。
当然,如果您不希望使用 Spring Boot 做页面返回这种奇怪的做法(毕竟几乎没有人会这么做),可以在启动文件中定义一个定义一个 RestTemplate 客户端,用来模拟浏览器进行接口测试(这是 Spring 提供的),只需要在启动文件中加入一些代码即可。
// MyRestfulWebApplicationTests.java: 启动文件
// 注意无需进行任何改动!
package com.limou.myrestfulweb;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
/**
* @SpringBootApplication 是一个便捷的注释,它添加了以下所有内容:
* = @SpringBootConfiguration 标识这是一个 Spring Boot 应用程序的配置类
* + @EnableAutoConfiguration 启用 Spring Boot 的自动配置机制, 根据依赖和类路径内容自动配置应用程序, 避免手动配置大量的 Spring 配置类
* + @ComponentScan 启用组件扫描, 默认会扫描当前包及其子包中的所有类(如 @Controller, @Service, @Repository, @Component 等注解标记的类)
*/
@SpringBootApplication
public class MyRestfulWebApplication {
public static void main(String[] args) {
SpringApplication.run(MyRestfulWebApplication.class, args);
}
private static final Logger log = LoggerFactory.getLogger(MyRestfulWebApplication.class);
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder.build();
}
@Bean
@Profile("!test")
public CommandLineRunner run(RestTemplate restTemplate) throws Exception {
return args -> {
try {
String getUrl = "http://127.0.0.1:8080/greeting_get?name=gimou";
// 创建请求头
HttpHeaders headers = new HttpHeaders();
headers.add("Cookie", "your_cookie_value"); // 在这里添加 Cookie 请求头
// 创建 HttpEntity 并将请求头和请求体设置进去
HttpEntity<String> entity = new HttpEntity<>(null, headers); // 请求体为 null
// 使用 exchange 方法发送请求
ResponseEntity<GreetingController.Greeting> getResponse =
restTemplate.exchange(
getUrl, // 请求的 URL
HttpMethod.GET, // 请求方法
entity, // 请求实体,包括头信息
GreetingController.Greeting.class // 响应体类型
);
// 打印响应结果
log.info("Response: {}", getResponse.getBody());
} catch (Exception e) {
// 捕获所有异常并记录日志
log.error("An error occurred while making the request: {}", e.getMessage(), e);
}
};
}
}- 使用上述代码中的
RestTemplate同步客户端根据来测试(不太推荐) - 使用已经编写好的前端程序,用浏览器进行调试,例如
Chrome(但要有前端代码) - 使用一些现成的接口调用软件,在本地进行测试,例如
Postman(比较不错) - 使用命令行进行测试,例如
wget, telnet(更加直观但难上手)
重要
补充:使用 RestTemplate 的方式其实意味着 Spring Boot 也是可以开发一个前端的,不过这种前端是无界面的前端,更加适合在终端中执行。
3.2.5.原理深入
不知道您有没有注意到一些问题,我们的确定义了两个东西:
- 资源表示类
- 资源控制类
但是值得令人好奇的是,我们没有调用资源控制类的对象,仅仅是在内部使用了资源表示类的对象,但是浏览器却能调用资源控制类内部的方法,这其实和 Bean 机制有关。
在 Spring 框架中,Bean 是指由 Spring 容器管理的对象。Spring 使用 DI(依赖注入) 机制来创建和管理这些对象,确保对象之间的依赖关系得到正确处理。Spring 容器通过配置文件、注解或 Java 配置类来管理这些 Bean。
而实际上 Bean 就是一个普通的 Java 对象,但是它被 Spring 容器 创建、管理、注入 到其他对象中。
Spring 容器通过使用注解(如 @Component、@Service、@Repository)或 XML 配置文件来标识哪些类是 Bean,并将这些对象注册到 Spring 容器中。
不过更多的原理,我认为可以转移到 Spring 那里去理解,毕竟 Spring Boot 可以理解为基于 Spring 的封装。
在上述代码中:
- 资源控制类 是由
Spring容器自动管理的Bean,Spring会根据配置的URL映射来自动调用控制器类的方法 - 资源表示类(如
Greeting)用于封装返回给客户端的数据。GreetingController类内部使用了Greeting类,但不需要显式地调用GreetingController实例 - 通过
Bean管理和 自动映射,Spring会自动处理HTTP请求并执行相应的方法,开发者无需手动实例化和调用控制器类
3.2.6.常用注解
我相信您一定能明白注解对于 Spring Boot 的重要性,因此这里补充一些关于常见注解的详细讲解,您需要按需加入到您的应用中,并且值得注意的是,每一组相关的组件我都会用足够完整的代码供您测试。
@SpringBootApplication = @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan标识Spring Boot应用程序的配置类、启用Spring Boot的自动配置机制、启用组件扫描来自动管理实例@RestController = @Controller + @ResponseBody:控制器@GetMapping = @RequestMapping(method = RequestMethod.GET):映射Get方法的接口@PostMapping = @RequestMapping(method = RequestMethod.POST):映射Post方法的接口@RequestParam: 获取路径中的查询参数@RequestHeader:请求报头,从请求报头中获取字段值@RequestBody:请求载荷,从请求载荷中获取字段值@JsonIgnoreProperties:对资源描述类约束的控制
4.Spring Boot 深入学习
4.1.启动流程
Spring Boot 的启动流程可以总结为以下几个核心步骤(示例为 Spring Boot 2.7.6 版本):
启动 main() 方法:应用从
main()方法启动,并通过SpringApplication.run()引导应用启动。创建 SpringApplication:应用会创建
SpringApplication对象,推断应用类型、设置初始化器、设置启动监听器、确定主应用类。准备环境:
Spring Boot在启动过程中准备应用环境,加载配置文件、系统环境变量以及命令行参数。创建并刷新 ApplicationContext:创建应用上下文,加载配置类和自动配置类,注册
Bean并执行依赖注入等初始化操作。在刷新上下文时启动嵌入式 Web 服务器:对于
Web应用,Spring Boot 会自动启动嵌入式Web容器(如Tomcat),并注册相关的Servlet和Filter。重要
补充:
Servlet是Java EE规范的一部分,本质上是处理HTTP请求的一个类。- 用于接收请求(如
GET、POST) - 处理业务逻辑
- 响应数据客户(返回
HTML、JSON等)
总结就是核心业务处理。
重要
补充:
Filter是一种“过滤器”,可以在请求到达Servlet之前 或响应返回客户端 之后 做处理。常用于:- 权限校验
- 日志记录
- 编码设置
- 跨域处理
总结就是拦截器/中间件。
重要
补充:一般
Spring通过@WebServlet/@WebFilter注解 +@ServletComponentScan,不过在Spring Boot中如果使用@RestController则会自动配置,除非又特殊场景才会手动使用(例如日志处理)。- 用于接收请求(如
发布应用已启动事件:对应监听
stated事件逻辑会被触发。执行 CommandLineRunner 和 ApplicationRunner:在应用启动完成后,执行实现了
CommandLineRunner和ApplicationRunner接口的初始化逻辑。发布 ready 事件、应用启动完成:触发
ApplicationReadyEvent,应用进入运行状态,处理业务请求或任务。
5.Spring Boot 常见依赖
Lombok
Hutool
Sa-Token
1.Sa-token 全面概述
在业务开发的时候,新手第一个碰到的难题就是如何进行权限校验的问题,登录和注册功能一直都是困惑初入开发朋友的一道门槛,您需要熟悉 HTTP 文档,并且还需要应对各种问题。不过在有了 Sa-token 以后,这一切都将变得明朗。Sa-token 是轻量的 Java 权限认证框架,可以集成到 Spring Boot 进行快速的使用。您可以阅读 官方文档,这里更多是偏向实战。
2.Sa-token 基本功能
Sa-Token 目前主要五大功能模块:
- 登录认证
- 权限认证
- 用户下线
- 单点登录
- 三方登录
- 微服鉴权

3.Sa-token 使用教程
3.1.基础使用
3.1.1.登录认证
首先我们需要理解登录的本质是什么,一般而言实现登录逻辑主要依赖两种方案,Session 和 Token 两种方案,两种方案我们需要对比一下,假设我们有多个服务器和一个浏览器。
| 登录方案 | 运行原理 | 状态存储位置 | 安全性 | 横向扩展 | 适用场景 | 跨域支持 |
|---|---|---|---|---|---|---|
| Session | 用户发出登录请求后,服务器将校验登录凭证,然后生成一个 sessionId,并且对应保存用户信息在内存中(一般使用 Redis 进行保存),然后响应一个 sessionId 返回给浏览器。而浏览器将存储这个 sessionId 到 cookie 里,后续所有的请求都需要携带 cookie,而其他接口通过这个 cookie 值也就是 sessionId 来找回存储在内存中的 session 数据,然后根据内存中对应用户状态信息进行响应即可。 | 服务器 | 较高 | 较难,需要做分布式 Session 否则所有的服务无法同时共享用户的登录信息(比如最重要的 userId) | 单服务系统 | 需要手动使用 withCredentials: true |
| Token | 用户发出登录请求后,服务器间校验登录凭证,然后直接生成一个 token(通常使用 JWT 标准,这里面包含用户的信息和一些防篡改的机制),然后直接响应给浏览器存储,不需要在服务端内存中进行存储。浏览器存储这个 token 后,后续的请求需要直接携带 token,由服务器检测这个 token 是否有效即可。 | 浏览器 | 较低 | 较易,每个服务都可以从 token 中读取到完整的用户信息(比如最重要的 userId) | 分布式系统 | 无需手动使用 withCredentials: true |
重要
补充:关于跨域的情况,可以简单看看两种方案对应的 HTTP 报文,这样看是最清晰的!
如果是 Session 机制的情况下:
浏览器请求登录
POST /api/user/login HTTP/1.1 Host: api.example.com <- 这是需要访问的后端域名 Origin: https://web.example.org <- 这是当前请求的来源域名(此时域名不相同发生跨域) Content-Type: application/json { "username": "foo", "password": "bar" }服务器响应登录
HTTP/1.1 200 OK <- 在响应之前会把用户登录凭证存储为后端内存中 session, 并且把表示该 session 的 sessionId 交给浏览器, 以备后续校验登录信息 Set-Cookie: sessionid=abc123; HttpOnly; Secure; SameSite=None <- 告诉浏览器为当前前端域名设置一个 cookie; HttpOnly 可以防止被 js 代码访问; Secure 表示限制使用 https 协议; SameSite=None 表示允许跨域携带 cookie 请求域名 api.example.com Access-Control-Allow-Origin: https://web.example.org <- 告诉浏览器后端域名允许这个前端域名向自己发起跨域 Access-Control-Allow-Credentials: true <- 告诉浏览器允许在前面的前端域名请求时发送凭证, 例如 cookie, 并且设置这个无法同时设置 Access-Control-Allow-Origin 为 *浏览器再次请求
GET /api/user/info HTTP/1.1 Host: api.example.com <- 这是需要访问的后端域名 Origin: https://web.example.org <- 当前请求的来源域名 Cookie: sessionid=abc123 <- 浏览器自动带上 cookie(前提是前端代码开启 credentials: 'include', 否则默认跨域的情况下无法携带 Cookie 进行请求)服务器再次响应
HTTP/1.1 200 OK Content-Type: application/json { "username": "foo", "email": "foo@example.com" }
如果是 Token 机制的情况下:
浏览器请求登录
POST /api/user/login HTTP/1.1 Host: api.example.com Origin: https://web.example.org Content-Type: application/json { "username": "foo", "password": "bar" }服务器响应登录
HTTP/1.1 200 OK Content-Type: application/json Access-Control-Allow-Origin: https://web.example.org Access-Control-Allow-Credentials: true { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjM0NTY3ODkwLCJpYXQiOjE2MTYwMjI3NTZ9.2AbF3z7J_TpBOJ-bZJYr5Z8xtHKHjznxShuA2k9hy0I" }浏览器再次请求
GET /api/user/info HTTP/1.1 Host: api.example.com Origin: https://web.example.org Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjM0NTY3ODkwLCJpYXQiOjE2MTYwMjI3NTZ9.2AbF3z7J_TpBOJ-bZJYr5Z8xtHKHjznxShuA2k9hy0I <- Bearer 是一种约定,表示后面跟着的就是 Token; 并且携带 token 不需要前端代码启动 withCredentials: true服务器再次响应
HTTP/1.1 200 OK Content-Type: application/json { "username": "foo", "email": "foo@example.com" }
保存在浏览器的机制使得 Token 机制非常适合分布式应用,因为不再需要维护一个共享的 Session 仓库。
不过 Sa-token 还是基于 Session 的机制实现的(因为利用了 HTTP 报文中的 Cookie 机制),不过凭证也叫做 token,并且机制会更加复杂一些,可以说融合了两者。不过,我们可以先尝试一下,您需要使用 Spring Boot 依赖和 Sa-token 的集成依赖,并且还需要配置一个配置文件,可以 查阅官方文档。这里我编写伪代码让您能理解,如果您好奇实战写法,则可以前往这个 work-user-center 项目中进行具体的用法(下面的代码就是基于这个项目中抽离出来的核心代码)。
<!-- Sa-Token -->
<dependency>
<!-- 权限认证,在线文档:https://sa-token.cc -->
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId> <!-- 注意我使用的是 Spring Boot3 -->
<version>1.42.0</version>
</dependency># sa-token 配置
# sa-token 配置
sa-token:
## token 名称
token-name: work-user-centre # 同时也是 cookie 名称
## token 有效期
timeout: 2592000 # 单位为秒, 默认 30 天, -1 代表永久有效
## token 最低活跃频率
active-timeout: -1 # 单位:为秒, 如果 token 超过此时间没有访问系统就会被冻结, 默认 -1 代表不限制, 永不冻结
## token 共享
is-share: false # 在多人登录同一账号时, 是否共用一个 token(为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token)
## 是否允许同一账号多地同时登录
is-concurrent: true # 为 true 时允许一起登录, 为 false 时新登录挤掉旧登录
## token 风格
token-style: uuid # 默认可取值: uuid、simple-uuid、random-32、random-64、random-128、tik
# 是否输出操作日志
is-log: true然后就可以开始使用 Sa-Token 提供的简易 API 了。
// 会话登录:参数填写要登录的账号id,建议的数据类型:long | int | String, 不可以传入复杂类型,如:User、Admin 等等
StpUtil.login(Object id);
// 当前会话注销登录
StpUtil.logout();
// 获取当前会话是否已经登录,返回true=已登录,false=未登录
StpUtil.isLogin();
// 检验当前会话是否已经登录, 如果未登录,则抛出异常:`NotLoginException`
StpUtil.checkLogin();一旦登录后就可以查询会话和查询凭证。
// 获取当前会话账号id, 如果未登录,则抛出异常:`NotLoginException`
StpUtil.getLoginId();
// 类似查询API还有:
StpUtil.getLoginIdAsString(); // 获取当前会话账号id, 并转化为`String`类型
StpUtil.getLoginIdAsInt(); // 获取当前会话账号id, 并转化为`int`类型
StpUtil.getLoginIdAsLong(); // 获取当前会话账号id, 并转化为`long`类型
// ---------- 指定未登录情形下返回的默认值 ----------
// 获取当前会话账号id, 如果未登录,则返回 null
StpUtil.getLoginIdDefaultNull();
// 获取当前会话账号id, 如果未登录,则返回默认值 (`defaultValue`可以为任意类型)
StpUtil.getLoginId(T defaultValue);// 获取当前会话的 token 值
StpUtil.getTokenValue();
// 获取当前 `StpLogic` 的 token 名称
StpUtil.getTokenName();
// 获取指定 token 对应的账号id,如果未登录,则返回 null
StpUtil.getLoginIdByToken(String tokenValue);
// 获取当前会话剩余有效期(单位:s,返回-1代表永久有效)
StpUtil.getTokenTimeout();
// 获取当前会话的 token 信息参数
StpUtil.getTokenInfo();重要
补充:使用注解可以用来确认该接口是否需要登录才能使用。您需要配置引入一个注解鉴权的配置。
package com.work.workusercentre.config;
import cn.dev33.satoken.interceptor.SaInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {
/**
* 注册 Sa-Token 拦截器, 打开注解式鉴权功能
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SaInterceptor()).addPathPatterns("/**");
}
}然后就可以使用注解添加到控制层方法中来快速检查某个接口是否需要登录才可以使用。
@SaIgnore代表该接口无需任何校验就可以通过@SaCheckLogin代表给接口还需要登录才可以通过
3.1.2.权限认证
权限认证包含 权限码值认证 和 角色标识认证。由于 Sa-token 无法决定用户的角色标识和权限码值,因此采取了接口设计,用户只需要实现两个接口就可以实现权限认证(甚至支持通配符的使用)。
/**
* 自定义权限加载接口实现类
*/
@Component // 保证此类被 SpringBoot 扫描,完成 Sa-Token 的自定义权限验证扩展
public class StpInterfaceImpl implements StpInterface {
/**
* 返回一个账号所拥有的权限码值集合
*/
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
// 实际上应该把 Id 解析为您在使用 StpUtil.login(Object id) 时注入的 id 的类型, 然后再通过数据库查询该 id 用户对应的权限码值集合后这这里以列表的形式做返回
// 本 list 仅做模拟,实际项目中要根据具体业务逻辑来查询权限
List<String> list = new ArrayList<String>();
list.add("101");
list.add("user.add");
list.add("user.update");
list.add("user.get");
// list.add("user.delete");
list.add("art.*");
return list;
}
/**
* 返回一个账号所拥有的角色标识集合 (权限与角色可分开校验)
*/
@Override
public List<String> getRoleList(Object loginId, String loginType) {
// 同理这个接口也是类似的
// 本 list 仅做模拟,实际项目中要根据具体业务逻辑来查询角色
List<String> list = new ArrayList<String>();
list.add("admin");
list.add("super-admin");
return list;
}
}然后再通过一些 API 做权限校验即可。
// 获取:当前账号所拥有的权限码值集合
StpUtil.getPermissionList();
// 判断:当前账号是否含有指定权限, 返回 true 或 false
StpUtil.hasPermission("user.add");
// 校验:当前账号是否含有指定权限, 如果验证未通过,则抛出异常: NotPermissionException
StpUtil.checkPermission("user.add");
// 校验:当前账号是否含有指定权限 [指定多个,必须全部验证通过]
StpUtil.checkPermissionAnd("user.add", "user.delete", "user.get");
// 校验:当前账号是否含有指定权限 [指定多个,只要其一验证通过即可]
StpUtil.checkPermissionOr("user.add", "user.delete", "user.get");// 获取:当前账号所拥有的角色标识集合
StpUtil.getRoleList();
// 判断:当前账号是否拥有指定角色, 返回 true 或 false
StpUtil.hasRole("super-admin");
// 校验:当前账号是否含有指定角色标识, 如果验证未通过,则抛出异常: NotRoleException
StpUtil.checkRole("super-admin");
// 校验:当前账号是否含有指定角色标识 [指定多个,必须全部验证通过]
StpUtil.checkRoleAnd("super-admin", "shop-admin");
// 校验:当前账号是否含有指定角色标识 [指定多个,只要其一验证通过即可]
StpUtil.checkRoleOr("super-admin", "shop-admin");重要
补充:在引入了之前注解的配置类,并且还实现了上面的两个接口后,就可以开始使用注解来做权限校验。
@SaCheckRole("admin")@SaCheckPermission("user:add")@SaCheckDisable("comment")
3.1.3.用户下线
用户下线分为三种情况:
- 强制注销:和用户自己使用注销接口登出是一样的,会导致
token被擦除 - 踢人下线:不会导致
token被擦除而是被打上特定标记,但该用户再次访问会发现被下线 - 顶人下线:这个是
Sa-token框架自己内部使用的,用来实现单点登录,一般您无需使用
// 强制注销
StpUtil.logout(10001); // 强制指定账号注销下线
StpUtil.logout(10001, "PC"); // 强制指定账号指定端注销下线
StpUtil.logoutByTokenValue("token"); // 强制指定 Token 注销下线
// 踢人下线
StpUtil.kickout(10001); // 将指定账号踢下线
StpUtil.kickout(10001, "PC"); // 将指定账号指定端踢下线
StpUtil.kickoutByTokenValue("token"); // 将指定 Token 踢下线
// 顶人下线
StpUtil.replaced(10001); // 将指定账号顶下线
StpUtil.replaced(10001, "PC"); // 将指定账号指定端顶下线
StpUtil.replacedByTokenValue("token"); // 将指定 Token 顶下线3.2.进阶使用
3.2.1.路由鉴权
可以更具路由进行鉴权,可以 查阅官方文档,不过我的项目还不够大型,而且这个挺好配置的,您看着配置就可以,实现一个类的问题而已。
3.2.2.中间缓存
Sa-Token 默认将数据保持在内存中,但是这种模式是有缺陷的,重启就会导致数据丢失,并且无法在分布式环境中共享数据,因此我们可以利用 Redis 来作为中间件缓存,这样哪怕我们的服务重启只要 Redis 还运行着就可以进行数据恢复(哪怕 Redis 自己挂了还有集群和持久化机制来做恢复)。
我使用的是 Spring Boot3,您需要仔细按照文档进行配置,我的核心依赖和核心配置如下。
<!-- Redis -->
<dependency>
<!-- 提供 Redis 连接池 -->
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!-- Sa-token -->
<dependency>
<!-- 添加关于登录认证、权限认证等模块的支持 -->
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>1.42.0</version>
</dependency>
<dependency>
<!-- Sa-Token 集成 redis, 并使用 jackson 序列化 -->
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
<version>1.42.0</version>
</dependency>
<dependency>
<!-- fastjson2 处理 json 数据 -->
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.48</version>
</dependency># 配置框架
spring:
## 配置缓存
data:
redis:
database: 2 # Redis 数据库索引(默认为 0, 通常为了方便开发会认为 0 号为生产环境、1 号为测试环境、2 号为开发环境)
host: 127.0.0.1 # Redis 服务器地址
port: 6379 # Redis 服务器连接端口
# password: Qwe54188_ # Redis 服务器连接密码(默认为空)
timeout: 10s # 连接超时时间
lettuce:
pool: # 链接池配置
max-active: 200 # 连接池最大连接数
max-wait: -1ms # 连接池最大阻塞等待时间(使用负值表示没有限制)
max-idle: 10 # 连接池中的最大空闲连接
min-idle: 0 # 连接池中的最小空闲连接
# sa-token 配置
sa-token: # 默开启 Redis 配置将自动支持 Sa-token 使用 Redis 存储认证相关键值对, 想要关掉需要去除相关依赖
## token 名称
token-name: work-user-centre # 同时也是 cookie 名称
## token 有效期
timeout: 2592000 # 单位为秒, 默认 30 天, -1 代表永久有效
## token 最低活跃频率
active-timeout: -1 # 单位:为秒, 如果 token 超过此时间没有访问系统就会被冻结, 默认 -1 代表不限制, 永不冻结
## token 共享
is-share: false # 在多人登录同一账号时, 是否共用一个 token(为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token)
## 是否允许同一账号多地同时登录
is-concurrent: true # 为 true 时允许一起登录, 为 false 时新登录挤掉旧登录
## token 风格
token-style: uuid # 默认可取值: uuid、simple-uuid、random-32、random-64、random-128、tik
## 是否输出操作日志
is-log: true然后就可以自动开始集成本地的 Redis 进行缓存和持久了,很简单。
3.2.3.会话级别
Session 是会话中专业的数据缓存组件,通过 Session 我们可以很方便的缓存一些高频读写数据,提高程序性能,在 Sa-token 中会话有三种级别:
Account-Session: 指的是框架为每个账号id分配的Session(在使用登录接口的时候就用到了,无论用户在哪个设备上登录,StpUtil都会通过相同的账号ID绑定同一会话)Token-Session: 指的是框架为每个token分配的Session(每个Token会话有独立的状态数据,互不干扰。如果用户在多个设备登录,每个设备的会话状态是独立的)Custom-Session: 指的是以一个特定的值作为SessionId,来分配的Session(custom-session-id是开发者指定的标识符,这样就不再依赖于账号ID或Token,而是根据自定义的标识来管理会话,例如以商品id作为key为每个商品分配一个Session,以便于缓存和商品相关的数据,有些时候有些用户没有登录也可以利用这种机制来进行临时缓存,目的是为了解决那些“脱离登录态、又需要有生命周期和结构管理”的缓存需求,有点像是添加了Redis这种组件的面向对象的视图)
这个不难理解,实际上就是不同方式调用不同的 API 即可。
3.2.4.同端互斥
在同种类型的客户端中,我们需要做到同端互斥,避免某些用户多开客户端在某些场景下(例如抢票)使用脚本进行刷量,这会无限制的加大我们系统的压力。因此我们希望用户至少在同一种客户端上无法多开登录。并且如果用户一直重复登录,我们的 Redis 中会出现大量的 token 记录,这也是一个安全隐患。
不过首先我们需要把配置文件中的 is-concurrent: true 修改为 is-concurrent: false,然后我们需要有一个工具类用来从请求报文中判断设备的类型,常见的主流设备类型有三种:桌面端、网页端、移动端,但是我们希望分得更加仔细一些,因此这里分为:pc、miniProgram、pad、mobile。
这个工具类 Sa=token 貌似没有提供,因此这里给出一个工具类供您使用,注意请求报文的类型,在 Spring Boot3 中发生了一些改动。
package com.work.workusercentre.utils;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.Header;
import cn.hutool.http.useragent.UserAgent;
import cn.hutool.http.useragent.UserAgentUtil;
import com.work.workusercentre.enums.CodeBindMessage;
import com.work.workusercentre.exception.BusinessException;
import lombok.extern.slf4j.Slf4j;
import jakarta.servlet.http.HttpServletRequest;
/**
* 设备工具类
*/
@Slf4j
public class DeviceUtils {
/**
* 根据请求获取设备信息
* @param request
* @return
*/
public static String getRequestDevice(HttpServletRequest request) {
String userAgentStr = request.getHeader(Header.USER_AGENT.toString());
// 使用 Hutool 解析 UserAgent
UserAgent userAgent = UserAgentUtil.parse(userAgentStr);
if (userAgent == null) {
throw new BusinessException(CodeBindMessage.PARAMS_ERROR, "禁止隐藏设备类型");
}
// 判断设备类型
String device = "pc"; // 是否为 PC
if (isMiniProgram(userAgentStr)) {
device = "miniProgram"; // 是否为小程序
} else if (isPad(userAgentStr)) {
device = "pad"; // 是否为 Pad
} else if (userAgent.isMobile()) {
device = "mobile"; // 是否为手机
}
log.debug("检测一次设备类型为 {}", device);
return device;
}
/**
* 判断是否是小程序
* 一般通过 User-Agent 字符串中的 "MicroMessenger" 来判断是否是微信小程序
**/
private static boolean isMiniProgram(String userAgentStr) {
// 判断 User-Agent 是否包含 "MicroMessenger" 表示是微信环境
return StrUtil.containsIgnoreCase(userAgentStr, "MicroMessenger")
&& StrUtil.containsIgnoreCase(userAgentStr, "MiniProgram");
}
/**
* 判断是否为平板设备
* 支持 iOS(如 iPad)和 Android 平板的检测
**/
private static boolean isPad(String userAgentStr) {
// 检查 iPad 的 User-Agent 标志
boolean isIpad = StrUtil.containsIgnoreCase(userAgentStr, "iPad");
// 检查 Android 平板(包含 "Android" 且不包含 "Mobile")
boolean isAndroidTablet = StrUtil.containsIgnoreCase(userAgentStr, "Android")
&& !StrUtil.containsIgnoreCase(userAgentStr, "Mobile");
// 如果是 iPad 或 Android 平板, 则返回 true
return isIpad || isAndroidTablet;
}
}然后无论是登入还是登出,都可以设置同端互斥,对于登入来说就会顶调用相同的种类的客户端登录状态,对于注销来说就会把所有的在线端全部顶下线。
3.2.5.脱离曲奇
有些环境是不支持在 HTTP 报文中使用 Cookie 机制的,但是问题是这些端必须支持 HTTP 报文的传输,比如微信小程序就是典型的场景,但是我们对现有的接口还不想使用微信官方的登录作法,还是希望使用 Cookie 机制怎么办?其实针对 Cookie 的特点就可以应对:
Cookie可由后端控制写入:不能后端控制写入了,就前端自己写入(难点在后端如何将Token传递到前端)Cookie每次请求自动提交:每次请求不能自动提交了,那就手动提交(难点在前端如何将Token传递到后端,同时后端将其读取出来)
因此我一般考虑这种特殊环境下单独进行条件判断,以支持像微信小程序这样的应用进行登录。我们前面不是写了关于用户端种类的判断么,登录接口中就可以根据这个判断对无 Cookie 环境进行另外的登录处理。可以先看看示例代码,然后再到我的项目中进行查阅。
// 登录接口
@RequestMapping("doLogin")
public SaResult doLogin() {
// 第1步,先登录上
StpUtil.login(10001);
// 第2步,获取 Token 相关参数
SaTokenInfo tokenInfo = StpUtil.getTokenInfo(); // 此方法返回一个对象,其有两个关键属性:tokenName和tokenValue
// 第3步,返回给前端
return SaResult.data(tokenInfo);
}这里再以 uniapp 为例(毕竟这中框架可以运行在多端上,包括微信小程序)。
// 1、首先在登录时,将 tokenValue 存储在本地,例如:
// 在这之前已经调用过登录接口...
uni.setStorageSync('tokenValue', tokenValue);
// 2、在发起ajax请求的地方,获取这个值,并塞到header里
uni.request({
url: 'https://www.example.com/request', // 仅为示例,并非真实接口地址。
header: {
"content-type": "application/x-www-form-urlencoded",
"satoken": uni.getStorageSync('tokenValue') // 关键代码, 注意参数名字是 satoken
},
success: (res) => {
console.log(res.data);
}
});另外,也可以再灵活一些,上面直接使用 "satoken" 这个参数其实写死了。
// 1、首先在登录时,将tokenName和tokenValue一起存储在本地,例如:
// 在这之前已经调用过登录接口...
uni.setStorageSync('tokenName', tokenName);
uni.setStorageSync('tokenValue', tokenValue);
// 2、在发起ajax的地方,获取这两个值, 并组织到head里
var tokenName = uni.getStorageSync('tokenName'); // 从本地缓存读取tokenName值
var tokenValue = uni.getStorageSync('tokenValue'); // 从本地缓存读取tokenValue值
var header = {
"content-type": "application/x-www-form-urlencoded"
};
if (tokenName != undefined && tokenName != '') {
header[tokenName] = tokenValue;
}
// 3、后续在发起请求时将 header 对象塞到请求头部
uni.request({
url: 'https://www.example.com/request', // 仅为示例,并非真实接口地址。
header: header,
success: (res) => {
console.log(res.data);
}
});再稍微封装一下每次请求需要写入的 token 信息函数后,再来调用就完美解决这个问题了。当然需要注意这种方式必须使用 https 协议,否则 token 明文传输会泄露用户信息,这可不是什么好事。
3.2.6.用户封禁
踢人下线、强制注销、顶人下线 功能,可以用于清退账号,不过对于一些严重违规的账号我们可以实施封禁,封禁的原理很简单,就是在数据库中有一个用标识身份的字段(比如 user、admin、ban),因此我们希望检测到 ban 时就不允许某个用户进行持续的非法访问。
不过在 Sa-tokne 中直接使用接口就可以进行封禁和解封,不需要依赖数据库,不过我们依旧保留这个字段,这样数据库管理员就不需要调用接口就可以直接在数据库中查看某个用户是否被封禁,对于维护来说比较简单。
// 封禁指定账号
StpUtil.disable(10001, 86400);
// 获取指定账号是否已被封禁 (true=已被封禁, false=未被封禁)
StpUtil.isDisable(10001);
// 校验指定账号是否已被封禁,如果被封禁则抛出异常 `DisableServiceException`
StpUtil.checkDisable(10001);
// 获取指定账号剩余封禁时间,单位:秒,如果该账号未被封禁,则返回-2
StpUtil.getDisableTime(10001);
// 解除封禁
StpUtil.untieDisable(10001);警告
警告:值得注意的是,旧版本的 Sa-token 在 StpUtil.login() 时会自动校验账号是否被封禁,v1.31.0 之后将 校验封禁 和 登录 两个动作分离成两个方法,登录接口不再自动校验,请注意其中的逻辑更改!
3.1.7.单点登录
解决了一些无 Cookie 环境的问题后,我们对端的设备就毫无畏惧了,那么就可以视所有客户端都是相同的环境,此时我们就可以考虑单点登录的问题了。简而言之**:在多个互相信任的系统中,用户只需登录一次,就可以访问所有系统,也就是 SSO 模块**。尤其是对同一公司的网站,明明共用一个用户数据库,却要求用户都要手动登录,很影响用户体验。对此 Sa-token 有三种应对场景,以及对应的解决方案。对于小型公司来说,第一种方案会比较常用,我这里只考虑第一种情况,其他情况查阅官方文档就可以了。
| 系统架构 | 采用模式 | 简介 | 文档链接 |
|---|---|---|---|
前端同域 + 后端同 Redis | 模式一 | 共享 Cookie 同步会话 | 文档、示例 |
前端不同域 + 后端同 Redis | 模式二 | URL 重定向传播会话 | 文档、示例 |
前端不同域 + 后端不同 Redis | 模式三 | Http 请求获取会话 | 文档、示例 |
首先我们来根据第一个方案来进行拆解,我们先搞清楚为什么我们无法在多个同根域名的项目里无法共享登录状态,并且给出对应的解决方案:
- 登陆后返回给前端的
Token无法在多个系统下共享 -> 使用共享Cookie来解决Token共享问题。所谓共享Cookie,就是主域名Cookie在二级域名下的共享,举个例子:写在父域名stp.com下的Cookie,在s1.stp.com、s2.stp.com等子域名都是可以共享访问的 - 登陆后存储在后端的
Session无法在多个系统间共享 -> 使用Redis来解决Session共享问题。而共享Redis,本来时需要共享一个Redis集群的,但是其实并不需要我们把所有项目的数据都放在同一个Redis中,Sa-Token提供了 权限缓存与业务缓存分离 的解决方案 Alone 独立 Redis 插件,感兴趣可以看一看,这里还是先使用单个的Redis主节点来做演示。
接下来就是开始实践的阶段,这一部分我推荐直接按照官方文档来搭建,如果您希望一点点改造,则可以从 Sa-token 中拉取官方示例(文档有提到 具体地址)我们需要按照以下的步骤:
- 搭建统一认证中心服务,请 参考官方示例代码 sa-token-demo-sso-server
- 搭建多个同域根客户端,请 参考官方示例代码 sa-token-demo-sso1-client
- 配置本地域名解析文件:在
windows下的C:\windows\system32\drivers\etc\hosts或linux下的/etc/hosts文件中添加以下域名解析规则
```bash
127.0.0.1 sso.stp.com # 注意官方的示例代码打印的地址是不对的应该访问这个(这个注释去除掉)
127.0.0.1 s1.stp.com
127.0.0.1 s2.stp.com
127.0.0.1 s3.stp.com
```
其中 `sso.stp.com` 为统一认证中心地址,当用户在其它 `Client` 端发起登录请求时,均将其重定向至认证中心,待到登录成功之后再原路返回到 `Client` 端
我们先来看看成果,再来分析做了什么,首先官方给的上述两个示例仓库是使用一个前端项目模拟三个不同的子域名,这些子域名的前端都是普通的 h5 页面,在点击登录按钮后,其他子域名的页面都会自动登录。




然后刷新其他页面就会发现自动进行了登录。


更多的地方以后研究,待补充...
3.2.8.三方登录
OAuth2.0 与 SSO 相比,增加了对应用授权范围的控制,减弱了应用之间数据同步的能力。待补充...
3.2.9.用户记录
有些时候我们希望登录后,用户可以选中“记住登录”,以支持保持登录状态,但是其实 Sa-token 默认支持的就是记住登录,因此我们的重心反而是如何让用户在不勾选的时候记不住登录,待补充...
3.2.10.密码加密
Sa-token 还提供了强大的密码加密工具包,可以简化对用户密码加密的过程,待补充...
Elasticsearch
1.Elasticsearch 的全面概述
Elasticsearch 是一个分布式、开源的搜索引擎,专门用于处理大规模的数据搜索和分析。它基于 Apache Lucene 构建,具有实时搜索、分布式计算和高可扩展性,广泛用于全文检索、日志分析、监控数据分析等场景,可以阅读一下官方文档。
2.Elasticsearch 的基本功能
基本上我们使用一种叫作分词器的东西在控制面板上对 Elasticsearch 进行操作。
3.Elasticsearch 的使用教程
3.1.核心组件
3.1.1.Elasticsearch
Elasticsearch 生态系统非常丰富,包含了一系列工具和功能,帮助用户处理、分析和可视化数据,Elastic Stack(也称为 ELK Stack) 是其核心组成部分,由几个部分组成。最核心的部分就是 Elasticsearch,是核心的搜索引擎,负责存储数据、索引数据、搜索数据。
3.1.2.Kibana
可视化平台,用于查询、分析、展示 Elasticsearch 中的数据,是 Elastic Stack 的可视化组件,允许用户通过图表、地图和仪表盘来展示存储在 Elasticsearch 中的数据。它提供了简单的查询接口、数据分析和实时监控功能。
3.1.3.Logstash
负责数据处理管道,负责数据收集、数据过滤、数据增强、数据传输到 Elasticsearch。是一个强大的数据收集管道工具,能够从多个来源收集、过滤、转换数据,然后将数据发送到 Elasticsearch,Logstash 支持丰富的输入插件、过滤插件、输出插件。

3.1.4.Beats
轻量级的数据传输工具,收集和发送数据到 Logstash 或 Elasticsearch。作为轻量级的数据采集代理,负责从不同来源收集数据并发送到 Elasticsearch 或 Logstash。常见的 Beats 包括:
Filebeat:收集日志文件Metricbeat:收集系统和服务的指标Packetbeat:监控网络流量

3.2.组件部署
我们采用 Docker 进行部署,由于我自己的项目用的 Spring Boot 2.x 版本,对应的 Spring Data Elasticsearch 客户端版本是 4.x,支持的 Elasticsearch 是 7.x,所以建议 Elasticsearch 使用 7.x 的版本。由于我这里使用的是 Sping Boot 2.7.2.,推荐 部署 Elasticsearch 7.17。我个人偏爱 Docker,基本上能使用 Docker 解决的部署问题我都会使用 Docker,因此我们需要先 sudo docker pull docker.elastic.co/elasticsearch/elasticsearch:7.17.28,然后编写配置文件 docker-compose.yaml,然后参考 这个文档 进行部署,我们部署个单机就足够了。
# 服务
services:
## Elasticsearch
work-elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:7.17.28
container_name: work-elasticsearch
restart: always
ports:
- "9200:9200"
environment:
- discovery.type=single-node # 表示单机部署
- xpack.security.enabled=true # 开启认证
- ELASTIC_PASSWORD=Qwe54188_ # 提供密码进行认证
- ES_JAVA_OPTS=-Xms4g -Xmx4g # 设置内存限制, 否则您的主机可能顶不住
networks:
- work-network
mem_limit: 5g # entrypoint 执行的是容器启动时始终执行的主程序, 下面安装了一个插件
entrypoint: >
sh -c "
if [ ! -d /usr/share/elasticsearch/plugins/analysis-ik ]; then
echo 'Installing IK plugin...';
elasticsearch-plugin install --batch https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-7.17.28.zip;
fi;
exec docker-entrypoint.sh
"
# 网络
networks:
work-network:
name: work-network然后使用 sudo docker-compose up -d work-elasticsearch 即可快速启动,可使用 curl -X GET "localhost:9200/?pretty" 来进行测试。
$ curl -X GET "localhost:9200/?pretty"
{
"name" : "9ffcfd197f5f",
"cluster_name" : "docker-cluster",
"cluster_uuid" : "4jdj3x2eRvqhZ0L3Eh-TBw",
"version" : {
"number" : "7.17.28",
"build_flavor" : "default",
"build_type" : "docker",
"build_hash" : "139cb5a961d8de68b8e02c45cc47f5289a3623af",
"build_date" : "2025-02-20T09:05:31.349013687Z",
"build_snapshot" : false,
"lucene_version" : "8.11.3",
"minimum_wire_compatibility_version" : "6.8.0",
"minimum_index_compatibility_version" : "6.0.0-beta1"
},
"tagline" : "You Know, for Search"
}不过我们还需要另外一个重要的组件,就是 Kibana 可视化平台,可以 根据部署文档进行部署,我依旧是 采用 Docker 的部署方式,首先需要拉取 sudo docker pull docker.elastic.co/kibana/kibana:7.17.28,然后还是给出 docker-compose.yaml 文件。
# 服务
services:
## Elasticsearch
work-elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:7.17.28
container_name: work-elasticsearch
restart: always
ports:
- "9200:9200"
environment:
- discovery.type=single-node # 表示单机部署
- xpack.security.enabled=true # 开启认证
- ELASTIC_PASSWORD=Qwe54188_ # 提供密码进行认证
- ES_JAVA_OPTS=-Xms4g -Xmx4g # 设置内存限制, 否则您的主机可能顶不住
networks:
- work-network
mem_limit: 5g # entrypoint 执行的是容器启动时始终执行的主程序, 下面安装了一个插件
entrypoint: >
sh -c "
if [ ! -d /usr/share/elasticsearch/plugins/analysis-ik ]; then
echo 'Installing IK plugin...';
elasticsearch-plugin install --batch https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-7.17.28.zip;
fi;
exec docker-entrypoint.sh
"
## Kibana
work-kibana:
image: docker.elastic.co/kibana/kibana:7.17.28
container_name: work-kibana
restart: always
ports:
- "5601:5601"
environment:
- ELASTICSEARCH_HOSTS=http://work-elasticsearch:9200 # 对接搜索引擎
- ELASTICSEARCH_USERNAME=elastic # 提供用户进行认证
- ELASTICSEARCH_PASSWORD=Qwe54188_ # 提供密码进行认证
networks:
- work-network
# 网络
networks:
work-network:
name: work-network然后使用 sudo docker-compose up -d work-kibana 快速部署,最后访问 http://127.0.0.1:5601 即可得到控制台。

重要
补充:您可以在容器内部的 /usr/share/kibana/config/kibana.yml 末尾添加 i18n.locale: "zh-CN" 即可实现汉化控制台。

尝试利用 Kibana 的开发工具来操作 Elasticsearch 的数据,比如查询操作。

然后输入分词器配置。
POST /_analyze
{
"analyzer": "standard",
"text": "缡墨是一位开发者。"
}
可以看到对中文的分词是比较糟糕的,因为标准的分词器 standard 只支持英文,我们需要使用对中文进行优化的分词器,不过可惜的是以下默认安装的分词器都不支持中文:
standard:标准分词器simple:简单分词器whitespace:按空格分词stop:带停用词的分词器keyword:不分词,将整个字段作为一个词条pattern:基于正则表达式的分词器ngram和edge_ngram:n-gram分词器
我们需要安装 IK 中文分词器(ES 插件),其实只需要执行 .\bin\elasticsearch-plugin.bat install https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-7.17.23.zip 就可以了,不过在 Docker 环境中该怎么做呢?很简单,在 ES 启动的时候运行这个指令即可,我们前面的配置文件已经做了这个事情,因此下面就可以直接使用了。IK 分词器插件为我们提供了两个分词器,ik_smart 和 ik_max_word:
ik_smart是智能分词,尽量选择最像一个词的拆分方式,比如“好学生”会被识别为一个词,适用于 搜索分词,即在查询时使用,保证性能的同时提供合理的分词精度。ik_max_word尽可能地分词,可以包括组合词,比如“好学生”会被识别为3个词:好学生、好学、学生,适用于 底层索引分词,确保在建立索引时尽可能多地分词,提高查询时的匹配度和覆盖面。

效果还不错。
3.3.快速使用
3.3.1.索引操作
分词器的语法我们可以慢慢来研究,不过有一个重要的问题,ES 需要读取 MySQL 中的数据才能进开始进行分词,而不是我们在控制台中执行。而 MySQL 中的数据是有可能变动的,因此我们就需要全量更新和增量更新。为了将 MySQL 题目表数据导入到 ES 中并实现分词搜索,需要为 ES 索引定义 mapping。ES 的 mapping 用于定义字段的类型、分词器及其索引方式,含义类似 MySQL 的数据表。
既然是类似数据库概念,那么就需要学习字段的类型,我们对项目中的用户数据进行一个索引 mapping 的制作,我们的主要目标是分词搜索用户的标识、帐号、简介、名字、昵称、地址。
| 字段类型 | 描述 | 示例用途 |
|---|---|---|
text | 用于存储需要分词的字符串,适合全文搜索 | 文章内容、评论 |
keyword | 用于存储不分词的字符串,适合精确匹配 | ID、标签、电子邮件 |
long | 用于存储 64 位整数 | 年份、ID、计数器 |
integer | 用于存储 32 位整数 | 小范围整数 |
short | 用于存储 16 位整数 | 较小的整数 |
byte | 用于存储 8 位整数 | 非常小的整数 |
double | 用于存储双精度浮动数值 | 浮动小数点数 |
float | 用于存储单精度浮动数值 | 较小精度的浮动数值 |
boolean | 用于存储布尔值(true 或 false) | 状态开关、是否启用 |
date | 用于存储日期和时间,支持多种日期格式 | 时间戳、日期字段 |
object | 用于存储嵌套的 JSON 对象,可以存储多个字段 | 地址、用户资料 |
nested | 用于存储数组中嵌套的对象,支持多级嵌套和嵌套查询 | 订单项、嵌套数据结构 |
range | 用于存储数值范围(如日期范围、数值范围等) | 日期范围、数值范围 |
geo_point | 用于存储地理坐标(经度、纬度),支持空间查询 | 地理位置、距离查询 |
geo_shape | 用于存储更复杂的地理形状(如多边形、线条等) | 多边形区域、复杂形状查询 |
ip | 用于存储 IP 地址,支持 IP 匹配查询 | IP 地址、CIDR 匹配 |
binary | 用于存储二进制数据,适用于图像、音频等文件 | 图像文件、音频文件 |
token_count | 用于存储文本字段中的词项数量 | 文本字段长度统计 |
version | 用于存储版本信息,常用于版本控制 | 软件版本 |
flattened | 用于存储多键值对,适合存储动态字段 | 动态数据、变化的字段结构 |
wildcard | 用于支持模糊匹配(* 或 ?)的字符串字段 | 模糊匹配查询 |
scaled_float | 用于存储以某个比例缩放的浮动数值,通常用于财务或计数数据 | 财务数据、精度缩放的计数数据 |
PUT /work_user_centre
{
"mappings": {
"properties": {
"account": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"index": true
},
"wx_union": {
"type": "keyword",
"index": true
},
"mp_open": {
"type": "keyword",
"index": true
},
"email": {
"type": "keyword",
"index": true
},
"phone": {
"type": "keyword",
"index": true
},
"ident": {
"type": "keyword",
"index": true
},
"passwd": {
"type": "keyword",
"index": false
},
"avatar": {
"type": "keyword",
"index": false
},
"tags": {
"type": "keyword",
"index": true
},
"nick": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"index": true
},
"name": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"index": true
},
"profile": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"index": true
},
"birthday": {
"type": "date",
"format": "yyyy-MM-dd",
"index": true
},
"country": {
"type": "keyword",
"index": true
},
"address": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"index": true
},
"role": {
"type": "integer",
"index": true
},
"level": {
"type": "integer",
"index": true
},
"gender": {
"type": "integer",
"index": true
},
"deleted": {
"type": "keyword",
"index": true
},
"create_time": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss"
},
"update_time": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss"
}
}
}
}有几个事情需要说明一下:
- 在
ES中,每个文档都有一个唯一的_id字段来标识文档,该字段用于文档的主键索引和唯一标识。通常,开发者并不需要显式定义id字段,因为ES会自动生成_id,或者在插入数据时,您可以手动指定_id。 "analyzer": "ik_max_word"指定了这个字段使用的分词器(analyzer)是ik_max_word,ik_max_word是IK分词器的一种配置,适用于最大化分词的精度,将文本拆分为更多的词项。用于细粒度的分词,能够将句子拆分成更多的词汇,这对于搜索时的精确匹配有帮助。"search_analyzer": "ik_smart":指定了一个不同的分词器ik_smart用于搜索时的分析。ik_smart是另一种分词模式,它会使用较少的词汇进行分词,通常适用于短文本或者对搜索精度要求不那么高的场景。这个设置的目的是在搜索过程中使用ik_smart来减少分词数量,从而提高查询效率,同时避免不必要的复杂性。"index": true是ES中的一个字段设置,它的作用是决定该字段是否需要被索引。默认情况下,字段是会被索引的,但在某些情况下,您可以显式地设置它为false,来禁用该字段的索引功能。deleted使用keyword类型,表示是否被删除。 因为keyword是为精确匹配设计的,适用于枚举值精确查询的场景,性能好且清晰。为什么不用boolean类型呢?因为MySQL数据库存储的是0和1,写入ES时需要转换类型。createTime、updateTime时间字段被定义为date类型,并指定了格式"yyyy-MM-dd HH:mm:ss"。这样做的好处是ES可以基于这些字段进行时间范围查询、排序和聚合操作,如按时间过滤或统计某时间段的数据。- 在
ES中,所有的字段类型(包括keyword和text)默认都支持数组。你可以直接插入一个包含多个值的数组,ES会自动将其视为多个值的集合。在查询时,ES会将数组中的每个值视为独立的keyword,可以进行精确匹配,例如上面的tags字段。
重要
补充:但是有一点要注意,推荐在创建索引时添加 alias(别名) ,因为它提供了灵活性和简化索引管理的能力。具体原因如下:
- 零停机切换索引:在更新索引或重新索引数据时,你可以创建一个新索引并使用
alias切换到新索引,而不需要修改客户端查询代码,避免停机或中断服务。 - 简化查询:通过
alias,可以使用一个统一的名称进行查询,而不需要记住具体的索引名称(尤其当索引有版本号或时间戳时)。 - 索引分组:
alias可以指向多个索引,方便对多个索引进行联合查询,例如用于跨时间段的日志查询或数据归档。
因此完整的创建映射的 json 结构如下,需要在刚刚的控制台中执行:
PUT /user_v1
{
"aliases": {
"user": {}
},
"mappings": {
"properties": {
"account": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"index": true
},
"wx_union": {
"type": "keyword",
"index": true
},
"mp_open": {
"type": "keyword",
"index": true
},
"email": {
"type": "keyword",
"index": true
},
"phone": {
"type": "keyword",
"index": true
},
"ident": {
"type": "keyword",
"index": true
},
"passwd": {
"type": "keyword",
"index": false
},
"avatar": {
"type": "keyword",
"index": false
},
"tags": {
"type": "keyword",
"index": true
},
"nick": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"index": true
},
"name": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"index": true
},
"profile": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"index": true
},
"birthday": {
"type": "date",
"format": "yyyy-MM-dd",
"index": true
},
"country": {
"type": "keyword",
"index": true
},
"address": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"index": true
},
"role": {
"type": "integer",
"index": true
},
"level": {
"type": "integer",
"index": true
},
"gender": {
"type": "integer",
"index": true
},
"deleted": {
"type": "keyword",
"index": true
},
"create_time": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss"
},
"update_time": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss"
}
}
}
}响应为:
{
"acknowledged" : true,
"shards_acknowledged" : true,
"index" : "user_v1"
}3.3.2.文档操作
ES 支持多种类型的客户端:
- HTTP API:
ES提供了RESTful HTTP API,用户可以通过直接发送HTTP请求来执行索引、搜索和管理集群的操作。 - Kibana:
Kibana是ES官方提供的可视化工具,用户可以通过Kibana控制台使用查询语法(如DSL、KQL)来执行搜索、分析和数据可视化。 - Java REST Client:
ES官方提供的Java高级REST客户端库,用于Java程序中与Elasticsearch进行通信,支持索引、查询、集群管理等操作。 - Spring Data Elasticsearch:
Spring全家桶的一员,用于将Elasticsearch与Spring框架集成,通过简化的Repository方式进行索引、查询和数据管理操作。 - Elasticsearch SQL CLI:命令行工具,允许通过类
SQL语法直接在命令行中查询Elasticsearch数据,适用于熟悉SQL的用户。
这里以 Spring Boot 为例子,使用
<!-- elasticsearch: https://www.elastic.co/docs/get-started/ -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>再添加配置文件配置:
spring:
elasticsearch:
uris: http://127.0.0.1:9200
username: elastic
password: Qwe54188_使用 Spring Data Elasticsearch 提供的 Bean 即可操作 Elasticsearch,我们可以直接通过 @Resource 注解引入,注入后根据我们创建的索引插入文档,对 ES 文档进行一些操作。
package cn.com.edtechhub.workusercentre;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.elasticsearch.index.query.QueryBuilders;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;
import org.springframework.data.elasticsearch.core.ElasticsearchRestTemplate;
import org.springframework.data.elasticsearch.core.SearchHit;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates;
import org.springframework.data.elasticsearch.core.query.IndexQuery;
import org.springframework.data.elasticsearch.core.query.IndexQueryBuilder;
import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder;
import org.springframework.data.elasticsearch.core.query.Query;
import javax.annotation.Resource;
import java.util.List;
import java.util.stream.Collectors;
@SpringBootApplication
@Slf4j
public class Test implements CommandLineRunner {
public static void main(String[] args) {
SpringApplication.run(Test.class, args);
}
@Resource
private ElasticsearchRestTemplate elasticsearchRestTemplate;
@Override
public void run(String... args) throws Exception {
User user = new User();
user.setId(1L); // 使用 Long 类型ID
user.setAccount("testUser");
user.setNick("limou3434");
user.setName("缡墨");
user.setProfile("我是一名测试用户");
user.setAddress("广州白云区");
user.setTags(1001);
// 1. 创建文档
addUser(user);
// 2. 查询文档(根据 id)
searchById(user.getId());
// 3. 查询文档(根据 account)
searchByAccount(user.getAccount());
// 4. 更新文档
updateUserProfile(user.getId());
// 5. 删除文档
// deleteUser(user.getId()); // 可以先别删除在 kibana 中查看一下
/*
可以使用下面 json 在 kibana 中查看
GET /user_v1/_doc/1
*/
}
private User addUser(User user) {
IndexQuery indexQuery = new IndexQueryBuilder()
.withId(user.getId().toString()) // 显式转为String
.withObject(user)
.build();
String documentId = elasticsearchRestTemplate.index(indexQuery, IndexCoordinates.of("user_v1"));
log.info("新增文档成功, ID: {}", documentId);
return user;
}
private void searchById(Long id) {
// 关键修改点:使用Long类型查询
User user = elasticsearchRestTemplate.get(id.toString(), User.class, IndexCoordinates.of("user_v1"));
log.info("根据 id 查询结果: {}", user);
}
private void searchByAccount(String account) {
Query query = new NativeSearchQueryBuilder()
.withQuery(QueryBuilders.matchQuery("account", account))
.build();
SearchHits<User> searchHits = elasticsearchRestTemplate.search(query, User.class);
List<User> users = searchHits.stream()
.map(SearchHit::getContent)
.collect(Collectors.toList());
log.info("根据 account 查询到 {} 条结果: {}", users.size(), users);
}
private void updateUserProfile(Long id) {
User user = elasticsearchRestTemplate.get(id.toString(), User.class, IndexCoordinates.of("user_v1"));
if (user != null) {
user.setProfile("更新后的用户简介 [" + System.currentTimeMillis() + "]");
IndexQuery updateQuery = new IndexQueryBuilder()
.withId(user.getId().toString())
.withObject(user)
.build();
String updatedId = elasticsearchRestTemplate.index(updateQuery, IndexCoordinates.of("user_v1"));
log.info("更新文档成功, id: {}", updatedId);
/*
可以使用下面 json 在 kibana 中查看
GET /user_v1/_search
{
"query": {
"match_all": {}
}
}
*/
} else {
log.warn("未找到 id 为 {} 的文档", id);
}
}
private void deleteUser(Long id) {
String deletedId = elasticsearchRestTemplate.delete(id.toString(), IndexCoordinates.of("user_v1"));
log.info("删除文档成功,ID: {}", deletedId);
}
}
@Data
@Document(indexName = "user_v1") // 由于我们最主要的还是根据下面几个字段来查询, 所以其他字段我暂时不考虑
class User {
@Id
private Long id; // 标识
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String account; // 帐号
@Field(type = FieldType.Keyword)
private Integer tags; // 标签
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String nick; // 昵称
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String name; // 名字
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String profile; // 简介
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String address; // 地址
}重要
补充:建议 好好阅读这篇文档。
可能您会发现这种开发模式有点眼熟,没错 ElasticsearchRestTemplate 就是有点类似基于 JDBC 的 JdbcTemplate,不过我们倚仗 MyBatisPlus,成功不用编写过多的代码,最终达到从 entity->service->controller 的模式,那么 ES 有没有办法书写 Dao 层呢?有的,官方支持 Spring 类型的项目这么做!类似 MyBatisPlus 的开发过程,您需要定义 ES 的索引实体类(最好内部含有和实体类相互转换的方法),并且继承 ElasticsearchRepository 得到操作接口 UserEsMapper,然后在 service 中您的接口,serviceImpl 中实现这些接口。
这种开发模式和我们以往的开发一模一样,在 UserEsMapper 中已经实现好了大部分可直接使用的 CURD 方法,我们可以先来编写一个接口用来查询用户,不过我们主要是为了分词查询,数据我们自己导入,所以实际上 UserEsMapper 在我们的服务中几乎对内提供全量导入和增量导入。而日常查询接口的编写中,我们依旧使用 ElasticsearchRestTemplate 来进行接口开发。
ok 基本的内容都有了,开始编写代码。
curl -u elastic:Qwe54188_ -X DELETE "http://localhost:9200/user_v1"
curl -u elastic:Qwe54188_ -X PUT "http://127.0.0.1:9200/user_v1" \
-H 'Content-Type: application/json' \
-d '{
"aliases": {
"user": {}
},
"mappings": {
"properties": {
"account": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"index": true
},
"wx_union": {
"type": "keyword",
"index": true
},
"mp_open": {
"type": "keyword",
"index": true
},
"email": {
"type": "keyword",
"index": true
},
"phone": {
"type": "keyword",
"index": true
},
"ident": {
"type": "keyword",
"index": true
},
"passwd": {
"type": "keyword",
"index": false
},
"avatar": {
"type": "keyword",
"index": false
},
"tags": {
"type": "keyword",
"index": true
},
"nick": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"index": true
},
"name": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"index": true
},
"profile": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"index": true
},
"birthday": {
"type": "date",
"format": "yyyy-MM-dd",
"index": true
},
"country": {
"type": "keyword",
"index": true
},
"address": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"index": true
},
"role": {
"type": "integer",
"index": true
},
"level": {
"type": "integer",
"index": true
},
"gender": {
"type": "integer",
"index": true
},
"deleted": {
"type": "keyword",
"index": true
},
"create_time": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss"
"index": true
},
"update_time": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss"
"index": true
}
}
}
}'
curl -u elastic:Qwe54188_ -X GET "http://127.0.0.1:9200/user_v1/_mapping?pretty"package cn.com.edtechhub.workusercentre.mapper;
import cn.com.edtechhub.workusercentre.model.entity.UserEs;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
/**
* 用户映射(ES)
*
* @author <a href="https://github.com/limou3434">limou3434</a>
*/
public interface UserEsMapper extends ElasticsearchRepository<UserEs, Long> {
}我们需要先来写全量更新的程序和增量更新的程序,不然 MySQL 没有导入数据到 ES 中的话,ES 无法进行分词。
package cn.com.edtechhub.workusercentre.job.once;
import cn.com.edtechhub.workusercentre.mapper.UserEsMapper;
import cn.com.edtechhub.workusercentre.model.entity.User;
import cn.com.edtechhub.workusercentre.model.entity.UserEs;
import cn.hutool.core.collection.CollUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.util.List;
import java.util.stream.Collectors;
/**
* 全量同步用户到 ES
*
* @author <a href="https://github.com/limou3434">limou3434</a>
*/
@Component // 注释这个则关闭全量同步
@Slf4j
public class FullSyncUserToEs {
/**
* 注入 userEsMapper
*/
@Resource
private UserEsMapper userEsMapper;
/**
* 注入 jdbcTemplate
*/
@Resource
private JdbcTemplate jdbcTemplate;
/**
* 执行内容
*/
@PostConstruct // 在 Spring 容器初始化后时执行一次
public void run() {
// 全量获取题目, 数据量不大的情况下使用
String sql = "SELECT * FROM user";
List<User> userList = jdbcTemplate.query(sql, new BeanPropertyRowMapper<>(User.class)); // 使用原生 JDBC 绕过逻辑删除避免无法同部到 ES
log.debug("asdkljuugasd {}", userList);
if (CollUtil.isEmpty(userList)) {
return;
}
// 转为 ES 实体类
List<UserEs> userEsList = userList.stream()
.map(UserEs::EntityToMapping)
.collect(Collectors.toList());
// 分页批量插入到 ES
final int pageSize = 10;
int total = userEsList.size();
log.debug("FullSyncUserToEs start, total {}", total);
for (int i = 0; i < total; i += pageSize) {
// 注意同步的数据下标不能超过总数据量
int end = Math.min(i + pageSize, total);
log.debug("sync from {} to {}", i, end);
userEsMapper.saveAll(userEsList.subList(i, end));
}
log.debug("FullSyncUserToEs end, total {}, is {}", total, userEsList);
}
}package cn.com.edtechhub.workusercentre.job.cycle;
import cn.com.edtechhub.workusercentre.mapper.UserEsMapper;
import cn.com.edtechhub.workusercentre.model.entity.User;
import cn.com.edtechhub.workusercentre.model.entity.UserEs;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.stream.Collectors;
/**
* 增量同步用户到 ES(每分钟一次)
*
* @author <a href="https://github.com/limou3434">limou3434</a>
*/
// todo 取消注释开启任务
@Component // 注释这个则关闭增量同步
@Slf4j
public class IncSyncUserToEs {
/**
* 注入 userEsMapper
*/
@Resource
private UserEsMapper userEsMapper;
/**
* 注入 jdbcTemplate
*/
@Resource
private JdbcTemplate jdbcTemplate;
/**
* 执行内容
*/
@Scheduled(fixedRate = 60 * 1000)
public void run() {
// 当前时间(UTC)
Instant now = Instant.now();
// 2 分钟前(UTC)
Instant fiveMinutesAgo = now.minus(2, ChronoUnit.MINUTES);
// 构建 SQL 语句
String sql = "SELECT * FROM user WHERE update_time >= ?"; //直接用 update_time 比较,无需转换(timestamp 默认是 UTC)
// 查询数据得到 2 分钟内被更新的数据(不过不包含直接被删除的记录)
List<User> userList = jdbcTemplate.query(
sql,
new BeanPropertyRowMapper<>(User.class),
Timestamp.from(fiveMinutesAgo)
);
// 转为 ES 实体类
List<UserEs> userEsList = userList.stream()
.map(UserEs::EntityToMapping)
.collect(Collectors.toList());
// 分页批量插入到 ES
final int pageSize = 10;
int total = userEsList.size();
if (total == 0) {
log.debug("IncSyncUserToEs no data...");
}
else {
log.debug("IncSyncUserToEs start, total {}", total);
for (int i = 0; i < total; i += pageSize) {
// 注意同步的数据下标不能超过总数据量
int end = Math.min(i + pageSize, total);
log.debug("sync from {} to {}", i, end);
userEsMapper.saveAll(userEsList.subList(i, end));
}
log.debug("IncSyncUserToEs end, total {}, is {}", total, userEsList);
}
}
}您可以使用下面的 json 进行查看是否全量更新。
GET /user/_search
{
"from": 0,
"size": 30,
"query": {
"match_all": {}
}
}然后我们需要编写索引实体,对照我们之前使用 MyBatisPlus 生成的实体类来编写。
package cn.com.edtechhub.workusercentre.model.entity;
import com.baomidou.mybatisplus.annotation.*;
import java.io.Serializable;
import java.util.Date;
import lombok.Data;
/**
* 用户实体
*
* @author <a href="https://github.com/limou3434">limou3434</a>
*/
@TableName(value ="user")
@Data
public class User implements Serializable {
/**
* 本用户唯一标识(业务层需要考虑使用雪花算法用户标识的唯一性)
*/
@TableId(type = IdType.ASSIGN_ID) // 手动添加雪花算法
private Long id;
/**
* 账户号(业务层需要决定某一种或多种登录方式, 因此这里不限死为非空)
*/
private String account;
/**
* 微信号
*/
private String wxUnion;
/**
* 公众号
*/
private String mpOpen;
/**
* 邮箱号
*/
private String email;
/**
* 电话号
*/
private String phone;
/**
* 身份证
*/
private String ident;
/**
* 用户密码(业务层强制刚刚注册的用户重新设置密码, 交给用户时默认密码为 123456, 并且加盐密码)
*/
private String passwd;
/**
* 用户头像(业务层需要考虑默认头像使用 cos 对象存储)
*/
private String avatar;
/**
* 用户标签(业务层需要 json 数组格式存储用户标签数组)
*/
private String tags;
/**
* 用户昵称
*/
private String nick;
/**
* 用户名字
*/
private String name;
/**
* 用户简介
*/
private String profile;
/**
* 用户生日
*/
private String birthday;
/**
* 用户国家
*/
private String country;
/**
* 用户地址
*/
private String address;
/**
* 用户角色(业务层需知 -1 为封号, 0 为用户, 1 为管理, ...)
*/
private Integer role;
/**
* 用户等级(业务层需知 0 为 level0, 1 为 level1, 2 为 level2, 3 为 level3, ...)
*/
private Integer level;
/**
* 用户性别(业务层需知 0 为未知, 1 为男性, 2 为女性)
*/
private Integer gender;
/**
* 是否删除(0 为未删除, 1 为已删除)
*/
@TableLogic
private Integer deleted; // 手动修改为逻辑删除
/**
* 创建时间(受时区影响)
*/
private Date createTime;
/**
* 更新时间(受时区影响)
*/
private Date updateTime;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
@Override
public boolean equals(Object that) {
if (this == that) {
return true;
}
if (that == null) {
return false;
}
if (getClass() != that.getClass()) {
return false;
}
User other = (User) that;
return (this.getId() == null ? other.getId() == null : this.getId().equals(other.getId()))
&& (this.getAccount() == null ? other.getAccount() == null : this.getAccount().equals(other.getAccount()))
&& (this.getWxUnion() == null ? other.getWxUnion() == null : this.getWxUnion().equals(other.getWxUnion()))
&& (this.getMpOpen() == null ? other.getMpOpen() == null : this.getMpOpen().equals(other.getMpOpen()))
&& (this.getEmail() == null ? other.getEmail() == null : this.getEmail().equals(other.getEmail()))
&& (this.getPhone() == null ? other.getPhone() == null : this.getPhone().equals(other.getPhone()))
&& (this.getIdent() == null ? other.getIdent() == null : this.getIdent().equals(other.getIdent()))
&& (this.getPasswd() == null ? other.getPasswd() == null : this.getPasswd().equals(other.getPasswd()))
&& (this.getAvatar() == null ? other.getAvatar() == null : this.getAvatar().equals(other.getAvatar()))
&& (this.getTags() == null ? other.getTags() == null : this.getTags().equals(other.getTags()))
&& (this.getNick() == null ? other.getNick() == null : this.getNick().equals(other.getNick()))
&& (this.getName() == null ? other.getName() == null : this.getName().equals(other.getName()))
&& (this.getProfile() == null ? other.getProfile() == null : this.getProfile().equals(other.getProfile()))
&& (this.getBirthday() == null ? other.getBirthday() == null : this.getBirthday().equals(other.getBirthday()))
&& (this.getCountry() == null ? other.getCountry() == null : this.getCountry().equals(other.getCountry()))
&& (this.getAddress() == null ? other.getAddress() == null : this.getAddress().equals(other.getAddress()))
&& (this.getRole() == null ? other.getRole() == null : this.getRole().equals(other.getRole()))
&& (this.getLevel() == null ? other.getLevel() == null : this.getLevel().equals(other.getLevel()))
&& (this.getGender() == null ? other.getGender() == null : this.getGender().equals(other.getGender()))
&& (this.getDeleted() == null ? other.getDeleted() == null : this.getDeleted().equals(other.getDeleted()))
&& (this.getCreateTime() == null ? other.getCreateTime() == null : this.getCreateTime().equals(other.getCreateTime()))
&& (this.getUpdateTime() == null ? other.getUpdateTime() == null : this.getUpdateTime().equals(other.getUpdateTime()));
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((getId() == null) ? 0 : getId().hashCode());
result = prime * result + ((getAccount() == null) ? 0 : getAccount().hashCode());
result = prime * result + ((getWxUnion() == null) ? 0 : getWxUnion().hashCode());
result = prime * result + ((getMpOpen() == null) ? 0 : getMpOpen().hashCode());
result = prime * result + ((getEmail() == null) ? 0 : getEmail().hashCode());
result = prime * result + ((getPhone() == null) ? 0 : getPhone().hashCode());
result = prime * result + ((getIdent() == null) ? 0 : getIdent().hashCode());
result = prime * result + ((getPasswd() == null) ? 0 : getPasswd().hashCode());
result = prime * result + ((getAvatar() == null) ? 0 : getAvatar().hashCode());
result = prime * result + ((getTags() == null) ? 0 : getTags().hashCode());
result = prime * result + ((getNick() == null) ? 0 : getNick().hashCode());
result = prime * result + ((getName() == null) ? 0 : getName().hashCode());
result = prime * result + ((getProfile() == null) ? 0 : getProfile().hashCode());
result = prime * result + ((getBirthday() == null) ? 0 : getBirthday().hashCode());
result = prime * result + ((getCountry() == null) ? 0 : getCountry().hashCode());
result = prime * result + ((getAddress() == null) ? 0 : getAddress().hashCode());
result = prime * result + ((getRole() == null) ? 0 : getRole().hashCode());
result = prime * result + ((getLevel() == null) ? 0 : getLevel().hashCode());
result = prime * result + ((getGender() == null) ? 0 : getGender().hashCode());
result = prime * result + ((getDeleted() == null) ? 0 : getDeleted().hashCode());
result = prime * result + ((getCreateTime() == null) ? 0 : getCreateTime().hashCode());
result = prime * result + ((getUpdateTime() == null) ? 0 : getUpdateTime().hashCode());
return result;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(getClass().getSimpleName());
sb.append(" [");
sb.append("Hash = ").append(hashCode());
sb.append(", id=").append(id);
sb.append(", account=").append(account);
sb.append(", wxUnion=").append(wxUnion);
sb.append(", mpOpen=").append(mpOpen);
sb.append(", email=").append(email);
sb.append(", phone=").append(phone);
sb.append(", ident=").append(ident);
sb.append(", passwd=").append(passwd);
sb.append(", avatar=").append(avatar);
sb.append(", tags=").append(tags);
sb.append(", nick=").append(nick);
sb.append(", name=").append(name);
sb.append(", profile=").append(profile);
sb.append(", birthday=").append(birthday);
sb.append(", country=").append(country);
sb.append(", address=").append(address);
sb.append(", role=").append(role);
sb.append(", level=").append(level);
sb.append(", gender=").append(gender);
sb.append(", deleted=").append(deleted);
sb.append(", createTime=").append(createTime);
sb.append(", updateTime=").append(updateTime);
sb.append(", serialVersionUID=").append(serialVersionUID);
sb.append("]");
return sb.toString();
}
}package cn.com.edtechhub.workusercentre.model.entity;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
/**
* 用户索引
*/
@Document(indexName = "user")
@Data
@Slf4j
public class UserEs implements Serializable {
private static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
/**
* 本用户唯一标识(业务层需要考虑使用雪花算法用户标识的唯一性)
*/
@Id
private Long id;
/**
* 账户号(业务层需要决定某一种或多种登录方式, 因此这里不限死为非空)
*/
@Field(name = "account") // 显式指定 ES 字段名, 避免字段风格不一致
private String account;
/**
* 微信号
*/
@Field(name = "wx_union") // 显式指定 ES 字段名, 避免字段风格不一致
private String wxUnion;
/**
* 公众号
*/
@Field(name = "mp_open") // 显式指定 ES 字段名, 避免字段风格不一致
private String mpOpen;
/**
* 邮箱号
*/
@Field(name = "email") // 显式指定 ES 字段名, 避免字段风格不一致
private String email;
/**
* 电话号
*/
@Field(name = "phone") // 显式指定 ES 字段名, 避免字段风格不一致
private String phone;
/**
* 身份证
*/
@Field(name = "ident") // 显式指定 ES 字段名, 避免字段风格不一致
private String ident;
/**
* 用户密码(业务层强制刚刚注册的用户重新设置密码, 交给用户时默认密码为 123456, 并且加盐密码)
*/
@Field(name = "passwd") // 显式指定 ES 字段名, 避免字段风格不一致
private String passwd;
/**
* 用户头像(业务层需要考虑默认头像使用 cos 对象存储)
*/
@Field(name = "avatar") // 显式指定 ES 字段名, 避免字段风格不一致
private String avatar;
/**
* 用户标签(业务层需要 json 数组格式存储用户标签数组)
*/
@Field(name = "tags") // 显式指定 ES 字段名, 避免字段风格不一致
private List<String> tags; // 修改以支持数组查询
/**
* 用户昵称
*/
@Field(name = "nick") // 显式指定 ES 字段名, 避免字段风格不一致
private String nick;
/**
* 用户名字
*/
@Field(name = "name") // 显式指定 ES 字段名, 避免字段风格不一致
private String name;
/**
* 用户简介
*/
@Field(name = "profile") // 显式指定 ES 字段名, 避免字段风格不一致
private String profile;
/**
* 用户生日
*/
@Field(name = "birthday") // 显式指定 ES 字段名, 避免字段风格不一致
private String birthday;
/**
* 用户国家
*/
@Field(name = "country") // 显式指定 ES 字段名, 避免字段风格不一致
private String country;
/**
* 用户地址
*/
@Field(name = "address") // 显式指定 ES 字段名, 避免字段风格不一致
private String address;
/**
* 用户角色(业务层需知 -1 为封号, 0 为用户, 1 为管理, ...)
*/
@Field(name = "role") // 显式指定 ES 字段名, 避免字段风格不一致
private Integer role;
/**
* 用户等级(业务层需知 0 为 level0, 1 为 level1, 2 为 level2, 3 为 level3, ...)
*/
@Field(name = "level") // 显式指定 ES 字段名, 避免字段风格不一致
private Integer level;
/**
* 用户性别(业务层需知 0 为未知, 1 为男性, 2 为女性)
*/
@Field(name = "gender") // 显式指定 ES 字段名, 避免字段风格不一致
private Integer gender;
/**
* 是否删除(0 为未删除, 1 为已删除)
*/
@Field(name = "deleted") // 显式指定 ES 字段名, 避免字段风格不一致
private Integer deleted; // 手动修改为逻辑删除
/**
* 创建时间(受时区影响)
*/
@Field(name = "create_time", type = FieldType.Date, format = {}, pattern = DATE_TIME_PATTERN)
// 显式指定 ES 字段名, 避免字段风格不一致
private Date createTime;
/**
* 更新时间(受时区影响)
*/
@Field(name = "update_time", type = FieldType.Date, format = {}, pattern = DATE_TIME_PATTERN)
// 显式指定 ES 字段名, 避免字段风格不一致
private Date updateTime;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
/**
* 对象转包装类
*/
public static UserEs EntityToMapping(User user) {
// 拷贝字段
if (user == null) {
return null;
}
UserEs userEs = new UserEs();
BeanUtils.copyProperties(user, userEs);
// 处理数组字段, 避免纯粹的 JSON 字符无法兼容 ES
String tags = user.getTags();
if (StringUtils.isNotBlank(tags)) {
userEs.setTags(JSONUtil.toList(tags, String.class)); // 快速把 json 数组字符转为数组
}
return userEs;
}
/**
* 包装类转对象
*/
public static User MappingToEntity(UserEs userEs) {
if (userEs == null) {
return null;
}
User user = new User();
BeanUtils.copyProperties(userEs, user);
// 处理数组字段, 避免纯粹的 JSON 字符无法兼容 ES
List<String> tagList = userEs.getTags();
if (CollUtil.isNotEmpty(tagList)) {
user.setTags(JSONUtil.toJsonStr(tagList)); // 快速把数组转为 json 数组字符
}
return user;
}
}然后定义接口服务。
public interface UserService extends IService<User> {
/**
* 用户查询服务(ES)
*/
List<User> userSearchEs(UserSearchRequest userSearchRequest);
}@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
private NativeSearchQuery getQueryWrapperEs(UserSearchRequest userSearchRequest) {
// 获取参数
Long id = userSearchRequest.getId();
String account = userSearchRequest.getAccount();
String tags = userSearchRequest.getTags();
String nick = userSearchRequest.getNick();
String name = userSearchRequest.getName();
String profile = userSearchRequest.getProfile();
String address = userSearchRequest.getAddress();
int pageCurrent = userSearchRequest.getPageCurrent() - 1; // 这里需要减 1 以适配 ES 的分页
int pageSize = userSearchRequest.getPageSize();
String sortField = userSearchRequest.getSortField();
String sortOrder = userSearchRequest.getSortOrder();
List<String> tagsList = JSONUtil.toList(tags, String.class); // 一行代码转换
// 构造查询条件
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
// 过滤
boolQueryBuilder.filter(QueryBuilders.termQuery("deleted", 0));
if (id != null) {
boolQueryBuilder.filter(QueryBuilders.termQuery("id", id));
}
// 查询
if (StringUtils.isNotBlank(account)) {
boolQueryBuilder.filter(QueryBuilders.matchQuery("account", account));
}
if (CollUtil.isNotEmpty(tagsList)) {
for (String tag : tagsList) {
boolQueryBuilder.filter(QueryBuilders.termQuery("tags", tag));
}
}
if (StringUtils.isNotBlank(nick)) {
boolQueryBuilder.filter(QueryBuilders.matchQuery("nick", nick));
}
if (StringUtils.isNotBlank(name)) {
boolQueryBuilder.filter(QueryBuilders.matchQuery("name", name));
}
if (StringUtils.isNotBlank(profile)) {
boolQueryBuilder.filter(QueryBuilders.matchQuery("profile", profile));
}
if (StringUtils.isNotBlank(address)) {
boolQueryBuilder.filter(QueryBuilders.matchQuery("address", address));
}
// 分页
PageRequest pageRequest = PageRequest.of(pageCurrent, pageSize);
// 构造查询
return new NativeSearchQueryBuilder()
.withQuery(boolQueryBuilder)
.withPageable(pageRequest)
.build();
}
}最后就是控制层。
@RestController // 返回值默认为 json 类型
@RequestMapping("/user")
@Slf4j
public class UserController {
/**
* 查询用户网络接口
*/
@SaCheckLogin
@SaCheckRole("admin")
@PostMapping("/search/es")
@SentinelResource(value = "userSearchEs")
public BaseResponse<List<UserVO>> userSearchEs(@RequestBody UserSearchRequest userSearchRequest) {
List<User> userList = userService.userSearchEs(userSearchRequest);
List<UserVO> userVoList = userList.stream()
.map(UserVO::removeSensitiveData)
.collect(Collectors.toList());
return TheResult.success(CodeBindMessage.SUCCESS, userVoList);
}
}待补充...
Nacos
1.Nacos 的全面概述
Nacos 是 Dynamic Naming and Configuration Service 的首字母简称,一个更易于构建云原生应用的动态服务发现、配置管理和服务管理平台。它提供了一组简单易用的特性集,帮助我们快速实现动态服务发现、服务配置、服务元数据及流量管理。实际上,Nacos 不仅支持配置管理,它还支持服务发现(作为注册中心),以下是官网总结的 Nacos 地图,而本文档只使用它的配置管理功能。

2.Nacos 的基本功能
要学会 Nacos 配置管理,就先要知道几个核心概念:
- Namespace 命名空间:命名空间用于隔离不同的配置集。它允许在同一个
Nacos集群中将不同的环境(如开发、测试、生产)或者不同的业务线的配置进行隔离(默认提供了一个public命名空间)。在多租户系统中,或者需要区分不同的环境时,可以使用命名空间。例如,开发环境的配置和生产环境的配置完全隔离,可以通过不同的命名空间来管理。 - Group 组:配置组是用于将多个相关的配置项进行分类管理的逻辑分组机制。每个配置项可以属于不同的组,以便于配置管理。 当一个应用有多个模块,且不同模块之间共享部分配置时,可以用组来对这些模块的配置进行分类和管理。例如,一个系统中的“支付服务”和“订单服务”可能需要用不同的组来存储各自的配置。
- Data ID 标识:
Data ID是一个唯一的配置标识符,通常与具体的应用程序相关。通过Data ID,Nacos知道如何获取特定应用的某个具体配置。 每个应用的配置都会有一个独特的Data ID。例如,一个支付系统可能有一个配置文件叫com.payment.pay-service.yaml,这就是它的Data ID。 - Config Listener 配置监听器:配置监听器用于让客户端实时监听
Nacos配置中心中的配置变化,可以自动感知配置的更新并做出相应的处理。在需要动态调整配置的场景下使用,例如调整缓存大小、切换不同的服务端点等,应用可以通过监听器及时感知这些变化并应用新的配置。
3.Nacos 的使用教程
3.1.组件部署
推送配置的方法一般有以下方式:
而监听方法一般是使用 SDK 配置 Config Listener 。
String serverAddr = "{serverAddr}";
String dataId = "{dataId}";
String group = "{group}";
Properties properties = new Properties();
properties.put("serverAddr", serverAddr);
ConfigService configService = NacosFactory.createConfigService(properties);
String content = configService.getConfig(dataId, group, 5000);
System.out.println(content);
configService.addListener(dataId, group, new Listener() {
@Override
public void receiveConfigInfo(String configInfo) {
System.out.println("recieve1:" + configInfo);
}
@Override
public Executor getExecutor() {
return null;
}
});
// 测试让主线程不退出,因为订阅配置是守护线程,主线程退出守护线程就会退出, 正式代码中无需下面代码
while (true) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}或者直接通过注解读取 value,也能够实时获取到最新的配置值:
@Controller
@RequestMapping("config")
public class ConfigController {
@NacosValue(value = "${useLocalCache:false}", autoRefreshed = true)
private boolean useLocalCache;
@RequestMapping(value = "/get", method = GET)
@ResponseBody
public boolean get() {
return useLocalCache;
}
}对 Nacos 有初步了解后,下面我们在 work-user-centre 中实现基于 Nacos 实现静态 IP 黑名单需求。首先您需要部署好 Nacos 控制台,我们使用控制台推送配置,注解监听配置的方案。参考下面的 docker-compose 来快速使用 Docker 部署,或者 参考官方部署文档。
services:
work-nacos:
image: nacos/nacos-server:v2.5.1
container_name: work-nacos
restart: always
ports:
- "8848:8848"
- "9848:9848"
environment:
MODE: standalone # 单机部署模式
NACOS_AUTH_TOKEN: au3y/JinInzSQu5hxaQQSiyvN3kMcfgOLcFRA4AHrUE= # 使用 openssl rand -base64 32 生成
NACOS_AUTH_IDENTITY_KEY: username
NACOS_AUTH_IDENTITY_VALUE: root
NACOS_AUTH_ENABLE: true # 开启权限系统
networks:
- work-network访问 http://127.0.0.8848 就可以访问 Nacos。
3.2.快速使用
然后定义我们的 IP 限制配置文件,命名空间使用默认的即可,Data ID 填写为项目的名称 workusercentre,Group 默认为 DEFAULT_GROUP 即可。


接下来我们配置在 Spring Boot2.7.2 中的监听配置。
<dependency>Elasticsearch 入门
可参考 编程导航 - 聚合搜索项目 的笔记,该项目系统讲解过 Elasticsearch。
1、什么是 Elasticsearch?
Elasticsearch 是一个分布式、开源的搜索引擎,专门用于处理大规模的数据搜索和分析。它基于 Apache Lucene 构建,具有实时搜索、分布式计算和高可扩展性,广泛用于 全文检索、日志分析、监控数据分析 等场景。
官方文档:https://www.elastic.co/docs,建议入门后阅读一遍,了解更多它的特性。
2、Elasticsearch 生态
Elasticsearch 生态系统非常丰富,包含了一系列工具和功能,帮助用户处理、分析和可视化数据,Elastic Stack 是其核心组成部分。
Elastic Stack(也称为 ELK Stack)由以下几部分组成:
Elasticsearch:核心搜索引擎,负责存储、索引和搜索数据。
Kibana:可视化平台,用于查询、分析和展示 Elasticsearch 中的数据。
Logstash:数据处理管道,负责数据收集、过滤、增强和传输到 Elasticsearch。
Beats:轻量级的数据传输工具,收集和发送数据到 Logstash 或 Elasticsearch。
Kibana 是 Elastic Stack 的可视化组件,允许用户通过图表、地图和仪表盘来展示存储在 Elasticsearch 中的数据。它提供了简单的查询接口、数据分析和实时监控功能。
img
Logstash 是一个强大的数据收集管道工具,能够从多个来源收集、过滤、转换数据,然后将数据发送到 Elasticsearch。Logstash 支持丰富的输入、过滤和输出插件。
img
Beats 是一组轻量级的数据采集代理,负责从不同来源收集数据并发送到 Elasticsearch 或 Logstash。常见的 Beats 包括:
Filebeat:收集日志文件。
Metricbeat:收集系统和服务的指标。
Packetbeat:监控网络流量。
img
上面这张图,也是标准的 Elastic Stack 技术栈的交互图。
3、Elasticsearch 的核心概念
索引(Index):类似于关系型数据库中的表,索引是数据存储和搜索的 基本单位。每个索引可以存储多条文档数据。
文档(Document):索引中的每条记录,类似于数据库中的行。文档以 JSON 格式存储。
字段(Field):文档中的每个键值对,类似于数据库中的列。
映射(Mapping):用于定义 Elasticsearch 索引中文档字段的数据类型及其处理方式,类似于关系型数据库中的 Schema 表结构,帮助控制字段的存储、索引和查询行为。
集群(Cluster):多个节点组成的群集,用于存储数据并提供搜索功能。集群中的每个节点都可以处理数据。
分片(Shard):为了实现横向扩展,ES 将索引拆分成多个分片,每个分片可以分布在不同节点上。
副本(Replica):分片的复制品,用于提高可用性和容错性。
img
和数据库类比:
Elasticsearch 概念 关系型数据库类比
Index Table
Document Row
Field Column
Mapping Schema
Shard Partition
Replica Backup
4、Elasticsearch 实现全文检索的原理
1)分词:Elasticsearch 的分词器会将输入文本拆解成独立的词条(tokens),方便进行索引和搜索。分词的具体过程包括以下几步:
字符过滤:去除特殊字符、HTML 标签或进行其他文本清理。
分词:根据指定的分词器(analyzer),将文本按规则拆分成一个个词条。例如,英文可以按空格拆分,中文使用专门的分词器处理。
词汇过滤:对分词结果进行过滤,如去掉停用词(常见但无意义的词,如 "the"、"is" 等)或进行词形归并(如将动词变为原形)。
Elasticsearch 内置了很多分词器,比如按照空格分词等,默认只支持英文,可以在 官方文档 了解。
2)倒排索引:
倒排索引是 Elasticsearch 实现高效搜索的核心数据结构。它将文档中的词条映射到文档 ID,实现快速查找。
工作原理:
每个文档在被索引时,分词器会将文档内容拆解为多个词条。
然后,Elasticsearch 为每个词条生成一个倒排索引,记录该词条在哪些文档中出现。
举个例子,假设有两个文档:
文档 1:鱼皮是帅锅
文档 2:鱼皮是好人
中文分词后,生成的倒排索引大致如下:
词条 文档 ID
鱼皮 1, 2
是 1, 2
帅锅 1
好人 2
通过这种结构,查询某个词时,可以快速找到包含该词的所有文档。
5、Elasticsearch 打分规则
实际应用 Elasticsearch 来实现搜索功能时,我们不仅要求能搜到内容,而且还要把和用户搜索最相关的内容展示在前面。这就需要我们了解 Elasticsearch 的打分规则。
打分规则(_Score)是用于衡量每个文档与查询条件的匹配度的评分机制。搜索结果的默认排序方式是按相关性得分(_score)从高到低。Elasticsearch 使用 BM25 算法 来计算每个文档的得分,它是基于词频、反向文档频率、文档长度等因素来评估文档和查询的相关性。
打分的主要因素:
词频(TF, Term Frequency):查询词在文档中出现的次数,出现次数越多,得分越高。
反向文档频率(IDF, Inverse Document Frequency):查询词在所有文档中出现的频率。词在越少的文档中出现,IDF 值越高,得分越高。
文档长度:较短的文档往往被认为更相关,因为查询词在短文档中占的比例更大。
下面举一个例子:假设要在 Elasticsearch 中查询 鱼皮 这个关键词,索引中有以下三个文档:
文档 1:
▼
plain
复制代码
鱼皮是个帅小伙,鱼皮非常聪明,鱼皮很喜欢编程。
分析:
查询词 鱼皮 出现了 3 次。
该文档较短,查询词 鱼皮 的密度很高。
由于 鱼皮 在文档中多次出现且文档较短,因此得分较高,相关性较强。
文档 2:
▼
plain
复制代码
鱼皮是个帅小伙。
分析:
查询词 鱼皮 出现了 1 次。
文档非常短
尽管文档短,但是查询词出现的次数少,因此得分中等,相关性较普通。
文档 3:
▼
plain
复制代码
鱼皮是个帅小伙,他喜欢写代码。他的朋友们也很喜欢编程和技术讨论,大家经常一起参与各种技术会议,讨论分布式系统、机器学习和人工智能等主题。
分析:
查询词 鱼皮 出现了 1 次。
文档较长,且 鱼皮 只在文档开头出现,词条密度较低。
由于文档很长,鱼皮 出现的次数少,密度也低,因此得分较低,相关性不强。
再举个例子,什么是反向文档频率?
假如说 ES 中有 10 个文档,都包含了“鱼皮”这个关键词;只有 1 个文档包含了“帅锅”这个关键词。
现在用户搜索“鱼皮帅锅”,大概率会把后面这条文档搜出来,因为更稀有。
当然,以上只是简单举例,实际上 ES 计算打分规则时,会有一套较为复杂的公式,感兴趣的同学可以阅读下面资料来了解:
鱼皮文章:https://liyupi.blog.csdn.net/article/details/119176943
官方文章:https://www.elastic.co/guide/en/elasticsearch/guide/master/controlling-relevance.html
6、Elasticsearch 查询语法
Elasticsearch 支持多种查询语法,用于不同的场景和需求,主要包括查询 DSL、EQL、SQL 等。
1)DSL 查询(Domain Specific Language)
一种基于 JSON 的查询语言,它是 Elasticsearch 中最常用的查询方式。
示例:
▼
json
复制代码
{
"query": {
"match": {
"message": "Elasticsearch 是强大的"
}
}
}
这个查询会对 message 字段进行分词,并查找包含 "Elasticsearch" 和 "强大" 词条的文档。
2)EQL
EQL 全称 Event Query Language,是一种用于检测和检索时间序列 事件 的查询语言,常用于日志和安全监控场景。
示例:查找特定事件
▼
plain
复制代码
process where process.name == "malware.exe"
这个查询会查找 process.name 为 "malware.exe" 的所有进程事件,常用于安全检测中的恶意软件分析。
3)SQL 查询
Elasticsearch 提供了类似于传统数据库的 SQL 查询语法,允许用户以 SQL 的形式查询 Elasticsearch 中的数据,对熟悉 SQL 的用户来说非常方便。
示例 SQL 查询:
▼
sql
复制代码
SELECT name, age FROM users WHERE age > 30 ORDER BY age DESC
这个查询会返回 users 索引中 age 大于 30 的所有用户,并按年龄降序排序。
以下几种简单了解即可:
4)Lucene 查询语法
Lucene 是 Elasticsearch 底层的搜索引擎,Elasticsearch 支持直接使用 Lucene 的查询语法,适合简单的字符串查询。
示例 Lucene 查询:
▼
plain
复制代码
name:Elasticsearch AND age:[30 TO 40]
这个查询会查找 name 字段为 "Elasticsearch" 且 age 在 30 到 40 之间的文档。
5)Kuery(KQL: Kibana Query Language)
KQL 是 Kibana 的查询语言,专门用于在 Kibana 界面上执行搜索查询,常用于仪表盘和数据探索中。
示例 KQL 查询:
▼
plain
复制代码
name: "Elasticsearch" and age > 30
这个查询会查找 name 为 "Elasticsearch" 且 age 大于 30 的文档。
6)Painless 脚本查询
Painless 是 Elasticsearch 的内置脚本语言,用于执行自定义的脚本操作,常用于排序、聚合或复杂计算场景。
示例 Painless 脚本:
▼
json
复制代码
{
"query": {
"script_score": {
"query": {
"match": { "message": "Elasticsearch" }
},
"script": {
"source": "doc['popularity'].value * 2"
}
}
}
}
这个查询会基于 populElasticsearch 入门
可参考 编程导航 - 聚合搜索项目 的笔记,该项目系统讲解过 Elasticsearch。
1、什么是 Elasticsearch?
Elasticsearch 是一个分布式、开源的搜索引擎,专门用于处理大规模的数据搜索和分析。它基于 Apache Lucene 构建,具有实时搜索、分布式计算和高可扩展性,广泛用于 全文检索、日志分析、监控数据分析 等场景。
官方文档:https://www.elastic.co/docs,建议入门后阅读一遍,了解更多它的特性。
2、Elasticsearch 生态
Elasticsearch 生态系统非常丰富,包含了一系列工具和功能,帮助用户处理、分析和可视化数据,Elastic Stack 是其核心组成部分。
Elastic Stack(也称为 ELK Stack)由以下几部分组成:
Elasticsearch:核心搜索引擎,负责存储、索引和搜索数据。
Kibana:可视化平台,用于查询、分析和展示 Elasticsearch 中的数据。
Logstash:数据处理管道,负责数据收集、过滤、增强和传输到 Elasticsearch。
Beats:轻量级的数据传输工具,收集和发送数据到 Logstash 或 Elasticsearch。
Kibana 是 Elastic Stack 的可视化组件,允许用户通过图表、地图和仪表盘来展示存储在 Elasticsearch 中的数据。它提供了简单的查询接口、数据分析和实时监控功能。
img
Logstash 是一个强大的数据收集管道工具,能够从多个来源收集、过滤、转换数据,然后将数据发送到 Elasticsearch。Logstash 支持丰富的输入、过滤和输出插件。
img
Beats 是一组轻量级的数据采集代理,负责从不同来源收集数据并发送到 Elasticsearch 或 Logstash。常见的 Beats 包括:
Filebeat:收集日志文件。
Metricbeat:收集系统和服务的指标。
Packetbeat:监控网络流量。
img
上面这张图,也是标准的 Elastic Stack 技术栈的交互图。
3、Elasticsearch 的核心概念
索引(Index):类似于关系型数据库中的表,索引是数据存储和搜索的 基本单位。每个索引可以存储多条文档数据。
文档(Document):索引中的每条记录,类似于数据库中的行。文档以 JSON 格式存储。
字段(Field):文档中的每个键值对,类似于数据库中的列。
映射(Mapping):用于定义 Elasticsearch 索引中文档字段的数据类型及其处理方式,类似于关系型数据库中的 Schema 表结构,帮助控制字段的存储、索引和查询行为。
集群(Cluster):多个节点组成的群集,用于存储数据并提供搜索功能。集群中的每个节点都可以处理数据。
分片(Shard):为了实现横向扩展,ES 将索引拆分成多个分片,每个分片可以分布在不同节点上。
副本(Replica):分片的复制品,用于提高可用性和容错性。
img
和数据库类比:
Elasticsearch 概念 关系型数据库类比
Index Table
Document Row
Field Column
Mapping Schema
Shard Partition
Replica Backup
4、Elasticsearch 实现全文检索的原理
1)分词:Elasticsearch 的分词器会将输入文本拆解成独立的词条(tokens),方便进行索引和搜索。分词的具体过程包括以下几步:
字符过滤:去除特殊字符、HTML 标签或进行其他文本清理。
分词:根据指定的分词器(analyzer),将文本按规则拆分成一个个词条。例如,英文可以按空格拆分,中文使用专门的分词器处理。
词汇过滤:对分词结果进行过滤,如去掉停用词(常见但无意义的词,如 "the"、"is" 等)或进行词形归并(如将动词变为原形)。
Elasticsearch 内置了很多分词器,比如按照空格分词等,默认只支持英文,可以在 官方文档 了解。
2)倒排索引:
倒排索引是 Elasticsearch 实现高效搜索的核心数据结构。它将文档中的词条映射到文档 ID,实现快速查找。
工作原理:
每个文档在被索引时,分词器会将文档内容拆解为多个词条。
然后,Elasticsearch 为每个词条生成一个倒排索引,记录该词条在哪些文档中出现。
举个例子,假设有两个文档:
文档 1:鱼皮是帅锅
文档 2:鱼皮是好人
中文分词后,生成的倒排索引大致如下:
词条 文档 ID
鱼皮 1, 2
是 1, 2
帅锅 1
好人 2
通过这种结构,查询某个词时,可以快速找到包含该词的所有文档。
5、Elasticsearch 打分规则
实际应用 Elasticsearch 来实现搜索功能时,我们不仅要求能搜到内容,而且还要把和用户搜索最相关的内容展示在前面。这就需要我们了解 Elasticsearch 的打分规则。
打分规则(_Score)是用于衡量每个文档与查询条件的匹配度的评分机制。搜索结果的默认排序方式是按相关性得分(_score)从高到低。Elasticsearch 使用 BM25 算法 来计算每个文档的得分,它是基于词频、反向文档频率、文档长度等因素来评估文档和查询的相关性。
打分的主要因素:
词频(TF, Term Frequency):查询词在文档中出现的次数,出现次数越多,得分越高。
反向文档频率(IDF, Inverse Document Frequency):查询词在所有文档中出现的频率。词在越少的文档中出现,IDF 值越高,得分越高。
文档长度:较短的文档往往被认为更相关,因为查询词在短文档中占的比例更大。
下面举一个例子:假设要在 Elasticsearch 中查询 鱼皮 这个关键词,索引中有以下三个文档:
文档 1:
▼
plain
复制代码
鱼皮是个帅小伙,鱼皮非常聪明,鱼皮很喜欢编程。
分析:
查询词 鱼皮 出现了 3 次。
该文档较短,查询词 鱼皮 的密度很高。
由于 鱼皮 在文档中多次出现且文档较短,因此得分较高,相关性较强。
文档 2:
▼
plain
复制代码
鱼皮是个帅小伙。
分析:
查询词 鱼皮 出现了 1 次。
文档非常短
尽管文档短,但是查询词出现的次数少,因此得分中等,相关性较普通。
文档 3:
▼
plain
复制代码
鱼皮是个帅小伙,他喜欢写代码。他的朋友们也很喜欢编程和技术讨论,大家经常一起参与各种技术会议,讨论分布式系统、机器学习和人工智能等主题。
分析:
查询词 鱼皮 出现了 1 次。
文档较长,且 鱼皮 只在文档开头出现,词条密度较低。
由于文档很长,鱼皮 出现的次数少,密度也低,因此得分较低,相关性不强。
再举个例子,什么是反向文档频率?
假如说 ES 中有 10 个文档,都包含了“鱼皮”这个关键词;只有 1 个文档包含了“帅锅”这个关键词。
现在用户搜索“鱼皮帅锅”,大概率会把后面这条文档搜出来,因为更稀有。
当然,以上只是简单举例,实际上 ES 计算打分规则时,会有一套较为复杂的公式,感兴趣的同学可以阅读下面资料来了解:
鱼皮文章:https://liyupi.blog.csdn.net/article/details/119176943
官方文章:https://www.elastic.co/guide/en/elasticsearch/guide/master/controlling-relevance.html
6、Elasticsearch 查询语法
Elasticsearch 支持多种查询语法,用于不同的场景和需求,主要包括查询 DSL、EQL、SQL 等。
1)DSL 查询(Domain Specific Language)
一种基于 JSON 的查询语言,它是 Elasticsearch 中最常用的查询方式。
示例:
▼
json
复制代码
{
"query": {
"match": {
"message": "Elasticsearch 是强大的"
}
}
}
这个查询会对 message 字段进行分词,并查找包含 "Elasticsearch" 和 "强大" 词条的文档。
2)EQL
EQL 全称 Event Query Language,是一种用于检测和检索时间序列 事件 的查询语言,常用于日志和安全监控场景。
示例:查找特定事件
▼
plain
复制代码
process where process.name == "malware.exe"
这个查询会查找 process.name 为 "malware.exe" 的所有进程事件,常用于安全检测中的恶意软件分析。
3)SQL 查询
Elasticsearch 提供了类似于传统数据库的 SQL 查询语法,允许用户以 SQL 的形式查询 Elasticsearch 中的数据,对熟悉 SQL 的用户来说非常方便。
示例 SQL 查询:
▼
sql
复制代码
SELECT name, age FROM users WHERE age > 30 ORDER BY age DESC
这个查询会返回 users 索引中 age 大于 30 的所有用户,并按年龄降序排序。
以下几种简单了解即可:
4)Lucene 查询语法
Lucene 是 Elasticsearch 底层的搜索引擎,Elasticsearch 支持直接使用 Lucene 的查询语法,适合简单的字符串查询。
示例 Lucene 查询:
▼
plain
复制代码
name:Elasticsearch AND age:[30 TO 40]
这个查询会查找 name 字段为 "Elasticsearch" 且 age 在 30 到 40 之间的文档。
5)Kuery(KQL: Kibana Query Language)
KQL 是 Kibana 的查询语言,专门用于在 Kibana 界面上执行搜索查询,常用于仪表盘和数据探索中。
示例 KQL 查询:
▼
plain
复制代码
name: "Elasticsearch" and age > 30
这个查询会查找 name 为 "Elasticsearch" 且 age 大于 30 的文档。
6)Painless 脚本查询
Painless 是 Elasticsearch 的内置脚本语言,用于执行自定义的脚本操作,常用于排序、聚合或复杂计算场景。
示例 Painless 脚本:
▼
json
复制代码
{
"query": {
"script_score": {
"query": {
"match": { "message": "Elasticsearch" }
},
"script": {
"source": "doc['popularity'].value * 2"
}
}
}
}
这个查询会基于 popularity 字段的值进行动态评分,将其乘以 2。
总结一下,DSL 是最通用的,EQL 和 KQL 则适用于特定场景,如日志分析和 Kibana 查询,而 SQL 则便于数据库开发人员上手。
7、Elasticsearch 查询条件
如何利用 Elasticsearch 实现数据筛选呢?需要了解其查询条件,以 ES 的 DSL 语法为例:
查询条件 介绍 示例 用途
match 用于全文检索,将查询字符串进行分词并匹配文档中对应的字段。 { "match": { "content": "鱼皮是帅小伙" } } 适用于全文检索,分词后匹配文档内容。
term 精确匹配查询,不进行分词。通常用于结构化数据的精确匹配,如数字、日期、关键词等。 { "term": { "status": "active" } } 适用于字段的精确匹配,如状态、ID、布尔值等。
terms 匹配多个值中的任意一个,相当于多个 term 查询的组合。 { "terms": { "status": ["active", "pending"] } } 适用于多值匹配的场景。
range 范围查询,常用于数字、日期字段,支持大于、小于、区间等查询。 { "range": { "age": { "gte": 18, "lte": 30 } } } 适用于数值或日期的范围查询。
bool 组合查询,通过 must、should、must_not 等组合多个查询条件。 { "bool": { "must": [ { "term": { "status": "active" } }, { "range": { "age": { "gte": 18 } } } ] } } 适用于复杂的多条件查询,可以灵活组合。
wildcard 通配符查询,支持 * 和 ?,前者匹配任意字符,后者匹配单个字符。 { "wildcard": { "name": "鱼*" } } 适用于部分匹配的查询,如模糊搜索。
prefix 前缀查询,匹配以指定前缀开头的字段内容。 { "prefix": { "name": "鱼" } } 适用于查找以指定字符串开头的内容。
fuzzy 模糊查询,允许指定程度的拼写错误或字符替换。 { "fuzzy": { "name": "yupi~2" } } 适用于处理拼写错误或不完全匹配的查询。
exists 查询某字段是否存在。 { "exists": { "field": "name" } } 适用于查找字段存在或缺失的文档。
match_phrase 短语匹配查询,要求查询的词语按顺序完全匹配。 { "match_phrase": { "content": "鱼皮 帅小伙" } } 适用于严格的短语匹配,词语顺序和距离都严格控制。
match_all 匹配所有文档。 { "match_all": {} } 适用于查询所有文档,通常与分页配合使用。
ids 基于文档 ID 查询,支持查询特定 ID 的文档。 { "ids": { "values": ["1", "2", "3"] } } 适用于根据文档 ID 查找特定文档。
geo_distance 地理位置查询,基于地理坐标和指定距离查询。 { "geo_distance": { "distance": "12km", "location": { "lat": 40.73, "lon": -74.1 } } } 适用于根据距离计算查找地理位置附近的文档。
aggregations 聚合查询,用于统计、计算和分组查询,类似 SQL 中的 GROUP BY。 { "aggs": { "age_stats": { "stats": { "field": "age" } } } } 适用于统计和分析数据,比如求和、平均值、最大值等。
其中的几个关键:
精确匹配 vs. 全文检索:term 是精确匹配,不分词;match 用于全文检索,会对查询词进行分词。
组合查询:bool 查询可以灵活组合多个条件,适用于复杂的查询需求。
模糊查询:fuzzy 和 wildcard 提供了灵活的模糊匹配方式,适用于拼写错误或不完全匹配的场景。
了解上面这些一般就足够了,更多可以随用随查,参考 官方文档 。
8、Elasticsearch 客户端
前面了解了 Elasticsearch 的概念和查询语法,但是如何执行 Elasticsearch 操作呢?还需要了解下 ES 的客户端,列举一些常用的:
1)HTTP API:Elasticsearch 提供了 RESTful HTTP API,用户可以通过直接发送 HTTP 请求来执行索引、搜索和管理集群的操作。官方文档
2)Kibana:Kibana 是 Elasticsearch 官方提供的可视化工具,用户可以通过 Kibana 控制台使用查询语法(如 DSL、KQL)来执行搜索、分析和数据可视化。
3)Java REST Client:Elasticsearch 官方提供的 Java 高级 REST 客户端库,用于 Java 程序中与 Elasticsearch 进行通信,支持索引、查询、集群管理等操作。官方文档
4)Spring Data Elasticsearch:Spring 全家桶的一员,用于将 Elasticsearch 与 Spring 框架集成,通过简化的 Repository 方式进行索引、查询和数据管理操作。官方文档
5)Elasticsearch SQL CLI:命令行工具,允许通过类 SQL 语法直接在命令行中查询 Elasticsearch 数据,适用于熟悉 SQL 的用户。
此外,Elasticsearch 当然不只有 Java 的客户端,Python、PHP、Node.js、Go 的客户端都是支持的。
💡 在选择客户端时,要格外注意版本号!!!要跟 Elasticsearch 的版本保持兼容。
9、ES 数据同步方案
一般情况下,如果做查询搜索功能,使用 ES 来模糊搜索,但是数据是存放在数据库 MySQL 里的,所以说我们需要把 MySQL 中的数据和 ES 进行同步,保证数据一致(以 MySQL 为主)。
数据流向:MySQL => ES (单向)
数据同步一般有 2 个过程:全量同步(首次)+ 增量同步(新数据)
总共有 4 种主流方案:
1)定时任务
比如 1 分钟 1 次,找到 MySQL 中过去几分钟内(至少是定时周期的 2 倍)发生改变的数据,然后更新到 ES。
优点:
简单易懂,开发、部署、维护相对容易。
占用资源少,不需要引入复杂的第三方中间件。
不用处理复杂的并发和实时性问题。
缺点:
有时间差:无法做到实时同步,数据存在滞后。
数据频繁变化时,无法确保数据完全同步,容易出现错过更新的情况。
对大数据量的更新处理不够高效,可能会引入重复更新逻辑。
应用场景:
数据实时性要求不高:适合数据短时间内不同步不会带来重大影响的场景。
数据基本不发生修改:适合数据几乎不修改、修改不频繁的场景。
数据容忍丢失
2)双写
写数据的时候,必须也去写 ES;更新删除数据库同理。
可以通过事务保证数据一致性,使用事务时,要先保证 MySQL 写成功,因为如果 ES 写入失败了,不会触发回滚,但是可以通过定时任务 + 日志 + 告警进行检测和修复(补偿)。
优点:
方案简单易懂,业务逻辑直接控制数据同步。
可以利用事务部分保证 MySQL 和 ES 的数据一致性。
同步的时延较短,理论上可以接近实时更新 ES。
缺点:
影响性能:每次写 MySQL 时,需要同时操作 ES,增加了业务写入延迟,影响性能。
一致性问题:如果 ES 写入失败,MySQL 事务提交成功后,ES 可能会丢失数据;或者 ES 写入成功,MySQL 事务提交失败,ES 无法回滚。因此必须额外设计监控、补偿机制来检测同步失败的情况(如通过定时任务、日志和告警修复)。
代码复杂度增加,需要对每个写操作都进行双写处理。
应用场景:
实时性要求较高
业务写入频率较低:适合写操作不频繁的场景,这样对性能的影响较小。
3)用 Logstash 数据同步管道
一般要配合 kafka 消息队列 + beats 采集器:
img
优点:
配置驱动:基于配置文件,减少了手动编码,数据同步逻辑和业务代码解耦。
扩展性好:可以灵活引入 Kafka 等消息队列实现异步数据同步,并可处理高吞吐量数据。
支持多种数据源:Logstash 支持丰富的数据源,方便扩展其他同步需求。
缺点:
灵活性差:需要通过配置文件进行同步,复杂的业务逻辑可能难以在配置中实现,无法处理细粒度的定制化需求。
引入额外组件,维护成本高:通常需要引入 Kafka、Beats 等第三方组件,增加了系统的复杂性和运维成本。
应用场景:
大数据同步:适合大规模、分布式数据同步场景。
对实时性要求不高:适合数据流处理或延迟容忍较大的系统。
系统已有 Kafka 或类似的消息队列架构:如果系统中已经使用了 Kafka 等中间件,使用 Logstash 管道会变得很方便。
4)监听 MySQL Binlog
有任何数据变更时都能够实时监听到,并且同步到 Elasticsearch。一般不需要自己监听,可以使用现成的技术,比如 Canal 。
img
💡 Canal 的核心原理:数据库每次修改时,会修改 binlog 文件,只要监听该文件的修改,就能第一时间得到消息并处理
优点:
实时性强:能够在 MySQL 数据发生变更的第一时间同步到 ES,做到真正的实时同步。
轻量级:Binlog 是数据库自带的日志功能,不需要修改核心业务代码,只需要新增监听逻辑。
缺点:
引入外部依赖:需要引入像 Canal 这样的中间件,增加了系统的复杂性和维护成本。
运维难度增加:需要确保 Canal 或者其他 Binlog 监听器的稳定运行,并且对 MySQL 的 Binlog 配置要求较高。
一致性问题:如果 Canal 服务出现问题或暂停,数据可能会滞后或丢失,必须设计补偿机制。
应用场景:
实时同步要求高:适合需要实时数据同步的场景,通常用于高并发、高数据一致性要求的系统。
数据频繁变化:适合数据变更频繁且需要高效增量同步的场景。
最终方案:对于本项目,由于数据量不大,题目更新也不频繁,容忍丢失和不一致,所以选用方案一,实现成本最低。arity 字段的值进行动态评分,将其乘以 2。
总结一下,DSL 是最通用的,EQL 和 KQL 则适用于特定场景,如日志分析和 Kibana 查询,而 SQL 则便于数据库开发人员上手。
7、Elasticsearch 查询条件
如何利用 Elasticsearch 实现数据筛选呢?需要了解其查询条件,以 ES 的 DSL 语法为例:
查询条件 介绍 示例 用途
match 用于全文检索,将查询字符串进行分词并匹配文档中对应的字段。 { "match": { "content": "鱼皮是帅小伙" } } 适用于全文检索,分词后匹配文档内容。
term 精确匹配查询,不进行分词。通常用于结构化数据的精确匹配,如数字、日期、关键词等。 { "term": { "status": "active" } } 适用于字段的精确匹配,如状态、ID、布尔值等。
terms 匹配多个值中的任意一个,相当于多个 term 查询的组合。 { "terms": { "status": ["active", "pending"] } } 适用于多值匹配的场景。
range 范围查询,常用于数字、日期字段,支持大于、小于、区间等查询。 { "range": { "age": { "gte": 18, "lte": 30 } } } 适用于数值或日期的范围查询。
bool 组合查询,通过 must、should、must_not 等组合多个查询条件。 { "bool": { "must": [ { "term": { "status": "active" } }, { "range": { "age": { "gte": 18 } } } ] } } 适用于复杂的多条件查询,可以灵活组合。
wildcard 通配符查询,支持 * 和 ?,前者匹配任意字符,后者匹配单个字符。 { "wildcard": { "name": "鱼*" } } 适用于部分匹配的查询,如模糊搜索。
prefix 前缀查询,匹配以指定前缀开头的字段内容。 { "prefix": { "name": "鱼" } } 适用于查找以指定字符串开头的内容。
fuzzy 模糊查询,允许指定程度的拼写错误或字符替换。 { "fuzzy": { "name": "yupi~2" } } 适用于处理拼写错误或不完全匹配的查询。
exists 查询某字段是否存在。 { "exists": { "field": "name" } } 适用于查找字段存在或缺失的文档。
match_phrase 短语匹配查询,要求查询的词语按顺序完全匹配。 { "match_phrase": { "content": "鱼皮 帅小伙" } } 适用于严格的短语匹配,词语顺序和距离都严格控制。
match_all 匹配所有文档。 { "match_all": {} } 适用于查询所有文档,通常与分页配合使用。
ids 基于文档 ID 查询,支持查询特定 ID 的文档。 { "ids": { "values": ["1", "2", "3"] } } 适用于根据文档 ID 查找特定文档。
geo_distance 地理位置查询,基于地理坐标和指定距离查询。 { "geo_distance": { "distance": "12km", "location": { "lat": 40.73, "lon": -74.1 } } } 适用于根据距离计算查找地理位置附近的文档。
aggregations 聚合查询,用于统计、计算和分组查询,类似 SQL 中的 GROUP BY。 { "aggs": { "age_stats": { "stats": { "field": "age" } } } } 适用于统计和分析数据,比如求和、平均值、最大值等。
其中的几个关键:
精确匹配 vs. 全文检索:term 是精确匹配,不分词;match 用于全文检索,会对查询词进行分词。
组合查询:bool 查询可以灵活组合多个条件,适用于复杂的查询需求。
模糊查询:fuzzy 和 wildcard 提供了灵活的模糊匹配方式,适用于拼写错误或不完全匹配的场景。
了解上面这些一般就足够了,更多可以随用随查,参考 官方文档 。
8、Elasticsearch 客户端
前面了解了 Elasticsearch 的概念和查询语法,但是如何执行 Elasticsearch 操作呢?还需要了解下 ES 的客户端,列举一些常用的:
1)HTTP API:Elasticsearch 提供了 RESTful HTTP API,用户可以通过直接发送 HTTP 请求来执行索引、搜索和管理集群的操作。官方文档
2)Kibana:Kibana 是 Elasticsearch 官方提供的可视化工具,用户可以通过 Kibana 控制台使用查询语法(如 DSL、KQL)来执行搜索、分析和数据可视化。
3)Java REST Client:Elasticsearch 官方提供的 Java 高级 REST 客户端库,用于 Java 程序中与 Elasticsearch 进行通信,支持索引、查询、集群管理等操作。官方文档
4)Spring Data Elasticsearch:Spring 全家桶的一员,用于将 Elasticsearch 与 Spring 框架集成,通过简化的 Repository 方式进行索引、查询和数据管理操作。官方文档
5)Elasticsearch SQL CLI:命令行工具,允许通过类 SQL 语法直接在命令行中查询 Elasticsearch 数据,适用于熟悉 SQL 的用户。
此外,Elasticsearch 当然不只有 Java 的客户端,Python、PHP、Node.js、Go 的客户端都是支持的。
💡 在选择客户端时,要格外注意版本号!!!要跟 Elasticsearch 的版本保持兼容。
9、ES 数据同步方案
一般情况下,如果做查询搜索功能,使用 ES 来模糊搜索,但是数据是存放在数据库 MySQL 里的,所以说我们需要把 MySQL 中的数据和 ES 进行同步,保证数据一致(以 MySQL 为主)。
数据流向:MySQL => ES (单向)
数据同步一般有 2 个过程:全量同步(首次)+ 增量同步(新数据)
总共有 4 种主流方案:
1)定时任务
比如 1 分钟 1 次,找到 MySQL 中过去几分钟内(至少是定时周期的 2 倍)发生改变的数据,然后更新到 ES。
优点:
简单易懂,开发、部署、维护相对容易。
占用资源少,不需要引入复杂的第三方中间件。
不用处理复杂的并发和实时性问题。
缺点:
有时间差:无法做到实时同步,数据存在滞后。
数据频繁变化时,无法Elasticsearch 入门
可参考 编程导航 - 聚合搜索项目 的笔记,该项目系统讲解过 Elasticsearch。
1、什么是 Elasticsearch?
Elasticsearch 是一个分布式、开源的搜索引擎,专门用于处理大规模的数据搜索和分析。它基于 Apache Lucene 构建,具有实时搜索、分布式计算和高可扩展性,广泛用于 全文检索、日志分析、监控数据分析 等场景。
官方文档:https://www.elastic.co/docs,建议入门后阅读一遍,了解更多它的特性。
2、Elasticsearch 生态
Elasticsearch 生态系统非常丰富,包含了一系列工具和功能,帮助用户处理、分析和可视化数据,Elastic Stack 是其核心组成部分。
Elastic Stack(也称为 ELK Stack)由以下几部分组成:
Elasticsearch:核心搜索引擎,负责存储、索引和搜索数据。
Kibana:可视化平台,用于查询、分析和展示 Elasticsearch 中的数据。
Logstash:数据处理管道,负责数据收集、过滤、增强和传输到 Elasticsearch。
Beats:轻量级的数据传输工具,收集和发送数据到 Logstash 或 Elasticsearch。
Kibana 是 Elastic Stack 的可视化组件,允许用户通过图表、地图和仪表盘来展示存储在 Elasticsearch 中的数据。它提供了简单的查询接口、数据分析和实时监控功能。
img
Logstash 是一个强大的数据收集管道工具,能够从多个来源收集、过滤、转换数据,然后将数据发送到 Elasticsearch。Logstash 支持丰富的输入、过滤和输出插件。
img
Beats 是一组轻量级的数据采集代理,负责从不同来源收集数据并发送到 Elasticsearch 或 Logstash。常见的 Beats 包括:
Filebeat:收集日志文件。
Metricbeat:收集系统和服务的指标。
Packetbeat:监控网络流量。
img
上面这张图,也是标准的 Elastic Stack 技术栈的交互图。
3、Elasticsearch 的核心概念
索引(Index):类似于关系型数据库中的表,索引是数据存储和搜索的 基本单位。每个索引可以存储多条文档数据。
文档(Document):索引中的每条记录,类似于数据库中的行。文档以 JSON 格式存储。
字段(Field):文档中的每个键值对,类似于数据库中的列。
映射(Mapping):用于定义 Elasticsearch 索引中文档字段的数据类型及其处理方式,类似于关系型数据库中的 Schema 表结构,帮助控制字段的存储、索引和查询行为。
集群(Cluster):多个节点组成的群集,用于存储数据并提供搜索功能。集群中的每个节点都可以处理数据。
分片(Shard):为了实现横向扩展,ES 将索引拆分成多个分片,每个分片可以分布在不同节点上。
副本(Replica):分片的复制品,用于提高可用性和容错性。
img
和数据库类比:
Elasticsearch 概念 关系型数据库类比
Index Table
Document Row
Field Column
Mapping Schema
Shard Partition
Replica Backup
4、Elasticsearch 实现全文检索的原理
1)分词:Elasticsearch 的分词器会将输入文本拆解成独立的词条(tokens),方便进行索引和搜索。分词的具体过程包括以下几步:
字符过滤:去除特殊字符、HTML 标签或进行其他文本清理。
分词:根据指定的分词器(analyzer),将文本按规则拆分成一个个词条。例如,英文可以按空格拆分,中文使用专门的分词器处理。
词汇过滤:对分词结果进行过滤,如去掉停用词(常见但无意义的词,如 "the"、"is" 等)或进行词形归并(如将动词变为原形)。
Elasticsearch 内置了很多分词器,比如按照空格分词等,默认只支持英文,可以在 官方文档 了解。
2)倒排索引:
倒排索引是 Elasticsearch 实现高效搜索的核心数据结构。它将文档中的词条映射到文档 ID,实现快速查找。
工作原理:
每个文档在被索引时,分词器会将文档内容拆解为多个词条。
然后,Elasticsearch 为每个词条生成一个倒排索引,记录该词条在哪些文档中出现。
举个例子,假设有两个文档:
文档 1:鱼皮是帅锅
文档 2:鱼皮是好人
中文分词后,生成的倒排索引大致如下:
词条 文档 ID
鱼皮 1, 2
是 1, 2
帅锅 1
好人 2
通过这种结构,查询某个词时,可以快速找到包含该词的所有文档。
5、Elasticsearch 打分规则
实际应用 Elasticsearch 来实现搜索功能时,我们不仅要求能搜到内容,而且还要把和用户搜索最相关的内容展示在前面。这就需要我们了解 Elasticsearch 的打分规则。
打分规则(_Score)是用于衡量每个文档与查询条件的匹配度的评分机制。搜索结果的默认排序方式是按相关性得分(_score)从高到低。Elasticsearch 使用 BM25 算法 来计算每个文档的得分,它是基于词频、反向文档频率、文档长度等因素来评估文档和查询的相关性。
打分的主要因素:
词频(TF, Term Frequency):查询词在文档中出现的次数,出现次数越多,得分越高。
反向文档频率(IDF, Inverse Document Frequency):查询词在所有文档中出现的频率。词在越少的文档中出现,IDF 值越高,得分越高。
文档长度:较短的文档往往被认为更相关,因为查询词在短文档中占的比例更大。
下面举一个例子:假设要在 Elasticsearch 中查询 鱼皮 这个关键词,索引中有以下三个文档:
文档 1:
▼
plain
复制代码
鱼皮是个帅小伙,鱼皮非常聪明,鱼皮很喜欢编程。
分析:
查询词 鱼皮 出现了 3 次。
该文档较短,查询词 鱼皮 的密度很高。
由于 鱼皮 在文档中多次出现且文档较短,因此得分较高,相关性较强。
文档 2:
▼
plain
复制代码
鱼皮是个帅小伙。
分析:
查询词 鱼皮 出现了 1 次。
文档非常短
尽管文档短,但是查询词出现的次数少,因此得分中等,相关性较普通。
文档 3:
▼
plain
复制代码
鱼皮是个帅小伙,他喜欢写代码。他的朋友们也很喜欢编程和技术讨论,大家经常一起参与各种技术会议,讨论分布式系统、机器学习和人工智能等主题。
分析:
查询词 鱼皮 出现了 1 次。
文档较长,且 鱼皮 只在文档开头出现,词条密度较低。
由于文档很长,鱼皮 出现的次数少,密度也低,因此得分较低,相关性不强。
再举个例子,什么是反向文档频率?
假如说 ES 中有 10 个文档,都包含了“鱼皮”这个关键词;只有 1 个文档包含了“帅锅”这个关键词。
现在用户搜索“鱼皮帅锅”,大概率会把后面这条文档搜出来,因为更稀有。
当然,以上只是简单举例,实际上 ES 计算打分规则时,会有一套较为复杂的公式,感兴趣的同学可以阅读下面资料来了解:
鱼皮文章:https://liyupi.blog.csdn.net/article/details/119176943
官方文章:https://www.elastic.co/guide/en/elasticsearch/guide/master/controlling-relevance.html
6、Elasticsearch 查询语法
Elasticsearch 支持多种查询语法,用于不同的场景和需求,主要包括查询 DSL、EQL、SQL 等。
1)DSL 查询(Domain Specific Language)
一种基于 JSON 的查询语言,它是 Elasticsearch 中最常用的查询方式。
示例:
▼
json
复制代码
{
"query": {
"match": {
"message": "Elasticsearch 是强大的"
}
}
}
这个查询会对 message 字段进行分词,并查找包含 "Elasticsearch" 和 "强大" 词条的文档。
2)EQL
EQL 全称 Event Query Language,是一种用于检测和检索时间序列 事件 的查询语言,常用于日志和安全监控场景。
示例:查找特定事件
▼
plain
复制代码
process where process.name == "malware.exe"
这个查询会查找 process.name 为 "malware.exe" 的所有进程事件,常用于安全检测中的恶意软件分析。
3)SQL 查询
Elasticsearch 提供了类似于传统数据库的 SQL 查询语法,允许用户以 SQL 的形式查询 Elasticsearch 中的数据,对熟悉 SQL 的用户来说非常方便。
示例 SQL 查询:
▼
sql
复制代码
SELECT name, age FROM users WHERE age > 30 ORDER BY age DESC
这个查询会返回 users 索引中 age 大于 30 的所有用户,并按年龄降序排序。
以下几种简单了解即可:
4)Lucene 查询语法
Lucene 是 Elasticsearch 底层的搜索引擎,Elasticsearch 支持直接使用 Lucene 的查询语法,适合简单的字符串查询。
示例 Lucene 查询:
▼
plain
复制代码
name:Elasticsearch AND age:[30 TO 40]
这个查询会查找 name 字段为 "Elasticsearch" 且 age 在 30 到 40 之间的文档。
5)Kuery(KQL: Kibana Query Language)
KQL 是 Kibana 的查询语言,专门用于在 Kibana 界面上执行搜索查询,常用于仪表盘和数据探索中。
示例 KQL 查询:
▼
plain
复制代码
name: "Elasticsearch" and age > 30
这个查询会查找 name 为 "Elasticsearch" 且 age 大于 30 的文档。
6)Painless 脚本查询
Painless 是 Elasticsearch 的内置脚本语言,用于执行自定义的脚本操作,常用于排序、聚合或复杂计算场景。
示例 Painless 脚本:
▼
json
复制代码
{
"query": {
"script_score": {
"query": {
"match": { "message": "Elasticsearch" }
},
"script": {
"source": "doc['popularity'].value * 2"
}
}
}
}
这个查询会基于 popularity 字段的值进行动态评分,将其乘以 2。
总结一下,DSL 是最通用的,EQL 和 KQL 则适用于特定场景,如日志分析和 Kibana 查询,而 SQL 则便于数据库开发人员上手。
7、Elasticsearch 查询条件
如何利用 Elasticsearch 实现数据筛选呢?需要了解其查询条件,以 ES 的 DSL 语法为例:
查询条件 介绍 示例 用途
match 用于全文检索,将查询字符串进行分词并匹配文档中对应的字段。 { "match": { "content": "鱼皮是帅小伙" } } 适用于全文检索,分词后匹配文档内容。
term 精确匹配查询,不进行分词。通常用于结构化数据的精确匹配,如数字、日期、关键词等。 { "term": { "status": "active" } } 适用于字段的精确匹配,如状态、ID、布尔值等。
terms 匹配多个值中的任意一个,相当于多个 term 查询的组合。 { "terms": { "status": ["active", "pending"] } } 适用于多值匹配的场景。
range 范围查询,常用于数字、日期字段,支持大于、小于、区间等查询。 { "range": { "age": { "gte": 18, "lte": 30 } } } 适用于数值或日期的范围查询。
bool 组合查询,通过 must、should、must_not 等组合多个查询条件。 { "bool": { "must": [ { "term": { "status": "active" } }, { "range": { "age": { "gte": 18 } } } ] } } 适用于复杂的多条件查询,可以灵活组合。
wildcard 通配符查询,支持 * 和 ?,前者匹配任意字符,后者匹配单个字符。 { "wildcard": { "name": "鱼*" } } 适用于部分匹配的查询,如模糊搜索。
prefix 前缀查询,匹配以指定前缀开头的字段内容。 { "prefix": { "name": "鱼" } } 适用于查找以指定字符串开头的内容。
fuzzy 模糊查询,允许指定程度的拼写错误或字符替换。 { "fuzzy": { "name": "yupi~2" } } 适用于处理拼写错误或不完全匹配的查询。
exists 查询某字段是否存在。 { "exists": { "field": "name" } } 适用于查找字段存在或缺失的文档。
match_phrase 短语匹配查询,要求查询的词语按顺序完全匹配。 { "match_phrase": { "content": "鱼皮 帅小伙" } } 适用于严格的短语匹配,词语顺序和距离都严格控制。
match_all 匹配所有文档。 { "match_all": {} } 适用于查询所有文档,通常与分页配合使用。
ids 基于文档 ID 查询,支持查询特定 ID 的文档。 { "ids": { "values": ["1", "2", "3"] } } 适用于根据文档 ID 查找特定文档。
geo_distance 地理位置查询,基于地理坐标和指定距离查询。 { "geo_distance": { "distance": "12km", "location": { "lat": 40.73, "lon": -74.1 } } } 适用于根据距离计算查找地理位置附近的文档。
aggregations 聚合查询,用于统计、计算和分组查询,类似 SQL 中的 GROUP BY。 { "aggs": { "age_stats": { "stats": { "field": "age" } } } } 适用于统计和分析数据,比如求和、平均值、最大值等。
其中的几个关键:
精确匹配 vs. 全文检索:term 是精确匹配,不分词;match 用于全文检索,会对查询词进行分词。
组合查询:bool 查询可以灵活组合多个条件,适用于复杂的查询需求。
模糊查询:fuzzy 和 wildcard 提供了灵活的模糊匹配方式,适用于拼写错误或不完全匹配的场景。
了解上面这些一般就足够了,更多可以随用随查,参考 官方文档 。
8、Elasticsearch 客户端
前面了解了 Elasticsearch 的概念和查询语法,但是如何执行 Elasticsearch 操作呢?还需要了解下 ES 的客户端,列举一些常用的:
1)HTTP API:Elasticsearch 提供了 RESTful HTTP API,用户可以通过直接发送 HTTP 请求来执行索引、搜索和管理集群的操作。官方文档
2)Kibana:Kibana 是 Elasticsearch 官方提供的可视化工具,用户可以通过 Kibana 控制台使用查询语法(如 DSL、KQL)来执行搜索、分析和数据可视化。
3)Java REST Client:Elasticsearch 官方提供的 Java 高级 REST 客户端库,用于 Java 程序中与 Elasticsearch 进行通信,支持索引、查询、集群管理等操作。官方文档
4)Spring Data Elasticsearch:Spring 全家桶的一员,用于将 Elasticsearch 与 Spring 框架集成,通过简化的 Repository 方式进行索引、查询和数据管理操作。官方文档
5)Elasticsearch SQL CLI:命令行工具,允许通过类 SQL 语法直接在命令行中查询 Elasticsearch 数据,适用于熟悉 SQL 的用户。
此外,Elasticsearch 当然不只有 Java 的客户端,Python、PHP、Node.js、Go 的客户端都是支持的。
💡 在选择客户端时,要格外注意版本号!!!要跟 Elasticsearch 的版本保持兼容。
9、ES 数据同步方案
一般情况下,如果做查询搜索功能,使用 ES 来模糊搜索,但是数据是存放在数据库 MySQL 里的,所以说我们需要把 MySQL 中的数据和 ES 进行同步,保证数据一致(以 MySQL 为主)。
数据流向:MySQL => ES (单向)
数据同步一般有 2 个过程:全量同步(首次)+ 增量同步(新数据)
总共有 4 种主流方案:
1)定时任务
比如 1 分钟 1 次,找到 MySQL 中过去几分钟内(至少是定时周期的 2 倍)发生改变的数据,然后更新到 ES。
优点:
简单易懂,开发、部署、维护相对容易。
占用资源少,不需要引入复杂的第三方中间件。
不用处理复杂的并发和实时性问题。
缺点:
有时间差:无法做到实时同步,数据存在滞后。
数据频繁变化时,无法确保数据完全同步,容易出现错过更新的情况。
对大数据量的更新处理不够高效,可能会引入重复更新逻辑。
应用场景:
数据实时性要求不高:适合数据短时间内不同步不会带来重大影响的场景。
数据基本不发生修改:适合数据几乎不修改、修改不频繁的场景。
数据容忍丢失
2)双写
写数据的时候,必须也去写 ES;更新删除数据库同理。
可以通过事务保证数据一致性,使用事务时,要先保证 MySQL 写成功,因为如果 ES 写入失败了,不会触发回滚,但是可以通过定时任务 + 日志 + 告警进行检测和修复(补偿)。
优点:
方案简单易懂,业务逻辑直接控制数据同步。
可以利用事务部分保证 MySQL 和 ES 的数据一致性。
同步的时延较短,理论上可以接近实时更新 ES。
缺点:
影响性能:每次写 MySQL 时,需要同时操作 ES,增加了业务写入延迟,影响性能。
一致性问题:如果 ES 写入失败,MySQL 事务提交成功后,ES 可能会丢失数据;或者 ES 写入成功,MySQL 事务提交失败,ES 无法回滚。因此必须额外设计监控、补偿机制来检测同步失败的情况(如通过定时任务、日志和告警修复)。
代码复杂度增加,需要对每个写操作都进行双写处理。
应用场景:
实时性要求较高
业务写入频率较低:适合写操作不频繁的场景,这样对性能的影响较小。
3)用 Logstash 数据同步管道
一般要配合 kafka 消息队列 + beats 采集器:
img
优点:
配置驱动:基于配置文件,减少了手动编码,数据同步逻辑和业务代码解耦。
扩展性好:可以灵活引入 Kafka 等消息队列实现异步数据同步,并可处理高吞吐量数据。
支持多种数据源:Logstash 支持丰富的数据源,方便扩展其他同步需求。
缺点:
灵活性差:需要通过配置文件进行同步,复杂的业务逻辑可能难以在配置中实现,无法处理细粒度的定制化需求。
引入额外组件,维护成本高:通常需要引入 Kafka、Beats 等第三方组件,增加了系统的复杂性和运维成本。
应用场景:
大数据同步:适合大规模、分布式数据同步场景。
对实时性要求不高:适合数据流处理或延迟容忍较大的系统。
系统已有 Kafka 或类似的消息队列架构:如果系统中已经使用了 Kafka 等中间件,使用 Logstash 管道会变得很方便。
4)监听 MySQL Binlog
有任何数据变更时都能够实时监听到,并且同步到 Elasticsearch。一般不需要自己监听,可以使用现成的技术,比如 Canal 。
img
💡 Canal 的核心原理:数据库每次修改时,会修改 binlog 文件,只要监听该文件的修改,就能第一时间得到消息并处理
优点:
实时性强:能够在 MySQL 数据发生变更的第一时间同步到 ES,做到真正的实时同步。
轻量级:Binlog 是数据库自带的日志功能,不需要修改核心业务代码,只需要新增监听逻辑。
缺点:
引入外部依赖:需要引入像 Canal 这样的中间件,增加了系统的复杂性和维护成本。
运维难度增加:需要确保 Canal 或者其他 Binlog 监听器的稳定运行,并且对 MySQL 的 Binlog 配置要求较高。
一致性问题:如果 Canal 服务出现问题或暂停,数据可能会滞后或丢失,必须设计补偿机制。
应用场景:
实时同步要求高:适合需要实时数据同步的场景,通常用于高并发、高数据一致性要求的系统。
数据频繁变化:适合数据变更频繁且需要高效增量同步的场景。
最终方案:对于本项目,由于数据量不大,题目更新也不频繁,容忍丢失和不一致,所以选用方案一,实现成本最低。确保数据完全同步,容易出现错过更新的情况。
对大数据量的更新处理不够高效,可能会引入重复更新逻辑。
应用场景:
数据实时性要求不高:适合数据短时间内不同步不会带来重大影响的场景。
数据基本不发生修改:适合数据几乎不修改、修改不频繁的场景。
数据容忍丢失
2)双写
写数据的时候,必须也去写 ES;更新删除数据库同理。
可以通过事务保证数据一致性,使用事务时,要先保证 MySQL 写成功,因为如果 ES 写入失败了,不会触发回滚,但是可以通过定时任务 + 日志 + 告警进行检测和修复(补偿)。
优点:
方案简单易懂,业务逻辑直接控制数据同步。
可以利用事务部分保证 MySQL 和 ES 的数据一致性。
同步的时延较短,理论上可以接近实时更新 ES。
缺点:
影响性能:每次写 MySQL 时,需要同时操作 ES,增加了业务写入延迟,影响性能。
一致性问题:如果 ES 写入失败,MySQL 事务提交成功后,ES 可能会丢失数据;或者 ES 写入成功,MySQL 事务提交失败,ES 无法回滚。因此必须额外设计监控、补偿机制来检测同步失败的情况(如通过定时任务、日志和告警修复)。
代码复杂度增加,需要对每个写操作都进行双写处理。
应用场景:
实时性要求较高
业务写入频率较低:适合写操作不频繁的场景,这样对性能的影响较小。
3)用 Logstash 数据同步管道
一般要配合 kafka 消息队列 + beats 采集器:
img
优点:
配置驱动:基于配置文件,减少了手动编码,数据同步逻辑和业务代码解耦。
扩展性好:可以灵活引入 Kafka 等消息队列实现异步数据同步,并可处理高吞吐量数据。
支持多种数据源:Logstash 支持丰富的数据源,方便扩展其他同步需求。
缺点:
灵活性差:需要通过配置文件进行同步,复杂的业务逻辑可能难以在配置中实现,无法处理细粒度的定制化需求。
引入额外组件,维护成本高:通常需要引入 Kafka、Beats 等第三方组件,增加了系统的复杂性和运维成本。
应用场景:
大数据同步:适合大规模、分布式数据同步场景。
对实时性要求不高:适合数据流处理或延迟容忍较大的系统。
系统已有 Kafka 或类似的消息队列架构:如果系统中已经使用了 Kafka 等中间件,使用 Logstash 管道会变得很方便。
4)监听 MySQL Binlog
有任何数据变更时都能够实时监听到,并且同步到 Elasticsearch。一般不需要自己监听,可以使用现成的技术,比如 Canal 。
img
💡 Canal 的核心原理:数据库每次修改时,会修改 binlog 文件,只要监听该文件的修改,就能第一时间得到消息并处理
优点:
实时性强:能够在 MySQL 数据发生变更的第一时间同步到 ES,做到真正的实时同步。
轻量级:Binlog 是数据库自带的日志功能,不需要修改核心业务代码,只需要新增监听逻辑。
缺点:
引入外部依赖:需要引入像 Canal 这样的中间件,增加了系统的复杂性和维护成本。
运维难度增加:需要确保 Canal 或者其他 Binlog 监听器的稳定运行,并且对 MySQL 的 Binlog 配置要求较高。
一致性问题:如果 Canal 服务出现问题或暂停,数据可能会滞后或丢失,必须设计补偿机制。
应用场景:
实时同步要求高:适合需要实时数据同步的场景,通常用于高并发、高数据一致性要求的系统。
数据频繁变化:适合数据变更频繁且需要高效增量同步的场景。
最终方案:对于本项目,由于数据量不大,题目更新也不频繁,容忍丢失和不一致,所以选用方案一,实现成本最低。
<groupId>com.alibaba.boot</groupId>
<artifactId>nacos-config-spring-boot-starter</artifactId>
<version>0.2.12</version>
</dependency>
<!-- Guava: https://github.com/google/guava?tab=readme-ov-file -->
<dependency>
<!-- 类似 Hutoll 的工具包, 这个依赖我们后面用得到 -->
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.1-jre</version>
</dependency># 配置 nacos
nacos:
config:
username: nacos
password: Qwe54188_
server-addr: 127.0.0.1:8848 # nacos 地址
bootstrap:
enable: true # 预加载
data-id: workusercentre # 控制台填写的 Data ID
group: DEFAULT_GROUP # 控制台填写的 group
type: yaml # 选择的文件格式
auto-refresh: true # 开启自动刷新因为 Nacos 配置文件的监听的粒度比较粗,只能知晓配置有变更,无法知晓是新增、删除还是修改,因此不论是选择布隆过滤器还是 HashSet 最方便的处理逻辑就是重建。我们需要给出一个工具类用来重建配置以读取黑名单,另外一个工具类需要读取请求的具体 IP。
重要
补充:Nacos 从 2.4.0 版本开始,支持监听服务变化的差值,即和之前相比,有哪些实例被新增等,具体可以查阅文档。
package cn.com.edtechhub.workusercentre.utils;
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import lombok.extern.slf4j.Slf4j;
import org.yaml.snakeyaml.Yaml;
import java.nio.charset.Charset;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
/**
* 黑名单工具类
*
* @author <a href="https://github.com/limou3434">limou3434</a>
*/
@Slf4j
public class BlackIpUtils {
/**
* 预期插入数量
*/
private static final int EXPECTED_INSERTIONS = 10000;
/**
* 误判率(0.1%)
*/
private static final double FALSE_POSITIVE_PROBABILITY = 0.001;
/**
* 布隆过滤器实例
*/
private static final AtomicReference<BloomFilter<String>> bloomFilterRef = new AtomicReference<>(createNewBloomFilter()); // 使用 Guava BloomFilter 实现, 使用原子性引用容器能让你在多线程环境下, 以无锁的方式安全地读取和替换一个对象引用
/**
* 创建新的布隆过滤器实例
*/
private static BloomFilter<String> createNewBloomFilter() {
return BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()),
EXPECTED_INSERTIONS,
FALSE_POSITIVE_PROBABILITY
);
}
/**
* 重新构建黑名单布隆过滤器
*/
public static void rebuildBlackIp(String configInfo) {
log.debug("重新构建黑名单布隆过滤器时传入的配置信息: {}", configInfo);
if (configInfo == null || configInfo.trim().isEmpty()) {
configInfo = "blackIpList: []"; // 默认空列表
}
// 解析 yaml 配置
Yaml yaml = new Yaml();
Map<String, Object> map = yaml.load(configInfo);
// 安全获取黑名单列表
List<String> blackIpList = Optional.ofNullable(map)
.map(m -> (List<String>) m.get("blackIpList"))
.orElse(Collections.emptyList());
// 创建新的布隆过滤器
BloomFilter<String> newBloomFilter = createNewBloomFilter();
// 添加 IP 到新过滤器
if (blackIpList != null && !blackIpList.isEmpty()) {
for (String ip : blackIpList) {
if (ip != null && !ip.trim().isEmpty()) {
newBloomFilter.put(ip.trim());
}
}
}
// 原子性更新引用
bloomFilterRef.set(newBloomFilter);
log.debug("布隆过滤器已更新, 当前黑名单数量: {}", blackIpList.size());
}
/**
* 判断 IP 是否在黑名单内
*/
public static boolean isBlackIp(String ip) {
if (ip == null || ip.trim().isEmpty()) {
return false;
}
boolean mightContain = bloomFilterRef.get().mightContain(ip.trim());
log.debug("检测客户端 IP 地址 {} 此时是否在黑名单内: {}", ip, mightContain);
return mightContain;
}
}package cn.com.edtechhub.workusercentre.utils;
import lombok.extern.slf4j.Slf4j;
import javax.servlet.http.HttpServletRequest;
import java.net.InetAddress;
/**
* 地址工具类
*
* @author <a href="https://github.com/limou3434">limou3434</a>
*/
@Slf4j
public class IpUtils {
/**
* 获取客户端 IP 地址方法
* 只做了简单的判断, 如果需要更加复杂的逻辑就需要自己定制化
*/
public static String getIpAddress(HttpServletRequest request) {
String ip = request.getHeader("x-forwarded-for");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
if (ip.equals("127.0.0.1")) {
// 根据网卡取本机配置的 IP
InetAddress inet = null;
try {
inet = InetAddress.getLocalHost();
} catch (Exception e) {
log.error("获取本机 IP 失败", e);
}
if (inet != null) {
ip = inet.getHostAddress();
}
}
}
// 多个代理的情况,第一个 IP 为客户端真实 IP, 多个 IP 按照 ',' 分割
if (ip != null && ip.length() > 15) {
if (ip.indexOf(",") > 0) {
ip = ip.substring(0, ip.indexOf(","));
}
}
if (ip == null) {
return "127.0.0.1";
}
log.debug("检测一次客户端的 IP 地址: {}", ip);
return ip;
}
}然后我们回到控制台,其实我们创建好配置后 Nacos 有给我们对应的示例代码,我们稍微改造以下就可以得到监听器代码。
@Slf4j
@Component
public class NacosListener implements InitializingBean {
@NacosInjected
private ConfigService configService;
@Value("${nacos.config.data-id}")
private String dataId;
@Value("${nacos.config.group}")
private String group;
@Override
public void afterPropertiesSet() throws Exception {
log.info("nacos 监听器启动");
String config = configService.getConfigAndSignListener(dataId, group, 3000L, new Listener() {
final ThreadFactory threadFactory = new ThreadFactory() {
private final AtomicInteger poolNumber = new AtomicInteger(1);
@Override
public Thread newThread(@NotNull Runnable r) {
Thread thread = new Thread(r);
thread.setName("refresh-ThreadPool" + poolNumber.getAndIncrement());
return thread;
}
};
final ExecutorService executorService = Executors.newFixedThreadPool(1, threadFactory);
// 通过线程池异步处理黑名单变化的逻辑
@Override
public Executor getExecutor() {
return executorService;
}
// 监听后续黑名单变化
@Override
public void receiveConfigInfo(String configInfo) {
log.info("监听到配置信息变化:{}", configInfo);
BlackIpUtils.rebuildBlackIp(configInfo);
}
});
// 初始化黑名单
BlackIpUtils.rebuildBlackIp(config);
}
}然后还需要黑名单对所有请求生效,这里的请求不止是针对 Controller 的接口,所以应该基于 WebFilter 实现而不是 AOP 切面。WebFilter 的优先级高于 @Aspect 切面,因为它在整个 Web 请求生命周期中更早进行处理。请求进入时的顺序:
- WebFilter:
WebFilter是Spring WebFlux中的过滤器,拦截所有请求(包括静态资源请求和其他HTTP请求),并且在请求到达任何处理器之前就能进行拦截,适用于对请求的全局处理。首先,WebFilter拦截HTTP请求,并可以根据逻辑决定是否继续执行请求。 - Spring AOP 切面(@Aspect):而
AOP(面向切面编程) 通常用于方法级别的切面,尤其是针对Controller层方法,但是它并不会拦截所有的HTTP请求,特别是静态资源或者一些不经过Controller处理的请求,因此不能满足对所有请求(包括静态资源和API接口)的过滤需求。如果请求经过过滤器并进入Spring管理的Bean(例如Controller层),此时切面生效,对匹配的Bean方法进行拦截。 - Controller 层:如果
@Aspect没有阻止执行,最终请求到达@Controller或@RestController的方法。
@WebFilter(urlPatterns = "/*", filterName = "blackIpFilter")
public class BlackIpFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
String ipAddress = NetUtils.getIpAddress((HttpServletRequest) servletRequest);
if (BlackIpUtils.isBlackIp(ipAddress)) {
servletResponse.setContentType("text/json;charset=UTF-8");
servletResponse.getWriter().write("{\"errorCode\":\"-1\",\"errorMsg\":\"黑名单IP,禁止访问\"}");
return;
}
filterChain.doFilter(servletRequest, servletResponse);
}
}
@WebFilter(urlPatterns = "/*", filterName = "blackIpFilter")
public class BlackIpFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
String ipAddress = NetUtils.getIpAddress((HttpServletRequest) servletRequest);
if (BlackIpUtils.isBlackIp(ipAddress)) {
servletResponse.setContentType("text/json;charset=UTF-8");
servletResponse.getWriter().write("{\"errorCode\":\"-1\",\"errorMsg\":\"黑名单IP,禁止访问\"}");
return;
}
filterChain.doFilter(servletRequest, servletResponse);
}
}待补充..
Sentinel
1.Sentinel 的全面概述
随着微服务的流行,服务和服务之间的稳定性变得越来越重要。Sentinel 是面向分布式、多语言异构化服务架构的流量治理组件,主要以流量为切入点,从流量路由、流量控制、流量整形、熔断降级、系统自适应过载保护、热点流量防护等多个维度来帮助开发者保障微服务的稳定性。
2012年,Sentinel诞生,主要功能为入口流量控制2013-2017年,Sentinel在阿里巴巴集团内部迅速发展,成为基础技术模块,覆盖了所有的核心场景。Sentinel也因此积累了大量的流量归整场景以及生产实践2018年,Sentinel开源,并持续演进2019年,Sentinel朝着多语言扩展的方向不断探索,推出C++原生版本,同时针对Service Mesh场景也推出了Envoy集群流量控制支持,以解决Service Mesh架构下多语言限流的问题2020年,推出Sentinel Go版本,继续朝着云原生方向演进2021年,Sentinel正在朝着2.0云原生高可用决策中心组件进行演进;同时推出了Sentinel Rust原生版本。同时我们也在Rust社区进行了Envoy WASM extension及eBPF extension等场景探索2022年,Sentinel品牌升级为流量治理,领域涵盖流量路由/调度、流量染色、流控降级、过载保护/实例摘除等;同时社区将流量治理相关标准抽出到OpenSergo标准中,Sentinel作为流量治理标准实现
2.Sentinel 的基本功能
Sentinel 最为基本的功能,就是先使用 Sentinel 的代码来定义自己的项目中的某些东西为资源,再加以规则来作为限制。
- 资源:是
Sentinel的关键概念。它可以是Java应用程序中的任何内容,例如,由应用程序提供的服务,或由应用程序调用的其它应用提供的服务,甚至可以是一段代码。在接下来的文档中,我们都会用资源来描述代码块。只要通过Sentinel API定义的代码(在Java也可以使用注解),就是资源,能够被Sentinel保护起来。大部分情况下,可以使用方法签名,URL,甚至服务名称作为资源名来标示资源。 - 规则:围绕资源的实时状态设定的规则,可以包括流量控制规则、熔断降级规则以及系统保护规则。所有规则可以动态实时调整。
而 Sentinel 也有一些自动识别的机制,例如自动识别某个项目中的所有网络接口,并且都定义为资源,然后再部署一个控制台,以这种类似切面的方式入侵到项目中,本文最主要学习这种模式,如果您希望使用清晰的代码来配置 Sentinel,可以 详细查阅文档。这里有个简单的代码可以说明 Sentinel 的接口调用过程,下面先来定义资源:
// 通过代码定义资源
Entry entry = null;
// 务必保证finally会被执行
try {
// 资源名可使用任意有业务语义的字符串
entry = SphU.entry("自定义资源名");
// 被保护的业务逻辑
// do something...
} catch (BlockException e1) { // BlockException 异常是一个父类异常
// 资源访问阻止,被限流或被降级
// 进行相应的处理操作
} finally {
if (entry != null) {
entry.exit();
}
}如果上述代码触发后续指定的某种规则,就会抛出对应的异常(这些异常都是 BlockException 的子类):
FlowException流量控制异常DegradeException熔断降级异常ParamFlowException热点参数异常SystemBlockException系统保护异常
我们只需要捕获不同种类的异常就可以判断出属于那一种规则保护,并且做出对应的动作。我们再来定义规则:
private static void initFlowQpsRule() {
List<FlowRule> rules = new ArrayList<>();
FlowRule rule1 = new FlowRule();
rule1.setResource(resource);
// Set max qps to 20
rule1.setCount(20);
rule1.setGrade(RuleConstant.FLOW_GRADE_QPS);
rule1.setLimitApp("default");
rules.add(rule1);
FlowRuleManager.loadRules(rules);
}重要
补充:还可以使用更加便捷的注解 @SentinelResource 来做到相同的事情。
| 参数名 | 类型 | 必填 | 说明 |
|---|---|---|---|
value | String | 是 | 资源名称,唯一标识这个被保护的资源(比如接口名、方法名等) |
entryType | EntryType | 否 | 资源的类型,默认是 EntryType.OUT,用于区分调用方向(出站/入站) |
blockHandler | String | 否 | 当触发限流/降级/系统保护(抛出 BlockException)时的处理方法名 |
blockHandlerClass | Class<?>[] | 否 | 如果 blockHandler 在其它类中定义,则指定其类名。方法需为 public static |
fallback | String | 否 | 执行方法发生业务异常(非 BlockException)时的 fallback 方法名 |
fallbackClass | Class<?>[] | 否 | 如果 fallback 在其他类中定义,指定其类名。方法需为 public static |
defaultFallback | String | 否 | 默认降级方法名,所有异常(除了忽略的)都会进入这里;只有 fallback 未配置时才会生效 |
exceptionsToIgnore | Class<?>[] | 否 | 指定不会触发 fallback 的异常类型,这些异常将原样抛出 |
// 通过注解定义资源
public class TestService {
// 对应的 `handleException` 函数需要位于 `ExceptionUtil` 类中,并且必须为 static 函数.
@SentinelResource(value = "test", blockHandler = "handleException", blockHandlerClass = {ExceptionUtil.class})
public void test() {
System.out.println("Test");
}
// 原函数
@SentinelResource(value = "hello", blockHandler = "exceptionHandler", fallback = "helloFallback")
public String hello(long s) {
return String.format("Hello at %d", s);
}
// Fallback 函数,函数签名与原函数一致或加一个 Throwable 类型的参数.
public String helloFallback(long s) {
return String.format("Halooooo %d", s);
}
// Block 异常处理函数,参数最后多一个 BlockException,其余与原函数一致.
public String exceptionHandler(long s, BlockException ex) {
// Do some log here.
ex.printStackTrace();
return "Oops, error occurred at " + s;
}
}为什么有了 blockHandler 还需要 fallback 呢?在项目中我们有些时候会使用全局异常处理器 ExceptionHandler 来处理业务异常,但是有些时候颗粒度不够,就可以考虑使用 fallback 来进行更加颗粒度的处理。
特别地,若 blockHandler 和 fallback 都进行了配置,则被限流降级而抛出 BlockException 时只会进入 blockHandler 处理逻辑。若未配置 blockHandler、fallback 和 defaultFallback,则被限流降级时会将 BlockException 直接抛出。
还有一点非常需要注意,blockHandler 和 fallback 参数对应的方法必须和原方法参数一一对应。不过 blockHandler 允许额外携带一个 BlockException 异常类型的参数,而 fallback 则允许额外携带一个 Throwable 异常类型的参数,具体可以 查阅该注解的文档。
3.Sentinel 的使用教程
3.1.控制面板
直接上代码把,依旧是使用我们的用户中心项目进行配置,我们先来配置一个控制台吧,不过 官方提供的安装包默认的是 .jar。直接使用 java -Dserver.port=8131 -Dsentinel.dashboard.auth.username=sentinel -Dsentinel.dashboard.auth.password=Qwe54188_ -Dserver.servlet.session.timeout=86400 -jar app.jar,最后访问http://127.0.0.1:8131 且输入密码后即可得到控制台。

警告
警告:不过我个人喜欢使用 Docker,我按照下面的镜像文件映射到本地端口,试图在容器内部署,但这种情况下客户端程序也必须是同一容器网络下的容器,否则 Docker 无法在默认的网络模式下直接访问宿主主机,那怕做了端口映射。因此这适合在测试环境和生产环境中使用...
# 基础镜像
FROM openjdk:17-jdk-slim
# 编译代码
COPY ./sentinel-dashboard-1.8.8.jar ./app.jar
# 运行端口
EXPOSE 8131
# 启动命令
CMD ["java", "-Dserver.port=8131", "-Dsentinel.dashboard.auth.username=sentinel", "-Dsentinel.dashboard.auth.password=Qwe54188_", "-Dserver.servlet.session.timeout=86400", "-jar", "app.jar"]后续我们不再使用代码来制定规则,而是使用控制台,不过值得注意的是,而当规则触发后,然后我们依旧采用注解 @SentinelResource 来定义触发后的调用。
3.2.流量控制

流量控制在网络传输中是一个常用的概念,它用于调整网络包的发送数据。然而,从系统稳定性角度考虑,在处理请求的速度上,也有非常多的讲究。任意时间到来的请求往往是随机不可控的,而系统的处理能力是有限的。我们需要根据系统的处理能力对流量进行控制。Sentinel 作为一个调配器,可以根据需要把随机的请求调整成合适的形状。
但是流量控制需要控制什么东西呢?流量控制有以下几个角度:
- 资源的调用关系,例如资源的调用链路,资源和资源之间的关系;
- 运行指标,例如
QPS、线程池、系统负载等; - 控制的效果,例如直接限流、冷启动、排队等...
我们把所有的服务都视为是 Sentinel 控制台 的客户端,因此这些服务如果需要资源保护,就需要引入依赖。注意我们使用的是 Spring Boot2.7.4,您可以 参考这篇文档 来查阅多种不同框架的适配。
<!-- Sentinel -->
<dependency>
<!-- 这是核心依赖, 有这个就可以定义资源、设置规则、流量控制... -->
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-core</artifactId>
<version>1.8.6</version>
</dependency>
<dependency>
<!-- 这是和控制台通信的依赖 -->
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-transport-simple-http</artifactId>
<version>1.8.6</version>
</dependency>还需要做一个配置类,这个配置类可以把所有的请求接口都自动转化为资源进行限制。
@Configuration
public class SentinelConfig {
@Bean
public FilterRegistrationBean sentinelFilterRegistration() {
FilterRegistrationBean<Filter> registration = new FilterRegistrationBean<>();
registration.setFilter(new CommonFilter());
registration.addUrlPatterns("/*");
registration.setName("sentinelFilter");
registration.setOrder(1);
return registration;
}
}然后启动客户端时,就需要在启动处加入 -Dcsp.sentinel.dashboard.server=127.0.0.1:8131 告知控制台的地址,如果您有多个客户端,则可以使用 -Dcsp.sentinel.api.port=xxx 来指定客户端的端口号(毕竟多个客户端端口不能冲突嘛,默认值是 8719)。这样就可以把所有的网络接口都识别到,然后只对网络接口进行资源管理,同时可以使用控制台进行限流。

不过还有更加容易的依赖方法,就是使用下面这个依赖,下面这个依赖就可以直接使用。
<!-- Sentinel: https://sentinelguard.io/zh-cn/index.html -->
<dependency>
<!-- 本依赖内部集成 Sentinel 的所有核心依赖, 包括上面的两个依赖 -->
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
<version>2021.0.5.0</version>
</dependency>我们先来研究这里的“流控”按钮,并且打开高级选项。这里可以限制 QPS, 每秒请求次数 或者限制并发线程数来达到限制流量的目的,这挺容易理解的,可以尝试限制一下对 /user/status 进行限制,填写需要限制的 QPS 次数后,就可以在流控规则中看到该规则,然后我们在接口文档中不断访问这个接口。




可以看到有一段时间是不允许访问 /user/status 接口的,除此之外我们还需要做一个响应规则触发的异常处理方法,因此我们最好在接口处使用,这里的示例代码也来源自 work-user-centre 中,您可以前往一看。
/**
* 用户控制层
*
* @author <a href="https://github.com/limou3434">limou3434</a>
*/
@RestController // 返回值默认为 json 类型
@RequestMapping("/user")
public class UserController { // 通常控制层有服务层中的所有方法, 并且还有组合而成的方法, 如果组合的方法开始变得复杂就会封装到服务层内部
/**
* 流量控制异常处理方法
*/
public static BaseResponse<?> blockExceptionHandle(BlockException ex) {
// 流量控制异常
if (ex instanceof FlowException) {
return TheResult.error(CodeBindMessage.TOO_MANY_REQUESTS, "请求频繁,请稍后重试");
}
// 熔断降级异常
else if (ex instanceof DegradeException) {
return TheResult.error(CodeBindMessage.SERVICE_DEGRADED, "服务退化,请稍后重试");
}
// 热点参数异常
else if (ex instanceof ParamFlowException) {
return TheResult.error(CodeBindMessage.PARAM_LIMIT, "请求繁忙,请稍后重试");
}
// 系统保护异常
else {
return TheResult.error(CodeBindMessage.SYSTEM_BUSY, "系统繁忙,请稍后重试");
}
}
/**
* 获取状态网络接口
*/
@SaIgnore
@SentinelResource(value = "userStatus", blockHandler = "blockExceptionHandle")
@GetMapping("/status")
public BaseResponse<UserStatus> userStatus() {
UserStatus userStatus = userService.userStatus();
return TheResult.success(CodeBindMessage.SUCCESS, userStatus);
}
}警告
警告:这里只是恰好 /user/status 接口没有参数,所以注解的两个方法就只需要各自携带允许的参数即可。

能拓展的点有很多,例如在触发流量控制异常的时候对用户的 IP 进行查询,然后视情况来做封禁...
3.3.熔断降级

- 熔断:除了流量控制以外,降低调用链路中的不稳定资源也是
Sentinel的使命之一。由于调用关系的复杂性,如果调用链路中的某个资源出现了不稳定,最终会导致请求发生堆积。这个问题和 Hystrix 里面描述的问题是一样的。Hystrix是一个库(这个库已经不再维护),它通过添加延迟容忍和容错逻辑来帮助您控制这些分布式服务之间的交互。Hystrix通过隔离服务之间的访问点、阻止服务之间的级联故障以及提供回退选项来实现这一点,所有这些都可以提高系统的整体弹性。而Sentinel和Hystrix的原则是一致的:当调用链路中某个资源出现不稳定,例如,表现为timeout,异常比例升高的时候,则对这个资源的调用进行限制,并让请求快速失败,避免影响到其它的资源,最终产生雪崩的效果。 - 降级:说白了,比如在做缓存的
Redis中的数据因为Redis节点宕机而无法访问时,熔断机制可以快速返回错误的响应而不是堆集请求最终导致雪崩。并且在熔断的基础上,可以尝试恢复熔断,一旦多次不成功或是异常时间过长,还可以做降级策略,比如不再使用Redis而是自动回退到MySQL中进行访问,并且向开发人员发送警告。
在限制的手段上,Sentinel 和 Hystrix 采取了完全不一样的方法。Hystrix 通过 线程池 的方式,来对资源进行隔离。这样做的好处是资源和资源之间做到了最彻底的隔离;缺点是除了增加了线程切换的成本,还需要预先给各个资源做线程池大小的分配。而 Sentinel 对这个问题采取了两种手段:
- 通过并发线程数进行限制:和资源池隔离的方法不同,
Sentinel通过限制资源并发线程的数量,来减少不稳定资源对其它资源的影响。这样不但没有线程切换的损耗,也不需要您预先分配线程池的大小。当某个资源出现不稳定的情况下,例如响应时间变长,对资源的直接影响就是会造成线程数的逐步堆积。当线程数在特定资源上堆积到一定的数量之后,对该资源的新请求就会被拒绝。堆积的线程完成任务后才开始继续接收请求。 - 通过响应时间对资源进行降级:除了对并发线程数进行控制以外,
Sentinel还可以通过响应时间来快速降级不稳定的资源。当依赖的资源出现响应时间过长后,所有对该资源的访问都会被直接拒绝,直到过了指定的时间窗口之后才重新恢复。
还是我们之前部署的控制台,只要设置好规则,然后在异常中做处理就可以了。控制台熔断规则中有一个 RT 就是响应时间,例如在调用一些查询量较大的调用时,有可能会因为 Redis 挂掉导致响应时间过长,这个时候一旦符合设置的 RT 上限值,就会触发异常 DegradeException,并且还可以设置熔断的时间以等待 Redis 恢复,同时在恢复会进行试触,会检查接口是否不再抛出异常,否则就继续进入熔断状态。不过降级需要我们自己来实现,如何实现呢?这个时候就不能只使用一个 blockExceptionHandle,需要对特定的方法自己填充 blockHandler 参数并且自己捕获熔断异常进而调用降级的服务。
降级这部分我就不演示了,我让这个熔断异常被抛出就足以说明问题。对于 RT 来说,我们可以在接口中使用 Thread.sleep(100); // 模拟 100 毫秒的延迟响应时间 来模拟,而异常比例就可以抛出 if (true) { throw new RuntimeException("模拟异常"); } 来进行模拟。然后统计时长要稍微大于 100ms,这样才能统计到完整的调用结果查验是否有接口超出 RT 设置。



警告
警告:异常的您可以自己试一试,或者干脆传错参数也是可以模拟的。但是需要注意的是,异常降级仅针对业务异常(比如请求参数错误、或者数据库操作失败等问题),对 Sentinel 限流降级本身的异常(BlockException)不生效。为了统计异常比例或异常数,如果写代码则需要通过 Tracer.trace(ex) 记录业务异常。但是我们使用的是 @SentinelResource 注解,会自动统计业务异常,无需手动调用。不过这种异常必须没有被处理过,或者处理过后继续被抛出才能被统计到。关于这个问题,
3.4.热点参数
不过有些情况下,直接使用流量控制或熔断降级会导致某个接口直接让所有人都不可用,这种范围还是太大了,我们需要颗粒度更小的方法。而热点参数则可以更居接口的参数,例如某件商品的 ID 值 1000 被传递过来,一旦触发热点参数规则,对该值就会进行限制,但是对 ID 为 2000 的商品就没有影响。
3.5.系统保护
Sentinel 同时提供 系统维度的自适应保护能力。防止系统雪崩,是系统防护中重要的一环。当系统负载较高的时候,如果还持续让请求进入,可能会导致系统崩溃,无法响应。在集群环境下,网络负载均衡会把本应这台机器承载的流量转发到其它的机器上去。
如果这个时候其它的机器也处在一个边缘状态的时候,这个增加的流量就会导致这台机器也崩溃,最后导致整个集群不可用。针对这个情况,Sentinel 提供了对应的保护机制,让系统的入口流量和系统的负载达到一个平衡,保证系统在能力范围之内处理最多的请求。
3.6.持久规则
规则推送分为 3 种模式,这 在官方文档中有所介绍:
| 推送模式 | 说明 | 优点 | 缺点 |
|---|---|---|---|
| 原始模式 | API 将规则推送至客户端并直接更新到内存中,扩展写数据源(WritableDataSource) | 简单,无任何依赖 | 不保证一致性;规则保存在内存中,重启即消失。严重不建议用于生产环境 |
| Pull 模式 | 扩展写数据源(WritableDataSource), 客户端主动向某个规则管理中心定期轮询拉取规则,这个规则中心可以是 RDBMS、文件 等 | 简单,无任何依赖;规则持久化 | 不保证一致性;实时性不保证,拉取过于频繁也可能会有性能问题。 |
| Push 模式 | 扩展读数据源(ReadableDataSource),规则中心统一推送,客户端通过注册监听器的方式时刻监听变化,比如使用 Nacos、Zookeeper 等配置中心。这种方式有更好的实时性和一致性保证。生产环境下一般采用 push 模式的数据源。 | 规则持久化;一致性;快速 | 引入第三方依赖 |
不过我们先使用推送模式,后续我再来和您说如何持久化我们的规则。这里还有一个问题,我们如何让接口在流量控制的情况下,看到被限制的响应呢?
Druid
1.Druid 的全面概述
2.Druid 的基本功能
3.Druid 的使用教程
Dubbo
1.Dubbo 的全面概述
Apache Dubbo 是一款 RPC 服务开发框架,用于解决微服务架构下的服务治理与通信问题,官方提供了 Java、Golang 等多语言 SDK 实现。使用 Dubbo 开发的微服务原生具备相互之间的远程地址发现与通信能力,利用 Dubbo 提供的丰富服务治理特性,可以实现诸如服务发现、负载均衡、流量调度等服务治理诉求。Dubbo 被设计为高度可扩展,用户可以方便的实现流量拦截、选址的各种定制逻辑。在云原生时代,Dubbo 相继衍生出了 Dubbo3、Proxyless Mesh 等架构与解决方案,在易用性、超大规模微服务实践、云原生基础设施适配、安全性等几大方向上进行了全面升级。
Apache Dubbo 最初是为了解决阿里巴巴内部的微服务架构问题而设计并开发的,在十多年的时间里,它在阿里巴巴公司内部的很多业务系统得到了非常广泛的应用。最早在 2008 年,阿里巴巴就将 Dubbo 捐献到开源社区,它很快成为了国内开源服务框架选型的事实标准框架,得到了业界更广泛的应用。在 2017 年,Dubbo 被正式捐献 Apache 软件基金会并成为 Apache 顶级项目,开始了一段新的征程。
Dubbo 被证实能很好的满足企业的大规模微服务实践,并且能有效降低微服务建设的开发与管理成本,不论是阿里巴巴还是工商银行、中国平安、携程、海尔等社区用户,它们都通过多年的大规模生产环境流量对 Dubbo 的稳定性与性能进行了充分验证。后来 Dubbo 在很多大企业内部衍生出了独立版本。自云原生概念推广以来,各大厂商都开始拥抱开源标准实现,阿里巴巴将其内部 HSF 系统与开源社区 Dubbo 相融合,与社区一同推出了云原生时代的 Dubbo3 架构,截止 2022 年双十一结束,Dubbo3 已经在阿里巴巴内部广泛落地,实现了老版本 HSF2 框架升级,包括电商核心、阿里云等核心系统已经全面运行在 Dubbo3 之上。
2.Dubbo 的基本功能
为什么需要 Dubbo,它能做什么?按照微服务架构的定义,采用它的组织能够很好的提高业务迭代效率与系统稳定性,但前提是要先能保证微服务按照期望的方式运行,要做到这一点需要解决:
- 服务拆分
- 数据通信
- 地址发现
- 流量管理
- 数据一致
- 系统容错
等一系列问题,而 Dubbo 可以帮助解决如下微服务实践问题:
- 微服务编程范式和工具:
Dubbo支持基于IDL或语言特定方式的服务定义,提供多种形式的服务调用形式(如同步、异步、流式等) - 高性能的 RPC 通信 Dubbo 帮助解决微服务组件之间的通信问题,提供了基于
HTTP、HTTP/2、TCP等的多种高性能通信协议实现,并支持序列化协议扩展,在实现上解决网络连接管理、数据传输等基础问题。 - 微服务监控与治理:
Dubbo官方提供的服务发现、动态配置、负载均衡、流量路由等基础组件可以很好的帮助解决微服务基础实践的问题。除此之外,您还可以用Admin控制台监控微服务状态,通过周边生态完成限流降级、数据一致性、链路追踪等能力。 - 部署在多种环境:
Dubbo服务可以直接部署在docker、Kubernetes、Service Mesh等多种架构下。
3.Dubbo 的使用教程
...
MQ
更新日志
6fa6e-迁移所有有效的文章于