从 1.8 版开始,Spring 数据项目包含一个有趣的特性——通过一个简单的 API 调用,开发人员可以请求数据库查询结果作为 Java 8 流返回。在技术上可行并得到底层数据库技术支持的情况下,结果将被逐一流式传输,并可用于使用流操作进行处理。此技术在处理大型数据集(例如,以特定格式导出大量数据库数据)时特别有用,因为除其他外,它可以限制应用程序处理层的内存消耗。在本文中,我将讨论 Spring Data 流与 MySQL 数据库一起使用时的一些好处(和陷阱!)。

从数据库中获取和处理大量数据(更大,我的意思是不适合正在运行的应用程序内存的数据集)的简单方法通常会导致内存不足。在使用 ORM / 抽象层(例如 JPA)时尤其如此,您无法访问允许您手动管理如何从数据库中获取数据的较低级别的设施。通常,至少对于我通常使用的堆栈 - MySQL、Hibernate/JPA 和 Spring Data - 大型查询的整个结果集将完全由 MySQL 的 JDBC 驱动程序或之后出现的上述框架之一获取。如果结果集足够大,这将导致 OutOfMemory 异常。

使用分页的解决方案

让我们关注一个示例 - 将大型查询的结果导出为 CSV 文件。当遇到这个问题并且我想留在 Spring Data/JPA 世界时,我通常会选择分页解决方案。查询被分解为较小的查询,每个查询返回一页结果,每个结果的大小有限。Spring Data 提供了很好的分页/切片功能,使这种方法易于实现。Spring Data 的 PageRequests 被转换为 MySQL 中的限制/偏移查询。不过有一些警告。使用 JPA 时,实体会缓存在 EntityManager 的缓存中。需要清除此缓存以使垃圾收集器能够从内存中删除旧的结果对象。

让我们看看分页策略的实际实现在实践中是如何表现的。出于测试目的,我将使用一个基于 Spring Boot、Spring Data、Hibernate/JPA 和 MySQL 的小型应用程序。这是一个待办事项列表管理 web 应用程序,它具有将所有待办事项下载为 CSV 文件的功能。Todos 存储在单个 MySQL 表中。该表已填满 100 万个条目。这是分页/切片导出功能的代码:

@RequestMapping(value = "/todos2.csv", method = RequestMethod.GET)
public void exportTodosCSVSlicing(HttpServletResponse response) {
	final int PAGE_SIZE = 1000;
	response.addHeader("Content-Type", "application/csv");
	response.addHeader("Content-Disposition", "attachment; filename=todos.csv");
	response.setCharacterEncoding("UTF-8");
	try {
		PrintWriter out = response.getWriter();
		int page = 0;
		Slice<Todo> todoPage;
		do {
			todoPage = todoRepository.findAllBy(new PageRequest(page, PAGE_SIZE));
			for (Todo todo : todoPage) {
				String line = todoToCSV(todo);
				out.write(line);
				out.write("\n");
			}
			entityManager.clear();
			page++;
		} while (todoPage.hasNext());
		out.flush();
	} catch (IOException e) {
		log.info("Exception occurred " + e.getMessage(), e);
		throw new RuntimeException("Exception occurred while exporting results", e);
	}
}

这是导出操作正在进行时内存使用情况的样子:

![在这里插入图片描述]()


内存使用图呈锯齿形:内存使用量随着从数据库中获取条目而增长,直到 GC 启动并清理已经输出并从 EntityManager 缓存中清除的条目。分页方法效果很好,但它肯定有改进的空间:

  • 我们发出 1000 个数据库查询(条目数 / PAGE_SIZE)来完成导出。如果我们能避免执行这些查询的开销会更好。
  • 您是否注意到随着出口的进行和峰间距离的增加,图中齿的上升斜率越来越不陡峭?似乎从数据库中获取新整体的速度越来越慢。原因是 MySQL 的限制/偏移性能特性 - 随着偏移变大,查找和返回所选行需要越来越多的时间。

我们可以使用 Spring Data 1.8 中提供的新流功能改进上述内容吗?我们试试吧。

Spring Data 1.8 中的流式处理功能

Spring Data 1.8 引入了对流式结果集的支持。存储库现在可以声明返回 Java 8 实体对象流的方法。例如,现在可以将具有以下签名的方法添加到存储库:

@Query("select t from Todo t")
Stream<Todo> streamAll();

Spring Data 将使用特定于特定 JPA 实现(例如 Hibernate、EclipseLink 等)的技术来流式传输结果集。让我们使用此流功能重新实现 CSV 导出:

