谈谈最近优化一个网站项目的经验,首先说一下背景情况:
1) 在页面后台代码中我们把页面上大部分的HTML都使用字符串来拼接生成然后直接赋值给LiteralControl。
2) 网站CPU很高,基本都在80%左右,即使使用了StringBuilder来拼接字符串性能也不理想。
3) 为了改善性能,把整个字符串保存在memcached中,性能还是不理想。
在比较了这个网站和其它网站服务器上相关性能监视器指标后发现有一个参数特别显眼:
就是其中的每秒分配字节数,这个性能比较差的网站每秒分配2GB的内存(而且需要注意由于性能监视器是每秒更新一下,对于一个非常健康的网站这个值应该经常看到是0才对)!而其它一些网站只分配200M左右的内存。服务器配备4G内存,而每秒分配2G内存,我想垃圾回收器一定需要不断运行来回收这些内存。观察%Time in GC可以发现,这个值一直在10%左右,也就是说上次回收到这次回收间隔10秒的话,这次垃圾回收1秒,由于回收的时间相对固定,那么这个值可以反映回收的频繁度。
知道了这个要点就知道了方向,在项目中找可能的问题点:
1) 是否分配了大量临时的小对象
2) 是否分配了数量不多但比较大的大对象
在经历了一番查找之后,发现一个比较大的问题,虽然使用了memcached来缓存整个页面的HTML,但是在输出之前居然进行了几次string的Replace操作,这样就产生了几个大的字符串,我们来做一个实验模拟这种场景:
public partial class _Default : System.Web.UI.Page { static string template; protected void Page_Load(object sender, EventArgs e) { if (template == null) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < 10000; i++) sb.Append("1234567890"); template = sb.ToString(); } Stopwatch sw = Stopwatch.StartNew(); for (int i = 0; i < 1; i++) { long mem1 = GC.GetTotalMemory(false); string s = template + i; long mem2 = GC.GetTotalMemory(false); Response.Write((mem2 - mem1).ToString("N0")); Response.Write("<br/>"); GC.KeepAlive(s); } for (int i = 0; i < 100000; i++) { double d = Math.Sqrt(i); } Thread.Sleep(30); Response.Write(sw.ElapsedMilliseconds); } }
在这段代码中:
1) 我们首先使用一个静态变量模拟缓存中的待输出的HTML
2) 我们中间的一段代码测算一下这个字符串占用的内存空间
3) 随后我们做了一些消耗CPU的运算操作来模拟页面的一些计算
4) 然后休眠一段时间
4) 最后我们输出了页面执行时间
我们这么做的目的是模拟一个比较“正常的”ASP.NET页面需要做的一些工作:
1) 内存上的分配
2) 一些计算
3) 涉及到IO访问的一些等待
来看看输出结果:
这里可以看到,我们这个字符串占用差不多200K的字节,字符串是字符数组,CLR中字符采用Unicode双字节存储,因此10万长度的字符串占用200千字节,并且也可以看到这个页面执行时间30毫秒,差不多是一个正常aspx页面的时间,而200K不到的字符串也差不多相当于这个页面的HTML片段,现在我们来改一下其中的一段代码模拟优化前进行的Replace操作带来的几个大字符串:
for (int i = 0; i < 10; i++) { //long mem1 = GC.GetTotalMemory(false); string s = template + i; //long mem2 = GC.GetTotalMemory(false); //Response.Write((mem2 - mem1).ToString("N0")); //Response.Write("<br/>"); //GC.KeepAlive(s); }
然后使用IDE自带压力测试1000常量用户来测试这个页面:
可以看到每秒分配了超过400M字节(这和我们线上环境比还差点毕竟请求少),CPU占用基本在120-160左右(双核),我们去掉每秒分配内存这个数值,来看看垃圾回收频率和CPU占用两个值的图表:
可以看到红色的CPU波动基本和蓝色的垃圾回收波动保持一致(这里不太准确的另外一个原因是压力测试客户端运行于本机,而为w3wp关联2个处理器)!为什么说垃圾回收会带来CPU的波动,从理论上来说有以下原因:
1) 垃圾回收的时候会暂时挂起所有线程,然后GC会检测扫描每一个线程栈上可回收对象,然后会移动对象,并且重新设置对象指针,这整个过程首先是消耗CPU的
2) 而且在这个过程之后恢复线程执行,这个时候CPU往往会引起一个高峰因为已经有更多的请求等待了
我们把Math.Sqrt这段代码注释掉并且把w3wp和VSTestHost关联到不同的处理器来看看对于CPU计算很少的页面,上图更明显的对比:
这说明垃圾回收的确会占用很多CPU资源,但这只是一部分,其实我觉得网站的CPU压力来自于几个地方:
1) 就是大量的内存分配带来的垃圾回收所占用的CPU,对于ASP.NET框架内部的很多行为无法控制,但是可以在代码中尽量避免在堆上产生很多不必要的对象
2) 是实际的CPU运算,不涉及IO的运算,这些可以通过改良算法来优化,但是优化比较有限
3) 是IO操作这块,数据量的多少很关键,还有要考虑memcached等外部缓存对象序列化反序列化的消耗
4) 虽然很多IO操作不占用CPU资源,线程处于休眠状态,但是很多时候其实是依托新线程进行的,带来的就是线程切换和线程创建消耗的消耗,这一块可以通过合理使用多线程来优化
发现了这个问题之后优化就很简单了,把Replace操作放到memcached的Set操作之前,取出之后不产生过多大字符串,把for循环改为一次,再来看一下:
这次内存分配明显少了很多,CPU降下来了,降的不多,但从压力测试监视器中看到页面执行平均时间从5秒变为3秒了,每秒平均请求数从170到了200(最高从200到了300)。在这里要说明一点很多时候网站的性能优化不能光看CPU还要对比优化前后网站的负载,因为在优化之后页面执行时间降低了,负载量就增大了CPU消耗也随之增大。并且可以看到垃圾回收频率的缩短很明显,从长期在30%到几十秒一次30%。
最后想补充几点:
1) 有的时候我们会使用GC.GetTotalMemory(true); 来得到垃圾回收之后内存分配数,类似这样涉及到垃圾回收的代码在项目上线后千万不能出现,否则很可能会% Time in GC达到80%以上大量占用CPU。
2) 对于放在缓存中的对象我们往往会觉得性能得到保障大量去使用,其实缓存实现的只是把创造这个对象过程的时间转化为空间,而在拿到这个对象之后再进行很多运算带来的大量空间始终会进行垃圾回收。做网站和做应用程序不一样,一个操作如果申请200K堆内存,一个页面执行这个操作10次,一秒200多个请求,大家可以自己算一下平均每秒需要分配多少内存,这个数值是相当可怕的,网站是一个多线程的环境,我们对内存的使用要考虑更多。