通过二级缓存来加快hibernate应用程序 新的hibernate开发人员有时并不知道hibernate的缓存而只是简单的把它作为一种结果,尽管如此,当我们正确使用缓存的时候,它能够变为加速hibernate应用程序最有力的武器之一。
在web应用程序中和大数据量的数据库交互经常导致性能问题。hibernate是一种高性能的,提供对象/关系持久化和查询的服务,但是如果没有帮助就不会解决所有性能上的问题。在很多情况下,二级缓存就是hibernate潜在的需要来实现所有的性能上的处理。这篇文章将研究hibernate的缓存功能并且展现怎么使用才能明显的提升应用程序的性能。
缓存介绍 缓存被广泛用于数据库应用领域。缓存的设计就是为了通过存储已经从数据库读取的数据来减少应用程序和数据库之间的数据流量,而数据库的访问只在检索的数据不在当前缓存的时候才需要。如果数据库以一些方式进行更新和修改的话,那么应用程序可能需要每隔一段时间清空缓存,因为它没有方法知道缓存里的数据是不是最新的。
Hibernate缓存 从对象来说,hibernate用了两种缓存的方法:一级缓存和二级缓存。一级缓存和Session对象关联,二级缓存和Session Factory对象关联。默认情况下,hibernate使用一级缓存来作为一次事务预处理的基础。hibernate使用它主要是为了减少在一次给定的事务中需要被生成的SQL查询语句。例如,一个对象在同一个事务中被修改好几次,hibernate会在事务结束的时候只生成一条SQL更新语句,它包含了所有修改内容。这篇文章主要介绍二级缓存。为了减少和数据库的交互,二级缓存保存已经读出的对象在各个事务之间的Session Factory级别。这些对象在整个程序中都可以得到,而不仅是用户运行查询的时候。这种方式,每次查询返回的对象都已经被载入了缓存,一次或多次潜在的数据库事务可以避免。
除此之外,你可以使用查询级别缓存如果你需要缓存的实际的查询结果,而不仅仅是持久化对象。
缓存的实现 缓存是软件中复杂的部分,市场上也提供了相当数量的选择,包括基于开源的和商业的。hibernate提供了以下开源的缓存实现,如下:
* EHCache (org.hibernate.cache.EhCacheProvider)
* OSCache (org.hibernate.cache.OSCacheProvider)
* SwarmCache (org.hibernate.cache.SwarmCacheProvider)
* JBoss TreeCache (org.hibernate.cache.TreeCacheProvider)
不同的缓存提供了在性能,内存使用和配置的可扩展性方面不同的能力
EHCache是一种快速的,轻量级的,容易上手的缓存。它支持只读和读写缓存,基于内存和硬盘的数据储存。但是,它不支持簇。
OSCache是另一个开源的缓存解决方案,它是一个也为jsp页面和任意对象提供缓存功能的大的开发包的一部分。它本身也是一个强大和灵活的开发包,像EHCache一样,也支持只读和读写缓存,基于内存和硬盘的数据储存。它通过JavaGroups或者JMS也提供了对簇的基本支持。
SwarmCache是一种基于簇的解决方案,它本身也基于集群服务实体间通信的通信协议。它提供了只读和没有限制的读写缓存(下一部分将解释这个名词)。这种类型的缓存对那些典型的读操作比写操作多的多的应用程序是适合的。
JBoss TreeCache是一种强大的两重性(同步的或异步的)和事务性的缓存。使用此实现如果你确实需要一种事务能力的缓存架构。
另一种值得提及的缓存实现是商业化的Tangosol Coherence cache。
缓存策略 一旦你选择了你的缓存实现,你需要指定你的缓存策略。以下是四种缓存策略:
只读:这种策略对那种数据被频繁读取但是不更新是有效的,这是到目前为止最简单,表现最好的缓存策略。
读写:读写缓存对假设你的数据需要更新是必要的。但是读写需要比只读缓存花费更多的资源。在非JTA的事务环境中,每一个事务需要完成在Session.close() 或Session.disconnect()被调用的时候。
没有限制的读写:这个策略不能保证2个事务不会同时的修改相同的数据。因此,它可能对那些数据经常被读取但只会偶尔进行写操作的最适合。
事务性:这是个完全事务性的缓存,它可能只能被用在JTA环境中。
对每一个缓存实现来说,支持策略不是唯一的。图一展现了对
缓存实现可供的选择。
文章余下的部分用来展示一个使用EHCache单一的JVM缓存。
缓存配置 为了启用二级缓存,我们需要在hibernate.cfg.xml配置文件中定义hibernate.cache.provider_class属性,如下
-
-
- ...
- "hibernate.cache.provider_class">
- org.hibernate.cache.EHCacheProvider
-
- ...
-
-
为了在hibernate3中测试的目的,你还要使用hibernate.cache.use_second_level_cache属性,它可以让你启用(和关闭)二级缓存。默认情况下,二级缓存是启用的同时使用EHCache。
一个实际应用
这个例子演示的程序包含四张简单的表:国家列表,机场列表,员工列表,语言列表。每个员工被分配了一个国家,并且能说很多语言。每个国家能有任意数量的机场。
图一展现了UML类图
图二展现了数据库架构
这个例子的源代码(http://assets.devx.com/sourcecode/14239.tgz)包括了以下的SQL脚本,你需要用它创建和实例化数据库。
* src/sql/create.sql: 创建数据库的SQL脚本
* src/sql/init.sql: 测试数据
安装Maven2
在写的时候,Maven2安装目录看来好像缺少了一些jars。为了解决这个问题,在应用程序源码的根目录里找到那些缺少的jars。把它们安装到Maven2的文件里,到应用程序的目录下,执行以下的命令
$ mvn install:install-file -DgroupId=javax.security -DartifactId=jacc
-Dversion=1.0
-Dpackaging=jar -Dfile=jacc-1.0.jar
$ mvn install:install-file -DgroupId=javax.transaction -DartifactId=jta -Dversion=1.0.1B
-Dpackaging=jar -Dfile=jta-1.0.1B.jar
建立一个只读缓存
从简单的开始,下面是country类的hibernate映射。
- package="com.wakaleo.articles.caching.businessobjects">
- <class name="Country" table="COUNTRY" dynamic-update="true">
- "implement-equals">true
- "read-only"/>
-
- "id" type="long" unsaved-value="null" >
- "cn_id" not-null="true"/>
- class="increment"/>
-
-
- "cn_code" name="code" type="string"/>
- "cn_name" name="name" type="string"/>
-
- "airports">
- "cn_id"/>
- class="Airport"/>
-
- class>
-
假设你要显示国家列表。你可以通过CountryDAO类中一个简单的方法实现,如下
- public class CountryDAO {
- ...
- public List getCountries() {
- return SessionManager.currentSession()
- .createQuery(
- "from Country as c order by c.name")
- .list();
- }
- }
因为这个方法经常被调用,所以你要知道它在压力下的行为。写一个简单的单元测试来模拟5次连续的调用。
- public void testGetCountries() {
- CountryDAO dao = new CountryDAO();
- for(int i = 1; i <= 5; i++) {
- Transaction tx = SessionManager.getSession().beginTransaction();
- TestTimer timer = new TestTimer("testGetCountries");
- List countries = dao.getCountries();
- tx.commit();
- SessionManager.closeSession();
- timer.done();
- assertNotNull(countries);
- assertEquals(countries.size(),229);
- }
- }
你能够运行这个测试通过自己喜欢的IDE或者Maven2的命令行(演示程序提供了2个Maven2的工程文件)。这个演示程序通过本地的mysql来测试。当你运行这个测试的时候,应该得到类似以下的一些信息:
$mvn test -Dtest=CountryDAOTest
...
testGetCountries: 521 ms.
testGetCountries: 357 ms.
testGetCountries: 249 ms.
testGetCountries: 257 ms.
testGetCountries: 355 ms.
[surefire] Running com.wakaleo.articles.caching.dao.CountryDAOTest
[surefire] Tests run: 1, Failures: 0, Errors: 0, Time elapsed: 3,504 sec
可以看出每次调用大概花费半秒的时间,对大多数标准来说还是有点迟缓的。国家的列表很可能不是经常的改变,所以这个类可以作为只读缓存一个好的候选。所以加上去
你可以启用二级缓存用以下两种方法的任意一种
1.你可以在*.hbm.xml里启用它,使用cache的属性
- package="com.wakaleo.articles.caching.businessobjects">
- <class name="Country" table="COUNTRY" dynamic-update="true">
- "implement-equals">true
- "read-only"/>
- ...
- class>
-
2.你可以存储所有缓存信息在hibernate.cfg.xml文件中,使用class-cache属性
-
-
- ...
- "hibernate.cache.provider_class">
- org.hibernate.cache.EHCacheProvider
-
- ...
- <class-cache
- class="com.wakaleo.articles.caching.businessobjects.Country"
- usage="read-only"
- />
-
-
下一步,你需要为这个类设置缓存规则,这些规则决定了缓存怎么表现的细节。这个例子的演示是使用EHCache,但是记住每一种缓存实现是不一样的。
EHCache需要一个配置文件(通常叫做ehcache.xml)在类的根目录。EHCache配置文件的详细文档可以看这里(http://ehcache.sourceforge.net/documentation)。基本上,你要为每个需要缓存的类定义规则,以及一个defaultCache在你没有明确指明任何规则给一个类的时候使用。
对第一个例子来说,你可以使用下面简单的EHCache配置文件
-
-
- "java.io.tmpdir"/>
-
-
- maxElementsInMemory="10000"
- eternal="false"
- timeToIdleSeconds="120"
- timeToLiveSeconds="120"
- overflowToDisk="true"
- diskPersistent="false"
- diskExpiryThreadIntervalSeconds="120"
- memoryStoreEvictionPolicy="LRU"
- />
-
- "com.wakaleo.articles.caching.businessobjects.Country"
- maxElementsInMemory="300"
- eternal="true"
- overflowToDisk="false"
- />
-
-
这个文件为countries类建立一个基于内存最多300单位的缓存(countries类包含了229个国家)。注意缓存不会过期('eternal=true'属性)。现在通过返回的结果看下缓存的表现
$mvn test -Dtest=CompanyDAOTest
...
testGetCountries: 412 ms.
testGetCountries: 98 ms.
testGetCountries: 92 ms.
testGetCountries: 82 ms.
testGetCountries: 93 ms.
[surefire] Running com.wakaleo.articles.caching.dao.CountryDAOTest
[surefire] Tests run: 1, Failures: 0, Errors: 0, Time elapsed: 2,823 sec
正如你期盼的那样,第一次查询没有改变因为需要加载数据。但是,随后的几次查询就快多了。
后台
在我们继续之前,看下后台发生了什么非常有用。一件事情你需要知道的是hibernate缓存不储存对象实例,代替的是它储存对象“脱水”的形式(hibernate的术语),也就是作为一系列属性值。以下是一个countries缓存例子的内容
{
30 => [bw,Botswana,30],
214 => [uy,Uruguay,214],
158 => [pa,Panama,158],
31 => [by,Belarus,31]
95 => [in,India,95]
...
}
注意每个ID是怎么样映射到拥有属性值的数组的。你可能也注意到了只有主要的属性被储存了,而没有airports属性,这是因为airports属性只是一个关联:对其他持久化对象一系列的引用。
默认情况下,hibernate不缓存关联。而由你决定来缓存哪个关联,哪个关联需要被重载当缓存对象从二级缓存获得的时候。
关联缓存是一个非常强大的功能。下一部分我们将介绍更多的内容。
和关联缓存一起工作
假设你需要显示一个给定的国家的所有的员工(包括员工的名字,使用的语言等)。以下是employee类的hibernate映射
- package="com.wakaleo.articles.caching.businessobjects">
- <class name="Employee" table="EMPLOYEE" dynamic-update="true">
- "implement-equals">true
-
- "id" type="long" unsaved-value="null" >
- "emp_id" not-null="true"/>
- class="increment"/>
-
-
- "emp_surname" name="surname" type="string"/>
- "emp_firstname" name="firstname" type="string"/>
-
- "country"
- column="cn_id"
- class="com.wakaleo.articles.caching.businessobjects.Country"
- not-null="true" />
-
-
- "languages" table="EMPLOYEE_SPEAKS_LANGUAGE" lazy="false">
- "emp_id"/>
- "lan_id" class="Language"/>
-
- class>
-
假设你每次使用employee对象的时候需要得到一个员工会说的语言。强逼hibernate自动载入关联的languages集合,你设置了lazy属性为false()。你还需要一个DAO类来得到所有employee,以下的代码来帮你实现
- public class EmployeeDAO {
-
- public List getEmployeesByCountry(Country country) {
- return SessionManager.currentSession()
- .createQuery(
- "from Employee as e where e.country = :country "
- + " order by e.surname, e.firstname")
- .setParameter("country",country)
- .list();
- }
- }
下一步,写一些简单的单元测试来看它怎么表现。正如前面的例子一样,你需要知道它被重复调用时候的性能
- public class EmployeeDAOTest extends TestCase {
-
- CountryDAO countryDao = new CountryDAO();
- EmployeeDAO employeeDao = new EmployeeDAO();
-
-
-
-
-
-
- protected void setUp() throws Exception {
- super.setUp();
- SessionManager.getSession();
- }
-
- public void testGetNZEmployees() {
- TestTimer timer = new TestTimer("testGetNZEmployees");
- Transaction tx = SessionManager.getSession().beginTransaction();
- Country nz = countryDao.findCountryByCode("nz");
- List kiwis = employeeDao.getEmployeesByCountry(nz);
- tx.commit();
- SessionManager.closeSession();
- timer.done();
- }
-
- public void testGetAUEmployees() {
- TestTimer timer = new TestTimer("testGetAUEmployees");
- Transaction tx = SessionManager.getSession().beginTransaction();
- Country au = countryDao.findCountryByCode("au");
- List aussis = employeeDao.getEmployeesByCountry(au);
- tx.commit();
- SessionManager.closeSession();
- timer.done();
- }
-
- public void testRepeatedGetEmployees() {
- testGetNZEmployees();
- testGetAUEmployees();
- testGetNZEmployees();
- testGetAUEmployees();
- }
- }
如果你运行上面的代码,你会得到类似以下的一些数据
$mvn test -Dtest=EmployeeDAOTest
...
testGetNZEmployees: 1227 ms.
testGetAUEmployees: 883 ms.
testGetNZEmployees: 907 ms.
testGetAUEmployees: 873 ms.
testGetNZEmployees: 987 ms.
testGetAUEmployees: 916 ms.
[surefire] Running com.wakaleo.articles.caching.dao.EmployeeDAOTest
[surefire] Tests run: 3, Failures: 0, Errors: 0, Time elapsed: 3,684 sec
所以对一个国家载入大约50个员工需要花费大约一秒的时间。这种方法显然太慢了。这是典型的N+1的查询问题。如果你启用SQL日志,你会发现对employee表的一次查询,紧跟着对language表几百次的查询,无论什么时候hibernate从缓存里得到一个employee对象,它都会重载所有关联的language。那怎么提升它的性能呢?第一件要做的事就是对employee启用读写缓存,如下
- package="com.wakaleo.articles.caching.businessobjects">
- <class name="Employee" table="EMPLOYEE" dynamic-update="true">
- "implement-equals">true
- "read-write"/>
- ...
- class>
-
你还应该对language类启用缓存。只读缓存如下
- package="com.wakaleo.articles.caching.businessobjects">
- <class name="Language" table="SPOKEN_LANGUAGE" dynamic-update="true">
- "implement-equals">true
- "read-only"/>
- ...
- class>
-
然后你需要配置缓存的规则通过加入以下的内容到ehcache.xml文件中
- "com.wakaleo.articles.caching.businessobjects.Employee"
- maxElementsInMemory="5000"
- eternal="false"
- overflowToDisk="false"
- timeToIdleSeconds="300"
- timeToLiveSeconds="600"
- />
- "com.wakaleo.articles.caching.businessobjects.Language"
- maxElementsInMemory="100"
- eternal="true"
- overflowToDisk="false"
- />
但是还是没有解决N+1的查询问题:当你载入一个employee对象的时候大约50次的额外查询还是会执行。这里你就需要在Employee.hbm.xml映射文件里关联的language启用缓存,如下
- package="com.wakaleo.articles.caching.businessobjects">
- <class name="Employee" table="EMPLOYEE" dynamic-update="true">
- "implement-equals">true
-
- "id" type="long" unsaved-value="null" >
- "emp_id" not-null="true"/>
- class="increment"/>
-
-
- "emp_surname" name="surname" type="string"/>
- "emp_firstname" name="firstname" type="string"/>
-
- "country"
- column="cn_id"
- class="com.wakaleo.articles.caching.businessobjects.Country"
- not-null="true" />
-
-
- "languages" table="EMPLOYEE_SPEAKS_LANGUAGE" lazy="false">
- "read-write"/>
- "emp_id"/>
- "lan_id" class="Language"/>
-
- class>
-
通过这个配置,你就能得到几近最优的性能
$mvn test -Dtest=EmployeeDAOTest
...
testGetNZEmployees: 1477 ms.
testGetAUEmployees: 940 ms.
testGetNZEmployees: 65 ms.
testGetAUEmployees: 65 ms.
testGetNZEmployees: 76 ms.
testGetAUEmployees: 52 ms.
[surefire] Running com.wakaleo.articles.caching.dao.EmployeeDAOTest
[surefire] Tests run: 3, Failures: 0, Errors: 0, Time elapsed: 0,228 sec
查询缓存
在确定的情况下,缓存一次查询的正确结果是非常有用的,不仅是只是个确定的对象。例如,getCountries()方法在每次调用的时候或许会返回相同的国家列表。所以,除了缓存country类之外,你也应该缓存查询结果本身。
为了实现这个目标,你需要在hibernate.cfg.xml文件中设置hibernate.cache.use_query_cache属性为true,如下
true
然后需要在对任何查询缓存的时候使用setCacheable()方法,如下
- public class CountryDAO {
-
- public List getCountries() {
- return SessionManager.currentSession()
- .createQuery("from Country as c order by c.name")
- .setCacheable(true)
- .list();
- }
- }
但是,它不能预知到其他程序对数据库任何的改变。所以你不应该使用任何二级缓存(或为类和集合的缓存设置简短的过期时间)如果你的数据总是要保证最新的状态。
正确的使用hibernate缓存
缓存是一种强大的技术,hibernate提供了一种强大的,灵活的,不显眼的方式来实现它。即使对很多简单的例子来说默认的设置能使实际的性能得到提升。但是,像很多强大工具一样,hibernate还是需要一些思考和微调来得到最优的结果,而缓存像其他的优化技术一样,应该使用一种可扩展,测试驱动的方法所实现。当正确使用的时候,少量的缓存实现,能最大程度的提高你的程序运行。