通过二级缓存提升Hibernate应用的性能

通过二级缓存提升Hibernate应用的性能



刚刚接触Hibernate的开发工程师有时不理解Hibernate的缓存,并合理地使用。如果能够很好地使用,二级缓存会成为提高应用性能的最有效的方式。
作者 John Ferguson Smart  译者 张立明([email protected]





大量的数据库通讯流量是影响Web应用性能的最常见原因。Hibernate是一个高性能、对象-关系型持久和查询服务的框架,但是,如果不做一些调整,它不能解决你的性能问题。本文剖析了Hibernate的缓存功能并告诉你如何显著提高应用程序的性能。

缓存介绍

缓存技术在优化数据库应用中被广泛地使用。缓存通过在应用程序中保存从数据库读取的数据的机制来减少数据库和应用程序之间的通讯流量。只有在读取缓存中没有的数据的时候,才有必要从数据库读取。因为应用程序的缓存无法知道缓存中的数据是否为最新的数据,所以应用程序在数据可能被更新的情况下,需要定时不断地清空缓存。

Hibernate缓存

Hibernate使用两种不同的缓存来保存对象:一级缓存和二级缓存。一级缓存是会话(Session)级的缓存,而二级缓存是会话工厂(Session Factory)级的缓存。默认情况下,Hibernate针对每个事务使用一级缓存。通过一级缓存,Hibernate减少了一个事务期间需要生成的SQL查询。例如,如果对象在一个事务期间被修改了几次,Hibernate会仅仅在事务结束之前生成一个SQL UPDATE语句,这个语句包含了这几次修改的内容。本文重点讲述二级缓存。为了减少数据库的访问压力,二级缓存保存跨多个事务的、会话工厂(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是一个快速的、轻量级的、易于使用的、进程内的缓存。它支持read-only和read/write缓存,内存和磁盘缓存。但是不支持集群(Clustering)。
•OSCache是另外一个开源的缓存方案。它同时还支持JSP页面或任意对象的缓存。OSCache功能强大、灵活,和EHCache一样支持read-only和read/write缓存、支持内存和磁盘缓存。同时,它还提供通过JGroups或JMS进行集群的基本支持。
•SwarmCache 是一个简单的、基于JavaGroups提供集群的缓存方案。支持read-only和nonstrict read/write缓存(下一节解释这个概念)。这种缓存适用于读操作远远高于写操作频率的应用。
•JBoss TreeCache is a powerful replicated (synchronous or asynchronous) and transactional cache. Use this solution if you really need a true transaction-capable caching architecture. 是一个强大的、可复制(同步或异步)和支持事务的缓存。如果你需要一个真正的支持事务的缓存架构,使用这个方案吧。

另外一个值得提及的商业缓存方案是 Tangosol Coherence cache.

缓存策略

一旦你选定了你的缓存方案,你需要指定你的缓存策略。下面是四个可选的缓存策略:

•Read-only: 这种策略对于从来不修改、只需要频繁读取的数据是非常有用的,也是最简单、性能最好的缓存策略。
•Read/write: 如果数据需要被更新,Read/write缓存策略可能是比较合适的。这种策略比read-only消耗更多的资源。 在非JTA环境中,每个事务必须在Session.close()或Session.disconnect()调用之前结束。
•Nonstrict read/write: 这种策略不保证两个事务不会同时改变同一个数据。因此,更适用于偶尔修改数据、频繁读取数据的场合。
•Transactional: 这是一个完全事务支持的策略,可以在JTA环境中使用。

每个缓存方案支持的缓存策略是不同的。表1列出了每个缓存方案可用的缓存策略:


缓存方案

Read-only

Nonstrict Read/write

Read/write

Transactional


EHCache

Yes

Yes

Yes

No


OSCache

Yes

Yes

Yes

No


SwarmCache

Yes

Yes

No

No


JBoss TreeCache

Yes

No

No

Yes



 


Table 1. Supported Caching Strategies for Hibernate Out-of-the-Box Cache Implementations



下面介绍在单个JVM中应用EHCache。



 





缓存配置

要使用二级缓存,你需要按照下面的步骤在hibernate.cfg.xml中定义hibernate.cache.provider_class属性。




<hibernate-configuration>


       <session-factory>


              ...


              <property name="hibernate.cache.provider_class">


                    org.hibernate.cache.EHCacheProvider


              </property>


              ...


       </session-factory>


</hibernate-configuration>

为了在Hibernate3中进行测试,你需要使用hibernate.cache.use_second_level_cache属性。这个属性允许你启用(和禁用)二级缓存。默认情况下,二级缓存是启用的,并且使用EHCache。

示例应用

下面的示例程序包含四个简单的数据库表:Country、Ariport、Employee、Spoken Language。每个Employee有一个Country、可以说多个Spoken Language。每个Country有任意多个Airport。图1是UML类图,图2是数据库模型。本示例的源代码中包含下面的用来初始化数据库的SQL脚本。

•src/sql/create.sql: 初始化数据库的SQL脚本.

0 && image.height>0){if(image.width>=700){this.width=700;this.height=image.height*700/image.width;}}" border=0>





图 1. Employee UML类图




•src/sql/init.sql: 测试数据

安装Maven2提示

到撰写本文时,Maven2 Repository似乎缺少一些jar文件。为了解决这些问题,可以在本示例程序的源代码根目录中找到缺失的jar。为了安装Maven2 repository,到app文件夹并执行下面的命令:




$ 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



0 && image.height>0){if(image.width>=700){this.width=700;this.height=image.height*700/image.width;}}" border=0>





