工作中 Spring Boot 五大实用小技巧,来看看你掌握了几个?
0. 引入
Spring Boot 以其简化配置、快速开发和微服务支持等特点,成为了 Java 开发的首选框架。本文将结合我在实际工作中遇到的问题,分享五个高效的 Spring Boot 的技巧。希望这些技巧能对你有所帮助。
1. Spring Boot 执行初始化逻辑
1.1 背景
项目的某次更新,数据库中的某张表新增了一个字段,且与业务有关联,需要对新建的字段根据对应的业务进行赋值操作。
一种解决方案就是,更新前手动写 SQL 更新字段的值,但这样做的效率太低,而且每给不同环境更新一次,就需要手动执行一次,容易出错且效率低。
另一种方案则是在项目启动时进行初始化操作,完成字段对应值的更新,这种方案效率更高且不容易出错。
1.2 实现
Spring Boot 提供了多种方案用于项目启动后执行初始化的逻辑。
- 实现
CommandLineRunner
接口,重写run方法。
@Slf4j
@Component
public class InitListen implements CommandLineRunner {
@Override
public void run(String... args) {
// 初始化相关逻辑...
}
}
- 实现
ApplicationRunner
接口,重写run方法。
@Slf4j
@Component
public class InitListen implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
// 初始化相关逻辑...
}
}
- 实现
ApplicationListener
接口
@Slf4j
@Configuration
public class StartClientListener implements ApplicationListener<ContextRefreshedEvent> {
@Override
public void onApplicationEvent(ContextRefreshedEvent arg0) {
// 初始化逻辑
}
}
针对于上述这个需求,如何实现仅更新一次字段的值?
可在数据库字典表中设置一个更新标识字段,每次执行初始化逻辑之前,校验判断下字典中的这个值,确认是否已经更新,如果已经更新,就不需要再执行更新操作了。
2. Spring Boot 动态控制数据源的加载
2.1 背景
期望通过在application.yml
文件中,添加一个开关来控制是否加载数据库。
2.2 实现
启动类上添加注解 @SpringBootApplication(exclude = { DataSourceAutoConfiguration.class }),代表禁止 Spring Boot 自动注入数据源。
新建 DataSourceConfig
配置类,用于初始化数据源。
在DataSourceConfig配置类上添加条件注解 @ConditionalOnProperty(name = "spring.datasource.enabled", havingValue = "true",代表只有当 spring.datasource.enabled 为 true时,加载数据库,其余情况不加载数据库。
仓库类 XxxRepository 的注入,需要使用注解 @Autowired(required = false)
详细可见文章:
Spring Boot 如何动态配置数据库的加载
3. Spring Boot 根据不同环境加载配置文件
3.1 背景
实际开发工作中,针对同一个项目,可能会存在开发环境、测试环境、正式环境等,不同环境的配置内容可能会不一致,如:数据库、Redis等等。期望在项目在启动时能够针对不同的环境来加载不同的配置文件。
3.2 实现
Spring 提供 Profiles 特性,通过启动时设置指令-Dspring.profiles.active
指定加载的配置文件,同一个配置文件中不同的配置使用---
来区分。
启动 jar 包时执行命令:
java -jar test.jar -Dspring.profiles.active=dev
-Dspring.profiles.active=dev
代表激活 profiles 为 dev 的相关配置。
## 用---区分环境,不同环境获取不同配置
---
# 开发环境
spring:
profiles: dev
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
# 命名空间为默认,所以不需要写命名空间
config:
server-addr: ${spring.cloud.nacos.discovery.server-addr}
extension-configs[0]:
data-id: database-base.yaml
group: DEFAULT_GR0UP
refresh: true
extension-configs[1]:
# 本地单机Redis
data-id: redis-base-auth.yaml
group: DEFAULT_GR0UP
refresh: true
extension-configs[2]:
data-id: master-base-auth.yaml
group: DEFAULT_GR0UP
refresh: true
---
#测试环境
spring:
profiles: test
cloud:
nacos:
discovery:
server-addr: 192.168.0.111:8904
# 测试环境注册的命名空间
namespace: b80b921d-cd74-4f22-8025-333d9b3d0e1d
config:
server-addr: ${spring.cloud.nacos.discovery.server-addr}
extension-configs[0]:
data-id: database-base-test.yaml
group: DEFAULT_GR0UP
refresh: true
extension-configs[1]:
data-id: redis-base-test.yaml
group: DEFAULT_GR0UP
refresh: true
extension-configs[2]:
data-id: master-auth-test.yaml
group: DEFAULT_GR0UP
refresh: true
---
# 生产环境
spring:
profiles: prod
cloud:
nacos:
discovery:
server-addr: 192.168.0.112:8848
config:
server-addr: ${spring.cloud.nacos.discovery.server-addr}
extension-configs[0]:
# 生产环境
data-id: database-auth.yaml
group: DEFAULT_GR0UP
refresh: true
extension-configs[1]:
# 生产环境
data-id: redis-base-auth.yaml
group: DEFAULT_GR0UP
refresh: true
extension-configs[2]:
data-id: master-base-auth.yaml
group: DEFAULT_GR0UP
refresh: true
也可以定义多个配置文件,如在application.yml中定义和环境无关的配置,而application-{profile}.yml
则根据环境做不同区分,如在 application-dev.yml 中定义开发环境相关配置、application-test.yml 中定义测试环境相关配置。
启动时指定环境命令同上,仍为:
java -jar test.jar -Dspring.profiles.active=dev
4. Spring Boot 配置文件加密
4.1 背景
配置文件中包含的敏感信息(如数据库密码)都会以明文的形式存储,这种情况可能会存在安全风险,期望通过加密配置文件,确保应用程序的安全。
4.2 实现
- pom.xml 文件中引入依赖。
<dependency>
<groupId>com.github.ulisesbocchio</groupId>
<artifactId>jasypt-spring-boot-starter</artifactId>
<version>2.1.2</version>
</dependency>
如果遇到 Unresolved dependency: 'com.github.ulisesbocchio:jasypt-spring-boot-starter:jar:2.1.2' 的错误,可执行
mvn clean install -U
强制更新依赖。
- application.yml 文件中增加配置如下:
jasypt:
encryptor:
password: G0C3D17o2n6
algorithm: PBEWithMD5AndDES
- 执行测试用例,获取加密后的内容。
@RunWith(SpringRunner.class)
@SpringBootTest
public class DatabaseTest {
@Autowired
private StringEncryptor encryptor;
@Test
public void getPass() {
String url = encryptor.encrypt("jdbc:mysql://localhost:3306/demo");
String name = encryptor.encrypt("root");
String password = encryptor.encrypt("123456");
System.out.println("database url: " + url);
System.out.println("database name: " + name);
System.out.println("database password: " + password);
Assert.assertTrue(url.length() > 0);
Assert.assertTrue(name.length() > 0);
Assert.assertTrue(password.length() > 0);
}
}
根据测试用例获取的结果,将加密后的字符串替换明文。
- 启动程序,验证数据库能否正常连接。
为了防止 jasypt.encryptor.password 泄露,反解出密码,有两种方案:
- 将 jasypt.encryptor.password 设置为环境变量,如:
vim /etc/profile
export jasypt.encryptor.password=YOUR_SECRET_KEY
- 将 jasypt.encryptor.password 作为启动程序的参数,如:
java -jar xxx.jar -Djasypt.encryptor.password=YOUR_SECRET_KEY
5. Spring Boot对打包好的jar包瘦身
5.1 背景
Sprng Boot项目的 jar 包动辄几百MB,如果有小的需求更新或者是Bug修复,就需要重新打包部署,改了一行代码,却上传几百MB的文件,这样会很浪费时间。
期望通过给 jar 包瘦身,从而节省部署的时间。
5.2 实现
pom.xml
文件中添加如下配置:
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<executable>true</executable>
<layout>ZIP</layout>
<!--这里是填写需要包含进去的jar,
必须项目中的某些模块,会经常变动,那么就应该将其坐标写进来
如果没有则nothing ,表示不打包依赖 -->
<includes>
<include>
<groupId>nothing</groupId>
<artifactId>nothing</artifactId>
</include>
</includes>
</configuration>
</plugin>
<!--拷贝依赖到jar外面的lib目录-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>copy</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<!--指定的依赖路径-->
<outputDirectory>
${project.build.directory}/lib
</outputDirectory>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
- 执行
mvn clean package
得到 jar 包,在项目启动时,需要通过 -Dloader.path指定lib的路径,如:
java -Dloader.path=./lib -jar testProject-0.0.1-SNAPSHOT.jar
效果如下:
通过分析 jar 包的结构可以得知,jar 包的 “大” 实际上是因为在打包时,会将项目所依赖的 jar 包放在 lib 夹文件中。而这部分依赖在版本迭代稳定后,基本是不会变化的。
上述这种给 jar 包瘦身的方案,实际上是在打包的时候忽略 lib 文件夹中的这些依赖,将这部分不变的依赖提前放到服务器上,打出来的 jar 包就变小了,从而提升发版效率。
参考资料
zhuanlan.zhihu.com/p/646593227
来源:juejin.cn/post/7424906244215193636
go的生态真的一言难尽
前言
标题党了,原生go很好用,只不过我习惯了java封装大法。最近在看golang,因为是javaer,所以突发奇想,不如开发一个类似于 Maven 或 Gradle 的构建工具来管理 Go 项目的依赖,众所周知,构建和发布是一个复杂的任务,但通过合理的设计和利用现有的工具与库,可以实现一个功能强大且灵活的工具。
正文分为两部分:项目本身和如何使用
一、项目本身
1. 项目需求分析
核心需求
- 依赖管理:
- 解析和下载 Go 项目的依赖。
- 支持依赖版本控制和冲突解决。
- 构建管理:
- 支持编译 Go 项目。
- 支持跨平台编译。
- 支持自定义构建选项。
- 发布管理:
- 打包构建结果。
- 支持发布到不同的平台(如 Docker Hub、GitHub Releases)。
- 任务管理:
- 支持定义和执行自定义任务(如运行测试、生成文档)。
- 插件系统:
- 支持扩展工具的功能。
可选需求
- 缓存机制:缓存依赖和构建结果以提升速度。
- 并行执行:支持并行下载和编译。
2. 技术选型
2.1 编程语言
- Go 语言:由于我们要构建的是 Go 项目的构建工具,选择 Go 语言本身作为开发语言是合理的。
2.2 依赖管理
- Go Modules:Go 自带的依赖管理工具已经很好地解决了依赖管理的问题,可以直接利用 Go Modules 来解析和管理依赖。
2.3 构建工具
- Go 标准库:Go 的标准库提供了强大的编译和构建功能(如
go build
,go install
等命令),可以通过调用这些命令或直接使用相关库来进行构建。
2.4 发布工具
- Docker:对于发布管理,可能需要集成 Docker 来构建和发布 Docker 镜像。
- upx:用于压缩可执行文件。
2.5 配置文件格式
- YAML 或 TOML:选择一种易于阅读和编写的配置文件格式,如 YAML 或 TOML。
3. 系统架构设计
3.1 模块划分
- 依赖管理模块:
- 负责解析和下载项目的依赖。
- 构建管理模块:
- 负责编译 Go 项目,支持跨平台编译和自定义构建选项。
- 发布管理模块:
- 负责将构建结果打包和发布到不同平台。
- 任务管理模块:
- 负责定义和执行自定义任务。
- 插件系统:
- 提供扩展点,允许用户编写插件来扩展工具的功能。
3.2 系统流程
- 初始化项目:读取配置文件,初始化项目环境。
- 依赖管理:解析依赖并下载。
- 构建项目:根据配置文件进行项目构建。
- 执行任务:执行用户定义的任务(如测试)。
- 发布项目:打包构建结果并发布到指定平台。
4. 模块详细设计与实现
4.1 依赖管理模块
4.1.1 设计
利用 Go Modules 现有的功能来管理依赖。可以通过 go list
命令来获取项目的依赖:
4.1.2 实现
package dependency
import (
"fmt"
"os/exec"
)
// ListDependencies 列出项目所有依赖
func ListDependencies() ([]byte, error) {
cmd := exec.Command("go", "list", "-m", "all")
return cmd.Output()
}
// DownloadDependencies 下载项目所有依赖
func DownloadDependencies() error {
cmd := exec.Command("go", "mod", "download")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("download failed: %s", output)
}
return nil
}
4.2 构建管理模块
4.2.1 设计
调用 Go 编译器进行构建,支持跨平台编译和自定义构建选项。
4.2.2 实现
package build
import (
"fmt"
"os/exec"
"runtime"
"path/filepath"
)
// BuildProject 构建项目
func BuildProject(outputDir string) error {
// 设置跨平台编译参数
var goos, goarch string
switch runtime.GOOS {
case "windows":
goos = "windows"
case "linux":
goos = "linux"
default:
goos = runtime.GOOS
}
goarch = "amd64"
output := filepath.Join(outputDir, "myapp")
cmd := exec.Command("go", "build", "-o", output, "-ldflags", "-X main.version=1.0.0")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("build failed: %s", output)
}
fmt.Println("Build successful")
return nil
}
4.3 发布管理模块
4.3.1 设计
打包构建结果并发布到不同平台。例如,构建 Docker 镜像并发布到 Docker Hub。
4.3.2 实现
package release
import (
"fmt"
"os/exec"
)
// BuildDockerImage 构建 Docker 镜像
func BuildDockerImage(tag string) error {
cmd := exec.Command("docker", "build", "-t", tag, ".")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker build failed: %s", output)
}
fmt.Println("Docker image built successfully")
return nil
}
// PushDockerImage 推送 Docker 镜像
func PushDockerImage(tag string) error {
cmd := exec.Command("docker", "push", tag)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker push failed: %s", output)
}
fmt.Println("Docker image pushed successfully")
return nil
}
5. 任务管理模块
允许用户定义和执行自定义任务:
package task
import (
"fmt"
"os/exec"
)
type Task func() error
func RunTask(name string, task Task) {
fmt.Println("Running task:", name)
err := task()
if err != nil {
fmt.Println("Task failed:", err)
return
}
fmt.Println("Task completed:", name)
}
func TestTask() error {
cmd := exec.Command("go", "test", "./...")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("tests failed: %s", output)
}
fmt.Println("Tests passed")
return nil
}
6. 插件系统
可以通过动态加载外部插件或使用 Go 插件机制来实现插件系统:
package plugin
import (
"fmt"
"plugin"
)
type Plugin interface {
Run() error
}
func LoadPlugin(path string) (Plugin, error) {
p, err := plugin.Open(path)
if err != nil {
return nil, err
}
symbol, err := p.Lookup("PluginImpl")
if err != nil {
return nil, err
}
impl, ok := symbol.(Plugin)
if !ok {
return nil, fmt.Errorf("unexpected type from module symbol")
}
return impl, nil
}
5. 示例配置文件
使用 YAML 作为配置文件格式,定义项目的构建和发布选项:
name: myapp
version: 1.0.0
build:
options:
- -ldflags
- "-X main.version=1.0.0"
release:
docker:
image: myapp:latest
tag: v1.0.0
tasks:
- name: test
command: go test ./...
6. 持续改进
后续我将持续改进工具的功能和性能,例如:
- 增加更多的构建和发布选项。
- 优化依赖管理和冲突解决算法。
- 提供更丰富的插件。
二、如何使用
1. 安装构建工具
我已经将构建工具发布到 GitHub 并提供了可执行文件,用户可以通过以下方式安装该工具。
1.1 使用安装脚本安装
我将提供一个简单的安装脚本,开发者可以通过 curl
或 wget
安装构建工具。
使用 curl
安装
curl -L https://github.com/yunmaoQu/GoForge/releases/download/v1.0.0/install.sh | bash
使用 wget
安装
wget -qO- https://github.com//yunmaoQu/GoForge/releases/download/v1.0.0/install.sh | bash
1.2 手动下载并安装
如果你不想使用自动安装脚本,可以直接从 GitHub Releases 页面手动下载适合你操作系统的二进制文件。
- 访问 GitHub Releases 页面。
- 下载适合你操作系统的二进制文件:
- Linux:
GoForge-linux-amd64
- macOS:
GoForge-darwin-amd64
- Windows:
GoForge-windows-amd64.exe
- Linux:
- 将下载的二进制文件移动到系统的 PATH 路径(如
/usr/local/bin/
),并确保文件有执行权限。
# 以 Linux 系统为例
mv GoForge-linux-amd64 /usr/local/bin/GoForge
chmod +x /usr/local/bin/GoForge
2. 创建 Go 项目并配置构建工具
2.1 初始化 Go 项目
假设你已经有一个 Go 项目或你想创建一个新的 Go 项目。首先,初始化 Go 模块:
mkdir my-go-project
cd my-go-project
go mod init github.com/myuser/my-go-project
2.2 创建 build.yaml
文件
在项目根目录下创建 build.yaml
文件,这个文件类似于 Maven 的 pom.xml
或 Gradle 的 build.gradle
,用于配置项目的依赖、构建任务和发布任务。
示例 build.yaml
project:
name: my-go-project
version: 1.0.0
dependencies:
- name: github.com/gin-gonic/gin
version: v1.7.7
- name: github.com/stretchr/testify
version: v1.7.0
build:
output: bin/my-go-app
commands:
- go build -o bin/my-go-app main.go
tasks:
clean:
command: rm -rf bin/
test:
command: go test ./...
build:
dependsOn:
- test
command: go build -o bin/my-go-app main.go
publish:
type: github
repo: myuser/my-go-project
token: $GITHUB_TOKEN
assets:
- bin/my-go-app
配置说明:
- project: 定义项目名称和版本。
- dependencies: 列出项目的依赖包及其版本号。
- build: 定义构建输出路径和构建命令。
- tasks: 用户可以定义自定义任务(如
clean
、test
、build
等),并可以配置任务依赖关系。 - publish: 定义发布到 GitHub 的配置,包括发布的仓库和需要发布的二进制文件。
3. 执行构建任务
构建工具允许你通过命令行执行各种任务,如构建、测试、清理、发布等。以下是一些常用的命令。
3.1 构建项目
执行以下命令来构建项目。该命令会根据 build.yaml
文件中定义的 build
任务进行构建,并生成二进制文件到指定的 output
目录。
GoForge build
构建过程会自动执行依赖任务(例如 test
任务),确保在构建之前所有测试通过。
3.2 运行测试
如果你想单独运行测试,可以使用以下命令:
GoForge test
这将执行 go test ./...
,并运行所有测试文件。
3.3 清理构建产物
如果你想删除构建生成的二进制文件等产物,可以运行 clean
任务:
GoForge clean
这会执行 rm -rf bin/
,清理 bin/
目录下的所有文件。
3.4 列出所有可用任务
如果你想查看所有可用的任务,可以运行:
GoForge tasks
这会列出 build.yaml
文件中定义的所有任务,并显示它们的依赖关系。
4. 依赖管理
构建工具会根据 build.yaml
中的 dependencies
部分来处理 Go 项目的依赖。
4.1 安装依赖
当执行构建任务时,工具会自动解析依赖并安装指定的第三方库(类似于 go mod tidy
)。
你也可以单独运行以下命令来手动处理依赖:
GoForge deps
4.2 更新依赖
如果你需要更新依赖版本,可以在 build.yaml
中手动更改依赖的版本号,然后运行 mybuild deps
来更新依赖。
5. 发布项目
构建工具提供了发布项目到 GitHub 等平台的功能。根据 build.yaml
中的 publish
配置,你可以将项目的构建产物发布到 GitHub Releases。
5.1 配置发布相关信息
确保你在 build.yaml
中正确配置了发布信息:
publish:
type: github
repo: myuser/my-go-project
token: $GITHUB_TOKEN
assets:
- bin/my-go-app
- type: 发布的目标平台(GitHub 等)。
- repo: GitHub 仓库路径。
- token: 需要设置环境变量
GITHUB_TOKEN
,用于认证 GitHub API。 - assets: 指定发布时需要上传的二进制文件。
5.2 发布项目
确保你已经完成构建,并且生成了二进制文件。然后,你可以执行以下命令来发布项目:
GoForge publish
这会将 bin/my-go-app
上传到 GitHub Releases,并生成一个新的发布版本。
5.3 测试发布(Dry Run)
如果你想在发布之前测试发布流程(不上传文件),可以使用 --dry-run
选项:
GoForge publish --dry-run
这会模拟发布过程,但不会实际上传文件。
6. 高级功能
6.1 增量构建
构建工具支持增量构建,如果你在 build.yaml
中启用了增量构建功能,工具会根据文件的修改时间戳或内容哈希来判断是否需要重新构建未被修改的部分。
build:
output: bin/my-go-app
incremental: true
commands:
- go build -o bin/my-go-app main.go
6.2 插件机制
你可以通过插件机制来扩展构建工具的功能。例如,你可以为工具增加自定义的任务逻辑,或在构建生命周期的不同阶段插入钩子。
在 build.yaml
中定义插件:
plugins:
- name: custom-task
path: plugins/custom-task.go
编写 custom-task.go
,并实现你需要的功能。
7. 调试和日志
如果你在使用时遇到了问题,可以通过以下方式启用调试模式,查看详细的日志输出:
GoForge --debug build
这会输出工具在执行任务时的每一步详细日志,帮助你定位问题。
总结
通过这个构建工具,你可以轻松管理 Go 项目的依赖、构建过程和发布任务。以下是使用步骤的简要总结:
- 安装构建工具:使用安装脚本或手动下载二进制文件。
- 配置项目:创建
build.yaml
文件,定义依赖、构建任务和发布任务。 - 执行任务:通过命令行执行构建、测试、清理等任务。
- 发布项目:将项目的构建产物发布到 GitHub 或其他平台。
来源:juejin.cn/post/7431545806085423158
不是,哥们,谁教你这样处理生产问题的?
你好呀,我是歪歪。
最近遇到一个生产问题,我负责的一个服务触发了内存使用率预警,收到预警的时候我去看了内存使用率已经到了 80%,看了一眼 GC 又发现还没有触发 FullGC,一次都没有。
基于这个现象,当时推测有两种可能,一种是内存溢出,一种是内存泄漏。
好,假设现在是面试,面试官目前就给了这点信息,他问你到底是溢出还是泄漏,你怎么回答?
在回答之前,我们得现明确啥是溢出,啥情况又是泄漏。
- 内存溢出(OutOfMemoryError):内存溢出指的是程序请求的内存超出了 JVM 当前允许的最大内存容量。当 JVM 试图为一个对象分配内存时,如果当前可用的堆内存不足以满足需求,就会抛出 java.lang.OutOfMemoryError 异常。这通常是因为堆空间太小或者由于某些原因导致堆空间被占满。
- 内存泄漏 (Memory Leak):内存泄漏是指不再使用的内存空间没有被释放,导致这部分内存无法再次被使用。虽然内存泄漏不会立即导致程序崩溃,但它会逐渐消耗可用内存,最终可能导致内存溢出。
虽然都与内存相关,但它们发生的时机和影响有所不同。内存溢出通常发生在程序运行时,当数据结构的大小超过预设限制时,常见的情况是你要分配一个大对象,比如一次从数据中查到了过多的数据。
而内存泄漏和“过多”关系不大,是一个细水长流的过程,一次内存泄漏的影响可能微乎其微,但随着时间推移,多次内存泄漏累积起来,最终可能导致内存溢出。
概念就是这个概念,这两个玩意经常被大家搞混,所以多嘴提一下。
概念明确了,回到最开始这个问题,你怎么回答?
你回答不了。
因为这些信息太不完整了,所以你回答不了。
面试的时候面试官就喜欢出这种全是错误选项的题目来迷惑你,摸摸你的底子到底怎么样。
首先,为什么不能判断,是因为前面说了:一次 FullGC 都没有。
虽然现在内存使用率已经到 80% 了,万一一次 FullGC 之后,内存使用率又下去了呢,说明程序没有任何问题。
如果没有下去,说明大概率是内存溢出了,需要去代码里面找哪里分配了大对象了。
那如果下去了,能说明一定没有内存泄漏吗?
也不能,因为前面又说了:内存泄漏是一个细水长流的过程。
关于内存溢出,如果监控手段齐全到位的话,你就记住左边这个走势图:
一个缓慢的持续上升的内存趋势图, 最后疯狂触发 GC,但是并没有内存被回收,最后程序直接崩掉。
内存泄漏,一眼定真假。
这个图来自我去年写的这篇文章:《虽然是我遇到的一个棘手的生产问题,但是我写出来之后,就是你的了。》
里面就是描述了一个内存泄漏的问题,通过分析 Dump 文件的方式,最终成功定位到泄漏点,修复代码。
一个不论多么复杂的内存泄漏问题,处理起来都是有方法论的。
不过就是 Dump 文件分析、工具的使用以及足够的耐心和些许的运气罢了。
所以我不打算赘述这些东西了,我想要分享的是我这次是怎么对应文章开始说的内存预警的。
我的处理方式就是:重启服务。
是的,常规来说都是会保留现场,然后重启服务。但是我的处理方式是:直接执行重启服务的预案。没有后续动作了。
我当时脑子里面的考虑大概是这样的。
首先,这个服务是一个边缘服务,它所承载的数据量不多,其业务已经超过一年多没有新增,存量数据正在慢慢的消亡。代码近一两年没啥改动,只有一些升级 jar 包,日志埋点这类的横向改造。
其次,我看了一下这个服务已经有超过四个月没有重启过了,这期间没有任何突发流量,每天处理的数据呈递减趋势,内存走势确实是一个缓慢上升的过程,我初步怀疑是有内存泄漏。
然后,这个服务是我从别的团队那边接手的一个服务,基于前一点,业务正在消亡这个因素,我也只是知道大概的功能,并不知道内部的细节,所以由于对系统的熟悉度不够,如果要定位问题,会较为困难。
最后,基于公司制度,虽然我知道应该怎么去排查问题,命令和工具我都会使用,但是我作为开发人员是没有权限使用运维人员的各类排查工具和排查命令的,所以如果要定位问题,我必须请求协调一个运维同事帮忙。
于是,在心里默默的盘算了一下投入产出比,我决定直接重启服务,不去定位问题。
按照目前的频率,程序正常运行四五个月后可能会触发内存预警,那么大不了就每隔三个月重启一次服务嘛,重启一次只需要 30s。一年按照重启 4 次算,也就是才 2 分钟。
这个业务我们就算它要五年后才彻底消亡,那么也就才 10 分钟而已。
如果我要去定位到底是不是内存泄露,到底在哪儿泄露的,结合我对于系统的熟悉程度和公司必须有的流程,这一波时间消耗,少说点,加起来得三五个工作日吧。
10 分钟和三五个工作日,这投入产出比,该选哪个,一目了然了吧?
我分享这个事情的目的,其实就是想说明我在这个事情上领悟到的一个点:在工作中,你遇到的问题,不是每一个都必须被解决的,也可以选择绕过问题,只要最终结果是好的就行。
如果我们抛开其他因素,只是从程序员的本职工作来看,那么遇到诸如内存泄漏的问题的时候,就是应该去定位问题、解决问题。
但是在职场中,其实还需要结合实际情况,进行分析。
什么是实际情况呢?
我前面列出来的那个“首先,其次,然后,最后”,就是我这个问题在技术之外的实际情况。
这些实际情况,让我决定不用去定位这个问题。
这也不是逃避问题,这是权衡利弊之后的最佳选择。
同样是一天的时间,我可以去定位这个“重启就能解决”的问题,也可以去做其他的更有价值事情,敲一些业务价值更大的代码。
这个是需要去权衡的,一个重要的衡量标准就是前面说的:投入产出比。
关于“不是所有的问题都必须被解决的,也可以选择绕过问题”这个事情,我再给你举一个我遇到的真实的例子。
几年前,我们团队遇到一个问题,我们使用的 RPC 框架是 Dubbo,有几个核心服务在投产期间滚动发布的时候,流量老是弄不干净,导致服务已经下线了,上游系统还在调用。
当时安排我去调研一下解决方案。
其实这就是一个优雅下线的问题,但是当时资历尚浅,我认真研究了一段时间,确实没研究出问题的根本解决方案。
后来我们给出的解决方案就是做一个容错机制,如果投产期间有因为流量不干净的问题导致请求处理失败的,我们把这些数据记录下来,然后等到投产完成后再进行重发。
没有解决根本问题,选择绕过了问题,但是从最终结果上看,问题是被解决了。
再后来,我们搭建了双中心。投产之前,A,B 中心都有流量,每次投产的时候,先把所有流量从 A 中心切到 B 中心去,在 A 中心没有任何流量的情况下,进行服务投产。B 中心反之。
这样,从投产流程上就规避了“流量老是弄不干净”的问题,因为投产的时候对应的服务已经没有在途流量了,不需要考虑优雅的问题了,从而规避了优雅下线的问题。
问题还是没有被解决,但是问题被彻底绕过。
最后,再举一个我在知乎上看到的一个回答,和我想要表达的观点,有异曲同工之妙:
http://www.zhihu.com/question/63…
这个回答下面的评论也很有意思,有兴趣的可以去翻一下,我截取两个我觉得有意思的:
在职场上,甚至在生活中,一个虽然没有解决方案但是可以被绕过的问题,我认为不是问题。
但是这个也得分情况,不是所有问题都能绕开的,假如是一个关键服务,那肯定不能置之不理,硬着头皮也得上。
关键是,我在职场上和生活中遇到过好多人,遇到问题的时候,似乎只会硬着头皮往上冲。
只会硬着头皮往上冲和知道什么时候应该硬着头皮往上冲,是两种截然不同的职场阶段。
所以有时候,遇到问题的时候,不要硬上,也让头皮休息一下,看看能不能绕过去。
来源:juejin.cn/post/7417842116506058771
三行五行的 SQL 只存在于教科书和培训班
教科书中 SQL 例句通常都很简单易懂,甚至可以当英语来读,这就给人造成 SQL 简单易学的印象。
但实际上,这种三行五行的 SQL 只存在于教科书和培训班,我们在现实业务中写的 SQL 不会论行,而是以 K 计的,一条 SQL 几百行 N 层嵌套,写出 3K5K 是常事,这种 SQL,完全谈不上简单易学,对专业程序员都是恶梦。
以 K 计本身倒不是大问题,需求真地复杂时,也只能写得长,Python/Java 代码可能会更长。但 SQL 的长和其它语言的长不一样,SQL 的长常常会意味着难写难懂,而且这个难写难懂和任务复杂度不成比例。除了一些最简单情况外,稍复杂些的任务,SQL 的难度就会陡增,对程序员的智商要求很高,所以经常用作应聘考题。
这是为什么呢?
其中一个原因是我们之前讲过的,SQL 像英语而缺乏过程性,要把很多动作搅合在一句中,凭空地增大思维难度。
但是我们会发现,即使 SQL 增加了步骤化的 CTE 语法,面对稍复杂的任务时,仍然会写的非常难懂。
这是因为,SQL 的描述能力还有不少重要的缺失,这导致程序员不能按自然思维写代码,要换着方法绕。
我们通过一个简单的例子来看一下。
简化的销售业绩表 T 有三个字段:sales 销售员,product 产品,amount 销售额。我们想知道空调和电视销售额都在前 10 名的销售员名单。
这个问题并不难,可以很自然地设计出计算过程:
1.按空调销售额排序,找出前 10 名;
2.按电视销售额排序,找出前 10 名;
3.对 1、2 的结果取交集,得到我们想要的
用 CTE 语法后 SQL 可以写成这样:
with A as (select top 10 sales from T where product='AC' order by amount desc),
B as (select top 10 sales from T where product='TV' order by amount desc)
select * from A intersect B
这个句子不太短,但思路还是清晰的。
现在,我们把问题复杂化一点,改为计算所有产品销售额都在前 10 名的销售员,延用上述的思路很容易想到:
1. 列出所有产品;
2. 算出每种产品销售额的前 10 名,分别保存;
3. 针对这些前 10 名取交集;
遗憾开始出现,CTE 语法只能写出确定个数的中间结果。而我们事先不知道总共有多个产品,也就是说 WITH 子句的个数是不确定的,这就写不出来了。
好吧,换一种思路:
1.将数据按产品分组,将每组排序,计算出每组前 10 名;
2.针对这些前 10 名取交集;
这需要把第一步的分组结果保存起来,而这个中间结果是一个表,其中有个字段要存储对应的分组成员的前 10 名,也就是字段的取值将是个集合,SQL 不支持这种数据类型,还是写不出来。
我们可以再转换思路。按产品分组后,计算每个销售员在所有分组的前 10 名中出现的次数,若与产品总数相同,则表示该销售员在所有产品销售额中均在前 10 名内。
select sales from (
select sales from (
select sales, rank() over (partition by product order by amount desc ) ranking
from T ) where ranking <=10 )
group by sales having count(*)=(select count(distinct product) from T)
在窗口函数支持下,终于能写出来了。但是,这样的思路,绕不绕呢,有多少人想到并写出来呢?
前两种简单的思路无法用 SQL 实现,只能采用第三种迂回的思路。这里的原因在于 SQL 的一个重要缺失:集合化不彻底。
SQL 有集合概念,但并未把集合作为一种基础数据类型提供,不允许字段取值是集合,除了表之外也没有其它集合形式的数据类型,这使得大量集合运算在思维和书写时都非常绕。
我们刚才用了关键字 top,事实上关系代数理论中没有这个东西,这不是 SQL 的标准写法。
没有 top 如何找前 10 名呢?
大体思路是这样:找出比自己大的成员个数作为是名次,然后取出名次不超过 10 的成员
select sales from (
select A.sales sales, A.product product,
(select count(*)+1 from T
where A.product=product and A.amount<=amount) ranking
from T A )where product='AC' and ranking<=10
注意,这里的子查询没办法用 CTE 语法分步写,因为它用到了主查询中的信息作为参数。
或可以用连接来写,这样子查询倒是可以用 CTE 语法分步了:
select sales from (
select A.sales sales, A.product product, count(*)+1 ranking from T A, T B
where A.sales=B.sales and A.product=B.product and A.amount<=B.amount
gr0up by A.sales,A.product )
where product='AC' and ranking<=10
无论如何,这种东西都太绕了,专业程序员也要想一阵子,仅仅是计算了一个前 10 名。
造成这个现象的原因就是 SQL 的另一个缺失:缺乏有序支持。SQL 继承了数学上的无序集合,与次序有关的计算相当困难,而可想而知,与次序有关的计算会有多么普遍(诸如比上月、比去年同期、前 20%、排名等)。
SQL2003 标准中增加的窗口函数提供了一些与次序有关的计算能力,这在一定程度上缓解 SQL 有序计算的困难,前 10 名可以这样写:
select sales from (
select sales, rank() over (partition by product order by amount desc ) ranking
from T )
where ranking <=10
还是要用子查询。
窗口函数并没有根本改变 SQL 无序集合的基础,还是会有许多有序运算难以解决。比如我们经常用来举例的,计算一支股票最长连续上涨了多少天:
select max(ContinuousDays) from (
select count(*) ContinuousDays from (
select sum(UpDownTag) over (order by TradeDate) NoRisingDays from (
select TradeDate,case when Price>lag(price) over ( order by TradeDate) then 0 else 1 end UpDownTag from Stock ))
group by NoRisingDays )
自然思维是这样,按日期排序后开始计数,碰到涨了就加 1,跌了就清 0,看计数器最大计到几。但这个思路写不出 SQL,只能绕成这样多层嵌套的。
这个问题真地是当作应聘考题的,通过率不到 20%。
这么一个简单的例子就能暴露出 SQL 缺失的能力,SQL 缺失的内容还有更多,限于篇幅,这里就不再深入讨论了。
反正结果就是,SQL 实现查询时无法应用自然思路,经常需要绕路迂回,写得又长又难懂。
现实任务要远远比这些例子复杂,过程中会面临诸多大大小小的困难。这个问题绕一下,那个问题多几行,一个稍复杂的任务写出几百行多层嵌套的 SQL 也就不奇怪了,过两月自己也看不懂也不奇怪了。
事实上 SQL 一点也不容易。
下面是广告时间。
SQL 很难写怎么办?用 esProc SPL!
esProc SPL 是个 Java 写的开源软件,在这里github.com/SPLWare/esP…
SPL 在 SQL 已有的集合化基础上增加了离散性,从而获得了彻底的集合化和有序能力,上面的例子就 SPL 就可以延用自然思路写出来:
所有产品销售额都在前 10 名的销售员,按产品分组,取每个组的前 10 名再算交集;
T.group(product).(~.top(10;-amount)).isect()
SPL 支持集合的集合,top 也只是常规的聚合计算,有了这些基础,实现自然思路很容易。
一支股票最长连续上涨了多少天,只要按自然思路写就行了
cnt=0
Stock.sort(TradeDate).max(cnt=if(Price>Price[-1],cnt+1,0))
SPL 有强大的有序计算能力,即使实现和上面 SQL 同样的逻辑也非常轻松:
Stock.sort(TradeDate).group@i(Price<Price[-1]).max(~.len())
来源:juejin.cn/post/7441756894094491689
✨Try-Catch✨竟然会影响性能
前言
一朋友问我Try-Catch写多了会不会让程序变慢,我不加思索的回答肯定不会,毕竟曾经研究过Java异常相关的字节码指令,只要被Try-Catch的代码不抛出异常,那么代码执行链路是不会加深的。
可事后我反复思考这个看似简单实则也不复杂的问题,我觉得顺着这个问题往下,还有一些东西可以思考,如果你感兴趣,那就跟随本文的视角一起来看下吧。
正文
首先郑重声明,单纯的针对一段代码添加Try-Catch,是 不会
影响性能的,我们可以通过下面的示例代码并结合字节码指令来看下。
示例代码如下所示。
public class TryCatchPerformance {
public Response execute(String state) {
return innerHandle(state);
}
public Response innerHandle(String state) {
// todo 暂无逻辑
return null;
}
public static class Response {
private int state;
public Response(int state) {
this.state = state;
}
public int getState() {
return state;
}
public void setState(int state) {
this.state = state;
}
}
}
我们依次执行如下语句为上述代码生成字节码指令。
# 编译Java文件
javac .\TryCatchPerformance.java
# 反汇编字节码文件
javap -c .\TryCatchPerformance.class
可以得到execute() 方法的字节码指令如下。
public com.lee.learn.exception.TryCatchPerformance$Response execute(java.lang.String);
Code:
0: aload_0
1: aload_1
2: invokevirtual #2 // Method innerHandle:(Ljava/lang/String;)Lcom/lee/learn/exception/TryCatchPerformance$Response;
5: areturn
现在对execute() 方法添加Try-Catch,如下所示。
public class TryCatchPerformance {
public Response execute(String state) {
try {
return innerHandle(state);
} catch (Exception e) {
return new Response(500);
}
}
public Response innerHandle(String state) {
// todo 暂无逻辑
return null;
}
public static class Response {
private int state;
public Response(int state) {
this.state = state;
}
public int getState() {
return state;
}
public void setState(int state) {
this.state = state;
}
}
}
查看execute() 方法的字节码指令如下所示。
public com.lee.learn.exception.TryCatchPerformance$Response execute(java.lang.String);
Code:
0: aload_0
1: aload_1
2: invokevirtual #2 // Method innerHandle:(Ljava/lang/String;)Lcom/lee/learn/exception/TryCatchPerformance$Response;
5: areturn
6: astore_2
7: new #4 // class com/lee/learn/exception/TryCatchPerformance$Response
10: dup
11: sipush 500
14: invokespecial #5 // Method com/lee/learn/exception/TryCatchPerformance$Response."":(I)V
17: areturn
Exception table:
from to target type
0 5 6 Class java/lang/Exception
虽然添加Try-Catch后,字节码指令增加了很多条,但是通过Exception table(异常表)我们可知,只有指令0到5在执行过程中抛出了Exception,才会跳转到指令6开始执行,换言之只要不抛出异常,那么在执行完指令5后方法就结束了,此时和没添加Try-Catch时的代码执行链路是一样的,也就是不抛出异常时,Try-Catch不会影响程序性能。
我们添加Try-Catch,其实就是为了做异常处理,也就是我们天然的认为被Try-Catch的代码就是会抛出异常的,而异常一旦发生,此时程序性能就会受到一定程度的影响,表现在如下两个方面。
- 异常对象创建有性能开销。具体表现在异常对象创建时会去爬栈得到方法调用链路信息;
- Try-Catch捕获到异常后会让代码执行链路变深。
由此可见Try-Catch其实不会影响程序性能,但是异常的出现的的确确会影响,无论是JVM创建的异常,还是我们在代码中new出来的异常,都是会影响性能的。
所以现在我们来看看如下代码有什么可以优化的地方。
public class TryCatchPerformance {
public Response execute(String state) {
try {
return innerHandle(state);
} catch (Exception e) {
return new Response(500);
}
}
public Response innerHandle(String state) {
if (state == null || state.isEmpty()) {
// 通过异常中断执行
throw new IllegalStateException();
} else if ("success".equals(state)) {
return new Response(200);
} else {
return new Response(400);
}
}
public static class Response {
private int state;
public Response(int state) {
this.state = state;
}
public int getState() {
return state;
}
public void setState(int state) {
this.state = state;
}
}
}
上述代码的问题出现在innerHandle() ,仗着调用方有Try-Catch做异常处理,就在入参非法时通过创建异常来中断执行,我相信在实际的工程开发中,很多时候大家都是这么干的,因为有统一异常处理,那么通过抛出异常来中断执行并在统一异常处理的地方返回响应,是一件再平常不过的事情了,但是通过前面的分析我们知道,创建异常有性能开销,捕获异常并处理也有性能开销,这些性能开销其实是可以避免的,例如下面这样。
public class TryCatchPerformance {
public Response execute(String state) {
try {
return innerHandle(state);
} catch (Exception e) {
return new Response(500);
}
}
public Response innerHandle(String state) {
if (state == null || state.isEmpty()) {
// 通过提前返回响应的方式中断执行
return new Response(500);
} else if ("success".equals(state)) {
return new Response(200);
} else {
return new Response(400);
}
}
public static class Response {
private int state;
public Response(int state) {
this.state = state;
}
public int getState() {
return state;
}
public void setState(int state) {
this.state = state;
}
}
}
如果当某个分支执行到了,我们也确切的知道该分支下的响应是什么,此时直接返回响应,相较于抛出异常后在统一异常处理那里返回响应,性能会更好。
总结
Try-Catch其实不会影响程序性能,因为在没有异常发生时,代码执行链路不会加深。
但是如果出现异常,那么程序性能就会受到影响,表现在如下两个方面。
- 异常对象创建有性能开销。具体表现在异常对象创建时会去爬栈得到方法调用链路信息;
- Try-Catch捕获到异常后会让代码执行链路变深。
因此在日常开发中,可以适当增加防御性编程来防止JVM抛出异常,也建议尽量将主动的异常抛出替换为提前返回响应,总之就是尽量减少非必要的异常出现。
来源:juejin.cn/post/7458929387784077349
又整新活,新版 IntelliJ IDEA 2024.1 有点东西!
就在上周,Jetbrains 又迎来了一波大版本更新,这也是 JetBrains 2024首个大动作!
JetBrains 为其多款 IDE 发布了 2024 年度首个大版本更新 (2024.1)。
作为旗下重要的产品之一,IntelliJ IDEA当然也不例外。这不,现如今 IntelliJ IDEA 也来到了 2024.1 大版本了!
据官方介绍,这次 2024.1 新版本进行了数十项改进。
下面就针对本次新版 IntelliJ IDEA 的一些主要更新和特性做一个梳理和介绍,希望能对大家有所帮助。
全行代码补全
IntelliJ IDEA Ultimate 2024.1 带有针对 Java 和 Kotlin 的全行代码补全。
该项功能由无缝集成到 IDE 中的高级深度学习模型来提供支持。它可以基于上下文分析预测和建议整行代码,以助于提高编码效率。
对 Java 22 的支持
IntelliJ IDEA 2024.1 提供了对 2024 年 3 月刚发布的 JDK 22 中的功能集的支持。
支持覆盖未命名变量与模式的最终迭代、字符串模板与隐式声明的类的第二个预览版,以及实例main方法。 此外,这次更新还引入了对super(...)
之前预览状态下的 new 语句支持。
新终端加持
IntelliJ IDEA 2024.1推出了重构后的新终端,具有可视化和功能增强,有助于简化命令行任务。
此更新为既有工具带来了全新的外观,命令被分为不同的块,扩展的功能集包括块间丝滑导航、命令补全和命令历史记录的轻松访问等。
编辑器中的粘性行
此次新版本更新在编辑器中引入了粘性行,旨在简化大文件的处理和新代码库的探索。滚动时,此功能会将类或方法的开头等关键结构元素固定到编辑器顶部。
这样一来作用域将始终保持在视野中,用户可以点击固定的行快速浏览代码。
AI Assistant 改进
在本次新版中,AI Assistant 获得了多项有价值的更新,包括改进的测试生成和云代码补全、提交消息的自定义提示语、从代码段创建文件的功能,以及更新的编辑器内代码生成。
不过需要注意的事,在这次 2024.1 版中,AI Assistant 已解绑,现在作为独立插件提供。这一改动是为了在使用 AI 赋能的技术方面提供更多的决策灵活度,让用户能够在工作环境中更好地控制偏好设置和要求。
索引编制期间 IDE 功能对 Java 和 Kotlin 的可用
这次新版本中,代码高亮显示和补全等基本 IDE 功能可在项目索引编制期间用于 Java 和 Kotlin,这将会增强用户项目的启动体验。
此外,用户可以在项目仍在加载时即使用 Go to class(转到类)和 Go to symbol(转到符号)来浏览代码。
更新的 New Project(新建项目)向导
为了减轻用户在配置新项目时的认知负担,新版微调了 New Project(新建项目)向导的布局。语言列表现在位于左上角,使最常用的选项更加醒目。
用于缩小整个 IDE 的选项
新版支持可以将 IDE 缩小到 90%、80% 或 70%,从而可以灵活地调整 IDE 元素的大小。
对Java支持的更新
- 字符串模板中的语言注入
IntelliJ IDEA 2024.1 引入了将语言注入字符串模板的功能。
用户既可以使用注解(注解会自动选择所需语言),也可以使用 Inject language or reference(注入语言或引用)来从列表中手动选择语言。
- 改进的日志工作流
由于日志记录是日常开发的重要环节,新版本引入了一系列更新来增强 IntelliJ IDEA 在日志方面的用户体验。
比如现在用户可以从控制台中的日志消息中轻松导航到生成它们的代码。
此外,IDE会在有需要的位置建议添加记录器,并简化插入记录器语句的操作,即便记录器实例不在作用域内。
- 新检查与快速修复
新版本为 Java 实现了新的检查和快速修复,帮助用户保持代码整洁无误。
比如,IDE 现在会检测可被替换为对 Long.hashCode() 或 Double.hashCode() 方法的调用的按位操作。
此外,新的快速修复也可以根据代码库的要求简化隐式和显式类声明之间的切换。
另一项新检查为匹配代码段建议使用现有 static 方法,使代码可以轻松重用,而无需引入额外 API。此外,IDE现在可以检测并报告永远不会执行的无法访问的代码。
- 重构的 Conflicts Detected(检测到冲突)对话框
这次版本 2024.1 重构了 Conflicts Detected(检测到冲突)对话框以提高可读性。
现在,对话框中的代码反映了编辑器中的内容,使用户可以更清楚地了解冲突,并且 IDE 会自动保存窗口大小调整以供将来使用。
另外,这次还更新了按钮及其行为以简化重构工作流,对话框现在可以完全通过键盘访问,用户可以使用快捷键和箭头键进行无缝交互。
- Rename(重命名)重构嵌入提示
为了使重命名流程更简单、更直观,新版推出了一个新的嵌入提示,在更改的代码元素上显示。要将代码库中的所有引用更新为新版本,点击此提示并确认更改即可。
版本控制系统改进
- 编辑器内的代码审查
IntelliJ IDEA 2024.1 为 GitHub 和 GitLab 用户引入了增强的代码审查体验。
该功能与编辑器集成,以促进作者与审查者直接互动。在检查拉取/合并请求分支时,审查模式会自动激活,并在装订区域中显示粉色标记,表明代码更改可供审查。
点击这些标记会弹出一个显示原始代码的弹出窗口,这样用户就能快速识别哪些代码已被更改。
装订区域图标可以帮助用户迅速发起新讨论,以及查看和隐藏现有讨论。另外这些图标还可以让用户更方便地访问评论,从而更轻松地完成查看、回复等功能。
- Log(日志)标签页中显示审查分支更改的选项
新版通过提供分支相关更改的集中视图来简化了代码审查工作流。
对于 GitHub、GitLab 和 Space,用户现在可以在 Git 工具窗口中的单独 Log(日志)标签页中查看具体分支中的更改。用户可以点击 Pull Requests(拉取请求)工具窗口中的分支名称,然后从菜单中选择 Show in Git Log(在 Git 日志中显示)。
- 对代码审查评论回应的支持
新版开始支持对 GitHub 拉取请求和 GitLab 合并请求的审查评论发表回复,目前已有一组表情符号可供选择。
- 从推送通知创建拉取/合并请求
成功将更改推送到版本控制系统后,新版IDE将会发布一条通知,提醒用户已成功推送并建议创建拉取/合并请求的操作。
- 防止大文件提交到仓库
为了帮助用户避免由于文件过大而导致版本控制拒绝,新版IDE现在包含预提交检查,以防止用户提交此类文件并通知用户该限制。
构建工具改进
- 针对 Maven 项目的打开速度提升
新版 IDEA 现在通过解析 pom.xml 文件构建项目模型。这使得有效项目结构可以在几秒钟内获得,具有所有依赖项的完整项目模型则同时在后台构建,这样一来用户就无需等待完全同步即可开始处理项目。
- 从快速文档弹出窗口直接访问源文件
快速文档弹出窗口现在提供了一种下载源代码的简单方式。
现在当用户需要查看库或依赖项的文档并需要访问其源代码时,按 F1 即可。
更新后的弹出窗口将提供一个直接链接,用户可以使用它来下载所需的源文件,以简化工作流。
- Maven 工具窗口中的 Maven 仓库
Maven 仓库列表及其索引编制状态现在直接显示在 Maven 工具窗口中,而不是以前 Maven 设置中的位置。
- Gradle 版本支持更新
从这个新版本开始,IntelliJ IDEA 将不再支持使用低于 Gradle 版本 4.5 的项目,并且 IDE 不会对带有不支持的 Gradle 版本的项目执行 Gradle 同步。
运行/调试更新
- 多语句的内联断点
新版IDEA为在包含 lambda 函数或 return 语句的行中的断点设置提供了更方便的工作流。
点击装订区域设置断点后,IDE会自动显示可在其中设置额外断点的内联标记。每个断点都可以独立配置,释放高级调试功能。
- 条件语句覆盖
2024.1 新版使 IntelliJ IDEA 距离实现全面测试覆盖又近了一步。该项更新的重点是确定测试未完全覆盖代码中的哪些条件语句。
现在,IntelliJ IDEA 既显示哪一行具有未覆盖的条件,还会指定未覆盖的条件分支或变量值。 这项功能默认启用。
框架和技术
- 针对 Spring 的改进 Bean 补全和自动装配
IntelliJ IDEA Ultimate 现在为应用程序上下文中的所有 Bean 提供自动补全,并自动装配 Bean。
如果 Bean 通过构造函数自动装配依赖项,则相关字段也会通过构造函数自动装配。 同样,如果依赖项是通过字段或 Lombok 的 @RequiredArgsConstructor 注解注入,则新 Bean 会自动通过字段装配。
- 增强的 Spring 图表
新版的 Spring 模型图表更易访问。用户可以使用 Bean 行标记或对 Spring 类使用意图操作 (⌥⏎) 进行调用。
同时新版为 Spring 图表引入了新的图标,增强了 Spring 原型(如组件、控制器、仓库和配置 Bean)的可视化。 此外,用户现在可以方便地切换库中 Bean 的可见性(默认隐藏)。
除此之外,其他包括像数据库工具、其他框架、语言和技术的支持等方面的更新和说明,大家也可参阅jetbrains.com/zh-cn/idea/whatsnew。
注:本文在GitHub开源仓库「编程之路」 github.com/rd2coding/R… 中已经收录,里面有我整理的6大编程方向(岗位)的自学路线+知识点大梳理、面试考点、我的简历、几本硬核pdf笔记,以及程序员生活和感悟,欢迎star。
来源:juejin.cn/post/7355389990531907636
身份认证的尽头竟然是无密码 ?
概述
几乎所有的系统都会面临安全认证相关的问题,但是安全相关的问题是一个很麻烦的事情。因为它不产生直接的业务价值,而且处理起来复杂繁琐,所以很多时都容易被忽视。很多后期造成重大的安全隐患,往往都是前期的不重视造成的。但庆幸的是安全问题是普遍存在的,而且大家面临的问题几乎相同,所以可以制定行业标准来规范处理,甚至是可以抽出专门的基础设施(例如:AD、LDAP 等)来专门解决这类共性的问题。总之,关于安全问题非常复杂而且麻烦,对于大多数 99% 的系统来说,不要想着在安全问题领域上搞发明和创新,容易踩坑。而且行业的标准解决方案已经非常成熟了。经过长时间的检验。所以在安全领域,踏踏实实的遵循规范和标准就是最好的安全设计。
HTTP 认证
HTTP 认证协议的最初是在 HTTP/1.1标准中定义的,后续由 IETF 在 RFC 7235 中进行完善。HTTP 协议的主要涉及两种的认证机制。
基本认证
常见的叫法是 HTTP Basic,是一种对于安全性不高,以演示为目的的简单的认证机制(例如你家路由器的登录界面),客户端用户名和密码进行 Base64 编码(注意是编码,不是加密)后,放入 HTTP 请求的头中。服务器在接收到请求后,解码这个字段来验证用户的身份。示例:
GET /some-protected-resource HTTP/1.1
Host: example.com
Authorization: Basic dXNlcjpwYXNzd29yZA==
虽然这种方式简单,但并不安全,因为 base64
编码很容易被解码。建议仅在 HTTPS 协议下使用,以确保安全性。
摘要认证
主要是为了解决 HTTP Basic 的安全问题,但是相对也更复杂一些,摘要认证使用 MD5 哈希函数对用户的密码进行加密,并结合一些盐值(可选)生成一个摘要值,然后将这个值放入请求头中。即使在传输过程中被截获,攻击者也无法直接从摘要中还原出用户的密码。示例:
GET /dir/index.html HTTP/1.1
Host: example.com
Authorization: Digest username="user", realm="example.com", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", uri="/dir/index.html", qop=auth, nc=00000001, cnonce="0a4f113b", response="6629fae49393a05397450978507c4ef1", opaque="5ccc069c403ebaf9f0171e9517f40e41"
**补充:**另在 RFC 7235 规范中还定义当用户没有认证访问服务资源时应返回 401 Unauthorized
状态码,示例:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="Restricted Area"
这一规范目前应用在所有的身份认证流程中,并且沿用至今。
Web 认证
表单认证
虽然 HTTP 有标准的认证协议,但目前实际场景中大多应用都还是基于表单认证实现,具体步骤是:
- 前端通过表单收集用户的账号和密码
- 通过协商的方式发送服务端进行验证的方式。
常见的表单认证页面通常如下:
html>
<html>
<head>
<title>Login Pagetitle>
head>
<body>
<h2>Login Formh2>
<form action="/perform_login" method="post">
<div class="container">
<label for="username"><b>Usernameb>label>
<input type="text" placeholder="Enter Username" name="username" required>
<label for="password"><b>Passwordb>label>
<input type="password" placeholder="Enter Password" name="password" required>
<button type="submit">Loginbutton>
div>
form>
body>
html>
为什么表单认证会成为主流 ?主要有以下几点原因:
- 界面美化:开发者可以创建定制化的登录界面,可以与应用的整体设计风格保持一致。而 HTTP 认证通常会弹出一个很丑的模态对话框让用户输入凭证。
- 灵活性:可以在表单里面自定义更多的逻辑和流程,比如多因素认证、密码重置、记住我功能等。这些功能对于提高应用的安全性和便利性非常重要。
- 安全性:表单认证可以更容易地结合现代的安全实践,背后也有 OAuth 2 、Spring Security 等框架的主持。
表单认证传输内容和格式基本都是自定义本没啥规范可言。但是在 2019 年之后 web 认证开始发布标准的认证协议。
WebAuthn
WebAuthn 是一种彻底抛弃传统密码的认证,完全基于生物识别技术和实体密钥作为身份识别的凭证(有兴趣的小伙伴可以在 github 开启 Webauhtn 的 2FA 认证体验一下)。在 2019 年 3 月,W3C 正式发布了 WebAuthn 的第一版规范。
相比于传统的密码,WebAuthn 具有以下优势:
- 减少密码泄露:传统的用户名和密码登录容易受到钓鱼攻击和数据泄露的影响。WebAuthn,不依赖于密码,不存在密码丢失风险。
- 提高用户体验:用户不需要记住复杂的密码,通过使用生物识别等方式可以更快捷、更方便地登录。
- 多因素认证:WebAuthn 可以作为多因素认证过程中的一部分,进一步增强安全性。使用生物识别加上硬件密钥的方式进行认证,比短信验证码更安全。
总的来说,WebAuthn 是未来的身份认证方式,通过提供一个更安全、更方便的认证方式,目的是替代传统的基于密码的登录方法,从而解决了网络安全中的一些长期问题。WebAuthn 目前已经得到流程的浏览器厂商(Chrome、Firefox、Edge、Safari)、操作系统(WIndows、macOS、Linux)的广泛支持。
实现效果
当你的应用接入 WebAuthn 后,用户便可以通过生物识别设备进行认证,效果如下:
实现原理
WebAuthn 实现较为复杂,这里不做详细描述,具体可参看权威的官方文档,大概交互过程可以参考以下时序图:
登录流程大致可以分为以下步骤:
- 用户访问登录页面,填入用户名后即可点击登录按钮。
- 服务器返回随机字符串 Challenge、用户 UserID。
- 浏览器将 Challenge 和 UserID 转发给验证器。
- 验证器提示用户进行认证操作。
- 服务端接收到浏览器转发来的被私钥加密的 Challenge,以此前注册时存储的公钥进行解密,如果解密成功则宣告登录成功。
WebAuthn 采用非对称加密的公钥、私钥替代传统的密码,这是非常理想的认证方案,私钥是保密的,只有验证器需要知道它,连用户本人都不需要知道,也就没有人为泄漏的可能;
备注:你可以通过访问 webauthn.me 了解到更多消息的信息
文章不适合加入过多的演示代码,想要手上体验的可以参考 okta 官方给出基于 Java 17 和 Maven 构建的 webauthn 示例程序,如下:
来源:juejin.cn/post/7354632375446061083
太惨了,凌晨4 点替别人修复bug……
差点翻车
前两个月的某天凌晨,我司全新的一个营销工具,在全国如期上线。然而整个发布过程并非一帆风顺,在线上环境全量发布后,有同事观测到他所负责模块的监控曲线有异常!监控曲线在发布的时刻近乎于直线下跌。
经过初步排查,故障影响是:一部分新用户无法使用营销优惠~ 影响面非常大,所幸在凌晨的业务低峰期,实际影响有限,但是需要快速修复!不然等天亮用户请求量上来了,故障影响和定级就更大了!
目前接近凌晨4 点,时间很紧张!虽然这部分内容并非我负责,但我是当天的现场值班人,必须上!肝!
屎海无涯
我喝了一口红牛,打开电脑就扎进了陌生代码的汪洋大海中……
看着看着,我察觉到味道不对劲。我觉得这部分代码不是汪洋大海,而是一片屎海…… 代码堆砌如屎山,单个方法竟超过500行;嵌套的if else结构深不可测;日志更是完全缺失;职责不但不单一,反而极度混乱。总之,整个代码简直如同一团乱麻,排查难度极大。
四五个同事一起在排查代码,虽然他们负责过这部分代码,但是大家都十分挠头,找不到 bug 在哪。
当局者迷,旁观者清。经过了30分钟的细致分析,终于,我率先找到了 bug 原因。激动地心颤抖的手,我开了 5 分钟的 bug 发布会,通报了 bug 根因和修复方案。
破案了!
确定 bug 根因后,其他人默默去休息了……
接下来我负责修 bug、测试、打包、发版、验证…… 不知不觉,天空破晓,一直搞到早上 8 点多…… 在线上完成验证,监控曲线恢复正常!bug 修复完成!
bug根因
由于公司代码保密,所以我使用伪代码解释。
业务逻辑是遍历所有的优惠活动,若任意一个优惠活动需要限制新用户使用,那么就需要去查询当前用户是否新用户。
bug 代码如下! (实际的屎山代码,比这部分代码要复杂得多!)
boolean newUserCheckEnabled = false;
for ( Activity activity : activityList ) {
newUserCheckEnabled = activity.isLimitNewUser();
}
想必大家一眼就能看出问题所在!这样写代码, newUserCheckEnabled 等于最后一个活动的值,如果最后一个活动不限制新用户使用,那么 newUserCheckEnabled 就是 false,然而中间的活动可能需要限制新用户,于是 bug 产生了!
老板亲自指导写代码
正确的代码应该这样写,我按照如下方式修复了 bug,但是老板对代码不满意!
boolean newUserCheckEnabled = false;
for ( Activity activity : activityList ) {
if (activity.isLimitNewUser()) {
newUserCheckEnabled = true;
}
}
”一行代码就能解决的事,不需要使用 if “ ,老板看完我的代码后,说道。
他给出的代码示例如下,使用 || 表达式
boolean newUserCheckEnabled = false;
for ( Activity activity : activityList ) {
newUserCheckEnabled = newUserCheckEnabled || activity.isLimitNewUser();
}
if 代码被替换如下!
newUserCheckEnabled = newUserCheckEnabled || activity.isLimitNewUser();
"这能行吗”? 我的大脑飞速运转…… 这两段代码等价吗?似乎等价,但不是十分确定……
老板面前,不能暴露自己没跟上节奏,否则暴露智商。
我假装立刻明白,于是吹了一句,“卧槽,牛逼,这样写确实更加简洁吖!👍🏻”。(大家觉得应该怎么拍马屁,更合适?)
私底下,我还在心里嘀咕,两者真的等价吗?
现在我可以肯定:确实是等价的!
来源:juejin.cn/post/7425875126527918130
旧Android手机改为个人服务器,不需要root
一、前言
随着手机更新换代的加速,每个人都有一些功能正常,但是闲置的手机,其实现在的手机都是ARM架构的,大多数手机内存还不小,相对于现在各大厂商提供的云服务器来讲,配置已经很不错了,所以这么好的资源能利用起来还是非常不错的~
二、工具介绍
目前能用的工具有很多,比如BusyBox、Linux Deploy、juice ssh、termux,但是很多都是需要手机能够root的,但是root并不是所有手机都能够简单获取到的,所以我这里选取Termux进行操作。
三、什么是Termux
Termux 是一款运行于 Android 系统的开源终端模拟器。提供了 Linux 环境,即使设备不具备 root 权限也可使用。通过自带的包管理器(Pacman、 APT),Termux 可以安装许多现代化的开发和系统维护工具,例如 zsh、Python、Ruby、NodeJS、MySQL 等软件。
四、开始改造
4.1 Termux安装
Termux下载:github.com/termux/term…
安装完成后,可以执行以下命令更新一下各软件包:
pkg update && pkg upgrade
4.2 安装openSSH
成功安装Termux之后,虽然手机是可以像服务器一样执行一些操作,但是毕竟手机管理配置起来没有PC方便,所以可以安装SSH服务,方便PC来远程操作。
# 安装openssh
pkg install openssh
# 默认端口为8022,修改端口
sshd -p 8888
# 启动ssh服务
sshd
4.3 远程连接SSH
要远程连接可以使用终端或者SSH客户端(如:PuTTY、Termius、XShell、MobaXterm等),使用以下命令连接到Termux服务。
ssh -p 8022 <username>@<device_ip>
username
在Android手机上使用Termux搭建服务器,并通过SSH让PC进行登录和操作时,**默认的用户名通常是u0_aXXX
,**可以通过以下方式获取到你的用户名是什么:
# 查询termux服务用户名
whoami
device_ip
通过以下命令获取手机的IP,这里的IP是局域网IP。
# 获取设备IP
ifconfig wlan0
连接时需要密码,由于termux服务默认密码为空,所以需要设置一个密码,具体方式如下:
# 切换管理员账户(如果有)
su
# 设置密码
passwd
五、注意点
5.1 保持服务在线
由于Termux是直接运行到Android手机上的,也是一个APP程序,所以需要注意Termux程序不要退出了。
5.2 内网服务
虽然经过上述方式已经实现了服务器的常规基础配置和操作功能,但是毕竟是在手机上的一个服务,也是受到网络环境限制的,因此如果要保证服务可用,需要保证手机和使用端在同意局域网内。
六、扩展
如果对手机作为网站服务器以及移动无线硬盘相关的内容,欢迎关注,后续会尽快分享相关方法。
来源:juejin.cn/post/7459816593230397494
小米正式官宣开源!杀疯了!
最近,和往常一样在刷 GitHub Trending 热榜时,突然看到又一个开源项目冲上了 Trending 榜单。
一天之内就狂揽数千 star,仅仅用两三天时间,star 数就迅速破万,增长曲线都快干垂直了!
出于好奇,点进去看了看。
好家伙,这居然还是小米开源的项目,相信不少小伙伴也刷到了。
这个项目名为:ha_xiaomi_home。
全称:Xiaomi Home Integration for Home Assistant。
原来这就是小米开源的 Home Assistant 米家集成,一个由小米官方提供支持的 Home Assistant 集成组件,它可以让用户在 Home Assistant 平台中使用和管理小米 IoT 智能设备。
Home Assistant 大家知道,这是一款开源的家庭自动化智能家居平台,以其开放性和兼容性著称,其允许用户将家中的智能设备集成到一个统一的系统中进行管理和控制,同时支持多种协议和平台。
通过 Home Assistant,用户可以轻松地实现智能家居的自动化控制,如智能灯光、智能安防、智能温控等,所以是不少智能家居爱好者的选择。
另外通过安装集成(Integration),用户可以在 Home Assistant 上实现家居设备的自动化场景创建,并且还提供了丰富的自定义功能,所以一直比较受 DIY 爱好者们的喜爱。
大家知道,小米在智能家居领域的战略布局一直还挺大的,IoT 平台的连接设备更是数以亿记,大到各种家电、电器,小到各种摄像头、灯光、开关、传感器,产品面铺得非常广。
那这次小米开源的这个所谓的米家集成组件,讲白了就是给 Home Assistant 提供官方角度的支持。
而这对于很多喜欢折腾智能家居或者 IoT 物联网设备的小伙伴来说,无疑也算是一个不错的消息。
ha_xiaomi_home 的安装方法有好几种,包括直接 clone 安装,借助 HACS 安装,或者通过 Samba 或 FTPS 来手动安装等。
但是官方是推荐直接使用 git clone 命令来下载并安装。
cd config
git clone https://github.com/XiaoMi/ha_xiaomi_home.git
cd ha_xiaomi_home
./install.sh /config
原因是,这样一来当用户想要更新至特定版本时,只需要切换相应 Tag 即可,这样会比较方便。
比如,想要更新米家集成版本至 v1.0.0,只需要如下操作即可。
cd config/ha_xiaomi_home
git checkout v1.0.0
./install.sh /config
安装完成之后就可以去 Home Assistant 的设置里面去添加集成了,然后使用小米账号登录即可。
其实在这次小米官方推出 Home Assistant 米家集成之前,市面上也有一些第三方的米家设备集成,但是多多少少会有一些不完美的地方,典型的比如设备状态响应延时,所以导致体验并不是最佳。
与这些第三方集成相比,小米这次新推出的官方米家集成无论是性能还是安全性都可以更期待一下。
如官方所言,Home Assistant 米家集成提供了官方的 OAuth 2.0 登录方式,并不会在 Home Assistant 中保存用户的账号密码,同时账号密码也不再需提供给第三方,因此也就避免了账号密码泄露的风险。
但是这里面仍然有一个问题需要注意,项目官方也说得很明确:虽说 Home Assistant 米家集成提供了 OAuth 的登录方式,但由于 Home Assistant 平台的限制,登录成功后,用户的小米用户信息(包括设备信息、证书、 token 等)会明文保存在 Home Assistant 的配置文件中。因此用户需要保管好自己的 Home Assistant 配置文件,确保不要泄露。
这个项目开源之后,在网上还是相当受欢迎的,当然讨论的声音也有很多。
小米作为一家商业公司,既然专门搞了这样一个开源项目来做 HA 米家集成,这对于他们来说不管是商业还是产品,肯定都是有利的。
不过话说回来,有了这样一个由官方推出的开源集成组件,不论是用户体验还是可玩性都会有所提升,这对于用户来说也未尝不是一件好事。
那关于这次小米官方开源的 Home Assistant 米家集成项目,大家怎么看呢?
来源:juejin.cn/post/7454170332712386572
悲惨!刚入职没几天,无意间把数据库删了,很尴尬,原因很奇葩
1. offer收割机,就职新公司
5年前的就业环境非常好,当时面试了很多家公司,收到了很多 offer。最终我决定入职一家互联网教育公司,新的公司福利非常好,各种零食随便吃,据说还能正点下班,一切都超出我的期望,“可算让我找着神仙公司了”,我的心里一阵窃喜。
在熟悉环境之后,我趁着上厕所的时候,顺便去旁边的零食摊挑了点零食。接下来的一天里,我专注地配置开发环境、阅读新人文档,当然我也不忘兼顾手边的零食。
入职几天后 ,领导给安排了一个小需求,我和同事沟通完技术方案后,就开始开发了。
2. 单元测试有点奇怪
完成开发后,我决定写个单元测试验证下,在研究单元测试代码后,我发现这种单测写法和我之前的写法不太一样。
这家公司的单测好像没有启动整个项目,仅加载了部分类,而且不能访问测试环境数据库~ 于是我决定按照前东家写单测的方式重新写单元测试。
于是我新增了一个单测基类,在单测中启动整个SpringBoot,直接访问测试环境数据库。然而也并不是很顺利,启动阶段总是会遇到各种异常报错,需要一个一个排查…… 所幸项目排期不紧张,还有充足时间。
我做梦也没有想到,此刻,已经铸成大错。
3. 故障现场
我身边的工位旁慢慢地聚集了越来越多的人,本来我还在安安静静的调试单元测试,注意力不自觉的被吸引了过去。
“测试环境为什么这么多异常,访问不通啊。到处都是 500 报错”,不知道谁在说话。
“嗯,我们还在排查,稍等一下”,我旁边的同事一边认真排查日志,一边轻声回复道。
“为什么数据库报的异常是, 查不到数据呢?” ,同事在小声嘀咕,然后打开 命令行,立即登上 MySQL。
我亲眼看着他在操作,奇怪的是数据库表里的数据全部被删掉了,其他的几个表数据也都被删除了。
简直太奇怪了,此刻的我还处于吃瓜心态。
有一个瞬间我在考虑,是否和我执行的单元测试有关系? 但我很快就否决掉了这个想法,因为我只是在调试单元测试,我没有删数据库啊,单测里也不可能删库啊。 我还在笑话自己 胡思乱想……
很快 DBA 就抱着电脑过来,指着电脑说,你们看这些日志,确实有人把这些表删除了。
"有 IP 吗,定位下是谁删除的, 另外线上环境有问题吗?”,旁边的大组长过来和 DBA 说。
“嗯,我找到ip 了,我找运维看下,这个ip是谁的”。DBA 回复道。
4. 庭审现场
当 DBA 找到我的时候,我感到无辜和无助,我懵逼了,我寻思我啥也没干啊,我怎么可能删库呢。 (他们知道我刚入职,我现在怀疑:那一刻他们可能会怀疑 我是友商派过来的卧底、间谍,执行删库的秘密任务)
经过一系列的掰扯和分析,最终定位 确实是我新增的单元测试把数据库删了。
5. 故障原因
需要明确的是,原单元测试执行时不会删除数据库;测试环境启动时也不会删除数据库。
只要在单元测试中连接测试数据库,就会删除掉数据库的所有数据。为什么呢?
5.1 为什么单元测试删除了所有数据?
原单元测试 使用的是 H2 内存数据库,即Java 开发的嵌入式(内存级别)数据库,它本身只是一个类库,也就是只有一个 jar 文件,可以直接嵌入到项目中。H2数据库又被称为内存数据库,因为它支持在内存中创建数据库和表。所以如果我们使用H2数据库的内存模式,那么我们创建的数据库和表都只是保存在内存中,一旦应用重启,那么内存中的数据库和表就不存在了。 所以非常适合用来做单元测试。
H2 数据库在启动阶段,需要执行用户指定的 SQL 脚本,脚本中一般包含表创建语句,用来构建需要使用的表。
但是我司的 SQL 脚本除了创建表语句,还包含了删除表语句。即在创建表之前先删除表。 为什么呢? 据他们说,是因为这个 SQL 脚本可能会重复执行,当重复执行时创建表语句 会报错。所以他们在创建表之前,先尝试删除表。这样确保 SQL 脚本可重复执行。( 其实可以用 Create if not exists )
故障的原因就是:测试数据库执行了这个删表再建表的 SQL 脚本,导致所有数据都被清除了。
5.2 为什么测试数据库会执行这条 SQL 脚本呢?
1) 我新建的单元测试把H2 内存数据库换成了测试数据库。
2) spring.data.initialize=默认值为 true; 默认情况下,会自动执行 sql 脚本。
所以测试数据库 执行了 SQL 脚本。
5.3 为什么在测试环境正常启动时,没有问题,不会删除所有数据呢?
只有单测引入测试数据库才会出问题,在测试环境正常启动项目是没问题的。
当编译项目时,测试目录下的文件、代码和正式代码编译后的结果不会放到一起。因为 SQL脚本被放在了 测试目录下, 所以正式代码在测试环境启动时,不会执行到这个 SQL脚本,自然不会有问题。
6. 深刻教训
最终数据被修复了,DBA有测试数据库的备份,然而快照并非实时的,不可避免地还是丢失了一部分数据。
所幸的是出问题的是测试环境,并非线上环境。 否则,我会不会被起诉,也未可知。
后续的改进措施包括
- 收回了数据库账户的部分权限,只有管理账户才可以修改数据库表结构。代码中执行 DML语句的账户不允许执行 DDL 语句。
- DBA 盘点测试数据库的快照能力,确保快照间隔足够短,另外新增一个调研课题:删库后如何快速恢复,参照下其他公司的方案。
- 所有的项目 spring.data.initialize 全部声明为 false。不自动执行 SQL 脚本
- SQL脚本一律不许出现 删除表的语句。SQL不能重复执行的问题,想其他办法解决。
- 另外的一个项目急需人手,把新来的那谁 调到其他项目上
这可能是程序员们在技术上越来越保守的原因……不经意的一个调整可能引发无法承受的滔天巨浪
来源:juejin.cn/post/7412490391935893541
用java做物品识别和姿态识别
前言
之前搞得语音识别突然发现浏览器就有接口可以直接用,而且识别又快又准,参考:使用 JavaScript 的 SpeechRecognition API 实现语音识别_speechrecognition js-CSDN博客
进入正题
这个功能首先要感谢一下作者常康,仓库地址(gitee.com/agriculture… 这个项目很早之前就关注了,最近这段时间正好要用才真正实践了一下,只是初步测试了一下,在性能方面还需要进一步测试,本人电脑就很拉识别就很卡。
先看效果
改动
主要对姿态识别做了一些小改动,将原图片识别改成视频视频识别,如果要调用摄像头将video.open(0);的代码注释放开即可
package cn.ck;
import ai.onnxruntime.OnnxTensor;
import ai.onnxruntime.OrtEnvironment;
import ai.onnxruntime.OrtException;
import ai.onnxruntime.OrtSession;
import cn.ck.config.PEConfig;
import cn.ck.domain.KeyPoint;
import cn.ck.domain.PEResult;
import cn.ck.utils.Letterbox;
import nu.pattern.OpenCV;
import org.opencv.core.Mat;
import org.opencv.core.Point;
import org.opencv.core.Scalar;
import org.opencv.core.Size;
import org.opencv.highgui.HighGui;
import org.opencv.imgproc.Imgproc;
import org.opencv.videoio.VideoCapture;
import org.opencv.videoio.Videoio;
import java.nio.FloatBuffer;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/*
* 姿态识别,可以识别动作等等.,比如跳绳技术
*/
public class PoseEstimation {
static {
// 加载opencv动态库
//System.load(ClassLoader.getSystemResource("lib/opencv_java470-无用.dll").getPath());
OpenCV.loadLocally();
}
public static void main(String[] args) throws OrtException {
String model_path = "src\main\resources\model\yolov7-w6-pose-nms.onnx";
// 加载ONNX模型
OrtEnvironment environment = OrtEnvironment.getEnvironment();
OrtSession.SessionOptions sessionOptions = new OrtSession.SessionOptions();
OrtSession session = environment.createSession(model_path, sessionOptions);
// 输出基本信息
session.getInputInfo().keySet().forEach(x -> {
try {
System.out.println("input name = " + x);
System.out.println(session.getInputInfo().get(x).getInfo().toString());
} catch (OrtException e) {
throw new RuntimeException(e);
}
});
VideoCapture video = new VideoCapture();
// video.open(0); //获取电脑上第0个摄像头
//可以把识别后的视频在通过rtmp转发到其他流媒体服务器,就可以远程预览视频后视频,需要使用ffmpeg将连续图片合成flv 等等,很简单。
if (!video.isOpened()) {
System.err.println("打开视频流失败,未检测到监控,请先用vlc软件测试链接是否可以播放!,下面试用默认测试视频进行预览效果!");
video.open("video/test2.mp4");
}
// 跳帧检测,一般设置为3,毫秒内视频画面变化是不大的,快了无意义,反而浪费性能
int detect_skip = 4;
// 跳帧计数
int detect_skip_index = 1;
// 最新一帧也就是上一帧推理结果
float[][] outputData = null;
//当前最新一帧。上一帧也可以暂存一下
Mat img = new Mat();
// 在这里先定义下线的粗细、关键的半径(按比例设置大小粗细比较好一些)
int minDwDh = Math.min((int)video.get(Videoio.CAP_PROP_FRAME_WIDTH), (int)video.get(Videoio.CAP_PROP_FRAME_HEIGHT));
int thickness = minDwDh / PEConfig.lineThicknessRatio;
int radius = minDwDh / PEConfig.dotRadiusRatio;
// 转换颜色空间
Mat image = new Mat();
// 图像预处理
Letterbox letterbox = new Letterbox();
letterbox.setNewShape(new Size(960, 960));
letterbox.setStride(64);
// 使用多线程和GPU可以提升帧率,线上项目必须多线程!!!,一个线程拉流,将图像存到[定长]队列或数组或者集合,一个线程模型推理,中间通过变量或者队列交换数据,代码示例仅仅使用单线程
while (video.read(img)) {
if ((detect_skip_index % detect_skip == 0) || outputData == null) {
Imgproc.cvtColor(img, image, Imgproc.COLOR_BGR2RGB);
image = letterbox.letterbox(image);
int rows = letterbox.getHeight();
int cols = letterbox.getWidth();
int channels = image.channels();
// 将图像转换为模型输入格式
float[] pixels = new float[channels * rows * cols];
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
double[] pixel = image.get(j, i);
for (int k = 0; k < channels; k++) {
pixels[rows * cols * k + j * cols + i] = (float) pixel[k] / 255.0f;
}
}
}
detect_skip_index = 1;
OnnxTensor tensor = OnnxTensor.createTensor(environment, FloatBuffer.wrap(pixels), new long[]{1L, (long) channels, (long) rows, (long) cols});
OrtSession.Result output = session.run(Collections.singletonMap(session.getInputInfo().keySet().iterator().next(), tensor));
// 处理输出结果并绘制
outputData = ((float[][]) output.get(0).getValue());
}else{
detect_skip_index = detect_skip_index + 1;
}
double ratio = letterbox.getRatio();
double dw =letterbox.getDw();
double dh = letterbox.getDh();
List<PEResult> peResults = new ArrayList<>();
for (float[] outputDatum : outputData) {
PEResult result = new PEResult(outputDatum);
if (result.getScore() > PEConfig.personScoreThreshold) {
peResults.add(result);
}
}
// 对结果进行非极大值抑制
peResults = nms(peResults, PEConfig.IoUThreshold);
for (PEResult peResult: peResults) {
System.out.println(peResult);
// 画框
Point topLeft = new Point((peResult.getX0()-dw)/ratio, (peResult.getY0()-dh)/ratio);
Point bottomRight = new Point((peResult.getX1()-dw)/ratio, (peResult.getY1()-dh)/ratio);
// Imgproc.rectangle(img, topLeft, bottomRight, new Scalar(255,0,0), thickness);
List<KeyPoint> keyPoints = peResult.getKeyPointList();
// 画点
keyPoints.forEach(keyPoint->{
if (keyPoint.getScore()>PEConfig.keyPointScoreThreshold) {
Point center = new Point((keyPoint.getX()-dw)/ratio, (keyPoint.getY()-dh)/ratio);
Scalar color = PEConfig.poseKptColor.get(keyPoint.getId());
Imgproc.circle(img, center, radius, color, -1); //-1表示实心
}
});
// 画线
for (int i = 0; i< PEConfig.skeleton.length; i++){
int indexPoint1 = PEConfig.skeleton[i][0]-1;
int indexPoint2 = PEConfig.skeleton[i][1]-1;
if ( keyPoints.get(indexPoint1).getScore()>PEConfig.keyPointScoreThreshold &&
keyPoints.get(indexPoint2).getScore()>PEConfig.keyPointScoreThreshold ) {
Scalar coler = PEConfig.poseLimbColor.get(i);
Point point1 = new Point(
(keyPoints.get(indexPoint1).getX()-dw)/ratio,
(keyPoints.get(indexPoint1).getY()-dh)/ratio
);
Point point2 = new Point(
(keyPoints.get(indexPoint2).getX()-dw)/ratio,
(keyPoints.get(indexPoint2).getY()-dh)/ratio
);
Imgproc.line(img, point1, point2, coler, thickness);
}
}
}
//服务器部署:由于服务器没有桌面,所以无法弹出画面预览,主要注释一下代码
HighGui.imshow("result", img);
// 多次按任意按键关闭弹窗画面,结束程序
if(HighGui.waitKey(1) != -1){
break;
}
}
HighGui.destroyAllWindows();
video.release();
System.exit(0);
}
public static List<PEResult> nms(List<PEResult> boxes, float iouThreshold) {
// 根据score从大到小对List进行排序
boxes.sort((b1, b2) -> Float.compare(b2.getScore(), b1.getScore()));
List<PEResult> resultList = new ArrayList<>();
for (int i = 0; i < boxes.size(); i++) {
PEResult box = boxes.get(i);
boolean keep = true;
// 从i+1开始,遍历之后的所有boxes,移除与box的IOU大于阈值的元素
for (int j = i + 1; j < boxes.size(); j++) {
PEResult otherBox = boxes.get(j);
float iou = getIntersectionOverUnion(box, otherBox);
if (iou > iouThreshold) {
keep = false;
break;
}
}
if (keep) {
resultList.add(box);
}
}
return resultList;
}
private static float getIntersectionOverUnion(PEResult box1, PEResult box2) {
float x1 = Math.max(box1.getX0(), box2.getX0());
float y1 = Math.max(box1.getY0(), box2.getY0());
float x2 = Math.min(box1.getX1(), box2.getX1());
float y2 = Math.min(box1.getY1(), box2.getY1());
float intersectionArea = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
float box1Area = (box1.getX1() - box1.getX0()) * (box1.getY1() - box1.getY0());
float box2Area = (box2.getX1() - box2.getX0()) * (box2.getY1() - box2.getY0());
float unionArea = box1Area + box2Area - intersectionArea;
return intersectionArea / unionArea;
}
}
姿态识别模型提取链接,
通过网盘分享的文件:yolov7-w6-pose-nms.onnx
链接: pan.baidu.com/s/1UdAUPWr1… 提取码: du6y
后言
就像原作者说的,不是每个同学都会python,不是每个项目都是python语言开发,不是每个岗位都会深度学习。
希望java在AI领域能有更好的发展
来源:juejin.cn/post/7413234304278970404
当我入手一台 MacBookPro 之后
从 13 年实习开始,开发环境从 Ubuntu 转战 MacOS,中间换了好几次电脑,每次都是直接用 Mac 自带的 Time Machine 来迁移数据,仅需一块移动硬盘或者一根 type c 线,经过一个晚上的数据迁移,第二天就可以用新电脑工作了,除了配置升级了,几乎感受不到换电脑的乐趣,并且升级过程中,也积累了不少系统升级的旧疾,这次从Intel芯片到 M3 Max 芯片,我打算从零开始,重新蒸腾一番,顺带更新一下工具库,说干就干,Go!
先介绍下新电脑的配置
- 太空黑:从经典的银色、到太空灰,这次体验一下太空黑
- 14 寸:用了大概 3 年的 14 寸,就一直用 15/16寸,因为这台不是用于办公,考虑携带方便,所以入手 14 寸(大屏幕肯定爽,但是在家主要也是外接显示器)
- M3 Max:想要体验一下本地大模型,直接入手 Max(找个借口🤐)
- 64G 内存:一直有在 macbook 上装虚拟机(Parallels Desktop)运行 Windows的习惯,升级了一下内存
- 2TB SSD:以前 512 的时候,由于各种 npm 包、docker 镜像,还是隔一段时间就要重启一下、硬盘清理等方式来释放空间,一步到位
后面还换过几台,从最开始的 touchbar ,蝶式键盘,再到取消 touchbar,这时候更多的是工作工具的更换,连拍照的激情都没有🥱🥱
开发工具
科学上网工具
作为开发,第一件事情是需要一个趁手的科学上网工具,不然类似下载 Google Chrome、安装 Homebrew 等基础的工具都会十分麻烦
我的科学上网工具,支持按照规则配置自动代理,同时也支持终端代理,以下是终端代理,这里不方便推荐具体工具
# 防止终端命令无法确定是否需要科学上网,不建议把这个命令持久化到 bashrc/zshrc,在需要时打开终端输入即可
export https_proxy=http://127.0.0.1:1235 http_proxy=http://127.0.0.1:1235 all_proxy=socks5://127.0.0.1:1234
Xcode
Xcode 命令行工具,许多开发工具和依赖所需的基础,运行一下命令,选择安装,稍等一会即可
xcode-select --install
Homebrew
通过 homebrew 来管理一些开发工具包,如 git、node等等,由于需要下载 github 地址,这里需要借助你的翻墙工具
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
然后按照提示,把命令加到 PATH
(echo; echo 'eval "$(/opt/homebrew/bin/brew shellenv)"') >> ~/.zprofile
eval "$(/opt/homebrew/bin/brew shellenv)"
Git/Tig
brew install git
# brew install git-gui
# brew install tig 个人是 vim 用户,偏向这种终端 gui
# 这里会自动安装 zsh 的自动补全工具,后续安装 zsh 可用
# /opt/homebrew/share/zsh/site-functions
安装 git 和 tig 都会默认新增 zsh 的补全方法,好吧,这是提醒我要立马安装 zsh
tigrc 可以用来自定义 tig 的一些配置和快捷键绑定
A sample of the default configuration has been installed to:
/opt/homebrew/opt/tig/share/tig/examples/tigrc
to override the system-wide default configuration, copy the sample to:
/opt/homebrew/etc/tigrc
zsh completions and functions have been installed to:
/opt/homebrew/share/zsh/site-functions
在任意已经初始化 git 的项目,打入 tig ,然后你就可以使用 vim 的方式来操作了 jk 等等
另外,使用 git 我还会额外安装两个 git 相关的小插件
一个是 tj 大神开发的 git-extras
brew install git-extras
# 添加到 ~/.zshrc
source /opt/homebrew/opt/git-extras/share/git-extras/git-extras-completion.zsh
详细的命令可查看文档,我比较常用了是 git summary、git undo、git setup
然后通过git 的 alias 来实现一个自定义的命令,git up 来实现每次切换到一个仓库时,有意思的更新一下最新代码
git config --global alias.up '!git remote update -p && git pull --rebase && git submodule update --init --recursive'
iTerm
实现通过 command + ecs 键,快速切换显示/隐藏 iTerm
- 设置默认终端
- 安装 shell integration
- 选配色:Solarized
- 安装 oh-my-zsh
sh -c "$(curl -fsSL https://raw.github.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
- 修改主题,配置插件等等
brew install zsh-syntax-highlighting
brew install zsh-autosuggestions
brew install autojump
brew install fzf
#ZSH_THEME="robbyrussell"
#ZSH_THEME="agnoster"
#ZSH_THEME="miloshadzic"
#ZSH_THEME="sunrise"
# ZSH_THEME="ys"
ZSH_THEME="gnzh"
plugins=(git ruby autojump osx rake rails lighthouse sublime)
plugins=(bgnotify)
plugins=(web-search)
plugins=(node)
source /opt/homebrew/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
source /opt/homebrew/share/zsh-autosuggestions/zsh-autosuggestions.zsh
[ -f /opt/homebrew/etc/profile.d/autojump.sh ] && . /opt/homebrew/etc/profile.d/autojump.sh
source <(fzf --zsh)
这就起飞了!看看效果
Docker Desktop
作为开发,docker 技能必不可少,mac 下直接使用 docker desktop,可以省掉很多事情,特别是当你如果需要在本地跑 k8s 环境时,直接勾上 Enable Kubernetes 即可;另外新版本查看镜像,也可以扫描镜像每一层是否有安全漏洞,十分方便
VSCode
vscode 不仅适合前端开发,对于 Go、Rust 等开发者,整体体验都不错。安装后的第一件事,就是把 code 命令加到 Terminal
然后第二件事就是安装 github 的 Copilot 插件,开发、看源码现在是少不了它了
第三件事就是在 vscode 开启 vim 模式(安装 vim 插件)从 vim - sublime - vscode,一直保留 vim 的使用习惯,改不掉了😂
其他的就是各种高亮、开发辅助插件,大家按需安装即可
其他
- 前端
- NVM:node 版本管理
- pnpm:上一台电脑只有 512G,在动不动就几个 G 的前端项目,硬盘一直告警,至此只用 pnpm
- Go
- GVM: go 版本管理
- GoLand:虽然 vscode 可以开发 go,但是整体体验还是比不上收费的 goland
环境搭建可参考 Go + React 指北
效率工具
ChatGPT
说到效率工具,ChatGPT 绝对是提高效率神器,从日常问题到开发、图片生成、画图等等,哦,还可以帮忙挑西瓜🍉
对了,用了苹果芯片,ChatGPT app 直接通过 option + space 即可随时调出,支持实时对话、支持截屏问问题等等,好用程度大幅上升⬆️⬆️⬆️
Bartender($)
吐血推荐,让你状态栏更加一目了然
支持自定义显示哪些 icon,配置哪些 icon 始终不显示,哪些第二状态栏显示
快捷键切换第二状态栏,一下子清爽颜值高
iStat Menus($)
拥有时刻关注着网速、CPU使用率、内存使用率的强迫症,绝对不少
Yoink
当你要把一个文件从一个地方移到另外一个地方时,当你想快速复制一张图片时,剪切板记录、跨设备文件接力等等,这个小小的工具都能帮助你
有时候通过截屏软件截图,可以一次性把需要的截屏操作完,然后在剪贴板直接拖下来使用,十分方便!
BettersnapTool
一款小而美的工具,用来快速调整你的窗口,比如当前窗口在两个显示屏直接切换;全屏,左右分屏等等
iShot
本来我一直使用 Skitch 的,但是这次切换新电脑之后,发现它下架了,之前有朋友推荐了,也使用了一段时间,感觉很不错,除了普通的截图,还有长截图、带壳截图;还有其他的小工具,官方宣传是
截图、长截图、全屏带壳截图、贴图、标注、取色、录屏、录音、OCR、翻译一个顶十个,样样皆优秀!
Draw.io
好吧,这个绝对画图神器,日常写文档几乎离不开他,在线版直接打开即可使用,也可以安装 vscode 插件,不过我还是习惯下载一个 app,这样本地的文件,直接打开即可使用
距离成为架构师,你只差一个 draw.io
Parallels Desktop
如果你有使用 Windows 的诉求,那么我建议你花点钱买个 pd,融合模式一开,原来我的 16 年的机器,玩个魔兽、英雄联盟完全没问题
安装直接点击下载 Windows 11,网速好的话,10 来分钟就安装成功了
融合模式,应用和直接使用 mac 的应用没任何差别
全屏模式,可以看到截图的时候有一部分黑色,应该是没有兼容刘海屏的原因
Markdown 编辑器
Quiver,原来所有的笔记、文档基本都靠它来记录,21 年的时候作者停止维护了,再加上使用纯 markdown 工具,还需要自己找图床,最后都转到语雀、飞书等在线文档
中间还用过其他的、Mweb、Typora 等等,如果自己搭建图床,推荐使用 Typora

Mweb 包含PC 和 移动端,通过 iCloud 同步,也是十分方便!
图床工具
原来写 markdown 的时候,使用的是微博免费的图床,2 年后,然后发现图片都失效了!失效了!
所以,图床还是自己维护比较靠谱!
PicList,免费开源,我自己是购买了阿里云按量付费的 OSS,简单配置一下 aks,即可上传图片,配合 Typora,轻松完成写作
配置好之后,图片拖到 markdown 编辑框,即可实现自动上传
BreakTime 定时提醒工具
为了你的健康,你可以让电脑提醒你,每隔30分钟休息一下,倒杯水,看看风景
DaisyDisk (付费)磁盘空间,文件大小分析工具
作为只能买 256G 的屌丝,每天困扰我的一件事就是磁盘空间不足
现在我是 2T 了,可以不用了
也可以使用 腾讯柠檬用过一段时间,也很好用
微信输入法
搜狗输入法、RIME、百度输入法(作恶多端,还用)
上一次推荐,我还是使用搜狗输入法,有朋友推荐微信输入法,体验了一把,简洁、功能齐全,所以手机、PC 全部改用微信输入法
推荐跨设备复制黏贴,速度比苹果自带的快了许多
思维导图:Xmind, MindNode
脑图应用,一般在项目开发过程中用于 需求分解,Model Design 等等。
其他小应用
Chrome 插件推荐
- Vimium, 通过键盘快捷键操作网页,比如打开,关闭,查找书签等等
- FeHelper(前端助手):JSON自动格式化、手动格式化,支持排序、解码、下载等,更多功能可在配置页按需安装
- Axure RP Extension for Chrome
- Grammarly for Chrome,语法检查
- Octotree,github源码查看神器
- OneTab,节省高达95%的内存,并减轻标签页混乱现象
- Postman Interceptor
- React Developer Tools
- Redux DevTools
- Yet Another Drag and Go:超级拖拽.向四个方向拖拽文字即可进行相应的搜索.拖拽链接可在前台/后台,左侧/右侧打开
- 掘金
- Sider: ChatGPT 侧边栏 + GPT-4o, Claude 3.5, Gemini 1.5 & AI工具”的产品徽标图片 Sider: ChatGPT 侧边栏 + GPT-4o, Claude 3.5, Gemini 1.5 & AI工具
- xSwitch:前端开发代理神器,在线 debug 问题,把线上资源代理到本地,方便复现问题
来源:juejin.cn/post/7398351048777842729
MyBatis里面写模糊查询,like怎么用才对呢?
深入浅出:MyBatis中的模糊查询技巧
在数据库操作的世界里,模糊查询堪称是一项既基本又极其强大的功能。特别是在处理大量数据,需要根据某些不完全匹配的条件进行搜索时,模糊查询的价值就显得尤为重要。🔍 MyBatis作为一个广泛使用的持久层框架,为实现这一功能提供了便捷的途径。但不少开发者对其模糊查询的实现方式仍然感到困惑。本文将试图消除这种困惑,通过一步步的解析,带领大家正确使用MyBatis进行模糊查询。
引言
简述模糊查询在数据处理中的重要性
模糊查询是数据库操作中不可或缺的一部分,尤其在处理文本数据时,它能够根据不完全或模糊的条件,帮助开发者快速定位并检索出所需的数据行。例如,在一个拥有成千上万用户信息的系统中,通过模糊查询姓名或地址,能够高效地筛选出符合条件的信息。🚀
为什么要掌握MyBatis中的模犹如查询技术
掌握MyBatis中的模糊查询,可以使数据库操作更加灵活高效。对于已经选择MyBatis作为数据层框架的项目,能准确运用模糊查询,意味着能在保持代码的可维护性和清晰结构的同时,实现强大的数据检索功能。
模犹如查询基础
SQL中的LIKE语句
在SQL中,LIKE
语句是实现模糊查询的关键。它通常与%
(表示任意多个字符)和_
(表示一个任意字符)这两个通配符一起使用。例如:
%apple%
:匹配任何包含"apple"的字符串。_apple%
:匹配以任意字符开头,后面跟着"apple"的字符串。
LIKE语句的常见使用模式
基于LIKE
语句的模糊查询可以有多种不同的用法,选择合适的模式可以大幅提升查询的效率和准确度。
MyBatis简介
MyBatis的核心功能
MyBatis是一种半ORM(对象关系映射)框架,它桥接了Java对象和数据库之间的映射,通过XML或注解的方式,将SQL语句与Java方法关联起来,从而简化了数据操作层的代码。
如何在MyBatis中配置和使用Mapper
在MyBatis中,Mapper的配置主要通过Mapper.xml文件进行。每一个Mapper.xml文件都对应一个Mapper接口,文件中定义了与接口方法相对应的SQL语句。使用Mapper非常简单,只需在相关的Service层中引入Mapper接口,MyBatis框架会自动代理这些接口,使得调用数据库操作像调用Java方法一样简单。
MyBatis中的模犹如查询实现
MyBatis中LIKE语句的基本用法
在Mapper.xml中使用#{variable}占位符
<select id="findUserByName" resultType="com.example.User">
SELECT * FROM users WHERE name LIKE CONCAT('%', #{name}, '%')
</select>
此处使用了CONCAT
函数和#{variable}
占位符,动态地将输入的变量与%
通配符结合起来,实现了基本的模犹如查询功能。
使用${variable}拼接SQL
虽然使用${variable}
进行SQL拼接能提供更灵活的查询方法,但需要谨慎使用,以避免SQL注入风险。
<select id="findUserByName" resultType="com.example.User">
SELECT * FROM users WHERE name LIKE '%${name}%'
</select>
动态SQL与模犹如查询
<if>
标签的使用
<select id="findUserByCondition" parameterType="map" resultType="com.example.User">
SELECT * FROM users
<where>
<if test="name != null">
AND name LIKE CONCAT('%', #{name}, '%')
</if>
... // 更多条件
</where>
</select>
<choose>
、<when>
、<otherwise>
的结合使用
<select id="findUserByDynamicCondition" parameterType="map" resultType="com.example.User">
SELECT * FROM users
<where>
<choose>
<when test="name != null">
AND name LIKE CONCAT('%', #{name}, '%')
</when>
<when test="email != null">
AND email LIKE CONCAT('%', #{email}, '%')
</when>
<otherwise>
AND id > 0 // 默认条件
</otherwise>
</choose>
</where>
</select>
实践案例
假设我们有一个用户管理系统,需要根据用户的姓名进行模糊查询。
场景描述
在用户管理系统中,后台需要根据前端传来的姓名关键字,模糊匹配数据库中的用户姓名,返回匹配的用户列表。
代码实现
Mapper接口定义
public interface UserMapper {
List<User> findUserByName(String name);
}
Mapper.xml配合LIKE的具体写法
<select id="findUserByName" resultType="com.example.User">
SELECT * FROM users WHERE name LIKE CONCAT('%', #{name}, '%')
</select>
结果验证
调用findUserByName
方法,传入关键字,即可得到所有姓名中包含该关键字的用户数据。
高级技巧与最佳实践
使用trim
标签优化LIKE查询
<select id="findUserByName" parameterType="string" resultType="com.example.User">
SELECT * FROM users
WHERE name LIKE
<trim prefix="%" suffix="%" prefixOverrides="%" suffixOverrides="%">
#{name}
</trim>
</select>
小技巧:避免模糊查询带来的性能问题
尽量避免以%
开头的模糊查询,因为这会使数据库全表扫描,极大地影响查询性能。
安全性考虑:防止SQL注入
在使用${}
进行SQL拼接时,一定要确保变量来源可控或已做过适当校验,防止SQL注入攻击。
总结与展望
虽然模糊查询在数据库操作中极其有用,但它也不是万能的。在使用MyBatis实现模糊查询时,既要考虑到其便捷性和灵活性,也不能忽视潜在的性能和安全风险。我们希望通过本文,你能更准确、更高效地使用MyBatis进行模糊查询。
未来随着技术的发展,MyBatis和相关的数据库技术仍将不断进化,但基本的原则和最佳实践应该是不变的。掌握这些,将能使你在使用MyBatis进行数据库操作时更加得心应手。
附录
欲了解更多MyBatis的高级功能和最佳实践,可以参考:
- MyBatis官方文档
- 相关技术社区和论坛
Q&A环节:如果你有任何关于MyBatis模糊查询的问题,欢迎在评论区留言交流。📢
希望本文能帮助你更好地理解和使用MyBatis进行模糊查询,欢迎分享和交流你的经验!🚀
来源:juejin.cn/post/7343225969237671972
面试官:limit 100w,10为什么慢?如何优化?
在 MySQL 中,limit X,Y 的查询中,X 值越大,那么查询速度也就越慢,例如以下示例:
- limit 0,10:查询时间大概在 20 毫秒左右。
- limit 1000000,10:查询时间可能是 15 秒左右(1秒等于 1000 毫秒),甚至更长时间。
所以,可以看出,limit 中 X 值越大,那么查询速度都越慢。
这个问题呢其实就是 MySQL 中典型的深度分页问题。那问题来了,为什么 limit 越往后查询越慢?如何优化查询速度呢?
为什么limit越来越慢?
在数据库查询中,当使用 LIMIT x, y 分页查询时,如果 x 值越大,查询速度可能会变慢。这主要是因为数据库需要扫描和跳过 x 条记录才能返回 y 条结果。随着 x 的增加,需要扫描和跳过的记录数也增加,从而导致性能下降。
例如 limit 1000000,10 需要扫描 1000010 行数据,然后丢掉前面的 1000000 行记录,所以查询速度就会很慢。
优化手段
对于 MySQL 深度分页比较典型的优化手段有以下两种:
- 起始 ID 定位法:使用最后查询的 ID 作为起始查询的 ID。
- 索引覆盖+子查询。
1.起始ID定位法
起始 ID 定位法指的是 limit 查询时,指定起始 ID。而这个起始 ID 是上一次查询的最后一条 ID。例如上一次查询的最后一条数据的 ID 为 6800000,那我们就从 6800001 开始扫描表,直接跳过前面的 6800000 条数据,这样查询的效率就高了,具体实现 SQL 如下:
select name, age, gender
from person
where id > 6800000 -- 核心实现 SQL
order by id limit 10;
其中 id 字段为表的主键字段。
为什么起始ID查询效率高呢?
因此这种查询是以上一次查询的最后 ID 作为起始 ID 进行查询的,而上次的 ID 已经定位到具体的位置了,所以只需要遍历 B+ 树叶子节点的双向链表(主键索引的底层数据结构)就可以查询到后面的数据了,所以查询效率就比较高,如下图所示:
如果上次查询结果为 9,之后再查询时,只需要从 9 之后再遍历 N 条数据就能查询出结果了,所以效率就很高。
优缺点分析
这种查询方式,只适合一页一页的数据查询,例如手机 APP 中刷新闻时那种瀑布流方式。
但如果用户是跳着分页的,例如查询完第 1 页之后,直接查询第 250 页,那么这种实现方式就不行了。
2.索引覆盖+子查询
此时我们为了查询效率,可以使用索引覆盖加子查询的方式,具体实现如下。
假设,我们未优化前的 SQL 如下:
select name, age, gender
from person
order by createtime desc
limit 1000000,10;
在以上 SQL 中,createtime 字段创建了索引,但查询效率依然很慢,因为它要取出 100w 完整的数据,并需要读取大量的索引页,和进行频繁的回表查询,所以执行效率会很低。
此时,我们可以做以下优化:
SELECT p1.name, p1.age, p1.gender
FROM person p1
JOIN (
SELECT id FROM person ORDER BY createtime desc LIMIT 1000000, 10
) AS p2 ON p1.id = p2.id;
相比于优化前的 SQL,优化后的 SQL 将不需要频繁回表查询了,因为子查询中只查询主键 ID,这时可以使用索引覆盖来实现。那么子查询就可以先查询出一小部分主键 ID,再进行查询,这样就可以大大提升查询的效率了。
索引覆盖(Index Coverage)是一种数据库查询优化技术,它指的是在执行查询时,数据库引擎可以直接从索引中获取所有需要的数据,而不需要再回表(访问主键索引或者表中的实际数据行)来获取额外的信息。这种方式可以减少磁盘 I/O 操作,从而提高查询性能。
课后思考
你还知道哪些深度分页的优化手段呢?欢迎评论区留下你的答案。
本文已收录到我的面试小站 http://www.javacn.site,其中包含的内容有:Redis、JVM、并发、并发、MySQL、Spring、Spring MVC、Spring Boot、Spring Cloud、MyBatis、设计模式、消息队列等模块。
来源:juejin.cn/post/7410987368343765046
SpringBoot引入Flyway
1 缘起与目的
最近遇到一个项目要部署到很多不同的地方,在每个地方升级时如何管理数据库升级脚本就成了一个叩待解决的问题。本文引入flyway工具来解决这个问题。
2 依赖
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
<version>7.15.0</version>
</dependency>
此处笔者MySQL版本为5.7,上述版本依赖可生效。此处踩坑过程见踩坑记录。
3 yml
spring:
# flyway 配置
flyway:
# 启用或禁用 flyway
enabled: false
# flyway 的 clean 命令会删除指定 schema 下的所有 table, 生产务必禁掉。这个默认值是 false 理论上作为默认配置是不科学的。
clean-disabled: true
# SQL 脚本的目录,多个路径使用逗号分隔 默认值 classpath:db/migration {vendor}对应数据库类型,可选值 https://github.com/spring-projects/spring-boot/blob/v2.3.3.RELEASE/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/jdbc/DatabaseDriver.java
locations: classpath:sql/{vendor}
# metadata 版本控制信息表 默认 flyway_schema_history
table: flyway_schema_history
# 如果没有 flyway_schema_history 这个 metadata 表, 在执行 flyway migrate 命令之前, 必须先执行 flyway baseline 命令
# 设置为 true 后 flyway 将在需要 baseline 的时候, 自动执行一次 baseline。
baseline-on-migrate: false
# 指定 baseline 的版本号,默认值为 1, 低于该版本号的 SQL 文件, migrate 时会被忽略
baseline-version: 1
# 字符编码 默认 UTF-8
encoding: UTF-8
# 是否允许不按顺序迁移 开发建议 true 生产建议 false
out-of-order: false
# 执行迁移时是否自动调用验证 当你的 版本不符合逻辑 比如 你先执行了 DML 而没有 对应的DDL 会抛出异常
validate-on-migrate: true
4 表结构
配好后依赖和yml直接启动项目会自动创建表结构。
值得一说的是checksum。可以理解为校验字符串,每次执行完sql脚本后会针对脚本生成checknum,后续如果之前执行过的脚本出现改动与前面的checknum不一致会直接报错。
4 脚本命名
命名规则如下:
V版本号__版本名.sql
例如: V2.1.5__create_user_ddl.sql
、V4.1_2__add_user_dml.sql
因为配置的baseline-version=1,所以只有1以上版本才会被执行,上图V0.0.1__base.sql是不会被执行的。上图只是为了展示命名规则。
5 踩坑记录
5.1 Unsupported Database: MySQL 5.7
笔者最开始的依赖如下:
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
报错如下:
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'asyncBeanPriorityLoadPostProcessor' defined in class path resource [io/github/linyimin0812/async/AsyncBeanAutoConfiguration.class]: Initialization of bean failed; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'efpxInitQuartzJob': Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'sysJobServiceImpl' defined in file [E:\java\project\pm2\pm_modularity\efp-plugins\target\classes\com\sdecloud\modules\quartz\service\impl\SysJobServiceImpl.class]: Unsatisfied dependency expressed through constructor parameter 1; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'flywayInitializer' defined in class path resource [org/springframework/boot/autoconfigure/flyway/FlywayAutoConfiguration$FlywayConfiguration.class]: Invocation of init method failed; nested exception is org.flywaydb.core.api.FlywayException: Unsupported Database: MySQL 5.7
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:628)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:542)
at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:335)
at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:234)
此处笔者检索到了互联网文章 【原创】Flyway 8.2.1及以后版本不再支持MySQL?!_unsupported database: mysql 8.0-CSDN博客,阅读后笔者表示???还是去官网一探究竟吧。
- 通过官网(documentation.red-gate.com/flyway/flyw… 对MySQL支持的说明,修改依赖如下:
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
结果依然报错如下
MySQL 5.7 is no longer supported by Flyway Community Edition, but still supported by Flyway Teams Edition.
- 通过stack overflow(stackoverflow.com/questions/7… )查询发现:
(1)Flyway Community Edition 8.0.0-beta1放弃了对5年以上数据库的支持,包括MySQL 5.7
在这次提交中,MySQL的最低支持版本从5.7增加到8.0,这是在Flyway 8.0.0-beta1中引入的。目前,支持MySQL 5.7的最新社区版本是Flyway 7.15.0。
(2)从Flyway第10版(2023年10月)起,此限制不再有效。我们已经更新了Flyway,使其适用于所有支持的数据库版本,因此如果您升级到版本10,您可以访问所有支持的MySQL版本。
笔者回退到7.15.0后再无报错。即最终依赖为标题1所示。
来源:juejin.cn/post/7330463614954209334
比Spring参数校验更优雅!使用函数式编程把参数检验玩出花来!
比Spring参数校验更优雅!使用函数式编程把参数检验玩出花来!
未经允许禁止转载!
使用 Vavr 验证库来替代标准的 Java Bean Validation(如 @NotBlank
, @Size
等注解)可以通过函数式的方式来处理验证逻辑。Vavr 是一个支持不可变数据结构和函数式编程的库,可以让代码更加简洁和函数式。
要使用 Vavr 的验证器,我们可以利用 Vavr 下Validation
类,它提供了一种函数式的方式来处理验证,允许收集多个错误,而不仅仅是遇到第一个错误就终止。
1. BeanValidator 实现的问题
以下是使用BeanValidator实现参数校验的代码:
@Data
public class User {
// bean validator 使用注解实现参数校验
@NotBlank(message = "用户姓名不能为空")
private String name;
@NotBlank(message = "密码不能为空")
@Size(min = 6, message = "密码长度不能少于6位")
private String password;
@Min(value = 0, message = "年龄不能小于0岁")
@Max(value = 150, message = "年龄不应超过150岁")
private Integer age;
@Pattern(regexp = "^((13[0-9])|(15[^4])|(18[0-9])|(17[0-9])|(147))\d{8}$", message = "手机号格式不正确")
private String phone;
}
Spring 提供了对 BeanValidator 的支持,可以在不同的层级(controller、service、repository)使用。
缺点:
- 要求被验证的对象是可变的 JavaBean(具有getter,setter方法),JavaBean是一种常见的反模式。
- 校验逻辑的复杂应用有很大的学习成本,比如自定义验证注解、分组校验等。
- 异常处理逻辑一般需要配合Spring全局异常处理。
最佳实践:
PlanA: 实践中建议仅在 controller 层面校验前端传入的 json 参数,不使用自定义注解,分组校验等复杂功能。
PlanB: 直接使用函数式验证。
2. 使用 Vavr 重新设计 User
类的验证逻辑
2.1 使用到的函数式思想:
- 校验结果视为值,返回结果为和类型,即异常结果或正常结果。这里的异常结果指的是校验失败的参数列表,正常结果指的是新创建的对象。
- 复用函数,这里具体指校验逻辑和构造器方法(或者静态方法创建对象)
- Applicative functor,本文不想讨论难以理解的函数式概念。这里可以简单理解成封装函数、同时支持 apply(map)的容器。
- 收集所有校验异常结果,此处的处理和提前返回(卫模式、短路操作)不同。
以下是使用 Vavr 中参数校验的代码:
PersonValidator personValidator = new PersonValidator();
// Valid(Person(John Doe, 30))
Validation<Seq<String>, Person> valid = personValidator.validatePerson("John Doe", 30);
// Invalid(List(Name contains invalid characters: '!4?', Age must be greater than 0))
Validation<Seq<String>, Person> invalid = personValidator.validatePerson("John? Doe!4", -1);
首先,需要定义一个验证器类,而不是直接在 User
类上使用注解。这个验证器类会对 User
的字段进行验证,并返回一个 Validation
对象。
2.2 验证器实现
// 使用实体类,这个类是无状态的
public class UserValidator {
// 验证用户
public Validation<Seq<String>, User> validateUser(String name, String password, Integer age, String phone) {
return Validation.combine(
validateName(name),
validatePassword(password),
validateAge(age),
validatePhone(phone))
.ap(User::new);
}
// 验证用户名
private Validation<String, String> validateName(String name) {
return (name == null || name.trim().isEmpty())
? Invalid("用户姓名不能为空")
: Valid(name);
}
// 验证密码
private Validation<String, String> validatePassword(String password) {
if (password == null || password.isEmpty()) {
return Invalid("密码不能为空");
}
if (password.length() < 6) {
return Invalid("密码长度不能少于6位");
}
return Valid(password);
}
// 验证年龄
private Validation<String, Integer> validateAge(Integer age) {
if (age == null) {
return Invalid("年龄不能为空");
}
if (age < 0) {
return Invalid("年龄不能小于0岁");
}
if (age > 150) {
return Invalid("年龄不应超过150岁");
}
return Valid(age);
}
// 验证手机号
private Validation<String, String> validatePhone(String phone) {
String phoneRegex = "^((13[0-9])|(15[^4])|(18[0-9])|(17[0-9])|(147))\\d{8}$";
if (phone == null || !phone.matches(phoneRegex)) {
return Invalid("手机号格式不正确");
}
return Valid(phone);
}
}
2.3 使用
public class UserValidationExample {
public static void main(String[] args) {
UserValidator validator = new UserValidator();
// 示例:测试一个有效用户
Validation<Seq<String>, User> validUser = validator.validateUser("Alice", "password123", 25, "13912345678");
if (validUser.isValid()) {
System.out.println("Valid user: " + validUser.get());
} else {
System.out.println("Validation errors: " + validUser.getError());
}
// 示例:测试一个无效用户
Validation<Seq<String>, User> invalidUser = validator.validateUser("", "123", -5, "12345");
if (invalidUser.isValid()) {
System.out.println("Valid user: " + invalidUser.get());
} else {
System.out.println("Validation errors: " + invalidUser.getError());
}
}
}
Validation.combine()
:将多个验证结果组合起来。每个验证返回的是Validation<String, T>
,其中String
是错误消息,T
是验证成功时的值。User::new
:这是一个方法引用,表示如果所有的字段都验证成功,就调用User
的构造函数创建一个新的User
对象。- 验证错误的收集:Vavr 的验证机制允许收集多个错误,而不是像传统 Java Bean Validation 那样一旦遇到错误就停止。这样,你可以返回所有的验证错误,让用户一次性修复。
2.4 结果示例
- 对于一个有效的用户:
Valid user: User(name=Alice, password=password123, age=25, phone=13912345678)
- 对于一个无效的用户:
Validation errors: List(用户姓名不能为空, 密码长度不能少于6位, 年龄不能小于0岁, 手机号格式不正确)
3. 源码解析
如果你仅关注使用的话,此段内容可以跳过。
此处仅分析其核心代码:
// Validation#combine 返回 Builder 类型
final class Builder<E, T1, T2> {
private Validation<E, T1> v1;
private Validation<E, T2> v2;
public <R> Validation<Seq<E>, R> ap(Function2<T1, T2, R> f) {
// 注意这里的执行顺序: v1#ap -> v2#ap
return v2.ap(v1.ap(Validation.valid(f.curried())));
}
}
f.curried
返回结果为 T1 => T2 => R,valid 方法使用 Validation 容器封装了函数:
// validation 为和类型,有且仅有两种实现
public interface Validation<E, T> extends Value<T>, Serializable {
static <E, T> Validation<E, T> valid(T value) {
return new Valid<>(value);
}
static <E, T> Validation<E, T> invalid(E error) {
Objects.requireNonNull(error, "error is null");
return new Invalid<>(error);
}
}
最关键的代码为 ap(apply的缩写):
default <U> Validation<Seq<E>, U> ap(Validation<Seq<E>, ? extends Function<? super T, ? extends U>> validation) {
Objects.requireNonNull(validation, "validation is null");
if (isValid()) {
if (validation.isValid()) {
// 正常处理逻辑
final Function<? super T, ? extends U> f = validation.get();
final U u = f.apply(this.get());
return valid(u);
} else {
// 保留原有的失败结果
final Seq<E> errors = validation.getError();
return invalid(errors);
}
} else {
if (validation.isValid()) {
// 初始化失败结果
final E error = this.getError();
return invalid(List.of(error));
} else {
// 校验失败,收集失败结果
final Seq<E> errors = validation.getError();
final E error = this.getError();
return invalid(errors.append(error));
}
}
}
这里的实现非常巧妙,柯里化的函数在正常处理逻辑中不断执行,最后调用成功,返回正确的函数结果。执行流程中有异常结果后,分成三中情况进行处理,分别是初始化,保留结果,进一步收集结果。
4. 总结与最佳实践
- 这种方式使用 Vavr 提供的函数式验证工具,使得验证逻辑更加简洁、灵活,并且可以收集多个错误进行统一处理,避免散弹枪问题。
- 对于需要返回单一错误的情况(实际上不多),也可以使用这种方法,然后取用任意一条结果。
- Validation支持多条无关参数的校验。当涉及到多参数的校验时,建议进行手动编码。
record Person(name, age) {}
static final String ADULT_CONTENT = "adult";
static final int ADULT_AGE = 18;
public Validation<Seq<String>, Person> validatePerson2(String name, int age) {
return Validation.combine(validateName(name), validateAge(age)).ap(Person::new)
.flatMap(this::validateAdult);
}
private Validation<Seq<String>, Person> validateAdult(Person p) {
return p.age < ADULT_AGE && p.name.contains(ADULT_CONTENT)
? Validation.invalid(API.List("Illegal name"))
: Validation.valid(p);
}
此外,对于某些参数传参,建议使用对象组合,比如range参数有两种做法,第一种可以传入 from, to, 校验条件为 from < to, 校验后对象包含属性Range,之后在额外校验中校验 Range;第二种可以限制传入参数为 Range。
来源:juejin.cn/post/7416605082688962610
shardingjdbc有点坑,数据库优化别再无脑回答分库分表了
故事背景
在八股文中,说到如何进行数据库的优化,除了基本的索引优化,经常会提到分库分表,说是如果业务量剧增,数据库性能会到达瓶颈,如果单表数据超过两千万,数据查询效率就会变低,就要引入分库分表巴拉巴拉。我同事也问我,我们数据表有些是上亿数据的,为什么不用分库分表,如果我没接触过分库分表我也会觉得大数据表就要分库分表呀,这是八股文一直以来教导的东西。但是我就跟他说,分库分表很坑爹,最近才让我遇到一个BUG......
系统复杂度upup
业务中有个设备表数据量很大,到现在为止已经有5、6亿数据了。在4年前,前人们已经尝试了分库分表技术,分了4个库,5个表,我只是负责维护这个业务发现他们用了分库分表。但是在查询表数据的时候看到是查询ES的,我就问为什么要用ES?同事回答查询分库分表一定要带分片才能走到路由,否则会查询全部库和全部表,意思是不查分片字段,单表只用一个SQL,但是分库分表要用20个SQL.....所以引入了ES进行数据查询。但是引入ES之后又引入一个新的问题,就是ES和数据库的数据同步问题。他们使用了logstash做数据同步,但不是实时的,在logstash设置了每20秒同步一次。

因为要使用分库分表,引入了shardingjdbc,因为查询方便引入了es,因为要处理数据同步问题引入了logstash......所以系统复杂度不是高了一点半点,之前发现有个字段长度设置小了,还要改20张表。
分页问题
最近遇到一个奇怪的bug,在一个设备的单表查询翻页失败,怎么翻都只显示第一页的数据,一开始我以为是分页代码有问题,看了半天跟其他表是一样的,其他表分页没问题,见鬼了。后面再细看发现这个单表的数据源是设备数据源,用的是shardingjdbc的配置。

之前就看过shardingjdbc有一些sql是不支持的,怀疑就是这个原因,百度了一下果然是有bug。

想了一下有两个解决办法,第一个是升级shardingjdbc的版本,据说是4.1之后修复了该问题,但是还没有尝试。
第二个办法是把分库分表业务的数据源跟单表区分开,单表业务使用普通的数据源后分页数据正常显示。
关于数据库优化
一般来说数据库优化,可以从几个角度进行优化:
1、硬件优化
(1) 提升存储性能
- 使用SSD:替换传统机械硬盘(HDD),SSD能提供更快的随机读写速度。
- 增加存储带宽:采用RAID(推荐RAID 10)提高数据存储的读写速度和冗余。
- 内存扩展:尽量让数据库缓存更多的数据,减少IO操作。
(2) 增强CPU性能
- 使用多核高频率CPU,支持更高并发。
- 分析数据库对CPU的利用情况,确保不被CPU性能瓶颈限制。
(3) 提高网络带宽
- 优化服务器与客户端之间的网络延迟和带宽,尤其是分布式数据库的场景中。
- 使用高速网络接口(如10GbE网卡)。
2、软件层面优化
(1) 数据库配置
- 调整数据库缓冲池(Buffer Pool)的大小,确保能缓存大部分热数据。
- 优化日志文件的写入(如MySQL中调整
innodb_log_buffer_size
)。 - 使用内存数据库或缓存技术(如Redis、Memcached)加速访问速度。
(2) 分布式架构
- 对于高并发需求,采用分布式数据库(如TiDB、MongoDB)进行读写分离或数据分片。
(3) 数据库索引
- 选择合适的索引类型:如B+树索引、哈希索引等,根据查询特点选择适配的索引。
- 避免冗余索引,定期清理无用索引。
(4) 数据库版本升级
- 保持数据库版本为最新的稳定版本,利用最新的优化特性和Bug修复。
3. SQL层面优化
(1) 查询优化
- 减少不必要的字段:只查询需要的列,避免使用
SELECT *
。 - 加速排序和分组:在
ORDER BY
和GR0UP BY
字段上建立索引。 - 拆分复杂查询:将复杂的SQL分解为多个简单查询或视图。
- 分页查询优化:如避免大OFFSET分页,可以使用索引条件替代(如
WHERE id > last_seen_id
)。
(2) 合理使用索引
- 对频繁用于WHERE、JOIN、GR0UP BY等的字段建立索引。
- 避免在索引列上使用函数或隐式转换。
(3) 减少锁定
- 尽量使用小事务,减少锁定范围。
- 使用合适的事务隔离级别,避免不必要的资源等待。
(4) SQL调优工具
- 使用数据库自带的分析工具(如MySQL的
EXPLAIN
、SQL Server的性能监控工具)来分析查询计划并优化执行路径。
4. 综合优化
- 定期进行性能分析:定期查看慢查询日志,优化慢查询。
- 清理历史数据:对于不再使用的历史数据,可存储到冷数据仓库,减少主数据库的负载。
- 使用连接池:通过数据库连接池(如HikariCP)管理和复用连接,降低创建和销毁连接的开销。
tips:
现网的数据库是64核128G内存,测试环境是32核64G,加上现网数据库配置的优化,现网数据库查询大表的速度是测试环境的3倍!所以服务器硬件配置和数据库配置都很重要。下面是数据库的配置文件,仅供参考
[universe]
bakupdir = /data/mysql/backup/7360
iops = 0
mem_limit_mb = 0
cpu_quota_percentage = 0
quota_limit_mb = 0
scsi_pr_level = 0
mycnf = /opt/mysql/etc/7360/my.cnf
run_user = actiontech-mysql
umask_dir = 0750
umask = 0640
id = mysql-mt1cbg
group_id = mysql-test
[mysql]
no-auto-rehash
prompt = '\\u@\\h:\\p\\R:\\m:\\s[\\d]> '
#default-character-set = utf8mb4
#tee = /data/mysql_tmp/mysql_operation.log
[mysqld]
super_read_only = 1
# DO NOT MODIFY, Universe will generate this part
port = 7360
server_id = 123
basedir = /opt/mysql/base/5.7.40
datadir = /data/mysql/data/7360
log_bin = /opt/mysql/log/binlog/7360/mysql-bin
tmpdir = /opt/mysql/tmp/7360
relay_log = /opt/mysql/log/relaylog/7360/mysql-relay
innodb_log_group_home_dir = /opt/mysql/log/redolog/7360
log_error = /data/mysql/data/7360/mysql-error.log
# 数据库ip
report_host = xxx
# BINLOG
binlog_error_action = ABORT_SERVER
binlog_format = row
binlog_rows_query_log_events = 1
log_slave_updates = 1
master_info_repository = TABLE
max_binlog_size = 250M
relay_log_info_repository = TABLE
relay_log_recovery = 1
sync_binlog = 1
# GTID #
gtid_mode = ON
enforce_gtid_consistency = 1
binlog_gtid_simple_recovery = 1
# ENGINE
default_storage_engine = InnoDB
innodb_buffer_pool_size = 64G
innodb_data_file_path = ibdata1:1G:autoextend
innodb_file_per_table = 1
innodb_flush_log_at_trx_commit = 1
innodb_flush_method = O_DIRECT
innodb_io_capacity = 1000
innodb_log_buffer_size = 64M
innodb_log_file_size = 2G
innodb_log_files_in_group = 2
innodb_max_dirty_pages_pct = 60
innodb_print_all_deadlocks = 1
#innodb_stats_on_metadata = 0
innodb_strict_mode = 1
#innodb_undo_logs = 128 #Deprecated In 5.7.19
#innodb_undo_tablespaces=3 #Deprecated In 5.7.21
innodb_max_undo_log_size = 4G
innodb_undo_log_truncate = 1
innodb_read_io_threads = 8
innodb_write_io_threads = 8
innodb_purge_threads = 4
innodb_buffer_pool_load_at_startup = 1
innodb_buffer_pool_dump_at_shutdown = 1
innodb_buffer_pool_dump_pct = 25
innodb_sort_buffer_size = 8M
#innodb_page_cleaners = 8
innodb_buffer_pool_instances = 8
innodb_lock_wait_timeout = 10
innodb_io_capacity_max = 2000
innodb_flush_neighbors = 1
#innodb_large_prefix = 1
innodb_thread_concurrency = 64
innodb_stats_persistent_sample_pages = 64
innodb_autoinc_lock_mode = 2
innodb_online_alter_log_max_size = 1G
innodb_open_files = 4096
innodb_temp_data_file_path = ibtmp1:12M:autoextend:max:50G
innodb_rollback_segments = 128
#innodb_numa_interleave = 1
# CACHE
key_buffer_size = 16M
tmp_table_size = 64M
max_heap_table_size = 64M
table_open_cache = 2000
query_cache_type = 0
query_cache_size = 0
max_connections = 3000
thread_cache_size = 200
open_files_limit = 65535
binlog_cache_size = 1M
join_buffer_size = 8M
sort_buffer_size = 2M
read_buffer_size = 8M
read_rnd_buffer_size = 8M
table_definition_cache = 2000
table_open_cache_instances = 8
# SLOW LOG
slow_query_log = 1
slow_query_log_file = /data/mysql/data/7360/mysql-slow.log
log_slow_admin_statements = 1
log_slow_slave_statements = 1
long_query_time = 1
# SEMISYNC #
plugin_load = "rpl_semi_sync_master=semisync_master.so;rpl_semi_sync_slave=semisync_slave.so"
rpl_semi_sync_master_enabled = 1
rpl_semi_sync_slave_enabled = 0
rpl_semi_sync_master_wait_for_slave_count = 1
rpl_semi_sync_master_wait_no_slave = 0
rpl_semi_sync_master_timeout = 30000
# CLIENT_DEPRECATE_EOF
session_track_schema = 1
session_track_state_change = 1
session_track_system_variables = '*'
# MISC
log_timestamps = SYSTEM
lower_case_table_names = 1
max_allowed_packet = 64M
read_only = 1
skip_external_locking = 1
skip_name_resolve = 1
skip_slave_start = 1
socket = /data/mysql/data/7360/mysqld.sock
pid_file = /data/mysql/data/7360/mysqld.pid
disabled_storage_engines = ARCHIVE,BLACKHOLE,EXAMPLE,FEDERATED,MEMORY,MERGE,NDB
log-output = TABLE,FILE
character_set_server = utf8mb4
secure_file_priv = ""
performance-schema-instrument = 'wait/lock/metadata/sql/mdl=ON'
performance-schema-instrument = 'memory/% = COUNTED'
expire_logs_days = 7
max_connect_errors = 1000000
interactive_timeout = 1800
wait_timeout = 1800
log_bin_trust_function_creators = 1
# MTS
slave-parallel-type = LOGICAL_CLOCK
slave_parallel_workers = 16
slave_preserve_commit_order = ON
slave_rows_search_algorithms = 'INDEX_SCAN,HASH_SCAN'
##BaseConfig
collation_server = utf8mb4_bin
explicit_defaults_for_timestamp = 1
transaction_isolation = READ-COMMITTED
##Unused
#plugin-load-add = validate_password.so
#validate_password_policy = MEDIUM
总结
如果我没用过分库分表,面试官问我数据库优化,我可能也会回答分库分表。但是踩过几个坑之后可能会推荐其他的方式。
1、按业务分表,比如用户表放在用户库,订单表放在订单库,用微服务的思想切割数据库减少数据库压力。
2、如果数据量超过10E,可以考虑上分布式数据库,融合了OLAP和OLTP的优点,毕竟mysql其实不适合做大数据量的查询统计。评论区也可以推荐一下有哪些好的数据库。
3、按时间归档数据表,每天或者每个月把历史数据存入历史数据表,适用于大数据量且历史数据查询较少的业务。
每个技术都有它的利弊,比如微服务、分库分表、分布式数据库等。按需选择技术类型,切勿过度设计!
来源:juejin.cn/post/7444014749321461811
Mybatis-Plus的insert执行之后,id是怎么获取的?
在日常开发中,会经常使用Mybatis-Plus
当简单的插入一条记录时,使用mapper的insert是比较简洁的写法
@Data
public class NoEo {
Long id;
String no;
}
NoEo noEo = new NoEo();
noEo.setNo("321");
noMapper.insert(noEo);
System.out.println(noEo);
这里可以注意到一个细节,就是不管我们使用的是什么类型的id,好像都不需要去setId,也能执行insert语句
不仅不需要setId,在insert语句执行完毕之后,我们还能通过实体类获取到这条insert的记录的id是什么
这背后的原理是什么呢?
自增类型ID
刚学Java的时候,插入了一条记录还要再select一次来获取这条记录的id,比较青涩
后面误打误撞才发现可以直接从insert的实体类中拿到这个id
难道框架是自己帮我查了一次嘛
先来看看自增id的情况
首先要先把yml中的mp的id类型设置为auto
mybatis-plus:
global-config:
db-config:
id-type: auto
然后从insert语句开始一直往下跟进
noMapper.insert(noEo);
后面会来到这个方法
// com.baomidou.mybatisplus.core.executor.MybatisSimpleExecutor#doUpdate
@Override
public int doUpdate(MappedStatement ms, Object parameter) throws SQLException {
Statement stmt = null;
try {
Configuration configuration = ms.getConfiguration();
StatementHandler handler = configuration.newStatementHandler(this, ms, parameter, RowBounds.DEFAULT, null, null);
stmt = prepareStatement(handler, ms.getStatementLog(), false);
return stmt == null ? 0 : handler.update(stmt);
} finally {
closeStatement(stmt);
}
}
在执行了下面这个方法之后
handler.update(stmt)
实体类的id就赋值上了
继续往下跟
// org.apache.ibatis.executor.statement.PreparedStatementHandler#update
@Override
public int update(Statement statement) throws SQLException {
PreparedStatement ps = (PreparedStatement) statement;
ps.execute();
int rows = ps.getUpdateCount();
Object parameterObject = boundSql.getParameterObject();
KeyGenerator keyGenerator = mappedStatement.getKeyGenerator();
keyGenerator.processAfter(executor, mappedStatement, ps, parameterObject);
return rows;
}
最后的赋值在这一行
keyGenerator.processAfter
可以看到会有一个KeyGenerator做一个后置增强,它具体的实现类是Jdbc3KeyGenerator
// org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator#processAfter
@Override
public void processAfter(Executor executor, MappedStatement ms, Statement stmt, Object parameter) {
processBatch(ms, stmt, parameter);
}
// org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator#processBatch
public void processBatch(MappedStatement ms, Statement stmt, Object parameter) {
final String[] keyProperties = ms.getKeyProperties();
if (keyProperties == null || keyProperties.length == 0) {
return;
}
try (ResultSet rs = stmt.getGeneratedKeys()) {
final ResultSetMetaData rsmd = rs.getMetaData();
final Configuration configuration = ms.getConfiguration();
if (rsmd.getColumnCount() < keyProperties.length) {
// Error?
} else {
assignKeys(configuration, rs, rsmd, keyProperties, parameter);
}
} catch (Exception e) {
throw new ExecutorException("Error getting generated key or setting result to parameter object. Cause: " + e, e);
}
}
// org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator#assignKeys
private void assignKeys(Configuration configuration, ResultSet rs, ResultSetMetaData rsmd, String[] keyProperties,
Object parameter) throws SQLException {
if (parameter instanceof ParamMap || parameter instanceof StrictMap) {
// Multi-param or single param with @Param
assignKeysToParamMap(configuration, rs, rsmd, keyProperties, (Map<String, ?>) parameter);
} else if (parameter instanceof ArrayList && !((ArrayList<?>) parameter).isEmpty()
&& ((ArrayList<?>) parameter).get(0) instanceof ParamMap) {
// Multi-param or single param with @Param in batch operation
assignKeysToParamMapList(configuration, rs, rsmd, keyProperties, (ArrayList<ParamMap<?>>) parameter);
} else {
// Single param without @Param
// 当前case会走这里
assignKeysToParam(configuration, rs, rsmd, keyProperties, parameter);
}
}
// org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator#assignKeysToParam
private void assignKeysToParam(Configuration configuration, ResultSet rs, ResultSetMetaData rsmd,
String[] keyProperties, Object parameter) throws SQLException {
Collection<?> params = collectionize(parameter);
if (params.isEmpty()) {
return;
}
List<KeyAssigner> assignerList = new ArrayList<>();
for (int i = 0; i < keyProperties.length; i++) {
assignerList.add(new KeyAssigner(configuration, rsmd, i + 1, null, keyProperties[i]));
}
Iterator<?> iterator = params.iterator();
while (rs.next()) {
if (!iterator.hasNext()) {
throw new ExecutorException(String.format(MSG_TOO_MANY_KEYS, params.size()));
}
Object param = iterator.next();
assignerList.forEach(x -> x.assign(rs, param));
}
}
// org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator.KeyAssigner#assign
protected void assign(ResultSet rs, Object param) {
if (paramName != null) {
// If paramName is set, param is ParamMap
param = ((ParamMap<?>) param).get(paramName);
}
MetaObject metaParam = configuration.newMetaObject(param);
try {
if (typeHandler == null) {
if (metaParam.hasSetter(propertyName)) {
// 获取主键的类型
Class<?> propertyType = metaParam.getSetterType(propertyName);
// 获取主键类型处理器
typeHandler = typeHandlerRegistry.getTypeHandler(propertyType,
JdbcType.forCode(rsmd.getColumnType(columnPosition)));
} else {
throw new ExecutorException("No setter found for the keyProperty '" + propertyName + "' in '"
+ metaParam.getOriginalObject().getClass().getName() + "'.");
}
}
if (typeHandler == null) {
// Error?
} else {
// 获取主键的值
Object value = typeHandler.getResult(rs, columnPosition);
// 设置主键值
metaParam.setValue(propertyName, value);
}
} catch (SQLException e) {
throw new ExecutorException("Error getting generated key or setting result to parameter object. Cause: " + e,
e);
}
}
// com.mysql.cj.jdbc.result.ResultSetImpl#getObject(int, java.lang.Class<T>)
@Override
public <T> T getObject(int columnIndex, Class<T> type) throws SQLException {
// ...
else if (type.equals(Long.class) || type.equals(Long.TYPE)) {
checkRowPos();
checkColumnBounds(columnIndex);
return (T) this.thisRow.getValue(columnIndex - 1, this.longValueFactory);
}
// ...
}
最后可以看到这个自增id是在ResultSet的thisRow里面
然后后面的流程就是去解析这个字节数据获取这个long的id
就不往下赘述了
雪花算法ID
yml切换回雪花算法
mybatis-plus:
global-config:
db-config:
id-type: assign_id
在使用雪花算法的时候,也是会走到这个方法
// com.baomidou.mybatisplus.core.executor.MybatisSimpleExecutor#doUpdate
@Override
public int doUpdate(MappedStatement ms, Object parameter) throws SQLException {
Statement stmt = null;
try {
Configuration configuration = ms.getConfiguration();
StatementHandler handler = configuration.newStatementHandler(this, ms, parameter, RowBounds.DEFAULT, null, null);
stmt = prepareStatement(handler, ms.getStatementLog(), false);
return stmt == null ? 0 : handler.update(stmt);
} finally {
closeStatement(stmt);
}
}
但是不同的是,执行完这一行之后,实体类的id字段就已经赋值上了
StatementHandler handler = configuration.newStatementHandler(this, ms, parameter, RowBounds.DEFAULT, null, null);
继续往下跟进
// org.apache.ibatis.session.Configuration#newStatementHandler
public StatementHandler newStatementHandler(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
StatementHandler statementHandler = new RoutingStatementHandler(executor, mappedStatement, parameterObject, rowBounds, resultHandler, boundSql);
statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler);
return statementHandler;
}
// org.apache.ibatis.executor.statement.RoutingStatementHandler#RoutingStatementHandler
public RoutingStatementHandler(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
switch (ms.getStatementType()) {
// ...
case PREPARED:
delegate = new PreparedStatementHandler(executor, ms, parameter, rowBounds, resultHandler, boundSql);
break;
// ...
}
}
最后跟进到一个构造器,会有一个processParameter的方法
// com.baomidou.mybatisplus.core.MybatisParameterHandler#MybatisParameterHandler
public MybatisParameterHandler(MappedStatement mappedStatement, Object parameter, BoundSql boundSql) {
this.typeHandlerRegistry = mappedStatement.getConfiguration().getTypeHandlerRegistry();
this.mappedStatement = mappedStatement;
this.boundSql = boundSql;
this.configuration = mappedStatement.getConfiguration();
this.sqlCommandType = mappedStatement.getSqlCommandType();
this.parameterObject = processParameter(parameter);
}
在这个方法里面会去增强参数
// com.baomidou.mybatisplus.core.MybatisParameterHandler#processParameter
public Object processParameter(Object parameter) {
/* 只处理插入或更新操作 */
if (parameter != null
&& (SqlCommandType.INSERT == this.sqlCommandType || SqlCommandType.UPDATE == this.sqlCommandType)) {
//检查 parameterObject
if (ReflectionKit.isPrimitiveOrWrapper(parameter.getClass())
|| parameter.getClass() == String.class) {
return parameter;
}
Collection<Object> parameters = getParameters(parameter);
if (null != parameters) {
parameters.forEach(this::process);
} else {
process(parameter);
}
}
return parameter;
}
// com.baomidou.mybatisplus.core.MybatisParameterHandler#process
private void process(Object parameter) {
if (parameter != null) {
TableInfo tableInfo = null;
Object entity = parameter;
if (parameter instanceof Map) {
Map<?, ?> map = (Map<?, ?>) parameter;
if (map.containsKey(Constants.ENTITY)) {
Object et = map.get(Constants.ENTITY);
if (et != null) {
entity = et;
tableInfo = TableInfoHelper.getTableInfo(entity.getClass());
}
}
} else {
tableInfo = TableInfoHelper.getTableInfo(parameter.getClass());
}
if (tableInfo != null) {
//到这里就应该转换到实体参数对象了,因为填充和ID处理都是争对实体对象处理的,不用传递原参数对象下去.
MetaObject metaObject = this.configuration.newMetaObject(entity);
if (SqlCommandType.INSERT == this.sqlCommandType) {
populateKeys(tableInfo, metaObject, entity);
insertFill(metaObject, tableInfo);
} else {
updateFill(metaObject, tableInfo);
}
}
}
}
最终生成id并赋值的操作是在populateKeys中
// com.baomidou.mybatisplus.core.MybatisParameterHandler#populateKeys
protected void populateKeys(TableInfo tableInfo, MetaObject metaObject, Object entity) {
final IdType idType = tableInfo.getIdType();
final String keyProperty = tableInfo.getKeyProperty();
if (StringUtils.isNotBlank(keyProperty) && null != idType && idType.getKey() >= 3) {
final IdentifierGenerator identifierGenerator = GlobalConfigUtils.getGlobalConfig(this.configuration).getIdentifierGenerator();
Object idValue = metaObject.getValue(keyProperty);
if (StringUtils.checkValNull(idValue)) {
if (idType.getKey() == IdType.ASSIGN_ID.getKey()) {
if (Number.class.isAssignableFrom(tableInfo.getKeyType())) {
metaObject.setValue(keyProperty, identifierGenerator.nextId(entity));
} else {
metaObject.setValue(keyProperty, identifierGenerator.nextId(entity).toString());
}
} else if (idType.getKey() == IdType.ASSIGN_UUID.getKey()) {
metaObject.setValue(keyProperty, identifierGenerator.nextUUID(entity));
}
}
}
}
在tableInfo中可以得知Id的类型
如果是雪花算法类型,那么生成雪花id;UUID同理
总结
insert之后,id被赋值到实体类的时机要根据具体情况具体讨论:
如果是自增类型的id,那么要在插入数据库完成之后,在ResultSet的ByteArrayRow中获取到这个id
如果是雪花算法id,那么在在插入数据库之前,会通过参数增强的方式,提前生成一个雪花id,然后赋值给实体类
来源:juejin.cn/post/7319541656399102002
第一次排查 Java 内存泄漏,别人觉得惊险为什么我觉得脸红害羞呢
今天前端一直在群里说,服务是不是又挂了?一直返回 503。我一听这不对劲,赶紧看了一眼 K8S 的 pod 状态,居然重启了4次。测试环境只有一个副本,所以赶紧把副本数给上调到了3个。
堵住前端的嘴,免得破坏我在老板心目中的形象,我害怕下次加薪名单没有我,而优化名单有我。
暂时安抚好前端之后我得立马看看哪里出问题了,先看看 K8S 为什么让这个容器领盒饭了。
Last State: Terminated
Reason: OOMKilled
看起来是 JVM 胃口太大,被 K8S 嫌弃从而被赶走了。看看最近谁提交部署了,把人拉过来拷问一番。
代码摆出来分析,发现这小子每次使用http调用都会 new 一个连接池对象。一次业务请求使用了 6 次 http 调用,也就是会 new 6 个连接池对象。有可能是这里的问题,抓紧改了发上去测试看看。
不出意外的话又出意外了,上去之后也没缓解,那就不是这个问题了。要找到具体的原因还是不能瞎猜,得有专业的工具来进行分析才行。之前为了省点镜像空间,所以使用了 jre
的基础镜像。
总所周知,jre
只有一个运行环境,是没有开发工具的。所以我们得使用 jdk
。你说我为省那点空间干什么?都想抽自己了。我们应该以 "让打靶老板花钱"为荣,以 "为打靶老板省钱"为耻。
把JDK准备好之后,就要开始我的第一次了。开始之前总是需要洗白白的,把一些影响心情的东西全部处理掉,就像这个 Skywalking,之前一直跟着我。但现在影响到我了,我得暂时把它放一边。不然他会在进行的过程中一直蹦出来烦人。
使用 Skywalking
需要设置此环境变量,每一次执行Java相关的命令都会执行 Skywalking
的一些操作,可以使用 unset
命令把环境变量临时置空。因为等我做完还是需要他来继续给我工作的。
unset JAVA_TOOL_OPTIONS
琐碎事处理完了之后,就得挑个技师才行。这行命令一把梭就会打印出所有 java
进程信息,这主要是为了获取到 vmid
,也就是技师的编号。
jps -lv
root@xxx-ext-6464577d8-vvz2n:/app# jps -lv
608 sun.tools.jps.Jps -Denv.class.path=.:/usr/local/java/lib/rt.jar:/usr/local/java/lib/dt.jar:/usr/local/java/lib/tools.jar -Dapplication.home=/usr/local/openjdk-8 -Xms8m
7 /root/app/xxx-ext.jar -javaagent:/skywalking/agent/skywalking-agent.jar -Dfile.encoding=UTF-8 -Xms1024m -Xmx2048m
568 sun.tools.jstat.Jstat -javaagent:/skywalking/agent/skywalking-agent.jar -Denv.class.path=.:/usr/local/java/lib/rt.jar:/usr/local/java/lib/dt.jar:/usr/local/java/lib/tools.jar -Dapplication.home=/usr/local/openjdk-8 -Xms8m
这里总共查到3个Java进程,608 jps
、7 xxx-ext
和 568 jstat
。中间这个 7 号技师 xxx-ext
就是我相中的,我将会把第一次交给他。
选完技师就正式开始了,过程中要时刻关心对方的身体状态。隔几秒钟就问一下状态怎么样?为了方便时刻了解对方的身体状态,可以用这个命令每隔5s就问一下。如果你对自己的能力有信心可以把间隔设置短一些。
# jstat -gcutil {vmid} {间隔毫秒}
jstat -gcutil 7 5000
root@xxx-ext-6464577d8-vvz2n:/app# jstat -gcutil 7 5000
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
99.96 0.00 100.00 100.00 95.85 94.74 178 8.047 8 3.966 12.012
99.97 0.00 100.00 100.00 95.50 94.33 178 8.047 11 8.072 16.118
99.99 0.00 100.00 100.00 95.51 94.33 178 8.047 14 12.408 20.455
100.00 0.00 100.00 100.00 95.48 94.30 178 8.047 18 17.140 25.187
100.00 0.00 100.00 100.00 95.48 94.30 178 8.047 23 22.730 30.776
100.00 0.00 100.00 100.00 95.48 94.30 178 8.047 27 27.035 35.082
100.00 0.00 100.00 100.00 95.49 94.30 178 8.047 32 32.614 40.661
虽然是第一次,但对方给回来的信息务必要了然于胸。知己知彼胜券在握,所以要把下面的心法记住。这会影响我们下一步的动作。
S0/S1 是Survivor区空间使用率
E 是新生代空间使用率
O 是老年代空间使用率
YGC 是 Young GC 次数
YGCT 是 Young GC 总耗时
FGC 是 Full GC 次数
FGCT 是 Full GC 总耗时
当对方的状态到达一个关键点的时候,一般是老年代满,或者是新生代满,这就表示对方快溢出来了。像我提供的这个示例,E 和 O 的使用率都是100,就说明对方不仅满了,还快噶了。我们得赶紧把这个关键时刻详细探究一下,看看是哪个对象让对方感觉到满的。
用这个命令查询对方体内对象占用排名,不用贪多,前10个就绰绰有余了。你能把前10个全部弄清楚就够牛了。
jmap -histo:live 7 | head -n 10
root@xxx-ext-6464577d8-vvz2n:/app# jmap -histo:live 7 | head -n 10
num #instances #bytes class name
----------------------------------------------
1: 454962 1852234368 [C
2: 1773671 56757472 java.util.HashMap$Node
3: 881987 30188352 [B
4: 55036 19781352 [Ljava.util.HashMap$Node;
5: 857235 13715760 java.lang.Integer
6: 852094 13633504 com.knuddels.jtokkit.ByteArrayWrapper
7: 454195 10900680 java.lang.String
8: 104386 6436624 [Ljava.lang.Object;
9: 191593 6130976 java.util.concurrent.ConcurrentHashMap$Node
10: 63278 5568464 java.lang.reflect.Method
可以看到对方已经在边缘了,我们要抓紧分析了。我提供的这个示例,排名前三分别是 [C
、java.util.HashMap$Node
和 [B
,[C
表示字符数组,[B
表示字节数组。看来对方偏爱 [C
,占用差不多1.7G,需要重点分析它。
这一步就到了十字路口,关键点在于我们能不能从这里分析得到对方偏爱的对象,从而定位到代码中的问题点。一旦我们定位到代码中的问题点,那就证明对方已经被我们拿捏了,流程结束。
那就开始分析吧,先看看最近哪个瘪犊子提交了代码,把他拉过来。然后看最近改动的代码哪里和 [C
相关,一般是 List<String>
、StringBuffer
这类对象。
我没想到小丑竟是我自己🤡,有一个接口入参是一个 List<ID>
,当这个 list 传了空的时候,就会把库里的所有数据都查出来。
破案了,这次把对方完全拿捏了,流程结束。
如果上一步无法拿捏,那就不要讲武德了。把对方的一举一动dump下来,最终导出成堆快照来分析。
dump 时间取决于数据量
jmap -dump:live,format=b,file=heap.hprof 7
root@xxx-ext-6464577d8-vvz2n:/app# jmap -dump:live,format=b,file=heap.hprof 7
Dumping heap to /app/heap.hprof ...
Heap dump file created
将dump文件从pod中复制出来
kubectl cp <ns>/<pod>:/app/heap.hprof ./heap.hprof
kubectl cp test/xxx-ext-6464577d8-vvz2n:/app/heap.hprof ./heap.hprof
我摊牌了,这一步我压根没做。
当我想从pod中把对快照复制出来的时候磁盘空间不够,然后pod就被 K8S 这个暴脾气干了,只剩下我颤抖的手无力地放在键盘上。
Ref
来源:juejin.cn/post/7426189830562906149
SpringBoot 实战:文件上传之秒传、断点续传、分片上传
文件上传功能几乎是每个 Web 应用不可或缺的一部分。无论是个人博客中的图片上传,还是企业级应用中的文档管理,文件上传都扮演着至关重要的角色。今天,松哥和大家来聊聊文件上传中的几个高级玩法——秒传、断点续传和分片上传。
一 文件上传的常见场景
在日常开发中,文件上传的场景多种多样。比如,在线教育平台上的视频资源上传,社交平台上的图片分享,以及企业内部的知识文档管理等。这些场景对文件上传的要求也各不相同,有的追求速度,有的注重稳定性,还有的需要考虑文件大小和安全性。因此,针对不同需求,我们有了秒传、断点续传和分片上传等解决方案。
二 秒传、断点上传与分片上传
秒传
秒传,顾名思义,就是几乎瞬间完成文件上传的过程。其实现原理是通过计算文件的哈希值(如 MD5 或 SHA-1),然后将这个唯一的标识符发送给服务器。如果服务器上已经存在相同的文件,则直接返回成功信息,避免了重复上传。这种方式不仅节省了带宽,也大大提高了用户体验。
断点续传
断点续传是指在网络不稳定或者用户主动中断上传后,能够从上次中断的地方继续上传,而不需要重新开始整个过程。这对于大文件上传尤为重要,因为它可以有效防止因网络问题导致的上传失败,同时也能节约用户的流量和时间。
分片上传
分片上传则是将一个大文件分割成多个小块分别上传,最后再由服务器合并成完整的文件。这种做法的好处是可以并行处理多个小文件,提高上传效率;同时,如果某一部分上传失败,只需要重传这一部分,不影响其他部分。
三 秒传实战
后端实现
在 SpringBoot 项目中,我们可以使用 MessageDigest
类来计算文件的 MD5 值,然后检查数据库中是否存在该文件。
@RestController
@RequestMapping("/file")
public class FileController {
@Autowired
FileService fileService;
@PostMapping("/upload1")
public ResponseEntity<String> secondUpload(@RequestParam(value = "file",required = false) MultipartFile file,@RequestParam(required = false,value = "md5") String md5) {
try {
// 检查数据库中是否已存在该文件
if (fileService.existsByMd5(md5)) {
return ResponseEntity.ok("文件已存在");
}
// 保存文件到服务器
file.transferTo(new File("/path/to/save/" + file.getOriginalFilename()));
// 保存文件信息到数据库
fileService.save(new FileInfo(file.getOriginalFilename(), DigestUtils.md5DigestAsHex(file.getInputStream())));
return ResponseEntity.ok("上传成功");
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("上传失败");
}
}
}
前端调用
前端可以通过 JavaScript 的 FileReader API 读取文件内容,通过 spark-md5 计算 MD5 值,然后发送给后端进行校验。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>秒传</title>
<script src="spark-md5.js"></script>
</head>
<body>
<input type="file" id="fileInput" />
<button onclick="startUpload()">开始上传</button>
<hr>
<script>
async function startUpload() {
const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0];
if (!file) {
alert("请选择文件");
return;
}
const md5 = await calculateMd5(file);
const formData = new FormData();
formData.append('md5', md5);
const response = await fetch('/file/upload1', {
method: 'POST',
body: formData
});
const result = await response.text();
if (response.ok) {
if (result != "文件已存在") {
// 开始上传文件
}
} else {
console.error("上传失败: " + result);
}
}
function calculateMd5(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const spark = new SparkMD5.ArrayBuffer();
spark.append(reader.result);
resolve(spark.end());
};
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(file);
});
}
</script>
</body>
</html>
前端分为两个步骤:
- 计算文件的 MD5 值,计算之后发送给服务端确定文件是否存在。
- 如果文件已经存在,则不需要继续上传文件;如果文件不存在,则开始上传文件,上传文件和 MD5 校验请求类似,上面的案例代码中我就没有重复演示了,松哥在书里和之前的课程里都多次讲过文件上传,这里不再啰嗦。
四 分片上传实战
分片上传关键是在前端对文件切片,比如一个 10MB 的文件切为 10 份,每份 1MB。每次上传的时候,需要多一个参数记录当前上传的文件切片的起始位置。
比如一个 10MB 的文件,切为 10 份,每份 1MB,那么:
- 第 0 片,从 0 开始,一共是
1024*1024
个字节。 - 第 1 片,从
1024*1024
开始,一共是1024*1024
个字节。 - 第 2 片...
把这个搞懂,后面的代码就好理解了。
后端实现
private static final String UPLOAD_DIR = System.getProperty("user.home") + "/uploads/";
/**
* 上传文件到指定位置
*
* @param file 上传的文件
* @param start 文件开始上传的位置
* @return ResponseEntity<String> 上传结果
*/
@PostMapping("/upload2")
public ResponseEntity<String> resumeUpload(@RequestParam("file") MultipartFile file, @RequestParam("start") long start,@RequestParam("fileName") String fileName) {
try {
File directory = new File(UPLOAD_DIR);
if (!directory.exists()) {
directory.mkdirs();
}
File targetFile = new File(UPLOAD_DIR + fileName);
RandomAccessFile randomAccessFile = new RandomAccessFile(targetFile, "rw");
FileChannel channel = randomAccessFile.getChannel();
channel.position(start);
channel.transferFrom(file.getResource().readableChannel(), start, file.getSize());
channel.close();
randomAccessFile.close();
return ResponseEntity.ok("上传成功");
} catch (Exception e) {
System.out.println("上传失败: "+e.getMessage());
return ResponseEntity.status(500).body("上传失败");
}
}
后端每次处理的时候,需要先设置文件的起始位置。
前端调用
前端需要将文件切分成多个小块,然后依次上传。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>分片示例</title>
</head>
<body>
<input type="file" id="fileInput" />
<button onclick="startUpload()">开始上传</button>
<script>
async function startUpload() {
const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0];
if (!file) {
alert("请选择文件");
return;
}
const filename = file.name;
let start = 0;
uploadFile(file, start);
}
async function uploadFile(file, start) {
const chunkSize = 1024 * 1024; // 每个分片1MB
const total = Math.ceil(file.size / chunkSize);
for (let i = 0; i < total; i++) {
const chunkStart = start + i * chunkSize;
const chunkEnd = Math.min(chunkStart + chunkSize, file.size);
const chunk = file.slice(chunkStart, chunkEnd);
const formData = new FormData();
formData.append('file', chunk);
formData.append('start', chunkStart);
formData.append('fileName', file.name);
const response = await fetch('/file/upload2', {
method: 'POST',
body: formData
});
const result = await response.text();
if (response.ok) {
console.log(`分片 ${i + 1}/${total} 上传成功`);
} else {
console.error(`分片 ${i + 1}/${total} 上传失败: ${result}`);
break;
}
}
}
</script>
</body>
</html>
五 断点续传实战
断点续传的技术原理类似于分片上传。
当文件已经上传了一部分之后,断了需要重新开始上传。
那么我们的思路是这样的:
- 前端先发送一个请求,检查要上传的文件在服务端是否已经存在,如果存在,目前大小是多少。
- 前端根据已经存在的大小,继续上传文件即可。
后端案例
先来看后端检查的接口,如下:
@GetMapping("/check")
public ResponseEntity<Long> checkFile(@RequestParam("filename") String filename) {
File file = new File(UPLOAD_DIR + filename);
if (file.exists()) {
return ResponseEntity.ok(file.length());
} else {
return ResponseEntity.ok(0L);
}
}
如果文件存在,则返回已经存在的文件大小。
如果文件不存在,则返回 0,表示前端从头开始上传该文件。
前端调用
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>断点续传示例</title>
</head>
<body>
<input type="file" id="fileInput"/>
<button onclick="startUpload()">开始上传</button>
<script>
async function startUpload() {
const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0];
if (!file) {
alert("请选择文件");
return;
}
const filename = file.name;
let start = await checkFile(filename);
uploadFile(file, start);
}
async function checkFile(filename) {
const response = await fetch(`/file/check?filename=${filename}`);
const start = await response.json();
return start;
}
async function uploadFile(file, start) {
const chunkSize = 1024 * 1024; // 每个分片1MB
const total = Math.ceil((file.size - start) / chunkSize);
for (let i = 0; i < total; i++) {
const chunkStart = start + i * chunkSize;
const chunkEnd = Math.min(chunkStart + chunkSize, file.size);
const chunk = file.slice(chunkStart, chunkEnd);
const formData = new FormData();
formData.append('file', chunk);
formData.append('start', chunkStart);
formData.append('fileName', file.name);
const response = await fetch('/file/upload2', {
method: 'POST',
body: formData
});
const result = await response.text();
if (response.ok) {
console.log(`分片 ${i + 1}/${total} 上传成功`);
} else {
console.error(`分片 ${i + 1}/${total} 上传失败: ${result}`);
break;
}
}
}
</script>
</body>
</html>
这个案例实际上是一个断点续传+分片上传的案例,相关知识点并不难,小伙伴们可以自行体会下。
六 总结
好了,以上就是关于文件上传中秒传、断点续传和分片上传的实战分享。通过这些技术的应用,我们可以极大地提升文件上传的效率和稳定性,改善用户体验。希望各位小伙伴在自己的项目中也能灵活运用这些技巧,解决实际问题。
本文完整案例:github.com/lenve/sprin…
来源:juejin.cn/post/7436026758438453274
MyBatis-Plus 效能提升秘籍:掌握这些注解,事半功倍!
MyBatis-Plus是一个功能强大的MyBatis扩展插件,它提供了许多便捷的注解,让我们在开发过程中能够更加高效地完成数据库操作,本文将带你一一了解这些注解,并通过实例来展示它们的魅力。
一、@Tablename注解
这个注解用于指定实体类对应的数据库表名。如果你的表名和实体类名不一致,就需要用到它:
@TableName("user_info")
public class UserInfo {
// 类的属性和方法
}
在上述代码中,即使实体类名为UserInfo,但通过@TableName注解,我们知道它对应数据库中的"user_info"表。
二、@Tableld注解
每个数据库表都有主键,@TableId注解用于标识实体类中的主键属性。通常与@TableName配合使用,确保主键映射正确。
AUTO(0),
NONE(1),
INPUT(2),
ASSIGN_ID(3),
ASSIGN_UUID(4),
/** @deprecated */
@Deprecated
ID_WORKER(3),
/** @deprecated */
@Deprecated
ID_WORKER_STR(3),
/** @deprecated */
@Deprecated
UUID(4);
- INPUT 如果开发者没有手动赋值,则数据库通过自增的方式给主键赋值,如果开发者手动赋值,则存入该值。
- AUTO 默认就是数据库自增,开发者无需赋值。
- ASSIGN_ID MP 自动赋值,雪花算法。
- ASSIGN_UUID 主键的数据类型必须是 String,自动生成 UUID 进行赋值。
// 自己赋值
//@TableId(type = IdType.INPUT)
// 默认使用的雪花算法,长度比较长,所以使用Long类型,不用自己赋值
@TableId
private Long id;
测试
@Test
void save(){
// 由于id加的有注解,这里就不用赋值了
Student student = new Student();
student.setName("天明");
student.setAge(18);
mapper.insert(student);
}
雪花算法
雪花算法是由Twitter公布的分布式主键生成算法,它能够保证不同表的主键的不重复性,以及相同表的主键的有序性。
核心思想:
- 长度共64bit(一个long型)。
- 首先是一个符号位,1bit标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0。
- 41bit时间截(毫秒级),存储的是时间截的差值(当前时间截 - 开始时间截),结果约等于69.73年。
- 10bit作为机器的ID(5个bit是数据中心,5个bit的机器ID,可以部署在1024个节点)。
- 12bit作为毫秒内的流水号(意味着每个节点在每毫秒可以产生 4096 个 ID)。
优点: 整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞,并且效率较高。
三、@TableField注解
当你的实体类属性名与数据库字段名不一致时,@TableField注解可以帮助你建立二者之间的映射关系。
- 映射非主键字段,value 映射字段名;
- exist 表示是否为数据库字段 false,如果实体类中的成员变量在数据库中没有对应的字段,则可以使用 exist,VO、DTO;
- select 表示是否查询该字段;
- fill 表示是否自动填充,将对象存入数据库的时候,由 MyBatis Plus 自动给某些字段赋值,create_time、update_time。
自动填充
1)给表添加 create_time、update_time 字段。
2)实体类中添加成员变量。
package com.md.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.md.enums.StatusEnum;
import lombok.Data;
import java.util.Date;
@Data
@TableName(value = "student")
public class Student {
@TableId
private Long id;
// 当该字段名称与数据库名字不一致
@TableField(value = "name")
private String name;
// 不查询该字段
@TableField(select = false)
private Integer age;
// 当数据库中没有该字段,就忽略
@TableField(exist = false)
private String gender;
// 第一次添加填充
@TableField(fill = FieldFill.INSERT)
private Date createTime;
// 第一次添加的时候填充,但之后每次更新也会进行填充
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
}
3)创建自动填充处理器。
注意:不要忘记添加 @Component 注解。
package com.md.handler;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import java.util.Date;
/**
* @author md
* @Desc 对实体类中使用的自动填充注解进行编写
* @date 2020/10/26 17:29
*/
// 加入注解才能生效
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
this.setFieldValByName("createTime", new Date(), metaObject);
this.setFieldValByName("updateTime", new Date(), metaObject);
}
@Override
public void updateFill(MetaObject metaObject) {
this.setFieldValByName("updateTime", new Date(), metaObject);
}
}
4)测试
@Test
void save(){
// 由于id加的有注解,这里就不用赋值了
Student student = new Student();
student.setName("韩立");
student.setAge(11);
// 时间自动填充
mapper.insert(student);
}
5)更新
当该字段发生变化的时候时间会自动更新。
@Test
void update(){
Student student = mapper.selectById(1001);
student.setName("韩信");
mapper.updateById(student);
}
四、@TableLogic注解
在很多应用中,数据并不是真的被删除,而是标记为已删除状态。@TableLogic注解用于标识逻辑删除字段,通常配合逻辑删除功能使用。
1、逻辑删除
物理删除: 真实删除,将对应数据从数据库中删除,之后查询不到此条被删除的数据。
逻辑删除: 假删除,将对应数据中代表是否被删除字段的状态修改为“被删除状态”,之后在数据库中仍旧能看到此条数据记录。
使用场景: 可以进行数据恢复。
2、实现逻辑删除
step1: 数据库中创建逻辑删除状态列。
step2: 实体类中添加逻辑删除属性。
@TableLogic
@TableField(value = "is_deleted")
private Integer deleted;
3、测试
测试删除: 删除功能被转变为更新功能。
-- 实际执行的SQL
update user set is_deleted=1 where id = 1 and is_deleted=0
测试查询: 被逻辑删除的数据默认不会被查询。
-- 实际执行的SQL
select id,name,is_deleted from user where is_deleted=0
你还在苦恼找不到真正免费的编程学习平台吗?可以试试【云端源想】!课程视频、知识库、微实战、云实验室、一对一咨询……你想要的全部学习资源这里都有,重点是现在还是免费的!点这里即可查看!
五、@Version注解
乐观锁是一种并发控制策略,@Version注解用于标识版本号字段,确保数据的一致性。
乐观锁
标记乐观锁,通过 version 字段来保证数据的安全性,当修改数据的时候,会以 version 作为条件,当条件成立的时候才会修改成功。
version = 2
- 线程1:update … set version = 2 where version = 1
- 线程2:update … set version = 2 where version = 1
1.数据库表添加 version 字段,默认值为 1。
2.实体类添加 version 成员变量,并且添加 @Version。
package com.md.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.md.enums.StatusEnum;
import lombok.Data;
import java.util.Date;
@Data
@TableName(value = "student")
public class Student {
@TableId
private Long id;
@TableField(value = "name")
private String name;
@TableField(select = false)
private Integer age;
@TableField(exist = false)
private String gender;
@TableField(fill = FieldFill.INSERT)
private Date createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
@Version
private Integer version; //版本号
}
3.注册配置类
在 MybatisPlusConfig 中注册 Bean。
package com.md.config;
import com.baomidou.mybatisplus.extension.plugins.OptimisticLockerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author md
* @Desc
* @date 2020/10/26 20:42
*/
@Configuration
public class MyBatisPlusConfig {
/**
* 乐观锁
*/
@Bean
public OptimisticLockerInterceptor optimisticLockerInterceptor(){
return new OptimisticLockerInterceptor();
}
}
六、@EnumValue注解
mp框架对枚举进行处理的一个注解。
使用场景: 创建枚举类,在需要存储数据库的属性上添加@EnumValue注解。
public enum SexEnum {
MAN(1, "男"),
WOMAN(2, "女");
@EnumValue
private Integer key;
}
MyBatis-Plus的注解是开发者的好帮手,它们简化了映射配置,提高了开发效率。希望以上的介绍能帮助新手朋友们快速理解和运用这些常用注解,让你们在MyBatis-Plus的世界里游刃有余!记得实践是最好的学习方式,快去动手试试吧!
来源:juejin.cn/post/7340471458949169215
Java 语法糖,你用过几个?
你好,我是猿java。
这篇文章,我们来聊聊 Java 语法糖。
什么是语法糖?
语法糖(Syntactic Sugar)是编程语言中的一种设计概念,它指的是在语法层面上对某些操作提供更简洁、更易读的表示方式。这种表示方式并不会新增语言的功能,而只是使代码更简洁、更直观,便于开发者理解和维护。
语法糖的作用:
- 提高代码可读性:语法糖可以使代码更加贴近自然语言或开发者的思维方式,从而更容易理解。
- 减少样板代码:语法糖可以减少重复的样板代码,使得开发者可以更专注于业务逻辑。
- 降低出错率:简化的语法可以减少代码量,从而降低出错的概率。
因此,语法糖不是 Java 语言特有的,它是很多编程语言设计中的一些语法特性,这些特性使代码更加简洁易读,但并不会引入新的功能或能力。
那么,Java中有哪些语法糖呢?
Java 语法糖
1. 自动装箱与拆箱
自动装箱和拆箱 (Autoboxing and Unboxing)是 Java 5 引入的特性,用于在基本数据类型和它们对应的包装类之间自动转换。
// 自动装箱
Integer num = 10; // 实际上是 Integer.valueOf(10)
// 自动拆箱
int n = num; // 实际上是 num.intValue()
2. 增强型 for 循环
增强型 for 循环(也称为 for-each 循环)用于遍历数组或集合。
int[] numbers = {1, 2, 3, 4, 5};
for (int number : numbers) {
System.out.println(number);
}
3. 泛型
泛型(Generics)使得类、接口和方法可以操作指定类型的对象,提供了类型安全的检查和消除了类型转换的需要。
List<String> list = new ArrayList<>();
list.add("Hello");
String s = list.get(0); // 不需要类型转换
4. 可变参数
可变参数(Varargs)允许在方法中传递任意数量的参数。
public void printNumbers(int... numbers) {
for (int number : numbers) {
System.out.println(number);
}
}
printNumbers(1, 2, 3, 4, 5);
5. try-with-resources
try-with-resources 语句用于自动关闭资源,实现了 AutoCloseable
接口的资源会在语句结束时自动关闭。
try (BufferedReader br = new BufferedReader(new FileReader("file.txt"))) {
System.out.println(br.readLine());
} catch (IOException e) {
e.printStackTrace();
}
6. Lambda 表达式
Lambda 表达式是 Java 8 引入的特性,使得可以使用更简洁的语法来实现函数式接口(只有一个抽象方法的接口)。
List<String> list = Arrays.asList("a", "b", "c");
list.forEach(s -> System.out.println(s));
7. 方法引用
方法引用(Method References)是 Lambda 表达式的一种简写形式,用于直接引用已有的方法。
list.forEach(System.out::println);
8. 字符串连接
从 Java 5 开始,Java 编译器会将字符串的连接优化为 StringBuilder
操作。
String message = "Hello, " + "world!"; // 实际上是 new StringBuilder().append("Hello, ").append("world!").toString();
9. Switch 表达式
Java 12 引入的 Switch 表达式使得 Switch 语句更加简洁和灵活。
int day = 5;
String dayName = switch (day) {
case 1 -> "Sunday";
case 2 -> "Monday";
case 3 -> "Tuesday";
case 4 -> "Wednesday";
case 5 -> "Thursday";
case 6 -> "Friday";
case 7 -> "Saturday";
default -> "Invalid day";
};
10. 类型推断 (Type Inference)
Java 10 引入了局部变量类型推断,通过 var
关键字来声明变量,编译器会自动推断变量的类型。
var list = new ArrayList<String>();
list.add("Hello");
这些语法糖使得 Java 代码更加简洁和易读,但需要注意的是,它们并不会增加语言本身的功能,只是对已有功能的一种简化和封装。
总结
本文,我们介绍了 Java 语言中的一些语法糖,从上面的例子可以看出,Java 语法糖只是一些简化的语法,可以使代码更简洁易读,而本身并不增加新的功能。
学习交流
如果你觉得文章有帮助,请帮忙转发给更多的好友,或关注公众号:猿java,持续输出硬核文章。
来源:juejin.cn/post/7412672643633791039
即时通讯 - 短轮询、长轮询、长连接、WebSocket
实现即时通讯主要有四种方式,它们分别是短轮询、长轮询、长连接、WebSocket
1. 短轮询
1.1 说明
传统的web通信模式。后台处理数据,需要一定时间,前端想要知道后端的处理结果,就要不定时的向后端发出请求以获得最新情况,得到想要的结果,或者超出规定的最长时间就终止再发请求。
1.2 优点:
前后端程序编写比较容易
1.3 缺点:
- 效率低:轮询的请求间隔时间一般是固定的,无论服务器是否有新的数据,都需要等待一段固定的时间。当数据更新的频率较低时,大部分请求都是无效的;
- 实时性差:如果数据在两次请求间发生了更新,那么用户只能在下一次轮询时才能得到最新数据;
- 浪费资源:高频率的操作功能,或者页面访问,导致的大量用户使用轮询时,会占用大量的网络资源,降低整体网络速度
1.4 基础实现:
每隔一段时间发送一个请求即可,得到想要的结果,或者超出规定的最长时间就终止再发请求。
let count = 0;
const timer = null;
// 超时时间
const MAX_TIME = 10 * 1000;
// 心跳间隙
const HEARTBEAT_INTERVAL = 1000;
/**
* @description: 模拟请求后端数据 (第6次时返回true)
*/
const fetchData = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('fetch data...', count)
count += 1
if(count === 5) {
resolve(true);
}else {
resolve(false);
}
}, 1000)
});
};
/**
* @description: 异步轮询,当超时时或者接口返回true时,中断轮询
*/
const doSomething = async () => {
try {
let startTime = 0;
const timer = setInterval(async ()=>{
const res = await fetchData();
startTime += HEARTBEAT_INTERVAL;
if(res || startTime > MAX_TIME) {
clearInterval(timer)
}
}, HEARTBEAT_INTERVAL)
} catch (err) {
console.log(err);
}
};
doSomething();
2. 长轮询
2.1 说明
客户端向服务器发送Ajax请求,服务器接到请求后hold住连接
,直到有新消息才返回响应信息并关闭连接,客户端处理完响应信息后再向服务器发送新的请求
长轮询的实现原理与轮询类似,只是客户端的请求会保持打开状态,直到服务器返回响应或超时。在服务器端,可以使用阻塞方式处理长轮询请求,即服务器线程会一直等待直到有新的数据或事件,然后返回响应给客户端。客户端收到响应后,可以处理数据或事件,并随后发送下一个长轮询请求。
2.2 优点
长轮询相较于轮询技术来说,减少了不必要的网络流量和请求次数,降低了服务器和客户端的资源消耗
2.3 缺点
但是相对于传统的轮询技术,长轮询的实现更加复杂,并且需要服务器支持长时间保持连接的能力。
2.4 基础实现
超时和未得到想要的结果都需要重新执行原方法(递归实现)
async function subscribe() {
let response = await fetch("/subscribe");
if (response.status == 502) {
// 状态 502 是连接超时错误,
// 连接挂起时间过长时可能会发生,
// 远程服务器或代理会关闭它
// 让我们重新连接
await subscribe();
} else if (response.status != 200) {
// 一个 error —— 让我们显示它
showMessage(response.statusText);
// 一秒后重新连接
await new Promise(resolve => setTimeout(resolve, 1000));
await subscribe();
} else {
// 获取并显示消息
let message = await response.text();
showMessage(message);
// 再次调用 subscribe() 以获取下一条消息
await subscribe();
}
}
subscribe();
3. 长链接
3.1 说明
HTTP keep-alive 也称为 HTTP 长连接。它通过重用一个 TCP 连接来发送/接收多个 HTTP请求,来减少创建/关闭多个 TCP 连接的开销
3.1.1 为什么HTTP是短连接?
HTTP是短连接,客户端向服务器发送一个请求,得到响应后,连接就关闭。
例如,用户通过浏览器访问一个web站点上的某个网页,当网页内容加载完毕之后(已得到响应),用户可能需要花费几分钟甚至更多的时间来浏览网页内容,此时完全没有必要继续维持底层连。当用户需要访问其他网页时,再创建新的连接即可。
因此,HTTP连接的寿命通常都很短。这样做的好处是,可以极大的减轻服务端的压力。一般而言,一个站点能支撑的最大并发连接数也是有限的,
面对这么多客户端浏览器,不可能长期维持所有连接。每个客户端取得自己所需的内容后,即关闭连接,更加合理。
3.1.2 为什么要引入keep-alive(也称HTTP长连接)
通常一个网页可能会有很多组成部分,除了文本内容,还会有诸如:js、css、图片等静态资源,有时还会异步发起AJAX请求。
只有所有的资源都加载完毕后,我们看到网页完整的内容。然而,一个网页中,可能引入了几十个js、css文件,上百张图片,
如果每请求一个资源,就创建一个连接,然后关闭,代价实在太大了。
基于此背景,我们希望连接能够在短时间内得到复用,在加载同一个网页中的内容时,尽量的复用连接,这就是HTTP协议中keep-alive属性的作用。
- HTTP 1.0 中默认是关闭的,需要在http头加入"Connection: Keep-Alive",才能启用Keep-Alive;
- HTTP 1.1 中默认启用Keep-Alive,如果加入"Connection: close ",才关闭
注意:这里复用的是 TCP连接,并不是复用request
HTTP 的 Keep-Alive 也叫 HTTP 长连接,该功能是由「应用程序」实现的,可以使得用同一个 TCP 连接来发送和接收多个 HTTP 请求/应答,减少了 HTTP 短连接带来的多次 TCP 连接建立和释放的开销。
TCP 的 Keepalive 也叫 TCP 保活机制,该功能是由「内核」实现的,当客户端和服务端长达一定时间没有进行数据交互时,内核为了确保该连接是否还有效,就会发送探测报文,来检测对方是否还在线,然后来决定是否要关闭该连接
4. WebSocket
4.1 说明
Websocket是基于HTTP
协议的,在和服务端建立了链接后,服务端有数据有了变化后会主动推送给前端;
一般可以用于 股票交易行情分析、聊天室、在线游戏,替代轮询和长轮询。
4.2 优点
请求响应快,不浪费资源。(传统的http请求,其并发能力都是依赖同时发起多个TCP连接访问服务器实现的(因此并发数受限于浏览器允许的并发连接数),而websocket则允许我们在一条ws连接上同时并发多个请求,即在A请求发出后A响应还未到达,就可以继续发出B请求。由于TCP的慢启动特性(新连接速度上来是需要时间的),以及连接本身的握手损耗,都使得websocket协议的这一特性有很大的效率提升;http协议的头部太大,且每个请求携带的几百上千字节的头部大部分是重复的,websocket则因为复用长连接而没有这一问题。)
4.3 缺点
- 主流浏览器支持的Web Socket版本不一致;
- 服务端没有标准的API。
4.4 基础实现
这里使用了一个 网页和打印app的通信举例(部分敏感代码已省略)
const printConnect = () => {
try {
const host = 'ws://localhost:13888'
cloundPrintInfo.webSocket = new WebSocket(host)
// 通信
cloundPrintInfo.webSocket.onopen = () => {
// 获取打印机列表
cloundPrintInfo.webSocket.send(
JSON.stringify({
cmd: 'getPrinters',
version: '1.0',
})
)
}
// 通信返回
cloundPrintInfo.webSocket.onmessage = (msg: any) => {
const { data: returnData } = msg
// code 1000: 全部成功 1001: 部分失败 1002: 全部失败
const { cmd } = JSON.parse(`${returnData}`)
// 获取打印机数据
if (cmd === 'GETPRINTERS') {
printerInfoSet(returnData)
}
// 处理发送打印请求结果
if (cmd === 'PRINT') {
handlePrintResult(returnData)
}
// 批量推送打印结果
if (cmd === 'NOTIFYPRINTRESULT') {
cloudPrintTip(returnData)
}
}
// 通信失败
cloundPrintInfo.webSocket.onerror = () => {
printClose()
}
// 关闭通信
cloundPrintInfo.webSocket.onclose = () => {
printClose()
}
} catch (exception) {
console.log('建立连接失败', exception)
printClose()
}
}
在实际应用中,你可能需要处理更复杂的情况,比如重连逻辑、心跳机制来保持连接活跃、以及安全性问题等
重连逻辑:当WebSocket连接由于网络问题或其他原因断开时,客户端可能需要自动尝试重新连接
var socket;
var reconnectInterval = 5000; // 重连间隔时间,例如5秒
function connect() {
socket = new WebSocket('ws://localhost:3000');
socket.onopen = function(event) {
console.log('Connected to the WebSocket server');
};
socket.onclose = function(event) {
console.log('WebSocket connection closed. Reconnecting...');
setTimeout(connect, reconnectInterval); // 在指定时间后尝试重连
};
socket.onerror = function(error) {
console.error('WebSocket error:', error);
socket.close(); // 确保在错误后关闭连接,触发重连
};
}
connect(); // 初始连接
心跳机制:指定期发送消息以保持连接活跃的过程。这可以防止代理服务器或负载均衡器因为长时间的不活动而关闭连接
function heartbeat() {
if (socket.readyState === WebSocket.OPEN) {
socket.send('ping'); // 发送心跳消息,内容可以是'ping'
}
}
// 每30秒发送一次心跳
var heartbeatInterval = setInterval(heartbeat, 30000);
// 清除心跳定时器,通常在连接关闭时调用
function clearHeartbeat() {
clearInterval(heartbeatInterval);
}
socket.onclose = function(event) {
clearHeartbeat();
};
4种对比
从兼容性角度考虑,短轮询>长轮询>长连接SSE>WebSocket;
从性能方面考虑,WebSocket>长连接SSE>长轮询>短轮询。
参考文章:
来源:juejin.cn/post/7451612338408521743
面试官:MySQL单表过亿数据,如何优化count(*)全表的操作?
本文首发于公众号:托尼学长,立个写 1024 篇原创技术面试文章的flag,欢迎过来视察监督~
最近有好几个同学跟我说,他在技术面试过程中被问到这个问题了,让我找时间系统地讲解一下。
其实从某种意义上来说,这并不是一个严谨的面试题,接下来 show me the SQL,我们一起来看一下。
如下图所示,一张有 3000多万行记录的 user 表,执行全表 count 操作需要 14.8 秒的时间。
接下来我们稍作调整再试一次,神奇的一幕出现了,执行全表 count 操作竟然连 1 毫秒的时间都用不上。
这是为什么呢?
其实原因很简单,第一次执行全表 count 操作的时候,我用的是 MySQL InnoDB 存储引擎,而第二次则是用的 MySQL MyISAM 存储引擎。
这两者的差别在于,前者在执行 count(*) 操作的时候,需要将表中每行数据读取出来进行累加计数,而后者已经将表的总行数存储下来了,只需要直接返回即可。
当然,InnoDB 存储引擎对 count(*) 操作也进行了一些优化,如果该表创建了二级索引,其会通过全索引扫描的方式来代替全表扫描进行累加计数,
毕竟,二级索引值只存储了索引列和主键列两个字段,遍历计数肯定比存储所有字段的数据表的 IO 次数少很多,也就意味着其执行效率更高。
而且,MySQL 的优化器会选择最小的那个二级索引的索引文件进行遍历计数。
所以,这个技术面试题严谨的问法应该是 —— MySQL InnoDB 存储引擎单表过亿数据,如何优化 count(*) 全表的操作?
下面我们就来列举几个常见的技术解决方案,如下图所示:
(1)Redis 累加计数
这是一种最主流且简单直接的实现方式。
由于我们基本上不会对数据表执行 delete 操作,所以当有新的数据被写入表的时候,通过 Redis 的 incr 或 incrby 命令进行累加计数,并在用户查询汇总数据的时候直接返回结果即可。
如下图所示:
该实现方式在查询性能和数据准确性上两者兼得,Redis 需要同时负责累加计数和返回查询结果操作,缺点在于会引入缓存和数据库间的数据一致性的问题。
(2)MySQL 累加计数表 + 事务
这种实现方式跟“Redis 累加计数”大同小异,唯一的区别就是将计数的存储介质从 Redis 换成了 MySQL。
如下图所示:
但这么一换,就可以将写入表操作和累加计数操作放在一个数据库事务中,也就解决了缓存和数据库间的数据一致性的问题。
该实现方式在查询性能和数据准确性上两者兼得,但不如“Redis 累加计数”方式的性能高,在高并发场景下数据库会成为性能瓶颈。
(3)MySQL 累加计数表 + 触发器
这种实现方式跟“MySQL 累加计数表 + 事务”的表结构是一样的,如下图所示:
****
唯一的区别就是增加一个触发器,不用在工程代码中通过事务进行实现了。
CREATE TRIGGER `user_count_trigger` AFTER INSERT ON `user` FOR EACH ROW BEGIN UPDATE user_count SET count = count + 1 WHERE id = NEW.id;END
该实现方式在查询性能和数据准确性上两者兼得,与“MySQL 累加计数表 + 事务”方式相比,最大的好处就是不用污染工程代码了。
(4)MySQL 增加并行线程
在 MySQL 8.014 版本中,总算增加了并行查询的新特性,其通过参数 innodb_parallel_read_threads 进行设定,默认值为 4。
下面我们做个实验,将这个参数值调得大一些:
set local innodb_parallel_read_threads = 16;
然后,我们再来执行一次上文中那个 3000 多万行记录 user 表的全表 count 操作,结果如下所示:
参数调整后,执行全表 count 操作的时间由之前的 14.8 秒,降低至现在的 6.1 秒,是可以看到效果的。
接下来,我们继续将参数值调整得大一些,看看是否还有优化空间:
set local innodb_parallel_read_threads = 32;
然后,我们再来执行一次上文中那个 3000 多万行记录 user 表的全表 count 操作,结果如下所示:
参数调整后,执行全表 count 操作的时间竟然变长了,从原来的 6.1 秒变成了 6.8 秒,看样子优化空间已经达到上限了,再多增加执行线程数量只会适得其反。
该实现方式一样可以保证数据准确性,在查询性能上有所提升但相对有限,其最大优势是只需要调整一个数据库参数,在工程代码上不会有任何改动。
不过,如果数据库此时的负载和 IOPS 已经很高了,那开启并行线程或者将并行线程数量调大,会加速消耗数据库资源。
(5)MySQL 增加二级索引
还记得我们在上文中说的内容吗?
InnoDB 存储引擎对 count() 操作也进行了一些优化,如果该表创建了二级索引,其会通过全索引扫描的方式来代替全表扫描进行累加计数,*
毕竟,二级索引值只存储了索引列和主键列两个字段,遍历计数肯定比存储所有字段的数据表的IO次数少很多,也就意味着执行效率更高。
而且,MySQL 的优化器会选择最小的那个二级索引的索引文件进行遍历计数。
为了验证这个说法,我们给 user 表中最小的 sex 字段加一个二级索引,然后通过 EXPLAIN 命令看一下 SQL 语句的执行计划:
果然,这个 SQL 语句的执行计划会使用新建的 sex 索引,接下来我们执行一次看看时长:
果不其然,执行全表 count 操作走了 sex 二级索引后,SQL 执行时间由之前的 14.8 秒降低至现在的 10.6 秒,还是可以看到效果的。
btw:大家可能会觉得效果并不明显,这是因为我们用来测试的 user 表中算上主键 ID 只有七个字段,而且没有一个大字段。
反之,user 表中的字段数量越多,且包含的大字段越多,其优化效果就会越明显。
该实现方式一样可以保证数据准确性,在查询性能上有所提升但相对有限,其最大优势是只需要创建一个二级索引,在工程代码上不会有任何改动。
(6)SHOW TABLE STATUS
如下图所示,通过 SHOW TABLE STATUS 命令也可以查出来全表的行数:
我们常用于查看执行计划的 EXPLAIN 命令也能实现:
只不过,通过这两个命令得出来的表记录数是估算出来的,都不太准确。那到底有多不准确呢,我们来计算一下。
公式为:33554432 / 33216098 = 1.01
就这个 case 而言,误差率大概在百分之一左右。
该实现方式一样可以保证查询性能,无论表中有多大量级的数据都能毫秒级返回结果,且在工程代码方面不会有任何改动,但数据准确性上相差较多,只能用作大概估算。
来源:juejin.cn/post/7444919285170307107
订单超时自动取消,这7种方案真香!
大家好,我是苏三,又跟大家见面了。
前言
在电商、外卖、票务等系统中,订单超时未支付自动取消是一个常见的需求。
这个功能乍一看很简单,甚至很多初学者会觉得:"不就是加个定时器么?" 但真到了实际工作中,细节的复杂程度往往会超乎预期。
这里我们从基础到高级,逐步分析各种实现方案,最后分享一些在生产中常见的优化技巧,希望对你会有所帮助。
在电商、外卖、票务等系统中,订单超时未支付自动取消是一个常见的需求。
这个功能乍一看很简单,甚至很多初学者会觉得:"不就是加个定时器么?" 但真到了实际工作中,细节的复杂程度往往会超乎预期。
这里我们从基础到高级,逐步分析各种实现方案,最后分享一些在生产中常见的优化技巧,希望对你会有所帮助。
1. 使用延时队列(DelayQueue)
适用场景: 订单数量较少,系统并发量不高。
延时队列是Java并发包(java.util.concurrent
)中的一个数据结构,专门用于处理延时任务。
订单在创建时,将其放入延时队列,并设置超时时间。
延时时间到了以后,队列会触发消费逻辑,执行取消操作。
示例代码:
import java.util.concurrent.*;
public class OrderCancelService {
private static final DelayQueue delayQueue = new DelayQueue<>();
public static void main(String[] args) throws InterruptedException {
// 启动消费者线程
new Thread(() -> {
while (true) {
try {
OrderTask task = delayQueue.take(); // 获取到期任务
System.out.println("取消订单:" + task.getOrderId());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}).start();
// 模拟订单创建
for (int i = 1; i <= 5; i++) {
delayQueue.put(new OrderTask(i, System.currentTimeMillis() + 5000)); // 5秒后取消
System.out.println("订单" + i + "已创建");
}
}
static class OrderTask implements Delayed {
private final long expireTime;
private final int orderId;
public OrderTask(int orderId, long expireTime) {
this.orderId = orderId;
this.expireTime = expireTime;
}
public int getOrderId() {
return orderId;
}
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(expireTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
}
@Override
public int compareTo(Delayed o) {
return Long.compare(this.expireTime, ((OrderTask) o).expireTime);
}
}
}
优点:
- 实现简单,逻辑清晰。
缺点:
- 依赖内存,系统重启会丢失任务。
- 随着订单量增加,内存占用会显著上升。
适用场景: 订单数量较少,系统并发量不高。
延时队列是Java并发包(java.util.concurrent
)中的一个数据结构,专门用于处理延时任务。
订单在创建时,将其放入延时队列,并设置超时时间。
延时时间到了以后,队列会触发消费逻辑,执行取消操作。
示例代码:
import java.util.concurrent.*;
public class OrderCancelService {
private static final DelayQueue delayQueue = new DelayQueue<>();
public static void main(String[] args) throws InterruptedException {
// 启动消费者线程
new Thread(() -> {
while (true) {
try {
OrderTask task = delayQueue.take(); // 获取到期任务
System.out.println("取消订单:" + task.getOrderId());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}).start();
// 模拟订单创建
for (int i = 1; i <= 5; i++) {
delayQueue.put(new OrderTask(i, System.currentTimeMillis() + 5000)); // 5秒后取消
System.out.println("订单" + i + "已创建");
}
}
static class OrderTask implements Delayed {
private final long expireTime;
private final int orderId;
public OrderTask(int orderId, long expireTime) {
this.orderId = orderId;
this.expireTime = expireTime;
}
public int getOrderId() {
return orderId;
}
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(expireTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
}
@Override
public int compareTo(Delayed o) {
return Long.compare(this.expireTime, ((OrderTask) o).expireTime);
}
}
}
优点:
- 实现简单,逻辑清晰。
缺点:
- 依赖内存,系统重启会丢失任务。
- 随着订单量增加,内存占用会显著上升。
2. 基于数据库轮询
适用场景: 订单数量较多,但系统对实时性要求不高。
轮询是最容易想到的方案:定期扫描数据库,将超时的订单状态更新为“已取消”。
示例代码:
public void cancelExpiredOrders() {
String sql = "UPDATE orders SET status = 'CANCELLED' WHERE status = 'PENDING' AND create_time < ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setTimestamp(1, new Timestamp(System.currentTimeMillis() - 30 * 60 * 1000)); // 30分钟未支付取消
int affectedRows = ps.executeUpdate();
System.out.println("取消订单数量:" + affectedRows);
} catch (SQLException e) {
e.printStackTrace();
}
}
优点:
- 数据可靠性强,不依赖内存。
- 实现成本低,无需引入第三方组件。
缺点:
- 频繁扫描数据库,会带来较大的性能开销。
- 实时性较差(通常定时任务间隔为分钟级别)。
优化建议:
- 为相关字段加索引,避免全表扫描。
- 结合分表分库策略,减少单表压力。
适用场景: 订单数量较多,但系统对实时性要求不高。
轮询是最容易想到的方案:定期扫描数据库,将超时的订单状态更新为“已取消”。
示例代码:
public void cancelExpiredOrders() {
String sql = "UPDATE orders SET status = 'CANCELLED' WHERE status = 'PENDING' AND create_time < ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setTimestamp(1, new Timestamp(System.currentTimeMillis() - 30 * 60 * 1000)); // 30分钟未支付取消
int affectedRows = ps.executeUpdate();
System.out.println("取消订单数量:" + affectedRows);
} catch (SQLException e) {
e.printStackTrace();
}
}
优点:
- 数据可靠性强,不依赖内存。
- 实现成本低,无需引入第三方组件。
缺点:
- 频繁扫描数据库,会带来较大的性能开销。
- 实时性较差(通常定时任务间隔为分钟级别)。
优化建议:
- 为相关字段加索引,避免全表扫描。
- 结合分表分库策略,减少单表压力。
3. 基于Redis队列
适用场景: 适合对实时性有要求的中小型项目。
Redis 的 List 或 Sorted Set 数据结构非常适合用作延时任务队列。
我们可以把订单的超时时间作为 Score,订单 ID 作为 Value 存到 Redis 的 ZSet 中,定时去取出到期的订单进行取消。
例子:
public void addOrderToQueue(String orderId, long expireTime) {
jedis.zadd("order_delay_queue", expireTime, orderId);
}
public void processExpiredOrders() {
long now = System.currentTimeMillis();
Set<String> expiredOrders = jedis.zrangeByScore("order_delay_queue", 0, now);
for (String orderId : expiredOrders) {
System.out.println("取消订单:" + orderId);
jedis.zrem("order_delay_queue", orderId); // 删除已处理的订单
}
}
优点:
- 实时性高。
- Redis 的性能优秀,延迟小。
缺点:
- Redis 容量有限,适合中小规模任务。
- 需要额外处理 Redis 宕机或数据丢失的问题。
适用场景: 适合对实时性有要求的中小型项目。
Redis 的 List 或 Sorted Set 数据结构非常适合用作延时任务队列。
我们可以把订单的超时时间作为 Score,订单 ID 作为 Value 存到 Redis 的 ZSet 中,定时去取出到期的订单进行取消。
例子:
public void addOrderToQueue(String orderId, long expireTime) {
jedis.zadd("order_delay_queue", expireTime, orderId);
}
public void processExpiredOrders() {
long now = System.currentTimeMillis();
Set<String> expiredOrders = jedis.zrangeByScore("order_delay_queue", 0, now);
for (String orderId : expiredOrders) {
System.out.println("取消订单:" + orderId);
jedis.zrem("order_delay_queue", orderId); // 删除已处理的订单
}
}
优点:
- 实时性高。
- Redis 的性能优秀,延迟小。
缺点:
- Redis 容量有限,适合中小规模任务。
- 需要额外处理 Redis 宕机或数据丢失的问题。
4. Redis Key 过期回调
适用场景: 对超时事件实时性要求高,并且希望依赖 Redis 本身的特性实现简单的任务调度。
Redis 提供了 Key 的过期功能,结合 keyevent
事件通知机制,可以实现订单的自动取消逻辑。
当订单设置超时时间后,Redis 会在 Key 过期时发送通知,我们只需要订阅这个事件并进行相应的处理。
例子:
- 设置订单的过期时间:
public void setOrderWithExpiration(String orderId, long expireSeconds) {
jedis.setex("order:" + orderId, expireSeconds, "PENDING");
}
- 订阅 Redis 的过期事件:
public void subscribeToExpirationEvents() {
Jedis jedis = new Jedis("localhost");
jedis.psubscribe(new JedisPubSub() {
@Override
public void onPMessage(String pattern, String channel, String message) {
if (channel.equals("__keyevent@0__:expired")) {
System.out.println("接收到过期事件,取消订单:" + message);
// 执行取消订单的业务逻辑
}
}
}, "__keyevent@0__:expired"); // 订阅过期事件
}
适用场景: 对超时事件实时性要求高,并且希望依赖 Redis 本身的特性实现简单的任务调度。
Redis 提供了 Key 的过期功能,结合 keyevent
事件通知机制,可以实现订单的自动取消逻辑。
当订单设置超时时间后,Redis 会在 Key 过期时发送通知,我们只需要订阅这个事件并进行相应的处理。
例子:
- 设置订单的过期时间:
public void setOrderWithExpiration(String orderId, long expireSeconds) {
jedis.setex("order:" + orderId, expireSeconds, "PENDING");
}
- 订阅 Redis 的过期事件:
public void subscribeToExpirationEvents() {
Jedis jedis = new Jedis("localhost");
jedis.psubscribe(new JedisPubSub() {
@Override
public void onPMessage(String pattern, String channel, String message) {
if (channel.equals("__keyevent@0__:expired")) {
System.out.println("接收到过期事件,取消订单:" + message);
// 执行取消订单的业务逻辑
}
}
}, "__keyevent@0__:expired"); // 订阅过期事件
}
优点:
- 实现简单,直接利用 Redis 的过期机制。
- 实时性高,过期事件触发后立即响应。
缺点:
- 依赖 Redis 的事件通知功能,需要开启
notify-keyspace-events
配置。 - 如果 Redis 中大量使用过期 Key,可能导致性能问题。
注意事项: 要使用 Key 过期事件,需要确保 Redis 配置文件中 notify-keyspace-events
的值包含 Ex
。比如:
notify-keyspace-events Ex
最近就业形势比较困难,为了感谢各位小伙伴对苏三一直以来的支持,我特地创建了一些工作内推群, 看看能不能帮助到大家。
你可以在群里发布招聘信息,也可以内推工作,也可以在群里投递简历找工作,也可以在群里交流面试或者工作的话题。
添加苏三的私人微信:su_san_java,备注:掘金+所在城市,即可加入。
5. 基于消息队列(如RabbitMQ)
适用场景: 高并发系统,实时性要求高。
订单创建时,将订单消息发送到延迟队列(如RabbitMQ 的 x-delayed-message
插件)。
延迟时间到了以后,消息会重新投递到消费者,消费者执行取消操作。
示例代码(以RabbitMQ为例):
public void sendOrderToDelayQueue(String orderId, long delay) {
Map<String, Object> args = new HashMap<>();
args.put("x-delayed-type", "direct");
ConnectionFactory factory = new ConnectionFactory();
try (Connection connection = factory.newConnection();
Channel channel = connection.createChannel()) {
channel.exchangeDeclare("delayed_exchange", "x-delayed-message", true, false, args);
channel.queueDeclare("delay_queue", true, false, false, null);
channel.queueBind("delay_queue", "delayed_exchange", "order.cancel");
AMQP.BasicProperties props = new AMQP.BasicProperties.Builder()
.headers(Map.of("x-delay", delay)) // 延迟时间
.build();
channel.basicPublish("delayed_exchange", "order.cancel", props, orderId.getBytes());
} catch (Exception e) {
e.printStackTrace();
}
}
优点:
- 消息队列支持分布式,高并发下表现优秀。
- 数据可靠性高,不容易丢消息。
缺点:
- 引入消息队列增加了系统复杂性。
- 需要处理队列堆积的问题。
6. 使用定时任务框架
适用场景: 订单取消操作复杂,需要分布式支持。
定时任务框架,比如:Quartz、Elastic-Job,能够高效地管理任务调度,适合处理批量任务。
比如 Quartz 可以通过配置 Cron 表达式,定时执行订单取消逻辑。
示例代码:
@Scheduled(cron = "0 */5 * * * ?")
public void scanAndCancelOrders() {
System.out.println("开始扫描并取消过期订单");
// 这里调用数据库更新逻辑
}
优点:
- 成熟的调度框架支持复杂任务调度。
- 灵活性高,支持分布式扩展。
缺点:
- 对实时性支持有限。
- 框架本身较复杂。
7. 基于触发式事件流处理
适用场景: 需要处理实时性较高的订单取消,同时结合复杂业务逻辑,例如根据用户行为动态调整超时时间。
可以借助事件流处理框架(如 Apache Flink 或 Spark Streaming),实时地处理订单状态,并触发超时事件。
每个订单生成后,可以作为事件流的一部分,订单未支付时通过流计算触发超时取消逻辑。
示例代码(以 Apache Flink 为例):
DataStream orderStream = env.fromCollection(orderEvents);
orderStream
.keyBy(OrderEvent::getOrderId)
.process(new KeyedProcessFunction() {
@Override
public void processElement(OrderEvent event, Context ctx, Collector out ) throws Exception {
// 注册一个定时器
ctx.timerService().registerProcessingTimeTimer(event.getTimestamp() + 30000); // 30秒超时
}
@Override
public void onTimer(long timestamp, OnTimerContext ctx, Collector out ) throws Exception {
// 定时器触发,执行订单取消逻辑
System.out.println("订单超时取消,订单ID:" + ctx.getCurrentKey());
}
});
优点:
- 实时性高,支持复杂事件处理逻辑。
- 适合动态调整超时时间,满足灵活的业务需求。
缺点:
- 引入了流计算框架,系统复杂度增加。
- 对运维要求较高。
总结
每种方案都有自己的适用场景,大家在选择的时候,记得结合业务需求、订单量、并发量来综合考虑。
如果你的项目规模较小,可以直接用延时队列或 Redis;而在大型高并发系统中,消息队列和事件流处理往往是首选。
当然,代码实现只是第一步,更重要的是在实际部署和运行中进行性能调优,保证系统的稳定性。
来源:juejin.cn/post/7451018774743269391
妙用MyBatisPlus,12个实战技巧解锁新知识
妙用MyBatisPlus,12个实战技巧解锁新知识
前言
说起数据库ORM,我忽然想起了小时候外婆做的那锅鲜美的羊肉汤。平常人家做的羊肉汤无非是几块肉、几片姜,味道寡淡得很,喝了和喝白开水差不多。但外婆的汤,那是另一回事儿 —— 一锅汤,香气四溢,肉质软烂,汤头浓郁得能让人连碗都想舔干净。
写代码何尝不是如此?以前写Mybatis,就像是在煮一锅没有灵魂的羊肉汤:原料都在,但就是不够鲜美。代码繁琐,每写一个查询都像是在不断调味,却怎么也调不出那种令人惊艳的味道。直到遇见MyBatisPlus,一切都变了 —— 这就像是从普通的羊肉汤,突然升级到了外婆秘制的顶级羊肉汤!
MyBatisPlus就像一位精通厨艺的帮厨,它帮你处理了所有繁琐的准备工作。想要一个复杂的查询?不用自己一刀一刀地切肉、一勺一勺地调味,框架已经帮你准备好了。你只需要轻轻地指挥,代码就像汤汁一样顺滑流畅,性能更是鲜美可口。
在接下来的篇幅里,我将与你分享12个MyBatisPlus优化的"秘制配方"。相信看完这些,你写的每一行代码,都会像外婆的羊肉汤一样,让人回味无穷。
耐心看完,你一定有所收获。
避免使用isNull判断
// ❌ 不推荐
LambdaQueryWrapper<User> wrapper1 = new LambdaQueryWrapper<>();
wrapper1.isNull(User::getStatus);
// ✅ 推荐:使用具体的默认值
LambdaQueryWrapper<User> wrapper2 = new LambdaQueryWrapper<>();
wrapper2.eq(User::getStatus, UserStatusEnum.INACTIVE.getCode());
- 📝 原因:
- 使用具体的默认值可以提高代码的可读性和维护性
- NULL值会使索引失效,导致MySQL无法使用索引进行查询优化
- NULL值的比较需要特殊的处理逻辑,增加了CPU开销
- NULL值会占用额外的存储空间,影响数据压缩效率
明确Select字段
// ❌ 不推荐
// 默认select 所有字段
List<User> users1 = userMapper.selectList(null);
// ✅ 推荐:指定需要的字段
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.select(User::getId, User::getName, User::getAge);
List<User> users2 = userMapper.selectList(wrapper);
- 📝 原因:
- 避免大量无用字段的网络传输开销
- 可以利用索引覆盖,避免回表查询
- 减少数据库解析和序列化的负担
- 降低内存占用,特别是在大量数据查询时
批量操作方法替代循环
// ❌ 不推荐
for (User user : userList) {
userMapper.insert(user);
}
// ✅ 推荐
userService.saveBatch(userList, 100); // 每批次处理100条数据
// ✅ 更优写法:自定义批次大小
userService.saveBatch(userList, BatchConstants.BATCH_SIZE);
- 📝 原因:
- 减少数据库连接的创建和销毁开销
- 批量操作可以在一个事务中完成,提高数据一致性
- 数据库可以优化批量操作的执行计划
- 显著减少网络往返次数,提升吞吐量
Exists方法子查询
// ❌ 不推荐
wrapper.inSql("user_id", "select user_id from order where amount > 1000");
// ✅ 推荐
wrapper.exists("select 1 from order where order.user_id = user.id and amount > 1000");
// ✅ 更优写法:使用LambdaQueryWrapper
wrapper.exists(orderService.lambdaQuery()
.gt(Order::getAmount, 1000)
.apply("order.user_id = user.id"));
- 📝 原因:
- EXISTS是基于索引的快速查询,可以使用到索引
- EXISTS在找到第一个匹配项就会停止扫描
- IN子查询需要加载所有数据到内存后再比较
- 当外表数据量大时,EXISTS的性能优势更明显
使用orderBy代替last
// ❌ 不推荐:SQL注入风险
wrapper.last("ORDER BY " + sortField + " " + sortOrder);
// ❌ 不推荐:直接字符串拼接
wrapper.last("ORDER BY FIELD(status, 'active', 'pending', 'inactive')");
// ✅ 推荐:使用 Lambda 安全排序
wrapper.orderBy(true, true, User::getStatus);
// ✅ 推荐:多字段排序示例
wrapper.orderByAsc(User::getStatus)
.orderByDesc(User::getCreateTime);
- 📝 原因:
- 直接拼接SQL容易导致SQL注入攻击
- 动态SQL可能破坏SQL语义完整性
- 影响SQL语句的可维护性和可读性
- last会绕过MyBatis-Plus的安全检查机制
使用LambdaQuery确保类型安全
// ❌ 不推荐:字段变更后可能遗漏
QueryWrapper<User> wrapper1 = new QueryWrapper<>();
wrapper1.eq("name", "张三").gt("age", 18);
// ✅ 推荐
LambdaQueryWrapper<User> wrapper2 = new LambdaQueryWrapper<>();
wrapper2.eq(User::getName, "张三")
.gt(User::getAge, 18);
// ✅ 更优写法:使用链式调用
userService.lambdaQuery()
.eq(User::getName, "张三")
.gt(User::getAge, 18)
.list();
- 📝 原因:
- 编译期类型检查,避免字段名拼写错误
- IDE可以提供更好的代码补全支持
- 重构时能自动更新字段引用
- 提高代码的可维护性和可读性
用between代替ge和le
// ❌ 不推荐
wrapper.ge(User::getAge, 18)
.le(User::getAge, 30);
// ✅ 推荐
wrapper.between(User::getAge, 18, 30);
// ✅ 更优写法:条件动态判断
wrapper.between(ageStart != null && ageEnd != null,
User::getAge, ageStart, ageEnd);
- 📝 原因:
- 生成的SQL更简洁,减少解析开销
- 数据库优化器可以更好地处理范围查询
- 代码更易读,语义更清晰
- 减少重复编写字段名的机会
排序字段注意索引
// ❌ 不推荐
// 假设lastLoginTime无索引
wrapper.orderByDesc(User::getLastLoginTime);
// ✅ 推荐
// 主键排序
wrapper.orderByDesc(User::getId);
// ✅ 更优写法:组合索引排序
wrapper.orderByDesc(User::getStatus) // status建立了索引
.orderByDesc(User::getId); // 主键排序
- 📝 原因:
- 索引天然具有排序特性,可以避免额外的排序操作
- 无索引排序会导致文件排序,极大影响性能
- 当数据量大时,内存排序可能导致溢出
- 利用索引排序可以实现流式读取
分页参数设置
// ❌ 不推荐
wrapper.last("limit 1000"); // 一次查询过多数据
// ✅ 推荐
Page<User> page = new Page<>(1, 10);
userService.page(page, wrapper);
// ✅ 更优写法:带条件的分页查询
Page<User> result = userService.lambdaQuery()
.eq(User::getStatus, "active")
.page(new Page<>(1, 10));
- 📝 原因:
- 控制单次查询的数据量,避免内存溢出
- 提高首屏加载速度,优化用户体验
- 减少网络传输压力
- 数据库资源利用更合理
条件构造处理Null值
// ❌ 不推荐
if (StringUtils.isNotBlank(name)) {
wrapper.eq("name", name);
}
if (age != null) {
wrapper.eq("age", age);
}
// ✅ 推荐
wrapper.eq(StringUtils.isNotBlank(name), User::getName, name)
.eq(Objects.nonNull(age), User::getAge, age);
// ✅ 更优写法:结合业务场景
wrapper.eq(StringUtils.isNotBlank(name), User::getName, name)
.eq(Objects.nonNull(age), User::getAge, age)
.eq(User::getDeleted, false) // 默认查询未删除记录
.orderByDesc(User::getCreateTime); // 默认按创建时间倒序
- 📝 原因:
- 优雅处理空值,避免无效条件
- 减少代码中的if-else判断
- 提高代码可读性
- 防止生成冗余的SQL条件
⚠️ 下面就要来一些高级货了
查询性能追踪
// ❌ 不推荐:简单计时,代码冗余
public List<User> listUsers(QueryWrapper<User> wrapper) {
long startTime = System.currentTimeMillis();
List<User> users = userMapper.selectList(wrapper);
long endTime = System.currentTimeMillis();
log.info("查询耗时:{}ms", (endTime - startTime));
return users;
}
// ✅ 推荐:使用 Try-with-resources 自动计时
public List<User> listUsersWithPerfTrack(QueryWrapper<User> wrapper) {
try (PerfTracker.TimerContext ignored = PerfTracker.start()) {
return userMapper.selectList(wrapper);
}
}
// 性能追踪工具类
@Slf4j
public class PerfTracker {
private final long startTime;
private final String methodName;
private PerfTracker(String methodName) {
this.startTime = System.currentTimeMillis();
this.methodName = methodName;
}
public static TimerContext start() {
return new TimerContext(Thread.currentThread().getStackTrace()[2].getMethodName());
}
public static class TimerContext implements AutoCloseable {
private final PerfTracker tracker;
private TimerContext(String methodName) {
this.tracker = new PerfTracker(methodName);
}
@Override
public void close() {
long executeTime = System.currentTimeMillis() - tracker.startTime;
if (executeTime > 500) {
log.warn("慢查询告警:方法 {} 耗时 {}ms", tracker.methodName, executeTime);
}
}
}
}
- 📝 原因:
- 业务代码和性能监控代码完全分离
- try-with-resources 即使发生异常,close() 方法也会被调用,确保一定会记录耗时
- 不需要手动管理计时的开始和结束
- 更优雅
枚举类型映射
// 定义枚举
public enum UserStatusEnum {
NORMAL(1, "正常"),
DISABLED(0, "禁用");
@EnumValue // MyBatis-Plus注解
private final Integer code;
private final String desc;
}
// ✅ 推荐:自动映射
public class User {
private UserStatusEnum status;
}
// 查询示例
userMapper.selectList(
new LambdaQueryWrapper<User>()
.eq(User::getStatus, UserStatusEnum.NORMAL)
);
- 📝 原因:
- 类型安全
- 自动处理数据库和枚举转换
- 避免魔法值
- 代码可读性更强
自动处理逻辑删除
@TableLogic // 逻辑删除注解
private Integer deleted;
// ✅ 推荐:自动过滤已删除数据
public List<User> getActiveUsers() {
return userMapper.selectList(null); // 自动过滤deleted=1的记录
}
// 手动删除
userService.removeById(1L); // 实际是更新deleted状态
- 📝 原因:
- 数据不丢失
- 查询自动过滤已删除数据
- 支持数据恢复
- 减少手动编写删除逻辑
- 📷 注意:
- XML中需要手动拼接 deleted = 1
乐观锁更新保护
public class Product {
@Version // 乐观锁版本号
private Integer version;
}
// ✅ 推荐:更新时自动处理版本
public boolean reduceStock(Long productId, Integer count) {
LambdaUpdateWrapper<Product> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(Product::getId, productId)
.ge(Product::getStock, count);
Product product = new Product();
product.setStock(product.getStock() - count);
return productService.update(product, wrapper);
}
- 📝 原因:
- 防止并发冲突
- 自动处理版本控制
- 简化并发更新逻辑
- 提高数据一致性
递增和递减:setIncrBy 和 setDecrBy
// ❌ 不推荐:使用 setSql
userService.lambdaUpdate()
.setSql("integral = integral + 10")
.update();
// ✅ 推荐:使用 setIncrBy
userService.lambdaUpdate()
.eq(User::getId, 1L)
.setIncrBy(User::getIntegral, 10)
.update();
// ✅ 推荐:使用 setDecrBy
userService.lambdaUpdate()
.eq(User::getId, 1L)
.setDecrBy(User::getStock, 5)
.update();
- 📝 原因:
- 类型安全
- 避免手动拼接sql,防止sql注入
- 代码可维护性更强,更清晰
总结
写代码如烹小鲜,讲究的是精细和用心。就像一碗好汤,不仅仅在于锅和火候,更在于厨师对食材的理解和尊重。MyBatisPlus的这12个优化技巧,何尝不是程序员对代码的一种尊重和雕琢?
还记得文章开头说的外婆的羊肉汤吗?优秀的代码,和一碗好汤,都需要用心。每一个细节,每一个调整,都是为了让最终的成果更加完美。MyBatisPlus就像是厨房里的得力助手,它帮你处理繁琐,让你专注于创造。
当你掌握了这些技巧,你的代码将不再是简单的指令堆砌,而是一首优雅的诗,一曲悦耳的交响乐。它们将像外婆的羊肉汤一样,散发着独特的魅力,让人回味无穷。
愿每一位开发者,都能用MyBatisPlus,煮出属于自己的"秘制汤羹"!
代码,就应该是这个样子 —— 简单而不失优雅,高效而不失温度。
来源:juejin.cn/post/7436567167728812044
反射为什么慢?
1. 背景
今天刷到一篇文章,标题是反射为什么慢,一下子懵逼了,确实没想过这个问题;盲猜了一下是由于反射实际上是做了一个代理的动作,导致执行的效率是小于直接实体类去调用方法的。
2. 文章给出的解释
文章中给出的理由是因为以下4点:
- 反射涉及动态解析的内容,不能执行某些虚拟机优化,例如JIT优化技术
- 在反射时,参数需要包装成object[]类型,但是方法真正执行的时候,又使用拆包成真正的类型,这些动作不仅消耗时间,而且过程中会产生很多的对象,这就会导致gc,gc也会导致延时
- 反射的方法调用需要从数组中遍历,这个遍历的过程也比较消耗时间
- 不仅需要对方法的可见性进行检查,参数也需要做额外的检查
3. 结合实际理解
3.1 第一点分析
首先我们需要知道,java中的反射是一种机制,它可以在代码运行过程中,获取类的内部信息(变量、构造方法、成员方法);操作对象的属性、方法。
然后关于反射的原理,首先我们需要知道一个java项目在启动之后,会将class文件加载到堆中,生成一个class对象,这个class对象中有一个类的所有信息,通过这个class对象获取类相关信息的操作我们称为反射。
其次是JIT优化技术,首先我们需要知道在java虚拟机中有两个角色,解释器和编译器;这两者各有优劣,首先是解释器可以在项目启动的时候直接直接发挥作用,省去编译的时候,立即执行,但是在执行效率上有所欠缺;在项目启动之后,随着时间推移,编译器逐渐将机器码编译成本地代码执行,减少解释器的中间损耗,增加了执行效率。
我们可以知道JIT优化通常依赖于在编译时能够知道的静态信息,而反射的动态性可能会破坏这些假设,使得JIT编译器难以进行有效的优化。
3.2 第二点
关于第二点,我们直接写一段反射调用对象方法的demo:
@Test
public void methodTest() {
Class clazz = MyClass.class;
try {
//获取指定方法
//这个注释的会报错 java.lang.NoSuchMethodException
//Method back = clazz.getMethod("back");
Method back = clazz.getMethod("back", String.class);
Method say = clazz.getDeclaredMethod("say", String.class);
//私有方法需要设置
say.setAccessible(true);
MyClass myClass = new MyClass("abc", 99);
//反射调用方法
System.out.println(back.invoke(myClass, "back"));
say.invoke(myClass, "hello world");
} catch (Exception e) {
e.printStackTrace();
}
}
在上面这段代码中,我们调用了一个invoke 方法,并且传了class对象和参数,进入到invoke方法中,我们可以看到invoke方法的入参都是Object类型的,args更是一个Object 数组,这就第二点,关于反射调用过程中的拆装箱。
@CallerSensitive
public Object invoke(Object obj, Object... args)
throws IllegalAccessException, IllegalArgumentException,
InvocationTargetException
{
if (!override) {
if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
Class<?> caller = Reflection.getCallerClass();
checkAccess(caller, clazz, obj, modifiers);
}
}
MethodAccessor ma = methodAccessor; // read volatile
if (ma == null) {
ma = acquireMethodAccessor();
}
return ma.invoke(obj, args);
}
3.3 第三点
关于调用方法需要遍历这点,还是上面那个demo,我们在获取Method 对象的时候是通过调用getMethod、getDeclaredMethod方法,点击进入这个方法的源码,我们可以看到如下代码:
private static Method searchMethods(Method[] methods,
String name,
Class<?>[] parameterTypes)
{
Method res = null;
String internedName = name.intern();
for (int i = 0; i < methods.length; i++) {
Method m = methods[i];
if (m.getName() == internedName
&& arrayContentsEq(parameterTypes, m.getParameterTypes())
&& (res == null
|| res.getReturnType().isAssignableFrom(m.getReturnType())))
res = m;
}
return (res == null ? res : getReflectionFactory().copyMethod(res));
}
我们可以看到,底层实际上也是将class对象的所有method遍历了一遍,最终才拿到我们需要的方法的,这也就是第二点,执行具体方法的时候需要遍历class对象的方法。
3.4 第四点
第4点说需要对方法和参数进行检查,也就是我们在执行具体的某一个方法的时候,我们实际上是需要校验这个方法是否可见的,如果不可见,我们还需要将这个方法设置为可见,否则如果我们直接调用这个方法的话,会报错。
同时还有一个点,在我们调用invoke方法的时候,反射类会对方法和参数进行一个校验,让我们来看一下源码:
@CallerSensitive
public Object invoke(Object obj, Object... args)
throws IllegalAccessException, IllegalArgumentException,
InvocationTargetException
{
if (!override) {
if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
Class<?> caller = Reflection.getCallerClass();
checkAccess(caller, clazz, obj, modifiers);
}
}
MethodAccessor ma = methodAccessor; // read volatile
if (ma == null) {
ma = acquireMethodAccessor();
}
return ma.invoke(obj, args);
}
我们可以看到还有quickCheckMemberAccess、checkAccess 等逻辑
4. 总结
平时在反射这块用的比较少,也没针对性的去学习一下。在工作之余,还是得保持一个学习的习惯,这样子才不会出现今天这种被一个问题难倒的情况,而且才能产出更多、更优秀的方案。
来源:juejin.cn/post/7330115846140051496
从 Java 8 到 Java 17:你真的会用 Stream API 吗
自从 Java 8 引入 Stream API,Java 开发者可以更方便地对集合进行操作,比如过滤、映射、排序等。
Stream API 提供了一种声明式编程风格,让代码更简洁、可读性更高。不过,虽然 Stream API 看起来很优雅,实际使用中可能会遇到一些性能问题和常见陷阱。
今天,我们就聊聊在 Java 8 到 Java 17 之间,Stream API 的性能优化技巧,以及我们可能踩到的那些坑。
1. Stream API 的优势
Stream 是一个抽象化的数据管道,允许我们以声明式的方式处理数据集合。Stream 的两个主要功能是:中间操作 和 终端操作。
- 中间操作:如
filter()
,map()
,这些操作是惰性的(lazy),不会立即执行。 - 终端操作:如
collect()
,forEach()
,这些操作会触发 Stream 的实际执行。
Java 8 的 Stream 使代码看起来更清晰,但它在使用时也带来了一些需要注意的地方,尤其是在处理大数据集时的性能。
2. Stream API 常见的性能陷阱
2.1 多次创建 Stream 导致浪费
在开发中,如果对同一个集合多次创建 Stream,可能会导致重复计算。例如:
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");
// 多次创建 Stream
long countA = names.stream().filter(name -> name.startsWith("A")).count();
long countB = names.stream().filter(name -> name.startsWith("B")).count();
在上面的代码中,names.stream()
被调用了两次,导致每次都从头开始扫描集合。可以优化为一次操作:
Map<String, Long> result = names.stream()
.collect(Collectors.groupingBy(name -> name.substring(0, 1), Collectors.counting()));

这样做的好处是只遍历一次集合,减少不必要的开销。
2.2 避免使用 forEach
进行数据聚合
forEach
是一个常见的终端操作,但它在很多场景下并不是最优解,尤其是在需要聚合数据时:
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> result = new ArrayList<>();
numbers.stream().forEach(result::add); // 这种方式不推荐
这里直接通过 forEach
操作来修改外部集合,会失去 Stream 的声明式风格,甚至可能出现线程安全问题。更好的做法是使用 collect
:
List<Integer> result = numbers.stream().collect(Collectors.toList());
这种方式不仅代码更简洁,还能保证线程安全,特别是在并行流的场景下。
简单说说声明式和命令式
Stream API 提供了一种声明式的编程风格,让你可以专注于“做什么”,而不是“怎么做”。使用
forEach
来修改外部集合是一个命令式的做法,涉及了外部状态的修改,这样就打破了 Stream 的声明式优势。
相比之下在使用
collect
的例子中,代码更简洁且更易读,表达了你的意图是“收集这些元素”,而不是“对每个元素进行操作”。
2.3 滥用并行流
Java 8 引入了并行流(Parallel Stream),它可以通过 stream().parallel()
方法来让 Stream 操作并行化。然而,并行流并不总是能带来性能提升:
// 生成一个 0~999999 的数字列表
List<Integer> numbers = IntStream.range(0, 1000000).boxed().collect(Collectors.toList());
// 直接使用并行流
long start1 = System.currentTimeMillis();
long sum = numbers.parallelStream().mapToInt(Integer::intValue).sum();
long end1 = System.currentTimeMillis();
System.out.println("并行流执行时间:" + (end1 - start1) + "ms");
System.out.println(sum);
// 使用普通流
long start2 = System.currentTimeMillis();
long sum2 = numbers.stream().mapToInt(Integer::intValue).sum();
long end2 = System.currentTimeMillis();
System.out.println("普通流执行时间:" + (end2 - start2) + "ms");
System.out.println(sum2);

> 并行流的适用场景是计算量较大、数据量足够多的情况下。如果数据量较小,或者 Stream 操作较简单,使用并行流反而会带来线程切换的开销,导致性能下降。
2.4 limit()
和 skip()
的误用
limit()
和 skip()
可以限制 Stream 的数据量,但要注意它们的相对位置。如果在 filter()
之后使用 limit()
,可能会带来不必要的性能消耗:
List<Integer> numbers = IntStream.range(0, 1_000_000).boxed().collect(Collectors.toList());
// 过滤偶数,然后取前 10 个
List<Integer> result = numbers.stream()
.filter(n -> n % 2 == 0)
.limit(10)
.collect(Collectors.toList());
这种情况下,filter()
会对 1,000,000 个元素逐个过滤,直到找到前 10 个符合条件的元素。更高效的方式是先 limit()
,再进行其他操作:
List<Integer> result = numbers.stream()
.limit(20) // 先取出前 20 个
.filter(n -> n % 2 == 0) // 再进行过滤
.collect(Collectors.toList());
这样,Stream 只会处理有限的元素,性能会更好。
3. Stream API 性能优化技巧
3.1 使用 toArray()
而不是 collect(Collectors.toList())
如果我们只需要将 Stream 转换为数组,使用 toArray()
是更快的选择:
String[] array = names.stream().toArray(String[]::new);
相比 collect(Collectors.toList())
,toArray()
在实现上更直接,尤其在处理大量数据时可以减少内存分配的开销。
collect(Collectors.toList())
:这个方法首先创建一个ArrayList
,然后将所有元素添加到这个列表中。在这个过程中,ArrayList
可能会经历多次扩容,每次扩容都需要新建一个更大的数组,并将现有元素复制到新数组中。这种重复的内存分配和数组复制操作在处理大量数据时会增加开销。
toArray()
:这个方法直接生成一个数组,避免了ArrayList
的扩容过程。
3.2 避免不必要的装箱与拆箱
在处理基本数据类型时,使用 mapToInt()
、mapToDouble()
这样的基本类型专用方法,可以避免不必要的装箱和拆箱操作,提高性能:
List<Integer> numbers = IntStream.range(0, 10000000).boxed().collect(Collectors.toList());
long start1 = System.currentTimeMillis();
// 使用 map 导致装箱和拆箱
int sumWithMap = numbers.stream()
.map(n -> n) // 装箱
.reduce(0, Integer::sum); // 拆箱
long end1 = System.currentTimeMillis();
System.out.println("sumWithMap: " + sumWithMap + " time: " + (end1 - start1));
long start2 = System.currentTimeMillis();
// 使用 mapToInt 避免装箱和拆箱
int sumWithMapToInt = numbers.stream()
.mapToInt(n -> n) // 直接处理基本类型
.sum();
long end2 = System.currentTimeMillis();
System.out.println("sumWithMapToInt: " + sumWithMapToInt + " time: " + (end2 - start2));

如果直接使用 `map()` 会导致频繁的装箱和拆箱,降低性能。
3.3 尽量使用 forEachOrdered()
在并行流中,forEach()
的执行顺序是非确定性的,如果我们希望按原来的顺序处理数据,使用 forEachOrdered()
可以保证顺序,但会稍微影响性能。
numbers.parallelStream().forEachOrdered(System.out::println);
3.4 减少链式调用中的中间操作
每个中间操作都会产生一个新的 Stream 实例,如果链式调用过多,会增加调用栈的深度,影响性能。尽量合并中间操作来减少链条长度:
// 原始链式调用
List<String> result = names.stream()
.filter(name -> name.length() > 3)
.map(String::toUpperCase)
.filter(name -> name.startsWith("A"))
.collect(Collectors.toList());
// 优化后的调用
List<String> resultOptimized = names.stream()
.filter(name -> name.length() > 3 && name.startsWith("A"))
.map(String::toUpperCase)
.collect(Collectors.toList());
通过合并 filter
的条件,可以减少 Stream 的中间操作,提升性能。
4. 从 Java 8 到 Java 17 的改进
Java 9 到 Java 17 中,Stream API 进行了多次优化和功能增强:
- Java 9 引入了
takeWhile()
和dropWhile()
方法,这些方法允许我们基于条件对 Stream 进行分割,性能上比过滤操作更高效。
List<Integer> limitedNumbers = numbers.stream()
.takeWhile(n -> n < 100)
.collect(Collectors.toList());
- Java 10 开始,
Collectors.toUnmodifiableList()
提供了一种方法来创建不可修改的集合,适用于需要更严格集合控制的场景。 - Java 16 增加了对
Stream.toList()
的支持,方便直接将流转换为不可变的List
:
List<String> immutableList = names.stream().filter(n -> n.length() > 3).toList();
- Java 17 进一步优化了 Stream 的性能,特别是在并行流的实现上,使其在多核环境下能够更高效地利用硬件资源。
5. 总结
Stream API 在 Java 8 引入后,可以说是极大地提高了代码的可读性和简洁性,但也带来了性能优化和陷阱需要注意。从 Java 8 到 Java 17 的不断优化中,我们可以看到 Stream API 逐渐变得更强大和高效。
要想充分利用 Stream API,开发者需要意识到 Stream 的惰性求值特点,避免重复计算和不必要的装箱、拆箱操作。同时,并行流的使用应在充分评估场景后进行,避免反而拖累性能。
希望这篇文章能帮助你更好地掌握 Java Stream API 的优化技巧,在开发中写出更高效、更优雅的代码!
若有勘误,烦请不吝赐教。
来源:juejin.cn/post/7419984211144736808
SpringBoot 中实现订单30分钟自动取消
在涉及到支付的业务时,通常需要实现一个功能:如果用户在生成订单的一定时间内未完成支付,系统将自动取消订单。本文将基于Spring Boot框架实现订单30分钟内未支付自动取消的几种方案,并提供实例代码。
方案一:定时任务
利用@Scheduled注解,我们可以轻松实现定时任务,周期性扫描订单记录,检查未支付的订单,如果有满足三十分钟则进行关闭。
@Component
public class OrderSchedule {
@Autowired
private OrderService orderService;
@Scheduled(cron = "0 0/1 * * * ?")
public void cancelUnpaidOrders() {
LocalDateTime now = LocalDateTime.now();
List<Integer> idList = new ArrayList<Integer>();
List<OrderEntity> orderList = orderService.getOrderList();
orderList.forEach(order -> {
if (order.getWhenCreated().plusMinutes(30).isBefore(now)) {
idList.add(order.getId());
}
});
orderService.cancelOrderList(idList);
}
}
方案二:延迟队列
使用消息队列的延迟队列,当订单生成时将订单ID推送到延迟队列,设置30分钟后过期,过期后消费该消息,判断订单状态,如果未支付则取消订单。
@Service
public class OrderService {
@Autowired
private RabbitTemplate rabbitTemplate;
public void createOrder(Order order) {
// 保存数据库
saveOrder(order);
// 将订单ID推送至延迟队列
rabbitTemplate.convertAndSend("orderDelayExchange", "orderDelayKey", order.getId(), message -> {
message.getMessageProperties().setDelay(30 * 60 * 1000); // 设置延迟时间
return message;
});
}
}
@Component
public class OrderDelayConsumer {
@Autowired
private OrderService orderService;
@RabbitHandler
@RabbitListener(queues = "orderDelayQueue")
public void cancelOrder(String orderId) {
// 取消订单
orderService.cancelOrder(orderId);
}
}
方案三:redis过期事件
使用redis的key过期事件,当订单创建时在Redis中存储一个key,设置30分钟过期,key过期时通过redis的过期事件通知功能触发订单取消。
@Service
public class OrderService {
@Autowired
private StringRedisTemplate redisTemplate;
public void createOrder(Order order) {
// 保存订单至数据库
saveOrder(order);
// 在redis中存储一个key,设置30分钟过期
redisTemplate.opsForValue().set("order:" + order.getId(), order.getId(), 30, TimeUnit.MINUTES);
}
// 当key过期时,redis会自动调用该方法(需要配置redis的过期事件通知)
public void onOrderKeyExpired(String orderId) {
cancelOrder(orderId);
}
}
注:redis的key过期通知是一种典型的发布-订阅模式。在redis中,我们可以订阅到某些特定的事件。key过期事件就是其中之一。但想要使用这个功能,需要redis服务器开启相关配置。具体如何配置会在后期的文章里分享给大家。
最后总结:
三种方案都可以实现订单在30分钟内未支付则自动取消的需求。根据实际业务需求、系统负载和其他因素,可以选择最适合自己系统的实现方案。每种方案都有其优缺点,需要根据具体情况权衡。
来源:juejin.cn/post/7340907184640065536
舒服了,学习了,踩到一个 Lombok 的坑!
你好呀,我是歪歪。
踩坑了啊,最近踩了一个 lombok 的坑,有点意思,给你分享一波。
我之前写过一个公共的服务接口,这个接口已经有好几个系统对接并稳定运行了很长一段时间了,长到这个接口都已经交接给别的同事一年多了。
因为是基础服务嘛,相对稳定,所以交出去之后他也一直没有动过这部分代码。
但是有一天有新服务要对接这个接口,同事反馈说遇到一个诡异的问题,这个新服务调用的时候,接口里面报了一个空指针异常。
根据日志来看,那一行代码大概是这样的:
//为了脱敏我用field1、2、3来代替了
if(reqDto.getField1()
&& reqDto.getField2()!=null
&& reqDto.getField3()!=null){
//满足条件则执行对应业务逻辑
}
reqDto 是接口入参对象,有好多字段。具体到 field1、2、3 大概是这样的:
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReqDto {
private Boolean field1 = true;
private String field2;
private String field3;
}
所以看到这一行抛出了空指针异常,我直接就给出了一个结论:首先排除 field1 为 null,因为有默认值。那只可能 reqDto 传进来的就是 null,导致在 get 字段的时候出现了空指针异常。
但是很不幸,这个结论一秒就被推翻了。
因为 reqDto 是请求入参,在方法入口处选了几个关键字段进行打印。
如果 reqDto 是 null 的话,那么日志打印的时候就会先抛出空指针异常了。
然后我又开始怀疑是部署的代码版本和我们看的版本不一致,可能并不是这一行报错。
和测试同学确认之后,也排除了这个方向。
盯着报错的那一行代码又看了几秒,排除所有不可能之后,我又下了一个结论:调用的时候,传递进来的 field1 主动设值为了 null。
也就是说调用方有这样的代码:
ReqDto reqDto = new ReqDto();
reqDto.setField1(null);
我知道,这样的代码看起来很傻,但是确实只剩下这一种可能了。
于是我去看了调用方构建参数的写法,准备吐槽一波为什么要写设置为 null 这样的坑爹代码。
然而,当时我就被打脸了,调用方的代码是这样的:
ReqDto reqDto = ReqDto.builder()
.field2("why")
.field3("max")
.build();
用的是 builder 模式构建的对象,并不是直接 new 出来的对象。
我一眼看着这个代码也没有发现毛病,虽然没有对 Boolean 类型的 field1 进行设值,但是我有默认值啊。
问调用方为什么不设值,对方的回答也是一句话:我看你有默认值,我本来也是想传 true,但是一看你的默认值就是 true,所以就没有给值了。
对啊,这逻辑无懈可击啊,难道......
是 builder 在里面搞事情了?
于是我里面写了一个代码进行了验证:
好你个浓眉大眼的 @Builder,果然是你在搞事情。
问题现象基本上就算是定位到了,用 @Builder 注解的时候,丢失默认值了。
所以拿着 “@Builder 默认值” 这样的关键词一搜:
立马就能找到这样的一个注解:@Builder.Default
对应到我的案例应该是这样的:
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReqDto {
@Builder.Default
private Boolean field1 = true;
private String field2;
private String field3;
}
这样,再次运行 Demo 就会发现有默认值了:
同时我们从两个写法生成的 class 文件中也可以看出一些端倪。
没有@Builder.Default 注解的时候,class 文件中 ReqDtoBuilder 类中关于 field1 字段是这样的:
但是有 @Builder.Default 注解的时候,是这样的:
明显是不同的处理方式。
反正,网上一搜索,加上 @Builder.Default 注解,问题就算是解决了。
但是紧接着我想到了另外一个问题:为什么?
为什么我明明给了默认值,@Builder 不使用,非得给再显示的标记一下呢?
于是我带着这个问题在网上冲了一大圈,不说没有找到权威的回答了,甚至没有找到来自“民间”的回答。
所以我也只能个人猜测一下,我觉得可能是 Lombok 觉得这样的赋默认值的写法是 Java 语言的规范:
private Boolean field1 = true;
规范我 Lombok 肯定遵守,但是我怎么知道你这个字段有没有默认值呢?
我肯定是有手段去检查的,但是我必须要每个字段都盲目的去瞅一眼,这个方案对我不友好啊。
这样,我给使用者定一个规范:你给我打个标,主动告诉我那些字段是有默认值的。对于打了标的字段,我才去解析对应的默认值,否则我就不管了。
如果你直接 new 对象,那是 Java 的规范,我管不了。
但是如果你使用 Builder 模式,你就得遵守我的规范。不然出了问题也别赖我,谁叫你不准守我的规范。
打个标,就是 @Builder.Default。
必须要强调的是,这个观点是歪师傅纯粹的个人想法,不保真。如果你有其他的看法也可以提出来一起交流,学习一波。
吃个瓜
虽然我没有找到关于 @Builder.Default 注解存在的意义的官方说明,但是我在 github 上找到了这个一个链接:
里面的讨论的问题和我们这个注解有点关系,而且我认为这是一个非常明确的 bug,但是官方却当做 feature 给处理了。
简单的一起吃个瓜。
2017 年 3 月 29 日的时候,一个老哥抛出了一个问题。
首先我们看一下提出问题的老哥给的代码:
就上面这个代码,如果我们这样去创建对象:
MyClass myClass = new MyClass();
按照 Java 规范来说,我们附了默认值的,调用 myClass.getEntitlements() 方法返回的肯定是一个空集合嘛。
但是,这个老哥说当 new MyClass 对象的时候,这个字段变成了 null:
他就觉得很奇怪,于是抛出了这个问题。
然后另外有人立马补充了一下。说不仅是 list/set/map,任何其他 non-primitive 类型都会出现这个问题:
啥意思呢,拿我们前面的案例来说就是,你用 1.16.16 这个版本,不加 @Builder.Default 注解,运行结果是符合预期的:
但是加上 @Builder.Default 注解,运行结果会变成这样:
build 倒是正确了,但是 new 对象的时候,你把默认值直接给干没了。
看到这个运行结果的第一个感觉是很奇怪,第二个感觉是这肯定是 lombok 的 BUG。
问题抛出来之后,紧接着就有老哥来讨论了:
这个哥们直接喊话官方:造孽啊,这么大个 BUG 还有没有人管啦?
同时他还抛出了一个观点:老实说,为字段生成默认值的最直观方法就是从字段初始化中获取值,而不是需要额外的 Builder.Default 注解来标记。
这个观点,和我前面的想法倒是不谋而合。但是还是那句话:一切解释权归官方所有,你要用,就得遵守我制定的规范。
那么到底是改了啥导致产生了这么一个奇怪的 BUG 呢?
注意 omega09 这个老哥的发言的后半句:field it will be initialized twice.
initialized twice,初始化两次,哪来的两次?
我们把目光放到这里来:
@NoArgsConstructor,这是个啥东西?
这不就是让 lombok 给我们搞一个无参构造函数吗?
搞无参构造函数的时候,不是得针对有默认值的字段,进行一波默认值的初始化吗?
这个算一次了。
前面我们分析了 @Builder.Default 也要对有默认值的字段初始化一次。
所以是 twice,而且这两次干得都是同一个活。
开发者一看,这不行啊,得优化啊。
于是把 @NoArgsConstructor 的初始化延迟到了 @Builder.Default 里面去,让两次合并为一次了。
这样一看,用 Builder 模式的时候确实没问题了,但是用 new 的时候,默认值就没了。
这是一种经典的顾头不顾尾的解决问题的方式。
作者可能也没想到,大家在使用的时候会把 @Builder 和 @NoArgsConstructor 两个注解放在一起用。
作者可能还觉得委屈呢:这明明就是两种不同的对象构建方式啊,二选一就行了,你要放在一起?哎哟,你干嘛~
接着一个叫做 davidje13 的老哥接过了话茬,顺着 omega09 老哥的话往下说,他除了解释两个注解放在一起使用的场景外,还提到了一个词:least-surprise。
least-surprise,是一个软件设计方面的词汇,翻译过来就是最小惊吓原则。
简单来说就是我们的程序所表现出的行为,应该尽量满足在其领域内具有一致性、显而易见、可预测、遵循惯例。
比如我们认为的惯例是 new 对象的时候,如果有默认值会附上默认值。
结果你这个就搞没了,就不遵循惯例了。
当然,你还是可以拿出那句万金油的话:一切解释权归官方所有,你要用,就得遵守我制定的规范。我的规范就是不让你们混用。
这就是纯纯的耍无赖了,相当于是做了一个违背祖宗的决定。
然而这个问题似乎并没有官方人员参与讨论,直到这个时候,2018 年 3 月 27 日:
rspiller 就是官方人员,他说:我们正在调查此事。
此时,距离这个问题提出的时间已经过去了一年。
我是比较吃惊的,因为我认为这是一个比较严重的 BUG 了,程序员在使用的时候会遇到一些就类似于我认为这个字段一定是有默认值的,但是实际上却变成了 null 这种莫名其妙的问题。
在官方人员介入之后,这个问题再次活跃起来。
一位 xak2000 老哥也发表了自己的看法,并艾特了官方人员:
他的观点我是非常认同的,给你翻译一波。
他说,导致这个问题的原因是为了消除可能出现的重复初始化。但实际上,与修改 POJO 字段的默认初始化这种完全出乎意料的行为相比,重复初始化的问题要小得多。
当然,解决这个问题的最佳方法是以某种方式摆脱双重初始化,同时又不破坏字段初始化器。
但如果这不可能,或者太难,或者时间太长,那么,就让重复初始化发生吧!
然后把“重复初始化”写到 @Builder.Default javadocs 中,大不了再给这几个字加个粗。
如果有人确实写了一些字段初始化比较复杂的程序,这可能会导致一些问题,但比起该初始化却没有初始化带来的问题要少得多。
在当前的这个情况下,当突然抛出一个空指针异常的时候,我真的很蒙蔽啊。
当然了,也有人提出了不一样的看法:
这个哥们的核心思路刚刚相反,就是呼吁大家不要把 @Builder 和 @NoArgsConstructor 混着用。
从“点赞数”你也能看出来,大家都不喜欢这个方案。
而这个 BUG 是在 2018 年 7 月 26 日,1.18.2 版本中才最终解决的:
此时,距离这个问题提出,已经过去了一年又四个月。
值得注意的是,在官方的描述里面,用的是 FEATURE 而不是 BUGFIX。
个中差异,你可以自己去品一品。
但是现在 Lombok 都已经发展到 1.18.32 版本了,1.16.x 版本应该没有人会去使用了。
所以,大家大概率是不会踩到这个坑的。
我觉得这个事情,了解“坑”具体是啥不重要,而是稍微走进一下开源项目维护者的内心世界。
开源不易,有时候真的就挺崩溃的。
编译时注解
既然聊到 Lombok 了,顺便也简单聊聊它的工作原理。
Lombok 的核心工作原理就是编译时注解,这个你知道吧?
不知道其实也很正常,因为我们写业务代码的时候很少自定义编译时注解,顶天了搞个运行时注解就差不多了。
其实我了解的也不算深入,只是大概知道它的工作原理是什么样的,对于源码没有深入研究。
但是我可以给你分享一下两个需要注意的地方和可以去哪里了解这个玩意。
以 Lombok 的日志相关的注解为例。
首先第一个需要注意的地方是这里:
log 相关注解的源码位于这个部分,可以看到很奇怪啊,这些文件是以 SCL.lombok 结尾的,这是什么玩意?
这是 lombok 的小心思,其实这些都是 class 文件,但是为了避免污染用户项目,它做了特殊处理。
所以你打开这类文件的时候选择以 class 文件的形式打开就行了,就可以看到里面的具体内容。
比如你可以看看这个文件:
lombok.core.handlers.LoggingFramework
你会发现你们就像是枚举似的,写了很多日志的实现:
这个里面把每个注解需要生成的 log 都硬编码好了。正是因为这样,Lombok 才知道你用什么日志注解,应该给你生成什么样的 log。
比如 log4j 是这样的:
private static final org.apache.logging.log4j.Logger log = org.apache.logging.log4j.LogManager.getLogger(TargetType.class);
而 SLF4J 是这样的:
private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(TargetType.class);
第二个需要注意的地方是找到入口:
这些 class 文件加载的入口在于这个地方,是基于 Java 的 SPI 机制:
AnnotationProcessorHider 这个类里面有两行静态内部类,我们看其中一个, AnnotationProcessor ,它是继承自 AbstractProcessor 抽象类:
javax.annotation.processing.AbstractProcessor
这个抽象类,就是入口中的入口,核心中的核心。
在这个入口里面,初始化了一个类加载器,叫做 ShadowClassLoader:
它干的事儿就是加载那些被标记为 SCL.lombok 的 class 文件。
然后我是怎么知道 Lombok 是基于编译时注解的呢?
其实这玩意在我看过的两本书里面都有写,有点模糊的印象,写文章的时候我又翻出来读了一遍。
首先是《深入理解 Java 虚拟机(第三版)》的第四部分程序编译与代码优化的第 10 章:前端编译与优化一节。
里面专门有一小节,说插入式注解的:
Lombok 的主要工作地盘,就在 javac 编译的过程中。
在书中的 361 页,提到了编译过程的几个阶段。
从 Java 代码的总体结构来看,编译过程大致可以分为一个准备过程和三个处理过程:
- 1.准备过程:初始化插入式注解处理器。
- 2.解析与填充符号表过程,包括:
- 词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树。
- 填充符号表。产生符号地址和符号信息。
- 3.插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段,本章的实战部分会设计一个插入式注解处理器来影响Javac的编译行为。
- 4.分析与字节码生成过程,包括:
- 标注检查。对语法的静态信息进行检查。
- 数据流及控制流分析。对程序动态运行过程进行检查。
- 解语法糖。将简化代码编写的语法糖还原为原有的形式。(java中的语法糖包括泛型、变长参数、自动装拆箱、遍历循环foreach等,JVM运行时并不支持这些语法,所以在编译阶段需要还原。)
- 字节码生成。将前面各个步骤所生成的信息转换成字节码。
如果说 javac 编译的过程就是 Lombok 的工作地盘,那么其中的“插入式注解处理器的注解处理过程”就是它的工位了。
书中也提到了 Lombok 的工作原理:
第二本书是《深入理解 JVM 字节码》,在它的第 8 章,也详细的描述了插件化注解的处理原理,其中也提到了 Lombok:
最后画了一个示意图,是这样的:
如果你看懂了书中的前面的十几页的描述,那么看这个图就会比较清晰了。
总之,Lombok 的核心原理就是在编译期对于 class 文件的魔改,帮你生成了很多代码。
如果你有兴趣深入了解它的原理的话,可以去看看我前面提到的这两本书,里面都有手把手的实践开发。
我就不写了,一个原因是因为确实门槛较高,写出来生涩难懂,对我们日常业务开发帮助也不大。
另外一个原因那不是因为我懒嘛。
荒腔走板
周末去了一趟都江堰。
问道青城山,拜水都江堰。读大学的时候就知道这句话了,所以从大学算起,都江堰景区去过的次数,没有十次也有七八次了。
之前每次去就是觉得:哇,好大的山;哇,好急的水;哇,这个一点也不像鱼嘴的地方为什么叫鱼嘴;哇,这个鱼嘴看介绍很牛逼,但是我感觉我上我也行的样子。
这次去的时候,我和 Max 同学算是自己做了一次攻略,看了相关的介绍视频,比较系统的了解了一下鱼嘴、飞沙堰、宝瓶口的作用。
如果你也有兴趣的话,推荐看看 B 站“星球研究所”有一期将都江堰的视频,简短且直观,很不错。
看视频的时候才知道原来这里面有这么多门道,并惊叹于古人的智慧和劳动能力。顺应自然规律,因时制宜,建造了都江堰水利工程,并一直沿用了约 2300 年。
当我们真的走进景区,看到鱼嘴、飞沙堰、宝瓶口就在眼前的时候,才真正明白了视频里面说的“四六分水、二八排沙”是怎么回事,“深淘摊,低作堰”又是怎么回事。
水旱从人,不知饥谨,时无荒年,天下谓之天府也。
成都不能没有都江堰。
李冰父子,配享太庙。
来源:juejin.cn/post/7349569626341490740
为什么很多人不推荐你用JWT?
为什么很多人不推荐你用JWT?
如果你经常看一些网上的带你做项目的教程,你就会发现 有很多的项目都用到了JWT。那么他到底安全吗?为什么那么多人不推荐你去使用。这个文章将会从全方面的带你了解JWT 以及他的优缺点。
什么是JWT?
这个是他的官网JSON Web Tokens - jwt.io
这个就是JWT
JWT 全称JSON Web Token
如果你还不熟悉JWT,不要惊慌!它们并不那么复杂!
你可以把JWT想象成一些JSON数据,你可以验证这些数据是来自你认识的人。
当然如何实现我们在这里不讲,有兴趣的可以去自己了解。
下面我们来说一下他的流程:
- 当你登录到一个网站,网站会生成一个JWT并将其发送给你。
- 这个JWT就像是一个包裹,里面装着一些关于你身份的信息,比如你的用户名、角色、权限等。
- 然后,你在每次与该网站进行通信时都会携带这个JWT。
- 每当你访问一个需要验证身份的页面时,你都会把这个JWT带给网站。
- 网站收到JWT后,会验证它的签名以确保它是由网站签发的,并且检查其中的信息来确认你的身份和权限。
- 如果一切都通过了验证,你就可以继续访问受保护的页面了。
为什么说JWT很烂?
首先我们用JWT应该就是去做这些事情:
- 用户注册网站
- 用户登录网站
- 用户点击并执行操作
- 本网站使用用户信息进行创建、更新和删除 信息
这些事情对于数据库的操作经常是这些方面的
- 记录用户正在执行的操作
- 将用户的一些数据添加到数据库中
- 检查用户的权限,看看他们是否可以执行某些操作
之后我们来逐步说出他的一些缺点
大小
这个方面毋庸置疑。
比如我们需要存储一个用户ID 为xiaou
如果存储到cookie里面,我们的总大小只有5个字节。
如果我们将 ID 存储在 一个 JWT 里。他的大小就会增加大概51倍
这无疑就增大了我们的宽带负担。
冗余签名
JWT的主要卖点之一就是其加密签名。因为JWT被加密签名,接收方可以验证JWT是否有效且可信。
但是,在过去20年里几乎每一个网络框架都可以在使用普通的会话cookie时获得加密签名的好处。
事实上,大多数网络框架会自动为你加密签名(甚至加密!)你的cookie。这意味着你可以获得与使用JWT签名相同的好处,而无需使用JWT本身。
实际上,在大多数网络身份验证情况下,JWT数据都是存储在会话cookie中的,这意味着现在有两个级别的签名。一个在cookie本身上,一个在JWT上。
令牌撤销问题
由于令牌在到期之前一直有效,服务器没有简单的方法来撤销它。
以下是一些可能导致这种情况危险的用例。
注销并不能真正使你注销!
想象一下你在推特上发送推文后注销了登录。你可能会认为自己已经从服务器注销了,但事实并非如此。因为JWT是自包含的,将在到期之前一直有效。这可能是5分钟、30分钟或任何作为令牌一部分设置的持续时间。因此,如果有人在此期间获取了该令牌,他们可以继续访问直到它过期。
可能存在陈旧数据
想象一下用户是管理员,被降级为权限较低的普通用户。同样,这不会立即生效,用户将继续保持管理员身份,直到令牌过期。
JWT通常不加密
因此任何能够执行中间人攻击并嗅探JWT的人都拥有你的身份验证凭据。这变得更容易,因为中间人攻击只需要在服务器和客户端之间的连接上完成
安全问题
对于JWT是否安全。我们可以参考这个文章
JWT (JSON Web Token) (in)security - research.securitum.com
同时我们也可以看到是有专门的如何攻击JWT的教程的
高级漏洞篇之JWT攻击专题 - FreeBuf网络安全行业门户
总结
总的来说,JWT适合作为单次授权令牌,用于在两个实体之间传输声明信息。
但是,JWT不适合作为长期持久数据的存储机制,特别是用于管理用户会话。使用JWT作为会话机制可能会引入一系列严重的安全和实现上的问题,相反,对于长期持久数据的存储,更适合使用传统的会话机制,如会话cookie,以及建立在其上的成熟的实现。
但是写了这么多,我还是想说,如果你作为自己开发学习使用,不考虑安全,不考虑性能的情况下,用JWT是完全没有问题的,但是一旦用到生产环境中,我们就需要避免这些可能存在的问题。
来源:juejin.cn/post/7365533351451672612
Java中使用for而不是forEach遍历List的10大理由
首发公众号:【赵侠客】
引言
我相信作为一名java开发者你一定听过或者看过类似《你还在用for循环遍历List吗?》、《JDK8都10岁了,你还在用for循环遍历List吗?》这类鄙视在Java中使用for循环遍历List的水文。这类文章说的其实就是使用Java8中的Stream.foreach()
来遍历元素,在技术圈感觉使用新的技术就高大上,开发者们也都默许接受新技术的很多缺点,而使用老的技术或者传统的方法就会被人鄙视,被人觉得Low,那么使用forEach()
真的很高大上吗?它真的比传统的for
循环好用吗?本文就列出10大推荐使用for
而不是forEach()
的理由。
理由一、for性能更好
在我的固有认知中我是觉得for
的循环性能比Stream.forEach()
要好的,因为在技术界有一条真理:
越简单越原始的代码往往性能也越好
而且搜索一些文章或者大模型都是这么觉得的,可时我并没有找到专业的基准测试证明此结论。那么实际测试情况是不是这样的呢?虽然这个循环的性能差距对我们的系统性能基本上没有影响,不过为了证明for
的循环性能真的比Stream.forEach()
好我使用基准测试用专业的实际数据来说话。我的测试代码非常的简单,就对一个List<Integer> ids
分别使用for
和Stream.forEach()
遍历出所有的元素,以下是测试代码:
@State(Scope.Thread)
public class ForBenchmark {
private List<Integer> ids ;
@Setup
public void setup() {
ids = new ArrayList<>();
//分别对10、100、1000、1万、10万个元素测试
IntStream.range(0, 10).forEach(i -> ids.add(i));
}
@TearDown
public void tearDown() {
ids = new ArrayList<>();
}
@Benchmark
public void testFor() {
for (int i = 0; i <ids.size() ; i++) {
Integer id = ids.get(i);
}
}
@Benchmark
public void testStreamforEach() {
ids.stream().forEach(x->{
Integer id=x;
});
}
@Test
public void testMyBenchmark() throws Exception {
Options options = new OptionsBuilder()
.include(ForBenchmark.class.getSimpleName())
.forks(1)
.threads(1)
.warmupIterations(1)
.measurementIterations(1)
.mode(Mode.Throughput)
.build();
new Runner(options).run();
}
}
我使用ArrayList分对10、100、1000、1万,10万个元素进行测试,以下是使用JMH基准测试的结果,结果中的数字为吞吐量,单位为ops/s,即每秒钟执行方法的次数:
方法 | 十 | 百 | 千 | 万 | 10万 |
---|---|---|---|---|---|
forEach | 45194532 | 17187781 | 2501802 | 200292 | 20309 |
for | 127056654 | 19310361 | 2530502 | 202632 | 19228 |
for对比 | ↑181% | ↑12% | ↑1% | ↓1% | ↓5% |
从使用Benchmark基准测试结果来看使用for遍历List比Stream.forEach性能在元素越小的情况下优势越明显,在10万元素遍历时性能反而没有Stream.forEach好了,不过在实际项目开发中我们很少有超过10万元素的遍历。
所以可以得出结论:
在小List(万元素以内)遍历中for性能要优于Stream.forEach
理由二、for占用内存更小
Stream.forEach()会占用更多的内存,因为它涉及到创建流、临时对象或者对中间操作进行缓存。for 循环则更直接,操作底层集合,通常不会有额外的临时对象。可以看如下求和代码,运行时增加JVM参数-XX:+PrintGCDetails -Xms4G -Xmx4G
输出GC日志:
- 使用for遍历
List<Integer> ids = IntStream.range(1,10000000).boxed().collect(Collectors.toList());
int sum = 0;
for (int i = 0; i < ids.size(); i++) {
sum +=ids.get(i);
}
System.gc();
//GC日志
[GC (System.gc()) [PSYoungGen: 392540K->174586K(1223168K)] 392540K->212100K(4019712K), 0.2083486 secs] [Times: user=0.58 sys=0.09, real=0.21 secs]
从GC日志中可以看出,使用for遍历List在GC回收前年轻代使用了392540K,总内存使用了392540K,回收耗时0.20s
- 使用stream
List<Integer> ids = IntStream.range(1,10000000).boxed().collect(Collectors.toList());
int sum = ids.stream().reduce(0,Integer::sum);
System.gc();
//GC日志
[GC (System.gc()) [PSYoungGen: 539341K->174586K(1223168K)] 539341K->212118K(4019712K), 0.3747694 secs] [Times: user=0.55 sys=0.83, real=0.38 secs]
从GC日志中可以看出,回收前年轻代使用了539341K,总内存使用了539341K,回收耗时0.37s ,从内存占用情况来看使用for会比Stream.forEach()占用内存少37%,而且Stream.foreach() GC耗时比for多了85%。
理由三、for更易控制流程
我们使用for遍历List可以很方便的使用break
、continue
、return
来控制循环,而使用Stream.forEach在循环中是不能使用break
、continue
,特别指出的使用return
是无法中断Stream.forEach循环的,如下代码:
List<Integer> ids = IntStream.range(1,4).boxed().collect(Collectors.toList());
ids.stream().forEach(i->{
System.out.println(""+i);
if(i>1){
return;
}
});
System.out.println("==");
for (int i = 0; i < ids.size(); i++) {
System.out.println(""+ids.get(i));
if(ids.get(i)>1){
return;
}
}
输出:
forEach-1
forEach-2
forEach-3
==
for-1
for-2
从输出结果可以看出在Stream.forEach中使用return后循环还会继续执行的,而在for循环中使用return将中断循环。
理由四、for访问变量更灵活
这点我想是很多人在使用Stream.forEach中比较头疼的一点,因为在Stream.forEach中引用的变量必须是final类型,也就是说不能修改forEach循环体之外的变量,但是我们很多业务场景就是修改循环体外的变量,如以下代码:
Integer sum=0;
for (int i = 0; i < ids.size(); i++) {
sum++;
}
ids.stream().forEach(i -> {
//报错
sum++;
});
像上面的这样的代码在实际中是很常见的,sum++在forEach中是不被允许的,有时为了使用类似的方法我们只能把变量变成一个引用类型:
AtomicReference<Integer> sum= new AtomicReference<>(0);
ids.stream().forEach(i -> {
sum.getAndSet(sum.get() + 1);
});
所以在访问变量方面for会更加灵活。
理由五、for处理异常更方便
这一点也是我使用forEach比较头疼的,在forEach中的Exception必须要捕获处理,如下代码:
public void testException() throws Exception {
List<Integer> ids = IntStream.range(1, 4).boxed().collect(Collectors.toList());
for (int i = 0; i < ids.size(); i++) {
//直接抛出Exception
System.out.println(div(i, i - 1));
}
ids.stream().forEach(x -> {
try {
//必须捕获Exception
System.out.println(div(x, x - 1));
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
private Integer div(Integer a, Integer b) throws Exception {
return a / b;
}
我们在循环中调用了div()方法,该方法抛出了Exception,如果是使用for循环如果不想处理可以直接抛出,但是使用forEach就必须要自己处理异常了,所以for在处理异常方面会更加灵活方便。
理由六、for能对集合添加、删除
在for循环中可以直接修改原始集合(如添加、删除元素),而 Stream 不允许修改基础集合,会抛出 ConcurrentModificationException,如下代码:
List<Integer> ids = IntStream.range(0, 4).boxed().collect(Collectors.toList());
for (int i = 0; i < ids.size(); i++) {
if(i<1){
ids.add(i);
}
}
System.out.println(ids);
List<Integer> ids2 = IntStream.range(0, 4).boxed().collect(Collectors.toList());
ids2.stream().forEach(x -> {
if(x<1){
ids2.add(x);
}
});
System.out.println(ids2);
输出:
[0, 1, 2, 3, 0]
java.util.ConcurrentModificationException
如果你想在循环中添加或者删除元素foreach是无法完成了,所以for处理集合更方便。
理由七、for Debug更友好
Stream.forEach()使用了Lambda表达示,一行代码可以搞定很多功能,但是这也给Debug带来了困难,如下代码:
List<Integer> ids = IntStream.range(0, 4).boxed().collect(Collectors.toList());
for (int i = 0; i < ids.size(); i++) {
System.out.println(ids.get(i));
}
List<Integer> ids2 = IntStream.range(0, 4).boxed().collect(Collectors.toList());
ids2.stream().forEach(System.out::println);
以下是DeBug截图:
我们可以看出使用for循环Debug可以一步一步的跟踪程序执行步骤,但是使用forEach却做不到,所以for可以更方便的调试你的代码,让你更快捷的找到出现问题的代码。
理由八、for代码可读性更好
Lambda表达示属于面向函数式编程,主打的就是一个抽象,相比于面向对象或者面向过程编程代码可读性是非常的差,有时自己不写的代码过段时间后自己都看不懂。就比如我在文章《解密阿里大神写的天书般的Tree工具类,轻松搞定树结构!》一文中使用函数式编程写了一个Tree工具类,我们可以对比一下面向过程和面向函数式编程代码可读性的差距:
- 使用for面向过程编程代码:
public static List<MenuVo> makeTree(List<MenuVo> allDate,Long rootParentId) {
List<MenuVo> roots = new ArrayList<>();
for (MenuVo menu : allDate) {
if (Objects.equals(rootParentId, menu.getPId())) {
roots.add(menu);
}
}
for (MenuVo root : roots) {
makeChildren(root, allDate);
}
return roots;
}
public static MenuVo makeChildren(MenuVo root, List<MenuVo> allDate) {
for (MenuVo menu : allDate) {
if (Objects.equals(root.getId(), menu.getPId())) {
makeChildren(menu, allDate);
root.getSubMenus().add(menu);
}
}
return root;
}
- 使用forEach面向函数式编程代码:
public static <E> List<E> makeTree(List<E> list, Predicate<E> rootCheck, BiFunction<E, E, Boolean> parentCheck, BiConsumer<E, List<E>> setSubChildren) {
return list.stream().filter(rootCheck).peek(x -> setSubChildren.accept(x, makeChildren(x, list, parentCheck, setSubChildren))).collect(Collectors.toList());
}
private static <E> List<E> makeChildren(E parent, List<E> allData, BiFunction<E, E, Boolean> parentCheck, BiConsumer<E, List<E>> children) {
return allData.stream().filter(x -> parentCheck.apply(parent, x)).peek(x -> children.accept(x, makeChildren(x, allData, parentCheck, children))).collect(Collectors.toList());
}
对比以上两段代码,可以看出面向过程的代码思路非常的清晰,基本上可以一眼看懂代码要做什么,反观面向函数式编程的代码,我想大都人一眼都不知道代码在干什么的,所以使用for的代码可读性会更好。
理由九、for更好的管理状态
for循环可以轻松地在每次迭代中维护状态,这在Stream.forEach中可能需要额外的逻辑来实现。这一条可理由三有点像,我们经常需要通过状态能控制循环是否执行,如下代码:
boolean flag = true;
for (int i = 0; i < 10; i++) {
if(flag){
System.out.println(i);
flag=false;
}
}
AtomicBoolean flag1 = new AtomicBoolean(true);
IntStream.range(0, 10).forEach(x->{
if (flag1.get()){
flag1.set(false);
System.out.println(x);
}
});
这个例子说明了在使用Stream.forEach时,为了维护状态,我们需要引入额外的逻辑,如使用AtomicBoolean,而在for循环中,这种状态管理是直接和简单的。
理由十、for可以使用索引直接访问元素
在某些情况下,特别是当需要根据元素的索引(位置)来操作集合中的元素时,for就可以直接使用索引访问了。在Stream.forEach中就不能直接通过索引访问,比如我们需要将ids中的数字翻倍:
List<Integer> ids = IntStream.range(0, 4).boxed().collect(Collectors.toList());
for (int i = 0; i < ids.size(); i++) {
ids.set(i,i*2);
}
List<Integer> ids2 = IntStream.range(0, 4).boxed().collect(Collectors.toList());
ids2=ids2.stream().map(x->x*2).collect(Collectors.toList());
我们使用for循环来遍历这个列表,并在每次迭代中根据索引i来修改列表中的元素。这种操作直接且直观。而使用Stream.foreach()不能直接通过索引下标访问元素的,只能将List转换为流,然后使用map操作将每个元素乘以2,最后,我们使用Collectors.toList()将结果收集回一个新的List。
总结
本文介绍了在实际开发中更推荐使用for循环而不是Stream.foreach()来遍历List的十大理由,并给出了具体的代码和测试结果,当然这并不是说就一定要使用传统的for循环,要根据自己的实际情况来选择合适的方法。通过此案件也想让读者明白在互联网世界中你所看到的东西都是别人想让你看到的,这个世界是没有真相的,别人想让你看到的就是所谓的”真相“,做为吃瓜群众一定不能随波逐流,要有鉴别信息真假的能力和培养独立思考的能力。
来源:juejin.cn/post/7416848881407524902
一文讲清DTO、BO、PO、VO,为什么可以不需要VO?
DTO、BO、PO、VO是什么
在讨论这些是什么的时候,建议先看看我的这篇文章:写好业务代码的经典案例 - 掘金 (juejin.cn)
在上面我的这篇文章中提到的缺乏模型抽象,无边界控制,就是正好对应的DTO BO PO VO这些模型的概念
如何对模型进行抽象,控制边界,可用看看我的这篇文章 :为啥建议用MapperStruct,不建议用BeanUtils.copyProperties拷贝数据? - 掘金 (juejin.cn)
在后端开发中,比如传统的MVC架构和现在流行的DDD架构,经常会使用到下列几种对象的概念
- DTO (Data Transfer Object) 数据传输对象: DTO设计模式用于将数据从服务端传输到客户端,或者在不同的服务之间传递。通常,DTO包含了特定业务场景需要的数据结构,并且不包含任何业务逻辑。它简化了不同服务或模块之间的交互,使得各个层之间的耦合度降低。
- BO (Business Object) 业务对象: BO代表了业务逻辑层中的对象,封装了与某个业务相关的数据以及针对这些数据的操作逻辑。一个BO可能由多个实体属性组成,并处理涉及多个实体的复杂业务逻辑。
- PO (Persistent Object) 持久化对象: PO主要用来表示数据库表的一条记录,它的属性和数据库表的字段相对应。通常在持久层(如Hibernate、JPA等ORM框架)中使用,主要用于操作数据库,如保存、更新和查询数据。
- VO (Value Object) 值对象: VO是视图层的对象,通常用于封装展示给用户的数据,它可以和数据库表对应,也可以根据UI界面需求进行定制。VO的主要目的是在页面展示时只携带必要的数据,从而避免把大量不必要的数据暴露给前端。
举个实际代码的例子,这里暂不给出VO,在最后的总结会讲这个VO
- 这个就是PO
@Data
public class User implements Serializable{
private Long id;
private String username;
private String password;
private String identityCard;
private String gender;
private String location;
private String userImage;
private String phoneNumber;
private String createTime;
private String updateTime;
@TableLogic
private int isDelete;
}
- UserDTO
@Data
public class UserDTO implements Serializable{
private Long id;
private String username;
private String password;
private String identityCard;
private String gender;
private String location;
private String userImage;
private String phoneNumber;
}
- UserLoginBO、UserUpdateBO ...
@Data
public class UserLoginBO implements Serializable{
private String username;
private String password;
}
@Data
public class UserUpdateBO implements Serializable{
private Long id;
private String username;
private String password;
private String identityCard;
private String gender;
private String location;
private String userImage;
private String phoneNumber;
}
从上面这个例子大家能看出来区别不
UserDTO是一个大的入口,它可以接收整个模块的参数
BO则是在进入Service层之前对UserDTO的数据进行过滤,并且对边界进行控制
最后在进入infra层之前转为PO
其实BO也可以像UserDTO那样,直接一个UserBO包含UserLoginBO和UserUpdateBO,单纯的做模型转换,不做值过滤也可以
在后端开发中怎么用的
总结
为什么我们通篇没有讲关于VO的事情呢?
我个人的理解是DTO能解决的事情没有必要再加一个VO,我们可以弄一个全局配置,将DTO里面为null值的字段全都过滤掉
这样就没有说将数据传给前端的时候需要加多一个VO
给出代码示例,这样配置就可以把DTO中为null值过滤掉,不会序列化发给前端
@Configuration
public class GlobalConfig extends WebMvcConfigurationSupport {
@Override
protected void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
super.configureMessageConverters(converters);
converters.add(mappingJackson2HttpMessageConverter());
}
/**
* 自定义mappingJackson2HttpMessageConverter
* 目前实现:空值忽略,空字段可返回
*/
private MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
return new MappingJackson2HttpMessageConverter(objectMapper);
}
}
来源:juejin.cn/post/7334691453833166848
为什么Spring官方不推荐使用 @Autowired ?
大家好,我是苏三,又跟大家见面了。
前言
很多人刚接触 Spring 的时候,对 @Autowired
绝对是爱得深沉。
一个注解,轻松搞定依赖注入,连代码量都省了。
谁不爱呢?
但慢慢地,尤其是跑到稍微复杂点的项目里,@Autowired
就开始给你整点幺蛾子。
于是,官方Spring 4.0开始:不建议无脑用 @Autowired
,而是更推荐构造函数注入。
为什么?
是 @Autowired
不行吗?并不是。
它可以用,但问题是:它不是无敌的,滥用起来容易埋坑。
下面就来聊聊为啥官方建议你慎用 @Autowired
,顺便再带点代码例子,希望对你会有所帮助。
苏三最近开源了一个基于 SpringBoot+Vue+uniapp 的商城项目,欢迎访问和star。
1. 容易导致隐式依赖
很多小伙伴在工作中喜欢直接写:
@Service
public class MyService {
@Autowired
private MyRepository myRepository;
}
看着挺简单,但问题来了:类的依赖关系藏得太深了。
- 你看这段代码,
MyService
和MyRepository
的关系其实是个“隐形依赖”,全靠@Autowired
来注入。 - 如果有个同事刚接手代码,打开一看,完全不知道
myRepository
是啥玩意儿、怎么来的,只有通过 IDE 或运行时才能猜出来。
隐式依赖的结果就是,代码看起来简单,但维护起来费劲。
后期加个新依赖,或者改依赖顺序,分分钟把人搞糊涂。
怎么破?
用 构造函数注入 替代。
@Service
public class MyService {
private final MyRepository myRepository;
// 构造函数注入,依赖一目了然
public MyService(MyRepository myRepository) {
this.myRepository = myRepository;
}
}
这样做的好处是:
- 依赖清晰: 谁依赖谁,直接写在构造函数里,明明白白。
- 更易测试: 构造函数注入可以手动传入 mock 对象,方便写单元测试。
2. 会导致强耦合
再举个例子,很多人喜欢直接用 @Autowired
注入具体实现类,比如:
@Service
public class MyService {
@Autowired
private SpecificRepository specificRepository;
}
表面上没毛病,但这是硬邦邦地把 MyService
和 SpecificRepository
绑死了。
万一有一天,业务改了,需要切换成另一个实现类,比如 AnotherSpecificRepository
,你得改代码、改注解,连带着测试也崩。
怎么破?
用接口和构造函数注入,把依赖解耦。
@Service
public class MyService {
private final Repository repository;
public MyService(Repository repository) {
this.repository = repository;
}
}
然后通过 Spring 的配置文件或者 @Configuration
类配置具体实现:
@Configuration
public class RepositoryConfig {
@Bean
public Repository repository() {
return new SpecificRepository();
}
}
这么搞的好处是:
- 灵活切换: 改实现类时,不用动核心逻辑代码。
- 符合面向接口编程的思想: 降低耦合,提升可扩展性。
3. 容易导致 NullPointerException
有些小伙伴喜欢这么写:
@Service
public class MyService {
@Autowired
private MyRepository myRepository;
public void doSomething() {
myRepository.save(); // 啪!NullPointerException
}
}
问题在哪?如果 Spring 容器还没来得及注入依赖,你的代码就跑了(比如在构造函数或初始化方法中直接调用依赖),结果自然就是 NullPointerException
。
怎么破?
用构造函数注入,彻底干掉 null
的可能性。
@Service
public class MyService {
private final MyRepository myRepository;
public MyService(MyRepository myRepository) {
this.myRepository = myRepository; // 确保依赖在对象初始化时就已注入
}
public void doSomething() {
myRepository.save();
}
}
构造函数注入的另一个优点是:依赖注入是强制的,Spring 容器不给你注入就报错,让问题早暴露。
4.自动装配容易搞出迷惑行为
Spring 的自动装配机制有时候是“黑魔法”,尤其是当你的项目里有多个候选 Bean 时。比如:
@Service
public class MyService {
@Autowired
private Repository repository; // 容器里有两个 Repository 实现类,咋办?
}
如果有两个实现类,比如 SpecificRepository
和 AnotherRepository
,Spring 容器直接报错。解决方法有两种:
- 指定
@Primary
。 - 用
@Qualifier
手动指定。
但这些方式都让代码看起来更复杂了,还可能踩坑。
怎么破?
构造函数注入 + 显式配置。
@Configuration
public class RepositoryConfig {
@Bean
public Repository repository() {
return new SpecificRepository();
}
}
你明确告诉 Spring 该用哪个实现类,别让容器帮你猜,省得以后“配错药”。
最近就业形势比较困难,为了感谢各位小伙伴对苏三一直以来的支持,我特地创建了一些工作内推群, 看看能不能帮助到大家。
你可以在群里发布招聘信息,也可以内推工作,也可以在群里投递简历找工作,也可以在群里交流面试或者工作的话题。
添加苏三的私人微信:su_san_java,备注:掘金+所在城市,即可加入。
5. 写单元测试非常痛苦
最后,聊聊测试的事儿。
@Autowired
依赖 Spring 容器才能工作,但写单元测试时,大家都不想起 Spring 容器(麻烦、慢)。结果就是:
- 字段注入: 没法手动传入 mock 对象。
- 自动装配: 有时候不清楚用的 Bean 是哪个,测试难搞。
怎么破?
构造函数注入天生就是为单元测试设计的。
public class MyServiceTest {
@Test
public void testDoSomething() {
MyRepository mockRepository = mock(MyRepository.class);
MyService myService = new MyService(mockRepository);
// 测试逻辑
}
}
看见没?
直接传入 mock 对象,测试简单、优雅。
总结
简单总结下问题:
- 隐式依赖让代码可读性差。
- 强耦合违背面向接口编程。
- 字段注入容易 NPE。
- 自动装配有坑。
- 单元测试不好写。
那到底咋办?用 构造函数注入,清晰、稳健、测试友好,官方推荐不是没道理的。
但话说回来,@Autowired
也不是不能用,只是你得分场景。
开发中,养成用构造函数注入的习惯,能让你的代码更健壮,少挖坑,多干活!
最后说一句(求关注,别白嫖我)
如果这篇文章对您有所帮助,或者有所启发的话,帮忙关注一下我的同名公众号:苏三说技术,您的支持是我坚持写作最大的动力。
求一键三连:点赞、转发、在看。
关注公众号:【苏三说技术】,在公众号中回复:进大厂,可以免费获取我最近整理的10万字的面试宝典,好多小伙伴靠这个宝典拿到了多家大厂的offer。
来源:juejin.cn/post/7442346963302203407
SpringBoot中使用LocalDateTime踩坑记录
@[toc]
前言
近日心血来潮想做一个开源项目,目标是做一款可以适配多端、功能完备的模板工程,包含后台管理系统和前台系统,开发者基于此项目进行裁剪和扩展来完成自己的功能开发。
本项目基于Java21和SpringBoot3开发,序列化工具使用的是默认的Jackson,使用Spring Data Redis操作Redis缓存。
在定义实体类过程中,日期时间类型的属性我使用了java.time
包下的LocalDate
和LocalDateTime
类,而没有使用java.util
包下的Date
类。
但在使用过程中遇到了一些问题,于是在此记录下来与诸位分享。
一、为什么推荐使用java.time包的LocalDateTime而不是java.util的Date?
LocalDateTime和Date是Java中表示日期和时间的两种不同的类,它们有一些区别和特点。
- 类型:LocalDateTime是Java 8引入的新类型,属于Java 8日期时间API(java.time包)。而Date是旧版Java日期时间API(java.util包)中的类。
- 不可变性:LocalDateTime是不可变的类型,一旦创建后,其值是不可变的,对该类对象的加减等计算操作不会修改原对象,而是会返回一个新的LocalDateTime对象。而Date是可变的类型,可以通过方法修改其值。
- 线程安全性:LocalDateTime是线程安全的,多个线程可以同时访问和操作不同的LocalDateTime实例。而Date是非线程安全的,如果多个线程同时访问和修改同一个Date实例,可能会导致不可预期的结果。
- 时间精度:LocalDateTime提供了纳秒级别的时间精度,可以表示更加精确的时间。而Date只能表示毫秒级别的时间精度。
- 时区处理:LocalDateTime默认不包含时区信息,表示的是本地日期和时间。而Date则包含时区信息,它的实际值会受到系统默认时区的影响。
由于LocalDateTime是Java 8及以上版本的新类型,并提供了更多的功能和灵活性,推荐在新的项目中使用LocalDateTime来处理日期和时间。
对于旧版Java项目,仍然需要使用Date类,但在多线程环境下需要注意其线程安全性。
如果需要在LocalDateTime和Date之间进行转换,可以使用相应的方法进行转换,例如通过LocalDateTime的atZone()方法和Date的toInstant()方法进行转换。
二、使用LocalDateTime和LocalDate时遇到了哪些坑?
2.1 Redis序列化报错
2.1.1 问题现象
在使用RedisTemplate向Redis中插入数据时,遇到了如下报错:
2024-01-11T21:33:25.233+08:00 ERROR 13212 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception
org.springframework.data.redis.serializer.SerializationException: Could not write JSON: Java 8 date/time type `java.time.LocalDateTime` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (through reference chain: java.util.ArrayList[0]->com.fast.alden.data.model.SysApiResource["createdTime"])
at org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer.serialize(Jackson2JsonRedisSerializer.java:157) ~[spring-data-redis-3.2.0.jar:3.2.0]
at org.springframework.data.redis.core.AbstractOperations.rawValue(AbstractOperations.java:128) ~[spring-data-redis-3.2.0.jar:3.2.0]
at org.springframework.data.redis.core.DefaultValueOperations.set(DefaultValueOperations.java:236) ~[spring-data-redis-3.2.0.jar:3.2.0]
2.1.2 问题分析
在使用Redis缓存含有LocalDateTime类型变量的实体类时会产生序列化问题,因为Jackson库在默认情况下不支持Java8的LocalDateTime类型的序列化和反序列化。
错误堆栈中也给出了解决方案,添加 com.fasterxml.jackson.datatype:jackson-datatype-jsr310
依赖,但光添加依赖是不够的,还我们需要自定义序列化和反序列化的行为。
2.1.3 解决方案
- 添加maven依赖
<dependency>
<groupId>com.fasterxml.jackson.datatypegroupId>
<artifactId>jackson-datatype-jsr310artifactId>
<version>2.13.0version>
dependency>
- 修改RedisSerializer Bean配置
在定义RedisSerializer Bean的代码中自定义ObjectMapper对象处理时间属性时的序列化和反序列化行为,LocalDate
、LocalDateTime
、LocalTime
的序列化和反序列化都要自定义,还要禁用将日期序列化为时间戳。
@Configuration
public class RedisConfig {
@Bean
public RedisSerializer
stream().toList()的大坑,你真的了解吗
stream().toList()
下面这两行代码相同吗?
List<Integer> list1 = list.stream().toList();
List<Integer> list2 = list.stream().collect(Collectors.toList());
在Idea里,Idea还会提醒你可以替换,难道真的是相同的api吗?
我们直接打印一下它们的Class
List<Integer> list1 = list.stream().toList();
List<Integer> list2 = list.stream().collect(Collectors.toList());
System.out.println(list1.getClass());
System.out.println(list2.getClass());
class java.util.ImmutableCollections$ListN
class java.util.ArrayList
发现一个是ImmutableCollection,一个是ArrayList
从名字中就可以看出来list1是不可变的,remove一下果然抛出了异常
// all mutating methods throw UnsupportedOperationException
@Override public void add(int index, E element) { throw uoe(); }
@Override public boolean addAll(int index, Collection<? extends E> c) { throw uoe(); }
@Override public E remove(int index) { throw uoe(); }
@Override public void replaceAll(UnaryOperator<E> operator) { throw uoe(); }
@Override public E set(int index, E element) { throw uoe(); }
@Override public void sort(Comparator<? super E> c) { throw uoe(); }
来源:juejin.cn/post/7436938110023958565
面试官问我String能存储多少个字符?
- 首先String的length方法返回是int。所以理论上长度一定不会超过int的最大值。
- 编译器源码如下,限制了字符串长度大于等于65535就会编译不通过
private void checkStringConstant(DiagnosticPosition var1, Object var2) {
if (this.nerrs == 0 && var2 != null && var2 instanceof String && ((String)var2).length() >= 65535) {
this.log.error(var1, "limit.string", new Object[0]);
++this.nerrs;
}
}
Java中的字符常量都是使用UTF8编码的,UTF8编码使用1~4个字节来表示具体的Unicode字符。所以有的字符占用一个字节,而我们平时所用的大部分中文都需要3个字节来存储。
//65534个字母,编译通过
String s1 = "dd..d";
//21845个中文”自“,编译通过
String s2 = "自自...自";
//一个英文字母d加上21845个中文”自“,编译失败
String s3 = "d自自...自";
对于s1,一个字母d的UTF8编码占用一个字节,65534字母占用65534个字节,长度是65534,长度和存储都没超过限制,所以可以编译通过。
对于s2,一个中文占用3个字节,21845个正好占用65535个字节,而且字符串长度是21845,长度和存储也都没超过限制,所以可以编译通过。
对于s3,一个英文字母d加上21845个中文”自“占用65536个字节,超过了存储最大限制,编译失败。
- JVM规范对常量池有所限制。量池中的每一种数据项都有自己的类型。Java中的UTF-8编码的Unicode字符串在常量池中以CONSTANTUtf8类型表示。CONSTANTUtf8的数据结构如下:
CONSTANT_Utf8_info {
u1 tag;
u2 length;
u1 bytes[length];
}
我们重点关注下长度为 length 的那个bytes数组,这个数组就是真正存储常量数据的地方,而 length 就是数组可以存储的最大字节数。length 的类型是u2,u2是无符号的16位整数,因此理论上允许的的最大长度是2^16-1=65535。所以上面byte数组的最大长度可以是65535
- 运行时限制
String 运行时的限制主要体现在 String 的构造函数上。下面是 String 的一个构造函数:
public String(char value[], int offset, int count) {
...
}
上面的count值就是字符串的最大长度。在Java中,int的最大长度是2^31-1。所以在运行时,String 的最大长度是2^31-1。
但是这个也是理论上的长度,实际的长度还要看你JVM的内存。我们来看下,最大的字符串会占用多大的内存。
(2^31-1)*16/8/1024/1024/1024 = 2GB
所以在最坏的情况下,一个最大的字符串要占用 2GB的内存。如果你的虚拟机不能分配这么多内存的话,会直接报错的。
补充 JDK9以后对String的存储进行了优化。底层不再使用char数组存储字符串,而是使用byte数组。对于LATIN1字符的字符串可以节省一倍的内存空间。
来源:juejin.cn/post/7343883765540831283
这年头不会还有谁没碰过minio的吧?这可太...🤡
🏆本文收录于「滚雪球学Spring Boot」专栏,专业攻坚指数级提升持续更新中,up!up!up!!
🥝 前言:文件存储那些“坑”,你踩过几个?
想象一下,你正在开发一个新项目,老板突然拍着桌子跟你说:“咱这个项目得支持海量文件存储,用户随时上传随时下载,成本要低,性能要高,安全也不能落下!”你抓了抓头发,盯着屏幕陷入沉思,传统文件系统?太笨重。云存储?预算超标。就在你一筹莫展时,MinIO横空出世,仿佛一道曙光,照亮了你前行的路。
MinIO,这款开源的对象存储系统,以其高性能、易扩展、S3兼容性等优点,迅速成为开发者圈中的“香饽饽”。如果你用Spring Boot开发项目,想要高效管理文件存储,那么接下来的内容会让你大呼过瘾。

🍇 MinIO是什么?
MinIO,是一款以高性能、轻量级著称的对象存储服务。它完全兼容Amazon S3 API,支持大规模非结构化数据的存储,适合图片、视频、日志、备份等海量数据的管理需求。
简单点说,它就是你的“私人云存储”,但没有昂贵的费用和复杂的运维。不论是几百GB还是上百TB的数据,MinIO都能轻松搞定。
🍒 MinIO的“秘密武器”
- 开源免费:没有隐藏费用,企业也能无压力使用。
- S3 API兼容:现有的S3工具可以无缝衔接。
- 性能炸裂:每秒高达数十GB的吞吐量,轻松应对高并发。
- 易部署,易维护:几行命令搞定,开发小白也能轻松上手。
🍅 为什么选择MinIO?
有人可能会问:“为啥不用传统的文件系统?” 传统文件系统确实在小规模存储中还算凑合,但当你面对动辄几百GB甚至TB级的数据时,传统方案的缺点就暴露无遗了。管理难、性能低、扩展性差……而MinIO正是为了解决这些痛点而生。

🥝 MinIO能给你什么?
- 超高性价比:无需支付昂贵的存储服务费用,MinIO让你拥有“云存储”的体验,却不需要“云存储”的钱包。
- 弹性扩展:无论是初创团队还是大型企业,MinIO都能根据业务规模灵活扩展,绝不让存储成为发展瓶颈。
- 高可用性:MinIO支持分布式部署,即使某个节点故障,数据依然安全无忧。
选择MinIO,就是选择一种面向未来的存储方式。
🥑 MinIO核心概念
● 对象(Object):对象是实际的数据单元,例如:上传的图片。
● 存储桶(Bucket):存储桶是用于组织对象的名称空间,类似于文件夹。每个存储桶可以包含多个对象(文件)。
● 端点(Endpoint):MinIO服务器的网络地址,用于访问存储桶和对象。例如:http://192.168.10.100:9000 , 注意:9000为 MinIO的API默认端口。
● AccessKey 和Secret Key:
- AccessKey:用于标识和验证访问者身份的唯一标识符,相当于用户名。
- Secret Key:与AccessKey关联的密码,用于验证访问者的身份。

🌽 MinIO客户端实操
🥬 创建bucket
这里的bucket存储桶是用于组织对象的名称空间,类似于我们所说的文件夹。
🥜 测试文件上传
然后来测试一下,文件上传。
上传文件,点击"upload",选择上传的文件即可。
🥖 设置匿名用户的访问权限
将匿名用户权限设置为只读。
🧆 创建 Access Key
这里的Access Key用于标识和验证访问者身份的唯一标识符,相当于用户名。
如上操作完后,我们便来进行此期的真正的干货了,直接上手实操。

🌯 Spring Boot集成MinIO的实操指南
🫔 环境准备
首先,确保你的开发环境已经配置好以下工具:
- JDK 1.8
- Spring Boot 2.6+
- MinIO服务(可使用Docker快速部署)
docker run -p 9000:9000 -p 9001:9001 --name minio \
-e "MINIO_ROOT_USER=admin" \
-e "MINIO_ROOT_PASSWORD=password123" \
minio/minio server /data --console-address ":9001"
这段命令会在本地启动MinIO服务,你只需要打开浏览器,输入http://localhost:9001
,用设置的账号密码登录,即可看到管理界面。
或者你也可以参考Linux常规搭建,可看这篇《Linux零基础安装Minio,手把手教学,一文搞定它!(超详细)》,妥妥傻瓜式教学。
🫑 引入依赖
接下来,修改pom.xml
,引入MinIO的Java SDK依赖:
<!--minio oss服务-->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.12</version>
</dependency>
🍌 定义MinIO连接信息
我们需要先将minio的连接信息配置到我们的配置类中,方便修改及动态配置。
故我们需要先去minio的客户端先创建于一个access key,然后将access-key 与 secret-key 填写到 yml 配置文件中。
具体配置如下,你们直接改成你们的即可。
# minio文件存储
minio:
access-key: Ro2ypdSShhmqQYgHWyDP
secret-key: 6XOaQsYXBKflV10KDcjgcwE9lvekcN4KYfE85fBL
url: http://10.66.66.143:9000
bucket-name: hpy-files
属性解读:
如上这段代码配置的是MinIO文件存储的连接信息,具体内容如下:
- access-key:
Ro2ypdSShhmqQYgHWyDP
— 这是MinIO的访问密钥(类似于用户名),用于身份验证。 - secret-key:
6XOaQsYXBKflV10KDcjgcwE9lvekcN4KYfE85fBL
— 这是MinIO的密钥(类似于密码),用于进行身份验证。 - url:
http://10.66.66.143:9000
— 这是MinIO服务器的地址,表示文件存储服务的主机IP地址和端口。 - bucket-name:
hpy-files
— 这是用于存储文件的桶(bucket)名称。在MinIO中,文件是按桶来存储和组织的。

🍐 配置MinIO客户端
我们需要为Spring Boot项目配置一个MinIO客户端。新建MinioConfig.java
:
/**
* @author: bug菌
* @date: 2024-10-21 11:59
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "minio")
public class MinioConfig {
private String accessKey;
private String secretKey;
private String url;
private String bucketName;
@Bean
public MinioClient minioClient() {
return MinioClient.builder()
.region("cn-north-1")
.endpoint(url)
.credentials(accessKey, secretKey)
.build();
}
}
配置完成后,MinIO客户端就已经准备好为我们的Spring Boot项目服务了。

🍌 创建文件工具类
接下来,我们需要创建一个MinioUtil类,该类的目的是为了封装和简化与 MinIO 文件存储服务的交互,提供一系列的操作方法,使得我们能够轻松地进行文件上传、下载、删除、获取文件信息等常见的文件存储操作。具体意义如下:
- 与 MinIO 交互的封装:
类中封装了与 MinIO 存储服务进行交互的代码,包括检查存储桶是否存在、文件上传、下载、删除等常见的操作。这样,业务逻辑代码无需直接操作 MinIO API,提升了代码的复用性和可维护性。 - 自动化存储桶管理:
在@PostConstruct
注解的init()
方法中,会自动检查并创建存储桶(bucket)。确保在程序启动时,指定的存储桶已经存在,避免了在使用过程中因存储桶不存在而导致的错误。 - 支持文件的 URL 生成:
提供了生成文件访问 URL 的功能,包括带过期时间的预签名 URL。这是为了允许用户在一定时间内访问文件,避免文件暴露或在外部用户访问时需要额外认证。 - 文件下载支持:
类中提供了文件下载的功能,包括标准下载(通过 HTTP ServletResponse)和流式下载(获取文件流)。它可以处理文件的大小、编码等问题,保证文件的正确下载。 - 文件操作的错误处理与日志:
通过Logger
对操作进行记录,且所有可能抛出异常的操作都进行了捕获和处理,避免了程序因为 MinIO 服务故障等原因而直接崩溃。确保系统的稳定性和错误反馈。 - 文件夹与文件的存在性检查:
该类提供了检查文件或文件夹是否存在的方法,有助于在上传或删除文件前进行状态验证,避免重复操作。 - 简化 API 调用:
通过抽象出一层高层次的操作接口,开发者不需要直接关注 MinIO 底层的复杂实现,只需调用简洁的方法即可完成文件存储操作。
总结而言,MinioUtil
类通过封装 MinIO 的常见文件操作,提供便捷的接口,降低与 MinIO 交互的复杂性,并通过统一的错误处理和日志记录,增强了系统的健壮性和可维护性。

代码实操:
/**
* 文件工具类
*
* @author: bug菌
* @date: 2024-10-21 12:02
* @desc:
*/
@Service
public class MinioUtil {
private static final Logger log = LoggerFactory.getLogger(MinioUtil.class);
@Autowired
private MinioClient minioClient;
@Autowired
private MinioConfig minioConfig;
@PostConstruct
public void init() {
existBucket(minioConfig.getBucketName());
}
/**
* 判断bucket是否存在,不存在则创建
*/
public boolean existBucket(String bucketName) {
boolean exists;
try {
exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
if (!exists) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
exists = true;
}
} catch (Exception e) {
e.printStackTrace();
exists = false;
}
return exists;
}
/**
* 上传文件
*/
public void upload(MultipartFile file, String fileName) {
// 使用putObject上传一个文件到存储桶中。
InputStream inputStream = null;
try {
inputStream = file.getInputStream();
minioClient.putObject(PutObjectArgs.builder()
.bucket(minioConfig.getBucketName())
.object(fileName)
.stream(inputStream, file.getSize(), -1)
.contentType(file.getContentType())
.build());
inputStream.close();
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 获取文件访问地址
*/
public String getFileUrl(String fileName) {
try {
return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(minioConfig.getBucketName())
.object(fileName)
.build()
);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* 下载一个文件(返回文件流)
*/
public InputStream download(String objectName) throws Exception {
InputStream stream = minioClient.getObject(
GetObjectArgs.builder().bucket(minioConfig.getBucketName()).object(objectName).build());
return stream;
}
/**
* 下载文件
*/
public void download(HttpServletResponse response, String newFileName, String saveFileName) {
InputStream in = null;
try {
// 获取对象信息
StatObjectResponse stat = minioClient.statObject(StatObjectArgs.builder()
.bucket(minioConfig.getBucketName())
.object(saveFileName)
.build());
// 设置请求头Content-Type
response.setContentType(stat.contentType());
// 确保使用 UTF-8 编码
// String encodedFileName = encodeFilename(newFileName);
String encodedFileName = URLEncoder.encode(newFileName, "UTF-8").replaceAll("\\+", "%20");
response.setHeader("Content-Disposition", "attachment; filename=\"" + encodedFileName + "\"");
// 设置禁用缓存
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
response.setHeader("Pragma", "no-cache");
response.setHeader("Expires", "0");
// 设置文件大小
long fileSize = stat.size();
response.setContentLengthLong(fileSize);
// 获取文件输入流
in = minioClient.getObject(GetObjectArgs.builder()
.bucket(minioConfig.getBucketName())
.object(saveFileName)
.build());
// 文件下载
IOUtils.copy(in, response.getOutputStream());
} catch (Exception e) {
e.printStackTrace();
try {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "File download failed: " + e.getMessage());
} catch (IOException ioException) {
ioException.printStackTrace();
}
} finally {
if (in != null) {
try {
in.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
/**
* 删除文件
*/
public void delete(String fileName) {
try {
minioClient.removeObject(RemoveObjectArgs.builder().bucket(minioConfig.getBucketName()).object(fileName).build());
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 判断文件是否存在
*
* @param objectName
*/
public boolean isFileExist(String objectName) {
boolean exist = true;
try {
minioClient.statObject(StatObjectArgs.builder().bucket(minioConfig.getBucketName()).object(objectName).build());
} catch (Exception e) {
log.error("[Minio工具类]>>>> 判断文件是否存在, 异常:", e);
exist = false;
}
return exist;
}
}

📝 文件上传/下载/预览/删除实战
🧁 1.文件上传
🍆 示例代码
/**
* @author: bug菌
* @date: 2024-10-21 12:07
*/
@Api(tags = "Minio文件管理")
@RestController
@RequestMapping("/file")
public class UploadFileController extends BaseController {
@Autowired
private MinioUtil minioUtil;
/**
* 上传文件
*/
@GetMapping(value = "/upload")
@ApiOperation("上传文件")
public R upload(MultipartFile file) {
// 获取到上传文件的完整名称,包括文件后缀
String fileName = file.getOriginalFilename();
// 获取不带后缀的文件名
String baseName = FilenameUtils.getBaseName(fileName);
// 获取文件后缀
String extension = FilenameUtils.getExtension(fileName);
//创建一个独一的文件名(存于服务器名),格式为 name_时间戳.后缀
String saveFileName = baseName + "_" + System.currentTimeMillis() + "." + extension;
minioUtil.upload(file, saveFileName);
return R.ok("上传成功!存放文件名为:" + saveFileName);
}
}
🥔 示例测试
Postman接口测试上传接口如下:
校验文件是否真正上传到minio中,我们可以上客户端查验下。根据登录查看确实是我们测试时所上传的文件。
🍓 示例代码解析
在本次的代码演示中,我将会深入剖析每句代码,详细阐述其背后的设计思想和实现逻辑。通过这样的讲解方式,我希望能够引导同学们逐步构建起对代码的深刻理解。我会先从代码的结构开始,逐步拆解每个模块的功能和作用,并指出关键的代码段,并解释它们是如何协同运行的。通过这样的讲解和实践相结合的方式,我相信每位同学都能够对代码有更深入的理解,并能够早日将其掌握,应用到自己的学习和工作中。
如上提供的这段代码是一个用于文件上传的控制器,使用 Spring Boot 构建,负责处理文件的上传操作。以下是代码的详细解析:

- 类注解:
@Api(tags = "Minio文件管理")
:使用 Swagger API 文档工具生成接口文档,并为该类提供了一个标签“Minio文件管理”,用于描述文件管理相关的接口。@RestController
:该注解表示这是一个控制器类,并且返回的内容会被自动序列化为 JSON 格式。它是@Controller
和@ResponseBody
的组合。@RequestMapping("/file")
:设置该类的基础请求路径为/file
,所有该类中的请求都会以/file
开头。
- 依赖注入:
@Autowired
:自动注入MinioUtil
类的实例,MinioUtil
是一个封装了 MinIO 操作的工具类,用于处理与 MinIO 存储服务的交互。
- 方法注解:
@GetMapping(value = "/upload")
:处理 HTTP GET 请求,路径为/file/upload
。尽管通常文件上传使用 POST 请求,但这里使用 GET 请求可能是简化了请求示例,实际应用中可能使用 POST。@ApiOperation("上传文件")
:Swagger 文档生成的描述,表示该接口用于上传文件。
- 上传文件操作:
MultipartFile file
:表示前端传递的文件。Spring 会自动将请求中的文件映射到该参数。String fileName = file.getOriginalFilename();
:获取上传文件的原始文件名,包括文件扩展名。String baseName = FilenameUtils.getBaseName(fileName);
:使用 Apache Commons IO 库的FilenameUtils
类,获取文件的基本名称(不包含扩展名)。String extension = FilenameUtils.getExtension(fileName);
:获取文件的扩展名。String saveFileName = baseName + "_" + System.currentTimeMillis() + "." + extension;
:生成一个新的唯一文件名。通过文件的基本名称加上当前的时间戳(毫秒级),确保文件名不重复。minioUtil.upload(file, saveFileName);
:调用MinioUtil
类中的upload
方法,将文件上传到 MinIO 存储服务,保存为saveFileName
。
- 返回结果:
return R.ok("上传成功!存放文件名为:" + saveFileName);
:返回上传成功的响应,R.ok()
是一个自定义的响应方法,表示操作成功并返回相应的信息,saveFileName
作为返回信息的一部分,告知客户端上传文件后的存储文件名。
小结:
该控制器类用于处理文件上传请求,接收文件并生成一个唯一的文件名,通过 MinioUtil
工具类将文件上传至 MinIO 存储。它结合了文件名生成、上传及响应返回等功能,实现了简单的文件上传管理。

🍬 2.文件下载
🍆 示例代码
/**
* 根据文件ID下载文件
*/
@GetMapping("/download")
@ApiOperation("根据文件ID下载文件")
public void downloadById(@RequestParam("fileName") String fileName, @RequestParam("saveFileName") String saveFileName, HttpServletResponse response) {
// 下载文件,传递存储文件名和显示文件名
minioUtil.download(response, fileName, saveFileName);
return;
}
🥔 示例测试
Postman接口测试上传接口如下:
🍓 示例代码解析
在本次的代码演示中,我将会深入剖析每句代码,详细阐述其背后的设计思想和实现逻辑。通过这样的讲解方式,我希望能够引导同学们逐步构建起对代码的深刻理解。我会先从代码的结构开始,逐步拆解每个模块的功能和作用,并指出关键的代码段,并解释它们是如何协同运行的。通过这样的讲解和实践相结合的方式,我相信每位同学都能够对代码有更深入的理解,并能够早日将其掌握,应用到自己的学习和工作中。
如上提供的这段代码是用于根据文件ID下载文件的控制器方法。以下是对代码的详细解析:
- 方法注解:
@GetMapping("/download")
:该方法处理 HTTP GET 请求,路径为/download
。该请求用于根据文件ID下载文件。@ApiOperation("根据文件ID下载文件")
:Swagger 文档生成的描述,表明该接口用于根据文件ID下载文件。
- 方法参数:
@RequestParam("fileName") String fileName
:从请求中获取名为fileName
的请求参数,并将其绑定到fileName
变量。这个参数通常表示文件在存储中的实际名称。@RequestParam("fileName") String saveFileName
:这个参数也是从请求中获取名为fileName
的请求参数。由于参数名称重复,可能会导致问题。正确的做法是使用不同的名字,例如fileName
和saveFileName
,用来分别传递存储文件名和显示文件名。HttpServletResponse response
:Spring MVC 自动注入的HttpServletResponse
对象,用于设置响应信息,发送文件内容到客户端。
- 下载文件操作:
minioUtil.download(response, fileName, saveFileName);
:调用MinioUtil
类中的download
方法。该方法接收HttpServletResponse
对象、存储文件名(fileName
)和显示文件名(saveFileName
)作为参数。download
方法将从 MinIO 存储中获取指定的文件并通过 HTTP 响应将其返回给客户端。
- 方法结束:
return;
:该方法没有返回任何内容,因为文件内容通过HttpServletResponse
被直接流式传输到客户端。
小结:
该方法用于处理根据文件ID下载文件的请求。它通过传递文件名参数,调用 MinioUtil
的下载方法,将文件从 MinIO 存储下载并返回给客户端。

🍩 3.文件预览
🍓 示例代码
@GetMapping("/preview")
@ApiOperation("根据文件ID预览文件")
public String previewFileById(@RequestParam("fileName") String fileName) {
return minioUtil.getFileUrl(fileName);
}
🥔 示例测试
Postman接口测试上传接口如下:
通过接口可直接给你返回该文件的预览地址,我们只需要在浏览器输入该地址便可预览。
🍆 示例代码解析
在本次的代码演示中,我将会深入剖析每句代码,详细阐述其背后的设计思想和实现逻辑。通过这样的讲解方式,我希望能够引导同学们逐步构建起对代码的深刻理解。我会先从代码的结构开始,逐步拆解每个模块的功能和作用,并指出关键的代码段,并解释它们是如何协同运行的。通过这样的讲解和实践相结合的方式,我相信每位同学都能够对代码有更深入的理解,并能够早日将其掌握,应用到自己的学习和工作中。
如上提供的这段代码是用于根据文件ID预览文件的控制器方法。以下是详细解析:
- 方法注解:
@GetMapping("/preview")
:该方法处理 HTTP GET 请求,路径为/preview
,用于根据文件ID预览文件。@ApiOperation("根据文件ID预览文件")
:Swagger 文档生成的描述,表明该接口用于根据文件ID预览文件。
- 方法参数:
@RequestParam("fileName") String fileName
:从请求中获取名为fileName
的请求参数,并将其绑定到fileName
变量。这个参数通常表示要预览的文件在存储中的文件名。
- 文件预览操作:
minioUtil.getFileUrl(fileName)
:调用MinioUtil
类中的getFileUrl
方法,该方法使用文件名从 MinIO 存储生成文件的预览 URL。返回的 URL 通常是一个可以直接访问该文件的链接,可以在客户端浏览器中打开进行预览。
- 返回值:
- 方法返回
String
类型的文件预览 URL,这个 URL 可以直接访问文件并在浏览器中预览。
- 方法返回
小结:
该方法用于处理根据文件ID预览文件的请求。它通过文件名生成一个文件的预览 URL,并将该 URL 返回给客户端,客户端可以使用该 URL 访问文件进行预览。

🍭 4.文件删除
🍓 示例代码
/**
* 根据文件ID删除文件
*/
@GetMapping("/delete")
@ApiOperation("根据文件ID删除文件")
public R deleteById(@RequestParam("fileName") String fileName) {
minioUtil.delete(fileName);
return R.ok();
}
🥔 示例测试
Postman接口测试上传接口如下:
接着我们上客户端查验下,该文件是否真被删除了。
根据时间倒序排序,确实该文件被删除了。
🍆 示例代码解析
在本次的代码演示中,我将会深入剖析每句代码,详细阐述其背后的设计思想和实现逻辑。通过这样的讲解方式,我希望能够引导同学们逐步构建起对代码的深刻理解。我会先从代码的结构开始,逐步拆解每个模块的功能和作用,并指出关键的代码段,并解释它们是如何协同运行的。通过这样的讲解和实践相结合的方式,我相信每位同学都能够对代码有更深入的理解,并能够早日将其掌握,应用到自己的学习和工作中。
如上提供的这段代码是用于根据文件ID删除文件的控制器方法。以下是详细解析:
- 方法注解:
@GetMapping("/delete")
:该方法处理 HTTP GET 请求,路径为/delete
,用于根据文件ID删除文件。@ApiOperation("根据文件ID删除文件")
:Swagger 文档生成的描述,表明该接口用于根据文件ID删除文件。
- 方法参数:
@RequestParam("fileName") String fileName
:从请求中获取名为fileName
的请求参数,并将其绑定到fileName
变量。这个参数通常表示要删除的文件在存储中的文件名。
- 删除文件操作:
minioUtil.delete(fileName)
:调用MinioUtil
类中的delete
方法,该方法会根据提供的fileName
删除 MinIO 存储中的对应文件。
- 返回值:
- 方法返回
R.ok()
:表示操作成功,返回一个响应对象,R.ok()
是一种常见的封装返回成功的方式,可能会带有自定义的状态码或消息。
- 方法返回
小结:
该方法处理根据文件ID删除文件的请求。它通过文件名调用 MinioUtil
删除对应的文件,并返回一个成功的响应。

🫐 MinIO与云原生架构的完美契合
MinIO不仅是一个存储工具,它更是云原生架构中不可或缺的一部分。与Kubernetes无缝整合,让微服务架构下的数据管理变得轻松自如。不论是CI/CD流水线还是大数据分析,MinIO都能应对自如。
🍐 总结与思考
通过这篇文章,你应该对Spring Boot与MinIO的结合有了一个全面的了解。这种现代化的文件存储方案不仅让开发更高效,也为未来业务的扩展奠定了坚实基础。既然已经Get到这么棒的技能,何不立即尝试一下,让你的项目也能“飞”起来?

🥕 附录相关报错及方案解决
🫛1、okhttp3包冲突
如果你遇到你的项目集成 minio 8.5.4 遇到 okhttp3包冲突,比如报错如下所示,可见我这篇《SpringBoot项目集成 minio 8.5.4 遇到 okhttp3包冲突,如何解决?》带你解决此问题:
🍏2、启动报错
如果你启动后遇到如下问题,比如报错如下所示,可见我这篇《集成minio启动报错:Caused by:java.lang.IllegalArgumentException:invalid hostname 10.66.66.143:9000...| 亲测有效》带你解决此问题:
ok,本期内容我就暂聊到这里,哇,一口气给大家输出完,我我我我...头发又脱落了一撮。

📣 关于我
我是bug菌,CSDN | 掘金 | InfoQ | 51CTO | 华为云 | 阿里云 | 腾讯云 等社区博客专家,C站博客之星Top30,华为云2023年度十佳博主,掘金多年度人气作者Top40,掘金等各大社区平台签约作者,51CTO年度博主Top12,掘金/InfoQ/51CTO等社区优质创作者;全网粉丝合计 30w+;硬核微信公众号「猿圈奇妙屋」,欢迎你的加入!免费白嫖最新BAT互联网公司面试真题、4000G PDF电子书籍、简历模板等海量资料,你想要的我都有,关键是你不来拿哇。

-End-
来源:juejin.cn/post/7443658338867134518
一个 Bug JDK 居然改了十年?
问题现象
今天偶然看到了一个 JDK 的 Bug,给大家分享一下。
假设现在有如下的代码:
List<String> list = new ArrayList<>();
list.add("1");
Object[] array = list.toArray();
array[0] = 1;
System.out.println(Arrays.toString(array));
上面的代码是可以正常支执行的,如下图所示:
修改代码为如下代码:
List<String> list = Arrays.asList("1");
Object[] array = list.toArray();
array[0] = 1;
System.out.println(Arrays.toString(array));
再次执行代码,结果就会抛出 ArrayStoreException
异常,这个异常表明这里并不能把一个 Integer
类型的对象存放到这个数组里面。如下图所示:
查看 Arrays
的静态内部类 ArrayList
的 toArray()
方法的返回值就是 Object[]
类型的,如下图所示:
这里就会引发一个疑问: 为啥使用 java.lang.util.ArrayList
代码就可以正常运行?但是使用 Arrays
的静态内部类 ArrayList
就会报错了?
原因分析
首先看下 java.lang.util.ArrayList
类的 toArray()
方法的实现逻辑:
从上面可以看出 toArray()
方法是拷贝了一个 ArrayList
内部的数组对象,然后返回的。而 elementData
这个数组在实际初始化的时候,就是 new 了 Object
类型的数组。如下图所示:
那么经过拷贝之后返回的还是一个实际类型为Object
类型的数组。既然这里是一个 Object
类型的数组,那么往里面放一个 Integer
类型的数据是合法的,因为 Object
是 Integer
类型的父类。
然后再看下 Arrays
的静态内部类 ArrayList
的 toArray()
方法的实现逻辑。这里返回的是 a
这个数组的一个克隆。如下图所示:
而这个 a
数组声明的类型是 E[]
,根据泛型擦除后的原则,这里实际上声明的类型也变成了 Object[]
。 如下图所示:
那接下来再看看 a
实际的类型是什么? 由于 Arrays
的静态内部类 ArrayList
的构造函数是包级访问的,因此只能通过 Arrays.asList()
静态方法来构造一个这个对象。如下图所示:
而 Arrays.asList()
方法的签名是变长参数类型,这个是 Java 的一个语法糖,实际对应的是一个数组,泛型擦除后就变成了 Object[]
类型。如下图所示:
而在代码实际调用处,实际上会 new
一个 String
类型的数组,也就是说 「a
的实际类型是一个 String
类型的数组」。 那么 a 调用了 clone()
方法之后返回的类型也是一个 String 类型的数组,克隆嘛,类型一样才叫克隆。如下图所示:
经过上面的分析,答案就呼之欲出了。a
的实际类型是一个 String
类型的数组,那么往这个数组里面放一个 Integer
类型的对象那肯定是要报错的。等效代码如下图所示:
为什么是个Bug ?
查看 Collection
接口的方法签名,方法声明明确是要返回的是一个 Object[]
类型的数组,因为方法明确声明了返回的是一个 Object[]
类型的数组,但是实际上在获取到了这个返回值后把它当作一个 Object[]
类型的数组使用某些情况下是不满足语义的。
同时这里要注意一下,返回的这个数组要是一个 「安全」的数组,安全的意思就是「集合本身不能持有对返回的数组的引用」,即使集合的内部是用数组实现的,也不能直接把这个内部的数组直接返回。这就是为什么上面两个 toArray()
方法的实现要么是把原有的数组复制了一份,要么是克隆了一份,本质上都是新建了一个数组。如下图所示:
在 OpenJDK 的 BugList 官网上很早就有人提出这个问题了,从时间上看至少在 2005 年就已经发现这个 Bug 了,这个 Bug 真正被解决是在 2015 年的时候,整整隔了 10 年时间。花了 10 年时间修这个 Bug,真是十年磨一剑啊!
如何修正的这个 Bug ?
JDK 9 中的实现修改为了新建一个 Object
类型的数组,然后把原有数组中的元素拷贝到这个数组里面,然后返回这个 Object
类型的数组,这样的话就和 java.util.ArrayList
类中的实现方法一样了。
在 java.util.ArrayList
类的入参为 Collection\<? exends E>
类型的构造函数中就涉及到可能调用 Arrays
的静态内部类 ArrayList
的 toArray()
方法,JDK 在实现的时候针对这个 Bug 还做了特殊的处理,不同厂商发行的 JDK 处理方式还有细微的不同。
Oracel JDK 8 版本的实现方式:
Eclipse Temurin Open JDK 8 版本的实现方式:
之所以在 java.util.ArrayList
对这个 Bug 做特殊的处理是因为 Sun 公司在当时选择不修复改这个Bug,因为怕修复了之后已有的代码就不能运行了。如下图所示:
比如在修复前有如下的代码,这个代码在 JDK 8 版本是可以正常运行的,如下图所示:
String[] strings = (String[]) Arrays.asList("foo", "bar").toArray();
for (String string : strings) {
System.out.println(string);
}
但是如果升级到 JDK 9 版本,就会报 ClassCastException
异常了,如下图所示:
因为修复了这个 Bug 之后,编译器并不能告诉你原来的代码存在问题,甚至连新的警告都没有。假设你从 JDK 8 升级到 JDK 9 了,代码也没有改,但是突然功能就用不了,这个时候你想不想骂人,哈哈哈哈。这也许就是 Sun 公司当年不愿意修复这个 Bug 的原因之一了。当然,如果你要问我为什么要升级的话,我会说:你发任你发,我用 Java 8 !
题外话
阿里巴巴的 Java开发手册对 toArray(T[] array)
方法的调用有如下的建议:
这里以 java.util.ArrayList
类的源码作为参考,源码实现如下:
// ArrayList 的 toArray() 方法实现:
public <T> T[] toArray(T[] a) {
if (a.length < size) // 如果传入的数组的长度小于 size
// Make a new array of a's runtime type, but my contents:
return (T[]) Arrays.copyOf(elementData, size, a.getClass());
System.arraycopy(elementData, 0, a, 0, size);
if (a.length > size)
a[size] = null;
return a;
}
// Arrays 的 coypyOf 方法实现:
public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
@SuppressWarnings("unchecked")
T[] copy = ((Object)newType == (Object)Object[].class)
? (T[]) new Object[newLength]
: (T[]) Array.newInstance(newType.getComponentType(), newLength);
System.arraycopy(original, 0, copy, 0,
Math.min(original.length, newLength));
return copy;
}
当调用 toArray()
方法时传入的数组长度为 0 时,方法内部会根据传入的数组类型动态创建一个和当前集合 size 相同的数组,然后把集合的元素复制到这个数组里面,然后返回。
当调用 toArray()
方法时传入的数组长度大于 0,小于 ArrayList
的 size 时,走的逻辑和上面是一样的,也会进入到 Arays
的 copyOf
方法的调用中,但是调用方法传入的新建的数组相当于新建之后没有被使用,白白浪费了,需要等待 GC 回收。
当调用 toArray()
方法时传入的数组长度大于等于 ArrayList
的 size 时,则会直接把集合的元素拷贝到这个数组中。如果是大于的情况,还会把数组中下标为 size
的元素设置为 null,但是 size
下标后面的元素保持不变。如下所示:
List<String> list = new ArrayList<>();
list.add("1");
String[] array = new String[3];
array[1] = "2";
array[2] = "3";
String[] toArray = list.toArray(array);
System.out.println(array == toArray);
System.out.println(Arrays.toString(toArray));
手册中提到的在高并发的情况下,传入的数组长度等于 ArrayList
的 size 时,如果 ArrayList 的 size 在数组创建完成后变大了,还是会走到重新新建数组的逻辑里面,仍然会导致调用方法传入的新建的数组没有被使用,而且这里因为调用方法时新建的数组和 ArrayList
之前的 size 相同,会造成比传入长度为 0 的数组浪费多得多的空间。但是我个人觉得,因为 ArrayList
不是线程安全的,如果存在数据竞争的情况就不应该使用。
参考
Arrays.asList(x).toArray().getClass() should be Object[].class
array cast Java 8 vs Java 9
toArray方法的小陷阱,写开发手册的大佬也未能幸免
.toArray(new MyClass[0]) or .toArray(new MyClass[myList.size()])?
Arrays of Wisdom of the Ancients
Java开发手册(黄山版).pdf
来源:juejin.cn/post/7443746761846374439
Java循环操作哪个快?
开发的时候我发现个问题,就是在学习玩streamAPI和lambda表达式后,我就变得越来越喜欢直接使用streamAPI,而不是使用for循环这种方式了,但是这种方式也有一定的缺点,但是直到某一次代码review,我的同事点醒了我,“小火汁,你的stream流写的是挺好,但是问题是为什么从同一个源取相似的对象,要分别写两次stream,你不觉得有点多余了吗?程序员不只是写代码,反而是最初的设计阶段就要把全局流程想好,要避免再犯这种错误哦~”,这句话点醒了我,所以我打算先看一下stream遍历、for循环、增强for循环、迭代器遍历、并行流parallel stream遍历的时间消耗,查看一下这几种方式的异同。
使用stream主要是在做什么?
此时我们先准备一个类
@Data
@Accessors(chain = true)
@AllArgsConstructor
@NoArgsConstructor
class Item {
private Integer name;
private Integer value;
}
- list转成map
list.stream().collect(Collectors.toMap(Item::getName, Item::getValue, (newValue, oldValue) -> newValue))
- List过滤,返回新List
List- collect = list.stream().filter(x -> x.getValue() > 50).collect(Collectors.toList());
- 模拟多次stream,因为我在开发中经常出现这种问题
Map collect = list.stream().collect(Collectors.toMap(Item::getName, Item::getValue, (newValue, oldValue) -> newValue));
Map collect3 = list.stream().collect(Collectors.toMap(Item::getName, Item::getValue, (newValue, oldValue) -> newValue));
- 取出list<类>中某一个属性的值,转成新的list
List collect = list.stream().map(Item::getValue).collect(Collectors.toList());
- list<类>中进行一组操作,并且转成新的list
List- collect1 = list.stream().parallel().map(x -> {
Integer temp = x.getName();
x.setName(x.getValue());
x.setValue(temp);
return x;
}).collect(Collectors.toList());
实际消耗
选择1、10、100、100_00、100_000的原因
1、10、100主要是业务决定的,实际代码编写中这块的数据量是占大头的,10_000,100_000是因为为了查看实际的大数据量情况下的效果。
结果结论如下:
- 如果只是用filter的API,则建议只使用普通for循环,其他情况下数据量较少时,虽然stream和for循环都是10ms以内,但是性能上会差着3-4倍
- 普通for循环可以使用for (Item item : list),因为这个是for (int i = 0; i < ; i++)的语法糖
- 增强for循环底层是Iterator接口,但是实际的验证时发现特别慢,暂时没发现原因,但是不推荐使用
- stream串行流转成并行流操作后普遍还是不如串行流快,速度如下:执行时间:串行流转并行流>串行流>并行流,所以串行流转并行流不推荐使用
- 串行流转并行流和并行流都会使用ForkJoinsPool.commonPool(),这是个进程共用的CPU型线程池,且数据不方便修改,我记得是需要在启动的时候进行修改
- 串行流转并行流和并行流均会产生线程争抢资源与线程安全问题
- 在单次stream多次中继操作的情况下,执行速度和单次中继操作差不多
总结
- 写一次stream操作耗时较少,但是会导致开发人员无意之间多次使用stream流做类似操作(如从订单类中多次取不一致但是相似的一组对象),从而导致可读性变差,不利于后续拓展
- 尽量使用普通for循环做遍历,迭代器循环做删除或者使用collection的remove、removeIf等API实现(如果只需要删除的话)
- 使用普通for循环比stream流节省时间,因此在提高性能的角度看开发中尽量使用普通for循环。
来源:juejin.cn/post/7427173759713951753
为什么现在连Date类都不建议使用了?
本文已经授权【稀土掘金技术社区】官方公众号独家原创发布。
一、有什么问题吗java.util.Date
?
java.util.Date
(Date
从现在开始)是一个糟糕的类型,这解释了为什么它的大部分内容在 Java 1.1 中被弃用(但不幸的是仍在使用)。
设计缺陷包括:
- 它的名称具有误导性:它并不代表 a
Date
,而是代表时间的一个瞬间。所以它应该被称为Instant
——正如它的java.time
等价物一样。 - 它是非最终的:这鼓励了对继承的不良使用,例如
java.sql.Date
(这意味着代表一个日期,并且由于具有相同的短名称而也令人困惑) - 它是可变的:日期/时间类型是自然值,可以通过不可变类型有效地建模。可变的事实
Date
(例如通过setTime
方法)意味着勤奋的开发人员最终会在各处创建防御性副本。 - 它在许多地方(包括)隐式使用系统本地时区,
toString()
这让许多开发人员感到困惑。有关此内容的更多信息,请参阅“什么是即时”部分 - 它的月份编号是从 0 开始的,是从 C 语言复制的。这导致了很多很多相差一的错误。
- 它的年份编号是基于 1900 年的,也是从 C 语言复制的。当然,当 Java 出现时,我们已经意识到这不利于可读性?
- 它的方法命名不明确:
getDate()
返回月份中的某一天,并getDay()
返回星期几。给这些更具描述性的名字有多难? - 对于是否支持闰秒含糊其辞:“秒由 0 到 61 之间的整数表示;值 60 和 61 仅在闰秒时出现,即使如此,也仅在实际正确跟踪闰秒的 Java 实现中出现。” 我强烈怀疑大多数开发人员(包括我自己)都做了很多假设,认为 for 的范围
getSeconds()
实际上在 0-59 范围内(含)。 - 它的宽容没有明显的理由:“在所有情况下,为这些目的而对方法给出的论据不必落在指定的范围内; 例如,日期可以指定为 1 月 32 日,并被解释为 2 月 1 日。” 多久有用一次?
原文如下:为什么要避免使用Date类?
二、为啥要改?
我们要改的原因很简单,我们的代码缺陷扫描规则认为这是一个必须修改的缺陷,否则不给发布,不改不行,服了。
解决思路:避免使用
java.util.Date
与java.sql.Date
类和其提供的API,考虑使用java.time.Instant
类或java.time.LocalDateTime
类及其提供的API替代。
三、怎么改?
只能说这种基础的类改起来牵一发动全身,需要从DO实体类看起,然后就是各种Converter,最后是DTO。由于我们还是微服务架构,业务服务依赖于基础服务的API,所以必须要一起改否则就会报错。这里就不细说修改流程了,主要说一下我们在改造的时候遇到的一些问题。
1. 耐心比对数据库日期字段和DO的映射
(1)确定字段类型
首先你需要确定数据对象中的 Date
字段代表的是日期、时间还是时间戳。
- 如果字段代表日期和时间,则可能需要使用
LocalDateTime
。 - 如果字段仅代表日期,则可能需要使用
LocalDate
。 - 如果字段仅代表时间,则可能需要使用
LocalTime
。 - 如果字段需要保存时间戳(带时区的),则可能需要使用
Instant
或ZonedDateTime
。
(2)更新数据对象类
更新数据对象类中的字段,把 Date
类型改为适当的 java.time
类型。
2. 将DateUtil中的方法改造
(1)替换原来的new Date()和Calendar.getInstance().getTime()
原来的方式:
Date nowDate = new Date();
Date nowCalendarDate = Calendar.getInstance().getTime();
使用 java.time
改造后:
// 使用Instant代表一个时间点,这与Date类似
Instant nowInstant = Instant.now();
// 如果需要用到具体的日期和时间(例如年、月、日、时、分、秒)
LocalDateTime nowLocalDateTime = LocalDateTime.now();
// 如果你需要和特定的时区交互,可以使用ZonedDateTime
ZonedDateTime nowZonedDateTime = ZonedDateTime.now();
// 如果你需要转换回java.util.Date,你可以这样做(假设你的代码其他部分还需要使用Date)
Date nowFromDateInstant = Date.from(nowInstant);
// 如果需要与java.sql.Timestamp交互
java.sql.Timestamp nowFromInstant = java.sql.Timestamp.from(nowInstant);
一些注意点:
Instant
表示的是一个时间点,它是时区无关的,相当于旧的Date
类。它通常用于表示时间戳。LocalDateTime
表示没有时区信息的日期和时间,它不能直接转换为时间戳,除非你将其与时区结合使用(例如通过ZonedDateTime
)。ZonedDateTime
包含时区信息的日期和时间,它更类似于Calendar
,因为Calendar
也包含时区信息。- 当你需要将
java.time
对象转换回java.util.Date
对象时,可以使用Date.from(Instant)
方法。这在你的代码需要与旧的API或库交互时非常有用。
(2)一些基础的方法改造
a. dateFormat
原来的方式
public static String dateFormat(Date date, String dateFormat) {
SimpleDateFormat formatter = new SimpleDateFormat(dateFormat);
return formatter.format(date);
}
使用java.time
改造后
public static String dateFormat(LocalDateTime date, String dateFormat) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(dateFormat);
return date.format(formatter);
}
b. addSecond、addMinute、addHour、addDay、addMonth、addYear
原来的方式
public static Date addSecond(Date date, int second) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.add(13, second);
return calendar.getTime();
}
public static Date addMinute(Date date, int minute) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.add(12, minute);
return calendar.getTime();
}
public static Date addHour(Date date, int hour) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.add(10, hour);
return calendar.getTime();
}
public static Date addDay(Date date, int day) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.add(5, day);
return calendar.getTime();
}
public static Date addMonth(Date date, int month) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.add(2, month);
return calendar.getTime();
}
public static Date addYear(Date date, int year) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
calendar.add(1, year);
return calendar.getTime();
}
使用java.time
改造后
public static LocalDateTime addSecond(LocalDateTime date, int second) {
return date.plusSeconds(second);
}
public static LocalDateTime addMinute(LocalDateTime date, int minute) {
return date.plusMinutes(minute);
}
public static LocalDateTime addHour(LocalDateTime date, int hour) {
return date.plusHours(hour);
}
public static LocalDateTime addDay(LocalDateTime date, int day) {
return date.plusDays(day);
}
public static LocalDateTime addMonth(LocalDateTime date, int month) {
return date.plusMonths(month);
}
public static LocalDateTime addYear(LocalDateTime date, int year) {
return date.plusYears(year);
}
c. dateToWeek
原来的方式
public static final String[] WEEK_DAY_OF_CHINESE = new String[]{"周日", "周一", "周二", "周三", "周四", "周五", "周六"};
public static String dateToWeek(Date date) {
Calendar cal = Calendar.getInstance();
cal.setTime(date);
return WEEK_DAY_OF_CHINESE[cal.get(7) - 1];
}
使用java.time
改造后
public static final String[] WEEK_DAY_OF_CHINESE = new String[]{"周日", "周一", "周二", "周三", "周四", "周五", "周六"};
public static String dateToWeek(LocalDate date) {
DayOfWeek dayOfWeek = date.getDayOfWeek();
return WEEK_DAY_OF_CHINESE[dayOfWeek.getValue() % 7];
}
d. getStartOfDay和getEndOfDay
原来的方式
public static Date getStartTimeOfDay(Date date) {
if (date == null) {
return null;
} else {
LocalDateTime localDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(date.getTime()), ZoneId.systemDefault());
LocalDateTime startOfDay = localDateTime.with(LocalTime.MIN);
return Date.from(startOfDay.atZone(ZoneId.systemDefault()).toInstant());
}
}
public static Date getEndTimeOfDay(Date date) {
if (date == null) {
return null;
} else {
LocalDateTime localDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(date.getTime()), ZoneId.systemDefault());
LocalDateTime endOfDay = localDateTime.with(LocalTime.MAX);
return Date.from(endOfDay.atZone(ZoneId.systemDefault()).toInstant());
}
}
使用java.time
改造后
public static LocalDateTime getStartTimeOfDay(LocalDateTime date) {
if (date == null) {
return null;
} else {
// 获取一天的开始时间,即00:00
return date.toLocalDate().atStartOfDay();
}
}
public static LocalDateTime getEndTimeOfDay(LocalDateTime date) {
if (date == null) {
return null;
} else {
// 获取一天的结束时间,即23:59:59.999999999
return date.toLocalDate().atTime(LocalTime.MAX);
}
}
e. betweenStartAndEnd
原来的方式
public static Boolean betweenStartAndEnd(Date nowTime, Date beginTime, Date endTime) {
Calendar date = Calendar.getInstance();
date.setTime(nowTime);
Calendar begin = Calendar.getInstance();
begin.setTime(beginTime);
Calendar end = Calendar.getInstance();
end.setTime(endTime);
return date.after(begin) && date.before(end);
}
使用java.time
改造后
public static Boolean betweenStartAndEnd(Instant nowTime, Instant beginTime, Instant endTime) {
return nowTime.isAfter(beginTime) && nowTime.isBefore(endTime);
}
我这里就只列了一些,如果有缺失的可以自己补充,不会写的话直接问问ChatGPT,它最会干这事了。最后把这些修改后的方法替换一下就行了。
四、小结一下
这个改造难度不高,但是复杂度非常高,一个地方没改好,轻则接口报错,重则启动失败,非常耗费精力,真不想改。
文末小彩蛋,自建摸鱼网站,各大网站热搜一览,上班和摸鱼很配哦!
来源:juejin.cn/post/7343161506699313162
延迟双删如此好用,为何大厂从来不用
摘要: 在绝大多数介绍缓存与数据库一致性方案的文章中,随着 Cache-aside 模式的数据变更几乎无例外的推荐使用删除缓存的策略,为进一步降低数据不一致的风险通常会配合延迟双删的策略。但是令人意外的是,在一些互联网大厂中的核心业务却很少使用这种方式。这背后的原因是什么呢?延迟双删策略有什么致命缺陷么?以及这些大厂如何选择缓存与数据库一致性保障的策略呢?如果你对此同样抱有有疑问的话,希望本文能为你答疑解惑。
当数据库(主副本)数据记录变更时,为了降低缓存数据不一致状态的持续时间,通常会选择主动 失效 / 更新 缓存数据的方式。绝大多数应用系统的设计方案中会选择通过删除缓存数据的方式使其失效。但同样会出现数据不一致的情况,具体情况参见下图:
所以延迟双删又成为了组合出现的常见模式。延迟双删最复杂的技术实现在于对延迟时间的确定上,间隔时间久的话数据不一致的状态持续时间会变长,如果间隔时间过短可能无法起到一致性保障的作用。所以基于经验会将这个时间设定在秒级,如 1-2 秒后执行第二次删除操作。
延迟双删的致命缺陷
但是延迟时间最大的问题不在于此,而是两次删除缓存数据引起的缓存穿透,短时间对数据库(主副本)造成的流量与负载压力。绝大多数应用系统本身流量与负载并不高,使用缓存通常是为了提升系统性能表现,数据库(主副本)完全可以承载一段时间内的负载压力。对于此类系统延迟双删是一个完全可以接受的高性价比策略。
现实世界中的系统响应慢所带来的却是流量的加倍上涨。回想一下当你面对 App 响应慢的情况,是如何反应与对待便能明白,几乎所有用户的下意识行为都是如出一辙。
所以对于那些流量巨大的应用系统而言,短时的访问流量穿透缓存访问数据库(主副本),恐怕很难接受。为了应对这种流量穿透的情况,通常需要增加数据库(主副本)的部署规格或节点。而且这类应用系统的响应变慢的时候,会对其支持系统产生影响,如果其支持系统较多的情况下,会存在影响的增溢。相比延迟双删在技术实现上带来高效便捷而言,其对系统的影响与副作用则变得不可忽视。
Facebook(今 Meta)解决方案
早在 2013 年由 Facebook(今 Meta)发表的论文 “Scaling Memcache at Facebook” 中便提供了其内部的解决方案,通过提供一种类似 “锁” 的 “leases”(本文译为“租约”)机制防止并发带来的数据不一致现象。
租约机制实现方法大致如下:
当有多个请求抵达缓存时,缓存中并不存在该值时会返回给客户端一个 64 位的 token ,这个 token 会记录该请求,同时该 token 会和缓存键作为绑定,该 token 即为上文中租约的值,客户端在更新时需要传递这个 token ,缓存验证通过后会进行数据的存储。其他请求需要等待这个租约过期后才可申请新的租约。
可结合下图辅助理解其作用机制。也可阅读缓存与主副本数据一致性系统设计方案(下篇)一文中的如何解决并发数据不一致,又能避免延迟双删带来的惊群问题章节进一步了解。
简易参考实现
接下来我们以 Redis 为例,提供一个 Java 版本的简易参考实现。本文中会给出实现所涉及的关键要素与核心代码,你可以访问 Github 项目 来了解整个样例工程,并通过查阅 Issue 与 commits 来了解整个样例工程的演化进程。
要想实现上述租约机制,需要关注的核心要素有三个:
- 需要复写 Redis 数据获取操作,当 Redis 中数据不存在时增加对租约的设置;
- 需要复写 Redis 数据设置操作,当设置 Redis 中数据时校验租约的有效性;
- 最后是当数据库(主副本)数据变更时,删除 Redis 数据同时要连带删除租约信息。
同时为了保障 Redis 操作的原子性,我们需要借助 Lua 脚本来实现上述三点。这里以字符串类型为例,对应脚本分别如下:
Redis 数据获取操作
返回值的第二个属性作为判断是否需要执行数据获取的判断依据。当为 false 时表示 Redis 中无对应数据,需要从数据库中加载,同时保存了当前请求与 key 对应的租约信息。
local key = KEYS[1]
local token = ARGV[1]
local value = redis.call('get', key)
if not value then
redis.replicate_commands()
local lease_key = 'lease:'..key
redis.call('set', lease_key, token)
return {false, false}
else
return {value, true}
end
Redis 数据设置操作
返回值的第二个属性作为判断是否成功执行数据设置操作的依据。该属性为 false 表示租约校验失败,未成功执行数据设置操作。同时意味着有其他进程/线程 执行数据查询操作并对该 key 设置了新的租约。
local key = KEYS[1]
local token = ARGV[1]
local value = ARGV[2]
local lease_key = 'lease:'..key
local lease_value = redis.call('get', lease_key)
if lease_value == token then
redis.replicate_commands()
redis.call('set', key, value)
return {value, true}
else
return {false, false}
end
Redis 数据删除操作
当数据库变更进程/线程 完成数据变更操作后,尝试删除缓存需要同时清理对应数据记录的 key 以及其关联租约 key。防止数据变更前的查询操作通过租约校验,将旧数据写入 Redis 。
local key = KEYS[1]
local token = ARGV[1]
local lease_key = 'lease:'..key
redis.call('del', key, leask_key)
该方案主要的影响在应用层实现,主要在集中在三个方面:
- 应用层不能调用 Redis 数据类型的原始操作命令,而是改为调用 EVAL 命令;
- 调用 Redis 返回结果数据结构的变更为数组,需要解析数组;
- 应用层对于 Redis 的操作变复杂,需要生成租约用的 token,并根据每个阶段返回结果进行后续处理;
为应对上述三点变化,对应操作 Redis 的 Java 实现如下:
封装返回结果
为便于后续操作,首先是对脚本返回结果的封装。
public class EvalResult {
String value;
boolean effect;
public EvalResult(List<?> args) {
value = (String) args.get(0);
if (args.get(1) == null) {
effect = false;
} else {
effect = 1 == (long) args.get(1);
}
}
}
组件设计
封装 Redis 操作
因为在样例工程中独立出了一个 Query Engine 组件,所以需要跨组件传递 token,这里为了实现简单采用了 ThreadLocal 进行 token 的传递,具体系统可查阅样例工程中的用例。
public class LeaseWrapper extends Jedis implements CacheCommands {
private final Jedis jedis;
private final TokenGenerator tokenGenerator;
private final ThreadLocal<String> tokenHolder;
public LeaseWrapper(Jedis jedis) {
this.jedis = jedis;
this.tokenHolder = new ThreadLocal<>();
this.tokenGenerator = () -> UUID.randomUUID().toString();
}
@Override
public String get(String key) {
String token = this.tokenGenerator.get();
tokenHolder.set(token);
Object result = this.jedis.eval(LuaScripts.leaseGet(), List.of(key), List.of(token));
EvalResult er = new EvalResult((List<?>) result);
if (er.effect()) {
return er.value();
}
return null;
}
@Override
public String set(String key, String value) {
String token = tokenHolder.get();
tokenHolder.remove();
Object result = this.jedis.eval(LuaScripts.leaseSet(), List.of(key), List.of(token, value));
EvalResult er = new EvalResult((List<?>) result);
if (er.effect()) {
return er.value();
}
return null;
}
}
补充
在上面的简易参考实现中,我们并没有实现其他请求需要等待这个租约过期后才可申请新的租约。该功能主要是防止惊群问题,进一步降低可能对数据库造成的访问压力。要实现该功能需要在 Redis 数据获取操作中改进脚本:
local key = KEYS[1]
local token = ARGV[1]
local value = redis.call('get', key)
if not value then
redis.replicate_commands()
local lease_key = 'lease:'..key
local current_token = redis.call('get', lease_key)
if not current_token or token == current_token then
redis.call('set', lease_key, token)
return {token, false}
else
return {current_token, false}
end
else
return {value, true}
end
同时也可以为租约数据设定一个短时 TTL,并在应用层通过对 EvalResult 的 effect 判断为 false 的情况下等待一段时间后再次执行。
上述实现的复杂点在于租约过期的时间的选取,以及超过设定时间的逻辑处理。我们可以实现类似自旋锁的机制,在最大等待时间内随时等待一个间隙向 Redis 发起查询请求,超过最大等待时间后直接查询数据库(主副本)获取数据。
Uber 解决方案
在 Uber 今年 2 月份发表的一篇技术博客 “How Uber Serves Over 40 Million Reads Per Second from Online Storage Using an Integrated Cache” 中透露了其内部的解决方案,通过比对版本号的方式避免将旧数据写入缓存。
版本号比对机制实现方法大致如下:
将数据库中行记录的时间戳作为版本号,通过 Lua 脚本通过 Redis EVAL 命令提供类似 MSET 的更新操作,基于自定义编解码器提取 Redis 记录中的版本号,在执行数据设置操作时进行比对,只写入较新的数据。
其中 Redis 的数据记录对应的 Key-Value 编码格式如所示:
简易参考实现
接下来我们以 Redis 为例,提供一个 Java 版本的简易参考实现。本文中会给出实现所涉及的关键要素与核心代码,你可以访问 Github 项目 来了解整个样例工程,并通过查阅 Issue 与 commits 来了解整个样例工程的演化进程。
我们这里不采取定制数据格式,而是通过额外的缓存 Key 存储数据版本,要想实现类似版本号比对机制,需要关注的核心要素有两个:
- 需要复写 Redis 数据设置操作,当设置 Redis 中数据时校验版本号;
- 在版本号比对通过后需要绑定版本号数据,与主数据同步写入 Redis 中。
同时为了保障 Redis 操作的原子性,我们需要借助 Lua 脚本来实现上述两点。这里以字符串类型为例,对应脚本分别如下:
Redis 数据设置操作
返回值的第二个属性作为判断是否成功执行数据设置操作的依据。该属性为 false 表示数据未成功写入 Redis。同时意味当前 进程/线程 执行写入的数据为历史数据,在次过程中数据已经发生变更并又其他数据写入。
local key = KEYS[1]
local value = ARGV[1]
local current_version = ARGV[2]
local version_key = 'version:'..key
local version_value = redis.call('get', version_key)
if version_value == false or version_value < current_version then
redis.call('mset', version_key, current_version, key, value)
return {value, true}
else
return {false, false}
end
该方案主要的影响在应用层实现,需要在调用 Redis 的 EVAL 命令前从数据实体中提取时间戳作为版本号,同时需要保障数据实体中包含时间戳相关属性。
封装 Redis 操作
结合我们的样例工程代码,我们通过实现 VersionWrapper 对 Redis 的操作进行如下封装。
public class VersionWrapper extends Jedis implements CacheCommands {
private final Jedis jedis;
public VersionWrapper(Jedis jedis) {
this.jedis = jedis;
}
@Override
public String set(String key, String value, String version) {
Object result = this.jedis.eval(LuaScripts.versionSet(), List.of(key), List.of(value, version));
EvalResult er = new EvalResult((List<?>) result);
if (er.effect()) {
return er.value();
}
return null;
}
}
补充
透过该方案我们推测 Uber 采取的并非数据变更后删除缓存的策略,很可能是更新缓存的策略(在 Uber 的技术博客中也间接的提到了更新缓存的策略)。
因为整个版本号比对的方式与删除缓存的逻辑相悖。我们抛开 Uber CacheFront 的整体架构,仅仅将该方案应用在简单架构模型中。采取删除缓存的策略,可能会产生如下图所示的结果,此时应用服务 Server - 2 因为查询缓存未获取到值,而从数据库加载并写入缓存,但是此时缓存中写入的为历史旧值,而在该数据过期前或者下次数据变更前,都不会再触发更新了。
当然对于更新缓存的策略同样面临这个问题,因为当数据变更发生期间,缓存中并没有该数据记录时,通常我们不会采取主动刷新缓存的策略,那么则依然会面对上面的问题。
而 Uber 的 CacheFront 基于企业内部的 Flux 技术组件实现对缓存的异步处理,通过阅读文章我们也可以发现这个异步延迟在秒级,那么在如此长的时间间隙后,无论采用删除还是更新策略想要产生上图中的不一致现象都比较难,因为对应用系统来说,进程/线程阻塞 2-3 秒是很难以忍受的现象,所以通常不会出现如此漫长的阻塞与卡顿。
如果你想进一步了解如何实现与 Uber 利用 Flux 实现缓存异步处理的内容,也可阅读我们此前缓存与主副本数据一致性系统设计方案(下篇)文章中更新主副本数据后更新缓存并发问题解决方案章节。
总结
本文并非对延迟双删的全盘否定,而是强调在特殊场景下,延迟双删策略的弊端会被放大,进而完全盖过其优势。对于那些业务体量大伴随着流量大的应用系统,必应要从中权衡取舍。
每一种策略都仅适配应用系统生命周期的一段。只不过部分企业随着业务发展逐步壮大,其研发基础设施的能力也更完善。从而为系统设计带来诸多便捷,从而使得技术决策变得与中小研发团队截然不同。
所以当我们在学习他人经验的过程中,到了落地执行环节一定要结合实际团队背景、业务需求、开发周期与资金预算进行灵活适配。如果你希望了解更多技术中立(排除特定基础设施)的系统设计方案,欢迎你关注我的账号或订阅我的系统设计实战:常用架构与模式详解专栏,我将在其中持续更新技术中立的系统设计系列文章。如果您发现文章内容中任何不准确或遗漏的部分。非常希望您能评论指正,我将尽快修正疏漏,为大家提供优质技术内容。
相关阅读
- 缓存与主副本数据一致性系统设计方案
- System-Design-Codebase
- Scaling Memcache at Facebook
- How Uber Serves Over 40 Million Reads Per Second from Online Storage Using an Integrated Cache
你好,我是 HAibiiin,一名探索技术之外更多可能性的 Product Engineer。如果本篇文章对你有所启发或提供了一定价值,还请不要吝啬点赞、收藏和关注。
来源:juejin.cn/post/7447033901657096202
被阿里抛弃的那个项目,救活了!
众所周知,上个月的时候,GitHub 知名开源项目 EasyExcel 曾对外发布公告将停止主动更新,当时这个消息在网上还引发了不少讨论。
至此,这个运营了 6 年,在 GitHub 上累计收获 32k+ star 的知名项目基本就宣告停更了,大家都觉得挺可惜的。
然而,就在阿里官宣停更的同时,EasyExcel 的原作者个人当即也站出来向大家透露了一个新项目计划,表示自己将会继续接手,并重启一个新项目开源出来。
那现在,这个承诺已经如期兑现了!
就在上周,EasyExcel 作者自己也正式发文表示,EasyExcel 的替代方案正式来了,相信不少同学也看到了这个消息。
作者把新项目定名为:FastExcel(ps:作者一开始初定叫 EasyExcel-Plus,后来改名为了 FastExcel),用于取代已经被阿里官方停更的 EasyExcel 项目。
新项目 FastExcel 同样定位在一个基于 Java 的、快速、简洁、解决大文件内存溢出的 Excel 处理工具。
并且新项目将兼容老的已停更的 EasyExcel 项目,并提供项目维护、性能优化、以及bugFix。
同时作者还表示,新项目 FastExcel 将始终坚持免费开源,并采用最开放的 MIT 协议,使其适用于任何商业化场景,以便为广大开发者和企业继续提供极大的自由度和灵活性。
不得不说,作者的格局还是相当打开的。
大家都知道,其实 EasyExcel 项目的作者原本就是工作在阿里,负责主导并维护着这个项目。
然而就在去年,Easyexcel 作者就从阿里离职出来创业了。
所以在上个月 EasyExcel 被宣布停更的时候,当时就有不少网友猜测各种原因。当然背后的真相我们不得而知,不过作为用户的角度来看,Easyexcel 以另外一种形式被继续开源和更新也何尝不是一件利好用户的好消息。
之前的 EasyExcel 之所以受到开发者们的广泛关注,主要是因为它具备了许多显著的特点和优势,而这次的新项目 FastExcel 更可谓是有过之而无不及。
- 首先,FastExcel 同样拥有卓越的读写性能,能够高效地处理大规模的Excel数据,这对于需要处理大量数据的开发者来说依然是一大福音。
- 其次,FastExcel 的 API设计简洁直观,开发者可以轻松上手,无需花费大量时间学习和熟悉。
- 再者,FastExcel 同样支持流式读取,可以显著降低内存占用,避免在处理大规模数据时可能出现的内存溢出问题。
- 此外,新项目 FastExcel 完全兼容原来 EasyExcel 的功能和特性,用户可以在项目中无缝过渡,从 EasyExcel 迁移到 FastExcel 只需更换包名和依赖即可完成升级。
FastExcel 的安装配置也非常简单。
对于使用 Maven 或 Gradle 进行构建的项目来说,只需在相应的配置文件中添加如下所示的 FastExcel 的依赖即可。
- Maven项目
<dependency>
<groupId>cn.idev.excel</groupId>
<artifactId>fastexcel</artifactId>
<version>1.0.0</version>
</dependency>
- Gradle项目
dependencies {
implementation 'cn.idev.excel:fastexcel:1.0.0'
}
在实际使用中,以读取Excel文件为例,开发者只需定义一个数据类和一个监听器类,然后在主函数中调用 FastExcel 的读取方法,并传入数据类和监听器类即可。
FastExcel 会自动解析 Excel 文件中的数据,并将其存储到数据类的实例中,同时触发监听器类中的方法,让开发者可以对解析到的数据进行处理。
// 实现 ReadListener 接口,设置读取数据的操作
public class DemoDataListener implements ReadListener<DemoData> {
@Override
public void invoke(DemoData data, AnalysisContext context) {
System.out.println("解析到一条数据" + JSON.toJSONString(data));
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
System.out.println("所有数据解析完成!");
}
}
public static void main(String[] args) {
String fileName = "demo.xlsx";
// 读取 Excel 文件
FastExcel.read(fileName, DemoData.class, new DemoDataListener()).sheet().doRead();
}
同样地,写 Excel 文件也非常简单,开发者只需定义一个数据类,并填充要写入的数据,然后调用 FastExcel 的写入方法即可。
// 示例数据类
public class DemoData {
@ExcelProperty("字符串标题")
private String string;
@ExcelProperty("日期标题")
private Date date;
@ExcelProperty("数字标题")
private Double doubleData;
@ExcelIgnore
private String ignore;
}
// 填充要写入的数据
private static List<DemoData> data() {
List<DemoData> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
DemoData data = new DemoData();
data.setString("字符串" + i);
data.setDate(new Date());
data.setDoubleData(0.56);
list.add(data);
}
return list;
}
public static void main(String[] args) {
String fileName = "demo.xlsx";
// 创建一个名为“模板”的 sheet 页,并写入数据
FastExcel.write(fileName, DemoData.class).sheet("模板").doWrite(data());
}
过程可谓是清晰易懂、直观明了,所以这对于开发者来说,在使用 FastExcel 时可以轻松上手。
新项目 FastExcel 刚开源不久,目前在 GitHub 上的 star 标星就已经突破了 2000!这也可见其受欢迎程度。
而且伴随着新项目的开源上线,开发者们的参与热情也是十分高涨的。
这才多少天,项目就已经收到上百条issue了。
仔细看了一下会发现,其中一大部分是开发者们对于新项目所提的需求或反馈。
而还有另外一部分则是对于新项目 FastExcel 以及作者的肯定与鼓励。
文章的最后也感谢项目作者的辛勤维护,大家有需要的话也可以上去提需求或者反馈一些意见,另外感兴趣的同学也可以上去研究研究相关的代码或者参与项目,尤其是数据处理这一块,应该会挺有收获的。
注:本文在GitHub开源仓库「编程之路」 github.com/rd2coding/R… 中已经收录,里面有我整理的6大编程方向(岗位)的自学路线+知识点大梳理、面试考点、我的简历、几本硬核pdf笔记,以及程序员生活和感悟,欢迎star。
来源:juejin.cn/post/7450088304001728523
面试官:GROUP BY和DISTINCT有什么区别?
在 MySQL 中,GR0UP BY 和 DISTINCT 都是用来处理查询结果中的重复数据,并且在官方的描述文档中也可以看出:在大多数情况下 DISTINCT 是特殊的 GR0UP BY,如下图所示:
官方文档地址:dev.mysql.com/doc/refman/…
但二者还是有一些细微的不同,接下来一起来看。
1.DISTINCT 介绍
- 用途:DISTINCT 用于从查询结果中去除重复的行,确保返回的结果集中每一行都是唯一的。
- 语法:通常用于 SELECT 语句中,紧跟在 SELECT 关键字之后。例如以下 SQL:
SELECT DISTINCT column1, column2 FROM table_name;
- 工作机制:DISTINCT 会对整个结果集进行去重,即只要结果集中的某一行与另一行完全相同,就会被去除。
2.GR0UP BY 介绍
- 用途:GR0UP BY 主要用于对结果集按照一个或多个列进行分组,通常与聚合函数(如 COUNT, SUM, AVG, MAX, MIN 等)一起使用,以便对每个组进行统计。
- 语法:GR0UP BY 通常跟在 FROM 或 WHERE 子句之后,在 SELECT 语句的末尾部分。例如以下 SQL:
SELECT column1, COUNT(*) FROM table_name GR0UP BY column1;
- 工作机制:GR0UP BY 将数据按指定的列进行分组,每个组返回一行数据。
3.举例说明
3.1 使用 DISTINCT
假设有一个表 students,包含以下数据:
id | name | age |
---|---|---|
1 | Alice | 20 |
2 | Bob | 22 |
3 | Alice | 20 |
使用 DISTINCT 去除重复行:
SELECT DISTINCT name, age FROM students;
结果:
name | age |
---|---|
Alice | 20 |
Bob | 22 |
3.2 使用 GR0UP BY
假设还是上面的表 students,我们想要统计每个学生的数量:
SELECT name, COUNT(*) AS count FROM students GR0UP BY name;
结果:
name | count |
---|---|
Alice | 2 |
Bob | 1 |
4.主要区别
- 功能不同:DISTINCT 用于去除重复行,而 GR0UP BY 用于对结果集进行分组,通常与聚合函数一起使用。
- 返回结果不同:DISTINCT 返回去重后的结果集,查询结果集中只能包含去重的列信息,有其他列信息会报错;GR0UP BY 返回按指定列分组后的结果集,可以展示多列信息,并可以包含聚合函数的计算结果。
- 应用场景不同:DISTINCT 更适合单纯的去重需求,GR0UP BY 更适合分组统计需求。
- 性能略有不同:如果去重的字段有索引,那么 GR0UP BY 和 DISTINCT 都可以使用索引,此情况它们的性能是相同的;而当去重的字段没有索引时,DISTINCT 的性能就会高于 GR0UP BY,因为在 MySQL 8.0 之前,GR0UP BY 有一个隐藏的功能会进行默认的排序,这样就会触发 filesort 从而导致查询性能降低。
课后思考
count(*)、count(1) 和 count(字段) 有什么区别?
本文已收录到我的面试小站 http://www.javacn.site,其中包含的内容有:Redis、JVM、并发、并发、MySQL、Spring、Spring MVC、Spring Boot、Spring Cloud、MyBatis、设计模式、消息队列等模块。
来源:juejin.cn/post/7415914114650685481
Java 8 魔法:利用 Function 接口告别冗余代码,打造高效断言神器
前言
在 Java
开发的征途中,我们时常与重复代码不期而遇。这些重复代码不仅让项目显得笨重,更增加了维护成本。幸运的是,Java 8
带来了函数式编程的春风,以 Function
接口为代表的一系列新特性,为我们提供了破除这一难题的利剑。本文将以一个实际应用场景为例,即使用 Java 8
的函数式编程特性来重构数据有效性断言逻辑,展示如何通过 SFunction
(基于 Java 8
的 Lambda
表达式封装)减少代码重复,从而提升代码的优雅性和可维护性。
背景故事:数据校验的烦恼
想象一下,在一个复杂的业务系统中,我们可能需要频繁地验证数据库中某个字段值是否有效,是否符合预期值。传统的做法可能充斥着大量相似的查询逻辑,每次都需要手动构建查询条件、执行查询并处理结果,这样的代码既冗长又难以维护。
例如以下两个验证用户 ID 和部门 ID 是否有效的方法,虽然简单,但每次需要校验不同实体或不同条件时,就需要复制粘贴并做相应修改,导致代码库中充满了大量雷同的校验逻辑,给维护带来了困扰。
// 判断用户 ID 是否有效
public void checkUserExistence(String userId) {
User user = userDao.findById(userId);
if (user == null) {
throw new RuntimeException("用户ID无效");
}
}
// 判断部门 ID 是否有效
public void checkDeptExistence(String deptId) {
Dept dept = deptDao.findById(deptId);
if (dept == null) {
throw new RuntimeException("部门ID无效");
}
}
Java 8 的魔法棒:函数式接口
Java 8 引入了函数式接口的概念,其中 Function<T, R>
是最基础的代表,它接受一个类型 T
的输入,返回类型 R
的结果。而在 MyBatis Plus
等框架中常用的 SFunction
是对 Lambda
表达式的进一步封装,使得我们可以更加灵活地操作实体类的属性。
实战演练:重构断言方法
下面的 ensureColumnValueValid
方法正是利用了函数式接口的魅力,实现了对任意实体类指定列值的有效性断言:
/**
* 确认数据库字段值有效(通用)
*
* @param <V> 待验证值的类型
* @param valueToCheck 待验证的值
* @param columnExtractor 实体类属性提取函数
* @param queryExecutor 单条数据查询执行器
* @param errorMessage 异常提示信息模板
*/
public static <T, R, V> void ensureColumnValueValid(V valueToCheck, SFunction<T, R> columnExtractor, SFunction<LambdaQueryWrapper<T>, T> queryExecutor, String errorMessage) {
if (valueToCheck == null) return;
LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
wrapper.select(columnExtractor);
wrapper.eq(columnExtractor, valueToCheck);
wrapper.last("LIMIT 1");
T entity = queryExecutor.apply(wrapper);
R columnValue = columnExtractor.apply(entity);
if (entity == null || columnValue == null)
throw new DataValidationException(String.format(errorMessage, valueToCheck));
}
这个方法接受一个待验证的值、一个实体类属性提取函数、一个单行数据查询执行器和一个异常信息模板作为参数。通过这四个参数,不仅能够进行针对特定属性的有效性检查,而且还能生成具有一致性的异常信息。
对比分析
使用 Function
改造前
// 判断用户 ID 是否有效
public void checkUserExistence(String userId) {
User user = userDao.findById(userId);
if (user == null) {
throw new RuntimeException("用户ID无效");
}
}
// 判断部门 ID 是否有效
public void checkDeptExistence(String deptId) {
Dept dept = deptDao.findById(deptId);
if (dept == null) {
throw new RuntimeException("部门ID无效");
}
}
使用 Function
改造后
public void assignTaskToUser(AddOrderDTO dto) {
ensureColumnValueValid(dto.getUserId(), User::getId, userDao::getOne, "用户ID无效");
ensureColumnValueValid(dto.getDeptId(), Dept::getId, deptDao::getOne, "部门ID无效");
ensureColumnValueValid(dto.getCustomerId(), Customer::getId, customerDao::getOne, "客户ID无效");
ensureColumnValueValid(dto.getDeptId(), Dept::getId, deptDao::getOne, "部门ID无效");
ensureColumnValueValid(dto.getSupplieId(), Supplie::getId, supplierDao::getOne, "供应商ID无效");
// 现在可以确信客户存在
Customer cus = customerDao.findById(dto.getCustomerId());
// 创建订单的逻辑...
}
对比上述两段代码,我们发现后者不仅大幅减少了代码量,而且通过函数式编程,表达出更为清晰的逻辑意图,可读性和可维护性都有所提高。
优点
- 减少重复代码: 通过
ensureColumnValueValid
方法,所有涉及数据库字段值有效性检查的地方都可以复用相同的逻辑,将变化的部分作为参数传递,大大减少了因特定校验逻辑而产生的代码量。 - 增强代码复用: 抽象化的校验方法适用于多种场景,无论是用户ID、订单号还是其他任何实体属性的校验,一套逻辑即可应对。
- 提升可读性和维护性: 通过清晰的函数签名和 Lambda 表达式,代码意图一目了然,降低了后续维护的成本。
- 灵活性和扩展性: 当校验规则发生变化时,只需要调整
ensureColumnValueValid
方法或其内部实现,所有调用该方法的地方都会自动受益,提高了系统的灵活性和扩展性。
举一反三:拓展校验逻辑的边界
通过上述的实践,我们见识到了函数式编程在简化数据校验逻辑方面的威力。但这只是冰山一角,我们可以根据不同的业务场景,继续扩展和完善校验逻辑,实现更多样化的校验需求。以下两个示例展示了如何在原有基础上进一步深化,实现更复杂的数据比较和验证功能。
断言指定列值等于预期值
首先,考虑一个场景:除了验证数据的存在性,我们还需确认查询到的某列值是否与预期值相符。这在验证用户角色、状态变更等场景中尤为常见。为此,我们设计了 validateColumnValueMatchesExpected
方法:
/**
* 验证查询结果中指定列的值是否与预期值匹配
*
* @param <T> 实体类型
* @param <R> 目标列值的类型
* @param <C> 查询条件列值的类型
* @param targetColumn 目标列的提取函数,用于获取想要验证的列值
* @param expectedValue 期望的列值
* @param conditionColumn 条件列的提取函数,用于设置查询条件
* @param conditionValue 条件列对应的值
* @param queryMethod 执行查询的方法引用,返回单个实体对象
* @param errorMessage 验证失败时抛出异常的错误信息模板
* @throws RuntimeException 当查询结果中目标列的值与预期值不匹配时抛出异常
*/
public static <T, R, C> void validateColumnValueMatchesExpected(
SFunction<T, R> targetColumn, R expectedValue,
SFunction<T, C> conditionColumn, C conditionValue,
SFunction<LambdaQueryWrapper<T>, T> queryMethod,
String errorMessage) {
// 创建查询包装器,选择目标列并设置查询条件
LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
wrapper.select(targetColumn);
wrapper.eq(conditionColumn, conditionValue);
// 执行查询方法
T one = queryMethod.apply(wrapper);
// 如果查询结果为空,则直接返回,视为验证通过(或忽略)
if (one == null) return;
// 获取查询结果中目标列的实际值
R actualValue = targetColumn.apply(one);
// 比较实际值与预期值是否匹配,这里假设notMatch是一个自定义方法用于比较不匹配情况
boolean doesNotMatch = notMatch(actualValue, expectedValue);
if (doesNotMatch) {
// 若不匹配,则根据错误信息模板抛出异常
throw new RuntimeException(String.format(errorMessage, expectedValue, actualValue));
}
}
// 假设的辅助方法,用于比较值是否不匹配,根据实际需要实现
private static <R> boolean notMatch(R actual, R expected) {
// 示例简单实现为不相等判断,实际情况可能更复杂
return !Objects.equals(actual, expected);
}
这个方法允许我们指定一个查询目标列(targetColumn)、预期值(expectedValue)、查询条件列(conditionColumn)及其对应的条件值(conditionValue),并提供一个查询方法(queryMethod)来执行查询。如果查询到的列值与预期不符,则抛出异常,错误信息通过 errorMessage 参数定制。
应用场景:
例如在一个权限管理系统中,当需要更新用户角色时,系统需要确保当前用户的角色在更新前是 “普通用户”,才能将其升级为 “管理员”。此场景下,可以使用 validateColumnValueMatchesExpected
方法来验证用户当前的角色是否确实为“普通用户”。
// 当用户角色不是 “普通用户” 时抛异常
validateColumnValueMatchesExpected(User::getRoleType, "普通用户", User::getId, userId, userMapper::getOne, "用户角色不是普通用户,无法升级为管理员!");
断言指定值位于期望值列表内
进一步,某些情况下我们需要验证查询结果中的某一列值是否属于一个预设的值集合。例如,验证用户角色是否合法。为此,我们创建了 validateColumnValueMatchesExpectedList
方法:
/**
* 验证查询结果中指定列的值是否位于预期值列表内
*
* @param <T> 实体类型
* @param <R> 目标列值的类型
* @param <C> 查询条件列值的类型
* @param targetColumn 目标列的提取函数,用于获取想要验证的列值
* @param expectedValueList 期望值的列表
* @param conditionColumn 条件列的提取函数,用于设置查询条件
* @param conditionValue 条件列对应的值
* @param queryMethod 执行查询的方法引用,返回单个实体对象
* @param errorMessage 验证失败时抛出异常的错误信息模板
* @throws RuntimeException 当查询结果中目标列的值不在预期值列表内时抛出异常
*/
public static <T, R, C> void validateColumnValueInExpectedList(
SFunction<T, R> targetColumn, List<R> expectedValueList,
SFunction<T, C> conditionColumn, C conditionValue,
SFunction<LambdaQueryWrapper<T>, T> queryMethod,
String errorMessage) {
LambdaQueryWrapper<T> wrapper = new LambdaQueryWrapper<>();
wrapper.select(targetColumn);
wrapper.eq(conditionColumn, conditionValue);
T one = queryMethod.apply(wrapper);
if (one == null) return;
R actualValue = targetColumn.apply(one);
if (actualValue == null) throw new RuntimeException("列查询结果为空");
if (!expectedValueList.contains(actualValue)) {
throw new RuntimeException(errorMessage);
}
}
这个方法接受一个目标列(targetColumn)、一个预期值列表(expectedValueList)、查询条件列(conditionColumn)及其条件值(conditionValue),同样需要一个查询方法(queryMethod)。如果查询到的列值不在预期值列表中,则触发异常。
应用场景: 在一个电商平台的订单处理流程中,系统需要验证订单状态是否处于可取消的状态列表里(如 “待支付”、“待发货”)才允许用户取消订单。此时,validateColumnValueInExpectedList
方法能有效确保操作的合法性。
// 假设 OrderStatusEnum 枚举了所有可能的订单状态,cancelableStatuses 包含可取消的状态
List<String> cancelableStatuses = Arrays.asList(OrderStatusEnum.WAITING_PAYMENT.getValue(), OrderStatusEnum.WAITING_DELIVERY.getValue());
// 验证订单状态是否在可取消状态列表内
validateColumnValueInExpectedList(Order::getStatus, cancelableStatuses, Order::getOrderId, orderId, orderMapper::selectOne, "订单当前状态不允许取消!");
通过这两个扩展方法,我们不仅巩固了函数式编程在减少代码重复、提升代码灵活性方面的优势,还进一步证明了通过抽象和泛型设计,可以轻松应对各种复杂的业务校验需求,使代码更加贴近业务逻辑,易于理解和维护。
核心优势
- 代码复用:通过泛型和函数式接口,该方法能够适应任何实体类和属性的校验需求,大大减少了重复的查询逻辑代码。
- 清晰表达意图:方法签名直观表达了校验逻辑的目的,提高了代码的可读性和可维护性。
- 灵活性:使用者只需提供几个简单的 Lambda 表达式,即可完成复杂的查询逻辑配置,无需关心底层实现细节。
- 易于维护与扩展:
- 当需要增加新的实体验证时,仅需调用
ensureColumnValueValid
并传入相应的参数,无需编写新的验证逻辑,降低了维护成本。 - 修改验证规则时,只需调整
ensureColumnValueValid
内部实现,所有调用处自动遵循新规则,便于统一管理。 - 异常处理集中于
ensureColumnValueValid
方法内部,统一了异常抛出行为,避免了在多个地方处理相同的逻辑错误,减少了潜在的错误源。
- 当需要增加新的实体验证时,仅需调用
函数式编程的力量
通过这个实例,我们见证了函数式编程在简化代码、提高抽象层次上的强大能力。在 Java 8 及之后的版本中,拥抱函数式编程思想,不仅能够使我们的代码更加简洁、灵活,还能在一定程度上促进代码的正确性和可测试性。因此,无论是日常开发还是系统设计,都值得我们深入探索和应用这一现代编程范式,让代码如魔法般优雅而高效。
来源:juejin.cn/post/7384256110280572980
三维gis的就业前景如何
一、前言
三维GIS是一个伪概念,GIS是地理信息系统,三维GIS就是三维地理信息系统,在课本上,专业概念上,也没有这一说法吧,所以三维GIS,就是技术人员造概念拼凑造出来的,本质上就是GIS三维可视化。
二、详细介绍
GIS从广义层面分为两大方面,第一是数据及数据管理,第二是业务功能,其中业务,就是分析、显示、交互。按照云计算服务划分,分别对应着DAAS层和SAAS层。按照所谓三维GIS的说法,那就是三维数据管理、三维分析、三维显示、三维交互。目前三维GIS基本上就是围绕着三维数据、三维分析、三维数据显示以及三维交互这四方面展开,其中三维数据是主要表现力。
1.三维数据
三维GIS场景与游戏场景的最主要也是最本质区别在于,三维GIS的数据都是真实的,来源于真实世界映射数据,包括卫星影像、三维地形DEM/DSM/DTM、倾斜摄影、点云等各式各样数据。游戏场景绝大部分人工建模出来的理想中的场景。一个是面向现实,一个面向未来,如果把两者结合,是不是有“元宇宙”的味道了呢?
2.三维分析
三维分析,即基于三维数据进行的分析计算,三维即三维空间,三维空间的GIS分析,三维空间距离量算、三维体积量算、三维高度量算……本质上应该比二维分析更丰富才对。可惜的是,三维空间分析,绝大部分在课本上找不到,更别说概念化描述。
3.三维可视化
三维可视化绝大部分是三维数据的可视化,我们常见的有地形可视化、倾斜摄影可视化
4.三维交互
除了普通的漫游、缩放、旋转、更有俯仰、翻转等操作。从上面可以看出三维GIS重点还是在于可视化,做三维GIS可视化,两大核心是GIS+图形学。想要在三维GIS深耕,就需要在计算机图形学有很好的基础,较强的GIS开发概念,以及自己的想象力。而图形学是公认的成长缓慢的大后期,比C++成长更慢,更别说与Java、JavaScript等相提并论。因此以后把三维GIS作为自己的就业方向,前景很光明,即使以后不在GIS圈,去互联网游戏行业都能胜任,而且比纯游戏开发更有优势。
三、结语
目前GIS公司对三维GIS的开发需求很强烈,但是受限于大学所学,能在大学既能懂GIS又懂图形学的,凤毛麟角,所以三维GIS开发很抢手,但是学校都找不到人,绝大部分都是招来基础好的学生,从零开始培养。
学好三维GIS,不仅仅会一个Cesium、MapBox就完了,这最初级阶段,只是二次开发,熟悉了接口,你就需要从原理角度去熟悉底层原理,渲染管线、地形瓦片及3dtiles调度与渲染等等,这些都需要你去认真深入学习的,还有一点,也是很三维GIS的最重要的一件武器:shader。这是必须熟练掌握的,包括glsl,hlsl等。
最后,就是要学游戏引擎,目前,做三维GIS与游戏引擎结合的越来越紧密,随着信创产业的快速发展,UE以后会越来越得到应用,做三维GIS,离不开熟练使用UE或者Unity。以上是我个人的一些看法,希望看到有更多的人投入到三维GIS中去,创造越来越多很酷的三维GIS产品,服务社会,造福人类!
来源:juejin.cn/post/7337188759060267023
23年计算机科学与技术毕业生的2024 年终总结
### 2024年年终工作总结
引言
随着2024年的落幕,作为一名计算机科学与技术专业的毕业生,我有幸参与到一款聊天应用的开发工作中。这一年,我不仅在技术层面获得了长足的进步,也在团队协作和项目管理方面积累了宝贵的经验。以下是我这一年工作的详细总结。
技术成长
1. 安卓开发技能提升
在这一年中,我深入学习了Android开发框架,掌握了Kotlin和Java两种编程语言,熟悉了Android Studio开发环境。
学习并实践了Material Design设计原则,优化了用户界面和用户体验。
掌握了网络编程,包括HTTP协议、RESTful API以及使用Retrofit和Volley等网络库进行数据通信。
2. 项目管理与协作
参与了敏捷开发流程,学习了Scrum框架,每周参与Scrum会议,及时同步项目进度。
学会了使用Git进行版本控制,以及在GitHub上进行代码管理和团队协作。
3. 性能优化与问题解决
学习了Android性能优化的技巧,包括内存管理、布局优化和多线程编程。
通过日志分析和性能监控工具,定位并解决了多个应用崩溃和性能瓶颈问题。
项目成果
1. 聊天应用核心功能开发
负责聊天应用的核心聊天功能模块开发,包括消息发送、接收和展示。
实现了消息加密传输,保障用户通信安全。
2. 用户界面与交互设计
参与设计并实现了应用的用户界面,使其更加直观和易用。
根据用户反馈,迭代优化了多个界面元素,提升了用户满意度。
3. 跨平台兼容性测试
参与了应用的跨平台测试工作,确保应用在不同设备和操作系统版本上的兼容性和稳定性。
团队协作与领导力
1. 团队沟通与协作
作为团队的一员,我积极参与团队讨论,提出建设性意见,并协助解决同事遇到的问题。
学会了如何更有效地与团队成员沟通,提高了团队的整体效率。
2. 领导力的培养
在项目中担任小组长,负责协调小组内的工作分配和进度跟踪,锻炼了我的领导能力。
个人成长与反思
1. 自我学习与提升
通过在线课程和专业书籍,不断学习新的技术和行业动态,保持自己的竞争力。
反思过去一年的工作,认识到自己在时间管理和优先级排序上的不足,并制定了改进计划。
2. 职业规划
明确了自己的职业发展方向,计划在未来几年内深入学习人工智能和机器学习,为公司的技术创新贡献力量。
2024年对我来说是充满挑战和成长的一年。我不仅在技术上有所提升,也在团队协作和项目管理方面获得了宝贵的经验。展望未来,我将继续努力,为公司的发展贡献自己的力量,并实现个人职业生涯的进一步发展。 环信的easyimkit很好用。
springboot多种生产打包方式简介
生产上发布 Spring Boot 项目时,流程颇为繁琐且低效。但凡代码有一丁点改动,就得把整个项目重新打包部署,耗时费力不说,生成的 JAR 包还特别臃肿,体积庞大。每次更新项目,光是上传这大文件就得花费不少时间,严重影响工作节奏。为解决这一痛点,我打算把依赖库以及配置文件(lib 文件夹下的那些 jar 包,还有config下的applacation.yml等文件)从项目主体里剥离出来,后续部署时,只需发布核心代码就行,这样既能加快部署速度,又能减轻文件传输负担,让项目更新变得轻松便捷
方法一 插件spring-boot-maven-plugin
1. 项目应用的配置文件排除 统一打包到config目录下
利用springboot中resource插件来排除配置,并统一打包到config目录下
<resources>
<resource>
<directory>src/main/resources</directory>
<!--filerting设置为true,则打包过程中会对这些文件进行过滤处理-->
<filtering>true</filtering>
<!--指定目标路径为config-->
<targetPath>${project.build.directory}/config</targetPath>
<includes>
<!--使用通配符-->
<include>**/*.properties</include>
<include>**/*.yml</include>
<include>**/*.xml</include>
<include>mapper/*.xml</include>
<!-- 这里可以根据你实际想要包含的配置文件类型来添加更多的include配置 -->
</includes>
</resource>
</resources>
2. 把我们写代码打包可执行jar,并排除依赖jar包
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!--项目的启动类,如果有多个main就必须指定,没有可以缺失
<mainClass>XXXXX.TwinWebApplication</mainClass>-->
<!--解决windows命令行窗口中文乱码-->
<jvmArguments>-Dfile.encoding=UTF-8</jvmArguments>
<layout>ZIP</layout>
<!--配置需要打包进项目的jar-->
<includes>
<!--填写需要打包所需要的依赖 。没有匹配上任何jar包机排除依赖-->
<include>
<groupId>no-exists-jar</groupId>
<artifactId>non-exists-jar</artifactId>
</include>
</includes>
</configuration>
<executions>
<execution>
<goals>
<!-- 表示当运行mavn package打包时,使用Springboot插件打包 -->
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
3 配置依赖的jar包 统一打包lib目录
<!--此插件用于将依赖包抽出-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
<excludeTransitive>false</excludeTransitive>
<stripVersion>false</stripVersion>
<includeScope>runtime</includeScope>
</configuration>
</execution>
</executions>
</plugin>
打包后目录结构,如下图所示
执行命令 java -jar -Dloader.path=./lib -jar xxx.jar
注意
springboot启动时候会优先读取config目录下配置文件 所以这里不用指定-Dspring.config.location=XX.yml文件
注意
例如日志文件配置以及mybits等配置文件 可以配成绝对路径 如下所示:
方法二 使用maven-jar-plugin插件实现
1 使用插件maven-resources-plugin处理配置文件打包到config目录
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<configuration>
<encoding>UTF-8</encoding>
</configuration>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<!--配置文件打包成config目录下 -->
<outputDirectory>${project.build.directory}/twin-web/config</outputDirectory>
<resources>
<resource>
<directory>src/main/resources</directory>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
2. 使用maven-jar-plugin 打包可执行jar 并排除依赖
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<outputDirectory>
<!--输入打包可执行的jar到twin-web\libs\下-->
${project.build.directory}/twin-web/
</outputDirectory>
<archive>
<addMavenDescriptor>false</addMavenDescriptor>
<manifest>
<addClasspath>true</addClasspath>
<!-- 增加执行启动jar的依赖jar包目录前缀-->
<classpathPrefix>./libs/</classpathPrefix>
<!-- 指定启动类-->
<mainClass>com.keqing.twinweb.TwinWebApplication</mainClass>
</manifest>
<manifestEntries>
<!-- 增加配置文件的classpath-->
<Class-Path>./config/</Class-Path>
</manifestEntries>
</archive>
<!-- 排除配置文件-->
<excludes>
<exclude>*.yml</exclude>
<exclude>mapper/**</exclude>
<exclude>*.xml</exclude>
</excludes>
</configuration>
</plugin>
3 使用maven-dependency-plugin 打包libs目录下
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/twin-web/libs</outputDirectory>
<excludeTransitive>false</excludeTransitive>
<stripVersion>false</stripVersion>
<includeScope>runtime</includeScope>
</configuration>
</execution>
</executions>
</plugin>
使用package打包后的目录
查看自己打包后jar目录,注意这种打包方式弊端,按照一定约定格式规范固定了,一旦依赖jar包(包括配置文件目录等)发生变化就必须重新打包
启动程序java -jar xxx.jar
方式三 使用maven-assembly-plugin打包
maven-assembly-plugin
是 Maven 中的一个插件,它允许用户将项目的输出以及依赖、模块、站点文档和其他文件打包成一个可发布的格式,例如 zip、tar.gz、jar 等。以下是使用 maven-assembly-plugin
的一些优势:
- 自定义打包格式:
maven-assembly-plugin
允许你通过定义描述符文件(descriptor)来完全自定义打包的内容和格式。你可以选择包含或排除特定的文件和目录。 - 一键打包:通过一个简单的 Maven 命令,你可以创建一个包含所有必需依赖的单一归档文件,这使得分发和部署变得非常简单。
- 多环境支持:可以为不同的环境(开发、测试、生产)创建不同的打包配置,使得环境迁移更加容易。
- 依赖管理:插件会自动处理项目依赖,将它们打包到最终的归档文件中,无需手动管理。
- 模块化项目支持:对于多模块项目,
maven-assembly-plugin
可以将所有模块的输出合并到一个归档文件中。 - 预配置的描述符:插件提供了一些预定义的描述符,如
bin
、jar-with-dependencies
等,可以直接使用,无需自定义。 - 灵活性:你可以通过修改描述符文件来调整打包行为,以适应不同的需求。
- 集成性:
maven-assembly-plugin
与 Maven 生态系统紧密集成,可以与其他 Maven 插件协同工作。 - 文档和社区支持:由于
maven-assembly-plugin
是 Maven 的一部分,因此有广泛的文档和社区支持。
1. 项目应用的配置文件排除
<resources>
<resource>
<directory>src/main/resources</directory>
<!--filerting设置为true,则打包过程中会对这些文件进行过滤处理-->
<filtering>true</filtering>
<includes>
<!--使用通配符-->
<include>**/*.properties</include>
<include>**/*.yml</include>
<include>**/*.xml</include>
<include>mapper/*.xml</include>
<!-- 这里可以根据你实际想要包含的配置文件类型来添加更多的include配置 -->
</includes>
</resource>
</resources>
2. 配置spring-boot-maven-plugin
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!--项目的启动类,如果有多个main就必须指定,没有可以缺失
<mainClass>XXXXX.TwinWebApplication</mainClass>-->
<!--解决windows命令行窗口中文乱码-->
<jvmArguments>-Dfile.encoding=UTF-8</jvmArguments>
<layout>ZIP</layout>
<!--配置需要打包进项目的jar-->
<includes>
<!--填写需要打包所需要的依赖 。没有匹配上任何jar包机排除依赖-->
<include>
<groupId>no-exists-jar</groupId>
<artifactId>non-exists-jar</artifactId>
</include>
</includes>
</configuration>
<executions>
<execution>
<goals>
<!-- 表示当运行mavn package打包时,使用Springboot插件打包 -->
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
3 引入springboot里约定maven-assembly-plugin
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<!-- 打包文件名字不包含 assembly.xml 中 id -->
<appendAssemblyId>false</appendAssemblyId>
<descriptors>
<!--项目所在目录配置文件的 assembly.xml文件 -->
<descriptor>assembly.xml</descriptor>
</descriptors>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
配置assembly.xml文件
<assembly>
<!-- 打包文件名的标识符,用来做后缀-->
<id>make-assembly</id>
<!-- 打包的类型,如果有N个,将会打N个类型的包 -->
<formats>
<format>tar.gz</format>
<format>zip</format>
</formats>
<!-- 压缩包下是否生成和项目名相同的根目录 -->
<includeBaseDirectory>true</includeBaseDirectory>
<!-- 用来设置一组文件在打包时的属性。-->
<fileSets>
<!-- 0755->即用户具有读/写/执行权限,组用户和其它用户具有读写权限;-->
<!-- 0644->即用户具有读写权限,组用户和其它用户具有只读权限;-->
<!-- 将src/bin目录下的jar启动脚本输出到打包后的目录中 -->
<fileSet>
<!--lineEnding选项可用于控制给定的行结束文件 -->
<lineEnding>unix</lineEnding>
<directory>${basedir}/bin</directory>
<outputDirectory>${file.separator}</outputDirectory>
<fileMode>0755</fileMode>
<includes>
<include>**.sh</include>
<include>**.bat</include>
</includes>
</fileSet>
<!-- 把项目的配置文件,打包进压缩文件的config目录 -->
<fileSet>
<directory>${basedir}/src/main/resources</directory>
<outputDirectory>config</outputDirectory>
<fileMode>0644</fileMode>
<includes>
<include>*.properties</include>
<include>*.yml</include>
<include>*.xml</include>
<include>mapper/*.xml</include>
</includes>
</fileSet>
<!-- 把项目自己编译出来的jar文件,打包进zip文件的根目录 -->
<fileSet>
<directory>${project.build.directory}</directory>
<outputDirectory>${file.separator}</outputDirectory>
<includes>
<include>*.jar</include>
</includes>
</fileSet>
</fileSets>
<!-- 依赖包的拷贝-->
<dependencySets>
<dependencySet>
<unpack>false</unpack>
<useProjectArtifact>true</useProjectArtifact>
<outputDirectory>lib</outputDirectory>
<scope>provided</scope>
</dependencySet>
<dependencySet>
<unpack>false</unpack>
<useProjectArtifact>true</useProjectArtifact>
<outputDirectory>lib</outputDirectory>
<scope>system</scope>
</dependencySet>
<dependencySet>
<unpack>false</unpack>
<useProjectArtifact>true</useProjectArtifact>
<outputDirectory>lib</outputDirectory>
<scope>runtime</scope>
</dependencySet>
</dependencySets>
</assembly>
打包后目录
解压zip目录查看
使用命令启动项目java -jar -Dloader.path=./lib -jar xxx.jar
来源:juejin.cn/post/7442154802832916530
Spring Boot + liteflow竟然这么好用!
liteflow
可以帮助我们快速编排复杂的业务规则,并实现动态的规则更新。
在liteflow
中,主要有以下几种组件:
- 普通组件:集成
NodeComponent
,用于执行具体的业务逻辑;- 选择组件:通过业务逻辑选择不同的执行路径;
- 条件组件:基于条件返回结果,决定下一步的业务流程。
我们通过代码示例来了解每种组件的用法。
java
// 普通组件示例
@LiteflowComponent("commonNode")
public class CommonNode extends NodeComponent {
@Override
public void process() throws Exception {
// 业务逻辑
System.out.println("Executing commonNode logic");
}
}
// 选择组件示例
@LiteflowComponent("choiceNode")
public class ChoiceNode extends NodeSwitchComponent {
@Override
public String processSwitch() throws Exception {
// 根据条件返回不同的节点ID
return "nextNodeId";
}
}
// 条件组件示例
@LiteflowComponent("conditionNode")
public class ConditionNode extends NodeIfComponent {
@Override
public boolean processIf() throws Exception {
// 判断条件
return true;
}
}
EL规则文件
在liteflow
中,规则文件可以采用XML格式编写,下面是一个简单的规则文件示例。
如何使用EL规则文件
- 创建规则文件:将上述规则文件保存为
flow.xml
,放在项目的resources
目录下; - 配置
liteflow
:在Spring Boot项目中添加liteflow
的配置,指定规则文件的位置;
yaml
liteflow:
rule-source: "classpath:flow.xml"
node-retry: 3
thread-executor:
core-pool-size: 10
max-pool-size: 20
keep-alive-time: 60
- 编写业务逻辑组件:按照规则文件中的定义,编写相应的组件逻辑。
数据上下文
在liteflow
中,数据上下文非常重要,它用于参数传递
和业务逻辑
的执行。
我们可以通过以下代码示例了解数据上下文的用法
配置详解
在使用liteflow
时,我们需要对一些参数进行配置,如规则文件地址、节点重试、线程池参数等。
以下是一个配置示例。
yaml
liteflow:
rule-source: "classpath:flow.xml" # 指定规则文件的位置
node-retry: 3 # 节点重试次数
thread-executor:
core-pool-size: 10 # 线程池核心线程数
max-pool-size: 20 # 线程池最大线程数
keep-alive-time: 60 # 线程存活时间
实践案例
接下来,我们来看一个完整的业务实践案例。
在电商场景下,当订单完成后,我们需要同时进行积分发放和消息发送。
这时候,我们可以利用liteflow
进行规则编排,处理这些并行任务。
1. 引入依赖
首先,在pom.xml
文件中添加liteflow
的依赖:
xml
<dependency>
<groupId>com.yomahubgroupId>
<artifactId>liteflow-spring-boot-starterartifactId>
<version>2.6.5version>
dependency>
2. 增加配置
在application.yml
文件中添加liteflow
的配置:
yaml
spring:
application:
name: liteflow-demo
liteflow:
rule-source: "classpath:flow.xml" # 指定规则文件的位置
node-retry: 3 # 节点重试次数
thread-executor:
core-pool-size: 10 # 线程池核心线程数
max-pool-size: 20 # 线程池最大线程数
keep-alive-time: 60 # 线程存活时间
3. 编写规则文件
在resources
目录下创建flow.xml
文件,编写规则文件内容:
xml
<flow>
<parallel>
<node id="pointNode"/>
<node id="messageNode"/>
parallel>
flow>
4. 编写业务逻辑组件
按照规则文件中的定义,编写相应的业务逻辑组件:
java
@LiteflowComponent("pointNode")
public class PointNode extends NodeComponent {
@Override
public void process() throws Exception {
// 发放积分逻辑
System.out.println("Issuing points for the order");
}
}
@LiteflowComponent("messageNode")
public class MessageNode extends NodeComponent {
@Override
public void process() throws Exception {
// 发送消息逻辑
System.out.println("Sending message for the order");
}
}
5. 流程触发
当订单完成后,我们需要触发liteflow
的流程来执行积分发放和消息发送的逻辑。
我们可以在订单完成的服务方法中添加如下代码:
java
@Service
public class OrderService {
@Autowired
private FlowExecutor flowExecutor;
public void completeOrder(Order order) {
// 完成订单的其他逻辑
System.out.println("Order completed: " + order.getId());
// 执行liteflow流程
flowExecutor.execute2Resp("flow", order);
}
}
性能统计
总结
来源:juejin.cn/post/7394790673612718092