11.2 JPA

JPA 的性能直接受底层 JDBC 驱动程序的影响,大多数影响 JDBC 驱动程序性能的因素都同样作用于 JPA。除此之外,JPA 的性能还受一些额外因素的影响。

通过调整实体类的字节码能够实现很多的 JPA 性能提升。在 Java EE 的环境中,这种性能的提升是无缝透明的。在 Java SE 的环境中,确保使用正确的字节码的处理方式是非常重要的。否则,JPA 应用程序的性能可能是无法预测的:期望推迟载入的字段可能很早就加载了,保存到数据库的数据可能是冗余的,期望保持在 JPA 缓存中的数据可能还需要从数据库中再次提取,等等。

为 JPA 特别定制的字节码处理方法并不存在。通常情况下,这是作为编译过程的一部分完成的——实体类完成编译后(在它们被载入到 JAR 文件、或者由 JVM 开始运行之前),它们被传递给与具体实现相关的后处理器(postprocessor),对字节码进行“增强”,最终生成一个替换类,这个类按照需要进行了优化。

有的 JPA 实现还提供了对字节码的动态优化机制,可以在类装载到 JVM 的过程中对其进行优化。这种方式需要在 JVM 内部运行一个代理,类载入到 JVM 时,代理会插入到类的载入过程中,在他们被用于定义类之前,对这些字节码进行修改。我们可以在应用程序的命令行指定使用代理,例如对于 EclipseLink,你可以添加 -javaagent:path_to/eclipselink.jar 到命令行参数列表中。

11.2.1 事务处理

JPA 同时适用于 Java SE 和 Java EE 的应用。应用运行的平台会影响 JPA 事务的处理方式。

Java EE 中,JPA 事务是应用服务器的 Java 事务 API(Java Transaction API,JTA)实现的组成部分。这种设计提供了两种实现事务的选择:可以由应用服务器来处理边界(使用容器管理事务,即 Container Managed Transactions,CMT),或者由应用程序通过编程显式地控制事务边界(使用用户管理事务,即 User-Managed Transaction,UMT)。

等效使用时,CMT 和 UMT 在性能上没有显著的差异。然而,它们并不总是可以等效地使用的。尤其是跟 CMT 对比起来,UMT 的范畴变化很大,而这些对性能的影响非常显著。我们以下面的伪代码为例讲解说明:

  1. @Stateless
  2. public class Calculator {
  3. @PersistenceContext(unitName="Calc")
  4. EntityManager em;
  5. @TransactionAttribute(REQUIRED)
  6. public void calculate() {
  7. Parameters p = em.find(...);
  8. ……进行昂贵的计算……
  9. em.persist(...answer...);
  10. }
  11. }

这里事务作用的范围(使用 CMT 的情况)是整个方法。如果该方法要求对持久化的数据具备重复读语义,表中的数据在昂贵的计算过程中就会被锁定。

采用 UMT 方式会更加灵活:

  1. @Stateless
  2. public class Calculator {
  3. @PersistenceContext(unitName="Calc")
  4. EntityManager em;
  5. public void calculate() {
  6. UserTransaction ut = ……在应用服务器中查找UT……;
  7. ut.begin();
  8. Parameters p = em.find(...);
  9. ut.commit();
  10. ……进行昂贵的计算……
  11. ut.begin();
  12. em.persist(...answer...);
  13. ut.commit();
  14. }
  15. }

切分事务可以限制事务对应用程序扩展性的影响,而这只能通过 UMT 方式实现。严格来说,使用 CMT 也能够实现类似功能,不过作业需要切分到三个不同的方法中,每个方法使用不同的事务属性。总体而言,采用 UMT 方式要方便得多。

类似地,通过 UMT 的 servlet 可以创建跨多个方法调用的事务访问 EJB。要想使用 CMT 达成同样的目的,你需要向 EJB 的接口中加入一个新的元方法(metamethod),用于调用同一事务中的其他方法。

在 Java SE 应用中,实体管理器(entity manager)负责提供事务对象,而应用负责划分事务对象的边界。使用 JPA 保存股票价格到数据库的例子包含下面的代码:

  1. public void run() {
  2. for (int i = startStock; i < numStocks; i++) {
  3. EntityManager em = emf.createEntityManager();
  4. EntityTransaction txn = em.getTransaction();
  5. txn.begin();
  6. while (!curDate.after(endDate)) {
  7. StockPrice sp = createRandomStock(curDate);
  8. if (sp != null) {
  9. em.persist(sp);
  10. for (int j = 0; j < 5; j++) {
  11. StockOptionPriceImpl sop =
  12. createRandomOption(sp.getSymbol, sp.getDate());
  13. em.persist(sop);
  14. }
  15. }
  16. curDate.setTime(curDate.getTime() + msPerDay);
  17. }
  18. txn.commit();
  19. em.close();
  20. }
  21. }

