我的 .NET Core 博客性能优化经验总结

导语

去年8月,我用 .NET Core 重写了我的博客系统。经过一年多的优化,服务器响应速度从上线时候的 80ms 提高到了现在的 8ms,十倍提速。可惜由于部署在国外,自然不可抗力会导致中国用户晚上访问速度不稳定。本文分享网络正常的前提下,我做了哪些优化和提升,希望能帮到大家。

其实,在.NET Core之前,我的旧版博客系统是 .NET Framework写的,从2008年的 ASP.NET Web From 2.0 一直维护到2018年的 ASP.NET MVC5,曾经被人怀疑过:“别骗人了,.NET怎么可能这么快?” 。而如今,.NET Core 从本质上就已经比 .NET Framework 有了巨大的性能提升,甚至在不少测试下超过了Node、Go、Java。其实光看 benchmark 没太大的意义,大部分实际应用中性能问题并不在于语言和框架,而是由不佳的设计、错误的框架使用方法引起的。在 .NET Core 的实践过程中,我也学习和收获了很多,因此写下此文,分享我自己的性能优化经验。

没有银弹

首先,每个系统都是不同的。性能优化需要针对不同系统,不同业务场景,不同应用领域,不同用户种群,没有一个通用方法。比如我的博客,是内容站,交互少,大量情况都是各种姿势读数据,所以我要保证的是尽可能快的提升数据读取速度。而有些系统,比如电商,有远比内容站复杂的业务逻辑,还有秒杀等极端情况。比如国内阿里带队的“数据库不要有外键”,这是因为阿里的业务压力必须这么做,他们需要的是极端情况的写入速度,显然我的博客以及很多内容站没有这种场景,因此我依然可以用外键。所以,在开始之前,读者必须明白,软件设计是没有银弹的。我所列出的经验仅仅针对我自己的博客。大部分经验能应用在类似的内容站上,但不要盲目实践。同样是内容站,面对的用户群和压力也不一样,比如我的博客肯定无法和新浪、网易等比流量,所以优化的关键点和方法也不同。

分析和发现关键点

虽然我们在系统设计时会有一定的预判,比如哪些功能是用户最常用的,哪些请求会是最频繁的。但是上线之后用户的行为才是事实,有时候系统的表现会和我们的预期不一样。而且,随着时间的推移,用户的使用习惯可能会变,系统面临压力的部分也会改变。所以,我们需要记录和分析系统在实际使用过程中产生的数据和用户行为。而我所使用的Azure Application Insights就是一款极佳的APM工具。作为一个网站,性能是服务端(后台)和客户端(前台)共同决定的,Azure Application Insights可以同时收集后端API处理速度、数据库查询相应速度以及前端页面资源加载速度、JS执行速度等,也会自动分析出最慢的请求是哪些,系统最耗时的操作在哪个环节(前端、程序或数据库),甚至Azure SQL Database能根据实际使用情况自动推荐优化方案(比如哪里加何种索引等)。本文不讨论APM工具的使用。但是做性能优化的时候,必须针对实际用户产生的数据,分析以后去鉴别哪里需要优化。我的博客上线几个月后,我的分析如下:

1. 客户端性能开销在加载资源和过多的请求(前端库,博客文章配图)

2. 服务端性能开销在过多重复的SQL查询

3. 博客配图由后端从Azure Blob Storage中读取再返回前端产生双倍性能开销

前端实践

使用 bundle 避免过多请求

我相信大部分Web程序员都熟悉这一条建议,这也是最直接有效的前端性能提升方式。我们网站中通常要加载许多不同的库和资源,有图片,CSS,JS等。而浏览器大量的时间开销在于对这些资源发起请求,等待响应。即使你的文件很小,但是太多的请求数量会明显降低网页加载速度。因此很久之前业界就流行一种做法,即打包压缩资源文件,比如将多个JS文件打包压缩成一份,这样浏览器就只要发起一个请求,就能加载你网站所有需要的JS资源。

打包工具五花八门,可以根据自己的喜好选择。我博客使用的是 BuildBundlerMinifier,它可以在编程和编译时完成打包:

<PackageReference Include="BuildBundlerMinifier" Version="3.2.435" />

其定义示例如下:

