摘要:
为了解决缓存时间敏感对象这个挑战,作者Greg Nudelman在这篇文章中提出了一个基于时间戳的缓存构架。这个架构在保证传递最新信息的同时降低了object churning。在因object churning产生问题的地方应用这个便利的缓存代码可以有效的提高程序的性能,这样你就能看到用户和老板的微笑了。
使用标准的Java工具类建立动态的LRU缓存构架
摘要
Java 开发人员经常需要根据数据库检索出的JDBC结果集创建大量的复杂的对象。由于对每次用户请求,这些复杂的对象都要经过创建、使用和被JVM垃圾收集的过程(后文称这种行为“object churning”),这种做法的性能通常是不理想的。缓存这些对象看上去是一个非常明显的解决方案。但是,在大多数实时系统中,对象经常包含时间敏感的信息(如:价格或者有效性),这些信息会随机地变化而且不能将过期的信息返回给用户。由于这些类型的对象没有可靠的生存时间属性,所以无法使用简单的方法缓存,因此如何缓存这些对象是一个独特的挑战。为了解决缓存时间敏感对象这个挑战,作者Greg Nudelman在这篇文章中提出了一个基于时间戳的缓存构架。这个架构在保证传递最新信息的同时降低了object churning。在因object churning产生问题的地方应用这个便利的缓存代码可以有效的提高程序的性能,这样你就能看到用户和老板的微笑了。
版权声明:任何获得Matrix授权的网站,转载时请务必保留以下作者信息和链接
作者:Greg Nudelman; niuji(作者的Blog: http://blog.matrix.org.cn/page/niuji)
原文: http://www.javaworld.com/javaworld/jw-01-2005/jw-0103-caching.html
译文: http://www.matrix.org.cn/resource/article/44/44176_caching+framework.html
关键字:缓存,时间戳,最近最少使用。 Cache. Timestamp. LRU.
“故善用兵者,屈人之兵而非战也,拔人之城而非攻也,毁人之国而非久也,必以全争于天下,故兵不顿而利可全……”(《孙子兵法》—— 谋攻篇)
当我们垃圾收集那些完好的对象时,我们就是在“顿我们的兵”,而且重新创建这些对象浪费了宝贵的系统资源。通过实现一个缓存架构,我们希望大多数由创建复杂对象产生的“利”能够保持“全”。如果我们将完成的对象存入缓存,就可以避免重复创建这些对象,这样就能享受到程序性能提升的快乐。
如果应用可以接受稍微过时的数据,那么通过使用生存时间(后文简称TTL)属性,缓存系统可以满足大多数这样的应用的需要。但是,如果我们缓存的对象包含时间敏感的价格或有效性信息,那就必须保证返回给用户的数据是最新的。由于这样的对象没有可靠的TTL属性,用通常的方法无法有效地缓存,因此我们需要使用不同的方法缓存它们。其中一个成功的方法就是这篇文章将要讨论的基于时间戳的缓存构架(timestamp-based caching framework,后文简称TBCF)。
不缓存Britney Spears(译注:布兰妮•斯皮尔斯,美国流行歌手)
想象一个贩卖CD的简单网站,在这个网站上,用户可以检索CD,查询CD的价格,也可以进行购买。对每一次用户检索,我们的应用都会从数据库中载入CD的封面、详细信息、价格和用户评价。当所有的信息都被载入后,应用就会创建CD对象来存放这些从一个或多个JDBC结果集中得到的信息。然后,应用会将这些 CD对象中的信息转换成用户浏览器可以显示的HTML代码。在检索结果显示之后,这次用户请求就算结束,这时我们刚才花费大量资源创建的CD对象就可以被 JVM垃圾回收。当不同的用户检索同一个CD时(例如Britney Spears的CD),这个过程将不断重复。
虽然这种典型的设计很直观而且容易实现,但是它的性能往往是不理想的。对每一次用户的请求,CD对象都要经历一次“创建-使用-销毁”的循环(object churning)。这个循环在CD对象小而简单,而且系统负载不高的时候并不会造成什么问题。但是,当CD对象大而复杂,需要多次JDBC调用和复杂的计算的时候,它会大大降低系统性能。object-churning问题会随着应用负载的提高而迅速的恶化。由于对象没有重用,n个用户需要创建n组CD 对象,我们的应用就需要使用n组系统资源。图一展示了这个问题:
图一 没有缓存:n个用户需要n组对象,在负载高时会引起问题
为了解决object-churning问题,这些组合了多个数据的CD对象需要被存入缓存。典型的缓存实现很像一个Map,CD对象作为值保存,而它的 ID就作为它的key。每一次查询请求在检索数据库之前都先检索缓存,如果缓存命中的话,就不用再执行数据库查询了。因此,只要缓存设置恰当,就能重用更过的CD对象,同时也减少了churning循环中创建和销毁的对象数量。典型的缓存机制中,所有缓存的对象都有一个TTL属性,当对象在缓存中的时间超过TTL的时候,这个对象就是过期的。后台线程会定期地清除这些对象。("Develop a Generic Caching Service to Improve Performance" (JavaWorld, 2001年7月)里有个优秀的 TTL缓存实现)
这个方法在大多数应用中工作得很好,特别应用中的对象有明确定义的TTL属性,或者应用的用户可以接受稍微过时的数据。例如:运行一个产品需求月报表需要每晚载入的数据。在这个例子中一天内的数据是完全可接受的,用它即可以安全地创建所需要的报表。这些数据的TTL是确定的24小时,因此我们的缓存可以每晚清除过时的数据并从数据库中得到最新的数据,从而安全地提高数据在白天使用效率。
但当我们尝试缓存时间敏感的对象时,上述缓存机制就会出现问题。在大多数需要考虑产品的有效性和价格的案例中,使用过时的数据会造成严重的后果,所以我们需要一个绝对可靠的方法判断数据是否过期。这种数据包括根据市场需求随时改变的产品价格、每日交易的股价、抵押利率、根据实时数据生成的报表等等。在这些复杂的应用中,由于价格信息整天都在随机地变化,所以缓存的对象不存在有效的TTL属性,这样TTL缓存的后台刷新机制就完全不够严格了。图二展示了TTL缓存的这个不足:
图二:由于不知道TTL属性,缓存中的对象可能是过时的
由于我们不能将过时的股值提供给投资者,因此我们必须先确定缓存中的对象是否过期。在著名的理论试验“薛定谔的猫”(Schrodinger's Cat)中,外面的观察者不打破密闭的盒子就无法确定猫是死是活。TTL缓存提供给我们的是同样的不确定性:由于不知道TTL属性,缓存中的对象可能是过时的,也可能是最新的。唯一可以确定的方法是打开那个盒子,也就是查询数据库。因此,如果我们想只返回最新的数据,那就必须通过查询数据库来检查返回的对象是否过期,但这样就抵消了我们通过TTL缓存得到的效率提升。
为了确保将最新的数据递交给用户,我们需要一个更系统的确定过期的方法。我们必须为缓存中每个被请求的数据设计一个快速检查有效性(是否过期)的方案。我在这篇文章中提出的TBCF就是这样一个系统。
基于时间戳的缓存构架(TBCF)的需求
通过对我们的音乐商店(Music Shop)应用的了解,我们可以描述出我们这个新的TBCF的大致软件需求:
1. 我们这个新的缓存构架要能将object churning最小化,并尽可能多得重用缓存的对象,从而提高应用的性能。
2. 我们只想显示最新的价格和有效性,因此TBCF决不能返回过时的数据。
3. 我们需要兼容过去的系统。我们的应用不能使用世界上最大的构架是没有好处的。因此,我们这个新的构架要有一个即插即用的结构:它必须能在任何系统中简单地安装和配置。理想情况下,只要一个对象实现一个简单的Cacheable接口,TBCF就能允许任何应用缓存这个对象。
4. 我们希望能够简单地在我们的构架中插接任意一个缓存算法。因为单一的缓存算法并不能适用于每个系统,所以只要一个缓存类实现了必须的Cache接口,我们就要提供插接这个类的功能。(典型的缓存算法有:最近最不常用(LFU)、最近最少使用(LRU)、先进先出(FIFO))
时间戳:缓存的关键
我们好像陷入了两个相互矛盾的需求中:缓存尽可能多的对象和保证缓存对象的实时性。
为了满足这两个需求,我们使用了对象的时间戳属性。时间戳(Timestamp)代表了系统最后修改这个对象的时间,通常以Date类型或long型保存。(顺便说一下,在我们的构架中用于判断对象是否过期的Version属性也是和Timestamp有关的,通常以int型保存。)
使用数据库触发器是实现Timestamp属性的最简单的方法。当数据库中某一行数据的指定域被修改的时候,触发器就将timestamp列修改成最新的系统时间(Sysdate)。由于数据库触发器是独立于应用的,当有多个应用需要操作数据库中同一张表时,使用触发器是个不错的主意。在我们的音乐商店应用中,我们使用long表示的Java中Date类型作为我们的Timestamp。这个long类型的数值表示了从1970年1月1日开始经历的毫秒数。(70年代是个伟大的十年,那段时间带给了我们星球大战、Abba、朋克和青蛙柯米。)
UML序列图比文字描述更直观
为了形象地描述怎样使用Timestamp属性,我建立了一个UML序列图,如图三。
图三:TBCF中从缓存和数据库载入对象的UML序列图。
一个用户对Britney Spears的CD的请求需要进行的处理可以概括成下面几点:
1. 获得Britney CD的ID列表
2. 从数据库中取得这些ID的Timestamp属性
3. 缓存:用这些ID-Timestamp组合收集下面这些信息:
a. 在缓存中的未过期的对象
b. 在缓存中的和ID匹配但Timestamp不匹配的对象(过期的对象)
c. 在缓存中没有匹配对象的ID(缓存没有命中)
4. 数据库:载入未命中和过期对象的数据
5. 将所有新建的和重新载入的对象加入缓存
6. 合并3a和4中的列表
7. 排序合并后的列表
8. 将结果生成HTML返回给用户
使用面向对象的设计思想
为了使我们的构架模块化并具有可扩展性,我们需要使用最好的面向对象设计的原则来创建我们的Java类。我们要求使用TBCF缓存的对象实现Cacheable接口(不用继承某个基类):
对于那些没有loadFromIds()方法的对象,我们也提供了一个基于Java反射API的替代的读取数据库的静态方法。为了使用这个载入方法,对象需要实现一个简单的Timestamped接口,代码在下面给出。使用一个静态的载入方法允许TBCF兼容更大范围的过去的系统。
为了让使用者可以应用任何缓存方案(LRU,LFU等),我们使用Cache接口(扩展了java.util.Map)作为缓存类的公共接口:
LRULinkedMapCache 缓存类基于流行的LRU算法,它继承了java.util中了LinkedHashMap类(J2se 1.4以上版本)。在这个构架中,我们尽可能使用标准的java.util中的接口(Collection、List和Map)作为方法的返回类型。
为解决多线程和同步问题,我们使用了Sun的Java命名约定(Sun Java naming convention)里的同步包装器(wrapper)(xxxTable包装成xxxMap)。
我们用DAO模式(Data Access Object)开发我们的CacheManager:CacheManage能自动地从缓存中载入一部分数据,从数据库中载入另一部分,这完全对调用者透明。CacheManage是一个singleton,实现了静态的Cache接口的初始化。
TBCF拥有动态的Java运行时配置能力,配置可通过载入属性文件或在命令行输入进行。为了简单和可靠,我们尽量使用不可变对象(例如TimestampedId)。运行时我们只抛出恰当级别的异常(例如ResourceLoadException)。为了使我们对设计意图清晰明了,对只包含静态工具方法的Util类,我们将其声明成final并设计成不能初始化的。
最终我们的UML类图如图四:
图四:TBCF的UML类图。
研究下代码
为了关注于描述功能,下面使用的代码片断中我省略了错误处理、注释和许多其他的基本的细节。如果你想细读这一章节的话,最好看下实际的代码(相关资源里给出了下载)。
第一步: 用户搜索:getCdIdsThatMatchUserSearch()
在这一节中,我们载入了所有Britney Spears的CD的ID/timestamp。如果我们不使用cache,我们首先会创建包含CD所有数据的列表。但是,TBCF的第一个需求是减少 object churning,所以我们只载入较小的不可变对象TimestampedIds的列表。就和它的名字一样,这个对象只有两个属性:Id和 Timestamp。Id用来从缓存或数据库中取CD对象,Timestamp用来检查缓存的对象是否过期,保证返回的数据是最新的。 TimestampedId被设计成轻量级的,这样就可以被快速地创建同时只会给我们的应用带来很小的负载:
第二步: CacheManager的重要方法:loadFromCacheAndDB()
loadFromCacheAndDB ()是我们这个构架中最重要的方法。我们遵循DAO(Data Access Object)设计模式编写了这个方法:我们提供给loadFromCacheAndDB()一个ID的列表,这个方法就会自动地先从缓存取出有效的对象,然后从数据库中载入余下的对象。
下面描述了这个方法是如何工作的:首先,loadFromCacheAndDB()建立了两个列表: currentCached和refreshFromDb。根据第一步中提供的TimestampedIds列表(包含了所有需要载入的CD),我们使用 fillFreshAndStaleFromCache()方法填充current和refresh列表;然后,根据refresh列表中的ID从数据库载入对象,这一步将使用被缓存对象的loadFromIds()方法(这个方法在接口Cacheable中定义)。通过使用这种方法,我们优化了我们的重取策略:那些不存在缓存中的对象才会通过查询数据库创建。
使用这个方法必须注意:执行自动载入的方法必须是实现了Cacheable的实例。这样做使我们能灵活地通过Java接口调用不同的load()方法。而JVM只需要知道调用哪个load()方法。举例来说, cd.loadFromIds()和author.loadFromIds()将从不同的表中载入数据,因此我们需要一个Cacheable的实例来帮助 JVM选择正确的方法。
在许多案例中更多的使用了静态的load()方法,但由于接口不能定义静态方法,所以我们必须提供一个虚拟的Cacheable实例以提供载入方法的调用。在这节的后面,我会讨论另一种载入机制:使用Java的反射调用静态的load()方法。
当所有的过期和缓存未命中的对象都被载入后,我们就会根据LRU算法将这些对象加入我们的缓存,这个算法在LRULInkedMapCache中实现。如果缓存已满,算法会移除一个最近最少使用的对象,然后将一个新对象加入缓存。最后,我们将currentCached和newlyLoaded两个列表合并成一个新的列表,从缓存和数据库中载入对象就完成了。
第三步: CacheManager过滤:fillFreshAndStaleFromCache()
在fillFreshAndStaleFromCache ()方法中,我们遍历了输入的Britney CD的TimestampedIds列表,判断哪些对象需要从数据库中读取,哪些可以从缓存中得到。我们使用TimestampedId.Id属性从缓存中查找对象。如果缓存中不存在这个对象,我们就将这个ID加入idsToRefresh列表;如果在缓存中找到了对应的对象,那么就比较这个对象的 timestamp和TimestampedId.timestamp属性,如果他们想等,那么这个缓存的对象是最新的,我们将它加入到 currentObject列表;如果timestamp不相等,那么这个对象是过期的,我们将它的ID加入idsToRefresh列表。
这个方法使用了作为参数传入的两个列表:currentObjectsFromCache和idsToRefreshFromDb。由于我们会修改这两个全局的列表,所以这种设计不是最好的。但是因这个方法是私有的,而且这样设计可以提高效率,所以在这是完全可以的。它能够让我们只遍历一次 TimestampedId列表就能同时填充current和refresh两个列表:
第四步: Cacheable的loadFromIds()
我们创建第三步中的过期和缓存未命中的对象的具体代码就在loadFromIds()里。我们将ID列表传递给这个方法,这个方法返回一个已经完成数据载入的CD对象的集合。为了一次载入所有CD的数据,我们使用Util类中一个非常方便的方法:将ID列表中的ID用单引号引用,然后将这些带引号的ID用逗号分隔组成一个字符串commaDelimitedIds(例如"'1','2,'3'"),这样就可以在SQL的IN语句中直接使用:
第五步: 排序并显示结果:performFinalSort()
performFinalSort ()在TBCF获得CD列表后执行。这里,我们将这些组合的已完全载入的CD对象根据标题排序,这我们需要用到Comparator。由于对TBCF来说,输入的CD列表都是未排序的,所以在构架里使用Java排序并没有什么好处。由于我们需要合并缓存中和从数据库得到的对象来得到这个列表,即使这两个列表已经是排序的,合并之后的列表也不能保证是排序的。下面是执行final sort的代码。我们使用了匿名内部类实现了Comparator接口。
现在一个排序的CD列表就可以转换成HTML发送给用户浏览器,或在应用的其他地方使用了。TBCF使我们的Music Store应用从缓存中读取未过期的对象,过期的从数据库载入,从而改善了应用的性能。只有过期和缓存未命中的对象才会被创建,这在保证数据实时的同时降低了object churning。
另一个从数据库载入对象的方法
让所有的CD对象实现Cacheable接口,以便使TBCF能自动地从数据库中载入这些对象的数据,这种方法在大多数应用上工作得很好。就像我在上面描述的那样,使用这种方法我们需要一个Cacheable对象的实例执行接口中定义的loadFromIds()方法来载入对象。尽管这个解决方案是一流的而且很容易和过去的系统整合,许多商业类已经松散地遵循了EJB(Enterprise JavaBean)的entity bean方法集合并对根据传入的对象ID载入对象的load()方法做了些改动。但是,某些过去的系统可能没有我们可以方便地使用的load()方法,或者系统更希望使用静态的load()方法。
没有实例化的load()方法是否就不能使用我们的构架呢?绝对不是这样。在这种情况下,Java反射API为我们提供一个调用静态方法的便捷方式。当然,使用反射更容易出错、而且不怎么优雅、更会消耗更多的资源。但是,它也非常灵活而且使用简单。
Java 反射API提供了执行运行时load()方法配置的方法,而且这个load()方法可以被声明成静态的。这使得TBCF可以缓存非常多的不同的对象,而且为与大量的过去的系统兼容提供了最大化的即插即用功能。尽管如此我们需要缓存的对象仍要实现Timestamped接口。我们的构架需要得到Id和 Timestamp属性来判断对象是否过期(这是没有其他办法的)。但是反射优雅地解决了访问用户定义的静态load()方法问题。基于反射的或基于实例的loadFromCacheAndDB()方法被重载,这样它们就可以在我们的构架中共存。
CacheManage中使用反射的载入方法:loadFromCacheAndDB()
loadFromCacheAndDB ()方法执行过程如下:和先前一样,我们建立两个列表:currentCached和refreshFromDb,然后从缓存中填充这两个列表。然而,在这载入机制使用了不同的实现:我们不使用一个虚拟的Cacheable类的实例,而是传入我们想要执行的load()方法的名字和声明这个load()方法的类的名字。然后,通过Java反射API调用这个静态的load()方法。缓存未命中和过期的对象载入后,我们做的工作和前面一样:更新缓存,合并 currentCached和newlyLoaded列表,排序合并后的列表。最后的结果是完全一样的:我们得到了一个完全载入的排序的列表,这个列表中的对象是从缓存和数据库中取到的最新的对象:
使用反射载入:loadStaleOrMissingFromDB()
这节示范了使用Java反射API调用静态load()方法的机制。我们将静态load()方法的名字和调用这个方法的类的名字作为参数传递给 loadStaleOrMissingFromDB()。我们使用常用的反射调用方法:建立了一个Class数组表示load方法参数的类型,然后得到 load()的Method对象引用。接着我们建立了作为load方法参数的Object数组,将需要载入的Britney CD的ID列表和数据库连接放入数组中。最后调用load()的Method对象的invoke()方法执行load。因为load()方法是静态的,所以invoke的第一个参数(调用load方法的实例)是null:
使用反射使我们能够访问静态的load方法,同时增加了对更大范围过去的系统的兼容性。
完成的需求
最后,让我们重新看一下我在文章开始阶段定义的需求,确保我们已经完成了它们:
1. 通过从缓存中载入实时的对象和从数据库中载入过期的,我们的TBCF最大程度地提高了应用的性能。只有缓存未命中的和过期的对象被创建,在保证数据实时的情况下最小化了object churning。
2. 为检查是否过期,我们使用了对象的一个特殊实例的Timestamp属性代替传统的TTL属性。因此,我们能确保分派了最新的信息。只要timestamp使用数据库触发器或其他可信赖的方法更新,TBCF就不会返回过时的数据。
3. 我们解决了TBCF和过去的系统的兼容问题。TBCF安装简单,而且可以通过命令行或配置文件在运行时进行配置。只要对象实现了Cacheable接口, TBCF允许任何过去的系统的对象被缓存。此外,我们通过使用Java反射API支持在运行时直接调用静态的load方法,这样就允许更多过去的系统的类被缓存。
4. 虽然我们使用LRU缓存类来搭建我们的构架,但只要实现了必须的Cache接口,任何缓存算法都可以简单地加入我们的构架。
结论
TBCF 可以将性能提高到一个较高的层次,而且降低了时间敏感对象的churning,同时又不会影响用户得到的数据的实时性。这个架构使用的自动优化的对象载入(从缓存和数据库中分别载入)策略是久经考验的,它使用对象的timestamp属性来保证数据过期判断的可靠性。这个构架有着简单而直接的设计而且只需要较低的资源开销。它使用了Java标准的优秀的工具类来创建,可以在许多过去的系统中使用,而且可以使用任意的缓存算法。使用这个构架,开发人员可以使 JVM必须消耗并重新创建的对象数量降到最低,大大提高应用的性能,同时还能保证这些实时信息的可用性。
作者介绍:
作者:Greg Nudelman; niuji(niuji的Blog: http://blog.matrix.org.cn/page/niuji)
Greg Nudelman:旧金山金门大学电脑咨询系统-科学硕士。现居住旧金山湾区。有6年多的Java和SQL开发经验。为生物和贷款公司设计开发了多个多层分布式系统。
相关资源
文中源码下载:[urlhttp://www.javaworld.com/javaworld/jw-01-2005/caching/jw-0103- caching.zip ]http://www.javaworld.com/javaworld/jw-01-2005/caching/jw-0103-caching.zip[/url]
孙子兵法:http://www.sonshi.com/sun3.html
一个优秀的TTL缓存,参见"Develop a Generic Caching Service to Improve Performance"(Jonathan Lurie, JavaWorld, 2001年7月):
摘要
Java 开发人员经常需要根据数据库检索出的JDBC结果集创建大量的复杂的对象。由于对每次用户请求,这些复杂的对象都要经过创建、使用和被JVM垃圾收集的过程(后文称这种行为“object churning”),这种做法的性能通常是不理想的。缓存这些对象看上去是一个非常明显的解决方案。但是,在大多数实时系统中,对象经常包含时间敏感的信息(如:价格或者有效性),这些信息会随机地变化而且不能将过期的信息返回给用户。由于这些类型的对象没有可靠的生存时间属性,所以无法使用简单的方法缓存,因此如何缓存这些对象是一个独特的挑战。为了解决缓存时间敏感对象这个挑战,作者Greg Nudelman在这篇文章中提出了一个基于时间戳的缓存构架。这个架构在保证传递最新信息的同时降低了object churning。在因object churning产生问题的地方应用这个便利的缓存代码可以有效的提高程序的性能,这样你就能看到用户和老板的微笑了。
版权声明:任何获得Matrix授权的网站,转载时请务必保留以下作者信息和链接
作者:Greg Nudelman; niuji(作者的Blog: http://blog.matrix.org.cn/page/niuji)
原文: http://www.javaworld.com/javaworld/jw-01-2005/jw-0103-caching.html
译文: http://www.matrix.org.cn/resource/article/44/44176_caching+framework.html
关键字:缓存,时间戳,最近最少使用。 Cache. Timestamp. LRU.
“故善用兵者,屈人之兵而非战也,拔人之城而非攻也,毁人之国而非久也,必以全争于天下,故兵不顿而利可全……”(《孙子兵法》—— 谋攻篇)
当我们垃圾收集那些完好的对象时,我们就是在“顿我们的兵”,而且重新创建这些对象浪费了宝贵的系统资源。通过实现一个缓存架构,我们希望大多数由创建复杂对象产生的“利”能够保持“全”。如果我们将完成的对象存入缓存,就可以避免重复创建这些对象,这样就能享受到程序性能提升的快乐。
如果应用可以接受稍微过时的数据,那么通过使用生存时间(后文简称TTL)属性,缓存系统可以满足大多数这样的应用的需要。但是,如果我们缓存的对象包含时间敏感的价格或有效性信息,那就必须保证返回给用户的数据是最新的。由于这样的对象没有可靠的TTL属性,用通常的方法无法有效地缓存,因此我们需要使用不同的方法缓存它们。其中一个成功的方法就是这篇文章将要讨论的基于时间戳的缓存构架(timestamp-based caching framework,后文简称TBCF)。
不缓存Britney Spears(译注:布兰妮•斯皮尔斯,美国流行歌手)
想象一个贩卖CD的简单网站,在这个网站上,用户可以检索CD,查询CD的价格,也可以进行购买。对每一次用户检索,我们的应用都会从数据库中载入CD的封面、详细信息、价格和用户评价。当所有的信息都被载入后,应用就会创建CD对象来存放这些从一个或多个JDBC结果集中得到的信息。然后,应用会将这些 CD对象中的信息转换成用户浏览器可以显示的HTML代码。在检索结果显示之后,这次用户请求就算结束,这时我们刚才花费大量资源创建的CD对象就可以被 JVM垃圾回收。当不同的用户检索同一个CD时(例如Britney Spears的CD),这个过程将不断重复。
虽然这种典型的设计很直观而且容易实现,但是它的性能往往是不理想的。对每一次用户的请求,CD对象都要经历一次“创建-使用-销毁”的循环(object churning)。这个循环在CD对象小而简单,而且系统负载不高的时候并不会造成什么问题。但是,当CD对象大而复杂,需要多次JDBC调用和复杂的计算的时候,它会大大降低系统性能。object-churning问题会随着应用负载的提高而迅速的恶化。由于对象没有重用,n个用户需要创建n组CD 对象,我们的应用就需要使用n组系统资源。图一展示了这个问题:
![](http://www.matrix.org.cn/resource/upload/forum/2006_01_19_001130_SnjiqoRfwQ.gif)
图一 没有缓存:n个用户需要n组对象,在负载高时会引起问题
为了解决object-churning问题,这些组合了多个数据的CD对象需要被存入缓存。典型的缓存实现很像一个Map,CD对象作为值保存,而它的 ID就作为它的key。每一次查询请求在检索数据库之前都先检索缓存,如果缓存命中的话,就不用再执行数据库查询了。因此,只要缓存设置恰当,就能重用更过的CD对象,同时也减少了churning循环中创建和销毁的对象数量。典型的缓存机制中,所有缓存的对象都有一个TTL属性,当对象在缓存中的时间超过TTL的时候,这个对象就是过期的。后台线程会定期地清除这些对象。("Develop a Generic Caching Service to Improve Performance" (JavaWorld, 2001年7月)里有个优秀的 TTL缓存实现)
这个方法在大多数应用中工作得很好,特别应用中的对象有明确定义的TTL属性,或者应用的用户可以接受稍微过时的数据。例如:运行一个产品需求月报表需要每晚载入的数据。在这个例子中一天内的数据是完全可接受的,用它即可以安全地创建所需要的报表。这些数据的TTL是确定的24小时,因此我们的缓存可以每晚清除过时的数据并从数据库中得到最新的数据,从而安全地提高数据在白天使用效率。
但当我们尝试缓存时间敏感的对象时,上述缓存机制就会出现问题。在大多数需要考虑产品的有效性和价格的案例中,使用过时的数据会造成严重的后果,所以我们需要一个绝对可靠的方法判断数据是否过期。这种数据包括根据市场需求随时改变的产品价格、每日交易的股价、抵押利率、根据实时数据生成的报表等等。在这些复杂的应用中,由于价格信息整天都在随机地变化,所以缓存的对象不存在有效的TTL属性,这样TTL缓存的后台刷新机制就完全不够严格了。图二展示了TTL缓存的这个不足:
![image](http://www.matrix.org.cn/resource/upload/forum/2006_01_19_001134_SUDBHkiZHI.gif)
图二:由于不知道TTL属性,缓存中的对象可能是过时的
由于我们不能将过时的股值提供给投资者,因此我们必须先确定缓存中的对象是否过期。在著名的理论试验“薛定谔的猫”(Schrodinger's Cat)中,外面的观察者不打破密闭的盒子就无法确定猫是死是活。TTL缓存提供给我们的是同样的不确定性:由于不知道TTL属性,缓存中的对象可能是过时的,也可能是最新的。唯一可以确定的方法是打开那个盒子,也就是查询数据库。因此,如果我们想只返回最新的数据,那就必须通过查询数据库来检查返回的对象是否过期,但这样就抵消了我们通过TTL缓存得到的效率提升。
为了确保将最新的数据递交给用户,我们需要一个更系统的确定过期的方法。我们必须为缓存中每个被请求的数据设计一个快速检查有效性(是否过期)的方案。我在这篇文章中提出的TBCF就是这样一个系统。
基于时间戳的缓存构架(TBCF)的需求
通过对我们的音乐商店(Music Shop)应用的了解,我们可以描述出我们这个新的TBCF的大致软件需求:
1. 我们这个新的缓存构架要能将object churning最小化,并尽可能多得重用缓存的对象,从而提高应用的性能。
2. 我们只想显示最新的价格和有效性,因此TBCF决不能返回过时的数据。
3. 我们需要兼容过去的系统。我们的应用不能使用世界上最大的构架是没有好处的。因此,我们这个新的构架要有一个即插即用的结构:它必须能在任何系统中简单地安装和配置。理想情况下,只要一个对象实现一个简单的Cacheable接口,TBCF就能允许任何应用缓存这个对象。
4. 我们希望能够简单地在我们的构架中插接任意一个缓存算法。因为单一的缓存算法并不能适用于每个系统,所以只要一个缓存类实现了必须的Cache接口,我们就要提供插接这个类的功能。(典型的缓存算法有:最近最不常用(LFU)、最近最少使用(LRU)、先进先出(FIFO))
时间戳:缓存的关键
我们好像陷入了两个相互矛盾的需求中:缓存尽可能多的对象和保证缓存对象的实时性。
为了满足这两个需求,我们使用了对象的时间戳属性。时间戳(Timestamp)代表了系统最后修改这个对象的时间,通常以Date类型或long型保存。(顺便说一下,在我们的构架中用于判断对象是否过期的Version属性也是和Timestamp有关的,通常以int型保存。)
使用数据库触发器是实现Timestamp属性的最简单的方法。当数据库中某一行数据的指定域被修改的时候,触发器就将timestamp列修改成最新的系统时间(Sysdate)。由于数据库触发器是独立于应用的,当有多个应用需要操作数据库中同一张表时,使用触发器是个不错的主意。在我们的音乐商店应用中,我们使用long表示的Java中Date类型作为我们的Timestamp。这个long类型的数值表示了从1970年1月1日开始经历的毫秒数。(70年代是个伟大的十年,那段时间带给了我们星球大战、Abba、朋克和青蛙柯米。)
UML序列图比文字描述更直观
为了形象地描述怎样使用Timestamp属性,我建立了一个UML序列图,如图三。
![image](http://www.matrix.org.cn/resource/upload/forum/2006_01_19_001207_cLHJMoAmfu.gif)
图三:TBCF中从缓存和数据库载入对象的UML序列图。
一个用户对Britney Spears的CD的请求需要进行的处理可以概括成下面几点:
1. 获得Britney CD的ID列表
2. 从数据库中取得这些ID的Timestamp属性
3. 缓存:用这些ID-Timestamp组合收集下面这些信息:
a. 在缓存中的未过期的对象
b. 在缓存中的和ID匹配但Timestamp不匹配的对象(过期的对象)
c. 在缓存中没有匹配对象的ID(缓存没有命中)
4. 数据库:载入未命中和过期对象的数据
5. 将所有新建的和重新载入的对象加入缓存
6. 合并3a和4中的列表
7. 排序合并后的列表
8. 将结果生成HTML返回给用户
使用面向对象的设计思想
为了使我们的构架模块化并具有可扩展性,我们需要使用最好的面向对象设计的原则来创建我们的Java类。我们要求使用TBCF缓存的对象实现Cacheable接口(不用继承某个基类):
public interface Cacheable extends Timestamped {
public Collection loadFromIds(Collection idsToRefreshFromDb, Connection conn) throws ResourceLoadException;
}
对于那些没有loadFromIds()方法的对象,我们也提供了一个基于Java反射API的替代的读取数据库的静态方法。为了使用这个载入方法,对象需要实现一个简单的Timestamped接口,代码在下面给出。使用一个静态的载入方法允许TBCF兼容更大范围的过去的系统。
public interface Timestamped {
public String getId();
public long getTimestamp();
}
为了让使用者可以应用任何缓存方案(LRU,LFU等),我们使用Cache接口(扩展了java.util.Map)作为缓存类的公共接口:
public interface Cache extends java.util.Map {
/* Additional methods for Cache (not found in Map). */
//Returns the maximum size of the cache.
public int getMaxSize();
}
LRULinkedMapCache 缓存类基于流行的LRU算法,它继承了java.util中了LinkedHashMap类(J2se 1.4以上版本)。在这个构架中,我们尽可能使用标准的java.util中的接口(Collection、List和Map)作为方法的返回类型。
为解决多线程和同步问题,我们使用了Sun的Java命名约定(Sun Java naming convention)里的同步包装器(wrapper)(xxxTable包装成xxxMap)。
我们用DAO模式(Data Access Object)开发我们的CacheManager:CacheManage能自动地从缓存中载入一部分数据,从数据库中载入另一部分,这完全对调用者透明。CacheManage是一个singleton,实现了静态的Cache接口的初始化。
TBCF拥有动态的Java运行时配置能力,配置可通过载入属性文件或在命令行输入进行。为了简单和可靠,我们尽量使用不可变对象(例如TimestampedId)。运行时我们只抛出恰当级别的异常(例如ResourceLoadException)。为了使我们对设计意图清晰明了,对只包含静态工具方法的Util类,我们将其声明成final并设计成不能初始化的。
最终我们的UML类图如图四:
![image](http://www.matrix.org.cn/resource/upload/forum/2006_01_19_001211_ZNWFBPOYuH.gif)
图四:TBCF的UML类图。
研究下代码
为了关注于描述功能,下面使用的代码片断中我省略了错误处理、注释和许多其他的基本的细节。如果你想细读这一章节的话,最好看下实际的代码(相关资源里给出了下载)。
第一步: 用户搜索:getCdIdsThatMatchUserSearch()
在这一节中,我们载入了所有Britney Spears的CD的ID/timestamp。如果我们不使用cache,我们首先会创建包含CD所有数据的列表。但是,TBCF的第一个需求是减少 object churning,所以我们只载入较小的不可变对象TimestampedIds的列表。就和它的名字一样,这个对象只有两个属性:Id和 Timestamp。Id用来从缓存或数据库中取CD对象,Timestamp用来检查缓存的对象是否过期,保证返回的数据是最新的。 TimestampedId被设计成轻量级的,这样就可以被快速地创建同时只会给我们的应用带来很小的负载:
public static List getCdIdsThatMatchUserSearch(String artist)
throws ResourceLoadException {
rs = stmt.executeQuery("SELECT id, timestamp FROM cd WHERE artist = '" + artist + "'");
TimestampedId id = null;
while (rs.next()) {
id = new TimestampedId(rs.getString(1), rs.getLong(2));
returnVec.addElement(id);
}
return returnVec;
}
第二步: CacheManager的重要方法:loadFromCacheAndDB()
loadFromCacheAndDB ()是我们这个构架中最重要的方法。我们遵循DAO(Data Access Object)设计模式编写了这个方法:我们提供给loadFromCacheAndDB()一个ID的列表,这个方法就会自动地先从缓存取出有效的对象,然后从数据库中载入余下的对象。
下面描述了这个方法是如何工作的:首先,loadFromCacheAndDB()建立了两个列表: currentCached和refreshFromDb。根据第一步中提供的TimestampedIds列表(包含了所有需要载入的CD),我们使用 fillFreshAndStaleFromCache()方法填充current和refresh列表;然后,根据refresh列表中的ID从数据库载入对象,这一步将使用被缓存对象的loadFromIds()方法(这个方法在接口Cacheable中定义)。通过使用这种方法,我们优化了我们的重取策略:那些不存在缓存中的对象才会通过查询数据库创建。
使用这个方法必须注意:执行自动载入的方法必须是实现了Cacheable的实例。这样做使我们能灵活地通过Java接口调用不同的load()方法。而JVM只需要知道调用哪个load()方法。举例来说, cd.loadFromIds()和author.loadFromIds()将从不同的表中载入数据,因此我们需要一个Cacheable的实例来帮助 JVM选择正确的方法。
在许多案例中更多的使用了静态的load()方法,但由于接口不能定义静态方法,所以我们必须提供一个虚拟的Cacheable实例以提供载入方法的调用。在这节的后面,我会讨论另一种载入机制:使用Java的反射调用静态的load()方法。
当所有的过期和缓存未命中的对象都被载入后,我们就会根据LRU算法将这些对象加入我们的缓存,这个算法在LRULInkedMapCache中实现。如果缓存已满,算法会移除一个最近最少使用的对象,然后将一个新对象加入缓存。最后,我们将currentCached和newlyLoaded两个列表合并成一个新的列表,从缓存和数据库中载入对象就完成了。
public static List loadFromCacheAndDB(List timestampedIds,
Cacheable cacheable) throws ResourceLoadException {
Vector currentObjectsFromCache = new Vector();
Vector idsToRefreshFromDb = new Vector();
fillFreshAndStaleFromCache(
timestampedIds,
idsToRefreshFromDb,
currentObjectsFromCache);
Collection objectsFromDb = cacheable.loadFromIds(idsToRefreshFromDb);
updateCache(objectsFromDb);
currentObjectsFromCache.addAll(objectsFromDb); //combine lists
return currentObjectsFromCache;
}
第三步: CacheManager过滤:fillFreshAndStaleFromCache()
在fillFreshAndStaleFromCache ()方法中,我们遍历了输入的Britney CD的TimestampedIds列表,判断哪些对象需要从数据库中读取,哪些可以从缓存中得到。我们使用TimestampedId.Id属性从缓存中查找对象。如果缓存中不存在这个对象,我们就将这个ID加入idsToRefresh列表;如果在缓存中找到了对应的对象,那么就比较这个对象的 timestamp和TimestampedId.timestamp属性,如果他们想等,那么这个缓存的对象是最新的,我们将它加入到 currentObject列表;如果timestamp不相等,那么这个对象是过期的,我们将它的ID加入idsToRefresh列表。
这个方法使用了作为参数传入的两个列表:currentObjectsFromCache和idsToRefreshFromDb。由于我们会修改这两个全局的列表,所以这种设计不是最好的。但是因这个方法是私有的,而且这样设计可以提高效率,所以在这是完全可以的。它能够让我们只遍历一次 TimestampedId列表就能同时填充current和refresh两个列表:
private static void fillFreshAndStaleFromCache(
List timestampedIds,
Vector idsToRefreshFromDb,
Vector currentObjectsFromCache) {
TimestampedId timestampedId = null;
Timestamped c = null;
for(Iterator i = timestampedIds.iterator(); i.hasNext();) {
timestampedId = (TimestampedId)i.next();
c = (Timestamped)cache.get(timestampedId.getId());
if(null == c) {
//<NOT FOUND>
idsToRefreshFromDb.add(timestampedId.getId());
continue;
} else if(c.getTimestamp() != timestampedId.getTimestamp()) {
//<STALE>
idsToRefreshFromDb.add(timestampedId.getId());
continue;
} else {
//<FRESH>
currentObjectsFromCache.add(c);
}
}
}
第四步: Cacheable的loadFromIds()
我们创建第三步中的过期和缓存未命中的对象的具体代码就在loadFromIds()里。我们将ID列表传递给这个方法,这个方法返回一个已经完成数据载入的CD对象的集合。为了一次载入所有CD的数据,我们使用Util类中一个非常方便的方法:将ID列表中的ID用单引号引用,然后将这些带引号的ID用逗号分隔组成一个字符串commaDelimitedIds(例如"'1','2,'3'"),这样就可以在SQL的IN语句中直接使用:
public Collection loadFromIds(Collection idsToRefreshFromDb)
throws ResourceLoadException {
Vector objectsFromDb = new Vector();
String commaDelimitedIds =
Util.makeCommaDelimitedStringsList(idsToRefreshFromDb);
String sql = new StringBuffer()
.append("SELECT id, title, artist")
.append("FROM cd WHERE id IN (")
.append(commaDelimitedIds) //"'1','2,'3'"
.append(")").toString();
stmt = conn.createStatement();
rs = stmt.executeQuery(sql);
//Load complete CDs from the result set
CD cd = null;
while (rs.next()) {
cd = new CD();
cd.id = rs.getString(1);
cd.title = rs.getString(2);
cd.artist = rs.getString(3);
objectsFromDb.addElement(cd);
}
return objectsFromDb;
}
第五步: 排序并显示结果:performFinalSort()
performFinalSort ()在TBCF获得CD列表后执行。这里,我们将这些组合的已完全载入的CD对象根据标题排序,这我们需要用到Comparator。由于对TBCF来说,输入的CD列表都是未排序的,所以在构架里使用Java排序并没有什么好处。由于我们需要合并缓存中和从数据库得到的对象来得到这个列表,即使这两个列表已经是排序的,合并之后的列表也不能保证是排序的。下面是执行final sort的代码。我们使用了匿名内部类实现了Comparator接口。
private static void performFinalSort(List cds) {
Collections.sort(cds,
new Comparator() {
public int compare(Object cd1, Object cd2) {
return ( ((CD)cd1).getTitle() )
.compareTo( ((CD)cd2).getTitle() );
}
}
);
}
现在一个排序的CD列表就可以转换成HTML发送给用户浏览器,或在应用的其他地方使用了。TBCF使我们的Music Store应用从缓存中读取未过期的对象,过期的从数据库载入,从而改善了应用的性能。只有过期和缓存未命中的对象才会被创建,这在保证数据实时的同时降低了object churning。
另一个从数据库载入对象的方法
让所有的CD对象实现Cacheable接口,以便使TBCF能自动地从数据库中载入这些对象的数据,这种方法在大多数应用上工作得很好。就像我在上面描述的那样,使用这种方法我们需要一个Cacheable对象的实例执行接口中定义的loadFromIds()方法来载入对象。尽管这个解决方案是一流的而且很容易和过去的系统整合,许多商业类已经松散地遵循了EJB(Enterprise JavaBean)的entity bean方法集合并对根据传入的对象ID载入对象的load()方法做了些改动。但是,某些过去的系统可能没有我们可以方便地使用的load()方法,或者系统更希望使用静态的load()方法。
没有实例化的load()方法是否就不能使用我们的构架呢?绝对不是这样。在这种情况下,Java反射API为我们提供一个调用静态方法的便捷方式。当然,使用反射更容易出错、而且不怎么优雅、更会消耗更多的资源。但是,它也非常灵活而且使用简单。
Java 反射API提供了执行运行时load()方法配置的方法,而且这个load()方法可以被声明成静态的。这使得TBCF可以缓存非常多的不同的对象,而且为与大量的过去的系统兼容提供了最大化的即插即用功能。尽管如此我们需要缓存的对象仍要实现Timestamped接口。我们的构架需要得到Id和 Timestamp属性来判断对象是否过期(这是没有其他办法的)。但是反射优雅地解决了访问用户定义的静态load()方法问题。基于反射的或基于实例的loadFromCacheAndDB()方法被重载,这样它们就可以在我们的构架中共存。
CacheManage中使用反射的载入方法:loadFromCacheAndDB()
loadFromCacheAndDB ()方法执行过程如下:和先前一样,我们建立两个列表:currentCached和refreshFromDb,然后从缓存中填充这两个列表。然而,在这载入机制使用了不同的实现:我们不使用一个虚拟的Cacheable类的实例,而是传入我们想要执行的load()方法的名字和声明这个load()方法的类的名字。然后,通过Java反射API调用这个静态的load()方法。缓存未命中和过期的对象载入后,我们做的工作和前面一样:更新缓存,合并 currentCached和newlyLoaded列表,排序合并后的列表。最后的结果是完全一样的:我们得到了一个完全载入的排序的列表,这个列表中的对象是从缓存和数据库中取到的最新的对象:
public static List loadFromCacheAndDB(
List timestampedIds,
String loaderClassName,
String loaderMethodName,
Connection conn)
throws ResourceLoadException {
Vector currentObjectsFromCache = new Vector();
Vector idsToRefreshFromDb = new Vector();
fillFreshAndStaleFromCache(
timestampedIds,
idsToRefreshFromDb,
currentObjectsFromCache);
/* Java Reflection-based call replaces cacheable.loadFromIds() */
Collection objectsFromDb = loadStaleOrMissingFromDB(
idsToRefreshFromDb,
loaderClassName,
loaderMethodName,
conn);
updateCache(objectsFromDb);
currentObjectsFromCache.addAll(objectsFromDb);
return currentObjectsFromCache;
}
使用反射载入:loadStaleOrMissingFromDB()
这节示范了使用Java反射API调用静态load()方法的机制。我们将静态load()方法的名字和调用这个方法的类的名字作为参数传递给 loadStaleOrMissingFromDB()。我们使用常用的反射调用方法:建立了一个Class数组表示load方法参数的类型,然后得到 load()的Method对象引用。接着我们建立了作为load方法参数的Object数组,将需要载入的Britney CD的ID列表和数据库连接放入数组中。最后调用load()的Method对象的invoke()方法执行load。因为load()方法是静态的,所以invoke的第一个参数(调用load方法的实例)是null:
private static Collection loadStaleOrMissingFromDB(
Vector idsToRefreshFromDb,
String loaderClassName,
String loaderMethodName,
Connection conn)
throws ResourceLoadException {
Class loaderClass = Class.forName(loaderClassName);
Class[] loaderMethodParameters = new Class[2];
loaderMethodParameters[0] = Collection.class;
loaderMethodParameters[1] = Connection.class;
Method loaderMethod = loaderClass.getMethod(
loaderMethodName,
loaderMethodParameters);
Object[] loaderMethodArguments = new Object[2];
loaderMethodArguments[0] = idsToRefreshFromDb;
loaderMethodArguments[1] = conn;
objectsFromDb = (Collection)loaderMethod.invoke(
null, //NULL = static method
loaderMethodArguments);
return objectsFromDb;
}
使用反射使我们能够访问静态的load方法,同时增加了对更大范围过去的系统的兼容性。
完成的需求
最后,让我们重新看一下我在文章开始阶段定义的需求,确保我们已经完成了它们:
1. 通过从缓存中载入实时的对象和从数据库中载入过期的,我们的TBCF最大程度地提高了应用的性能。只有缓存未命中的和过期的对象被创建,在保证数据实时的情况下最小化了object churning。
2. 为检查是否过期,我们使用了对象的一个特殊实例的Timestamp属性代替传统的TTL属性。因此,我们能确保分派了最新的信息。只要timestamp使用数据库触发器或其他可信赖的方法更新,TBCF就不会返回过时的数据。
3. 我们解决了TBCF和过去的系统的兼容问题。TBCF安装简单,而且可以通过命令行或配置文件在运行时进行配置。只要对象实现了Cacheable接口, TBCF允许任何过去的系统的对象被缓存。此外,我们通过使用Java反射API支持在运行时直接调用静态的load方法,这样就允许更多过去的系统的类被缓存。
4. 虽然我们使用LRU缓存类来搭建我们的构架,但只要实现了必须的Cache接口,任何缓存算法都可以简单地加入我们的构架。
结论
TBCF 可以将性能提高到一个较高的层次,而且降低了时间敏感对象的churning,同时又不会影响用户得到的数据的实时性。这个架构使用的自动优化的对象载入(从缓存和数据库中分别载入)策略是久经考验的,它使用对象的timestamp属性来保证数据过期判断的可靠性。这个构架有着简单而直接的设计而且只需要较低的资源开销。它使用了Java标准的优秀的工具类来创建,可以在许多过去的系统中使用,而且可以使用任意的缓存算法。使用这个构架,开发人员可以使 JVM必须消耗并重新创建的对象数量降到最低,大大提高应用的性能,同时还能保证这些实时信息的可用性。
作者介绍:
作者:Greg Nudelman; niuji(niuji的Blog: http://blog.matrix.org.cn/page/niuji)
Greg Nudelman:旧金山金门大学电脑咨询系统-科学硕士。现居住旧金山湾区。有6年多的Java和SQL开发经验。为生物和贷款公司设计开发了多个多层分布式系统。
相关资源
文中源码下载:[urlhttp://www.javaworld.com/javaworld/jw-01-2005/caching/jw-0103- caching.zip ]http://www.javaworld.com/javaworld/jw-01-2005/caching/jw-0103-caching.zip[/url]
孙子兵法:http://www.sonshi.com/sun3.html
一个优秀的TTL缓存,参见"Develop a Generic Caching Service to Improve Performance"(Jonathan Lurie, JavaWorld, 2001年7月):