@RequestMapping(value = "/todos.csv", method = RequestMethod.GET)
@Transactional(readOnly = true)
public void exportTodosCSV(HttpServletResponse response) {
	response.addHeader("Content-Type", "application/csv");
	response.addHeader("Content-Disposition", "attachment; filename=todos.csv");
	response.setCharacterEncoding("UTF-8");
	try(Stream<Todo> todoStream = todoRepository.streamAll()) {
		PrintWriter out = response.getWriter();
		todoStream.forEach(rethrowConsumer(todo -> {
			String line = todoToCSV(todo);
			out.write(line);
			out.write("\n");
			entityManager.detach(todo);
		}));
		out.flush();
	} catch (IOException e) {
		log.info("Exception occurred " + e.getMessage(), e);
		throw new RuntimeException("Exception occurred while exporting results", e);
	}
}

我像往常一样开始导出,但是结果没有显示出来。发生了什么?

看来我们的内存已经用完了。此外,没有结果写入HttpServletResponse. 为什么这不起作用?在深入研究源代码后org.springframework.data.jpa.provider.PersistenceProvider可以发现 Spring Data 正在使用可滚动的结果集来实现结果集流。谷歌搜索可滚动结果集和 MySQL 表明使用它们时存在陷阱。例如,这里引用MySQL 的 JDBC 驱动程序文档

默认情况下,ResultSet 被完全检索并存储在内存中。在大多数情况下,这是最有效的操作方式,并且由于 MySQL 网络协议的设计,更容易实现。如果您正在使用具有大量行或大值的 ResultSet,并且无法在 JVM 中为所需的内存分配堆空间,您可以告诉驱动程序一次将结果流回一行。要启用此功能,请按以下方式创建 Statement 实例:

stmt = conn.createStatement(java.sql.ResultSet.TYPE_FORWARD_ONLY,java.sql.ResultSet.CONCUR_READ_ONLY); stmt.setFetchSize(Integer.MIN_VALUE);

只进、只读结果集与 Integer.MIN_VALUE 提取大小的组合用作驱动程序逐行流式传输结果集的信号。在此之后,将逐行检索使用该语句创建的任何结果集。

这种方法有一些注意事项。您必须先读取结果集中的所有行(或将其关闭),然后才能对连接发出任何其他查询,否则将引发异常。

好吧,似乎在使用 MySQL 时,为了真正流式传输结果,我们需要满足三个条件:

  • 只进结果集
  • 只读语句
  • Fetch-size 设置为 Integer.MIN_VALUE

Spring Data 似乎已经设置了 Forward-only,因此我们不必为此做任何特别的事情。我们的代码示例已经有@Transactional(readOnly = true)足够满足第二个条件的注释。似乎缺少的是获取大小。我们可以使用存储库方法上的查询提示来设置它:

...
import static org.hibernate.jpa.QueryHints.HINT_FETCH_SIZE;

@Repository
public interface TodoRepository extends JpaRepository<Todo, Long> {

	@QueryHints(value = @QueryHint(name = HINT_FETCH_SIZE, value = "" + Integer.MIN_VALUE))
	@Query(value = "select t from Todo t")
	Stream<Todo> streamAll();
	
	...
}

有了查询提示,让我们再次运行导出:

现在一切正常,而且它似乎比分页方法更有效:

  • 流式传输时,导出在大约9 秒内完成,而使用分页时大约137 秒
  • 当数据集足够大时,似乎偏移性能、查询开销和结果预加载确实会损害分页方法

结论

  • 在使用流式传输(通过可滚动的结果集)与分页时,我们已经看到了显着的性能改进,诚然,在导出数据的非常具体的任务中。
  • Spring Data 的新特性通过流提供了对可滚动结果集的非常方便的访问。
  • 有一些陷阱可以让它与 MySQL 一起工作,但它们是易于管理的。
  • 在 MySQL 中读取可滚动结果集时还有进一步的限制——在完全读取结果集之前,不得通过同一数据库连接发出任何语句。
  • 导出工作正常,因为我们将结果直接写入HttpServletResponse. 如果我们使用默认 Spring 的消息转换器(例如从控制器方法返回流),那么很有可能不会按预期工作。这是一篇关于这个主题的有趣文章。

我很想尝试使用其他数据库进行测试,并通过上面链接的文章中的 Spring 消息转换器探索流式传输结果的可能性。如果您想自己进行实验,可以在 github 上找到测试应用程序。我希望你觉得这篇文章很有趣,我欢迎你在下面的评论部分发表评论。