Figure 2. The Database Schema





设置 Read-Only 缓存策略

从简单开始。下面是Hibernate 中Country类的映射




<hibernate-mapping package="com.wakaleo.articles.caching.businessobjects">


    <class name="Country" table="COUNTRY" dynamic-update="true">


              <meta attribute="implement-equals">true</meta>   


              <cache usage="read-only"/>





        <id name="id" type="long" unsaved-value="null" >


            <column name="cn_id" not-null="true"/>


            <generator class="increment"/>


        </id>





          <property column="cn_code" name="code" type="string"/>


          <property column="cn_name" name="name" type="string"/>





         <set name="airports">


          <key column="cn_id"/>


          <one-to-many class="Airport"/>


         </set>


    </class>


</hibernate-mapping>

假定你需要显示所有的国家,你需要像下面一样实现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的命令行(示例程序中提供了Maven2的Project文件)中执行测试。这个程序在MySQK数据库下测试。当你运行测试的时候,你应该能看到下面的结果:





$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

看来,每个调用消耗大概四分之一秒,这个数字多数情况下应该是不理想的。Country表应该是不会频繁变更数据的,因此这是一个非常适合使用read-only的缓存策略的场合。

你可以通过下面两个方式中的任何一个来启用二级缓存:

1.你通过在*.hbm.xml中使用cache属性来为每一个class启用二级缓存:

2.         




3.         

<hibernate-mapping package="com.wakaleo.articles.caching.businessobjects">


4.         

         <class name="Country" table="COUNTRY" dynamic-update="true">


5.         

              <meta attribute="implement-equals">true</meta>


6.         

              <cache usage="read-only"/>


7.         

            ...                          


8.         

        </class>


9.         

    </hibernate-mapping>


10.你可以在hibernate.cfg.xml中使用class-cache属性来存储所有class的缓存设置:

11.    




12.    

<hibernate-configuration>


13.    

       <session-factory>


14.    

              ...


15.    

              <property name="hibernate.cache.provider_class">


16.    

                    org.hibernate.cache.EHCacheProvider


17.    

              </property>


18.    

              ...


19.    

              <class-cache


20.    

class="com.wakaleo.articles.caching.businessobjects.Country"


21.    

