代码改变世界

输出缓存与CachePanel

2008-07-28 10:02 Jeffrey Zhao 阅读(...) 评论(...) 编辑 收藏

缓存的级别

缓存的作用自不必说,提高系统性能最重要的手段之一。上至应用框架,下至文件系统乃至CPU,计算机中各部分设计都能见到缓存的身影。许多朋友一直在追求如何提高Web应用程序的性能,其实最容易被理解和采纳的一条估计就是“缓存”了。也正因为如此Live Journal才会开发出memcached,而微软也推出了Velocity。

有朋友说生成静态页?好吧,在老赵看来,其实这只是把页面内容缓存至硬盘罢了。不过这就涉及到了缓存的某些“级别”了。撇开硬件(如CPU)和系统(例如文件系统,数据库系统)的缓存不说,如果只谈论应用级别——也就是我们平时“代码”中会遇到的缓存来说,一般可以将缓存分为两大类:缓存“数据”和“输出”。啥叫缓存数据呢?例如在代码中,我们将一个表示用户的User实例放入内存,以避免第二次读取时访问较慢的存储设备,这就是缓存了“数据”。至于缓存输出,最典型的也就莫过于刚才提到的“静态页”了。生成了静态页之后,大量的请求将直接获得结果,而不需要进行动态处理,这性能自然就提高了。

这两大类缓存其实也就是缓存的两个级别。之所以把它们分个“高低”或“上下”(并非褒贬,仅指“位置”),是因为在一个应用中,这两类缓存在使用和处理上有所不同。

对于数据缓存,如果用典型的三层架构(有些朋友会单独提出一个“缓存层”,这我就不多作区分了,该往哪层靠拢相信大家自能分清)来举例,往往发生在业务逻辑层和数据访问层。数据访问层里的缓存自然与业务逻辑无关,因此缓存的大都以“单个对象”为主,而缓存的命中与过期策略由“对象”所表示的“数据”的特性来决定,例如Hibernate的一级和二级缓存会根据对象的ID进行确定命中或过期操作。有些数据访问框架提供了更复杂的缓存功能,例如Hibernate也能够根据Query生成Key来进行缓存。而业务逻辑层中的缓存策略则可能较为复杂,命中与过期都与业务需求密切相关。例如一个用户的好友列表(假设是一个int数组)将会根据用户修改其好友信息时过期——当然也可能不是简单的过期,而是直接在内存中进行修改,不过这时候就要考虑到并发性等等。

至于“输出”缓存则容易理解多了。任何应用最终都要有所输出,输出往往就要导致格式的改变,例如变成HTML,XML,亦或是二进制流(有人说其实一切都是二进制流——没错没错,不过我们还是在略高层次上看这个问题吧)。这种直接和表现相关的缓存自然是放在“表现层”了。缓存“输出”相对于缓存“数据”的优点便是有更高的性能。“数据”还需要通过运算才能变成“输出”(试想我们可能需要操作了10K的数据只为了生成1K的有效内容),而缓存“输出”则直接面向了输出内容,性能的提高难道不是显而易见的吗?至于缺点,那就是降低了缓存的复用几率,一个HTML形式的数据自然无法被需要SOAP格式的输出所复用。降低了复用几率则意味着降低了缓存命中率,则意味着提高了存储所需的空间——或者降低了性能。等等,这不是和之前所说“较高性能”的有所矛盾吗?没错,我们刚开始学算法就会接触到一个词:“时间换空间、空间换时间”,这就道出玩儿编程的本质:一切都是在权衡,世上没有真正完美的做法。缓存“输出”其实玩的就是“空间换时间”的游戏,我们缓存的便是同样数据的不同表现形式。

当然缓存输出还有个问题可能就是不太容易设计合适缓存策略(主要是过期策略),对于实时性要求高的内容,往往不得不放弃输出缓存而启用数据缓存。

话说回来,在很多情况下,我们的业务真需要很高的实时性吗?例如一张新建的照片真的需要被立即索引到吗?照片的访问次数真要在列表中即时更新吗?很多时候需求的一些细节膨胀只是一厢情愿,对于用户来说并没有太大意义——尤其是在Web 2.0应用中,因为用户是很容易被诱导的,而且并不会对很多数据进行追究。因此技术架构师在和领域专家进行探讨时不如多提一些建议,一个常用的“句式”是:“XX要求的目的是不是为了YY?如果ZZ的话您能否接受呢?”。不过,如果一个用户删除了自己的一篇文章,却发现它还在自己的管理列表中出现,那么就实在不能说是一个“能够令人接受”的现象了。