与 JDBC 中观察到的事务行为类似,使用 JPA 时,在事务提交的频率以及自然事务边界的定义上也存在很多效率上的取舍。这个例子中的选择事务提交时机的考量会在下一节中展开讨论。

XA 事务

JPA 实体会频繁参与到 XA 事务中;这里解释一下,XA 事务是使用了多个数据库资源,或者同时使用了数据库及其他事务资源(譬如 JMS)的事务。

跨多个不同事务资源提交事务是一种非常昂贵的操作——为了提交数据,完成这种操作的算法通常都要实现可扩展架构(eXtended Architecture,XA)标准。这是一种非常智能和复杂的操作,它要求在事务涉及的各个资源之间有多次的交换往返。

这种情况下,大多数的 Java EE 应用服务器都支持一种优化,借此最后的资源能绕过正常的 XA 协议。这种优化有多个名称,譬如最终代理优化(Last Agent Optimization,LAO)、日志最终资源(Logging Last Resource,LLR)优化、最终资源提交优化(Last Resource Commit Optimization,LRCO)、最终资源开局(Last Resource Gambit),或者其他名字。

从技术上来说,这些优化并不完全相同。尤其是,如果最终的代理能够存储 XA 日志的 JDBC 资源,LLR 和 LRCO 优化就能提供完全的 ACID1 兼容性。有的 LAO 实现能达到这一标准,有的目前还做不到。如果 JPA 数据库能用于存放支持 LAO 的应用服务器的事务日志,事务的处理就会显著加速,因为数据库的更新不再需要使用 XA 协议。它们同时也是 ACID 兼容的。

如果具体的 LAO 实现并未像上述那样存放事务日志,它仍然具备巨大的性能优势——不过要注意的是,如果在事务进行中包括了这样的资源,一旦发生崩溃,这些崩溃是无法自动恢复的。这时,人力的加入就变得必不可少,管理员需要查看挂起的事务以及最近提交的事务,手工地回滚一些事务以重建数据的一致性。

1ACID 的全称为 Atomicity Consistency Isolation Durability,即原子性、一致性、隔离性和持久性,详见 http://www.fredosaurus.com/notes-db/transactions/acid.html。——译者注

 

11.2 JPA - 图1 快速小结

1. 采用 UMT 显式地管理事务的边界通常能提升应用程序的性能。

2. 默认的 Java EE 编程模型——Servlet 或者 Web Service 通过 EJB 访问 JPA 实体——很容易支持这种模式。

3. 还有另一种替代方案,即可以按照应用程序的事务需要,将 JPA 的逻辑划分到不同的方法中处理。

11.2.2 对JPA的写性能进行优化

在 JDBC 中,我们关注两类关键的性能技巧:重用预处理语句和通过批处理进行更新。

使用 JPA 有可能同时实现这两方面的优化,但是如何优化取决于使用 JPA 的具体实现;JPA 的应用程序接口并没有提供相应的调用。对于 Java SE 而言,典型的优化方法是修改应用程序的 persistence.xml 文件,以调整某个属性的设置。

尽量减少写入的字段

优化数据库的写操作性能的一个比较通用的方式是只更新那些已经变化的字段。比如,在一个人力资源系统中,使我的薪水翻倍所使用的代码会访问我们员工记录中的二十个字段,但是只有一个字段(这点非常重要)要写回到数据库。

JPA 的实现应该透明地完成这种优化。JPA 需要提供一种机制来记录代码中的哪些值发生了改变,这是为什么 JPA 字节码必须增强的原因之一。JPA 字节码适当增强后,用于记录我加薪记录的 SQL 语句就只会更新那个变化的字段。

譬如,使用 JPA 的 EclipseLink 应用实现时,通过在 persistence.xml 文件中添加下面的属性可以开启语句重用。

  1. <property name="eclipselink.jdbc.cache-statements" value="true" />

注意,该选项是在 EclipseLink 实现的范围内开启语句重用的。如果 JDBC 驱动程序能够提供语句池(statement pool),通常建议在驱动程序级别开启语句缓存而不是在 JPA 的配置中进行这样的设置。