{

"outputFileName": "wwwroot/js/app/app-js-bundle.js",

"inputFiles": [

"wwwroot/lib/jquery/jquery.min.js",

"wwwroot/lib/jquery-validate/jquery.validate.min.js",

"wwwroot/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js",

"wwwroot/lib/twitter-bootstrap/js/bootstrap.bundle.min.js",

"wwwroot/lib/jquery-qrcode/jquery.qrcode.min.js",

"wwwroot/lib/toastr.js/toastr.min.js",

"wwwroot/js/lazyload.js",

"wwwroot/js/app/moonglade-base.js",

"wwwroot/js/app/postslug.js",

"wwwroot/js/app/csrf.js",

"wwwroot/js/app/comments.js"

]

}

Js真的要放body最后吗?

这也是一条几乎Web程序员人尽皆知的原则。如果你将JS资源放在body最后加载,即</body>标签之前,那么浏览器会异步加载你的JS。如果按照传统方式将JS资源放在head标签里,那么浏览器必须加载完JS资源才开始渲染网页。

聪明的朋友可能了解,这一条在2019年已经不一定适用了。首先,我们可以通过添加defer标签来告诉浏览器,遇到这个JS,不要等加载完成再继续干活,你管你渲染网页,我管我加载:

<script defer src="996.js"></script>

<script defer src="007.js"></script>

不过defer的脚本还是会按顺序执行,这对于有依赖关系的JS资源十分重要,比如上面这段代码,即使007.js非常小,首先加载完成,它也必须等到996.js加载完成后才能执行。如果你想要谁先加载完,谁先执行的效果,把defer换成async即可,这种情况下你得保证你的JS之间没有依赖关系,没有依赖关系,没有依赖关系!!!重要的说三遍!

可惜,由于我们控制不了用户使用的浏览器类型和版本,根据 Azure Application Insights 的后台统计,仍然有不少用户使用低版本的浏览器访问我的网站,它们并不认识 defer和 async。

所以目前,我博客的实践依然是JS尽量放body最后,但不是绝对!由于框架性质的JS文件必须完成加载才能正确渲染网页,因此我博客中它们还是放在head里,而用户代码我会放在body最后。优化性能的前提,一定是不要影响正常功能!所以,程序员看问题不要非黑即白,还是那句软件工程的老话:没有银弹。

如果你的网站没有低版本的客户端,那么可以尽量用 defer和 async。

使用 HTTP/2