ASP.NET的Output Cache及其缺陷

ASP.NET作为一个成熟、强大的应用程序框架,缓存相关的设计自然是它不可或缺的一部份。而ASP.NET作为一个面向Web应用的框架,实现的便是表现层的方方面面,因此它所涉及到的缓存,自然就是上文所说的“输出缓存”了(HTTP协议的缓存不在本文讨论范围之内)。ASP.NET中的输出缓存即为所谓的“OutputCache”。Output Cache的使用可以在任意一本ASP.NET程序设计的书中找到,不过最详细的描述自然莫过于MSDN了。下文将对于ASP.NET的Output Cache进行简单描述,仅仅是为了形成完整的讨论内容。

OutputCache为WebForms框架的一部分,可以在Page和User Control中使用。OutputCache的命中可以让对于某个控件,甚至是整个请求内容的处理直接获得HTML内容,而不需要对页面或控件进行处理。Output Cache主要通过在aspx或ascx文件顶部添加<@ OutputCache />标记来使用,在标记中可以定义缓存的各种特性,例如缓存在多少时间后失效(Duration),缓存的存储位置(Location),缓存的版本控制(VaryByControl / Header / Param / Custom / ContentEncoding)以及缓存与哪个SqlDependency依赖等等。而User Control中的OutputCache标记相对于Page还可以指定一个Shared属性,如果该属性为true,则表示UserControl的缓存可以跨Page命中,反之则会为不同的页面生成不同的缓存内容。此外,ASP.NET还提供了<asp:Substitution />控件,该控件在User Control或Page被缓存的时候依然会被执行,使程序员可以通过编程的方式为缓存内容中的特定部分进行改变。

ASP.NET OutputCache使用起来非常容易,但是在实际运用中往往会显得不够。例如有一个Users控件,用于显示UserIDs所指定的用户。这很容易。不过我们现在有个“特别”的需求,就是在同一个页面中放置两个这样的控件:

<jeffz:Users runat="server" UserIDs="1, 2, 3, 4, 5" />
<jeffz:Users runat="server" UserIDs="6, 7, 8, 9, 10" />

两个控件“实例”的UserIDs参数截然不同,自然输出的内容也会大相径庭。不过如果我们为Users控件添加了OutputCache(并且使用了最传统的VaryByParam="*")之后,问题就出现了。猜猜看结果如何?第二个控件实例的输出和第一个完全相同了,都是参数“1, 2, 3, 4, 5”的结果。这是因为VaryByParam的Param是指QueryString,或Post时的Form数据,而我们的页面在请求时哪有这方面的变化?于是我们就需要开始寻找解决方案了,翻遍了MSDN有关OutputCache的内容,可能只有一个VaryByCustom有些接近。可是VaryByCustom将某个特定参数传给GlobalApplication的GetVaryByCustomString方法中,其余的信息就只有个HttpContext了。所以VaryByCustom也只能通过公用的信息来判断是否使用之前缓存的版本,而无法根据哪个页面的具体哪个控件实例,以及某个控件实例的状态来决定缓存的版本。因此我们可以这么认为,在这种情况下,我们的Users控件无法使用ASP.NET的OutputCache。

还有一种情况就是需要“无条件”地为控件保存多个版本的缓存。例如有个需求是写一个RandomUsers控件,根据UserIDs指定的数据中随机挑选出几个用于展示的用户数据,例如:

<jeffz:RandomUsers runat="server" UserIDs="1, 2, 3, 4, 5" Count="3" />

那么现在还能够使用ASP.NET的OutputCache吗?至少老赵不知道该如何做。因此我们需要一个额外的缓存输出的解决方案,它的要求其实只有两个:

  • 可以自由地定义缓存版本。
  • 可以为每个版本缓存不同的副本,并随机输出。

这就是老赵下面要提到的这个解决方案:CachePanel的需求来源。

CachePanel的构建与使用

其实CachePanel很简单,相信已经有一些朋友能够想象出这个组件的大致逻辑了。根据老赵的习惯,我们还是使用“用例驱动开发”的方式来进行组件开发。例如老赵期望的使用方式是这样的:

<jeffz:CachePanel runat="server"
    Duration="00:15:00"
    CopyCount="10"
    CacheKey="RandomUsers"
    ResolveCacheKey="CachePanel_ResolveCacheKey"
>
    <jeffz:RandomUsers runat="server" UserIDs="1, 2, 3, 4, 5" Count="3" />
</asp:CachePanel>

以下是CachePanel的各种成员描述与代码:

public class CachePanel : Control
{
    public TimeSpan Duration { get; set; }
 