添加下面的属性可以在引用 JPA 实现中开启语句批处理(statement batching):

  1. <property name="eclipselink.jdbc.batch-writing" value="JDBC" />
  2. <property name="eclipselink.jdbc.batch-writing.size" value="10000" />

JDBC 驱动程序无法自动实现语句批处理,所以该选项在所有的场景中都非常有帮助。批处理可以从两个方面进行控制:首先可以设置 size 属性,就如上面的例子所述。其次,应用可以定期地调用实体管理器的 flush() 方法,调用该方法会立即触发所有的批处理语句执行。

表 11-2 展示了使用语句重用和批处理向数据库创建和写入股票实体的效果。

表11-2:使用JPA插入128条股票记录的耗时对比

编程模式 消耗时间(秒)
不使用批处理和语句池 240
不使用批处理,使用语句池 200
使用批处理,不使用语句池 23.37
同时使用批处理和语句池 21.08

11.2 JPA - 图2 快速小结

1. JPA 应用和 JDBC 应用一样,受益于对数据库写操作调用的次数限制(有时还需要权衡是否持有事务锁)。

2. 语句缓存可以在 JPA 层面实现,也可以在 JDBC 层面实现。不过,我们应该首先使用 JDBC 层面的缓存。

3. 批量的 JPA 更新可以通过声明(在 persistence.xml 文件中)实现,也可以通过编程方式(通过调用 flush() 方法)实现。

11.2.3 对JPA的读性能进行优化

优化 JPA 的读操作,确定何时以及如何从数据库中读取数据是非常复杂的,远不像看起来那么简单。原因在于为了满足将来潜在的请求,JPA 会缓存数据。对提高性能而言,这通常是个不错的设计,但是它也意味着由 JPA 生成的用于读取数据的 SQL 语句,从数据量的角度看似乎并不太令人满意。数据检索针对 JPA 缓存的情况进行优化,而非针对正在进行的请求进行优化。

关于缓存的细节,我们在下一节会深入讨论。现在先看看对 JPA 进行数据库查询优化的基本方式。JPA 在三种情况下需要从数据库中读取数据,分别是:EntityManagerfind() 方法被调用时;一个 JPA 查询执行时;代码访问一个新的实体,该实体会使用现有实体的一些关系时。就股票类而言,它属于最后一种情况,它在股票实体上调用了 getOptions() 方法。

调用 find() 方法是最直观的情况:这时只涉及一行记录,该行记录(至少一行)会从数据库中读取。这里能控制的是有多少数据会返回。JPA 可以只返回该行中的某些字段,也可以返回整行的数据,它还可以预取与正在处理的行相关的其他实体数据。这些优化都能够应用到查询上。有两种可能的优化途径:读更少的数据(因为这些数据都不需要),或者一次读取更多的数据(因为这些数据将来一定会被访问)。

1. 读取更少的数据

为了读取更少的数据,你需要指定哪些字段要延迟载入。查询实体时,被声明为延迟载入的字段会从查询载入数据的 SQL 语句中移除。如果该字段的 getter 方法被执行,这意味着需要再次查询数据库才能取得该字段的数据。

我们很少在基本类型的简单列上使用该声明,不过如果实体包含大型的 BLOB 或者 CLOB 对象,就需要考虑是否使用这种声明了。

  1. @Lob
  2. @Column(name ="IMAGEDATA")
  3. @Basic(fetch = FetchType.LAZY)
  4. private byte[] imageData;

在这个例子中,实体映射到了一个存储二进制图像数据的表中。二进制数据非常大,因此例子认为这部分数据除非有真实的需要,否则没有必要载入。不载入不需要的数据在这里带来了两个好处:查询实体时,SQL 的运行速度更快了;除此之外,它还节省了大量内存,直接减轻了垃圾回收的压力。

提取组(Fetch Groups)

如果实体有些字段被定义为延迟载入(lazy load),通常它们会在需要访问时一次一个地被载入。

如果实体中有多个字段(譬如三个)都被定义为延迟载入,如果一个字段被访问,它们有可能都被访问吗?如果答案是确定的,最好一次性载入所有延迟载入的字段。

以上并不是 JPA 标准的一部分,但是大多数的 JPA 实现都允许自行定义提取组来完成这样的任务。使用提取组,我们可以指定哪些延迟载入的字段可以作为一个群组,一旦这个群组中的一个成员被访问,整个群组都会被载入。典型的情况下,我们可以定义多个相互独立的字段群组,每个群组都可以在需要的时候载入。

由于不是 JPA 的标准,所以使用提取组的代码都依赖于特定的 JPA 实现。但是如果有需要,有关的细节请查阅你使用的 JPA 实现文档。

