代码改变世界

ASP.NET 应用程序的扩展策略

2008-06-05 13:25  Valens  阅读(206)  评论(0编辑  收藏  举报
     作为 ASP.NET 性能顾问,我们接触的项目通常都是已经出现问题的项目。在许多情况下,求助电话都是在应用程序已经投产后才打来的。在开发人员那里一切都正常的程序到了用户那里却无法正常运行。他们抱怨:站点太慢了。管理部门想知道为什么在测试的时候没有发现这一问题。开发部门却无法重现问题。于是有人说 ASP.NET 不能扩展。听起来是不是很熟悉?
     世界上一些最繁忙的 Web 站点都是运行在 ASP.NET 上。MySpace 就是一个很好的例子;实际上,它是在多种不同的平台上都经过运行后才被迁移到 ASP.NET 上的。事实上,性能问题可能是随着应用程序的不断扩展而显现出来的,当出现这种情况时,您需要确定所发生的实际问题并找出解决该问题的最佳策略。您将面临的最大挑战是创建一组测量标准,其中要涵盖应用程序方方面面的性能。如果不将问题通盘加以考虑,您就无法知道要将侧重点放在哪一方面。

性能等式
     2006 年 9 月,NetForecast 的 Peter Sevcik 和 Rebecca Wetzel 发表了一篇名为 "Field Guide to Application Delivery Systems" 的论文。该论文专门讨论了如何改善广域网 (WAN) 应用程序的性能,并包括了图 1 所示的等式。此等式针对的是 WAN 的性能,但只需做少量修改便可用来衡量 Web 应用程序的性能。修改后的等式如图 2 所示,其中的各个元素在图 3 中进行了解释。

变量 定义
R 响应时间。从用户请求页面(通过单击链接等操作)到整个页面全部呈现在用户计算机中所需的总时间。通常以秒为测量单位。
负载 发送到浏览器的字节总数,包括标记和所有资源(例如,CSS、JS 和图像文件等)。
带宽 与浏览器之间的传输率。这可能是不对称的,如果给定页面是从多个源生成的,这可能表示多个速度。通常情况下,会加总取一平均值作为单一带宽,单位为字节/秒。
AppTurns 给定页面所需的资源文件数。这些资源文件包括 CSS、JS、图像等,还包括浏览器在页面显示过程中检索的任何其他文件。在此等式中,HTML 页面是通过在 AppTurns 表达式之前加上往返时间 (RTT) 单独计算的。
RTT 往返所需的时间,与传输的字节无关。对于页面本身,每个请求至少需要耗用一个 RTT。通常以毫秒为测量单位。
并发请求 浏览器同时发出的请求资源文件的请求数。默认情况下,Internet Explorer 执行两个并发请求。此设置可以进行调整,但很少这样做。
Cs 服务器上的计算时间。这是运行代码、从数据库检索数据以及合成要发送到浏览器的响应所需的时间。测量单位为毫秒。
Cc 客户端上的计算时间。这是浏览器在屏幕上实际显示 HTML、执行 JavaScript、实施 CSS 规则等所需的时间。
Figure 1 The Original Performance Equation 
Figure 2 The Web Version of the Performance Equation 

     现在您已经有了公式,接下来的挑战就是逐一测定各个元素。结束值、响应时间相对比较容易测定;有许多工具都可以精确测量整个过程所花费的时间。
     负载可通过各种工具加以测定(websiteoptimization.com/services/analyze 是一种很好的选择),带宽(参见 speedtest.net)和往返时间(使用 Ping)也是如此。websiteoptimization.com/services/analyze 之类的工具还可以报告 Web 页面的 HTML、CSS、JavaScript、图像等内容的大小。并发请求实际上是一个常数(Internet Explorer® 默认为 2)。
     这样还剩下 Cs 和 Cc,它们会需要一些附加的开发工作。在 ASP.NET 页面中,很容易就可以编写代码来记录页面开始执行的准确时间,然后再将其从执行完成时的当时时间中减掉。客户端也是如此;可在 HTML 页面顶部执行一段 JavaScript 来记录开始时间,然后当页面执行完毕并触发了 OnLoad 事件时将其从当时时间中减掉。
     事实上,如果想在 Web 站点中嵌入调试模式以利用这一性能等式,可以对所有这些元素自行编写代码。这样做有充分的理由:如果能够定期在浏览器中列出性能等式元素,则很容易就可以检测出存在性能问题的位置。
     例如,假设您的 ASP.NET 应用程序的用户都位于其他洲,而且使用的是低带宽。由于 ping 时间较高 (> 200ms) 而带宽较低 (< 500kbps),所以这些用户非常容易受应用程序总负载和往返次数的影响。这时您必须站在这些用户的角度来审视您的应用程序,因为他们的体验与您的体验截然不同。

扩展问题
     作为顾问我们知道,如果应用程序在测试环境中执行正常而在实际环境中却很糟糕,则很可能是遇到了扩展方面的问题。通常情况下,二者间唯一的不同之处在于同时使用的用户数。如果应用程序的执行情况总是很糟糕,则您遇到的可能是性能问题,而不是扩展问题。
     有三种策略可以用来改善扩展:特殊化、优化和分配。这三种策略的应用方式各有不同,但实际策略都非常简单而且一致。
     特殊化的目的是将应用程序分成较小的片段,以便隔离问题。例如,您可以考虑将静态资源文件(如图像、CSS 和 JS 文件)从 ASP.NET 服务器中移出。针对 ASP.NET 精心调配的服务器并不太适合处理这些类型的文件。为此可以专门为处理资源文件调配一组 IIS 服务器,它们会给运行的应用程序在可伸缩性方面带来实质性的改变。
     如果需要执行大量的压缩或加密(对于 SSL)操作,则设置专用于 SSL 的服务器会有所帮助。您应该知道,甚至还有专门为压缩和 SSL 终端提供的专业硬件设备。
     尽管有关分解服务器层的许多传统策略可能会促使您针对数据访问、复杂计算等不同用途使用不同的服务器而不考虑实际生成的 Web 页面,但我却宁愿使用五个 Web 服务器来执行所有任务,而不是使用三个 Web 服务器和两个业务对象服务器。Web 服务器与业务对象服务器之间的所有这些进程外调用会产生大量的开销。
     只有在已知和预计会产生收益的情况下才应采取特殊化策略。最快的解决方案并不总是最好的。可伸缩性的目标与性能是一致的。您希望在负载增加时缩小性能范围;无论有一名用户还是有一千名用户,您都希望指定页面的呈现时间对所有用户都是相同的。
     最后,您需要优化服务器代码以更有效地进行扩展。实际上,除了服务器的计算时间以外,性能等式的每一方面都是线性扩展的;您只要增加更多带宽(增加时机很容易判断),客户端的计算时间就不会随着客户端数量的增加而变化。在进行扩展时,性能等式中的其他元素也会保持一致。但服务器的计算时间需要随着用户数量的增加而调整。

优化代码
     优化服务器代码的关键所在是要通过测试确保它确实产生了效果。您应使用分析工具对应用程序进行分析并查明应用程序花费时间最长的部分。整个过程要根据经验来执行:使用工具找出要改进的代码、改进代码、测试性能是否真的有所改进、从头开始重复。在规模较大的站点中,您经常会听到性能优化的说法,这就好比是金门大桥的喷绘作业:完成整个喷绘作业后,就应该返回来再从头开始。
     我经常会惊诧于为什么会有如此多的人认为扩展的起点是分配。他们叫嚷着“投入更多的硬件”。请不要误解我的意思;毫无疑问,添加硬件会有所帮助。但是如果不经过特殊化和优化,回报会相当少。
     特殊化允许您根据需要分配一些较小的应用程序片段。例如,如果您分离了图像服务器,则可以独立于应用程序的其余部分轻松扩展图像服务。
     优化还可以减少执行指定操作时所需的工作量,为分配提供回报。这将会减少扩展到相同数量的用户所需的服务器数量。

负载平衡
     要实施分配,需要添加服务器、在各服务器间复制应用程序以及实施负载平衡。为使负载平衡,您可以使用“网络负载平衡”(NLB),所有版本的 Windows Server® 2003 都带有此服务。有了 NLB,在负载平衡关系中每个服务器都是平等的伙伴关系。它们都使用相同的平衡算法,并且它们都侦听所有流量的共享虚拟 IP 地址。根据负载平衡算法,每个服务器都知道应由哪一个服务器来处理给定的请求。群集中的每个服务器都会发出一个检测信号,以使其他服务器知道自己正处于活动状态。如果服务器发生故障,该服务器的检测信号将会停止,其他服务器会自动进行补偿。
     如果有大量用户提出相似的请求,这时 NLB 会非常适用。但是,如果某些请求产生的负载要比其他请求多很多,则补偿机制也不能解决问题。幸运的是,对这种情况可采用硬件负载平衡解决方案。

相似性
     从根本上讲,有效地进行分配所面临的主要问题是如何消除相似性。例如,如果您只有一个 Web 服务器,则将会话数据存储在其中是合情合理的。但是,如果您有多个 Web 服务器,那么应将会话信息存储在哪里呢?
     一种方法是将其保留在一个 Web 服务器上,然后使用相似性。实质上,这意味着来自给定用户的第一个请求是处于负载平衡状态的,之后,来自该用户/会话的所有后续请求都与第一个请求一样被发送到同一个服务器。这是一种简单的方法,每个负载平衡解决方案都支持这种方法,在某些情况下它会更合理一些。
     但从长远来看,相似性会带来麻烦。使会话数据保留在进程中可能会加快速度,但如果回收 ASP.NET 工作进程,则所有这些会话都将处于无效状态。在很多情况下都需要回收工作进程。在负载过高时,IIS 可能会回收 ASP.NET 的工作进程,因为它认为该进程出现了问题。实际上,在 IIS 6.0 中,默认情况下会每隔 23 小时回收一次工作进程。您可以对此进行调整,但无论如何,用户都可能会丢失进程中的会话数据。如果站点规模较小,这不算是个大问题,但随着站点变得越来越大、越来越繁忙,此问题就会变得愈发突出。还有更多其他情况。
     如果您是按 IP 地址进行负载平衡的,则一个服务器可能会成为 megaproxy 代理(如 AOL)的目标,导致无法支持其自身的整个负载。而且,将服务器更新为新版本的应用程序会变得越来越困难;您或者必须等待数小时以便让用户退出站点,或者冒着激怒这些用户的风险将其挤出会话。您的可信赖性将大打折扣:不仅丢失了服务器,还丢失了许多会话。
消除相似性是主要的分配目标。这需要将会话状态数据移出进程,而这意味着通过牺牲性能来增加可伸缩性。将会话移出进程后,会话数据将被记录在所有 Web 服务器都可以访问的位置(例如 SQL Server® 服务器或 ASP.NET 状态服务器上)。这是在 web.config 中进行配置的。
     要支持进程外会话还需要进行一些编码工作。将要存储在 Session 对象中的任何类都必须标有 Serializable 属性。这表示该类中的所有数据都必须是可序列化的或标有 NonSerialized 的,以便可以将其忽略。如果未对类进行标记,则在运行序列化程序以将会话数据存储在进程外时会出现错误。
     最后,如果发现会话对象中的数据过多,则将会话移出进程是一个非常好的方法,因为现在对于每个页面请求,都需要付出代价将大量数据跨越整个网络传送两次(一次是在页面开头检索数据,一次是在页面结尾返回数据)。
     确定了 Session 对象后,再设法确定其他相似性问题,如成员身份和角色管理器等。每一项在消除相似性方面都面临着各自的困难。对于要进行扩展的 ASP.NET 应用程序而言,您需要深入探究能够发现的各种形式的相似性问题并将其消除。
     截至目前我们讨论的每个策略几乎都适用于所有需要扩展的 Web 应用程序。实际上,这些策略几乎可以用于使用任何技术扩展的任何应用程序。现在我们就来了解一些 ASP.NET 专用的技术。

最大程度减小负载
     看一下性能等式,您会发现负载扮演着极其重要的角色,尤其是当您面对的带宽有限时。减小负载大小可以改善响应时间,也可以因为来回移动的字节变少而获得一些扩展带来的好处,甚至可以节省一些在带宽方面的开销。
     减小负载大小最简单的一个办法就是启用压缩。在 IIS 6.0 中,您可以指定是压缩静态文件还是压缩动态生成的响应(如 ASP.NET 页面),或者是对二者都进行压缩(请参见图 4)。
Figure 4 Configuring Compression Server-Wide in IIS 6.0 

     IIS 6.0 会根据需要来压缩静态文件,并将其存储在指定的压缩文件缓存中。对于动态生成的响应,不会存储任何副本;每次都要对其进行压缩。IIS 7.0 在处理压缩内容方面更加智能化,它只压缩一些常用的文件。
     压缩需要耗用处理器周期数,但您的专用 Web 服务器通常都拥有大量额外的处理器容量。IIS 7.0 做了进一步的优化,当处理器工作繁忙时,它将挂起压缩工作。还有一些专门执行压缩工作的设备,它们独立于 Web 服务器本身。
     另一个减小负载的途径是 ViewState。在开发过程中,ViewState 的使用很容易变得难于控制。大多数 Web 控件都使用若干 ViewState,在控件比较密集的页面上,ViewState 可以增加到数千字节。要减小 ViewState 的使用,请在不需要它的控件上将其关闭。在某些情况下,开发人员甚至为了减少 ViewState 而不再使用控件。但并非总需要如此。大多数时下流行的 Web 控件对 ViewState 过多的问题都十分敏感,因此对其大小实施了精细的控制。还有一些硬件设备可以撇开并取代 ViewState,而不会改变代码或应用程序的运行方式。
     减小负载大小的最有效技术之一就是 AJAX。不同之处在于 AJAX 实际上并不减小负载大小,它只是在发送给浏览器的总字节数增加时减小负载的感知量。使用 AJAX,父页面会变小,因此初始呈现时间会变快。然后该页面中的各个元素会向服务器发出各自的请求以填充数据。
     事实上,AJAX 会随着时间的推移来传播负载,以便在加载其他字符位时为用户提供一些可以观看的内容。因此,使用 AJAX 可以改进用户的整体体验,但是让我们回头查看一下性能等式并测定您的实际工作成本。AJAX 通常会增加客户端的计算时间,有时会非常明显,甚至增加到使性能变得无法接受的程度。
     如果 AJAX 往返于服务器以填充各个元素的行程取代了整个页面请求,您的往返数将净减少。但在许多情况下,您会发现特定用户的总往返数会增加。您只需认真进行测试,即可知道 AJAX 是改进了性能还是降低了性能。

缓存
     从事 ASP.NET 应用程序扩展方面研究的专家对缓存做了详细的介绍。从根本上来说,缓存可使数据更接近用户。在一个典型的 ASP.NET 应用程序中,在完成任何重要的优化工作之前,实际上用户所需的所有数据都在数据库中,并在每次请求时从数据库中检索这些数据。缓存将会改变这一行为。ASP.NET 实际上支持三种形式的缓存:页面缓存(也称为输出缓存)、部分页面缓存和编程(也称为数据)缓存。
     页面缓存是截至目前最为简单的缓存形式。要使用它,可在 ASP.NET 页面中添加一个 @OutputCache 指令,并加入一条用来指定过期时限的规则。例如,您可以指定将页面缓存 60 秒。将该指令放在适当位置后,该页面的第一个请求将会正常处理,它将访问数据库以及所需的任何其他资源以生成页面。之后,该页面会在 Web 服务器的内存中保留 60 秒,在此期间对该页面的所有请求都将直接从内存中给出结果。
     遗憾的是,虽然本示例非常简单,但它忽略了页面缓存的基本实际情况:实际上,没有 ASP.NET 页面可以达到如此的静态程度,以致于您可以将整个部分缓存任意长的时间。这样部分页面缓存便应运而生了。有了部分页面缓存,您就可以将 ASP.NET 页面的某些部分标记为可以缓存,以便仅计算页面中定期更改的部分。它看上去非常复杂,但却很有效率。
     毋庸置疑,最强大(也最复杂)的缓存形式是编程缓存,它关注的是页面使用的对象。最常见的编程缓存使用情形是存储从数据库检索的数据。
     缓存数据所面临的常见问题是基础数据可能在您缓存后发生了改变。缓存过期是实施任何形式的缓存时都不容回避的一个最大挑战。此外还要考虑内存问题。
     在繁忙的 ASP.NET 服务器中,各种原因交汇使内存成为一个非常重要的环节。每次计算 ASP.NET 页面时,它都会使用一些内存。Microsoft® .NET Framework 被设置为分配内存时非常迅速而释放时却相对缓慢(通过垃圾收集操作实现)。有关垃圾收集和 .NET 内存分配有专门的文章讨论,而且已经编写过多次。但可以肯定的是,对于繁忙的 Web 服务器,为 ASP.NET 应用程序提供 2GB 内存空间是非常必要的。理想情况下,其中的大部分内存使用量都是临时的,因为它是被分配给计算 Web 页面时所使用的各种变量和结构的。
     但对于永久内存对象(如进程中会话和缓存对象)而言,内存使用量所存在的问题要多很多。当然,只有当应用程非常繁忙时,这些问题才会浮现出来。
     请看下面的情况:您的 Web 站点推出了一些新的市场促销举措,有成千上万的用户点击该站点,您马上就会赚得盆满钵满。为了保持良好的响应时间,您应尽可能缓存页面的某些部分和数据对象组。用户的每个页面请求都会占用一些内存,因此消耗的内存量会持续向上增加。用户越多,增加速度就越快。缓存和会话对象还会产生大的跳跃。
     当内存使用总量接近 ASP.NET 默认缓存内存限制的 90% 时,系统会调用垃圾收集事件。垃圾收集器将分析整个内存空间,移动永久内存对象(如缓存对象和会话对象)并释放不再使用的内存(用于计算 Web 页面的内存)。释放不再使用的内存会很快,但移动永久对象会很慢。因此,存在的永久对象越多,垃圾收集器执行操作时就越困难。此类问题在 perform.exe 中可通过大量的第 2 代收集反映出来。
     请回想一下,在进行垃圾收集时,该 ASP.NET 服务器将无法提供任何页面;所有内容都存放在队列中,等待垃圾收集进程完成。IIS 也在观察。如果它认为进程执行时间太长,可能已经挂起,它将回收工作线程。虽然这会迅速释放大量内存,但由于所有永久内存对象均被抛出,因此有些客户可能会感到不快。
     现在 ASP.NET 有了一个修补程序,如果内存变得不足,它将自动删除编程缓存中的对象,这听起来是个不错的主意。因为总比系统崩溃要好。不过要注意,每次从缓存中删除了某些内容后,编码最终都会将其恢复。
     缓存某些内容时,这些内容可能会发生错误。例如,以小组件数据库和相应的订购页面为例。在最初形式的小组件页面中,每次呈现该页面时都涉及一个数据库请求,即请求小组件在库存中的数量。如果您分析这些请求,可能就会发现 99% 的时间您都在反复检索同一数量。那为什么不缓存它呢?
     一种简单的缓存方式是按时间缓存。您可以将小组件库存缓存一小时。这种方法的缺点是某人购买了某个小组件后,当他返回该页面时会发现库存没有任何变化。您可能会收到对此的抱怨。但更糟糕的是如果某人想购买小组件而且发现它的确在库存中,但实际上它已被售出。您可以建立一个缺货提示系统,但无论如何,您都要面对感到失望的客户。
     或许问题根源在于您的过期设定方案:时间设定的不够好。您可以缓存库存数,当有人购买了某个小组件后,缓存对象即过期。这样一来就比较合乎逻辑了,但如果有多个 ASP.NET 服务器会怎样?这时您会得到该小组件的不同库存数,具体取决于您请求的是哪个服务器。请想一想,接收新库存(添加到计数中)甚至不会经过您的 Web 应用程序,这样就可能会引入新的错误。
     可以在各 ASP.NET 服务器之间同步过期时间,但必须要小心。各 Web 服务器之间产生的对话量将随着缓存对象和 Web 服务器数量的增加而呈几何性上升。
     有关缓存过期对性能的影响还需要做进一步的研究。在高负载情况下,某个缓存对象过期可能会带来很多麻烦。例如,假定您执行了一次非常耗时的查询,它需要 30 秒才能从数据库中返回结果。由于在有负载情况下,该页面每秒都会被请求一次,因此您缓存该查询以节省开销。
     处理缓存对象的代码非常简单。应用程序首先会检查是否填充了缓存对象,而不是在需要时才从数据库中检索数据。如果检查到缓存对象,则它将使用缓存对象中的数据。如果未检查到,它将执行代码从数据库中检索数据,然后使用该数据填充缓存对象;代码随后继续正常执行。
     问题在于如果您的查询需要花费 30 秒的时间,而您每秒都要执行该页面,那么在填充缓存项目的这段时间内,将有 29 个其他请求传入,而且它们都试图用自己的数据库查询结果来填充缓存项目。要解决此问题,您可以添加一个线程锁,以阻止其他页面从数据库请求数据。
     但要重新运行整个方案:第一个请求传入,发现缓存项目尚未填充,对代码应用一次锁定,然后运行查询以填充缓存对象。一秒钟后第二个请求到达(此时第一个请求仍在运行),发现缓存对象尚未填充,但已置入锁定,因此第二个请求被封锁。接下来的 28 个请求都会有同样的经历。接下来,第一个请求完成其处理并删除锁定,然后继续执行。其他 29 个请求会怎样?它们不再被封锁,因此也继续执行。但是它们已经检查过缓存对象是否已被填充(当时尚未填充)。因此它们将尝试夺取锁定,有一个请求将会胜出并重新运行查询。
     发现问题了吗?在第一个请求完成了缓存对象的填充之后到达的那些请求将正常运行,但在查询运行期间传入的那些请求却处于困境。您必须编写代码来处理这种情况。如果某个请求夺取了锁定,当锁定被提升后,它会重新检查缓存对象是否已填充,如图 5 所示。很可能缓存对象马上就被填充;这就是首先夺取锁定的原因。尽管可能并非如此,但由于其他一些代码块已过期,因此会再次缓存对象。
     虽然编写行之有效的缓存代码是一项非常艰巨的工作,但回报也是相当可观的。不过,缓存确实增加了复杂性,因此要明智地使用它。要确保您确实会从复杂的状况中得到益处。请务必针对以下复杂情况对缓存代码进行测试。同时出现多个请求时该怎么办?很快就会过期时该怎么办?您必须要知道这些问题的答案。您肯定不希望您的缓存代码把扩展问题搞的更糟。

扩展数据库
     扩展 Web 站点的一般方法是全面扩展,而不是向上扩展。这主要是考虑到 ASP.NET 线程和内存限制以及 Web 请求的短期特性。
     但扩展数据库时,通常的做法是向上扩展,即一个大型框,在群集配置中可能是两个(尽管在任意给定时间只有一个在实际运行数据库)。不过,在所有大规模的 Web 应用程序中,单个数据库是无法处理负载的。必须进行扩展。这是完全可能的;您只需应用 Web 应用程序本身所应用的那些策略即可。第一步肯定是特殊化策略,即将数据库分成若干个逻辑分区。这些分区可能是以数据为中心,也可能是按区域划分的。因此,您将拥有多个数据库,每个都包含整个数据库的一部分。例如,一个服务器包含东海岸数据,而另一个服务器包含西海岸数据。
     但是,对于规模非常大的 Web 应用程序,会将其数据库分成若干读取方和写入方(请参见图 6)。读取方数据库是只读的;它们通过复制操作从写入方数据库接收数据。所有数据查询都转到读取方数据库,而且还对其进行了优化以便尽可能快地读取数据。读取方数据库可按其性质加以分类。
 
Figure 6 Distributed Database Architecture 

     所有数据写入请求都被发送到写入方数据库,这些数据库都进行了分区和调整,可以高效率地写入。复制操作将新数据从写入方数据库移到读取方数据库。
     创建此类专业化数据库会导致延迟:现在写入操作要经过一段时间才能发布到读取方数据库。但如果您可以解决延迟问题,则扩展潜力是巨大的。

无止境的扩展工作
     只要您的应用程序不断增加,您的扩展工作也会不断增加。可以有效处理一万个用户的 ASP.NET 技术在处理十万个用户时效率可能会大打折扣,而处理一百万个用户时规则又会有所不同。当然,性能可能完全取决于您的应用程序;我们曾看到过有的应用程序其扩展需求少于一千个用户!
     有效扩展的关键是在削减之前进行权衡:通过测试确保您的努力方向是正确无误的。测试您的工作以确保您获得了实质的改进,而不仅仅是些许变化。即使在开发周期的收尾阶段,也要注意优化可伸缩性,您应该弄清楚自己的薄弱环节。但是,我希望它们对目前的用户来说速度足够快,这样您便可以着手开始工作以满足未来用户的需求。