Java 持久化/缓存
缓存是最重要的性能优化技术。在持久化中可以缓存很多东西,例如对象、数据、数据库连接、数据库语句、查询结果、元数据、关系等等。对象持久化中的缓存通常指的是对象或其数据的缓存。缓存也会影响对象标识,也就是说,如果你读取了一个对象,然后再次读取同一个对象,你应该获得相同的对象(相同的引用)。
JPA 1.0 没有定义 JPA 提供程序是否支持共享对象缓存,但大多数提供程序都支持。JPA 中的缓存需要在事务或扩展持久化上下文中才能保留对象标识,但 JPA 不要求跨事务或持久化上下文支持缓存。
JPA 2.0 定义了共享缓存的概念。@Cacheable
注解或 cacheable
XML 属性可用于启用或禁用类上的缓存。
@Entity
@Cacheable
public class Employee {
...
}
SharedCacheMode
枚举也可以在 persistence.xml 中的 <shared-cache-mode>
XML 元素中设置,用于配置整个持久化单元的默认缓存模式。
<persistence-unit name="ACME">
<shared-cache-mode>NONE</shared-cache-mode>
</persistence-unit>
有两种类型的缓存。你可以缓存对象本身,包括其所有结构和关系,或者你可以缓存其数据库行数据。两者都有益处,但仅仅缓存行数据会丢失很大一部分缓存效益,因为检索每个关系通常都涉及数据库查询,而读取对象的绝大部分成本都花在了检索其关系上。
Java 中的对象标识意味着如果两个变量 (x, y
) 引用同一个逻辑对象,那么 x == y
返回 true
。这意味着两者都引用了同一个东西(两者都指向同一个内存位置)。
在 JPA 中,对象标识在事务中(通常)在同一个 EntityManager
内得到维护。例外情况是 JEE 管理的 EntityManager
,对象标识仅在事务内部得到维护。
因此,以下在 JPA 中是正确的
Employee employee1 = entityManager.find(Employee.class, 123);
Employee employee2 = entityManager.find(Employee.class, 123);
assert (employee1 == employee2);
无论如何访问对象,这都成立
Employee employee1 = entityManager.find(Employee.class, 123);
Employee employee2 = employee1.getManagedEmployees().get(0).getManager();
assert (employee1 == employee2);
在 JPA 中,对象标识不跨 EntityManager
保持。每个 EntityManager
都维护自己的持久化上下文及其对象的自己的事务状态。
因此,以下在 JPA 中是正确的
EntityManager entityManager1 = factory.createEntityManager();
EntityManager entityManager2 = factory.createEntityManager();
Employee employee1 = entityManager1.find(Employee.class, 123);
Employee employee2 = entityManager2.find(Employee.class, 123);
assert (employee1 != employee2);
对象标识通常是一件好事,因为它避免了应用程序管理对象的多个副本,并避免了应用程序更改一个副本,但没有更改另一个副本。不同 EntityManager
或事务(在 JEE 中)不维护对象标识的原因是,每个事务必须将其更改与系统的其他用户隔离。这通常也是一件好事,但是它确实要求应用程序了解副本、分离对象和合并。
一些 JPA 产品可能有一个只读对象的概念,其中对象标识可能通过共享对象缓存跨 EntityManager
保持。
对象缓存是 Java 对象(实体)本身被缓存的地方。对象缓存的优点是数据以 Java 中使用的相同格式被缓存。所有内容都存储在对象级别,在获取缓存命中时不需要任何转换。对于 JPA,EntityManager
仍然必须将对象复制到缓存中和从缓存中复制出来,因为它必须维护其事务隔离,但这是唯一的要求。对象不需要重新构建,关系已经可用。
使用对象缓存,瞬态数据也可以被缓存。这可能是自动发生的,或者可能需要一些工作。如果不需要瞬态数据,你可能还需要在对象被缓存时清除数据。
一些 JPA 产品允许只读查询直接访问对象缓存。一些产品只允许对只读数据进行对象缓存。在只读数据上获取缓存命中非常高效,因为对象不需要被复制,除了查找之外,不需要任何工作。
你可以通过将对象从 JPA 加载到自己的对象缓存或 JCache
实现中来创建自己的只读数据对象缓存。主要问题,这也是一般缓存中的主要问题,是如何处理更新和陈旧的缓存数据,但是如果数据是只读的,这可能不是问题。
- TopLink / EclipseLink : 支持对象缓存。对象缓存默认启用,但可以在全局或选择性地为每个类启用或配置。持久化单元属性
"eclipselink.cache.shared.default"
可以设置为"false"
以禁用缓存。只读查询通过"eclipselink.read-only"
查询提示支持,实体也可以使用@ReadOnly
注解标记为始终是只读的。
数据缓存缓存的是对象的數據,而不是对象本身。数据通常是对象数据库行的表示。数据缓存的优点是它更容易实现,因为你不必担心关系、对象标识或复杂的内存管理。数据缓存的缺点是它不会以应用程序中使用的方式存储数据,也不会存储关系。这意味着在缓存命中时,对象仍然必须从数据中构建,并且关系必须从数据库中获取。一些支持数据缓存的产品也支持关系缓存或查询缓存以允许缓存关系。
- Hibernate : 支持与第三方数据缓存集成。缓存默认情况下未启用,必须使用 Ehcache 等第三方缓存产品才能启用缓存。
一些产品支持用于缓存关系的单独缓存。这通常对于 OneToMany
和 ManyToMany
关系是必需的。OneToOne
和 ManyToOne
关系通常不需要被缓存,因为它们引用对象的 Id
。但是,反向 OneToOne
将需要关系被缓存,因为它引用的是外键,而不是主键。
对于关系缓存,结果通常只存储相关对象的 Id
,而不是对象本身或其数据(以避免重复和陈旧的数据)。关系缓存的键是源对象的 Id
和关系名称。有时关系被缓存在数据缓存中,如果数据缓存存储的是结构而不是数据库行。当关系缓存命中时,相关对象会一个接一个地在数据缓存中查找。这样做的一个潜在问题是,如果相关对象不在数据缓存中,它将需要从数据库中选择。这可能导致数据库性能很差,因为对象可以一个接一个地加载。一些支持缓存关系的产品也支持批处理选择,以尝试缓解这个问题。
有许多不同的缓存类型。最常见的是 LRU 缓存,或者一种逐出最不常使用对象的缓存,并维护固定数量的最常使用 (MRU) 对象。
一些缓存类型包括
- LRU - 在缓存中保留 X 个最近使用的对象。
- Full - 缓存所有读取的內容,永远保留。(如果数据库很大,这并不总是最好的选择)
- Soft - 使用 Java 垃圾收集提示,在内存不足时从缓存中释放对象。
- Weak - 通常与对象缓存相关,在缓存中保留所有当前正在使用的对象。
- L1 - 这是指每个
EntityManager
都包含的事务性缓存,它不是共享缓存。 - L2 - 这是一个共享缓存,概念上存储在
EntityManagerFactory
中,因此所有EntityManager
都可以访问。 - 数据缓存 - 表示对象的的数据被缓存(数据库行)。
- 对象缓存 - 对象被直接缓存。
- 关系缓存 - 对象的关系被缓存。
- 查询缓存 - 来自查询的结果集被缓存。
- 只读 - 仅存储或仅允许只读对象的缓存。
- 读写 - 可以处理插入、更新和删除(非只读)的缓存。
- 事务性 - 可以处理插入、更新和删除(非只读),并遵守事务的 ACID 属性的缓存。
- 集群 - 通常指使用 JMS、JGroups 或其他机制在集群中的其他服务器上广播失效消息以更新或删除对象的缓存。
- 复制 - 通常指使用 JMS、JGroups 或其他机制在任何服务器缓存中读取对象时将对象广播到所有服务器的缓存。
- 分布式 - 通常指将缓存对象分布在集群中的多个服务器上,并且可以在其他服务器的缓存中查找对象的缓存。
- TopLink / EclipseLink : 支持 L1 和 L2 对象缓存。支持 LRU、Soft、Full 和 Weak 缓存类型。支持查询缓存。对象缓存是读写的,并且始终是事务性的。为集群提供了通过 RMI 和 JMS 进行缓存协调的支持。TopLink 产品包含一个与 Oracle Coherence 集成的Grid组件,以提供分布式缓存。
查询缓存
[edit | edit source]查询缓存缓存查询结果而不是对象。对象缓存根据Id
缓存对象,因此通常对不是Id
的查询不太有用。一些对象缓存支持二级索引,但即使是索引缓存对于可以返回多个对象的查询也不太有用,因为您始终需要访问数据库以确保您拥有所有对象。这就是查询缓存有用的地方,它不是根据Id
存储对象,而是缓存查询结果。缓存键基于查询名称和参数。因此,如果您有一个经常执行的NamedQuery
,您可以缓存其结果,并且只需要第一次执行查询。
查询缓存的主要问题,与一般缓存一样,是陈旧数据。查询缓存通常与对象缓存交互,以确保对象至少与对象缓存中的对象一样更新。查询缓存通常也具有类似于对象缓存的失效选项。
- TopLink / EclipseLink : 通过查询提示
"eclipselink.query-results-cache"
支持启用查询缓存。支持几个配置选项,包括失效。
陈旧数据
[edit | edit source]缓存任何东西的主要问题是缓存版本可能与原始版本不同步。这被称为陈旧或不同步数据。对于只读数据,这不是问题,但对于很少或经常更改的数据,这可能是一个主要问题。有很多技术可以处理陈旧数据和不同步数据。
一级缓存
[edit | edit source]在事务或请求的持续时间内缓存对象的狀態通常不是问题。这通常称为一级缓存或EntityManager
缓存,并且 JPA 为了正确的事务语义而需要它。如果您两次读取同一个对象,您必须获得相同对象,并且具有相同的内存内更改。唯一的问题发生在查询和 DML 中。
对于访问数据库的查询,查询可能不反映对象的未写入状态。例如,您已经持久化了一个新对象,但 JPA 尚未将该对象插入数据库,因为它通常只在事务提交时写入数据库。因此,您的查询不会返回这个新对象,因为它正在查询数据库,而不是一级缓存。这通常通过用户首先调用flush()
或flushMode
自动触发刷新来解决。EntityManager
或Query
上的默认flushMode
是触发刷新,但这可以被禁用,如果在每次查询之前都希望写入数据库(通常不是,因为它可能很昂贵并导致较差的并发性)。一些 JPA 提供程序还支持使数据库查询结果与内存中的对象更改一致,这可以用来获取一致的数据而无需触发刷新。这适用于简单的查询,但对于复杂的查询,这通常变得非常复杂甚至不可能。应用程序通常在开始进行更改之前请求的开头查询数据,或者不查询它们已经找到的对象,因此这通常不是问题。
如果您绕过 JPA 并直接对数据库执行 DML,无论是通过原生 SQL 查询、JDBC 还是 JPQL UPDATE
或 DELETE
查询,那么数据库可能会与一级缓存不同步。如果您在执行 DML 之前访问过对象,它们将具有旧状态,并且不包括更改。这可能取决于您正在做什么,否则您可能需要从数据库刷新受影响的对象。
一级缓存或EntityManager
缓存也可以跨越 JPA 中的事务边界。JTA 管理的EntityManager
仅在 JEE 中 JTA 事务的持续时间内存在。通常,JEE 服务器会将EntityManager
的代理注入应用程序,并且在每次 JTA 事务之后,将自动创建新的EntityManager
或EntityManager
将被清除,从而清除一级缓存。在应用程序管理的EntityManager
中,一级缓存将存在于EntityManager
的持续时间内。如果EntityManager
被保留太长时间,这会导致陈旧数据,甚至内存泄漏和性能下降。这就是为什么通常最好为每个请求或每个事务创建一个新的EntityManager
。一级缓存也可以使用EntityManager.clear()
方法清除,或者可以使用EntityManager.refresh()
方法刷新对象。
二级缓存
[edit | edit source]二级缓存跨越事务和EntityManager
,并且不是 JPA 的一部分。大多数 JPA 提供程序都支持二级缓存,但实现和语义各不相同。一些 JPA 提供程序默认情况下启用二级缓存,而一些提供程序默认情况下不使用二级缓存。
如果应用程序是唯一访问数据库的应用程序和服务器,那么二级缓存几乎没有问题,因为它应该始终是最新的。唯一的问题是 DML,如果应用程序通过原生 SQL 查询、JDBC 或 JPQL UPDATE
或 DELETE
查询直接对数据库执行 DML。JPQL 查询应该自动使二级缓存失效,但这可能取决于 JPA 提供程序。如果您直接使用原生 DML 查询或 JDBC,您可能需要使受 DML 影响的对象失效、刷新或清除。
如果有其他应用程序或其他应用程序服务器访问同一个数据库,那么陈旧数据可能会成为更大的问题。只读对象和插入新对象不应该有问题。即使使用缓存,新对象也应该被其他服务器获取,因为查询通常仍然访问数据库。它通常只影响find()
操作和关系。其他应用程序或服务器更新和删除的对象会导致二级缓存变得陈旧。
对于已删除的对象,唯一的问题是find()
操作,因为访问数据库的查询不会返回已删除的对象。如果对象被缓存,则find()
可以通过对象的Id
返回该对象,即使它不存在。如果您从其他对象中添加了对该对象的关联,或者如果尝试更新该对象,这可能会导致约束问题或更新失败。请注意,这些都可能在没有缓存的情况下发生,即使单个应用程序和服务器访问数据库也是如此。在事务期间,应用程序的另一个用户始终可以删除另一个事务正在使用的对象,并且第二个事务将以相同的方式失败。不同之处在于这种并发问题发生的可能性会增加。
对于更新的对象,任何对对象的查询都可能返回陈旧数据。这可能会在更新时触发乐观锁异常,或者如果未使用锁定,则会导致一个用户覆盖另一个用户的更改。再次注意,这些都可能在没有缓存的情况下发生,即使单个应用程序和服务器访问数据库也是如此。这就是为什么通常始终使用乐观锁非常重要的原因。陈旧数据也可能会返回给用户。
刷新
[edit | edit source]刷新是解决陈旧数据最常见的解决方案。大多数应用程序用户都熟悉缓存的概念,并且知道何时需要最新数据并愿意点击刷新按钮。这在互联网浏览器中非常常见,大多数浏览器都有一个已经访问过的网页缓存,并且会避免两次加载同一个页面,除非用户点击刷新按钮。相同的概念可用于构建 JPA 应用程序。JPA 提供了几个刷新选项,请参阅刷新。
一些 JPA 提供商还在其二级缓存中支持刷新选项。一种选项是在每次查询数据库时始终刷新。这意味着 find()
操作仍然会访问缓存,但如果查询访问数据库并带回数据,二级缓存将使用该数据刷新。这避免了查询返回陈旧数据,但也意味着缓存的收益会更少。成本不仅仅在于刷新对象,还在于刷新它们的关系。一些 JPA 提供商支持将此选项与乐观锁结合使用。如果来自数据库的行中的版本值比来自缓存中对象的版本值更新,则对象将被刷新,因为它已过时,否则将返回缓存值。此选项提供最佳缓存,并避免查询时出现陈旧数据。但是,通过 find()
或通过关系返回的对象仍然可能过时。一些 JPA 提供商还允许 find()
操作配置为首先检查数据库,但这通常会违背缓存的目的,因此最好根本不使用二级缓存。如果要使用二级缓存,则必须对陈旧数据有一定的容忍度。
JPA 2.0 缓存 API
[edit | edit source]JPA 2.0 提供了一组标准查询提示,以允许刷新或绕过缓存。查询提示在两个枚举类 CacheRetrieveMode 和 CacheStoreMode 上定义。
查询提示
javax.persistence.cache.retrieveMode
:CacheRetrieveMode
BYPASS
: 忽略缓存,并直接从数据库结果构建对象。USE
: 允许查询使用缓存。如果对象/数据已在缓存中,则将使用缓存的对象/数据。
javax.persistence.cache.storeMode
:CacheStoreMode
BYPASS
: 不要缓存数据库结果。REFRESH
: 如果对象/数据已在缓存中,则使用数据库结果刷新/替换它。USE
: 缓存从查询返回的对象/数据。
缓存提示示例
[edit | edit source]Query query = em.createQuery("Select e from Employee e");
query.setHint("javax.persistence.cache.storeMode", CacheStoreMode.REFRESH);
JPA 2.0 还提供了一个 Cache 接口。可以使用 getCache()
API 从 EntityManagerFactory
获取 Cache
接口。Cache
可用于手动驱逐/使缓存中的实体失效。可以驱逐特定实体、整个类或整个缓存。还可以检查 Cache
以查看它是否包含实体。
一些 JPA 提供商可能会扩展 getCache()
接口以提供其他 API。
- TopLink / EclipseLink : 提供扩展的
Cache
接口JpaCache
。它为失效、查询缓存、访问和清除提供了其他 API。
缓存驱逐示例
[edit | edit source]Cache cache = factory.getCache();
cache.evict(Employee.class, id);
缓存失效
[edit | edit source]处理陈旧缓存数据的常用方法是使用缓存失效。缓存失效在一定时间后或在一天中的特定时间删除或使缓存中的数据或对象失效。生存时间失效保证应用程序永远不会读取比一定时间更旧的缓存数据。时间可以根据应用程序的要求进行配置。一天中的特定时间失效允许在一天中的特定时间使缓存失效,通常在晚上进行,这确保数据永远不会超过一天。如果已知批处理作业在晚上更新数据库,也可以使用它,失效时间可以设置为批处理作业计划运行之后。也可以手动使数据失效,例如使用 JPA 2.0 evict()
API。
大多数缓存实现都支持某种形式的失效,JPA 没有定义任何可配置的失效选项,因此这取决于 JPA 和缓存提供商。
- TopLink / EclipseLink : 使用
@Cache
注释和<cache>
orm.xml 元素,提供对生存时间和一天中的特定时间缓存失效的支持。缓存失效也通过 API 支持,并且可以在集群中用于使其他机器上更改的对象失效。
集群中的缓存
[edit | edit source]在集群环境中缓存很困难,因为每台机器都将直接更新数据库,但不会更新其他机器的缓存,因此每台机器的缓存都可能过时。这并不意味着不能在集群中使用缓存,但您必须小心配置它。
对于只读对象,仍然可以使用缓存。对于大多数读取对象,可以使用缓存,但应使用一些机制来避免陈旧数据。如果陈旧数据只是写入问题,那么使用乐观锁将避免对陈旧数据进行写入。当发生乐观锁异常时,一些 JPA 提供商会自动刷新或使缓存中的对象失效,因此如果用户或应用程序重试事务,下次写入将成功。您的应用程序也可以捕获锁异常并刷新或使对象失效,并且如果用户不需要收到锁错误的通知,可以重试事务(但请小心这样做,因为通常情况下用户应该知道锁错误)。缓存失效还可以用于通过在缓存上设置生存时间来降低陈旧数据的可能性。缓存的大小也会影响陈旧数据的发生。
虽然向用户返回陈旧数据可能是一个问题,但通常情况下,向刚刚更新数据的用户返回陈旧数据是一个更大的问题。这通常可以通过会话亲和性来解决,但要确保用户在整个会话期间与集群中的同一台机器交互。这也可以提高缓存使用率,因为同一个用户通常会访问相同的数据。通常在 UI 中添加一个刷新按钮也很有用,这将允许用户刷新他们的数据,如果他们认为他们的数据过时了,或者他们希望确保他们拥有最新的数据。应用程序还可以选择在需要最新数据的地方刷新对象,例如使用缓存进行只读查询,但在进入事务以更新对象时刷新。
对于大多数写入对象,最佳解决方案可能是禁用这些对象的缓存。缓存对插入没有好处,避免更新时出现陈旧数据的成本可能意味着缓存始终被更新的对象没有好处。缓存会给写入添加一些开销,因为必须更新缓存,拥有一个大型缓存也会影响垃圾收集,因此如果缓存没有提供任何好处,则应关闭它以避免这种开销。但这取决于对象的复杂性,如果对象具有许多复杂的关系,并且只更新了对象的一部分,那么缓存仍然值得。
缓存协调
[edit | edit source]在集群环境中缓存的一种解决方案是使用消息框架在集群中的机器之间协调缓存。JMS 或 JGroups 可以与 JPA 或应用程序事件结合使用,以广播消息以使其他机器上的缓存失效,当更新发生时。一些 JPA 和缓存提供商在集群环境中支持缓存协调。
- TopLink / EclipseLink : 使用 JMS 或 RMI 在集群环境中支持缓存协调。缓存协调通过
@Cache
注释或<cache>
orm.xml 元素配置,并使用持久性单元属性eclipselink.cache.coordination.protocol
。
分布式缓存
[edit | edit source]分布式缓存是指将缓存分布在集群中的每台机器上的缓存。每个对象只存在于一台或一组机器上。这避免了陈旧数据,因为当访问或更新缓存时,对象始终从同一位置检索,因此始终是最新的。此解决方案的缺点是缓存访问现在可能需要网络访问。当集群中的机器连接到同一个高速网络,而数据库机器连接不好或负载过重时,此解决方案效果最佳。分布式缓存减少了数据库访问,因此允许应用程序扩展到更大的集群,而不会使数据库成为瓶颈。一些分布式缓存提供商还提供本地缓存,并在缓存之间提供缓存协调。
- TopLink : 支持与 Oracle Coherence 分布式缓存集成。
缓存事务隔离
[edit | edit source]当使用缓存时,缓存的一致性和隔离性与数据库事务隔离性一样重要。对于基本的缓存隔离,重要的是在数据库事务提交后才将更改提交到缓存中,否则其他用户可能会访问未提交的数据。
缓存可以是事务性的,也可以是非事务性的。在事务性缓存中,事务的更改作为一个原子单元提交到缓存。这意味着对象/数据首先在缓存中被锁定(防止其他线程/用户访问对象/数据),然后在缓存中更新,最后释放锁。理想情况下,在提交数据库事务之前获取锁,以确保与数据库的一致性。在非事务性缓存中,对象/数据逐个更新,没有任何锁定。这意味着在缓存中的数据与数据库不一致的短暂时间内。这可能是或可能不是一个问题,这是一个复杂的问题,需要思考和讨论,并涉及锁定和应用程序的隔离需求。
乐观锁是缓存隔离中另一个重要的考虑因素。如果使用乐观锁,缓存应该避免用旧数据替换新数据。这在读取和更新缓存时很重要。
一些 JPA 提供者可能允许配置他们的缓存隔离,或者不同的缓存可能定义不同的隔离级别。虽然通常应该使用默认值,但了解缓存的使用如何影响事务隔离以及性能和并发性至关重要。
- 这意味着您已在 JPA 配置中启用了缓存,或者您的 JPA 提供者默认情况下使用缓存。您可以禁用 JPA 配置中的二级缓存,或者在直接更改数据库后刷新对象或使缓存失效。请参阅 过时数据
- TopLink / EclipseLink:默认情况下启用缓存。要禁用缓存,请在您的 persistence.xml 或持久性属性中将持久性属性
"eclipselink.cache.shared.default"
设置为false
。您还可以针对每个类配置此属性,如果希望在某些类中允许缓存而在其他类中不允许缓存。请参阅,EclipseLink 常见问题解答。
- TopLink / EclipseLink:默认情况下启用缓存。要禁用缓存,请在您的 persistence.xml 或持久性属性中将持久性属性