我的 .NET Core 部落格性能優化經驗總結
- 2020 年 2 月 11 日
- 筆記
導語
去年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,使用負載均衡功能。歡迎大家留言分享自己的想法,以及對本文的補充和建議!