也请注意,延迟的声明最终只是对 JPA 实现的建议。JPA 实现可以自由决定是否要对数据采用主动载入,或者延迟载入。

另一方面,可能有些数据需要提前载入——譬如,获取一个实体时,其他(相关)的实体也应该返回。这种情况被称为预取(eager fetching),它使用类似的注释:

  1. @OneToMany(mappedBy="stock", fetch=FetchType.EAGER)
  2. private Collection<StockOptionPriceImpl> optionsPrices;

如果实体之间的关系类型定义是 @OneToOne 或者 @ManyToOne,默认情况下这些相关实体就会被主动载入(相反的优化可以采取类似的方式:如果实体几乎不会被使用,可以将它们标记为 FetchType.LAZY)。

对 JPA 实现而言,这也只是一种建议,但它本质上想要表达的是,任何时候,我们在提取股票价格实体的同时,应该确保所有相关期权价格也同时被返回。有一点需要注意:通常我们认为主动方式的载入在生成的 SQL 中会使用 JOIN。但是典型的 JPA 供应商都没有使用这种方式:它们会执行一条 SQL 查询取得主要的对象,紧接着再执行一条或多条 SQL 命令取得其他相关的对象。如果你使用 find() 方法,这里就没有任何限制:如果需要使用 JOIN 语句,你可以自己构造查询,并在程序的查询中调用 JOIN 查询。

2. 在查询中使用JOIN

JPQL 不允许你指定返回对象的哪些字段。以下面的 JPQL 查询为例:

  1. Query q = em.createQuery("SELECT s FROM StockPriceImpl s");

这个查询产生的是下面的这条 SQL 语句:

  1. SELECT <enumerated list of non-LAZY fields> FROM StockPriceTable

如果你希望在生成的 SQL 中返回更少的字段,没有其他的办法,只能将它们标记为延迟载入类型。同样地,对标记为 lazy 的字段,没有其他选项可以在查询中返回它们的内容。

如果实体间有关系,这些实体可以在 JPQL 中使用显式的联合查询,一次性地返回初始实体以及与它相关的其他实体。以前文的股票实体为例,我们可以构造下面的查询:

  1. Query q = em.createQuery("SELECT s FROM StockOptionImpl s " +
  2. "JOIN FETCH s.optionsPrices");

这条 JPQL 会生成类似下面的 SQL 语句:

  1. SELECT t1.<fields>, t0.<fields> FROM StockOptionPrice t0, StockPrice t1
  2. WHERE ((t0.SYMBOL = t1.SYMBOL) AND (t0.PRICEDATE = t1.PRICEDATE))

联合查询(JOIN FETCH)的其他机制

很多 JPA 的提供商允许通过在查询上设置查询提示(Query Hint)指定进行联合查询。譬如,在 EclipseLink 中,下面的代码会生成 JOIN 查询:

  1. Query q = em.createQuery("SELECT s FROM StockOptionImpl s");
  2. q.setQueryHint("eclipselink.join-fetch", "s.optionsPrices");

有的 JPA 提供商还提供了特殊的 @JoinFetch 注释,可用于这种关系的定义。

不同的 JPA 供应商生成的具体 SQL 语句可能略有不同(本例使用的是 EclipseLink),但是通用的流程都大同小异。

对实体关系(Entity Relationship)而言,无论它们被注解为主动载入还是延迟载入,都可以使用联合查询。如果 join 应用于延迟载入关系的实体,且注释为延迟载入的实体满足查询条件,这部分实体也会从数据库中返回,且这部分实体在将来使用时,不需要再次访问数据库。

联合查询返回的所有数据都被使用时,其带来的性能提升效果最显著。然而,联合查询还会以一些难以预期的方式和 JPA 缓存进行交互。这种情况在介绍缓存的一节将会举例阐述;正式使用联合查询编写自定义的查询之前,请确保你已经完全理解各种可能的后果。

3. 批处理和查询

JPA 查询的处理和 JDBC 查询一样,都会产生结果集:JPA 的实现提供了多种选择,可以一次取得所有的数据,或者随着应用程序遍历整个查询结果,每次取得一条记录,或者每次读取若干个结果(类似于 JDBC 提取大小那样工作)。

标准并未规定这些情况如何处理,但是 JPA 的提供商们大都有自己的专有机制设置提取大小。譬如,EclipseLink 中对查询设置了 hint 来指定提取大小,如下所示:

  1. q.setHint("eclipselink.JDBC_FETCH_SIZE", "100000");