usage="read-only"


22.    

              />


23.    

       </session-factory>


24.    

</hibernate-configuration>

下一步,你需要为这个class配置缓存规则。这些规则决定了缓存的一些行为细节。本示例程序使用EHCache,但是记住,每一个缓存方案是不同的。

EHCache需要一个配置文件(通常文件名为ehcache.xml),这个文件位于classpath的根目录下。关于EHCache的配置文件,在其官方网站上有详细的文档。简而言之,你为每个你希望缓存的class定义规则,如果你不明确定义规则,将使用defaultCache这个默认规则。

对于这个例子,你会看到下面的EHCache配置文件:




<ehcache>





    <diskStore path="java.io.tmpdir"/>





    <defaultCache


       

maxElementsInMemory

="10000"


        eternal="false"


        timeToIdleSeconds="120"


        timeToLiveSeconds="120"


        overflowToDisk="true"


        diskPersistent="false"


        diskExpiryThreadIntervalSeconds="120"


        memoryStoreEvictionPolicy="LRU"


        />


       


    <cache name="com.wakaleo.articles.caching.businessobjects.Country"


        maxElementsInMemory="300"


        eternal="true"


        overflowToDisk="false"


        />





</ehcache>

这个文件为Country对象设置了一个基于内存的缓存,最多300个实例(这个国家列表实际包含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缓存不保存真正的对象的实例。实际上,它把对象保存在对象的“dehydrated”(Hibernate的术语,可以理解为“被烘干的、风干的”)形式,也就是说,仅仅是属性的值。下面是Country缓存中的内容:




{


  30  => [bw,Botswana,30],


  214 => [uy,Uruguay,214],


  158 => [pa,Panama,158],


  31  => [by,Belarus,31]


  95  => [in,India,95]


  ...


}

在此请注意每个ID是如何映射到值数组的。你可能还注意到只有基本的数据类型(primitive)的值被存储了,没有任何关于Airport的属性出现在里面。这是因为Airport实际上是一个引用—指向其他持久化对象的引用。

默认情况下,Hibernate不缓存引用。当缓存中的对象从缓存中读出的时候,哪些引用对象需要被缓存、哪些引用对象需要被重新加载,是由你来决定的。

引用对象的缓存是非常有效的功能,下面一节详细说明。





 





使用引用对象缓存

假定你需要显示一个国家的所有的Employee列表(名字、语言等)。下面是Hibernate中Employee类的映射文件:




<hibernate-mapping package="com.wakaleo.articles.caching.businessobjects">


    <class name="Employee" table="EMPLOYEE" dynamic-update="true">


              <meta attribute="implement-equals">true</meta>   





       <id name="id" type="long" unsaved-value="null" >


            <column name="emp_id" not-null="true"/>


            <generator class="increment"/>


       </id>





       <property column="emp_surname" name="surname" type="string"/>


       <property column="emp_firstname" name="firstname" type="string"/>


         


        <many-to-one name="country"


                    column="cn_id"


                     class="com.wakaleo.articles.caching.businessobjects.Country" 


                      not-null="true" />


                       


        <!-- Lazy-loading is deactivated to demonstrate caching behavior -->   


        <set name="languages" table="EMPLOYEE_SPEAKS_LANGUAGE" lazy="false">


        <key column="emp_id"/>


             <many-to-many column="lan_id" class="Language"/>


       </set>                                  


    </class>


</hibernate-mapping>

假定你每次加载Employee对象时都需要加载语言。为了强制Hibernate自动加载语言,你应该设置lazy属性为false(这只是这个例子的需要。实际上,在大多数情况下,禁用Lasy属性不是很好的,只有在绝对必要的情况下才这样做)。这里仍然需要一个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();





       /**


       * Ensure that the Hibernate session is available


       * to avoid the Hibernate initialisation interfering with


       * the benchmarks


       */


       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个左右的Employee,耗时大概1秒。这太慢了。这是一个典型的N+1查询问题。如果你启用SQL日志,会发现一个对Employee表的查询后面跟着上百个对Language表的查询:每次Hibernate从缓存中获取一个Employee对象,都会从数据库加载所有的Language。怎么能改进这一点呢?首先要启用Employee对象的read/write缓存:




       <hibernate-mapping package="com.wakaleo.articles.caching.businessobjects">


        <class name="Employee" table="EMPLOYEE" dynamic-update="true">


              <meta attribute="implement-equals">true</meta>


              <cache usage="read-write"/>


            ...                          


        </class>


    </hibernate-mapping>


还需要启用Language对象的缓存,Read-only缓存即可:


    <class name="Language" table="SPOKEN_LANGUAGE" dynamic-update="true">


              <meta attribute="implement-equals">true</meta>   


              <cache usage="read-only"/>


            ...                          


        </class>


    </hibernate-mapping>

接下来,你应该配置缓存规则--增加下面的内容到ehcache.xml文件:




    <cache name="com.wakaleo.articles.caching.businessobjects.Employee"


        maxElementsInMemory="5000"


        eternal="false"


        overflowToDisk="false"


        timeToIdleSeconds="300"


        timeToLiveSeconds="600"


    />


    <cache name="com.wakaleo.articles.caching.businessobjects.Language"


        maxElementsInMemory="100"


        eternal="true"


        overflowToDisk="false"


    />

似乎已经可以了,但是这仍然不能解决N+1查询问题:当你加载一个Employee时,大约50左右的额外查询仍然执行。这种情况下你需要在Employee.hbm.xml中启用对Language引用的缓存。




<hibernate-mapping package="com.wakaleo.articles.caching.businessobjects">


    <class name="Employee" table="EMPLOYEE" dynamic-update="true">


              <meta attribute="implement-equals">true</meta>   





      <id name="id" type="long" unsaved-value="null" >


            <column name="emp_id" not-null="true"/>


            <generator class="increment"/>


      </id>





       <property column="emp_surname" name="surname" type="string"/>


       <property column="emp_firstname" name="firstname" type="string"/>


         


       <many-to-one name="country"


                     column="cn_id"


                    class="com.wakaleo.articles.caching.businessobjects.Country" 


                    not-null="true" />


                       


       <!-- Lazy-loading is deactivated to demonstrate caching behavior -->   


      <set name="languages" table="EMPLOYEE_SPEAKS_LANGUAGE" lazy="false">


           <cache usage="read-write"/>


           <key column="emp_id"/>


           <many-to-many column="lan_id" class="Language"/>


       </set>                                          


    </class>


</hibernate-mapping>

通过这样配置,你得到了优化的性能。




$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属性:




    <property name="hibernate.cache.use_query_cache">true</property>

然后,你在你希望缓存的查询中使用setCacheable()方法:




public class CountryDAO {





    public List getCountries() {


        return SessionManager.currentSession()


                             .createQuery("from Country as c order by c.name")


                               .setCacheable(true)(查询缓存)

                             .list();


    }


}

为了保证缓存中的查询结果的有效性,Hibernate在缓存的数据被应用修改的时候,把查询结果设置为失效,但是如果有其他的应用直接访问数据库,这种保证就失去意义了。因此如果你的数据必须在任何时候都是最新的,你就不要用任何的二级缓存(或者配置class缓存和collection缓存很短的时间过期)。

合理的Hibernate缓存

缓存是有效的技术,Hibernate提供了一个高效的、灵活的、优雅的实现方式。即使是默认的配置也能显著提高简单场景的性能。然而,和许多其他强大的工具一样,Hibernate需要一些思考和细致调整以达到最优的结果。和其他优化技术一样,缓存应该通过一种增量的、测试驱动的方式来实现。如果能正确地使用,少量的缓存能把系统的性能提升到最大化。

猜你喜欢

转载自gcq04552015.iteye.com/blog/1668047