Java项目性能优化思路

385 阅读23分钟

一、性能优化思路

首先明确性能优化的定义和目标。性能优化是指通过持续的 持续的 分析、实践和测试,确保系统稳定高效运行,从而满足用户的诉求。

性能优化分类

一般情况下,可以把性能优化分为2大类:

1)通用优化

是指一些经典的、对于绝大多数情况都适用的优化策略。比如增大服务器的并发请求处理数、使用缓存减少数据库查询、通过负载均衡分摊请求、同步转异步等。

2)对症下药

是指结合具体的业务特性和系统现状,先通过性能监控工具、压力测试等方式,分析出系统的性能瓶颈,再针对性地选取策略进行优化。

比如数据库单次查询超过 1 秒,属于慢查询,根据实际地查询条件给对应的字段增加索引,一般就能提高查询性能。

通用性能优化手段

有哪些通用的性能优化手段呢?

我们以一个请求的完整生命周期为例,依次介绍。

通常,用户从发起请求,到最终得到数据,要分别经过以下节点:

image.png

每个节点,都有对应的优化方法:

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 个核心要素:

  1. 缓存哪些内容?
  2. 缓存如果淘汰?
  3. 缓存 key 如何设计?
  4. 如何保证缓存一致性?

首先是缓存哪些内容,不建议每个文件都缓存,原因是难以控制占用的空间、并且还要考虑每个文件的缓存一致性,会增大开发成本。

而且,如果每个文件都缓存,还需要对象存储么?不就有点像自己实现了一个简单的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. 数据需要高频访问
  2. 数据量较大,查询缓慢
  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 种方法:

  1. 减少后端返回的数据体积,可以减少返回的数据、或压缩数据
  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、优化

如何优化数据库查询呢?

最常见的几种方案:

  1. 减少查询次数,能不查数据库就不查数据库,比如使用缓存
  2. 优化 SQL 语句
  3. 添加合适的索引

进行优化时,优先选择成本最低的方案。

第 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 种策略:

  1. 缓存整页数据
  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 真的能够提高我们接口的性能么?还是要以实际测试为准。