与此相反,Hibernate 则提供了自定义的 @BatchSize 声明来设定提取大小。

如果有超大量的数据正在处理,代码可能需要对返回的列表进行分页处理。这与数据如何在网页上呈现给用户有天然的联系:刚开始时,数据的一个子集呈现在页面上(譬如 100 行记录),随着点击“向后”、“向前”的页面链接,我们就能通过页面浏览所有的数据,这些都是通过对查询设置返回区间实现的。

  1. Query q = em.createNamedQuery("selectAll");
  2. query.setFirstResult(101);
  3. query.setMaxResults(100);
  4. List<? implements StockPrice> = q.getResultList();

这个查询返回的是用于呈现在 Web 应用第二页的列表数据:101 号到 200 号条目。相对于提取 200 行数据,之后再丢弃前 100 行而言,只提取这个区间的数据更加高效。

注意,这个例子使用了命名查询(通过 createNamedQuery() 方法),没有使用即时查询(即 createQuery() 方法)。在很多 JPA 实现中,命名查询的运行速度都更快:JPA 的实现几乎都结合语句缓存池使用了带绑定参数的预处理语句。没有任何规定禁止 JPA 实现使用类似于匿名或即时查询应用的逻辑,只不过这种情况实现的难度会更大,而 JPA 的实现可能仅仅是每次创建一个新的语句(即一个 Statement 对象)。

11.2 JPA - 图3 快速小结

1. JPA 会进行多种优化,以限制(或增加)一次读取操作所返回的数据量。

2. JPA 实体中,如果一个大型字段(譬如 BLOB 类型的字段)很少被使用,就应该延迟载入。

3. JPA 实体之间存在关系时,相关的数据可以主动载入或者延迟载入,具体的选择取决于应用程序的需要。

4. 采用主动载入关系时,可以使用命名查询生成一个使用 JOIN 的 SQL 语句。应注意的是,这会影响 JPA 缓存,因此并不总是最好的主意(下一节会讨论它的影响)。

5. 使用命名查询读取数据比普通的查询要快很多,因为 JPA 实现为命名查询构造 PreparedStatement 更容易。

11.2.4 JPA缓存

Java 与性能相关的最经典的用例之一是它提供了一种机制可以充当中间层,缓存后端数据库返回的数据。从架构的角度看,Java 层完成了几个重要的功能(譬如,避免客户端直接访问数据库)。从性能的角度出发,在 Java 层缓存频繁使用的数据能极大地加速客户端的响应时间,从而改善用户体验。

JPA 从设计之初就秉持了这样的架构设计。在 JPA 中包含了两类缓存。每个实体管理器实例都有自己的缓存:它会在本地缓存事务中取得的数据。与此同时,它还会在本地缓存事务中写入的数据;只有在事务提交时,这些缓存的数据才会发送给数据库。一个程序可能包含多个不同的实体管理器实例,每个管理器执行着不同的事务,每个也都有自己的本地缓存(尤其是,插入到 Java EE 应用中的实体管理器都是相互独立的实例)。

实体管理器提交事务时,本地缓存中的所有数据会合并到全局缓存中。全局缓存对应用程序的所有实体管理器而言是共享的。全局缓存也被称为二级缓存(L2 Cache)或者是二层缓存(Second-Level Cache);而实体管理器中的缓存被称为一级缓存(L1 Cache)或者是一层缓存(First-Level Cache)。

实体管理器的事务缓存基本上不需要进行调优,且在所有的 JPA 实现中,L1 缓存默认都是开启的。L2 缓存则稍有不同:大多数 JPA 实现只提供了一个缓存,而不是默认开启所有的缓存(譬如,Hibernate 默认并未开启所有的缓存,不过 EclipseLink 默认就启动了所有的缓存)。一旦开启 L2 缓存,它的调优和使用就会极大地影响应用的性能。

JPA 缓存只在通过它们的主键访问实体时有效,即通过调用 find() 方法返回对象,或者通过访问相关实体(或者主动载入)得到对象。实体管理器尝试通过它的主键或者关系映射查找对象时,首先会在 L2 缓存中查找,如果找到满足条件的对象就直接返回,从而节省了访问数据库的开销。

一般通过查询返回的对象不会在 L2 缓存中保存。有些 JPA 实现会提供自己独特的机制对查询的结果进行缓存,但是这些结果只有在几乎完全相同的查询再次执行时才能重用。即使 JPA 的实现支持查询缓存,实体自身也不会保存在 L2 缓存中,因此无法在之后调用 find() 方法时返回。

