原文:http://www.devx.com/dbzone/article/29685/1954
作者:John Ferguson Smart
翻译及加工: 魏超
因为对hibernate缓存的不了解,新接触hiberante开发的人往往无法很好的使用它。然而,合理的使用缓存将成为加速hibernate程序的最有效途径。
频繁的数据库读写会影响web项目的性能表现。作为一个高性能的对象/关系持久化查询技术,单纯的使用hibernate可能还不能解决你所有的性能问题。很多时候,开启二级缓存将会很好的改变这种境况。下面的文章会让你对缓存有个初步的了解,同时告诉你怎么用缓存来提升性能。
什么是缓存(Cache)?
缓存被广泛应用的用于优化数据库。当一些数据被从数据库中读取出来的时候,我们可以把它们放到缓存里.这样,在再次用到这些数据的时候,我们就可以直接从缓存把他们取出来,而不是去连接数据库。当然,当数据库的记录被修改更新的时候,我们就需要把缓存清空掉。因为我们无从得知在数据库记录更新时,缓存中的记录是否还和数据库里的相同
Hibernate有一级和二级两种缓存对象。一级缓存关联session对象,而二级缓存关联着session工厂对象。默认情况下,一级缓存是在单个事务中使用的。举例来说,在同一个事务中,当一个对象在事务提交前,被修改了很多次,那么同过一级缓存,在事务提交的时候,我们就会把所有这些修改写在同一条SQL语句中传递给数据库,而不是每次修改都有一条语句。当然,我们这篇文章关注的是二级缓存。可以这么说,相比一级缓存,二级缓存是跨事务的,它将一个事务中产生的查询对象保存下来,在其他事务执行相同的查询的时候,这些被保存的对象就可以被直接拿出来使用,这样就能最大化的减少数据库操作。因此,对于同一个服务,只要有一个用户执行了某个查询,那么其他将要执行相同查询的用户都将从二级缓存中受益。
此外,相对于上面说的缓存持久化对象,你可以使用query-level的缓存来存储真正的查询结果集。
市场上提供了相当多的缓存技术的选择,既有开源的也有收费的。 Hibernate支持下面的开源缓存:
以下是不同的缓存产品的特点:
另一个值得一提的是商业缓存Tangosol: Tangosol的高速缓存的一致性.
一旦选择了缓存实现,你还需要指定你的访问策略。以下四个缓存策略可供选择:
下表显示了可供选择的不同的缓存实现:
EHCache | 是的 | 是的 | 是的 | 没有 |
OSCache | 是的 | 是的 | 是的 | 没有 |
SwarmCache | 是的 | 是的 | 没有 | 没有 |
JBoss TreeCache | 是的 | 没有 | 没有 | 是的 |
下面的部分将展示在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>
当你Hibernate版本是3以上时,你可能还需要使用hibernate.cache.use_second_level_cache属性。这个属性让你可以激活(或停止)二级缓存。默认情况下,二级缓存使用的是EHCache并且已经激活。
这个例子由几个简单的表组成:Airport, Employee, Language,Country. 每个employee属于一个country,会说多国的language. 而每个country有许多的airport. 下面2幅图分别是这4个类的类图和数据库表图。
首先设置国家(Country)的Hibernate映射类:
<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>
如果这时候你要取所有的country列表,你可以在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的命令行来运行上面的代码。在这里我们连接了一个本地的MySQL数据库。当这段代码被成功的运行,你应该会看到下面的输出:
testGetCountries: 521 ms. testGetCountries: 357 ms. testGetCountries: 249 ms. testGetCountries: 257 ms. testGetCountries: 355 ms.
每次操作基本上要用4分之1秒。在多数情况下,上面我们取得的国家列表不会被频繁的修改。因此这是一个很好的例子来引入只读缓存(read-only Cache).
有2种方法可以激活二级缓存的类:
1. 在要使用二级缓存的那个类对应的hbm.xml文件中添加下面的属性:
<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"/> ... </class> </hibernate-mapping>
2. 或者把所有的缓存信息都记录在hibernate.cfg.xml文件中:
<hibernate-configuration> <session-factory> ... <property name="hibernate.cache.provider_class"> org.hibernate.cache.EHCacheProvider </property> ... <class-cache class="com.wakaleo.articles.caching.businessobjects.Country" usage="read-only" /> </session-factory> </hibernate-configuration>
接下来,你需要配置这个类的缓存规则。这些规则会决定缓存的一些细节。我们在这个例子中用的是EHChe缓存。当然,在其他不同的缓存中,配置规则的方式是不一样的。
EHCache需要一个配置文件(在类路径的根目录,一般称为ehcache.xml)。在下面这个站点有这个文件的模板: EHCache模板 。基本上,你要为每一个你想做二级缓存的类在这个文件中配置规则。如果你没有配置,程序将调用一个默认的规则。
对于这个例子,我们可以使用下面的简单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>
这个文件基本上建立了一个国家基于内存的缓存,最多300个元素(国家清单包含229个国家)。请注意,缓存永不过期(即'永恒=真正的财产)。
这个配置建立了一个基于内存的country缓存。里面最多可以保存300个元素(在我们这个例子中,查询出的结果集包含229个country).(注:这个缓存被配置为永远不过期。eternal=true)
现在,重新运行测试,结果如下:
testGetCountries: 412 ms. testGetCountries: 98 ms. testGetCountries: 92 ms. testGetCountries: 82 ms. testGetCountries: 93 ms.
可以明显的看出,操作时间提高到了10分之1秒左右(第一次用了400多ms是因为第一次真正和数据库交互了)
在继续下去之前,我们有必要先来了解在上面的代码背后发生了什么。值得注意的是,hibernate缓存并不存储对象的实例(Instances).实际上它存储的是对象的“dehydrated”格式(hibernate术语),这种格式其实就是一系列的属性值。下面是country缓存中内容的一个例子:
{ 30 => [bw,Botswana,30], 214 => [uy,Uruguay,214], 158 => [pa,Panama,158], 31 => [by,Belarus,31] 95 => [in,India,95] ... }
注意,每一个Id映射到一系列的属性上。这里你可能会发现,只有原始的属性被缓存下来了,具体来说,airport属性没有被缓存。实际上,这是因为airport属性是一个association:是一系列指向其他持久化对象的引用。
默认情况下,hibernate不会对associations进行缓存。当然,你可以设置是否缓存association,同时可以设置当二级缓存中的对象被重新加载出来的时候哪些associations要被检索。
Association缓存是一个非常强大的功能。下面我们将对它进行更详细的研究。
假设你需要获得一个country对应的所有employees的列表(包括employee名字, 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"> <key column="emp_id"/> <many-to-many column="lan_id" class="Language"/> </set> </class> </hibernate-mapping>
假如你真的需要在每次取得employee对象的时候都加载其对应的language信息,你需要把language属性的lazy设置为false.(不延迟加载)(这里只是为了举例的需要,在真实情况下,不建议取消延迟加载,因为这会影响性能)。同时,你需要添加下面的方法来获得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(); } }
运行这个单元测试,可以看到下面的结果:
testGetNZEmployees: 1227 ms. testGetAUEmployees: 883 ms. testGetNZEmployees: 907 ms. testGetAUEmployees: 873 ms. testGetNZEmployees: 987 ms. testGetAUEmployees: 916 ms.
可以看出,每次为一个country取得50几个employee都需要差不多1秒钟 。这样的速度太慢了。这是一个传统的"N+1"问题。如果启用SQL日志,我们可以看到,每次执行一个employee的查询语句后面,都跟着数百条language表的查询。每次我们从缓存中加载employee对象,其关联的全部language都会被重新检索。我们应该怎么改善这里的性能呢?首先,激活employee类的读写缓存(read/write cache):
<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 cache):
<class name="Language" table="SPOKEN_LANGUAGE" dynamic-update="true"> <meta attribute="implement-equals">true</meta> <cache usage="read-only"/> ... </class> </hibernate-mapping>
之后,在ehcache.xml文件中添加下面2个缓存规则:
<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的association.
<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>
这样配置之后,我们可以看到下面的结果(速度提高了10倍左右):
testGetNZEmployees: 1477 ms. testGetAUEmployees: 940 ms. testGetNZEmployees: 65 ms. testGetAUEmployees: 65 ms. testGetNZEmployees: 76 ms. testGetAUEmployees: 52 ms.
在某些情况下,我们需要缓存的是确切的查询结果集,而不是某些对象。例如,每次调用getCountries()方法的时候,我们都会得到相同的country列表。因此,除了缓存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中就过期了(hibernate会重新刷新这部分缓存)。然而,hibernate却无法获悉本身应用之外的,其他应用直接去修改数据库数据的操作。因此,如果你使用的数据会频繁的处于提交更新的状态下,你就不应该使用任何的二级缓存。如果非要使用的话,至少,你应该将二级缓存的超时时间设置的足够短。
缓存是一个强大的技术,Hibernate提供了一个有效,灵活且过度缓和的方式来实现它。即使是默认的配置也可以在许多简单的应用中有效的提高性能。然而,如同其他强大的工具一样,hibernate需要更深入的思考和微调,来取得最佳的效果。而缓存——如同其他的优化技术——应该遵循增量的,测试驱动的方法。当合理使用的时候,少量的缓存就可以使你程序发挥出最大的效能。