聊聊ASP.Net服務(wù)性能優(yōu)化原則
本文轉(zhuǎn)載自微信公眾號「老王Plus」,作者老王Plus的老王。轉(zhuǎn)載本文請聯(lián)系老王Plus公眾號。
服務(wù)器性能問題,通常在數(shù)據(jù)少的時候不會顯現(xiàn),也無需太多關(guān)注。但一旦數(shù)據(jù)量大了,就會變成一個麻煩且必須處理的事。
通常,性能問題可能有許多不同的原因。內(nèi)存問題、緩慢的數(shù)據(jù)庫請求和太少的機器只是其中的一部分。手上的項目,每天10億級的數(shù)量量,在最近一個時間段,填了很多坑,也學(xué)到了不少東西。
今天這個文章,我會把這一段的體會,總結(jié)成幾大類問題。當(dāng)然,分類不一定很嚴謹,重要的是能給到大家一些建議,真到用時,能少刨一些坑,就夠了。另外,次序也不重要,我是想到哪些到哪的,并不是說前邊的內(nèi)容就比后面的內(nèi)容更需要注意。
1. 數(shù)據(jù)庫調(diào)用
數(shù)據(jù)庫調(diào)用的性能,會嚴重影響系統(tǒng)整體的性能。大多數(shù)情況下,與數(shù)據(jù)庫快速交互是獲得良好性能的最重要的因素。
以下幾個點需要重點關(guān)注:
- 索引策略
索引對數(shù)據(jù)庫交互的影響不需要解釋。重要的是檢查,檢查每一個索引,和每一個查詢語句。很多時候,你以為的未必是你以為的。檢查查詢語句和條件對索引的使用,檢查索引的結(jié)構(gòu)。要確保每個查詢語句,能正確使用你所希望使用的索引。
- 表結(jié)構(gòu)設(shè)計
表結(jié)構(gòu)設(shè)計最重要的,是對業(yè)務(wù)的理解。對數(shù)據(jù)之間的關(guān)系理解越深,表結(jié)構(gòu)越趨于合理。
- 同樣的工作,盡可能在數(shù)據(jù)庫上完成,避免在服務(wù)器中完成
這個話不太好理解,用代碼舉個例子:
- // 好的方式
- var girls = dbContext.Users.Where(user => user.gender == female);
- var count = girls.Count();
- // 不好的方式
- var girls = dbContext.Users.Where(user => user.gender == female).ToList();
- var count = girls.Count;
下邊這種方式,第一行以ToList()結(jié)束。當(dāng)實體執(zhí)行查詢時,會從數(shù)據(jù)庫中檢索并獲取全部數(shù)據(jù),然后在服務(wù)器中進行計數(shù)。而上面的方式,會在數(shù)據(jù)庫中直接計數(shù)。很顯而易見的,數(shù)據(jù)庫中執(zhí)行計數(shù),網(wǎng)絡(luò)傳輸?shù)拇鷥r會更少。
- 盡可能讓數(shù)據(jù)庫離應(yīng)用服務(wù)器"近"點
數(shù)據(jù)庫到應(yīng)用服務(wù)器之間,無非是網(wǎng)絡(luò)。更"近"的網(wǎng)絡(luò),會帶來更少的延時。這個"近"說的是網(wǎng)絡(luò)拓撲上的近,不是位置和距離。對于多機房分布式的應(yīng)用,起碼的要求是讓一個或幾個完整的副本集與應(yīng)用服務(wù)處于同一個數(shù)據(jù)中心。
- 用數(shù)據(jù)庫希望的方式使用數(shù)據(jù)庫
數(shù)據(jù)庫有很多種,關(guān)系型、NoSQL、內(nèi)存數(shù)據(jù)庫,等等。并不是所有的數(shù)據(jù)庫都一樣。有些適合Key-Value鍵值對,有些適合事務(wù)處理,有些適合存儲日志。
在開發(fā)中,不要拘泥于數(shù)據(jù)庫類型,而應(yīng)該根據(jù)業(yè)務(wù)類型和數(shù)據(jù)庫特性進行使用。比方說,MongoDB,本身是基于文檔的數(shù)據(jù)庫,結(jié)構(gòu)上很不適合JOIN操作。但它非常適合存儲包含大量業(yè)務(wù)數(shù)據(jù)的文檔。所以,使用時要避免使用JOIN操作的業(yè)務(wù)。當(dāng)然,這只是個例子。事實上MongoDB對于類似JOIN的內(nèi)容,有更好的處理模式,這個大家可以自行了解。
- 保證數(shù)據(jù)庫有足夠的硬件資源
服務(wù)器的伸縮一般提的比較高,但其實數(shù)據(jù)庫的伸縮性也需要非常重視。數(shù)據(jù)庫服務(wù)器,要關(guān)注到存儲空間、內(nèi)存、網(wǎng)絡(luò)和CPU。經(jīng)驗中,接近極限時,服務(wù)器未必會有明確的警報給你;而等到有警報出現(xiàn)時,恐怕已經(jīng)到達極限并發(fā)生了故障,就非常難于處理了。
所以,當(dāng)發(fā)現(xiàn)某些任務(wù)開始變慢,就意味著需要全面檢查了。
- 承認某些低效查詢的存在
不是所有的查詢都可以做到高效。尤其查詢是基于某些實體框架,例如EF或Hibernate。在技術(shù)和時間可能的情況下,少用數(shù)據(jù)框架是個好習(xí)慣。
- 使用連接池,而不是單個連接
如果每個查詢都需要重新建立連接,那是非??膳碌?,從性能到應(yīng)用的可靠性。使用數(shù)據(jù)庫,第一件事就是學(xué)會如何使用連接池。
- 小心使用存儲過程
當(dāng)有需要花費大量時間的復(fù)雜查詢需要處理時,存儲過程是個解決方案。但一定要小心,一定要小心,一定要小心,重要的事情說三遍。
在我的團隊中,存儲過程是被禁止使用的。相對來說,這兒安全的要求超過性能。
不過,在這個文章中,尤其在討論數(shù)據(jù)庫操作的性能時,咱還是不能忘了存儲過程。
- 數(shù)據(jù)庫分片策略
分布式數(shù)據(jù)庫性能的核心在于分片。分片就一個原則:讓業(yè)務(wù)的每一個查詢操作,對應(yīng)盡可能少的分片。
上面寫的,其實是一些原則。實際上,最難的部分是確定這些問題。所以,需要對各種工具都熟悉。通常,數(shù)據(jù)庫本身也能提供相關(guān)內(nèi)容,例如慢查詢、擴展問題、網(wǎng)絡(luò)瓶頸等。對于數(shù)據(jù)庫,不要僅限于使用,一定深度的了解會對成長有相當(dāng)?shù)膸椭?/p>
2. 內(nèi)存壓力
對于某些高吞吐量的應(yīng)用,服務(wù)器的內(nèi)存壓力是最常見的問題。
當(dāng)吞吐量非常大的時候,垃圾回收(GC)會跟不上內(nèi)存的分配和釋放。而且這種壓力的體現(xiàn),是服務(wù)器在垃圾回收上花費的時間更多,而執(zhí)行代碼的時間更少。
這種狀態(tài)在多種情況下都可能發(fā)生。最常見的情況是內(nèi)存容量耗盡。當(dāng)您達到內(nèi)存極限時,垃圾回收器將出現(xiàn)恐慌,并啟動更頻繁的整體垃圾回收,而這種模式的回收代價非常大。但問題是,為什么會發(fā)生這種情況?為什么你內(nèi)存使用接近極限了?原因通常是錯誤或不太好的緩存管理或內(nèi)存泄漏。通過捕獲內(nèi)存快照并檢查是什么占用了所有字節(jié),可以很容易地用內(nèi)存分析器發(fā)現(xiàn)這一點。
重要的是首先要意識到你有內(nèi)存問題。最簡單的方法是使用性能計數(shù)器。
3. 緩存數(shù)據(jù)
緩存可以是一個非常好、非常有效的優(yōu)化技術(shù)。典型的例子是,當(dāng)客戶端發(fā)送請求時,服務(wù)器可以將結(jié)果保存在緩存中。當(dāng)客戶端再次發(fā)送相同的請求(不一定是同一個客戶端)時,服務(wù)器不需要再次查詢數(shù)據(jù)庫或進行任何計算來獲得結(jié)果,而只是從緩存中獲取它。
考慮一下搜索引擎的做法。如果這是一個常見的搜索,它可能會被要求每天多次。如果不做緩存,每次都使用計算力去生成相同的頁面,是不是很可怕?
當(dāng)然,使用緩存,在一定程序上增加了應(yīng)用的復(fù)雜性。首先,每隔一段時間就需要使緩存失效并刷新,對吧?我們總不可能永遠返回相同的結(jié)果。另一個問題是,如果使用不合理,緩存容易膨脹,并導(dǎo)致內(nèi)存問題。
好在,ASP.Net有很多已經(jīng)實現(xiàn)的優(yōu)秀的緩存庫可以幫助解決大部分的工作。
4. 垃圾回收優(yōu)化
應(yīng)用服務(wù)器性能優(yōu)化中,垃圾回收是一個必須考慮的問題。
我們知道,Dotnet垃圾回收有兩種不同的模式:工作站模式和服務(wù)器模式。前者被優(yōu)化為以最小的資源使用快速響應(yīng),而后者用于高吞吐量。
Dotnet運行時默認將桌面應(yīng)用程序中的GC模式設(shè)置為工作站模式,而服務(wù)器中的GC模式設(shè)置為服務(wù)器模式。這個默認值幾乎總是最好的。在服務(wù)器中,GC將使用更多的機器資源,但是能夠處理更大的吞吐量。換句話說,該進程將有更多的線程專門用于垃圾回收,它將能夠每秒釋放更多字節(jié)。
相比由系統(tǒng)自動默認GC模式而言,手動設(shè)置應(yīng)用的垃圾回收模式會是一個安全的做法。服務(wù)器并不是總能正確地意識到需要什么樣的回收模式。
5. 減少不必要的客戶端請求
客戶端請求的數(shù)量,很大程度上可以決定服務(wù)器的數(shù)量或服務(wù)器的負載。所以,通過一些技巧來減少服務(wù)器請求,也是優(yōu)化的一部分內(nèi)容。
這個內(nèi)容需要在應(yīng)用中具體探討或體會。我只舉幾個實用的例子:
- 自動完成機制
通常這種應(yīng)用,就是我們在前端輸入時,客戶端從第一個輸入字符開始做API調(diào)用。比方我們輸入"Dotnet",那我們會向服務(wù)器發(fā)送6個請求 --- "D"、"Do"、"Dot"、"Dotn"等等。但實際上,考慮到輸入的連續(xù)性,我們可以在調(diào)用前,做個短時的延時,比方停止輸入500ms后才向服務(wù)器發(fā)送請求。你可能不會相信,我們實際應(yīng)用中實測的結(jié)果,可以減少93%的調(diào)用。
- 客戶端緩存
還是上面的例子。對于同一個應(yīng)用,很多位置的輸入都是相同或類似的。如果我們將自動完成的結(jié)果緩存在客戶端,而不是每次都發(fā)送這些請求,同樣可以減少很多不必要的請求。
- 批處理
應(yīng)用中,一個頁面跟服務(wù)器的交互通常會有很多。通常最無腦的做法,就是一個事件發(fā)送一個請求。這樣的方式無形中會對服務(wù)器產(chǎn)生相當(dāng)?shù)膲毫?。如果可能,把這樣的事件合并成一個請求,會更有效率,對服務(wù)器更友好。
6. 正確處理掛起的請求
客戶端對服務(wù)器的請求,可能會被掛起。也就是說,客戶端發(fā)送了一個請求,但未收到響應(yīng),或者準確地說,是經(jīng)過一個比較長的時間后,收到一個超時響應(yīng)。雖然我們不希望發(fā)生這樣的事,但這種事情總在發(fā)生:處理請求時間過長、或代碼死鎖、或代碼出錯并且沒有正常捕獲錯誤,當(dāng)然還包括等待一些本應(yīng)該出現(xiàn)但實際未出現(xiàn)的東西,例如來自隊列的消息、長時間的數(shù)據(jù)庫響應(yīng)或?qū)α硪粋€服務(wù)的調(diào)用。
本質(zhì)上,當(dāng)一個請求被掛起時,會掛起一個或多個線程。但應(yīng)用程序并不會停,并繼續(xù)處理新的請求。如果這個掛起在其它請求上也有重現(xiàn),那隨著時間,掛起的線程將越來越多,并最終影響服務(wù)器或系統(tǒng)的響應(yīng)。
因此,請求掛起對服務(wù)器性能的影響非常大。
這個問題的解決,需要針對核心的部分,就是掛起的部分進行調(diào)試,以確保程序處理了各種可能性,并不會產(chǎn)生任何意外的掛起。
7. 服務(wù)器崩潰
服務(wù)器崩潰也是一個可能的性能問題。
通常來說,客戶端請求期間發(fā)生一般的異常時,應(yīng)用程序不會崩潰。但總有一些問題,比方上下文之外的異常,或者一些災(zāi)難性的異常,比方OutOfMemoryException、ExecutionEngineException、StackOverflowException,當(dāng)這些發(fā)生時,不管加多少catch,也擋不住崩潰的發(fā)生。
通常如果的托管在Web Server上,例如:IIS、Nginx、Jexus上的ASP.Net應(yīng)用,崩潰時Web Server會自動回收資源,并重啟應(yīng)用。客戶端的感覺是臨時的慢響應(yīng)或503錯誤。
而如果是直接啟動的ASP.Net應(yīng)用,則程序會永久關(guān)閉,需要手動重啟。這將是一個問題。
所以,一方面,使用Web Server會是一個好習(xí)慣。另一方面,還是要檢查代碼,從根本上解決問題。
8. 永遠記著應(yīng)用規(guī)模
這個問題說起來很簡單,但實際開發(fā)中,其實經(jīng)常會忘記,或者說忽略應(yīng)用的規(guī)模。
用緩存,會忘了分布式緩存,忘了同步問題,直接使用單機內(nèi)存緩存;
數(shù)據(jù)庫寫入,會忘了并發(fā)下的數(shù)據(jù)一致性問題;
。。。太多了,不一一寫了
解決的辦法,是從頭開始,就把代碼規(guī)?;?--- 從開發(fā)到測試,全部使用雙向擴展,即水平擴展(向外擴展)和垂直擴展(向上擴展)。垂直擴展意味著服務(wù)機器添加更多的功能,比如更多的CPU和RAM,而水平擴展意味著添加更多的機器。
記著,從開發(fā)和測試開始,就要使用與生產(chǎn)環(huán)境使用同等規(guī)模的環(huán)境來做。
9. 同步和異步
應(yīng)用服務(wù)不同于桌面應(yīng)用或終端應(yīng)用。當(dāng)服務(wù)在執(zhí)行過程中需要等待響應(yīng)時,比方數(shù)據(jù)庫操作、或者調(diào)用別的服務(wù)時,這個服務(wù)本身就開始有了一定的風(fēng)險。如果數(shù)據(jù)庫或別的服務(wù)正忙著處理別的請求、或者存在性能問題時,必然會把性能問題傳遞到調(diào)用方。
怎么辦?
解決的基本模式是異步調(diào)用。異步調(diào)用有兩個含義:
- 代碼的異步調(diào)用,就是我們常說的async和await。
- 架構(gòu)的異步調(diào)用。這個通常是通過使用Kafka或RabbitMQ這樣的隊列服務(wù)來完成。向隊列發(fā)送消息,并不等待響應(yīng)。由另一個服務(wù)提取這些消息并處理。這個方式,通常是不需要回復(fù)的服務(wù)。而如果需要回復(fù),也可以用類似SignalR這樣的推送通知。
重要的是,這樣的方式下,系統(tǒng)組件不需要主動等待服務(wù)。一切都是異步處理的。服務(wù)之間的耦合可以松散很多。
當(dāng)然同樣的,這樣會讓代碼變得更復(fù)雜。
取舍之間,是對代碼的控制力。
10. 一個小總結(jié)
出差期間,斷斷續(xù)續(xù)寫的這個東西,似乎有點亂,但就這樣吧,:P
在實際項目中,很多方面稍不注意,就能搞亂服務(wù)器的性能,而且有很多地方會出錯。而解決呢,又沒有捷徑和技巧,需要仔細的計劃,有經(jīng)驗的工程師,以及大量的緩沖時間來應(yīng)對可能出現(xiàn)的問題。
后面我寫寫一些工具的應(yīng)用吧。很多方面,還是有好的工具可以幫助解決或至少是快速發(fā)現(xiàn)問題的。
總之,這是一篇個人的經(jīng)驗之談,希望能給大家一個拋磚引玉的作用。