    public int CopyCount { get; set; }
 
    public string CacheKey { get; set; }
 
    public EventHandler ResolveCacheKey { get; set; }
 
    ...
}
  • Duration属性:每份缓存副本的有效时间长度。这里使用TimeSpan的字符串表示法,老赵认为相较传统的秒数,这样能够更直接地设定和读取一段时间长度。
  • CopyCount属性:每个缓存版本的副本数量,输出时将任意选择一个副本输出。在上例中,我们通过生成10个副本让用户看起来的确是在输出随机的结果,而其实我们只是缓存了10个副本而已。
  • CacheKey属性:不同的CacheKey决定了不同的缓存版本。请注意这个CacheKey是全局的,因此不同页面中的CachePanel如果CacheKey相同,将会得到相同的缓存结果(排除CopyCount属性的影响)。
  • ResolveCacheKey事件:提供了一个动态指定缓存版本的可能。开发人员可以响应该事件,根据上下文环境的不同(例如QueryString,Form或Header的不同)对CacheKey进行改变。

可以看到,其实只是这简单的四个成员就能满足上文的要求(而且事实上,在理论上CopyCount也能够省略,因为我们有ResolveCacheKey事件,不是吗?)。

至于与缓存相关的具体逻辑,其实非常简单。首先是在OnInit事件中检查是否命中缓存:

  1. 执行ResolveCacheKey事件以确认CacheKey。
  2. 随机选取副本编号。
  3. 根据CacheKey和副本编号确认被缓存的数据所使用的key(如果CacheKey为空,则使用默认的CacheKey,它保证了同一页面中的位置相同的CachePanel实例共享缓存版本)。
  4. 如果缓存命中,则清除CachePanel内的所有子控件。

代码如下:

public class CachePanel : Control
{
    ...
    private static Random s_random = new Random(DateTime.Now.Millisecond);
 
    public bool CacheHit { get; private set; }
 
    private string m_cacheKey;
    private string m_cachedContent;
 
    protected override void OnInit(EventArgs e)
    {
        var resolveCacheKey = this.ResolveCacheKey;
        if (resolveCacheKey != null)
        {
            resolveCacheKey(this, EventArgs.Empty);
        }
 
        int copyIndex = s_random.Next(this.CopyCount);
        this.m_cacheKey = this.GetCacheKey(copyIndex);
 
        this.m_cachedContent = this.Context.Cache.Get(this.m_cacheKey) as string;
        this.CacheHit = (this.m_cachedContent != null);
        if (this.CacheHit) this.Controls.Clear();
 
        base.OnInit(e);
    }
 
    private string GetCacheKey(int copyIndex)
    {
        var cacheKeyBase = this.CacheKey ?? this.GetDefaultCacheKeyBase();
        return "$CachePanel$" + cacheKeyBase + "_" + copyIndex;
    }
 
    private string GetDefaultCacheKeyBase()
    {
        return this.Context.Request.AppRelativeCurrentExecutionFilePath + "_" + this.UniqueID;
    }
    ...
}

由于内容被清空,然后到了生成内容阶段,事情就好办了——简单的缓存子控件生成的HTML内容即可:

public partial class CachePanel : Control
{
    ...
    protected override void RenderChildren(HtmlTextWriter writer)
    {
        if (this.m_cachedContent == null)
        {
            StringBuilder sb = new StringBuilder();
            HtmlTextWriter innerWriter = new HtmlTextWriter(new StringWriter(sb));
            base.RenderChildren(innerWriter);
 
            this.m_cachedContent = sb.ToString();
            this.Context.Cache.Insert(this.m_cacheKey, this.m_cachedContent, null,
                DateTime.Now.Add(this.Duration), Cache.NoSlidingExpiration);
        }
 
        writer.Write(this.m_cachedContent);
    }
}

至此,CachePanel就制作完成了,其实只是短短的几十行代码而已。到这里老赵不禁又要发一句感慨:只要了解了框架的运行规则,开发出各种扩展又有多少难度呢?一切都只是看您有多少想象力而已。

不过大家在使用CachePane时可能还需要注意以下几点:

  • CacheKey的作用域是整个ASP.NET应用程序,因此如果您要指定CacheKey的话,请给出清晰而明确的值。
  • CachePanel能够缓存页面中任意部分的内容,不过在使用时可能就需要您根据CacheHit属性的值来判断是否需要为控件填充数据,否则可能就会无法达到缓存的目的。
  • CachePanel将会在缓存命中时清空所有子控件,因此在操作时也请注意这一点,以免出现不可预知(Unpredictable)的结果。