L2 缓存、查询以及对象的载入之间关系紧密,多种方式都会影响应用程序的性能。为了说明这些关系,我们使用下面的循环代码进行例证:

  1. EntityManager em = emf.createEntityManager();
  2. Query q = em.createNamedQuery(queryName);
  3. List<StockPrice> l = q.getResultList();
  4. for (StockPrice sp : l) {
  5. …… 处理sp ……
  6. if (processOptions) {
  7. Collection<? extends StockOptionPrice> options = sp.getOptions();
  8. for (StockOptionPrice sop : options) {
  9. …… 处理sop ……
  10. }
  11. }
  12. }
  13. em.close();

➊ SQL Call Site 1

➋ SQL Call Site 2

由于 L2 缓存的存在,循环的第一次执行与之后的执行(通常会更快)的路径是不一样的。具体性能上的差异取决于查询的细节以及实体关系。接下来的几个子节会详细探讨这些结果的差异。

这个例子中的差异主要源于 JPA 配置的不同,但也包括一些测试的运行并未遍历 StockStockOptions 类之间的关系。这些没有遍历关系的测试中,processOptions 在循环中的值为 false;因此实际在使用的只有 StockPrice 对象。

1. 默认缓存(延迟载入)

示例代码中,股票价格通过命名查询载入。默认情况下都会使用这种简单的查询载入股票数据:

  1. @NamedQuery(name="findAll",
  2. query="SELECT s FROM StockPriceImpl s ORDER BY s.id.symbol")

StockPrice 类与 StockOptionPrice 之间通过 optionPrices 实例变量有 @OneToMany 的关系:

  1. @OneToMany(mappedBy="stock")
  2. private Collection<StockOptionPrice> optionsPrices;

@OneToMany 关系默认采用延迟载入机制。表 11-3 展示了执行这个循环所消耗的时间。

表11-3:读取128支股票数据消耗的时间(默认配置)

测试用例 首次执行 后续执行
延迟载入 61.9 秒(33 409 次 SQL 调用) 3.2 秒(1 次 SQL 调用)
延迟载入,不遍历 5.6 秒(1 次 SQL 调用) 2.8 秒(1 次 SQL 调用)

示例循环首次运行这种场景(读取 128 支股票一年的数据)时,JPA 代码会调用 executeQuery() 方法执行一条 SQL 语句。该语句会执行代码列表中第一部分的 SQL 调用(SQL Call Site 1)。

随着代码遍历该股票,读取期权价格的集合,JPA 会执行 SQL 语句提取与特定实体相关的所有期权信息(即该语句会一次性地返回某支股票 / 日期的整个集合)。这就是第二部分的 SQL 语句,执行过程中它会生成 33 408 个单独 SELECT 语句(261 天 × 128 支股票)。

首次执行循环时,这个例子耗时 62 秒钟。第二次执行时,只花费了 3.2 秒。这是因为第二次循环运行时,仅执行了命名查询。通过关系提取的实体还保持在 L2 缓存内,所以这种情况无需访问数据库。(前面提过,L2 缓存只作用于通过关系加载或者 find() 操作的实体。所以我们才能在 L2 缓存中找到股票期权实体,但是股价就不行——因为股价是由查询载入的,在 L2 缓存内不存在,所以必须重新载入。)

表 11-3 的第二行代表的是不依次遍历访问每个期权的情况(譬如,processOptions 变量值为 false)。这种情况下,代码的运行速度会大大提升:完成循环的第一次迭代仅耗时 5.6 秒,紧接着的迭代耗时 2.8 秒。(这两个例子的性能差异源于编译器的预热。虽然我们很难察觉到,但这个预热在第一个例子中也存在。)

2. 缓存和主动载入(Eager Loading)

接下来的两个实验中,为了主动载入期权价格,股票价格与期权价格之间的关系进行了重新定义。

