U9C性能规范-基础篇
1.C#语言
1.1 垃圾回收
垃圾回收是现代语言的标志之一。垃圾回收解放了手工管理对象释放的工作,提高了程序的健壮性,但副作用就是程序代码可能对于对象创建变得随意。
1.1.1 避免不必要的对象创建
由于垃圾回收的代价较高,所以C#程序开发要遵循的一个基本原则就是避免不必要的对象创建。以下列举一些常见的情形。
1.1.1.1 避免循环创建对象 ★
如果对象并不会随每次循环而改变状态,那么在循环中反复创建对象将带来性能损耗。例如下例:
高效的做法是将builder对象提到循环外面创建。
1.1.1.2 在需要的逻辑分支中创建对象
如果对象只在某些逻辑分支中才被用到,那么应只在该逻辑分支中创建对象。例如:
正确的做法是:
1.1.1.3 使用常量避免创建对象
如下例,程序中存在大量new decimal(0)的代码,这会导致小对象频繁创建及回收:
正确的做法是使用Decimal.Zero常量。另外,我们也可以学习这个设计手法,应用到类似场景中。
1.1.1.4 使用StringBuilder做字符串连接
请参见1.2节。
1.1.2 不要使用空析构函数 ★
如果类包含析构函数,则创建对象时会在Finalize 队列中添加对象的引用,以保证当对象无法可达时,仍然可以调用到Finalize方法。垃圾回收器在运行期间,会启动一个低优先级的线程处理该队列。相比之下,没有析构函数的对象就没有这些消耗。如果析构函数为空,这个消耗就毫无意义,只会导致性能降低!因此,我们要求不要使用空的析构函数。
从实际情况看,许多是曾经在析构函数中包含有处理代码,但后来因为种种原因被注释掉或者删除掉了,只留下了一个空壳。此时应注意把析构函数本身注释掉或者删除掉。
1.1.3 实现IDisposable接口
垃圾回收事实上只支持托管内存的回收,对于其它的非托管资源,例如Windows GDI句柄或数据库连接,在析构函数中释放资源有很大问题。原因是垃圾回收依赖于内存紧张情况,虽然数据库连接可能已濒临耗尽,但如果内存还很充足的话,垃圾回收是不会运行的。
C#的IDisposable接口是一种显式释放资源的机制。通过提供using语句,还简化了使用方式(编译器自动生成try..finally块,并在finally块中调用Dispose方法)。对于申请了非托管资源的对象,应为其实现IDisposable接口,以保证资源一旦超出using语句范围,即得到及时释放。这对于构造健壮且性能优良的程序非常有意义!
为防止对象的Dispose方法不被调用的情况发生,一般还要提供析构函数,两者调用一个处理资源释放的公共方法。同时,Dispose方法应调用System.GC.SuppressFinalize(this),告诉垃圾回收器无需再处理Finalize方法了。
1.2 String操作
1.2.1 使用StringBuilder做字符串连接
String是不变类,使用+操作连接字符串会导致创建一个新的字符串。如果字符串连接次数不是固定的,例如在一个循环中,则应该使用StringBuilder类来做字符串连接工作。因为StringBuilder内部有一个StringBuffer,连接操作不会每次分配新的字符串空间。只有当连接后的字符串超出Buffer大小时,才会申请新的Buffer空间。典型代码如下:
StringBuilder sb = new StringBuilder(256);
for (int i=0; i< Results.Count; i++)
{
sb.Append (Results[i]);
}
而如果连接次数是固定的并且只有几次,此时应该直接用+号连接,保持程序简洁易读。实际上,编译器已经做了优化,会依据加号次数调用不同参数个数的String.Concat方法。例如:
String str = str1+str2+str3+str4;
会被编译为String.Concat(str1,str2,str3, str4)。该方法内部会计算总的String长度,仅分配一次,并不会如通常想象的那样分配三次。作为一个经验值,当字符串连接操作达到10次以上时,则应该使用StringBuilder。
这里有一个细节提醒注意:StringBuilder内部Buffer的缺省值为16,这个实在太小。按StringBuilder的使用场景,Buffer肯定得重新分配。我们建议使用256作为Buffer的初值。当然,如果能计算出最终生成字符串长度的话,则应该按这个值来设定Buffer的初值。我们曾经开发过一个生成44位UUID的方法,仅仅把new StringBuilder()改为new StringBuilder(44),前后就有3倍的效率差异。
1.2.2 避免不必要的调用ToUpper或ToLower方法
String是不变类,调用ToUpper或ToLower方法都会导致创建一个新的字符串。如果被频繁调用,将导致频繁创建字符串对象。这违背了前面讲到的“避免频繁创建对象”这一基本原则。
例如,bool.Parse方法本身已经是忽略大小写的,但下面的代码每次访问IsNullable属性时,都要不必要的调用ToLower方法:
另外一个非常普遍的场景是字符串比较,例如:
高效的做法是使用Compare方法,这个方法可以做大小写忽略的比较,并且不会创建新字符串:
最后列举的一个示例是使用HashTable的时候,有时候无法保证传递key的大小写是否符合预期,往往会把key强制转换到大写或小写方式,例如:myTable.Add(myKey.ToLower(), myObject)。实际上HashTable有不同的构造形式,完全支持采用忽略大小写的Key:new HashTable(StringComparer.OrdinalIgnoreCase)。
1.2.3 最快的空串比较方法
将String对象的Length属性与0比较是最快的方法:if (str.Length == 0)
其次是与String.Empty常量或空串比较:if (str == String.Empty)或if (str == "")
注:C#在编译时会将程序集中声明的所有字符串常量放到保留池中(intern pool),相同常量不会重复分配。
1.3 多线程
1.3.1 线程同步
线程同步是编写多线程程序需要首先考虑的问题。C#为同步提供了Monitor、Mutex、AutoResetEvent和ManualResetEvent对象来分别包装Win32的临界区、互斥对象和事件对象这几种基础的同步机制。C#还提供了一个lock语句,方便使用,编译器会自动生成适当的Monitor.Enter和Monitor.Exit调用。
1.3.1.1 同步粒度
同步粒度可以是整个方法,也可以是方法中某一段代码。为方法指定MethodImplOptions.Synchronized属性将标记对整个方法同步。例如:
通常情况下,应减小同步的范围,使系统获得更好的性能。简单将整个方法标记为同步不是一个好主意,除非能确定方法中的每行代码都需要受同步保护。
1.3.1.2 同步策略
使用lock进行同步,同步对象可以选择Type、this或为同步目的专门构造的成员变量。
避免锁定Type ★
锁定Type对象会影响同一进程中所有AppDomain该类型的所有实例,这不仅可能导致严重的性能问题,还可能导致一些无法预期的行为。这是一个很不好的习惯。即便对于一个只包含static方法的类型,也应额外构造一个static的成员变量,让此成员变量作为锁定对象。
避免锁定this
锁定this会影响该实例的所有方法。假设对象obj有A和B两个方法,其中A方法使用lock(this)对方法中的某段代码设置同步保护。现在,因为某种原因,B方法也开始使用lock(this)来设置同步保护了,并且可能为了完全不同的目的。这样,A方法就被干扰了,其行为可能无法预知。所以,作为一种良好的习惯,我们建议避免使用lock(this)这种方式。
使用为同步目的专门构造的成员变量
这是推荐的做法。方式就是new一个object对象,该对象仅仅用于同步目的。示例如下:
如果有多个方法都需要同步并且有不同的目的,那么就可以为此分别建立几个同步成员变量。
1.3.1.3 集合同步
C#为各种集合类型提供了两种方便的同步机制:Synchronized包装器和SyncRoot属性。
调用Synchronized方法会返回一个可保证所有操作都是线程安全的相同集合对象。考虑mySyncdAL[0] = mySyncdAL[0] + "test"这一语句,读和写一共要用到两个锁。一般讲,效率不高。推荐使用SyncRoot属性,可以做比较精细的控制。
1.3.2 使用ThreadStatic替代NamedDataSlot ★
存取NamedDataSlot的Thread.GetData和Thread.SetData方法需要线程同步,涉及两个锁:一个是LocalDataStore.SetData方法需要在AppDomain一级加锁,另一个是ThreadNative.GetDomainLocalStore方法需要在Process一级加锁。如果一些底层的基础服务使用了NamedDataSlot,将导致系统出现严重的伸缩性问题。
规避这个问题的方法是使用ThreadStatic变量。示例如下:
1.3.3 多线程编程技巧
1.3.3.1 使用Double Check技术创建对象
创建单例对象是很常见的一种编程情况。一般在lock语句后就会直接创建对象了,但这不够安全。因为在lock锁定对象之前,可能已经有多个线程进入到了第一个if语句中。如果不加第二个if语句,则单例对象会被重复创建,新的实例替代掉旧的实例。如果单例对象中已有数据不允许被破坏或者别的什么原因,则应考虑使用Double Check技术。
1.4 类型系统
1.4.1 避免无意义的变量初始化动作
CLR保证所有对象在访问前已初始化,其做法是将分配的内存清零。因此,不需要将变量重新初始化为0、false或null。例如下面的代码,大部分赋初值的动作都毫无意义:
需要注意的是:方法中的局部变量不是从堆而是从栈上分配,所以C#不会做清零工作。如果使用了未赋值的局部变量,编译期间即会报警。不要因为有这个印象而对所有类的成员变量也做赋值动作,两者的机理完全不同!
1.4.2 ValueType和ReferenceType
1.4.2.1 以引用方式传递值类型参数
值类型从调用栈分配,引用类型从托管堆分配。当值类型用作方法参数时,默认会进行参数值复制,这抵消了值类型分配效率上的优势。作为一项基本技巧,以引用方式传递值类型参数可以提高性能:
1.4.2.2 为ValueType提供Equals方法
.net默认实现的ValueType.Equals方法使用了反射技术,依靠反射来获取所有成员变量值做比较,这个效率极低:
如果我们编写的值对象其Equals方法要被用到(例如将值对象放到HashTable中),那么就应该重载Equals方法。例如:
- 避免装箱和拆箱
C#可以在值类型和引用类型之间自动转换,方法是装箱和拆箱。装箱需要从堆上分配对象并拷贝值,有一定性能消耗。如果这一过程发生在循环中或是作为底层方法被频繁调用,则应该警惕累计的效应。
一种经常的情形出现在使用集合类型时。例如:
解决这个问题的方法是使用.net 2.0支持的泛型集合类型。
1.5 异常处理
异常也是现代语言的典型特征。与传统检查错误码的方式相比,异常是强制性的(不依赖于是否忘记了编写检查错误码的代码)、强类型的、并带有丰富的异常信息(例如调用栈)。
1.5.1 不要吃掉异常 ★
关于异常处理的最重要原则就是:不要吃掉异常。这个问题与性能无关,但对于编写健壮和易于排错的程序非常重要。这个原则换一种说法,就是不要捕获哪些你不能处理的异常。例如:
吃掉异常是极不好的习惯,因为你消除了解决问题的线索。一旦出现错误,定位问题将非常困难。除了这种完全吃掉异常的方式外,只将异常信息写入日志文件但并不做更多处理的做法也同样不妥:
如上例,一旦调用Bp.Do方法失败,pageDefine为null,随后访问pageDefine.PartRelation将抛出NullReferenceException错误,这与原始的异常信息完全不一样。例如,原始的异常信息可能指示数据库配置字符串有错,简单更改后就ok了。而象现在这样,如果没有源代码,定位问题将很困难。
1.5.2 不要吃掉异常信息 ★
有些代码虽然抛出了异常,但却把异常信息吃掉了。例如下例,后台调用异常的原因可能是因为编码输入重复,未能通过数据库唯一性约束。但展现的异常信息却是一句毫无意义的废话,原始的异常信息被吃掉了。本来只需要用户把编码修改一下,重新提交就ok了,但现在将不得不依靠程序员来协助排错。
为异常披露详尽的信息是程序员的职责所在(在没有异常的时代,健壮的错误处理代码能体现专业水准的差异)。如果不能在保留原始异常信息含义的前提下附加更丰富和更人性化的内容,那么让原始的异常信息直接展示也要强得多。千万不要吃掉异常信息!
1.5.3 避免不必要的抛出异常
抛出异常和捕获异常属于消耗比较大的操作,在可能的情况下,应通过完善程序逻辑避免抛出不必要的异常。例如:
此方法运行期间会抛出许多NullReferenceException,应通过增加对Namespace是否为null的检查,避免抛出异常。与此相关的一个倾向是利用异常来控制处理逻辑。尽管对于极少数的情况,这可能获得更为优雅的解决方案,但通常而言应该避免。
1.5.4 避免不必要的重新抛出异常
如果是为了包装异常的目的(即加入更多信息后包装成新异常),那么是合理的。但是有不少代码,捕获异常没有做任何处理就再次抛出,这将无谓地增加一次捕获异常和抛出异常的消耗,对性能有伤害。例如:
将这个原则与“不要吃掉异常”比较起来,不要吃掉异常更为重要!例如下例:
程序员已经清楚应该在以后补上异常处理动作,但由于吃掉了异常,会给排错带来极大困难。此时,增加一个throw语句要比吃掉异常强得多!
1.5.5 使用throw而不是throw e抛出原来的异常 ★
如果在捕获异常之后,不必包装成新的异常,而只是继续抛出原来的异常,通常可以看到两种写法。
方式1:使用不带参数的throw语句。示例如下:
方式2:使用带对象实例的throw语句。示例如下:
方式2表面上看跟方式1没有什么不同,其实有很微妙的差异:throw e表示在当前位置重新抛出异常,并不是转发原来的异常。它会更改异常的内部信息,最主要的是StackTrace会发生变化,引发异常的代码位置将变为throw e代码所在的位置而非最初引发异常的代码位置!这应该不是希望看到的结果吧。如果查看IL代码,会发现一个是rethrow指令,另一个则是throw指令,完全不同。
另外,方式2在性能上也有较大损失,因为异常最主要的消耗不是在new Exception的时候,而是在throw Exception的时候,因为throw语句才会更改包括StackTrace在内的许多异常内部信息。对于复杂的应用,如果发生异常时的调用链很深,构造StackTrace的消耗之大远远超乎想象。我们在研究异常消耗时可能会写一个简单的控制台程序,模拟抛出异常、捕获异常来研究异常消耗,在这种场景下得出的印象是.net的异常性能还可以,但在真实的应用中你会看到这个消耗能上升2到3个数量级!
因此,综合考虑这两方面的因素,我们要求严格按throw而不是throw e的方式抛出原来的异常。
1.6 反射
反射是一项很基础的技术,它将编译期间的静态绑定转换为延迟到运行期间的动态绑定。在很多场景下(特别是类框架的设计),可以获得灵活易于扩展的架构。但带来的问题是与静态绑定相比,动态绑定会对性能造成较大的伤害。
1.6.1 反射分类
type comparison:类型判断,主要包括is和typeof两个操作符及对象实例上的GetType调用。这是最轻型的消耗,可以无需考虑优化问题。注意typeof运算符比对象实例上的GetType方法要快,只要可能则优先使用typeof运算符。
member enumeration:成员枚举,用于访问反射相关的元数据信息,例如Assembly.GetModule、Module.GetType、Type对象上的IsInterface、IsPublic、GetMethod、GetMethods、GetProperty、GetProperties、GetConstructor调用等。尽管元数据都会被CLR缓存,但部分方法的调用消耗仍非常大,不过这类方法调用频度不会很高,所以总体看性能损失程度中等。
member invocation:成员调用,包括动态创建对象及动态调用对象方法,主要有Activator.CreateInstance、Type.InvokeMember等。
1.6.2 动态创建对象
C#主要支持5种动态创建对象的方式:
- Type.InvokeMember
- ContructorInfo.Invoke
- Activator.CreateInstance(Type)
- Activator.CreateInstance(assemblyName, typeName)
- Assembly.CreateInstance(typeName)
经测试,性能情况对比如下:
可见,最快的是方式3,与Direct Create的差异缩小在一个数量级之内,约慢7倍的水平。其它方式,至少在40倍以上,最慢是方式4,要慢三个数量级。
1.6.3 动态方法调用
方法调用分为编译期的早期绑定和运行期的动态绑定两种,称为Early-Bound Invocation和Late-Bound Invocation。Early-Bound Invocation可细分为Direct-call、Interface-call和Delegate-call。Late-Bound Invocation主要有Type.InvokeMember和MethodBase.Invoke,还可以通过使用LCG(Lightweight Code Generation)技术生成IL代码来实现动态调用。
从测试结果看,相比Direct Call,Type.InvokeMember要接近慢三个数量级;MethodBase.Invoke虽然比Type.InvokeMember要快三倍,但比Direct Call仍慢270倍左右。可见动态方法调用的性能是非常低下的。我们的建议是:除非要满足特定的需求,否则不要使用!
1.6.4 推荐的使用原则
模式
- 如果可能,则避免使用反射和动态绑定
- 使用接口调用方式将动态绑定改造为早期绑定
- 使用Activator.CreateInstance(Type)方式动态创建对象
- 使用typeof操作符代替GetType调用
反模式
- 在已获得Type的情况下,却使用Assembly.CreateInstance(type.FullName)
1.7 基本代码技巧
本节描述一些应用场景下,可以提高性能的基本代码技巧。对处于关键路径的代码,进行这类的优化还是很有意义的。普通代码可以不做要求,但养成一种好的习惯也是有意义的。
1.7.1 循环写法
可以把循环的判断条件用局部变量记录下来。局部变量往往被编译器优化为直接使用寄存器,相对于普通从堆或栈中分配的变量速度快。如果访问的是复杂计算属性的话,提升效果将更明显。例如:
高效的做法是:for (int i = 0, j = collection.GetIndexOf(item); i < j; i++)
需要说明的是:这种写法对于CLR集合类的Count属性没有意义,原因是编译器已经按这种方式做了特别的优化。
1.7.2 拼装字符串
拼装好之后再删除是很低效的写法。有些方法其循环长度在大部分情况下为1,这种写法的低效就更为明显了:
推荐下面的写法:
其实这种写法非常自然,而且效率很高,完全不需要用个Remove方法绕来绕去。
1.7.3 避免两次检索集合元素
获取集合元素时,有时需要检查元素是否存在。通常的做法是先调用ContainsKey(或Contains)方法,然后再获取集合元素。这种写法非常符合逻辑:
但如果考虑效率,可以先直接获取对象,然后判断对象是否为null来确定元素是否存在。对于Hashtable,这可以节省一次GetHashCode调用和n次Equals比较。
又比如下面的示例:
其实完全可用一行代码完成:return this.idTable[id] as IData;
1.7.4 避免两次类型转换
考虑如下示例,其中包含了两处类型转换:
效率更高的做法如下:
1.7.5 避免组织log语句的消耗
日志级别已经被设置为Info级别,如下所示的方法并不会输出日志信息到文件中。但从性能跟踪文件中却发现这个方法有显著消耗(底层方法,调用频繁):
原来,这个消耗发生在组织log语句上。虽然logger.Debug方法内会检查日志级别,并不实际输出,但组织log语句的消耗已然发生。编写log语句的基本技巧如下:
if (logger.IsDebugEnabled)
{
logger.Debug(…);
}
1.8 Hashtable
Hashtable是一种使用非常频繁的基础集合类型。需要理解影响Hashtable的效率有两个因素:一是散列码(GetHashCode方法),二是等值比较(Equals方法)。Hashtable首先使用键的散列码将对象分布到不同的存储桶中,随后在该特定的存储桶中使用键的Equals方法进行查找。
良好的散列码是第一位的因素,最理想的情况是每个不同的键都有不同的散列码。Equals方法也很重要,因为散列只需要做一次,而存储桶中查找键可能需要做多次。从实际经验看,使用Hashtable时,Equals方法的消耗一般会占到一半以上。
System.Object类提供了默认的GetHashCode实现,使用对象在内存中的地址作为散列码。我们遇到过一个用Hashtable来缓存对象的例子,每次根据传递的OQL表达式构造出一个ExpressionList对象,再调用QueryCompiler的方法编译得到CompiledQuery对象。以ExpressionList对象和CompiledQuery对象作为键值对存储到Hashtable中。ExpressionList对象没有重载GetHashCode实现,其超类ArrayList也没有,这样最后用的就是System.Object类的GetHashCode实现。由于ExpressionList对象会每次构造,因此它的HashCode每次都不同,所以这个CompiledQueryCache根本就没有起到预想的作用。这个小小的疏漏带来了重大的性能问题,由于解析OQL表达式频繁发生,导致CompiledQueryCache不断增长,造成服务器内存泄漏!解决这个问题的最简单方法就是提供一个常量实现,例如让散列码为常量0。虽然这会导致所有对象汇聚到同一个存储桶中,效率不高,但至少可以解决掉内存泄漏问题。当然,最终还是会实现一个高效的GetHashCode方法的。
以上介绍这些Hashtable机理,主要是希望大家理解:如果使用Hashtable,你应该检查一下对象是否提供了适当的GetHashCode和Equals方法实现。否则,有可能出现效率不高或者与预期行为不符的情况。
2. Ado.net
2.1 应用Ado.net的一些思考原则
- 根据数据使用的方式来设计数据访问层
- 缓存数据,避免不必要的操作
- 使用服务帐户进行连接
- 必要时申请,尽早释放
- 关闭可关闭的资源
- 减少往返
- 仅返回需要的数据
- 选择适当的事务类型
- 使用存储过程
2.2 Connection
数据库连接是一种共享资源,并且打开和关闭的开销较大。Ado.net默认启用了连接池机制,关闭连接不会真的关闭物理连接,而只是把连接放回到连接池中。因为池中共享的连接资源始终是有限的,如果在使用连接后不尽快关闭连接,那么就有可能导致申请连接的线程被阻塞住,影响整个系统的性能表现。
2.2.1 在方法中打开和关闭连接
这个原则有几层含义:
- 主要目的是为了做到必要时申请和尽早释放
- 不要在类的构造函数中打开连接、在析构函数中释放连接。因为这将依赖于垃圾回收,而垃圾回收只受内存影响,回收时机不定
- 不要在方法之间传递连接,这往往导致连接保持打开的时间过长
这里强调一下在方法之间传递连接的危害:我们曾经在压力测试中遇到过一个测试案例,当增大用户数的时候,这个案例要比别的案例早很久就用掉连接池中的所有连接。经分析,就是因为A方法把一个打开的连接传递到了B方法,而B方法又调用了一个自行打开和关闭连接的C方法。在A方法的整个运行期间,它至少需要占用两条连接才能够成功工作,并且其中的一条连接占用时间还特别长,所以造成连接池资源紧张,影响了整个系统的可伸缩性!
2.2.2 显式关闭连接
Connection对象本身在垃圾回收时可以被关闭,而依赖垃圾回收是很不好的策略。推荐使用using语句显式关闭连接,如下例:
2.2.3 确保连接池启用
Ado.net是为每个不同的连接串建立连接池,因此应该确保连接串不会出现与具体用户相关的信息。另外,要注意连接串是大小写敏感的。
2.2.4 不要缓存连接
例如,把连接缓存到Session或Application中。在启用连接池的情况下,这种做法没有任何意义。
2.3 Command
2.3.1 使用ExecuteScalar和ExecuteNonQuery
如果想返回像Count(*)、Sum(Price)或Avg(Quantity)那样的单值,可以使用ExecuteScalar方法。ExecuteScalar返回第一行第一列的值,将结果集作为标量值返回。因为单独一步就能完成,所以ExecuteScalar不仅简化了代码,还提高了性能。
使用不返回行的SQL语句时,例如修改数据(INSERT、UPDATE或DELETE)或仅返回输出参数或返回值,请使用ExecuteNonQuery。这避免了用于创建空DataReader的任何不必要处理。
2.3.2 使用Prepare
当需要重复执行同一SQL语句多次,可考虑使用Prepare方法提升效率。需要注意的是,如果只是执行一次或两次,则完全没有必要。例如:
2.3.3 使用绑定变量 ★
SQL语句需要先被编译成执行计划,然后再执行。如果使用绑定变量的方式,那么这个执行计划就可以被后续执行的SQL语句所复用。而如果直接把参数合并到了SQL语句中,由于参数值千变万化,执行计划就难以被复用了。例如上面Prepare一节给出的示例,如果把参数值直接写到insert语句中,那么上面的四次调用将需要编译四次执行计划。
为避免这种情况造成性能损失,要求一律使用绑定变量方式。
2.4 DataReader
DataReader最适合于访问只读的单向数据集。与DataSet不同,数据集并不全部在内存中,而是随不断发出的read请求,一旦发现数据缓冲区中的数据均被读取,则从数据源传输一个数据缓冲区大小的数据块过来。另外,DataReader保持连接,DataSet则与连接断开。
2.4.1 显式关闭DataReader
与连接类似,也需要显式关闭DataReader。另外,如果与DataReader关联的Connection仅为DataReader服务的话,可考虑使用Command对象的ExecuteReader(CommandBehavior.CloseConnection)方式。这可以保证当DataReader关闭时,同时自动关闭Connection。
2.4.2 用索引号访问代替名称索引号访问属性
从Row中访问某列属性,使用索引号的方式比使用名称方式有细微提高。如果会被频繁调用,例如在循环中,那么可考虑此类优化。示例如下:
2.4.3 使用类型化方法访问属性
从Row中访问某列属性,用GetString、GetInt32这种显式指明类型的方法,其效率较通用的GetValue方法有细微提高,因为不需要做类型转换。
2.4.4 使用多数据集
部分场景可以考虑一次返回多数据集来降低网络交互次数,提升效率。示例如下:
2.5 DataSet
2.5.1 利用索引加快查找行的效率
如果需要反复查找行,建议增加索引。有两种方式:
- 设置DataTable的PrimaryKey
适用于按PrimaryKey查找行的情况。注意此时应调用DataTable.Rows.Find方法,一般惯用的Select方法不能利用索引。 - 使用DataView
适用于按Non-PrimaryKey查找行的情况。可为DataTable创建一个DataView,并通过SortOrder参数指示建立索引。此后使用Find或FindRows查找行。
3 Asp.net
3.1 减少往返行程(Reduce Round Trips)
使用下面的方法可以减少Web服务器和Browser之间的往返行程:
- 为Browser启用缓存
如果呈现的内容是静态的或变化周期较长,应启用Browser缓存,避免发出冗余的http请求。
- 缓冲页面输出
如果可能,则尽量缓冲页面输出,处理结束后再一次传送到客户端,这可以避免频繁传递小块内容所造成的多次网络交互。由于这种方式在页面处理结束之前客户端无法看到页面内容,因此如果一个页面的尺寸较大的话,可考虑使用Response.Flush方法。该方法强制输出迄今为止在缓冲区中的内容,你应当采用合理的算法控制调用Response.Flush方法的次数。
- 使用Server.Transfer重定向请求
3.2 避免阻塞和长时间的作业
使用Server.Transfer方法重定向请求优于Response.Redirect方法。原因是Response.Redirect会向Broswer回送一个响应头,在响应头中指出重定向的URL,之后Brower使用新的URL重新发出请求。而Server.Transfer方法直接是一个简单的服务端调用,完全没有这些开销!
需要注意Server.Transfer有局限性:第一,它会跳过安全检查;第二,只适用于在同一Web应用内的页面间跳转。
如果需要运行阻塞或长时间运行的操作,可以考虑使用异步调用的机制,以便Web服务器能够继续处理其它的请求。
- 使用异步方式调用Web服务和远程对象
只要有可能就要避免在请求的处理过程中对Web服务和远程对象的同步调用,因为它占用的是的ASP.NET 线程池中的工作线程,这将直接影响Web服务器响应其它请求的能力。
- 考虑给不需要返回值的Web方法或远程对象的方法添加OneWay属性
这种模式能让Web Server调用之后就立即返回。可根据实际情况决定是否使用这种方法。
- 使用工作队列
3.3 使用缓存
将作业提交到服务器上的工作队列中。客户端通过发送请求来轮询作业的执行结果。
缓存能在很大程度上决定ASP.NET应用的最终性能。Asp.net支持页面输出缓存和页面部分缓存,并提供Cache API,供应用程序缓存自己的数据。是否使用缓存可考虑下面的要点:
- 识别创建与访问代价较大的数据
- 评估需要缓存数据的易变性
- 评估数据的使用频次
- 将要缓存数据中易变数据和不变数据分离,只缓存不变数据
- 选择合适的缓存机制(除Asp.net Cache外,Application state和Session state也可以作为缓存使用)
3.4 多线程
- 避免在请求处理过程中创建线程
在执行请求的过程中创建线程是一种代价较大的操作,会严重影响Web Server的性能。如果后续的操作必须用线程完成,建议通过thread pool来创建/管理线程。
- 不要依赖线程数据槽或线程静态变量
由于执行请求的线程是ASP.NET thread pool中的工作线程,同一个Client的两次请求不一定由相同的线程来处理。
- 避免阻塞处理请求的线程
参考“避免阻塞和长时间的作业”小节。
- 避免异步调用
3.5 系统资源
这和1的情况类似。异步调用会导致创建新的线程,增加服务器的负担。所以,如果没有并发的作业要执行,就不要执行异步调用。
- 考虑实现资源池以提升性能
- 明确地调用Dispose或Close释放系统资源
- 不要缓存或长时间占用资源池中的资源
- 尽可能晚的申请,尽可能早的释放
3.6 页面处理
- 尽量减小Page的尺寸
包括缩短控件的名称、CSS的class的名称、去掉无谓空行和空格、禁用不需要的ViewState
- 启用页面输出的缓冲区(Buffer)
如果Buffer的机制被关闭,可以用下面的方法打开。
使用程序打开页面输出缓存:
Response.BufferOutput = true;
使用@Page开关打开页面输出缓冲机制:
<%@ Page Buffer = "true" %>
使用Web.config或Machine.config配置文件的节点:
- 利用Page.IsPostBack优化页面输出
- 通过分离页面的不同的内容,来提高缓存效率和减少呈现的时间
- 优化复杂和代价较大的循环
- 合理利用客户端的计算资源,将一些操作转移到客户端进行
3.7 ViewState
ViewState是Asp.net为服务端控件在页面回传之间跟踪状态信息而设计的一种机制。
- 关闭ViewState
如果不需要跟踪页面状态,例如页面不会 回传(PostBack)、不需要处理服务端控件事件或者每次页面刷新时都会重新计算控件内容,那么就不需要用ViewState来记录页面状态了。可以对特定的WebControl设置EnableViewState属性,也可以在页面一级设置:
<%@ Page EnableViewState="false" %>
- 在恰当的时间点初始化控件属性
ASP.NET的控件在执行构造函数、初始化的期间设置的属性不会被跟踪变化;而在初始化阶段之后对属性的修改都会被跟踪,并最终记录到IE页面的__VIEWSTATE之中。所以,选择合理的初始化控件属性的执行点,能有效的减小页面尺寸。
- 谨慎选择放到ViewState中的内容
放到ViewState中的内容会被序列化/反序列化,Asp.net为String、Integer、Boolean等基本类型的序列化做了优化,如果Array、ArrayList、HashTable存储的是基本类型效率也较高,但其它类型则需要提供类型转换器(Type Converter),否则将使用代价昂贵的二进制序列化程序。
4 JavaScript
4.1 JScript性能优化的基本原则
- 尽可能少地减少执行次数。毕竟对解释语言来说,每一个执行步骤,都需要和解释引擎做一次交互。
- 尽可能使用语言内置的功能,比如串链接。
- 尽可能使用系统提供的API来进行优化。因为这些API是编译好的二进制代码,执行效率很高。
- 书写最正确的代码。容错功能是要付出性能代价的。
4.2 JScript语言本身的优化
4.2.1 变量
- 尽量使用局部变量。
因为全局变量其实是全局对象的成员,而局部变量在栈上定义,优先查找,性能相对于全局变量要高。
- 尽量在一个语句中做定义变量和赋值。
不好的例子:
好的例子:
- 省略不必要的变量定义。
如果变量的定义可以被一个常量替代,就直接使用常量。
- 使用Object语法对对象赋值。
Object的赋值语法在操作复杂对象时效率更高。
例如,可以将下面的代码:
替换成:
4.2.2 对象缓存
- 缓存对象查找的中间结果。
因为JavaScript的解释性,所以a.b.c.d.e,需要进行至少4次查询操作,先检查a再检查a中的b,再检查b中的c,如此往下。所以如果这样的表达式重复出现,只要可能,应该尽量少出现这样的表达式,可以利用局部变量,把它放入一个临时的地方进行查询。
例如,有下面的一个JScript函数,如下:
第一次针对对象的查找路径进行优化,结果如下:
而第二次结合函数的功能和对象的查找路径进行优化,结果如下:
得到性能更好的实现。
- 缓存创建时间较长的对象。
自定义高级对象和Date、RegExp对象在构造时都会消耗大量时间。如果可以复用,应采用缓存的方式。
4.2.3 字符串操作
- 使用“+=” 追加字符串,使用“+”来连接字符串。
如果是追加字符串,最好使用s+=anotherStr操作,而不是要使用s=s+anotherStr。
如果要连接多个字符串,应该使用“+”,如:
s+=a;
s+=b;
s+=c;
应该写成
s+=a + b + c;
- 连接大量的字符串,应使用Array的join方法。
如果是收集字符串,最好使用JavaScript数组缓存,最后使用join方法连接起来,如下:
4.2.4 类型转换
- 使用Math.floor()或者Math.round()将浮点数转换成整型。
浮点数转换成整型,这个更容易出错,很多人喜欢使用parseInt(),其实parseInt()是用于将字符串转换成数字,而不是浮点数和整型之间的转换,我们应该使用Math.floor()或者Math.round()。
对象查找中的问题不一样,Math是内部对象,所以Math.floor()其实并没有多少查询方法和调用的时间,速度是最快的。
- 自定义的对象,推荐定义和使用toString()方法来进行类型转换。
对于自定义的对象,如果定义了toString()方法来进行类型转换的话,推荐显式调用toString()。因为内部的操作在尝试所有可能性之后,会尝试对象的toString()方法尝试能否转化为String,所以直接调用这个方法效率会更高。
4.2.5 循环的优化
- 尽可能少使用for(in)循环。
在JavaScript中,我们可以使用for(;;),while(),for(in)三种循环,事实上,这三种循环中for(in)的效率极差,因为他需要查询散列键,只要可以就应该尽量少用。
- 预先计算collection的length。
如:将
替换成:
效果会更好,尤其是在大循环中。
- 尽量减少循环内的操作。
循环内的每个操作,都会被放大为循环次数的倍数。所以,大循环内微小的改进,在性能的整体提升上都是可观的。
- 使用循环替代递归。
相比循环,递归的效率更差一些。递归的优点是在形式上更自然一些。所以,在不影响代码的维护性的前提下,用循环替代递归。
4.2.6 其它方面
- 尽量使用语言内置的语法。
“var arr = […];”和“var arr = new Array(…);”是等效的,但是前者的效能优于后者。同样,“var foo = {};”的方式也比“var foo = new Object();”快;“var reg = /../;”要比“var reg=new RegExp()”快。
- 尽量不要使用eval。
使用eval,相当于在运行时再次调用解释引擎,对传入的内容解释运行,需要消耗大量时间。
- 使用prototype代替closure。
使用closure在性能和内存消耗上都是不利的。如果closure使用量过大,这就会成为一个问题。所以,尽量将:
替换成:
和closure存在于对象实例之中不同,prototype存在于类中,被该类的所有的对象实例共享。
- 避免使用with语句。
With语句临时扩展对象查找的范围,节省了文字的录入时间,但付出了更多的执行时间。因为每个给出的名称都要在全局范围查找。所以,可以将下面的代码:
变更为:
4.3 DOM相关
4.3.1 创建DOM节点
相比较通过document.write来给页面生成内容,找一个容器元素(比如指定一个div或者span)并设置他们的innerHTML效率更高。
而设置innerHTML的方式比通过createElement方法创建节点的效率更高。事实上,设置元素的innerHTML是创建节点效率最高的一种方式。
如果必须使用createElement方法,而如果文档中存在现成的样板节点,应该是用cloneNode()方法。因为使用createElement()方法之后,你需要设置多次元素的属性,使用cloneNode()则可以减少属性的设置次数。同样,如果需要创建很多元素,应该先准备一个样板节点。
4.3.2 离线操作大型的DOM树
在添加一个复杂的DOM树时,可以先构造,构造结束后再将其添加到DOM数的适当节点。这能够节省界面刷新的时间。
例如,下面代码:
可以写成:
同样,在准备编辑一个复杂的树时,可以先将树从DOM树上删除,等编辑结束后再添加回来。
例如,下面代码:
可以写成:
4.3.3 对象查询
使用[“”]查询要比.item()更快。调用.item()增加了一次查询和函数的调用。
4.3.4 定时器
如果针对的是不断运行的代码,不应该使用setTimeout,而应该用setInterval。setTimeout每次要重新设置一个定时器。
4.4 其他
- 尽量减小文件尺寸。将JScript文件中无关的空行、空格、注释去掉,有助于减小JS文件的尺寸,提高下载的时间。(可以通过工具来支持代码发布)
- 尽量不要在同一个Page内同时引用JScript和VBScript引擎
- 将Page内的JScript移入到单独的JS文件中。
- 将Page内的JScript放置在Page的最下面,有助于提高页面的响应速度。
- 利用cache,减少JScript文件的下载次数
- 在HTML内书写JScript文件的URL时,注意统一大小写。这样可以利用前面URL缓存的文件。
- 推荐使用JScript Lint检查Javascript代码。毕竟,对JScript引擎来说,最容易理解的JScript代码,执行的效率也就最高。
5 数据库操作
5.1 表设计
5.1.1 树表设计
U9很多表都需要设计为树状表。但是大多数只使用ID和Parent两个字段来表示树关系。如果使用Oracle,进行树查找的时候是很方便的直接Start With … Connect By。但是目前为止U9还是只支持SQL Server,对树进行查找只能使用自关联方式,不光写法麻烦而且记录多的时候查询性能会非常差。建议在设计树表的时候可以考虑加入TreePath字段,记载到该节点记录需要经历的树路径。虽然这会增加Insert和Update的成本,但是对查询树关系非常有帮助,可以避免大部分的自关联查询。
5.1.2 分区表
记录数超过一百万的表要考虑是否需要使用分区表。对于能够明确确定分区字段,并且经常通过分区字段访问记录的,分区表会提高查询性能。
5.1.3 冗余字段
U9很多对字典表的关联查询都只是查询Code、Name等很少的字段。可以考虑将频繁需要关联查询的这种字段冗余到主表中。这种表设计会要求同步更新两个表,除非主表关联字典表查询已经出现性能问题,否则慎用!
5.1.4 Code字段长度
Code作为逻辑主键或者逻辑主键的一部分设置为nvarchar(50)就已经足够了,而且在“U9数据库设计规范”中已经对其作了规定。但是还是有不少业务表中将其设置为nvarchar(255),甚至更大。这样在建立唯一索引的时候会导致数据库常常警告超过900bytes的限制。
5.1.5 纵表变横表
有些数据库设计人员为了减少表中记录将一些表字段刻意的拉伸,将记录变成了字段,这样确实可以减少表关联查询时的计算成本,但是更多的是增加了记录锁定的危险。对于高并发系统,长时间的锁定是最影响数据库性能的因素。查询性能我们可以通过建立索引或者分区表进行优化,没有必要通过表拉伸来提高性能!
5.1.6 字段命名 ★
字段命名使用SQL Server关键字的问题虽然和性能无关,但确是最容易引发Bug的因素。如果在开发阶段不杜绝这一问题,以后再修改会增加很多代码修改等连带成本。目前关键字所引发的问题是数据生成工具无法为含有关键字字段的表生成测试数据。
5.2 索引设计
5.2.1 索引的选择性
索引的选择性是以每个索引键值对应的数据行数来衡量。良好的选择性要求选择的行数较少,最好的选择性是唯一索引。使用DBCC Show_Statistics(tablename, indexname)可以查看索引的选择性。密度(即重复度)越低,表示选择性越高。
索引的选择性是建立索引最重要的依据,不要为选择性差的列建立索引,例如性别。由于通过索引获取数据不仅要读取数据块,还要读取索引块,选择性差的索引会导致读取更多的索引块才能定位到需要的数据。在实际应用过程中,发现这种索引不仅无用,还可能反而带来性能问题,因为有时候查询优化器会被误导做出错误的选择。
另外,当一个索引由多个列构成时,应注意将选择性强的列放在前面。仅仅前后次序的不同,性能上就可能出现数量级的差异。
5.2.2 聚集索引
聚集索引与非聚集索引最大的不同,是索引的叶节点不仅包含索引项,还同时包含数据行,即:聚集索引和表中数据构成了树结构。这意味着获取每条记录(select *)都会比非聚集索引要少一次IO。
聚集索引最适合排序性质的范围查询,因为从索引树上搜索到第一行数据后,可以不再做索引查找,而是连续读取直至超出需要的数据范围。这个效率是非常高的!
聚集索引会影响数据的物理组织,所以一个表只能建立一个。默认情况下,会为主键自动创建聚集索引。构成聚集索引键的列越少越好,因为非聚集索引的索引叶节点要包含聚集索引键。
以下访问方式,较适合建立聚集索引:
- 包含大量非重复值的列。
- 使用下列运算符返回一个范围值的查询:BETWEEN、>、>=、< 和 <=。
- 被连续访问的列。
- 返回大型结果集的查询。
- 经常被使用联接或 GROUP BY 子句的查询访问的列;一般来说,这些是外键列。对 ORDER BY 或 GROUP BY 子句中指定的列进行索引,可以使 SQL Server 不必对数据进行排序,因为这些行已经排序。这样可以提高查询性能。
- OLTP 类型的应用程序,这些程序要求进行非常快速的单行查找(一般通过主键)。应在主键上创建聚集索引。
以下访问方式,不适合建立聚集索引:
- 频繁更改的列
这将导致整行移动(因为 SQL Server 必须按物理顺序保留行中的数据值)。这一点要特别注意,因为在大数据量事务处理系统中数据是易失的。
- 宽键
来自聚集索引的键值由所有非聚集索引作为查找键使用,因此存储在每个非聚集索引的叶条目内。
5.2.3 非聚集索引
非聚集索引与课本中的索引类似。数据存储在一个地方,索引存储在另一个地方,索引带有指针指向数据的存储位置。索引中的项目按索引键值的顺序存储,而表中的信息按另一种顺序存储(这可以由聚集索引规定)。
以下访问方式,较适合建立非聚集索引:
- 包含大量非重复值的列,如姓氏和名字的组合(如果聚集索引用于其它列)。如果只有很少的非重复值,如只有 1 和 0,则大多数查询将不使用索引。
- 不返回大型结果集的查询。
- 返回精确匹配的查询的搜索条件(WHERE 子句)中经常使用的列。
- 经常需要联接和分组的决策支持系统应用程序。应在联接和分组操作中使用的列上创建多个非聚集索引,在任何外键列上创建一个聚集索引。
- 在特定的查询中覆盖一个表中的所有列。这将完全消除对表或聚集索引的访问。
5.2.4 索引覆盖
索引覆盖的含义是被查询的列均包含在非聚集索引中,无须再额外通过索引项去表中查找数据。对于查询数据较多的情况,这可以减少大量的IO。
当没有形成索引覆盖的时候,查询计划中将看到bookmark lookup或clustered index seek,这是由于查询所需数据不能在非聚集索引中完全找到,需要定位到表中获取。
SQL Server 2005提供了索引附加字段功能,增加了索引覆盖的可能。一般非聚集索引的设计可以考虑将Where谓词中的字段作为索引键值,Select谓词中的字段(不包含在Where谓词中的)作为索引附加字段放在非聚集索引的叶子节点上。
5.3 避免死锁
5.3.1 使死锁最小化的原则
从理论上讲,数据库死锁无法避免,但可以遵循一定原则使死锁发生的概率尽量降低。主要的原则如下:
- 按照相同的顺序访问对象
- 降低事务大小
- 降低事务隔离级别
5.4 存储过程开发
按照相同的顺序访问对象可以避免相互持有对方请求资源的情况发生。例如一个操作主从表的处理流程,涉及查询和修改两个步骤。如果查询时是先查主表再查从表,则修改也应先修改主表再修改从表。
降低事务大小的一个主要手段,是将查询操作尽可能地提前(包括用一些中间变量记录下查询结果供后续使用),而把插入、修改等操作集中在方法靠后的部分。这样,可以让一个事务需要持有排它锁的时间尽可能短,减少死锁发生的概率。
事务隔离级别对死锁有重大影响。通常,应只设置到读提交级别,如果提高到重复读或串行化读的级别,由于查询也将持有锁不放,将极大提高死锁发生的概率。
5.4.1 使用存储过程
如果在开发SQL中需要用到SQL Server的特性功能的话,如:getdate()、substring()等函数,不要直接在C#代码中拼写SQL,这主要是考虑到今后更换数据库平台后程序的可用性。这种情况最好使用存储过程实现。需要和数据库频繁交互的操作最好也在存储过程中实现,这样可以减少网络传输负担。
5.4.2 SET NOCOUNT ON★
虽然在“U9数据库开发规范”中已经要求每个存储过程的开头第一条语句必须是SET NOCOUNT ON,但是发现还是有很多存储过程没有添加,或者被注释掉了。这可能是大家对其作用还是不太了解。
摘录SQL Server帮助文档的描述:
当SET NOCOUNT 为ON 时,将不向客户端发送存储过程中每个语句的DONE_IN_PROC 消息。使用由SQL Server 2005 提供的实用工具执行查询时,其结果会防止在Transact-SQL 语句(例如SELECT、INSERT、UPDATE 和DELETE)的末尾显示nn rows affected。如果存储过程中包含的一些语句并不返回许多实际数据,则该设置由于大量减少了网络流量,因此可显著提高性能。
5.4.3 游标的使用
当需要处理庞大记录集的时候,游标会大量消耗系统资源造成数据库系统整体性能低下。能用单条语句处理的最好避免使用游标。但是有些必须使用游标的时候也不需要为了避免游标,而采用比游标性能更加低下的方法。下面是一个为了避免游标的例子:
上面的例子虽然避免使用游标了,但是这种实现方法比游标效率还要低下。
下面我们看一个利用insert into …select 避免使用游标的例子:
5.4.4 临时数据
在存储过程中将需要成批插入业务表的数据暂时用表变量或临时表存储,最后再通过insert into … select成批插入业务表可以减少表锁定的时间,而且可以减少生成主键存储过程的调用次数。见下例:
如果临时数据记录不多,要优先考虑使用表变量,相比临时表它是更轻量级的对象,不生成数据库字典信息。并且作用域只局限于进行声明的存储过程范围内。
5.4.5 避免关联查询产生的冗余记录
在递归操作临时表或多多关系关联查询的时候,比较容易出现“积”操作产生的冗余记录。本来希望返回几千条记录,但是由于产生了冗余记录,往往会返回几万甚至几百万的记录。如果出现这种问题,最好通过嵌套查询方式避免之,或者检查算法逻辑。下面是在U9发现的一些例子。
递归操作产生冗余记录:
多多关联查询产生冗余记录:
5.4.6 判断是否存在的语句
判断普通数据库对象是否存在:
判断临时表是否存在:
判断记录是否存在:
5.4.7 使用Truncate清空表
Truncate会将表中记录全部清空,而不能有选择性的删除指定记录。而Delete可以指定删除的记录。由于Truncate操作在TransactionLog中只记录被Truncate的页号,而Delete需要记载被删除记录的详细内容,因此Truncate会比Delete更迅速。对大数据表使用Truncate,效果更加明显。Truncate Table只会删除表中记录,而不会对表的索引和结构造成影响。
5.4.8 谨慎使用Order By
Order By 操作会导致记录排序,如果实现了索引覆盖,其排序操作不会带来额外的性能影响。如果查询需要返回排序结果集,最好考虑为需要排序的字段建立索引。赋值和插入记录语句禁止使用Order By语句。下面是错误使用Order By的例子:
U9C性能规范-基础篇
本文2024-08-20 15:56:41发表“u9cloud知识”栏目。
本文链接:https://wenku.my7c.com/article/yonyou-u9cloud-1138.html