启用HTTP2可以有效提高网络传输效率,根据该项调研(https://w3techs.com/technologies/details/ce-http2),截至2019年12月,全球大约有42.6%的网站已经升级到了HTTP2。其对于网络性能的提升主要在这几个方面:

降低延迟以提高网页加载速度:

  • HTTP头的数据压缩
  • 服务器端推送 (这个.NET Core好像没有)
  • 请求管线
  • 修复HTTP 1.x中head-of-line blocking 的问题
  • 同一个TCP连接上的请求多路复用

(参考:https://en.wikipedia.org/wiki/HTTP/2)

而我的博客使用微软 Azure App Service 托管,可以点点鼠标一秒切换到 HTTP/2,而不用自己996收福报:

如果你没有用 Azure,也不用担心,最新版 .NET Core 3.1 的kestrel 默认就打开了HTTP/2:

https://docs.microsoft.com/en-us/aspnet/core/fundamentals/servers/kestrel?view=aspnetcore-3.1#http2-support

使用压缩

开启服务器端response压缩可以减小资源传输的体积,从而达到提升性能的目的。使用 ASP.NET Core 开发的网站,部署在Azure上默认就会开启gzip,不需要自己996去研究。我的博客采用的 App Service Plan 是 Windows Server 2016,上面的IIS启用了静态和动态资源压缩。

然而,如果你不幸没有使用 Azure,那么自己稍微996一下,在IIS上开启压缩也不难,可以点点鼠标就搞定,也可以通过Web.config开启(.NET Core部署在IIS下也认web.config),具体方法可以参考:https://docs.microsoft.com/en-us/iis/configuration/system.webserver/httpcompression/

如果你用的不是IIS,也没关系,再996一下,.NET Core自己也可以加response压缩:

https://docs.microsoft.com/en-us/aspnet/core/performance/response-compression?view=aspnetcore-3.1

真的要用SPA吗?

2014年以后,随着SPA的兴起,Angular等框架逐渐成为了前端开发的主流。它们解决的问题正是提升前端的响应度,让Web应用尽量接近本地原生应用的体验。我也遇到过不少朋友有疑问,为啥我的博客不用angular写?是我不会吗?

其实并不那么简单。实际上我在公司的主要工作目前也是写angular,博客曾经的.NET Framework版的后台也用过angularjs以及angular2,经过一系列的实践表明,我博客这样的内容站用angular收益并不大。

其实这并不奇怪,在盲目选择框架之前,我们得注意一个前提条件:SPA框架所针对的,其实是Web应用。而应用的意思是重交互,即像Azure Portal或Outlook邮箱那样,目的是把网页当应用程来开发,这时候SPA不仅能提升用户体验,也能降低开发成本,何乐而不为?但是博客属于内容为主的网站,不是应用,要说应用也勉强只能说博客的后台管理可以是应用。博客前台唯一的交互就是评论、搜索,因此SPA并不适合这样的工作。这就像你要去菜场买菜,骑自行车反而比你开个坦克过去方便。

所以,程序员切记,看待问题不要非黑即白,不要觉得什么流行就一定适合所有项目,还是那个著名的软件工程原则:没有银弹!

在微软官方文档里也有同样的关于何时选择SPA,何时选择传统网站的参考:

https://docs.microsoft.com/en-us/dotnet/architecture/modern-web-apps-azure/choose-between-traditional-web-and-single-page-apps

You should use traditional web applications when:

  • Your application's client-side requirements are simple or even read-only.
  • Your application needs to function in browsers without JavaScript support.
  • Your team is unfamiliar with JavaScript or TypeScript development techniques.

You should use a SPA when:

  • Your application must expose a rich user interface with many features.
  • Your team is familiar with JavaScript and/or TypeScript development.
  • Your application must already expose an API for other (internal or public) clients.

后端实践

尽量避免Exception

.NET的Exception是一种特殊的类型,不管用户代码是否处理exception,只要产生,就会在CLR上有开销。所以尽量避免产生Exception,尤其是不要利用Exception控制程序流程,这一点通常在.NET的技术文章里都会提及。一个不正常利用Exception的例子是我曾经在公司代码里看见过类似这样判断输入的内容是否为数字的代码:

try

{

Convert.ToInt32(userInput);

return true;

}

catch (Exception ex)

{

return false;

}

而.NET其实可以这样写:

int.TryParse(userInput);

我相信大部分正常的.NET程序员都不会犯上面这种错误。这样的代码效率低下且不说,还容易炸毁IIS。IIS的应用程序池如果在短时间检测到大量CLR异常就会自爆重启并返回503,中断你的网站服务。

不过关于Exception的另一个争论点在于,是否需要为业务异常设计自己的Exception类型?也就是检查到非正常业务行为,到底返回Error Code还是直接抛出Exception再由上层处理?关于这点,我也没有确定的结论。目前我的实践是,仅对于非法输入抛出参数异常,业务上的错误不抛异常,例如文章被和谐后产生的404,不去设计比如 PostNotFoundException,这一点很关键,因为经常有无聊黑客新手使用自动化工具扫描我的博客是否有漏洞,而这些工具会批量请求例如wp-login.php之类的对于我博客来说不存在的资源,如果我设计成抛出Exception再返回404,那么会造成短时间内CLR上大量的异常,绝对会爆。

参考:https://devblogs.microsoft.com/cbrumme/the-exception-model/ 中“Performance and Trends”一节。

EF尽量使用AsNoTracking筛选只读数据

每个.NET群,都可以为Entity Framework vs Dapper吵一天。其实EF虽然在很多场景由局限,但并不那么差,只是想要用对,不产生性能问题,付出的学习成本相当高。但是既然入坑了,就最好把它用用对。而最常见的情况就是遇到只读数据,可以加上AsNoTracking()。我博客大部分的场景都是只读数据,并且读取后直接处理好关联数据(Include),因此可以使用AsNoTracking()来断开EF对于对象的追踪,节省内存也提高性能。为了不每次手写AsNoTracking() 导致996,我在博客的存储层直接设置了默认参数:

public IReadOnlyList<T> Get(ISpecification<T> spec, bool asNoTracking = true)

{

return asNoTracking ?

ApplySpecification(spec).AsNoTracking().ToList() :

ApplySpecification(spec).ToList();

}

关于EF,我在2012年还写过一篇关于性能的文章,至今也适用于.NET Core,欢迎参考:

《Performance tips for Entity Framework》

另外,在最新的EF Core 3.x中,微软为了不被人骂EF性能差,直接默认禁止了client side evaluation,避免了忘写Include结果还开Lazy Load导致外键表被查询几百次的尴尬场面。

数据库DTU

我的博客采用Azure SQL数据库的DTU计量方式。请求频繁的时候会导致DTU耗尽,从而后续请求需要排队执行。所以首先优化的就是增加DTU容量,目前20个DTU基本管够。

而DTU是否够用可以直接在Azure的面板里看报表得到:

内存及缓存,减少数据库调用

计算机的内存是为了用,而不是为了省。程序要么牺牲空间换时间,要么牺牲时间换空间。合理使用内存做缓存,而不是每次都调用数据库,可以提高一段时间内的性能。特别是云端环境,数据库的调用通常是最花时间的环节(Application Insights里认为是dependency call)。即使不用内存缓存,也可以根据项目需要配置redis等产品。

在我博客里,缓存的使用随处可见。比如文章分类、Custom Page这种不经常更新的数据,就可以缓存起来,这样就不至于每次请求都去查询数据库。另外,像配置之类的数据,也建议设计成单例模式,网站启动时候加载完毕,不要每个请求都去数据库里重新读配置。这将极大的减少数据库的压力并提高网站响应速度。

var cacheKey = $"page-{routeName.ToLower()}";

var pageResponse = await cache.GetOrCreateAsync(cacheKey, async entry =>

{

var response = await _customPageService.GetPageAsync(routeName);

return response;

});

除了数据库,本地、远程图片或其他类型的文件也可以利用缓存来提高性能。

CDN

尽量用CDN服务静态资源,并配置pre-fetch,减少DNS解析次数。我的博客图片由于设计了抽象隔离,博客的配图并不是像访问静态资源那样直接输出到客户端,目前支持两种存储方式:Azure Blob、本地文件系统,不管哪种存储,都避免不了从对应位置读取图片,并返回给客户端显示,即使加上了服务器端缓存(MemoryCache),这个过程也依然对服务器有较大压力。

目前我选用的存储方式为Azure Blob。以前读取一张图片的过程是:

首次请求:服务器去Azure Blob拿图片,客户端再去网站服务器拿图片。

后续请求:Hit到memory cache,仅从网站服务器返回图片给客户端。

然而,即使后续请求不用经过Azure Blob,对Web服务器的请求还是必须存在的,这也是挺大的开销。于是,我通过CDN,让图片请求再也不经过我自己的Web服务器,而是直接访问Azure Blob。

于是现在,读取一张图片的过程是:

首次请求:CDN判断自己是否已经缓存了图片,如果没有,去Azure Blob里拿,并缓存起来。

首次请求:CDN判断自己是否已经缓存了图片,如果没有,去Azure Blob里拿,并缓存起来。

这样一来,用户阅读博客文章时产生的图片请求只会经过Azure CDN的服务器,不会对Web服务器造成压力。

另外,可以在网页 header 上加个 dns-prefetch,指向CDN服务器域名:

<link rel="dns-prefetch" href="https://cdn-blob.edi.wang" />

这样浏览器就会提前解析CDN服务器的地址,进一步加快网页加载速度。

日志级别

很多程序员习惯本地和生产用同一份日志配置,而本地通常打开Debug、Trace等低等级日志以帮助我们的开发和测试工作,线上的产品是经过测试的相对稳定的发布版本,其实并不需要这些低等级日志,所有的事件都要记log的话会极大的影响应用性能。所以我的实践是生产环境只开Warning以上的日志级别,除非遇到刁钻问题需要收集详细爆炸数据,会临时开几个小时的Debug日志。

APM不要随便加profiler

这条建议和上面类似,APM工具通常提供了各种profiler,然而这一般都会影响性能。就算是Azure自己的Application Insights也是如此。所以除非程序出现需要996调查的爆炸事故,一般不建议打开这些profiler。

总结

以上是我目前使用到的提升博客性能的方法。但是性能优化没有完全通用的策略,需要根据不同系统,不同业务,不同压力来动态调整优化方案,总体思想即:减少不必要的调用与开销。但有时候也需要调整应用程序的部署架构,比如Azure可以加上Traffic Manager、Front Door,使用负载均衡功能。欢迎大家留言分享自己的想法,以及对本文的补充和建议!