所有数据都被使用时(譬如表 11-3 和表 11-4 中第一行的情况,主动载入与延迟载入的效果几乎是一样的。但是如果载入的相关数据实际并未使用(如每张表中第二行的情况),采用延迟载入相关数据的方式能够节省一些时间——尤其循环第一次执行时。若在紧接着的执行中采用这种方式,循环就无法再节省时间了,因为主动载入的代码在之后的执行过程中不会重新载入,而是直接从 L2 缓存中读取。

表11-4:读取128支股票所消耗的时间(采用主动载入)

测试用例 首次执行 后续执行
主动载入 60.2 秒(33 409 次 SQL 调用) 3.1 秒(1 次 SQL 调用)
主动载入,不遍历 60.2 秒(33 409 次S QL 调用) 2.8 秒(1 次 SQL 调用)

主动载入相关数据

无论相关数据是通过延迟方式载入还是通过主动方式载入,这个循环都会执行 33 408 个 SELECT 语句以取得相应的股票期权(正如上一节所提到的,默认不会使用 JOIN)。

这种情况下,主动载入和延迟载入的区别是什么时候这些 SQL 语句会执行。如果指定的关系是主动载入,查询运行时结果集立即会被处理(在 getResultList() 方法调用内进行)。JPA 框架会查看该调用返回的每个实体,执行相关的 SQL 语句,提取关联实体。所有这些 SQL 语句的执行都发生在 SQL 调用区 1(SQL Call Site 1)——采用主动载入时,没有任何 SQL 语句会在 SQL 调用区 2(SQL Call Site 2)内运行。

如果指定的关系是延迟载入,在 SQL 调用区 1 内(使用命名查询)只有股票价格会载入。股票的期权价格会在 SQL 调用区 2 进行关系遍历时载入。这个循环会运行 33 408 次,因此会触发 33 408 次 SQL 调用。

无论 SQL 在什么时候执行,SQL 语句的总数是固定的——我们假定延迟载入的例子中所有的数据都会被使用。

3. 联合查询和缓存

正如前一节中所讨论到的,我们可以显式地使用 JOIN 语句编写查询:

  1. @NamedQuery(name="findAll",
  2. query="SELECT s FROM StockPriceEagerLazyImpl s " +
  3. "JOIN FETCH s.optionsPrices ORDER BY s.id.symbol")

使用命名查询(结合全遍历)的数据如表 11-5 所示:

表11-5:读取128支股票的耗时(使用联合查询)

测试用例 首次执行 后续执行
默认配置 61.9 秒(33 409 次 SQL 调用) 3.2 秒(1 次 SQL 调用)
联合查询 17.9 秒(1 次 SQL 调用) 11.4 秒(1 次 SQL 调用)
带查询缓存的联合查询 17.9 秒(1 次 SQL 调用) 1.1 秒(0 次 SQL 调用)

首个循环中使用联合查询得到了极大的性能提升:完成本次循环仅耗时 17.9 秒。这是执行一个 SQL 请求的结果,而不是 33 409 次 SQL 查询的结果。

不幸的是,下一次代码执行还需要再次运行那条同样的 SQL 语句,因为查询的结果没在 L2 缓存内保存。这个例子接下来的执行耗时 11.4 秒——这是由于执行的 SQL 语句包含 JOIN 语句,返回的数据超过 200 000 行记录。

如果 JPA 的提供商支持查询缓存,这个场景下使用该机制无疑能极大地改善程序的性能。

如果代码第二次执行时不需要再次运行 SQL 语句,接下来的执行耗时就只需 1.1 秒。注意,查询缓存只有在每次查询运行时使用的参数都完全相同时才工作。

4. 避免查询

如果实体不需要通过查询取得,那么经过初始的预热阶段后,所有的实体都可以通过 L2 缓存访问。L2 缓存可以通过载入所有的实体预热,所以我们在之前例子代码的基础上稍加修改得到了下面的代码:

  1. EntityManager em = emf.createEntityManager();
  2. ArrayList<String> allSymbols = …… 所有有效的股票 ……;
  3. ArrayList<Date> allDates = …… 所有有效的日期……;
  4. for (String symbol : allSymbols) {
  5. for (Date date = allDates) {
  6. StockPrice sp =
  7. em.find(StockPriceImpl.class, new StockPricePK(symbol, date);
  8. …… 处理sp ……
  9. if (processOptions) {
  10. Collection<? extends StockOptionPrice> options = sp.getOptions();
  11. …… 处理选项 ……
  12. }
  13. }
  14. }

这段代码的运行结果如表 11-6 所示。

表11-6:读取128支股票数据的耗时(使用L2缓存)

测试用例 首次执行 后续执行
默认配置 61.9 秒(33 409 次 SQL 调用) 3.2 秒(1 次 SQL 调用)
不作查询 100.5 秒(66 816 次 SQL 调用) 1.19 秒(0 次 SQL 调用)

首次执行这个循环需要 66 816 个 SQL 语句:find() 方法要执行 33 408 次 SQL 调用,getOptions() 方法又执行了 33 408 次 SQL 调用。所以,优化之后这段代码的速度提升了很多,因为现在所有的实体都保持在 L2 缓存内,不再需要执行任何的 SQL 语句了。

测试预热

Java 性能测试——尤其是基准测试,通常都有个预热阶段。正如我们在第四章中讨论的,预热能帮助编译器编译出优化的代码。

这是另一个证明预热阶段极其有益的例子。JPA 应用的预热阶段中,最常使用的实体会被载入到 L2 缓存中。通过对不同测试期间的度量,我们看到随着实体第一次载入,应用的性能发生了显著的变化。这一点在上一个例子中尤其突出,因为优化后不再需要使用查询载入实体了。

前面提过,样本数据库中包含每个日期及股票组合对应的五个期权价格,或者 128 支股票超过一年的数据,总计 167 040 笔期权的价格。通过关联访问某支股票在某个日期的五个股票期权,这些数据会一次性地返回。这就是为什么载入所有的期权价格需要执行 33 408 条 SQL 语句。虽然运行的 SQL 语句会返回多行数据,JPA 自身仍能缓存这些返回的实体——这和单执行一条查询的情况略有不同。如果 L2 缓存是通过遍历实体的方式构建的,就不要用循环的方式访问相关实体——通过关联可以非常容易地访问相关的实体。

随着代码优化,你必须考虑缓存的影响(尤其是 L2 缓存的影响)。即使你认为自己编写的 SQL 比 JPA 自动生成的更优(因此会使用更复杂的命名查询),也应权衡缓存发挥作用时,这样的代码是否还有意义。虽然使用简单的命名查询能快速地载入数据,也应考虑,如果这些实体是通过调用 find() 方法载入 L2 缓存,长期来看会造成什么影响。

5. 调整JPA缓存的大小

正如所有利用对象重用的机制,JPA 缓存也有同样的问题,它可能会对性能产生潜在的冲击:如果缓存消耗了过多内存,垃圾回收就会面临巨大的压力。出现这种情况,你往往需要调整缓存大小,或者控制哪些实体可以继续保持在缓存内。不幸的是,这方面并没有标准选项,因此你必须依据使用的 JPA 提供商,针对性地进行调优。

通常 JPA 实现都提供了选项来对缓存大小进行设置,要么是全局的设置,要么是针对每个实体的设置。显然,后者的适应性更广、更灵活,不过它也要求为确定每个实体的最优大小做更多的工作。一个替代方案是使用软引用或者弱引用,作为 JPA 实现的 L2 缓存。譬如,对 EclipseLink 而言,依据不同的弱引用或者软引用组合提供五种不同的缓存类型(包括额外不推荐使用的类型)。虽然这种方式与为每个实体都定义最优大小比起来相对更容易一些,不过它也需要做一些计划:尤其是,我们在第 7 章中介绍过,弱引用在所有垃圾回收中都会被清理,因此不适合用作缓存的对象。

如果缓存是基于软引用或者弱引用的,则应用的性能也受制于堆的使用情况。本节所有的例子使用的堆都比较大,因此即使缓存应用中的 200 448 个实体对象,也不会给垃圾收集带来任何问题。为了更优的性能,如果 JPA L2 缓存很大,则对堆进行调优是非常重要的。

11.2 JPA - 图4 快速小结

1. JPA 的 L2 缓存会自动对应用的实体进行缓存。

2. L2 缓存不会对查询返回的实体进行缓存。长期来看,这种方式有利于避免查询。

3. 除非使用的 JPA 实现支持查询缓存,否则使用 JOIN 查询的效果通常会对程序的性能造成负面的效果,因为这种操作没有充分利用 L2 缓存。

11.2.5 JPA的只读实体

JPA 规范并未直接定义只读实体,但是很多 JPA 供应商提供了该功能。通常情况下,只读实体比(默认的)读写实体性能更好,因为 JPA 实现很明确地知道它不需要跟踪实体状态,不必在事务中注册实体,也不必对实体上锁,等等。Java EE 的容器通常都支持只读实体,无论使用的是哪种 JPA 实现。这种情况下,应用服务器需要确保实体的访问使用非事务型(Non-Transactional)JDBC 连接。一般情况下,这种方式能带来显著的性能提升。

JPA 规范中定义了如何在 Java EE 容器中支持只读实体的事务:可以在事务之外运行一个通过 @TransactionAttributeType.SUPPORTS 注释的业务方法(假定该方法调用的同时没有事务在运行)。

在这种情况下,该方法访问的实体必须是只读的,因为它们不是事务的一部分。然而,如果方法是从该事务的某个方法中调用的,实体就会成为事务的一部分。