数据层性能
技巧 1 — 返回多个结果集
技巧 2 — 分页的数据访问
技巧 3 — 连接池
技巧 4 — ASP.NET 缓存 API
技巧 5 — 每请求缓存
技巧 6 — 后台处理
技巧 7 — 页输出缓存和代理服务器
技巧 8 — 运行 IIS 6.0(只要用于内核缓存)
技巧 9 — 使用 Gzip 压缩
技巧 10 — 服务器控件视图状态
使用 ASP.NET 编写 Web 应用程序的简单程度令人不敢相信。正因为如此简单,所以很多开发人员就不会花时间来设计其应用程序的结构,以获得更好的性能了。在本文中,我将讲述 10 个用于编写高性能 Web 应用程序的技巧。但是我并不会将这些建议仅局限于ASP.NET 应用程序,因为这些应用程序只是 Web 应用程序的一部分。本文不作为对 Web 应用程序进行性能调整的权威性指南 — 一整本书恐怕都无法轻松讲清楚这个问题。请将本文视作一个很好的起点。
成为工作狂之前,我原来喜欢攀岩。在进行任何大型攀岩活动之前,我都会首先仔细查看指南中的路线,阅读以前游客提出的建议。但是,无论指南怎么好,您都需要真正的攀岩体验,然后才能尝试一个特别具有挑战性的攀登。与之相似,当您面临修复性能问题或者运行一个高吞吐量站点的问题时,您只能学习如何编写高性能 Web 应用程序。
我的个人体验来自在 Microsoft 的 ASP.NET 部门作为基础架构程序经理的经验,在此期间我运行和管理 www.ASP.NET,帮助设计社区服务器的结构,社区服务器是几个著名ASP.NET 应用程序(组合到一个平台的 ASP.NET Forums、.Text 和 nGallery)。我确信有些曾经帮助过我的技巧对您肯定也会有所帮助。
您应该考虑将应用程序分为几个逻辑层。您可能听说过 3 层(或者 n 层)物理体系结构一词。这些通常都是规定好的体系结构方式,将功能在进程和/或硬件之间进行了物理分离。当系统需要扩大时,可以很轻松地添加更多的硬件。但是会出现一个与进程和机器跳跃相关的性能下降,因此应该避免。所以,如果可能的话,请尽量在同一个应用程序中一起运行 ASP.NET 页及其相关组件。
因为代码分离以及层之间的边界,所以使用 Web 服务或远程处理将会使得性能下降 20%甚至更多。
数据层有点与众不同,因为通常情况下,最好具有专用于数据库的硬件。然而进程跳跃到数据库的成本依然很高,因此数据层的性能是您在优化代码时首先要考虑的问题。
在深入应用程序的性能修复问题之前,请首先确保对应用程序进行剖析,以便找出具体的问题所在。主要性能计数器(如表示执行垃圾回收所需时间百分比的计数器)对于找出应用程序在哪些位置花费了其主要时间也非常有用。然而花费时间的位置通常非常不直观。
本文讲述了两种类型的性能改善:大型优化(如使用 ASP.NET 缓存),和进行自身重复的小型优化。这些小型优化有时特别有意思。您对代码进行一点小小的更改,就会获得很多很多时间。使用大型优化,您可能会看到整体性能的较大飞跃。而使用小型优化时,对于某个特定请求可能只会节省几毫秒的时间,但是每天所有请求加起来,则可能会产生巨大的改善。
数据层性能
谈到应用程序的性能调整,有一个试纸性的测试可用来对工作进行优先级划分:代码是否访问数据库?如果是,频率是怎样的?请注意,这一相同测试也可应用于使用 Web 服务或远程处理的代码,但是本文对这些内容未做讲述。
如果某个特定的代码路径中必需进行数据库请求,并且您认为要首先优化其他领域(如字符串操作),则请停止,然后执行这个试纸性测试。如果您的性能问题不是非常严重的话,最好花一些时间来优化一下与数据库、返回的数据量、进出数据库的往返频率相关的花费时间。
了解这些常规信息之后,我们来看一下可能会有助于提高应用程序性能的十个技巧。首先,我要讲述可能会引起最大改观的更改。
技巧 1 — 返回多个结果集
仔细查看您的数据库代码,看是否存在多次进入数据库的请求路径。每个这样的往返都会降低应用程序可以提供的每秒请求数量。通过在一个数据库请求中返回多个结果集,可以节省与数据库进行通信所需的总时间长度。同时因为减少了数据库服务器管理请求的工作,还会使得系统伸缩性更强。
虽然可以使用动态 SQL 返回多个结果集,但是我首选使用存储过程。关于业务逻辑是否应该驻留于存储过程的问题还存在一些争议,但是我认为,如果存储过程中的逻辑可以约束返回数据的话(缩小数据集的大小、缩短网络上所花费时间,不必筛选逻辑层的数据),则应赞成这样做。
使用 SqlCommand 实例及其 ExecuteReader 方法填充强类型的业务类时,可以通过调用NextResult 将结果集指针向前移动。图 1 显示了使用类型类填充几个 ArrayList 的示例会话。只从数据库返回您需要的数据将进一步减少服务器上的内存分配。
Figure 1 Extracting Multiple Resultsets from a DataReader
// read the first resultset
reader = command.ExecuteReader();
// read the data from that resultset
while (reader.Read()) {
suppliers.Add(PopulateSupplierFromIDataReader( reader ));
}
// read the next resultset
reader.NextResult();
// read the data from that second resultset
while (reader.Read()) {
products.Add(PopulateProductFromIDataReader( reader ));
}
技巧 2 — 分页的数据访问
ASP.NET DataGrid 具有一个很好的功能:数据分页支持。在 DataGrid 中启用分页时,一次会显示固定数量的记录。另外,在 DataGrid 的底部还会显示分页 UI,以便在记录之间进行导航。该分页 UI 使您能够在所显示的数据之间向前和向后导航,并且一次显示固定数量的记录。
还有一个小小的波折。使用 DataGrid 的分页需要所有数据均与网格进行绑定。例如,您的数据层需要返回所有数据,那么 DataGrid 就会基于当前页筛选显示的所有记录。如果通过 DataGrid 进行分页时返回了 100,000 个记录,那么针对每个请求会放弃 99,975 个记录(假设每页大小为 25 个记录)。当记录的数量不断增加时,应用程序的性能就会受到影响,因为针对每个请求必须发送越来越多的数据。
要编写性能更好的分页代码,一个极佳的方式是使用存储过程。图 2 显示了针对Northwind 数据库中的 Orders 表进行分页的一个示例存储过程。简而言之,您此时要做的只是传递页索引和页大小。然后就会计算合适的结果集,并将其返回。
Figure 2 Paging Through the Orders Table
CREATE PROCEDURE northwind_OrdersPaged
(
@PageIndex int,
@PageSize int
)
AS
BEGIN
DECLARE @PageLowerBound int
DECLARE @PageUpperBound int
DECLARE @RowsToReturn int
-- First set the rowcount
SET @RowsToReturn = @PageSize * (@PageIndex + 1)
SET ROWCOUNT @RowsToReturn
-- Set the page bounds
SET @PageLowerBound = @PageSize * @PageIndex
SET @PageUpperBound = @PageLowerBound + @PageSize + 1
-- Create a temp table to store the select results
CREATE TABLE #PageIndex
(
IndexId int IDENTITY (1, 1) NOT NULL,
OrderID int
)
-- Insert into the temp table
INSERT INTO #PageIndex (OrderID)
SELECT
OrderID
FROM
Orders
ORDER BY
OrderID DESC
-- Return total count
SELECT COUNT(OrderID) FROM Orders
-- Return paged results
SELECT
O.*
FROM
Orders O,
#PageIndex PageIndex
WHERE
O.OrderID = PageIndex.OrderID AND
PageIndex.IndexID > @PageLowerBound AND
PageIndex.IndexID < @PageUpperBound
ORDER BY
PageIndex.IndexID
END
在社区服务器中,我们编写了一个分页服务器控件,以完成所有的数据分页。您将会看到,我使用的就是技巧 1 中讨论的理念,从一个存储过程返回两个结果集:记录的总数和请求的数据。
返回记录的总数可能会根据所执行查询的不同而有所变化。例如,WHERE 子句可用来约束返回的数据。为了计算在分页 UI 中显示的总页数,必须了解要返回记录的总数。例如,如果总共有 1,000,000 条记录,并且要使用一个 WHERE 子句将其筛选为 1000 条记录,那么分页逻辑就需要了解记录的总数才能正确呈现分页 UI。
技巧 5 — 每请求缓存
在本文前面部分,我提到了经常遍历代码路径的一些小改善可能会导致较大的整体性能收益。对于这些小改善,其中有一个绝对是我的最爱,我将其称之为"每请求缓存"。
缓存 API 的设计目的是为了将数据缓存较长的一段时间,或者缓存至满足某些条件时,但每请求缓存则意味着只将数据缓存为该请求的持续时间。对于每个请求,要经常访问某个特定的代码路径,但是数据却只需提取、应用、修改或更新一次。这听起来有些理论化,那么我们来举一个具体的示例。
在社区服务器的论坛应用程序中,页面上使用的每个服务器控件都需要个性化的数据来确定使用什么外观、使用什么样式表,以及其他个性化数据。这些数据中有些可以长期缓存,但是有些数据却只针对每个请求提取一次,然后在执行该请求期间对其重用多次,如要用于控件的外观。
为了达到每请求缓存,请使用 ASP.NET HttpContext。对于每个请求,都会创建一个HttpContext 实例,在该请求期间从 HttpContext.Current 属性的任何位置都可访问该实例。该 HttpContext 类具有一个特殊的 Items 集合属性;添加到此 Items 集合的对象和数据只在该请求持续期间内进行缓存。正如您可以使用缓存来存储经常访问的数据一样,您也可以使用 HttpContext.Items 来存储只基于每个请求使用的数据。它背后的逻辑非常简单:数据在它不存在的时候添加到 HttpContext.Items 集合,在后来的查找中,只是返回 HttpContext.Items 中的数据。
技巧 3 — 连接池
在 Web 应用程序和 SQL Server? 之间设置 TCP 连接可能是一个非常消耗资源的操作。Microsoft 的开发人员到目前为止能够使用连接池已经有一段时间了,这使得他们能够重用数据库连接。他们不是针对每个请求都设置一个新的 TCP 连接,而是只在连接池中没有任何连接时才设置新连接。当连接关闭时,它会返回连接池,在其中它会保持与数据库的连接,而不是完全破坏该 TCP 连接。
当然,您需要小心是否会出现泄漏连接。当您完成使用连接时,请一定要关闭这些连接。再重复一遍:无论任何人对 Microsoft?.NET Framework 中的垃圾回收有什么评论,请一定要在完成使用连接时针对该连接显式调用 Close 或 Dispose。不要相信公共语言运行库(CLR) 会在预先确定的时间为您清除和关闭连接。尽管 CLR 最终会破坏该类,并强制连接关闭,但是当针对对象的垃圾回收真正发生时,并不能保证。
要以最优化的方式使用连接池,需要遵守一些规则。首先打开连接,执行操作,然后关闭该连接。如果您必须如此的话,可以针对每个请求多次打开和关闭连接(最好应用技巧 1),但是不要一直将连接保持打开状态并使用各种不同的方法对其进行进出传递。第二,使用相同的连接字符串(如果使用集成身份验证的话,还要使用相同的线程标识)。如果不使用相同的连接字符串,例如根据登录的用户自定义连接字符串,那么您将无法得到连接池提供的同一个优化值。如果您使用集成身份验证,同时还要模拟大量用户,连接池的效率也会大大下降。尝试跟踪与连接池相关的任何性能问题时,.NET CLR 数据性能计数器可能非常有用。
每当应用程序连接资源时,如在另一个进程中运行的数据库,您都应该重点考虑连接该资源所花时间、发送或检索数据所花时间,以及往返的数量,从而进行优化。优化应用程序中任何种类的进程跳跃都是获得更佳性能的首要一点。
应用层包含了连接数据层、将数据转换为有意义类实例和业务流程的逻辑。例如社区服务器,您要在其中填充Forums 或 Threads集合,应用业务规则(如权限);最重要的是要在其中执行缓存逻辑。
技巧 4 — ASP.NET 缓存 API
编写应用程序代码行之前,一个首要完成的操作是设计应用层的结构,以便最大化利用ASP.NET 缓存功能。
如果您的组件要在 ASP.NET 应用程序中运行,则只需在该应用程序项目中包括一个System.Web.dll 引用。当您需要访问该缓存时,请使用 HttpRuntime.Cache 属性(通过Page.Cache 和 HttpContext.Cache 也可访问这个对象)。
对于缓存数据,有几个规则。首先,如果数据可能会多次使用时,则这是使用缓存的一个很好的备选情况。第二,如果数据是通用的,而不特定于某个具体的请求或用户时,则也是使用缓存的一个很好的备选情况。如果数据是特定于用户或请求的,但是寿命较长的话,仍然可以对其进行缓存,但是这种情况可能并不经常使用。第三,一个经常被忽略的规则是,有时可能您缓存得太多。通常在一个 x86 计算机上,为了减少内存不足错误出现的机会,您会想使用不高于 800MB 的专用字节运行进程。因此缓存应该有个限度。换句话说,您可能能够重用某个计算结果,但是如果该计算采用 10 个参数的话,您可能要尝试缓存 10 个排列,这样有可能给您带来麻烦。一个要求 ASP.NET 的最常见支持是由于过度缓存引起的内存不足错误,尤其是对于大型数据集。
缓存有几个极佳的功能,您需要对它们有所了解。首先,缓存会实现最近最少使用的算法,使得ASP.NET 能够在内存运行效率较低的情况下强制缓存清除 - 从缓存自动删除未使用过的项目。第二,缓存支持可以强制失效的过期依赖项。这些依赖项包括时间、密钥和文件。时间经常会用到,但是对于 ASP.NET 2.0,引入了一个功能更强的新失效类型:数据库缓存失效。它指的是当数据库中的数据发生变化时自动删除缓存中的项。有关数据库缓存失效的详细信息,请参阅 MSDN?Magazine 2004 年 7 月的 Dino Esposito Cutting Edge 专栏。要了解缓存的体系结构,请参阅图 3。
技巧 6 — 后台处理
通往代码的路径应该尽可能快速,是吗?可能有时您会觉得针对每个请求执行的或者每n 个请求执行一次的任务所需资源非常多。发送电子邮件或者分析和验证传入数据就是这样的一些例子。
剖析 ASP.NET Forums 1.0 并重新构建组成社区服务器的内容时,我们发现添加新张贴的代码路径非常慢。每次添加新张贴时,应用程序首先需要确保没有重复的张贴,然后必须使用"坏词"筛选器分析该张贴,分析张贴的字符图释,对张贴添加标记并进行索引,请求时将张贴添加到合适的队列,验证附件,最终张贴之后,立即向所有订阅者发出电子邮件通知。很清楚,这涉及很多操作。
经研究发现,大多数时间都花在了索引逻辑和发送电子邮件上。对张贴进行索引是一个非常耗时的操作,人们发现内置的 System.Web.Mail 功能要连接 SMYP 服务器,然后连续发送电子邮件。当某个特定张贴或主题领域的订阅者数量增加时,执行 AddPost 功能所需的时间也越来越长。
并不需要针对每个请求都进行电子邮件索引。理想情况下,我们想要将此操作进行批处理,一次索引 25 个张贴或者每五分钟发送一次所有电子邮件。我们决定使用以前用于对数据缓存失效进行原型设计的代码,这个失效是用于最终进入 Visual Studio? 2005 的内容的。
System.Threading 命名空间中的 Timer 类非常有用,但是在 .NET Framework 中不是很有名,至少对于 Web 开发人员来说是这样。创建之后,这个 Timer 类将以一个可配置的间隔针对 ThreadPool 中的某个线程调用指定的回调。这就表示,您可以对代码进行设置,使其能够在没有对 ASP.NET 应用程序进行传入请求的情况下得以执行,这是后台处理的理想情况。您还可以在此后台进程中执行如索引或发送电子邮件之类的操作。
但是,这一技术有几个问题。如果应用程序域卸载,该计时器实例将停止触发其事件。另外,因为 CLR 对于每个进程的线程数量具有一个硬性标准,所以可能会出现这样的情形:服务器负载很重,其中计时器可能没有可在其基础上得以完成的线程,在某种程度上可能会造成延迟。ASP.NET 通过在进程中保留一定数量的可用线程,并且仅使用总线程的一部分用于请求处理,试图将上述情况发生的机会降到最低。但是,如果您具有很多异步操作时,这可能就是一个问题了。
这里没有足够的空间来放置该代码,但是您可以下载一个可以看懂的示例,网址是www.rob-howard.net。请了解一下 Blackbelt TechEd 2004 演示中的幻灯片和演示。
技巧 7 — 页输出缓存和代理服务器
ASP.NET 是您的表示层(或者说应该是您的表示层);它由页、用户控件、服务器控件(HttpHandlers 和 HttpModules)以及它们生成的内容组成。如果您具有一个 ASP.NET 页,它会生成输出(HTML、XML、图像或任何其他数据),并且您针对每个请求运行此代码时,它都会生成相同的输出,那么您就拥有一个可用于页输出缓存的绝佳备选内容。
将此行内容添加页的最上端
就可以高效地为此页生成一次输出,然后对它进行多次重用,时间最长为 60 秒,此时该页将重新执行,输出也将再一次添加到 ASP.NET 缓存。通过使用一些低级程序化 API 也可以完成此行为。对于输出缓存有几个可配置的设置,如刚刚讲到的 VaryByParams 属性。VaryByParams 刚好被请求到,但还允许您指定 HTTP GET 或 HTTP POST 参数来更改缓存项。例如,只需设置 VaryByParam="Report" 即可对 default.aspx?Report=1 或 default.aspx?Report=2 进行输出缓存。通过指定一个以分号分隔的列表,还可以指定其他参数。
很多人都不知道何时使用输出缓存,ASP.NET 页还会生成一些位于缓存服务器下游的HTTP 标头,如 Microsoft Internet Security and Acceleration Server 或 Akamai 使用的标头。设置了 HTTP 缓存标头之后,可以在这些网络资源上对文档进行缓存,客户端请求也可在不必返回原始服务器的情况下得以满足。
因此,使用页输出缓存不会使得您的应用程序效率更高,但是它可能会减少服务器上的负载,因为下游缓存技术会缓存文档。当然,这可能只是匿名内容;一旦它成为下游之后,您就再也不会看到这些请求,并且再也无法执行身份验证以阻止对它的访问了。
技巧 8 — 运行 IIS 6.0(只要用于内核缓存)
如果您未运行 IIS 6.0 (Windows Server? 2003),那么您就错过了 Microsoft Web 服务器中的一些很好的性能增强。在技巧 7 中,我讨论了输出缓存。在 IIS 5.0 中,请求是通过 IIS 然后进入 ASP.NET 的。涉及到缓存时,ASP.NET 中的 HttpModule 会接收该请求,并返回缓存中的内容。
如果您正在使用 IIS 6.0,就会发现一个很好的小功能,称为内核缓存,它不需要对ASP.NET 进行任何代码更改。当请求由 ASP.NET 进行输出缓存时,IIS 内核缓存会接收缓存数据的一个副本。当请求来自网络驱动程序时,内核级别的驱动程序(无上下文切换到用户模式)就会接收该请求,如果经过了缓存,则会将缓存的数据刷新到响应,然后完成执行。这就表示,当您将内核模式缓存与 IIS 和 ASP.NET 输出缓存一起使用时,就会看到令人不敢相信的性能结果。在 ASP.NET 的 Visual Studio 2005 开发过程中,我一度是负责 ASP.NET 性能的程序经理。开发人员完成具体工作,但是我要看到每天进行的所有报告。内核模式缓存结果总是最有意思的。最常见的特征是网络充满了请求/响应,而 IIS 运行时的 CPU 使用率只有大约 5%。这太令人震惊了!当然使用 IIS 6.0 还有一些其他原因,但是内核模式缓存是其中最明显的一个。
技巧 9 — 使用 Gzip 压缩
虽然使用 gzip 并不一定是服务器性能技巧(因为您可能会看到 CPU 使用率的提高),但是使用 gzip 压缩可以减少服务器发送的字节数量。这就使人们觉得页速度加快了,并且还减少了带宽的用量。根据所发送数据、可以压缩的程度以及客户端浏览器是否支持(IIS只会向支持 gzip 压缩的客户端发送经过 gzip 压缩的内容,如 Internet Explorer 6.0 和 Firefox),您的服务器每秒可以服务于更多的请求。实际上,几乎每当您减少所返回数据的数量时,都会增加每秒请求数。
Gzip 压缩已经内置到 IIS 6.0 中,并且其性能比 IIS 5.0 中使用的 gzip 压缩要好的多,这是好消息。但不幸的是,当尝试在 IIS 6.0 中打开 gzip 压缩时,您可能无法在IIS 的属性对话中找到该设置。IIS 小组在该服务器中置入了卓越的 gzip 功能,但是忘了包括一个用于启用该功能的管理 UI。要启用 gzip 压缩,您必须深入到 IIS 6.0 的XML 配置设置内部(这样不会引起心脏虚弱)。顺便提一句,这归功于 OrcsWeb 的 Scott Forsyth,他帮助我提出了在 OrcsWeb 上宿主的 www.asp.net 服务器的这个问题。
本文就不讲述步骤了,请阅读 Brad Wilson 的文章,网址是 IIS6 Compression。还有一篇有关为 ASPX 启用压缩的知识库文章,网址是 Enable ASPX Compression in IIS。但是您应该注意,由于一些实施细节,IIS 6.0 中不能同时存在动态压缩和内核缓存。
技巧 10 — 服务器控件视图状态
视图状态是一个有趣的名称,用于表示在所生成页的隐藏输出字段中存储一些状态数据的ASP.NET。当该页张贴回服务器时,服务器可以分析、验证、并将此视图状态数据应用回该页的控件树。视图状态是一个非常强大的功能,因为它允许状态与客户端一起保持,并且它不需要 cookie 或服务器内存即可保存此状态。很多 ASP.NET 服务器控件都使用视图状态来保持在与页元素进行交互期间创建的设置,例如保存对数据进行分页时显示的当前页。
然而使用视图状态也有一些缺点。首先,服务或请求页时,它都会增加页的总负载。对张贴回服务器的视图状态数据进行序列化或取消序列化时,也会发生额外的开销。最后,视图状态会增加服务器上的内存分配。
几个服务器控件有着过度使用视图状态的趋势,即使在并不需要的情况下也要使用它,其中最著名的是 DataGrid。ViewState 属性的默认行为是启用,但是如果您不需要,则可以在控件或页级别关闭。在控件内,只需将 EnableViewState 属性设置为 false,或者在页中使用下列设置即可对其进行全局设置:
如果您不回发页,或者总是针对每个请求重新生成页上的控件,则应该在页级别禁用视图状态。
我为您讲述了一些我认为在编写高性能 ASP.NET 应用程序时有所帮助的技巧。正如我在本文前面部分提到的那样,这是一个初步指南,并不是 ASP.NET 性能的最后结果。(有关改善 ASP.NET 应用程序性能的信息,请参阅 Improving ASP.NET Performance。)只有通过自己的亲身体验才能找出解决具体性能问题的最好方法。但是,在您的旅程中,这些技巧应该会为您提供一些好的指南。在软件开发中,几乎没有绝对的东西;每个应用程序都是唯一的。