数据层性能
技巧 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
应用程序进行性能调整的权威性指南 — 一整本书恐怕都无法轻松讲清楚这个问题。请将
本文视作一个很好的起点。
我的个人体验来自在 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。
技巧 3 — 连接池
在 Web 应用程序和 SQL Server? 之间设置 TCP 连接可能是一个非常消耗资源的操作。Mi
crosoft 的开发人员到目前为止能够使用连接池已经有一段时间了,这使得他们能够重用
数据库连接。他们不是针对每个请求都设置一个新的 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。
技巧 5 — 每请求缓存
在本文前面部分,我提到了经常遍历代码路径的一些小改善可能会导致较大的整体性能收
益。对于这些小改善,其中有一个绝对是我的最爱,我将其称之为"每请求缓存"。
缓存 API 的设计目的是为了将数据缓存较长的一段时间,或者缓存至满足某些条件时,但
每请求缓存则意味着只将数据缓存为该请求的持续时间。对于每个请求,要经常访问某个
特定的代码路径,但是数据却只需提取、应用、修改或更新一次。这听起来有些理论化,
那么我们来举一个具体的示例。