一、性能优化思路
首先明确性能优化的定义和目标。性能优化是指通过持续的 持续的 分析、实践和测试,确保系统稳定高效运行,从而满足用户的诉求。
性能优化分类
一般情况下,可以把性能优化分为2大类:
1)通用优化
是指一些经典的、对于绝大多数情况都适用的优化策略。比如增大服务器的并发请求处理数、使用缓存减少数据库查询、通过负载均衡分摊请求、同步转异步等。
2)对症下药
是指结合具体的业务特性和系统现状,先通过性能监控工具、压力测试等方式,分析出系统的性能瓶颈,再针对性地选取策略进行优化。
比如数据库单次查询超过 1 秒,属于慢查询,根据实际地查询条件给对应的字段增加索引,一般就能提高查询性能。
通用性能优化手段
有哪些通用的性能优化手段呢?
我们以一个请求的完整生命周期为例,依次介绍。
通常,用户从发起请求,到最终得到数据,要分别经过以下节点:
每个节点,都有对应的优化方法:
1)前端:
- 离线缓存:利用浏览器的缓存机制,请求过一次的资源就不用重复请求,提高页面加载速度。
- 请求合并:页面请求过多时,将多个小请求合并成一个大请求,减少网络开销。
- 懒加载:延迟加载页面的图片等元素,提高首屏加载速度。
2)网关:
- 负载均衡:负责接收请求,根据一定的路由算法转发到对应的后端系统,实现多个后端服务器分摊请求,增大并发量。
- 缓存:将后端返回的数据进行缓存,下次前端请求时,直接从网关获取数据,减少后端调用、提高数据获取速度。
3)后端请求层:
- 服务器优化:根据业务特性,选择性能更高的服务器并调整参数,比如Nginx、Undertow等。
- 微服务:将大型服务拆分为小型服务,并通过微服务网关进行转发,增大各服务的并发处理能力。
4)业务逻辑层:
- 异步化:将同步的业务逻辑改为异步,尽早响应,提高并发处理能力。
- 多线程:将复杂的操作拆分成多个任务,通过多线程并发执行,提高任务处理效率。
5)中间件(第三方依赖):
- 缓存:将数据库查询出的结果数据缓存到性能更高的服务(比如基于内存的Redis或本地),减少数据库的压力、并提高数据查询性能。
- 队列:使用消息队列,对系统进行解耦、或者将操作异步化,实现流量的削峰填谷。
6)存储层:
- 分库分表:数据量极大时,对数据库进行垂直或水平切分,提高数据库并发处理能力。
- 数据清理:定期清理无用或过期的数据,减少存储压力,必要时可以对数据进行备份转储。
虽然有很多性能优化方法,但并不是每一种都要用、每一种都有用。在做性能优化时,一定要根据实际情况,权衡性价比和系统改动风险,并且做好充分的测试,不要好心优化、结果给系统导入了新的Bug。一般情况下,不建议为了优化盲目引入新技术,先从成本最低的优化方法开始。
二、核心功能性能优化
在这里用最近做的一个代码生成器项目来举例,该项目提供给用户可以自定义制作代码生成器的能力,基于 React + Spring Boot + Picocli + 对象存储的代码生成器共享平台。
项目地址:(gitee.com/you4189/you…)
针对项目中的核心功能进行优化,如:下载生成器、使用生成器、制作生成器。这些接口的主要耗时都在后端,下面依次分析和优化这些功能对应的接口。
在进行具体分析代码耗时之前,先要对要优化的业务进行测试,记录时间,进而在优化后进行对比。文中测试相关的内容没有编写,但在实际开发优化中,必须要先测试。
下载生成器接口
接口名称:downloadGeneratorById
1、分析代码耗时
通过分析核心代码,统计关键代码的耗时操作,定位接口的性能瓶颈。可以使用Spring提供的 StopWatch 计时器工具类,给下载接口添加统计耗时代码:
StopWatch stopWatch = new StopWatch();
stopWatch.start();
COSObject cosObject = cosManager.getObject(filepath);
cosObjectInput = cosObject.getObjectContent();
// 处理下载到的流
byte[] bytes = IOUtils.toByteArray(cosObjectInput);
stopWatch.stop();
System.out.println(stopWatch.getTotalTimeMillis());
通过统计耗时发现,最核心的耗时因素在于 从第三方对象存储服务下载文件,流程是先下载完整文件到服务器后,再整体下入到输出流。
// 处理下载到的流,本质上是下载完整文件到服务器
byte[] bytes = IOUtils.toByteArray(cosObjectInput);
// 写入响应输出流
response.getOutputStream().write(bytes);
response.getOutputStream().flush();
2、下载优化-流式处理
下载大文件时,除了下载慢之外,还可能会占用服务器的内存、硬盘空间,导致资源紧张。
所以,如果文件较大、并且服务端不用处理文件,可以选用流式处理,通过循环的方式,持续从 OSObjectInputStream 读取数据并写进输出流,防止过大的文件占满内存。
示例代码如下:
// 设置响应头
response.setHeader("Content-Disposition", "attachment; filename=" + fileName);
response.setContentType("application/octet-stream;charset=UTF-8");
// 将 InputStream 写入到 HttpServletResponse 的 OutputStream
try (OutputStream out = response.getOutputStream()) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = cosObjectInput.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
} catch (IOException e) {
// 处理异常
e.printStackTrace();
}
在前端进行测试,发现采用这种方式后,下载文件时响应内容的大小会逐渐增加,而不是阻塞半天后一次性得到完整的响应结果。
但是经过测试发现,大文件整体的下载时间并没有得到明显的减少。因为无论是否流式处理,服务器都要先从 COS 对象存储下载文件,再返回给前端。
那么如果要优化,可以不从对象存储下载文件呢?
3、下载优化-本地缓存
答案是肯定的,代码生成器文件的业务特点是 读多写少,是一个典型的缓存适用场景。
其实 CDN 本质上就是一种缓存,如果不想使用 CDN 增加开销的话,可以选用本地缓存。不需要引入额外的存储技术,只需将下载过一次的代码生成器保存在服务器上,之后要下载时,如果服务器已经有下载好的文件,就不用从对象存储获取,直接读取并返回给前端即可。
缓存的 4 个核心要素:
- 缓存哪些内容?
- 缓存如果淘汰?
- 缓存 key 如何设计?
- 如何保证缓存一致性?
首先是缓存哪些内容,不建议每个文件都缓存,原因是难以控制占用的空间、并且还要考虑每个文件的缓存一致性,会增大开发成本。
而且,如果每个文件都缓存,还需要对象存储么?不就有点像自己实现了一个简单的CDN么?
所以此处选择一种相对简单的实现方式:手动设置哪些文件需要缓存,并且可通过接口提前缓存指定文件。
1)在 GeneratorController 中编写一个缓存生成器的接口 cacheGenerator。代码如下:
/**
* 缓存代码生成器
*
* @param generatorCacheRequest
* @param request
* @param response
*/
@PostMapping("/cache")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public void cacheGenerator(@RequestBody GeneratorCacheRequest generatorCacheRequest, HttpServletRequest request, HttpServletResponse response) {
if (generatorCacheRequest == null || generatorCacheRequest.getId() <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
// 获取生成器
long id = generatorCacheRequest.getId();
Generator generator = generatorService.getById(id);
if (generator == null) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
}
String distPath = generator.getDistPath();
if (StrUtil.isBlank(distPath)) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "产物包不存在");
}
// 缓存空间
String zipFilePath = getCacheFilePath(id, distPath);
// 新建文件
if (!FileUtil.exist(zipFilePath)) {
FileUtil.touch(zipFilePath);
}
// 下载生成器
try {
cosManager.download(distPath, zipFilePath);
} catch (InterruptedException e) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "压缩包下载失败");
}
}
2)设计缓存 key
缓存 key 相当于数据的 id,用来唯一标识和查找某个缓存内容。
一般情况下,写入缓存的 key 和读取的 key 是一致的,所以编写一个公共方法来获取缓存 key。
由于没有引入额外的缓存技术,这里的缓存 key 就是文件在服务器上的路径。
代码如下:
/**
* 获取缓存文件路径
*
* @param id
* @param distPath
* @return
*/
public String getCacheFilePath(long id, String distPath) {
String projectPath = System.getProperty("user.dir");
String tempDirPath = String.format("%s/.temp/cache/%s", projectPath, id);
String zipFilePath = String.format("%s/%s", tempDirPath, distPath);
return zipFilePath;
}
3)修改生成器下载接口,优先从缓存获取。修改后的代码如下:
// 追踪事件
log.info("用户 {} 下载了 {}", loginUser, distPath);
// 设置响应头
response.setContentType("application/octet-stream;charset=UTF-8");
response.setHeader("Content-Disposition", "attachment; filename=" + distPath);
// 优先从缓存读取
String zipFilePath = getCacheFilePath(id, distPath);
if (FileUtil.exist(zipFilePath)) {
// 写入响应
Files.copy(Paths.get(zipFilePath), response.getOutputStream());
return;
}
COSObjectInputStream cosObjectInput = null;
try {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
COSObject cosObject = cosManager.getObject(distPath);
cosObjectInput = cosObject.getObjectContent();
// 处理下载到的流
byte[] bytes = IOUtils.toByteArray(cosObjectInput);
stopWatch.stop();
System.out.println(stopWatch.getTotalTimeMillis());
// 写入响应
response.getOutputStream().write(bytes);
response.getOutputStream().flush();
} catch (Exception e) {
log.error("file download error, filepath = " + distPath, e);
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "下载失败");
} finally {
if (cosObjectInput != null) {
cosObjectInput.close();
}
}
4)测试调用
首先执行缓存接口,提前下载文件。然后调用接口下载该文件,使用 F12 网页控制台查看下载接口耗时。
测试发现,使用文件缓存后,接口响应时长大幅缩短!
使用生成器接口
下面优化使用生成器接口,相比生成器下载,这个接口的逻辑更复杂,耗时操作可能包括下载文件、执行脚本、压缩解压等。
1、分析代码耗时
接下来通过 StopWatch 对使用生成器的各个关键操作进行耗时统计,代码如下:
@PostMapping("/use")
public void useGenerator(@RequestBody GeneratorUseRequest generatorUseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException {
// 1)用户在前端输入模型参数
Long id = generatorUseRequest.getId();
Map<String, Object> dataModel = generatorUseRequest.getDataModel();
// 需要登录
User loginUser = userService.getLoginUser(request);
Generator generator = generatorService.getById(id);
if (generator == null) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
}
// 2)从对象存储上下载生成器压缩包,到一个独立的工作空间
String distPath = generator.getDistPath();
if (StrUtil.isBlank(distPath)) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "产物包不存在");
}
// 工作空间
String projectPath = System.getProperty("user.dir");
String tempDirPath = String.format("%s/.temp/use/%s", projectPath, id);
String zipFilePath = tempDirPath + "/dist.zip";
// 新建文件
if (!FileUtil.exist(zipFilePath)) {
FileUtil.touch(zipFilePath);
}
StopWatch stopWatch = new StopWatch();
stopWatch.start();
// 下载文件
try {
cosManager.download(distPath, zipFilePath);
} catch (InterruptedException e) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "生成器下载失败");
}
stopWatch.stop();
System.out.println("下载:" + stopWatch.getTotalTimeMillis());
// 3)解压,得到生成器
stopWatch = new StopWatch();
stopWatch.start();
File unzipDistDir = ZipUtil.unzip(zipFilePath);
stopWatch.stop();
System.out.println("解压:" + stopWatch.getTotalTimeMillis());
// 4)将用户输入的参数写入到 json 文件中
stopWatch = new StopWatch();
stopWatch.start();
String dataModelFilePath = tempDirPath + "/dataModel.json";
String jsonStr = JSONUtil.toJsonStr(dataModel);
FileUtil.writeUtf8String(jsonStr, dataModelFilePath);
stopWatch.stop();
System.out.println("写数据文件:" + stopWatch.getTotalTimeMillis());
// 5)执行脚本,构造脚本调用命令,传入模型参数 json 文件路径,调用脚本并生成代码
// 找到脚本文件所在路径
File scriptFile = FileUtil.loopFiles(unzipDistDir, 2, null).stream()
.filter(file -> file.isFile() && "generator".equals(file.getName()))
.findFirst()
.orElseThrow(RuntimeException::new);
// 添加可执行权限
try {
Set<PosixFilePermission> permissions = PosixFilePermissions.fromString("rwxrwxrwx");
Files.setPosixFilePermissions(scriptFile.toPath(), permissions);
} catch (Exception e) {
}
// 构造命令
File scriptDir = scriptFile.getParentFile();
String[] commands = new String[]{"./generator", "json-generate", "--file=" + dataModelFilePath};
ProcessBuilder processBuilder = new ProcessBuilder(commands);
processBuilder.directory(scriptDir);
try {
stopWatch = new StopWatch();
stopWatch.start();
Process process = processBuilder.start();
// 读取命令的输出
InputStream inputStream = process.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
// 等待命令执行完成
int exitCode = process.waitFor();
System.out.println("命令执行结束,退出码:" + exitCode);
stopWatch.stop();
System.out.println("执行脚本:" + stopWatch.getTotalTimeMillis());
} catch (Exception e) {
e.printStackTrace();
}
// 6)返回生成的代码结果压缩包
// 生成代码的位置
stopWatch = new StopWatch();
stopWatch.start();
String generatedPath = scriptDir.getAbsolutePath() + "/generated";
String resultPath = tempDirPath + "/result.zip";
File resultFile = ZipUtil.zip(generatedPath, resultPath);
stopWatch.stop();
System.out.println("压缩结果:" + stopWatch.getTotalTimeMillis());
// 下载文件
// 设置响应头
response.setContentType("application/octet-stream;charset=UTF-8");
response.setHeader("Content-Disposition", "attachment; filename=" + resultFile.getName());
// 写入响应
Files.copy(resultFile.toPath(), response.getOutputStream());
// 7)清理文件
CompletableFuture.runAsync(() -> {
FileUtil.del(tempDirPath);
});
}
2、优化策略
经过测试发现下载、执行脚本、解压都是耗时操作。
由于生成器脚本是由 maker 制作工具提前生成好的,执行脚本的操作优化可能比较困难。虽然可以优化,比如多线程并发生成文件,但是复杂度过高、优化的性价比不高。
所以,重点优化下载和解压操作。对于频繁使用的生成器,反复下载文件、解压文件是没必要的,可以像下载生成器一样使用缓存,不再赘述。
制作生成器接口
1、分析代码耗时
跟之前一样,先分析代码耗时。代码如下:
try {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
cosManager.download(zipFilePath, localZipFilePath);
stopWatch.stop();
System.out.println("下载文件:" + stopWatch.getTotalTimeMillis());
} catch (InterruptedException e) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "压缩包下载失败");
}
// 3)解压,得到项目模板文件
File unzipDistDir = ZipUtil.unzip(localZipFilePath);
// 4)构造 meta 对象和输出路径
String sourceRootPath = unzipDistDir.getAbsolutePath();
meta.getFileConfig().setSourceRootPath(sourceRootPath);
MetaValidator.doValidAndFill(meta);
String outputPath = String.format("%s/generated/%s", tempDirPath, meta.getName());
// 5)调用 maker 方法制作生成器
GenerateTemplate generateTemplate = new ZipGenerator();
try {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
generateTemplate.doGenerate(meta, outputPath);
stopWatch.stop();
System.out.println("制作:" + stopWatch.getTotalTimeMillis());
} catch (Exception e) {
e.printStackTrace();
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "制作失败");
}
3、优化策略
主要耗时的操作是下载和制作。
可以考虑使用 异步化,将生成器制作封装为一个任务,用户可以通过前端自主查询任务的执行状态并下载制作结果。
三、查询性能优化
刚才主要是对文件下载相关业务场景进行性能优化,下面再优化一个典型的业务场景——数据查询。
一般有以下几个场景:
- 数据需要高频访问
- 数据量较大,查询缓慢
- 数据查询实时性要求高,追求用户体验
对于代码生成项目,主页代码生成器列表的访问频率应该是最高的,而且主页一般也需要有较快的加载速度,所以下面就来优化主要调用的 分页查询生成器接口。
通过这个例子,分享常用的数据查询接口优化方法。
方法一、精简数据
目前,整个系统的数据量不大,先不考虑高并发, 从最简单的优化开始做起,目标是减少接口的响应时长,从而提高页面的加载速度。
1、分析代码耗时
接下来通过 StopWatch 对接口的各个关键操作进行耗时统计,如下:
/**
* 分页获取列表(封装类)
*
* @param generatorQueryRequest
* @param request
* @return
*/
@PostMapping("/list/page/vo")
public BaseResponse<Page<GeneratorVO>> listGeneratorVOByPage(@RequestBody GeneratorQueryRequest generatorQueryRequest,
HttpServletRequest request) {
long current = generatorQueryRequest.getCurrent();
long size = generatorQueryRequest.getPageSize();
// 限制爬虫
ThrowUtils.throwIf(size > 20, ErrorCode.PARAMS_ERROR);
StopWatch stopWatch = new StopWatch();
stopWatch.start();
Page<Generator> generatorPage = generatorService.page(new Page<>(current, size),
generatorService.getQueryWrapper(generatorQueryRequest));
stopWatch.stop();
System.out.println("查询生成器:" + stopWatch.getTotalTimeMillis());
stopWatch = new StopWatch();
stopWatch.start();
Page<GeneratorVO> generatorVOPage = generatorService.getGeneratorVOPage(generatorPage, request);
stopWatch.stop();
System.out.println("查询关联数据:" + stopWatch.getTotalTimeMillis());
return ResultUtils.success(generatorVOPage);
}
经过多次测试,发现 2 个数据库查询操作的总耗时接近 100 毫秒。原因是服务器查到数据后,还需要传输数据给前端,存在一个下载耗时。在网页控制台中查看请求消耗的时间,发现前端下载内容花费了额外的时间。
2、优化
如何减少前端下载内容的时间呢?
很简单,2 种方法:
- 减少后端返回的数据体积,可以减少返回的数据、或压缩数据
- 提高服务器的带宽
很显然,第一种方法的成本是最低的,所以可以尝试精简数据,只让后端返回主页需要展示的数据。像文件配置(fileConfig)、模型配置(modelConfig)都是不必要的。
1)在 GeneratorController 中编写一个新的数据查询接口 listGeneratorVOByPageFast,而不是修改老的接口,便于查看对比效果。
整个接口的逻辑与之前的数据查询一致,只是在查询到数据后,将部分多余的字段值设置为 null,不再返回。
代码如下:
/**
* 快速分页获取列表(封装类)
*
* @param generatorQueryRequest
* @param request
* @return
*/
@PostMapping("/list/page/vo/fast")
public BaseResponse<Page<GeneratorVO>> listGeneratorVOByPageFast(@RequestBody GeneratorQueryRequest generatorQueryRequest,
HttpServletRequest request) {
long current = generatorQueryRequest.getCurrent();
long size = generatorQueryRequest.getPageSize();
// 限制爬虫
ThrowUtils.throwIf(size > 20, ErrorCode.PARAMS_ERROR);
Page<Generator> generatorPage = generatorService.page(new Page<>(current, size),
generatorService.getQueryWrapper(generatorQueryRequest));
Page<GeneratorVO> generatorVOPage = generatorService.getGeneratorVOPage(generatorPage, request);
generatorVOPage.getRecords().forEach(generatorVO -> {
generatorVO.setFileConfig(null);
generatorVO.setModelConfig(null);
});
return ResultUtils.success(generatorVOPage);
}
2)前端修改主页调用的接口,修改的代码如下:
/**
* 搜索
*/
const doSearch = async () => {
setLoading(true);
try {
const res = await listGeneratorVoByPageFastUsingPost(searchParams);
setDataList(res.data?.records ?? []);
setTotal(Number(res.data?.total) ?? 0);
} catch (error: any) {
message.error('获取数据失败,' + error.message);
}
setLoading(false);
};
4、测试
跟之前一样,测试查询 10 次的平均耗时,接口平均响应时长减少了 15%。而且查询的原始数据量越大,优化效果越明显。
方法二、SQL优化
之后向数据量插入 10 万条示例数据(ACM示例模板生成器),模拟真实的系统用量。
可以通过编写单元测试来实现数据的批量插入,读取一条现有数据并循环插入即可,代码如下:
@SpringBootTest
class GeneratorServiceTest {
@Resource
private GeneratorService generatorService;
@Test
public void testInsert() {
Generator generator = generatorService.getById(18L);
for (int i = 0; i < 100000; i++) {
generator.setId(null);
generatorService.save(generator);
}
}
}
1、分析耗时
由于数据量增大导致查询性能降低,因此要做的就是优化数据库的查询。
首先从业务日志中找到对应的 SQL 语句。复制上述语句到 SQL 控制台,补充参数后,连续执行 10 次观察耗时。执行总耗时 1171 毫秒。
2、优化
如何优化数据库查询呢?
最常见的几种方案:
- 减少查询次数,能不查数据库就不查数据库,比如使用缓存
- 优化 SQL 语句
- 添加合适的索引
进行优化时,优先选择成本最低的方案。
第 1 种方案通常要引入额外的技术(缓存),所以等下再考虑
第 3 种方案虽然改动成本不高,但是对于主页的查询,默认并没有任何的查询条件(除了 isDelete = 0),所以也不适合用于添加索引来优化。
所以目前最该做的就是优化 SQL 语句。既然有些字段不需要在主页展示,那么就不需要从数据库种查出这些字段了
修改对应的代码,只查询需要的字段:
QueryWrapper<Generator> queryWrapper = generatorService.getQueryWrapper(generatorQueryRequest);
queryWrapper.select("id","name","description","tags","picture","status","userId","createTime","updateTime");
Page<Generator> generatorPage = generatorService.page(new Page<>(current, size), queryWrapper);
Page<GeneratorVO> generatorVOPage = generatorService.getGeneratorVOPage(generatorPage, request);
return ResultUtils.success(generatorVOPage);
4、测试
优化后,再次进行测试,发现接口的平均响应时间减少了 1/3!
一般来说,单词查询时间超过 100ms、500ms或者 1s算是慢查询。目前接口的性能还可以,能够应对用户量不大的场景。但如果同时使用系统的用户增多、并发量增大呢?又该如何优化系统?
方法三、分布式缓存
想要提升数据的查询性能,最有效的办法之一就是 缓存。把数据放到一个读取更快的存储,而不是每次都是用数据库查询。
缓存尤其适用于 读多写少 的数据,可以最大程度利用缓存、并且减少数据不一致的风险。
对于该项目,生成器的修改频率一般是很低的,而且实际运营时,生成器应该是需要人工审核才能展示到主页,所以对数据更新实时性的要求并不高,使用缓存非常合适。而像实时电商数据大屏这种需要持续展示最新数据的场景,用缓存的成本就比较大了。
此处,我们使用主流的、基于内存的分布式缓存 Redis,来存储生成器分页数据,它的读写性能远超 MySQL。
1、使用缓存
1)引入 Redis
web 项目默认已经引入了 Redis 相关的依赖,直接取消 application.yml 配置中 Redis 的注释,开启 Redis。
代码如下:
spring:
# Redis 配置
redis:
database: 1
host: localhost
port: 6379
timeout: 5000
修改 MainApplication 入口类,取消移除 Redis 的代码:
@SpringBootApplication
然后引入 Spring 为我们提供的 StringRedisTemplate 就能操作 Redis 了:
@Resource
private StringRedisTemplate stringRedisTemplate;
2)缓存 key 设计
先设计缓存的 key,规则为:业务前缀:数据分类:请求参数。
业务前缀和数据分类的作用是为了区分不同业务和接口的缓存,防止冲突。
将请求参数作为 key,就能实现不同的分页查询不同的缓存。需要注意的是,请求参数字符串可能很长,所以选用 base64 进行编码。
编写一个方法,用于获取分页缓存 key。代码如下:
/**
* 获取分页缓存 key
* @param generatorQueryRequest
* @return
*/
private static String getPageCacheKey(GeneratorQueryRequest generatorQueryRequest) {
String jsonStr = JSONUtil.toJsonStr(generatorQueryRequest);
String base64 = Base64Encoder.encode(jsonStr);
String key = "generator:page:" + base64;
return key;
}
3)缓存内容设计
对于分页数据的缓存,可以有如下 2 种策略:
- 缓存整页数据
- 分页的每条数据单独缓存。查询时先获取到 id 集合,再根据 id 集合去批量查询缓存。
此处选择第一种策略,直接缓存接口的返回结果,不仅开发成本更低、性能也更高,但缺点就是不利于分页中某一条数据的更新。
我们先使用 Redis 的 String 数据结构,将分页对象转为 JSON 字符串后写入。相比于 JDK 自带的序列化机制,用 JSON 字符串会使缓存的可读性更好。
4)应用缓存
改造分页查询接口,优先从缓存读取,如果有缓存,直接读取数据并返回;如果没有缓存,才读取数据库查询数据,并写入缓存。
注意,一定要给缓存设置过期时间!
代码如下:
/**
* 快速分页获取列表(封装类)
*
* @param generatorQueryRequest
* @param request
* @return
*/
@PostMapping("/list/page/vo/fast")
public BaseResponse<Page<GeneratorVO>> listGeneratorVOByPageFast(@RequestBody GeneratorQueryRequest generatorQueryRequest,
HttpServletRequest request) {
long current = generatorQueryRequest.getCurrent();
long size = generatorQueryRequest.getPageSize();
// 优先从缓存读取
String cacheKey = getPageCacheKey(generatorQueryRequest);
ValueOperations<String, String> valueOperations = stringRedisTemplate.opsForValue();
String cacheValue = valueOperations.get(cacheKey);
if (StrUtil.isNotBlank(cacheValue)) {
Page<GeneratorVO> generatorVOPage = JSONUtil.toBean(cacheValue,
new TypeReference<Page<GeneratorVO>>() {
},
false);
return ResultUtils.success(generatorVOPage);
}
// 限制爬虫
ThrowUtils.throwIf(size > 20, ErrorCode.PARAMS_ERROR);
Page<Generator> generatorPage = generatorService.page(new Page<>(current, size),
generatorService.getQueryWrapper(generatorQueryRequest));
Page<GeneratorVO> generatorVOPage = generatorService.getGeneratorVOPage(generatorPage, request);
generatorVOPage.getRecords().forEach(generatorVO -> {
generatorVO.setFileConfig(null);
generatorVO.setModelConfig(null);
});
// 写入缓存
valueOperations.set(cacheKey, JSONUtil.toJsonStr(generatorVOPage), 100, TimeUnit.MINUTES);
return ResultUtils.success(generatorVOPage);
}
5、测试
测试发现,使用缓存后,响应时间大幅减少,平均缩短了 80%!
方法五、多级缓存
如果 Redis 缓存还不够快,我们还可以使用本地缓存,直接从内存中读取缓存,不需要任何网络请求,一般能得到进一步的性能提升。
1、Caffeine本地缓存
要想在 Java 中使用本地缓存,推荐使用 Caffeine 库,这是一个主流的、高性能的本地缓存库。相比于自己构造 HashMap, Caffeine还支持多种数据淘汰、数据通知、异步刷新等能力,更易用。
几行代码就能实现缓存的增删改查:
Cache<Key, Graph> cache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(10_000)
.build();
// 查找一个缓存元素, 没有查找到的时候返回null
Graph graph = cache.getIfPresent(key);
// 查找缓存,如果缓存不存在则生成缓存元素, 如果无法生成则返回null
graph = cache.get(key, k -> createExpensiveGraph(key));
// 添加或者更新一个缓存元素
cache.put(key, graph);
// 移除一个缓存元素
cache.invalidate(key);
2、多级缓存设计
对于分布式系统,一般不会单独使用本地缓存,而是将本地缓存和分布式缓存进行组合,形成多级缓存。
就以 Caffeine 和 Redis 为例,通常 Caffeine 作为一级缓存,Redis 作为二级缓存。
1)Caffeine 一级缓存:将数据存储在应用程序的内存中,性能更高。但是仅在本地生效,而且应用程序关闭后,数据会丢失。 2)Redis 二级缓存:将数据存储在 Redis 中,所有的程序都从 Redis 内读取数据,可以实现数据的持久化和缓存的共享。
二者结合,请求数据时,首先查找本地一级缓存;如果在本地缓存中没有查询到数据,再查找远程二级缓存,并且写入到本地缓存;如果还没有数据,才从数据库中读取,并且写入到所有缓存。
使用多级缓存,可以充分利用本地缓存的快速读取特性,以及远程缓存的共享和持久化特性。
3、多级缓存开发
首先在 manager 包下新建一个通用的多级缓存类 CacheManager,并分别编写读取缓存、写入缓存、清理缓存的方法。
代码如下:
/**
* 多级缓存
*/
@Component
public class CacheManager {
@Resource
private StringRedisTemplate stringRedisTemplate;
// 本地缓存
Cache<String, String> localCache = Caffeine.newBuilder()
.expireAfterWrite(100, TimeUnit.MINUTES)
.maximumSize(10_000)
.build();
/**
* 写缓存
*
* @param key
* @param value
*/
public void put(String key, String value) {
localCache.put(key, value);
stringRedisTemplate.opsForValue().set(key, value, 100, TimeUnit.MINUTES);
}
/**
* 读缓存
*
* @param key
* @return
*/
public String get(String key) {
// 先从本地缓存中尝试获取
String value = localCache.getIfPresent(key);
if (value != null) {
return value;
}
// 本地缓存未命中,尝试从 Redis 中获取
value = stringRedisTemplate.opsForValue().get(key);
if (value != null) {
// 将从 Redis 获取的值放入本地缓存
localCache.put(key, value);
}
return value;
}
/**
* 移除缓存
*
* @param key
*/
public void delete(String key) {
localCache.invalidate(key);
stringRedisTemplate.delete(key);
}
}
然后就可以在查询接口中使用多级缓存,代码如下:
/**
* 快速分页获取列表(封装类)
*
* @param generatorQueryRequest
* @param request
* @return
*/
@PostMapping("/list/page/vo/fast")
public BaseResponse<Page<GeneratorVO>> listGeneratorVOByPageFast(@RequestBody GeneratorQueryRequest generatorQueryRequest,
HttpServletRequest request) {
long current = generatorQueryRequest.getCurrent();
long size = generatorQueryRequest.getPageSize();
String cacheKey = getPageCacheKey(generatorQueryRequest);
// 多级缓存
String cacheValue = cacheManager.get(cacheKey);
if (cacheValue != null) {
Page<GeneratorVO> generatorVOPage = JSONUtil.toBean(cacheValue,
new TypeReference<Page<GeneratorVO>>() {
},
false);
return ResultUtils.success(generatorVOPage);
}
// 限制爬虫
ThrowUtils.throwIf(size > 20, ErrorCode.PARAMS_ERROR);
QueryWrapper<Generator> queryWrapper = generatorService.getQueryWrapper(generatorQueryRequest);
queryWrapper.select("id", "name", "description", "tags", "picture", "status", "userId", "createTime", "updateTime");
Page<Generator> generatorPage = generatorService.page(new Page<>(current, size), queryWrapper);
Page<GeneratorVO> generatorVOPage = generatorService.getGeneratorVOPage(generatorPage, request);
// 写入多级缓存
cacheManager.put(cacheKey, JSONUtil.toJsonStr(generatorVOPage));
return ResultUtils.success(generatorVOPage);
}
4、测试
在浏览器简单测试,发现使用本地缓存后,响应时间进一步减少,缩短了 25%。
方法六、计算优化
1、分析
任何计算都会消耗系统的 CPU 资源,在 CPU 资源有限的情况下,能做的就是 减少不必要的计算。
分析代码,基本上没有循环计算逻辑,可能消耗计算资源的操作就是 JSON 序列化(反序列化)。
在 JSON 序列化中,需要遍历数据结构并将其转换为 JSON 格式的字符串。这个过程中可能涉及到字符串拼接、字符编码转换等计算密集型操作。
之前是为了更直观地查看缓存数据,才将对象序列化为 JSON 后写入缓存,现在为了提高性能,可以直接用 JDK 默认地序列化工具读写缓存。
2、开发
1)首先修改 maker 项目中,Meta 对象的所有子类,给它们都添加序列化支持,比如:
@NoArgsConstructor
@Data
public static class FileConfig implements Serializable {
private String inputRootPath;
private String outputRootPath;
private String sourceRootPath;
private String type;
private List<FileInfo> files;
@NoArgsConstructor
@Data
public static class FileInfo implements Serializable {
private String inputPath;
private String outputPath;
private String type;
private String generateType;
private String condition;
private String groupKey;
private String groupName;
private List<FileInfo> files;
}
}
2)修改 CacheManager,将缓存类型从 String 改为 Object。
代码如下:
package com.yupi.web.manager;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
/**
* 多级缓存
*/
@Component
public class CacheManager {
@Resource
private RedisTemplate<String, Object> redisTemplate;
// 本地缓存
Cache<String, Object> localCache = Caffeine.newBuilder()
.expireAfterWrite(100, TimeUnit.MINUTES)
.maximumSize(10_000)
.build();
/**
* 写缓存
*
* @param key
* @param value
*/
public void put(String key, Object value) {
localCache.put(key, value);
redisTemplate.opsForValue().set(key, value, 100, TimeUnit.MINUTES);
}
/**
* 读缓存
*
* @param key
* @return
*/
public Object get(String key) {
// 先从本地缓存中尝试获取
Object value = localCache.getIfPresent(key);
if (value != null) {
return value;
}
// 本地缓存未命中,尝试从 Redis 中获取
value = redisTemplate.opsForValue().get(key);
if (value != null) {
// 将从 Redis 获取的值放入本地缓存
localCache.put(key, value);
}
return value;
}
/**
* 移除缓存
*
* @param key
*/
public void delete(String key) {
localCache.invalidate(key);
redisTemplate.delete(key);
}
}
3)修改查询接口,移除序列化相关代码
代码如下:
@PostMapping("/list/page/vo/fast")
public BaseResponse<Page<GeneratorVO>> listGeneratorVOByPageFast(@RequestBody GeneratorQueryRequest generatorQueryRequest,
HttpServletRequest request) {
long current = generatorQueryRequest.getCurrent();
long size = generatorQueryRequest.getPageSize();
String cacheKey = getPageCacheKey(generatorQueryRequest);
// 本地缓存
Object cacheValue = cacheManager.get(cacheKey);
if (cacheValue != null) {
return ResultUtils.success((Page<GeneratorVO>) cacheValue);
}
// 限制爬虫
ThrowUtils.throwIf(size > 20, ErrorCode.PARAMS_ERROR);
QueryWrapper<Generator> queryWrapper = generatorService.getQueryWrapper(generatorQueryRequest);
queryWrapper.select("id", "name", "description", "tags", "picture", "status", "userId", "createTime", "updateTime");
Page<Generator> generatorPage = generatorService.page(new Page<>(current, size), queryWrapper);
Page<GeneratorVO> generatorVOPage = generatorService.getGeneratorVOPage(generatorPage, request);
// 写入本地缓存
cacheManager.put(cacheKey, generatorVOPage);
return ResultUtils.success(generatorVOPage);
}
3、测试
移除序列化后再次测试,接口的查询性能已经能够满足大多数并发诉求了,但是如果想进行进一步优化,需要怎么做呢?
当然还有放啊。但是下面的仅是优化思路,能否进一步提升性能,要以实际测试为准。
方法七、请求层性能优化
1、参数优化
分析上述测试结果,当并发请求数量超出系统处理能力时,会出现请求排队,而且请求排队最大时间长达 8 秒。
怎么能解决这个问题呢?
用生活场景来类比。就跟去快餐店一样,如果服务员不够了,只要增加工作人员的数量,就能同时服务更多顾客。
而如果餐厅备菜的速度足够快,可以增加排队的最大容量,起码业务繁忙时不用把顾客赶出去。
回到我们的后端系统,如果业务逻辑层很难进一步优化,可以尝试优化请求层。
比如 Spring Boot 项目默认使用嵌入式的 Tomcat 服务器接受处理请求,可以调整 tomcat 的参数,比如最大线程数 maxThreads、最大连接数 maxConnections、请求队列长度 accept-count 等,来增加同时接受处理请求的能力。
比如:
复制代码
server:
tomcat:
threads:
max: 1024
2、Vert.x反应式编程
如果接受请求的服务器性能有限,那我们就尝试更换一个性能更高的服务器(或者请求处理框架),比如基于反应式编程的 Vert.x。
为什么选择它呢?首先它是基于 Java 的。另外,在 techempower 最新的压力测试排行榜上,我们发现 Vert.x 这个框架的排名高达第 7 名!
而 Spring 排名是第 88 名,Vert.x 甩了 Spring 几条街。
不过,这只是别人的测试结果,使用 Vert.x 真的能够提高我们接口的性能么?还是要以实际测试为准。