构建高性能的ASP.NET应用程序
——纰漏之后的亡羊补牢之道
作者:刘如鸿
原文发表于:《MSDN开发精选》
看见大标题的时候,也许各位看官会自然而然的联想到如何在设计阶段考虑系统性能问题,如何编写高性能的程序代码。关于这一点,大家可以在MSDN和相关网站上找到非常多的介绍,不过大多是防患于未难,提供的是在设计和代码编写过程中的一些指导原则。
2005年1月份《MSDN Magazine》提供了一篇名为《10 tips for writing high-performance web applications》的文章,提供了编写Web应用程序的十条“金科玉律”,这些规则为开发人员编写ASP.NET应用程序提供了非常值得借鉴的参考。同样在MSDN文档中提供了一篇名为《开发高性能的ASP.NET应用程序》,指出了ASP.NET开发中代码级别上的性能最佳处理方法:
1) 在不需要的时候禁用会话
2) 慎重选择Session提供程序
3) 避免不必要的服务器边界交互
4) 在回调时使用Page.IsPostback来避免执行不必要的处理
5) 依照情况适当的选择使用Web控件
6) 只有在需要的时候保存服务器控件的视图状态(ViewState)
7) 如果没有特殊原因,不要去ASP.NET输出缓冲区
8) 在代码中不要太依赖于异常
9) 合理的使用GC和自动内存管理
10) 如果站点足够庞大,首先考虑预编译
11) 假如需要,适当的调整应用程序每工作进程的最大线程数
12) 在同一个应用程序中,尽量使用HttpServerUtility.Tranfer()替代 HttpServerUtility.Redirect,避免不必要的服务器交互
13) 卸载不必要的Http请求管道中的模块
14) 使用SQL存储过程加速数据访问
15) 尽量使用DataReader
………….
不知道各位看官是否注意到,我这里所提到的问题都是针对于代码编写初期或者编写过程中的应对之策,那么在已经运行的系统出现性能问题的情况下呢,是否有一些“亡羊补牢”的做法呢?也许有人会告诉你“绝对没有”,因为大多数开发人员都会将问题归咎于设计本身的问题,也有人会告诉你“基本上很难”,是的,的确没有什么招数能够让您的应用系统立马起死回生,剩余可以推荐的只有“Scale-Up”了,这是无奈之后的唯一之举。
我今天要探讨的主题是在性能出现问题的情况下我们还有多少努力和挽回的空间,是否能够利用一些工具和经验通过局部性的重构提高应用程序的性能呢,一切的废话已尽,让我们开始今天的旅程。
庖丁解牛
Eric是一家网站的开发部经理,因为各种原因,目前的网站架构设计存在一些问题,同时因为业务增长速度比较快(每天接近1万新用户加入),这个时候性能问题显得愈发严峻,于是如何在不改动现有架构的情况下适当的提高应用程序的性能就成为了Eric需要面对的挑战。
我们知道,一个典型的ASP.NET应用程序会有三个部分组成:数据访问层、业务罗基层及其表现层。一个中等复杂应用的ASP.NET网站(以论坛为例)大概会有50个的数据表,加上一些存储过程和视图,再加上中间层和aspx页面,超过10万行代码并不是什么稀奇事情,问题也是由此产生,那么多的代码,如何找到具体的性能问题出现在哪里呢?是编写不合理的SQL?是数据表没有合适的索引?是存储过程有问题?还是数据访问的方法存在问题?甚至是页面的组织结构存在问题?
一切都是问号,而且没有人能够给你答案,也没有一个工具能够告诉你答案,那么开始解剖你的系统,解开系统这头牛,我们才可能知道其中发生了什么。基于数据库的Web应用程序任何性能问题的发生可以归结为两个问题:
1) 和数据库服务的交互存在性能的瓶颈
2) 页面表现存在瓶颈
光说不练可不是一个好屠夫,只有通过解剖才可能确定其中到底发生了什么。对于数据库访问存在瓶颈的情况,我们需要确定如下几个事情:
1) 有多少SQL在访问数据库
2) 哪些SQL执行效率比较低下
3) 在固定时间内哪些SQL耗费了最多的执行资源
4) 哪些SQL被执行的次数最多
那么如何去确定呢?幸运的是《MSDN开发精选》2005年第三期给我们提供了一篇名为《应用Profiler优化SQL Server数据库系统》的好文章,在文中苏永全先生提出了利用SQL Profiler捕获生产环境(实际运行环境)所有执行的SQL语句和存储过程,然后将其结果存储为TRC文件,在利用Read80Trace这样的工具进行标准化。
与此同时,苏先生还提供了一个自己编写的存储过程,通过usp_GetAccessPattern这个存储过程我们“提纲契领”地得出我们应用系统的数据访问模式,这就是我要给大家推荐的第一招:“确定数据访问模式”。
那么我们需要怎样去确定应用程序的页面访问模式呢?或许你会通过一个日志分析系统得到哪些页面被访问多少次,然后确定哪些页面是被频繁访问的页面,也就此将关注点转移到被频繁访问的页面,不可否认,日志分析能够帮你解决一部分的问题,但是依旧无法帮你找到性能问题的关键,我们需要确定的是哪些页面执行时间很长,那些页面被频繁执行,那些页面的总执行时间在所有执行时间中占据多大比例。
为此我编写了一个很小的模块,用来记录每个页面的执行次数和总执行时间,因为没有必要记录每次的执行时间,我采用了每个小时存储一次每个页面的执行次数和总执行时间,虽然这样的做法在应用程序更新的时候会导致部分数据的丢失,但是对于一个比较长时间(如2天)的场景捕获来说,损失是无关紧要的。(代码可以从MSDN杂志网站下载)
为此我定义了BasePageEntry类,用来处理每个页面的相关的信息,如执行次数,最后访问时间,总执行时间,具体Url等,具体代码如下
using System;
using System.Web;
namespace HeiYou.Match100.Modules.PerformanceMonitor
{
///<summary>
/// Summary description for IPageEntry.
///</summary>
public class BasePageEntry
{
public BasePageEntry()
{
this.m_LastAccessTime = DateTime.Now;
}
public virtual void Store()
{
}
///<summary>
///入口Url
///</summary>
public string Url
{
get { return m_Url; }
set { m_Url = value; }
}
private string m_Url = null;
///<summary>
///最后访问时间
///</summary>
public DateTime LastAccessTime
{
get { return m_LastAccessTime; }
}
private DateTime m_LastAccessTime;
public double TotalExecuteTime
{
get { return m_TotalExecuteTime; }
}
protected double m_TotalExecuteTime = 0;
public long PageExecuteCount
{
get { return m_PageExecuteCount; }
}
protected long m_PageExecuteCount = 0;
///<summary>
///自动记录页面执行的总时间
///</summary>
///<param name="executeTime"></param>
public void Increase(double executeTime)
{
m_PageExecuteCount++;
m_TotalExecuteTime += executeTime;
m_LastAccessTime = DateTime.Now;
}
///<summary>
///实现每天数据的自动切换
///</summary>
///<param name="executeTime"></param>
public BasePageEntry WiseIncrease(double executeTime)
{
DateTime dt = DateTime.Now;
if (dt.Hour != this.m_LastAccessTime.Hour) //当天时间
{
this.Store();
}
this.Increase(executeTime);
return this;
}
}
}
在DbPageEntry中我实现了数据存储的接口,用来将其数据保存到数据库,以方便日后作统计分析,具体代码如下:
public override void Store()
{
SqlParameter[] queryParam=new SqlParameter[] {
new SqlParameter("@Url",SqlDbType.VarChar,800),
new SqlParameter("@LogTime",SqlDbType.DateTime),
new SqlParameter("@TotalExecuteTime",SqlDbType.Float),
new SqlParameter("@PageExecuteCount",SqlDbType.Int)
};
queryParam[0].Value=this.Url;
queryParam[1].Value=this.LastAccessTime;
queryParam[2].Value=this.m_TotalExecuteTime;
queryParam[3].Value=this.m_PageExecuteCount;
try
{
SqlHelper.ExecuteNonQuery(CONN_STRING,CommandType.StoredProcedure,"LogPageExecuteInfo",queryParam);
}
catch{}
this.m_PageExecuteCount=0;
this.m_TotalExecuteTime=0;
}
为了准确地确定每个页面的执行时间,采用了一个HttpModoule,在Application_BeginRequest和Application_EndRequest处加入代码,从而准确的拦截到每个页面的具体执行时间,具体代码如下:
private void context_BeginRequest(object sender, EventArgs e)
{
PerformanceCounterUtil.AttachTickCount();
}
private void context_EndRequest(object sender, EventArgs e)
{
HttpContext Context=HttpContext.Current;
string url=Context.Request.Url.AbsolutePath.ToLower();
double tickcount=PerformanceCounterUtil.GetPageExecuteTime();
//Context.Response.Write("URL="+url +" TickCount"+tickcount);
PageEntryManager.GetManager().Log(url,tickcount);
}
到目前为止,我们已经完成了一个基本的页面执行时间检测模块,程序每小时将不同页面的监测数据写入到数据中,通过如下的存储过程我们就可以得到大致的执行情况
create proc dbo.SummaryPageResponse
as
declare @ExecuteAllTime float
declare @PageViewAll int
select @ExecuteAllTime=sum(TotalExecuteTime) from PageResponseLog
select @PageViewAll=sum(PageExecuteCount) from PageResponseLog
select * from(
select a.*,b.Url,ExecuteTime/PageViewCount as AvgTime,ExecuteTime * 100 /@ExecuteAllTime as ExecuteTimeRate
,PageViewCount * 100.0 /@PageViewAll as PageViewRate from(
select sum(TotalExecuteTime) as ExecuteTime,sum(PageExecuteCount) as PageViewCount,EntryID from pageresponselog group by EntryID
) a
inner join
PageEntry b
on b.EntryID=a.EntryID
) a order by ExecuteTimeRate desc
利用这个存储过程我们可以看到哪些页面的平均执行时间最长,哪些页面执行次数最多,哪些页面占据总执行时间最多。这就是我要说的第二招:“确定页面访问模式”。
到目前为止我们已经找到整个应用程序性能瓶颈所在了,不外乎如下几种情况
1) 运行最频繁的语句
2) 运行最频繁的页面
3) 执行最慢的语句
4) 执行最慢的页面
5) 总执行时间最长的语句
6) 总执行时间最长的页面
有了这个六个之最,也就找到了问题的症结,那么下面的工作就是开始对其对症下药了。
对症下药
上文提到的六个情况根据不同的业务情况有不同的轻重缓急,这里给出的是一些常规性的建议,如果不是特殊的业务需要,也建议按照下面的顺利入手一点点地调整应用程序的性能。
1. 占据总执行时间最长(比例最大)的语句
从经验上看,占据总执行最长的语句往往不是执行时间最长的语句,而是执行时间中等但是相对频繁的语句,这些也是业务的重点,造成这样的情况会有很多,所以不同的情况需要使用不同的解决方法
1) SQL语句编写不是最优化
请检查是否使用了Like,Not in那样操作效率非常低的操作符,检查你的业务,确实是否有必要使用模糊查询,如果不需要,则使用=操作符。检查是否in中有太多的不确定性并且数量不大,假若如此,建议将in的内容首先查询出来然后通过SQL拼接的方式进行二次查询,这样的查询效率会明显比单一SQL语句高。
2) 没有合理的索引
Select username,userid from t_user where email=’eric@heiyou.com’
假如是出现这样的语句,请确定是否对于经常查询的字段做了索引,对于主键是否做了簇集索引,如果对于某些表的查询经常需要使用了某两个字段组合查询,请作必要的组合索引。
3) 不必要的数据返回
Select * from t_user where email=’eric@heiyou.com’
如果没有必要返回所有字段,确定只返回需要使用的字段,以减少不必要的服务器来回,特别是对于有大的varchar或者text类型的字段表。
2. 执行最频繁的语句
检查您的业务是否需要如此频率的访问数据,对于不同类型的业务数据采用不同的策略。
1) 应用程序公共数据
如全国各地的省份这样的数据尽可能的使用缓存,以避免无谓的数据库访问
2) 用户私有数据
在整个页面执行过程中,尽可能在Context(上下文)中唯一访问一次
3. 占据总执行时间最长的页面
面对这类情况,如果是统计业务而且不被频繁使用,那么可以放到一个比较低的优先级去考虑,如果是被频繁访问,而且程序也无法修改成上述提供的建议,那么对于一些必要的统计信息作冗余表,以加快统计速度。
4. 执行占总时间最长的页面
面对这样的页面,大多是业务中用户关注的表现页面,并且大多情况下页面比较复杂,这个时候优化的原则是尽可能减小输出和重复访问。
1) 模块化表现页面,将一些内容相对固定的页面抽象成Web控件,没有必要的情况下禁用这些服务器控件的视图状态,业务允许的情况下作控件页面缓存
2) 优化HTML结构,尽可能采取xhtml,以方便浏览器更加快速展现,从而提供更好的用户体验
3) 分析页面结构,适当的情况下采用AJAX提高响应速度,避免不必要的Postback
5. 执行最频繁的页面
1) 如果是公共页面,那么进行静态页面缓存
2) 如果是私有用户页面,依旧通过AJAX技术减小页面刷新区域
6. 执行时间最长的页面
对于上传图片之类的页面因为受网络传输的影响,可以暂时不放到关注的重点来,如果是页面包含太多的处理,可以考虑使用后台线程,以使Web服务器拥有更好的吞吐量。
总结
上面提到的一些方法只是在架构确定之下的一些调整技巧,它并不能够帮你解决所有的问题,因为问题的本质还是在架构设计上,好了,各位看官,今天话题就到这里,下次我们将讨论如何在设计阶段去保证应用程序的高性能。
最后,万变不离其中,如何写出高性能的Web应用程序呢,我想如下几点是必须具备的
1) 良好的代码风格
2) 了解CLR运行机制
3) 了解ASP.NET